Skip to content

Instantly share code, notes, and snippets.

@nickalbrecht
Last active December 7, 2023 21:45
Show Gist options
  • Save nickalbrecht/e5972c7018affd6c60e333168f2b5ecc to your computer and use it in GitHub Desktop.
Save nickalbrecht/e5972c7018affd6c60e333168f2b5ecc to your computer and use it in GitHub Desktop.
Attribute to mark properties backed by primitive types or structs (int, DateTime, Guid, etc) as requiring a value other than their default value. `RequireNonDefaultAttribute` alone is enough for Server side validation. If you want to use this for client side as well, you'll need the other files too.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.DataAnnotations;
using Microsoft.Extensions.Localization;
public class CustomValidationAttributeAdapterProvider : IValidationAttributeAdapterProvider
{
readonly IValidationAttributeAdapterProvider baseProvider = new ValidationAttributeAdapterProvider();
public IAttributeAdapter GetAttributeAdapter(ValidationAttribute attribute, IStringLocalizer stringLocalizer)
{
if (attribute is RequireNonDefaultAttribute)
return new RequireNonDefaultAttributeAdapter((RequireNonDefaultAttribute) attribute, stringLocalizer);
else
{
return baseProvider.GetAttributeAdapter(attribute, stringLocalizer);
}
}
}
using System.Collections.Concurrent;
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using System.Runtime.CompilerServices;
/// <summary>
/// Override of <see cref="ValidationAttribute.IsValid(object)"/>
/// </summary>
/// <remarks>Is meant for use with primitive types, structs (like DateTime, Guid), or enums. Specifically ignores null values (considers them valid) so that this can be combined with RequiredAttribute.</remarks>
/// <example>
/// //Allows you to effectively mark the field as required with out having to resort to Guid? and then having to deal with SomeId.GetValueOrDefault() everywhere (and then test for Guid.Empty)
/// [RequireNonDefault]
/// public Guid SomeId { get; set;}
///
/// //Enforces validation that requires the field to not be 0
/// [RequireNonDefault]
/// public int SomeId { get; set; }
///
/// //The nullable int lets the field be optional, but if it IS provided, it can't be 0
/// [RequireNonDefault]
/// public int? Age { get; set;}
///
/// //Forces a value other than the default Enum, so `Unspecified` is not allowed
/// [RequireNonDefault]
/// public Fruit Favourite { get; set; }
/// public enum Fruit { Unspecified, Apple, Banana }
/// </example>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)]
public sealed class RequireNonDefaultAttribute : ValidationAttribute
{
private static readonly ConcurrentDictionary<string, object> defaultInstancesCache = new();
public RequireNonDefaultAttribute()
: base("The {0} field requires a non-default value.")
{
}
/// <param name="value">The value to test</param>
/// <returns><c>false</c> if the <paramref name="value"/> is equal the default value of an instance of its own type.</returns>
public override bool IsValid(object? value)
{
if (value is null)
return true; //Only meant to test default values. Use `System.ComponentModel.DataAnnotations.RequiredAttribute` to consider NULL invalid
var defaultInstance = GetDefaultValueForType(value.GetType());
return !Equals(value, defaultInstance);
}
public static object? GetDefaultValueForType(Type type)
{
//Decorating type.FullName with '!' because I can't find a situation where type.FullName would ever be null, in spite of the documentation at https://learn.microsoft.com/en-us/dotnet/api/system.type.fullname.
if (!defaultInstancesCache.TryGetValue(type.FullName!, out var defaultInstance)) {
if (type.GetConstructor(BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null) is ConstructorInfo)
defaultInstance = Activator.CreateInstance(Nullable.GetUnderlyingType(type) ?? type); //Faster, but requires a public parameterless constructor
else
defaultInstance = RuntimeHelpers.GetUninitializedObject(Nullable.GetUnderlyingType(type) ?? type);
//Helps to avoid repeat overhead of reflection for any given type (FullName includes full namespace, so something like System.Int32, System.Decimal, System.Guid, etc)
defaultInstancesCache[type.FullName!] = defaultInstance!; //Using '!' because I can't find a situation where defaultInstance would ever be null
}
return defaultInstance;
}
}
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.DataAnnotations;
using Microsoft.Extensions.Localization;
public class RequireNonDefaultAttributeAdapter : AttributeAdapterBase<RequireNonDefaultAttribute>
{
public RequireNonDefaultAttributeAdapter(RequireNonDefaultAttribute attribute, IStringLocalizer stringLocalizer)
: base(attribute, stringLocalizer)
{
}
public override string GetErrorMessage(ModelValidationContextBase validationContext)
{
if (validationContext == null)
{
throw new ArgumentNullException(nameof(validationContext));
}
return GetErrorMessage(validationContext.ModelMetadata, validationContext.ModelMetadata.GetDisplayName());
}
public override void AddValidation(ClientModelValidationContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
MergeAttribute(context.Attributes, "data-val", "true");
MergeAttribute(context.Attributes, "data-val-notequals", GetErrorMessage(context));
MergeAttribute(context.Attributes, "data-val-notequals-val", Activator.CreateInstance(Nullable.GetUnderlyingType(context.ModelMetadata.ModelType) ?? context.ModelMetadata.ModelType).ToString());
}
}
//Code for wiring up unobtrusive client side validation.
//Date comparison is done using Moment, but you can use whatever implementation you'd like
(function ($) {
jQuery.validator.addMethod("notequals", function (value, element, param) {
if (!isNaN(parseFloat(param)) && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(param)) //regex only tests if it looks like a GUID, not if it is a valid GUID
{
if (element.classList.contains("datetimepicker-input"))
return !moment(value).isSame(param);
else
return parseFloat(value) != parseFloat(param);
}
else
return value != param;
});
});
jQuery.validator.unobtrusive.adapters.addSingleVal("notequals", "val");
});
public class Startup
{
//Rest of the file excluded for brevity. This is your Startup class when dealing with an ASP.NET Core application
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<Microsoft.AspNetCore.Mvc.DataAnnotations.IValidationAttributeAdapterProvider, CustomValidationAttributeAdapterProvider>();
}
}
@nickalbrecht
Copy link
Author

