-
Notifications
You must be signed in to change notification settings - Fork 29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for external class validators #67
base: main
Are you sure you want to change the base?
Changes from 1 commit
cc66599
24d867e
47b4f6b
86a610b
f5e39c0
471248c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
using System.Collections.Generic; | ||
using System.ComponentModel.DataAnnotations; | ||
using System.Threading.Tasks; | ||
|
||
namespace MiniValidation; | ||
|
||
/// <summary> | ||
/// Provides a way to add a validator for a type outside the class. | ||
/// </summary> | ||
/// <typeparam name="T"></typeparam> | ||
public interface IValidatable<in T> | ||
{ | ||
/// <summary> | ||
/// Determines whether the specified object is valid. | ||
/// </summary> | ||
/// <param name="instance">The object instance to validate.</param> | ||
/// <param name="validationContext">The validation context.</param> | ||
/// <returns>A collection that holds failed-validation information.</returns> | ||
Task<IEnumerable<ValidationResult>> ValidateAsync(T instance, ValidationContext validationContext); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we have a non-async version? Or do we think given this is a net-new addition it's fine to just make it async-only? Do the non-async validate methods throw if an There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It feels like this whole library could be simplified if it was async / valuetask only, but that would obviously be a breaking change and dataannotations doesn't support async either. I really wish .NET would modernize data annotations and make them more powerful. :-) Anyway, it would be pretty trivial to add non-async support. Up to you. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think for net-new stuff it's fine to say it's async only. This needs to add logic to throw if the non-async validate method is called on a type that has one of these registered. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It throws for non-async already using the |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -32,9 +32,10 @@ public static class MiniValidator | |
/// </remarks> | ||
/// <param name="targetType">The <see cref="Type"/>.</param> | ||
/// <param name="recurse"><c>true</c> to recursively check descendant types; if <c>false</c> only simple values directly on the target type are checked.</param> | ||
/// <param name="serviceProvider">The service provider to use when checking for validators.</param> | ||
/// <returns><c>true</c> if <paramref name="targetType"/> has anything to validate, <c>false</c> if not.</returns> | ||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="targetType"/> is <c>null</c>.</exception> | ||
public static bool RequiresValidation(Type targetType, bool recurse = true) | ||
public static bool RequiresValidation(Type targetType, bool recurse = true, IServiceProvider? serviceProvider = null) | ||
{ | ||
if (targetType is null) | ||
{ | ||
|
@@ -44,7 +45,8 @@ public static bool RequiresValidation(Type targetType, bool recurse = true) | |
return typeof(IValidatableObject).IsAssignableFrom(targetType) | ||
|| typeof(IAsyncValidatableObject).IsAssignableFrom(targetType) | ||
|| (recurse && typeof(IEnumerable).IsAssignableFrom(targetType)) | ||
|| _typeDetailsCache.Get(targetType).Properties.Any(p => p.HasValidationAttributes || recurse); | ||
|| _typeDetailsCache.Get(targetType).Properties.Any(p => p.HasValidationAttributes || recurse) | ||
|| serviceProvider?.GetService(typeof(IValidatable<>).MakeGenericType(targetType)) != null; | ||
} | ||
|
||
/// <summary> | ||
|
@@ -163,7 +165,7 @@ private static bool TryValidateImpl<TTarget>(TTarget target, IServiceProvider? s | |
throw new ArgumentNullException(nameof(target)); | ||
} | ||
|
||
if (!RequiresValidation(target.GetType(), recurse)) | ||
if (!RequiresValidation(target.GetType(), recurse, serviceProvider)) | ||
{ | ||
errors = _emptyErrors; | ||
|
||
|
@@ -306,7 +308,7 @@ private static bool TryValidateImpl<TTarget>(TTarget target, IServiceProvider? s | |
|
||
IDictionary<string, string[]>? errors; | ||
|
||
if (!RequiresValidation(target.GetType(), recurse)) | ||
if (!RequiresValidation(target.GetType(), recurse, serviceProvider)) | ||
{ | ||
errors = _emptyErrors; | ||
|
||
|
@@ -415,6 +417,7 @@ private static async Task<bool> TryValidateImpl( | |
(property.Recurse | ||
|| typeof(IValidatableObject).IsAssignableFrom(propertyValueType) | ||
|| typeof(IAsyncValidatableObject).IsAssignableFrom(propertyValueType) | ||
|| serviceProvider?.GetService(typeof(IValidatable<>).MakeGenericType(propertyValueType!)) != null | ||
|| properties.Any(p => p.Recurse))) | ||
{ | ||
propertiesToRecurse!.Add(property, propertyValue); | ||
|
@@ -532,6 +535,47 @@ private static async Task<bool> TryValidateImpl( | |
} | ||
} | ||
|
||
if (isValid) | ||
{ | ||
var validators = (IEnumerable?)serviceProvider?.GetService(typeof(IEnumerable<>).MakeGenericType(typeof(IValidatable<>).MakeGenericType(targetType))); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It'd be better to cache as much of this reflection in the type cache while walking the type. We don't want to be calling There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cached and used |
||
if (validators != null) | ||
{ | ||
foreach (var validator in validators) | ||
{ | ||
if (!isValid) | ||
continue; | ||
|
||
var validatorMethod = validator.GetType().GetMethod(nameof(IValidatable<object>.ValidateAsync)); | ||
if (validatorMethod is null) | ||
{ | ||
throw new InvalidOperationException( | ||
$"The type {validators.GetType().Name} does not implement the required method 'Task<IEnumerable<ValidationResult>> ValidateAsync(object, ValidationContext)'."); | ||
} | ||
|
||
var validateTask = (Task<IEnumerable<ValidationResult>>?)validatorMethod.Invoke(validator, | ||
new[] { target, validationContext }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Invoking this via reflection every time is not ideal. Would be good to figure out if there's a way to avoid this somehow but, e.g. generating a delegate in the type cache that can be invoked from here without the generic (the body of the generated method would just cast to the target type). Pre-generating a delegate would mean we could avoid the checks here too on every call and just store the exception up front when the type cache is being built and generate the method to just throw. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added as much caching as I could. I couldn't figure out how to get MethodInfo.CreateDelegate to work because we need to cast the target parameter to the target type, but at runtime, we don't know that type. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, figured out how to use expressions to compile a delegate that casts the parameter. |
||
if (validateTask is null) | ||
{ | ||
throw new InvalidOperationException( | ||
$"The type {validators.GetType().Name} does not implement the required method 'Task<IEnumerable<ValidationResult>> ValidateAsync(object, ValidationContext)'."); | ||
} | ||
|
||
// Reset validation context | ||
validationContext.MemberName = null; | ||
validationContext.DisplayName = validationContext.ObjectType.Name; | ||
|
||
ThrowIfAsyncNotAllowed(validateTask.IsCompleted, allowAsync); | ||
|
||
var validatableResults = await validateTask.ConfigureAwait(false); | ||
if (validatableResults is not null) | ||
{ | ||
ProcessValidationResults(validatableResults, workingErrors, prefix); | ||
isValid = workingErrors.Count == 0 && isValid; | ||
} | ||
} | ||
} | ||
} | ||
|
||
// Update state of target in tracking dictionary | ||
validatedObjects[target] = isValid; | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not 100% sold on the name. Some alternatives to consider:
IValidate<TTarget>
IValidator<TTarget>
IValidatorFor<TTarget>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I vote for
IValidate<T>
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good.