-
-
Save nickalbrecht/e5972c7018affd6c60e333168f2b5ecc to your computer and use it in GitHub Desktop.
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>(); | |
} | |
} |
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.
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"
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
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
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¢
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
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.