Tweaked behavior to treat null as valid and keep this attribute checking exclusively for default values. Added logic for adding client side validation via the unobtrusive validation js libraries.

@nickalbrecht
Copy link
Author

Updated the implementation of using Activator.CreateInstance to cache the result by the type's FullName (Namespace & Type) to avoid repeat reflection since there's only ever going to be a handful of types that this gets used with. Also added some remarks about enums. It always supported them, I just never had comments about it.

@nickalbrecht
Copy link
Author

Updated javascript implementation to attempt numeric comparison when the default value to avoid is a number. This fixes a bug that I ran into when one of my view models had [RequireNonDefault, DataType(DataType.Currency)] decimal Amount { get; set; } and it was wrongly considering the non-default validation satisfied when my input field had "0.00". Also handles other ways of representing zero, like "0000"

@nickalbrecht
Copy link
Author

Added regex to not try to parse GUID as a number. Since for some reason parseFloat("abc2") yields NaN as expected, but parseFloat("2abc") returns 2. This is to try and avoid parsing GUIDs that begin with numbers

@nickalbrecht
Copy link
Author

Further updated JS logic to correctly test for default dates. This which was a regression bug caused by the GUID changes. This is both a fix and a lot less naive than the original behavior around date comparison

@nickalbrecht
Copy link
Author

Bug fix to look for a specific CSS class before trying to validate against a specific date to prevent default date value, because moment for some reason was considering "0.01" as a valid date for some reason when the intent was to treat it as $0.01 or 1¢

@nickalbrecht
Copy link
Author

nickalbrecht commented Dec 7, 2023

Small tweak taking some inspiration from the DisallowAllDefaultValues feature that never made it to .NET 8. The main benefit is a fallback to using a different method of getting the default instance, should the type not have a parameterless constructor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment