-
-
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 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
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.