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

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