Last active
December 7, 2023 21:45
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//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"); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>(); | |
} | |
} |
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
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¢