diff --git a/Directory.Packages.props b/Directory.Packages.props index 3973b15..7243da4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,5 +13,6 @@ + \ No newline at end of file diff --git a/README.md b/README.md index 3650c9f..934ac97 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,11 @@ class Widget var widget = new Widget { Name = "" }; // Get your serviceProvider from wherever makes sense -var serviceProvider = ... -var isValid = MiniValidator.TryValidate(widget, serviceProvider, out var errors); +var services = new ServicesCollection(); +services.AddMiniValidation(); +var serviceProvider = services.CreateServiceProvider(); +var validator = serviceProvider.GetRequiredService(); +var isValid = validator.TryValidate(widget, out var errors); class Widget : IValidatableObject { diff --git a/samples/Samples.Console/Samples.Console.csproj b/samples/Samples.Console/Samples.Console.csproj index 92b9057..7bfb498 100644 --- a/samples/Samples.Console/Samples.Console.csproj +++ b/samples/Samples.Console/Samples.Console.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 enable diff --git a/samples/Samples.Web/Program.cs b/samples/Samples.Web/Program.cs index 68bedb0..86fd134 100644 --- a/samples/Samples.Web/Program.cs +++ b/samples/Samples.Web/Program.cs @@ -6,6 +6,8 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddMiniValidator(); +builder.Services.AddClassMiniValidator(); var app = builder.Build(); @@ -23,15 +25,36 @@ app.MapGet("/widgets/{name}", (string name) => new Widget { Name = name }); -app.MapPost("/widgets", Results> (Widget widget) => - !MiniValidator.TryValidate(widget, out var errors) - ? TypedResults.ValidationProblem(errors) - : TypedResults.Created($"/widgets/{widget.Name}", widget)); +app.MapPost("/widgets", Results> (Widget widget, IMiniValidator validator) => +{ + if (!validator.TryValidate(widget, out var errors)) + { + return TypedResults.ValidationProblem(errors); + } + + return TypedResults.Created($"/widgets/{widget.Name}", widget); +}); + +app.MapPost("/widgets/class-validator", async Task>> (WidgetWithClassValidator widget, IMiniValidator validator) => +{ + var (isValid, errors) = await validator.TryValidateAsync(widget); + if (!isValid) + { + return TypedResults.ValidationProblem(errors); + } -app.MapPost("/widgets/custom-validation", Results> (WidgetWithCustomValidation widget) => - !MiniValidator.TryValidate(widget, out var errors) - ? TypedResults.ValidationProblem(errors) - : TypedResults.Created($"/widgets/{widget.Name}", widget)); + return TypedResults.Created($"/widgets/{widget.Name}", widget); +}); + +app.MapPost("/widgets/custom-validation", Results> (WidgetWithCustomValidation widget, IMiniValidator validator) => +{ + if (!validator.TryValidate(widget, out var errors)) + { + return TypedResults.ValidationProblem(errors); + } + + return TypedResults.Created($"/widgets/{widget.Name}", widget); +}); app.Run(); @@ -43,13 +66,34 @@ class Widget public override string? ToString() => Name; } +class WidgetWithClassValidator : Widget +{ + [Required, MinLength(3), Display(Name = "Widget name")] + public string? Name { get; set; } + + public override string? ToString() => Name; +} + class WidgetWithCustomValidation : Widget, IValidatableObject { public IEnumerable Validate(ValidationContext validationContext) { if (string.Equals(Name, "Widget", StringComparison.OrdinalIgnoreCase)) { - yield return new($"Cannot name a widget '{Name}'.", new[] { nameof(Name) }); + yield return new($"Cannot name a widget '{Name}'.", [nameof(Name)]); + } + } +} + +class WidgetValidator : IValidate +{ + public IEnumerable Validate(WidgetWithClassValidator instance, ValidationContext validationContext) + { + if (string.Equals(instance.Name, "Widget", StringComparison.OrdinalIgnoreCase)) + { + return [new($"Cannot name a widget '{instance.Name}'.", [nameof(instance.Name)])]; } + + return []; } } diff --git a/samples/Samples.Web/Samples.Web.csproj b/samples/Samples.Web/Samples.Web.csproj index 7190ba0..f3b5e57 100644 --- a/samples/Samples.Web/Samples.Web.csproj +++ b/samples/Samples.Web/Samples.Web.csproj @@ -1,7 +1,7 @@  - net7.0 + net8.0 enable enable diff --git a/src/MiniValidation/IAsyncValidatableObject.cs b/src/MiniValidation/IAsyncValidatableObject.cs index 2fde10e..0140589 100644 --- a/src/MiniValidation/IAsyncValidatableObject.cs +++ b/src/MiniValidation/IAsyncValidatableObject.cs @@ -14,5 +14,9 @@ public interface IAsyncValidatableObject /// /// The validation context. /// A collection that holds failed-validation information. +#if NET6_0_OR_GREATER + ValueTask> ValidateAsync(ValidationContext validationContext); +#else Task> ValidateAsync(ValidationContext validationContext); +#endif } diff --git a/src/MiniValidation/IAsyncValidate.cs b/src/MiniValidation/IAsyncValidate.cs new file mode 100644 index 0000000..b84eaea --- /dev/null +++ b/src/MiniValidation/IAsyncValidate.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace MiniValidation; + +/// +/// Provides a way to add a validator for a type outside the class. +/// +/// The type to validate. +public interface IAsyncValidate +{ + /// + /// Determines whether the specified object is valid. + /// + /// The object instance to validate. + /// The validation context. + /// A collection that holds failed-validation information. +#if NET6_0_OR_GREATER + ValueTask> ValidateAsync(T instance, ValidationContext validationContext); +#else + Task> ValidateAsync(T instance, ValidationContext validationContext); +#endif +} \ No newline at end of file diff --git a/src/MiniValidation/IMiniValidator.cs b/src/MiniValidation/IMiniValidator.cs new file mode 100644 index 0000000..71b4b1c --- /dev/null +++ b/src/MiniValidation/IMiniValidator.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MiniValidation; + +/// +/// Represents a validator that can validate an object. +/// +public interface IMiniValidator +{ + /// + /// Determines if the specified has anything to validate. + /// + /// + /// Objects of types with nothing to validate will always return true when passed to . + /// + /// The . + /// true to recursively check descendant types; if false only simple values directly on the target type are checked. + /// true if has anything to validate, false if not. + /// Thrown when is null. + bool RequiresValidation(Type targetType, bool recurse = true); + + /// + /// Determines whether the specific object is valid. This method recursively validates descendant objects. + /// + /// The object to validate. + /// A dictionary that contains details of each failed validation. + /// true if is valid; otherwise false. + /// Thrown when is null. + bool TryValidate(TTarget target, out IDictionary errors); + + /// + /// Determines whether the specific object is valid. + /// + /// The type of the target of validation. + /// The object to validate. + /// true to recursively validate descendant objects; if false only simple values directly on are validated. + /// A dictionary that contains details of each failed validation. + /// true if is valid; otherwise false. + /// Thrown when is null. + bool TryValidate(TTarget target, bool recurse, out IDictionary errors); + + /// + /// Determines whether the specific object is valid. + /// + /// + /// The object to validate. + /// true to recursively validate descendant objects; if false only simple values directly on are validated. + /// true to allow asynchronous validation if an object in the graph requires it. + /// A dictionary that contains details of each failed validation. + /// true if is valid; otherwise false. + /// Thrown when is null. + /// Throw when requires async validation and is false. + bool TryValidate(TTarget target, bool recurse, bool allowAsync, out IDictionary errors); + + /// + /// Determines whether the specific object is valid. + /// + /// The object to validate. + /// Thrown when is null. +#if NET6_0_OR_GREATER + ValueTask<(bool IsValid, IDictionary Errors)> TryValidateAsync(TTarget target); +#else + Task<(bool IsValid, IDictionary Errors)> TryValidateAsync(TTarget target); +#endif + + /// + /// Determines whether the specific object is valid. + /// + /// The object to validate. + /// true to recursively validate descendant objects; if false only simple values directly on are validated. + /// true if is valid; otherwise false and the validation errors. + /// +#if NET6_0_OR_GREATER + ValueTask<(bool IsValid, IDictionary Errors)> TryValidateAsync(TTarget target, bool recurse); +#else + Task<(bool IsValid, IDictionary Errors)> TryValidateAsync(TTarget target, bool recurse); +#endif + + /// + /// Gets a validator for the specified target type. + /// + /// + /// + IMiniValidator GetValidator(); +} + +/// +/// Represents a validator that can validate an object of type . +/// +/// +public interface IMiniValidator +{ + + /// + /// Determines whether the specific object is valid. This method recursively validates descendant objects. + /// + /// The object to validate. + /// A dictionary that contains details of each failed validation. + /// true if is valid; otherwise false. + /// Thrown when is null. + bool TryValidate(T target, out IDictionary errors); + + /// + /// Determines whether the specific object is valid. + /// + /// The object to validate. + /// true to recursively validate descendant objects; if false only simple values directly on are validated. + /// A dictionary that contains details of each failed validation. + /// true if is valid; otherwise false. + /// Thrown when is null. + bool TryValidate(T target, bool recurse, out IDictionary errors); + + /// + /// Determines whether the specific object is valid. + /// + /// The object to validate. + /// true to recursively validate descendant objects; if false only simple values directly on are validated. + /// true to allow asynchronous validation if an object in the graph requires it. + /// A dictionary that contains details of each failed validation. + /// true if is valid; otherwise false. + /// Thrown when is null. + /// Throw when requires async validation and is false. + bool TryValidate(T target, bool recurse, bool allowAsync, out IDictionary errors); + + /// + /// Determines whether the specific object is valid. + /// + /// The object to validate. + /// Thrown when is null. +#if NET6_0_OR_GREATER + ValueTask<(bool IsValid, IDictionary Errors)> TryValidateAsync(T target); +#else + Task<(bool IsValid, IDictionary Errors)> TryValidateAsync(T target); +#endif + + /// + /// Determines whether the specific object is valid. + /// + /// The object to validate. + /// true to recursively validate descendant objects; if false only simple values directly on are validated. + /// true if is valid; otherwise false and the validation errors. + /// +#if NET6_0_OR_GREATER + ValueTask<(bool IsValid, IDictionary Errors)> TryValidateAsync(T target, bool recurse); +#else + Task<(bool IsValid, IDictionary Errors)> TryValidateAsync(T target, bool recurse); +#endif +} \ No newline at end of file diff --git a/src/MiniValidation/IValidate.cs b/src/MiniValidation/IValidate.cs new file mode 100644 index 0000000..cddfc22 --- /dev/null +++ b/src/MiniValidation/IValidate.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace MiniValidation; + +/// +/// Provides a way to add a validator for a type outside the class. +/// +/// The type to validate. +public interface IValidate +{ + /// + /// Determines whether the specified object is valid. + /// + /// The object instance to validate. + /// The validation context. + /// A collection that holds failed-validation information. + IEnumerable Validate(T instance, ValidationContext validationContext); +} \ No newline at end of file diff --git a/src/MiniValidation/Internal/MiniValidatorImpl.cs b/src/MiniValidation/Internal/MiniValidatorImpl.cs new file mode 100644 index 0000000..85570f7 --- /dev/null +++ b/src/MiniValidation/Internal/MiniValidatorImpl.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MiniValidation.Internal; + +internal class MiniValidatorImpl : IMiniValidator +{ + private readonly IServiceProvider _serviceProvider; + + public MiniValidatorImpl(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public bool RequiresValidation(Type targetType, bool recurse = true) + { + return MiniValidator.RequiresValidation(targetType, recurse, _serviceProvider); + } + + public bool TryValidate(TTarget target, out IDictionary errors) + { + return MiniValidator.TryValidate(target, _serviceProvider, out errors); + } + + public bool TryValidate(TTarget target, bool recurse, out IDictionary errors) + { + return MiniValidator.TryValidate(target, _serviceProvider, recurse, out errors); + } + + public bool TryValidate(TTarget target, bool recurse, bool allowAsync, out IDictionary errors) + { + return MiniValidator.TryValidate(target, _serviceProvider, recurse, allowAsync, out errors); + } + +#if NET6_0_OR_GREATER + public ValueTask<(bool IsValid, IDictionary Errors)> TryValidateAsync(TTarget target) +#else + public Task<(bool IsValid, IDictionary Errors)> TryValidateAsync(TTarget target) +#endif + { + return MiniValidator.TryValidateAsync(target, _serviceProvider); + } + +#if NET6_0_OR_GREATER + public ValueTask<(bool IsValid, IDictionary Errors)> TryValidateAsync(TTarget target, bool recurse) +#else + public Task<(bool IsValid, IDictionary Errors)> TryValidateAsync(TTarget target, bool recurse) +#endif + { + return MiniValidator.TryValidateAsync(target, _serviceProvider, recurse); + } + + public IMiniValidator GetValidator() + { + return new MiniValidatorImpl(_serviceProvider); + } +} + +internal class MiniValidatorImpl : IMiniValidator +{ + private readonly IServiceProvider _serviceProvider; + + public MiniValidatorImpl(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public bool TryValidate(T target, out IDictionary errors) + { + return MiniValidator.TryValidate(target, _serviceProvider, out errors); + } + + public bool TryValidate(T target, bool recurse, out IDictionary errors) + { + return MiniValidator.TryValidate(target, _serviceProvider, recurse, out errors); + } + + public bool TryValidate(T target, bool recurse, bool allowAsync, out IDictionary errors) + { + return MiniValidator.TryValidate(target, _serviceProvider, recurse, allowAsync, out errors); + } + +#if NET6_0_OR_GREATER + public ValueTask<(bool IsValid, IDictionary Errors)> TryValidateAsync(T target) +#else + public Task<(bool IsValid, IDictionary Errors)> TryValidateAsync(T target) +#endif + { + return MiniValidator.TryValidateAsync(target, _serviceProvider); + } + +#if NET6_0_OR_GREATER + public ValueTask<(bool IsValid, IDictionary Errors)> TryValidateAsync(T target, bool recurse) +#else + public Task<(bool IsValid, IDictionary Errors)> TryValidateAsync(T target, bool recurse) +#endif + { + return MiniValidator.TryValidateAsync(target, _serviceProvider, recurse); + } +} \ No newline at end of file diff --git a/src/MiniValidation/MiniValidation.csproj b/src/MiniValidation/MiniValidation.csproj index 7f2afc4..90b46b5 100644 --- a/src/MiniValidation/MiniValidation.csproj +++ b/src/MiniValidation/MiniValidation.csproj @@ -14,6 +14,7 @@ + all diff --git a/src/MiniValidation/MiniValidator.cs b/src/MiniValidation/MiniValidator.cs index ca7c3b6..bece8c2 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -1,11 +1,14 @@ -using System; +using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using System.Linq; +using System.Linq.Expressions; using System.Runtime.CompilerServices; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; namespace MiniValidation; @@ -16,8 +19,19 @@ namespace MiniValidation; public static class MiniValidator { private static readonly TypeDetailsCache _typeDetailsCache = new(); + private static readonly ConcurrentDictionary _validateTypesCache = new(); + private static readonly ConcurrentDictionary _asyncValidateTypesCache = new(); + private static readonly ConcurrentDictionary _validateMethodsCache = new(); + private static readonly ConcurrentDictionary _asyncValidateMethodsCache = new(); private static readonly IDictionary _emptyErrors = new ReadOnlyDictionary(new Dictionary()); + private delegate IEnumerable Validate(object instance, ValidationContext validationContext); +#if NET6_0_OR_GREATER + private delegate ValueTask> ValidateAsync(object instance, ValidationContext validationContext); +#else + private delegate Task> ValidateAsync(object instance, ValidationContext validationContext); +#endif + /// /// Gets or sets the maximum depth allowed when validating an object with recursion enabled. /// Defaults to 32. @@ -32,19 +46,47 @@ public static class MiniValidator /// /// The . /// true to recursively check descendant types; if false only simple values directly on the target type are checked. + /// The service provider to use when checking for validators. /// true if has anything to validate, false if not. /// Thrown when is null. - 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) { throw new ArgumentNullException(nameof(targetType)); } - + 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) + || HasValidatorsRegistered(targetType, serviceProvider); + } + + private static bool HasValidatorsRegistered(Type targetType, IServiceProvider? serviceProvider) + { + if (serviceProvider == null) + return false; + + var validatorType = GetValidatorType(targetType); + var asyncValidatorType = GetAsyncValidatorType(targetType); + var serviceProviderIsService = serviceProvider.GetService(); + if (serviceProviderIsService != null) + { + return serviceProviderIsService.IsService(validatorType) || serviceProviderIsService.IsService(asyncValidatorType); + } + + return serviceProvider.GetService(validatorType) != null || serviceProvider.GetService(asyncValidatorType) != null; + } + + private static Type GetValidatorType(Type targetType) + { + return _validateTypesCache.GetOrAdd(targetType, t => typeof(IEnumerable<>).MakeGenericType(typeof(IValidate<>).MakeGenericType(t))); + } + + private static Type GetAsyncValidatorType(Type targetType) + { + return _asyncValidateTypesCache.GetOrAdd(targetType, t => typeof(IEnumerable<>).MakeGenericType(typeof(IAsyncValidate<>).MakeGenericType(t))); } /// @@ -163,7 +205,7 @@ private static bool TryValidateImpl(TTarget target, IServiceProvider? s throw new ArgumentNullException(nameof(target)); } - if (!RequiresValidation(target.GetType(), recurse)) + if (!RequiresValidation(target.GetType(), recurse, serviceProvider)) { errors = _emptyErrors; @@ -306,7 +348,7 @@ private static bool TryValidateImpl(TTarget target, IServiceProvider? s IDictionary? errors; - if (!RequiresValidation(target.GetType(), recurse)) + if (!RequiresValidation(target.GetType(), recurse, serviceProvider)) { errors = _emptyErrors; @@ -415,6 +457,7 @@ private static async Task TryValidateImpl( (property.Recurse || typeof(IValidatableObject).IsAssignableFrom(propertyValueType) || typeof(IAsyncValidatableObject).IsAssignableFrom(propertyValueType) + || serviceProvider?.GetService(typeof(IAsyncValidate<>).MakeGenericType(propertyValueType!)) != null || properties.Any(p => p.Recurse))) { propertiesToRecurse!.Add(property, propertyValue); @@ -506,11 +549,8 @@ private static async Task TryValidateImpl( validationContext.DisplayName = validationContext.ObjectType.Name; var validatableResults = validatable.Validate(validationContext); - if (validatableResults is not null) - { - ProcessValidationResults(validatableResults, workingErrors, prefix); - isValid = workingErrors.Count == 0 && isValid; - } + ProcessValidationResults(validatableResults, workingErrors, prefix); + isValid = workingErrors.Count == 0 && isValid; } if (isValid && typeof(IAsyncValidatableObject).IsAssignableFrom(targetType)) @@ -525,8 +565,114 @@ private static async Task TryValidateImpl( ThrowIfAsyncNotAllowed(validateTask.IsCompleted, allowAsync); var validatableResults = await validateTask.ConfigureAwait(false); - if (validatableResults is not null) + ProcessValidationResults(validatableResults, workingErrors, prefix); + isValid = workingErrors.Count == 0 && isValid; + } + + if (isValid) + { + var validatorType = GetValidatorType(targetType); + + var validators = new List(); + var serviceProviderIsService = serviceProvider?.GetService(); + if (serviceProviderIsService != null) + { + if (serviceProviderIsService.IsService(validatorType)) + { + validators.AddRange(((IEnumerable)serviceProvider!.GetService(validatorType)!).Cast()!); + } + } + else if (serviceProvider is not null) + { + var validatorServices = serviceProvider.GetService(validatorType) as IEnumerable; + validators.AddRange(validatorServices?.Cast() ?? Array.Empty()); + } + + foreach (var validator in validators) + { + if (!isValid) + continue; + + var validateDelegate = _validateMethodsCache.GetOrAdd(validator.GetType(), t => + { + var validateMethod = t.GetMethod(nameof(IValidate.Validate)); + if (validateMethod is null) + { + throw new InvalidOperationException( + $"The type {validators.GetType().Name} does not implement the required method 'Validate'."); + } + + var parameters = validateMethod.GetParameters(); + var targetArg = Expression.Parameter(typeof(object), "target"); + var contextArg = Expression.Parameter(typeof(ValidationContext), "context"); + var callExp = Expression.Call(Expression.Constant(validator), validateMethod, Expression.Convert(targetArg, parameters.First().ParameterType), contextArg); + var handler = Expression.Lambda(callExp, targetArg, contextArg).Compile(); + + return handler; + }); + + var validatableResults = validateDelegate(target, validationContext); + + // Reset validation context + validationContext.MemberName = null; + validationContext.DisplayName = validationContext.ObjectType.Name; + + ProcessValidationResults(validatableResults, workingErrors, prefix); + isValid = workingErrors.Count == 0 && isValid; + } + } + + if (isValid) + { + var validatorType = GetAsyncValidatorType(targetType); + + var validators = new List(); + var serviceProviderIsService = serviceProvider?.GetService(); + if (serviceProviderIsService != null) + { + if (serviceProviderIsService.IsService(validatorType)) + { + validators.AddRange(((IEnumerable)serviceProvider!.GetService(validatorType)!).Cast()!); + } + } + else if (serviceProvider is not null) + { + var validatorServices = serviceProvider.GetService(validatorType) as IEnumerable; + validators.AddRange(validatorServices?.Cast() ?? Array.Empty()); + } + + foreach (var validator in validators) { + if (!isValid || validator is null) + continue; + + var validateDelegate = _asyncValidateMethodsCache.GetOrAdd(validator.GetType(), t => + { + var validateMethod = t.GetMethod(nameof(IAsyncValidate.ValidateAsync)); + if (validateMethod is null) + { + throw new InvalidOperationException( + $"The type {validators.GetType().Name} does not implement the required method 'ValidateAsync'."); + } + + var parameters = validateMethod.GetParameters(); + var targetArg = Expression.Parameter(typeof(object), "target"); + var contextArg = Expression.Parameter(typeof(ValidationContext), "context"); + var callExp = Expression.Call(Expression.Constant(validator), validateMethod, Expression.Convert(targetArg, parameters.First().ParameterType), contextArg); + var handler = Expression.Lambda(callExp, targetArg, contextArg).Compile(); + + return handler; + }); + + var validateTask = validateDelegate(target, validationContext); + + // Reset validation context + validationContext.MemberName = null; + validationContext.DisplayName = validationContext.ObjectType.Name; + + ThrowIfAsyncNotAllowed(validateTask.IsCompleted, allowAsync); + + var validatableResults = await validateTask.ConfigureAwait(false); ProcessValidationResults(validatableResults, workingErrors, prefix); isValid = workingErrors.Count == 0 && isValid; } @@ -626,9 +772,14 @@ private static IDictionary MapToFinalErrorsResult(Dictionary validationResults, Dictionary> errors, string? prefix) + + private static void ProcessValidationResults(IEnumerable? validationResults, Dictionary> errors, string? prefix) { + if (validationResults is null) + { + return; + } + foreach (var result in validationResults) { var hasMemberNames = false; diff --git a/src/MiniValidation/ServiceProviderExtensions.cs b/src/MiniValidation/ServiceProviderExtensions.cs new file mode 100644 index 0000000..5334d41 --- /dev/null +++ b/src/MiniValidation/ServiceProviderExtensions.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using MiniValidation.Internal; + +namespace MiniValidation; + +/// +/// Extension methods for . +/// +public static class ServiceProviderExtensions +{ + /// + /// Adds IValidator service to the service collection. + /// + /// The to add the service to. + /// The so that additional calls can be chained. + public static IServiceCollection AddMiniValidator(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(typeof(IMiniValidator<>), typeof(MiniValidatorImpl<>)); + return services; + } + + /// + /// Adds a class based validator that implements or to the service collection. + /// + /// The to add the service to. + /// The of the service. + /// A class that implements or + /// The so that additional calls can be chained. + public static IServiceCollection AddClassMiniValidator(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Transient) + where TValidator : class + { + var validators = typeof(TValidator) + .GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IAsyncValidate<>) || i.GetGenericTypeDefinition() == typeof(IValidate<>)) + .ToArray(); + + foreach (var validator in validators) + { + services.Add(new ServiceDescriptor(validator, null, typeof(TValidator), lifetime)); + } + + return services; + } +} \ No newline at end of file diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index aee8fb2..05686dd 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -183,16 +183,18 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut } } + var customAttributes = new List(); + var propertyAttributes = property.GetCustomAttributes(); - var customAttributes = paramAttributes is not null - ? paramAttributes.Concat(propertyAttributes) - : propertyAttributes; + customAttributes.AddRange(propertyAttributes); + if (paramAttributes is not null) + { + customAttributes.AddRange(paramAttributes); + } - if (TryGetAttributesViaTypeDescriptor(property, out var typeDescriptorAttributes)) + if (customAttributes.Count == 0 && TryGetAttributesViaTypeDescriptor(property, out var typeDescriptorAttributes)) { - customAttributes = customAttributes - .Concat(typeDescriptorAttributes.Cast()) - .Distinct(); + customAttributes.AddRange(typeDescriptorAttributes); } foreach (var attr in customAttributes) diff --git a/tests/MiniValidation.Benchmarks/MiniValidation.Benchmarks.csproj b/tests/MiniValidation.Benchmarks/MiniValidation.Benchmarks.csproj index e142f3c..59985a1 100644 --- a/tests/MiniValidation.Benchmarks/MiniValidation.Benchmarks.csproj +++ b/tests/MiniValidation.Benchmarks/MiniValidation.Benchmarks.csproj @@ -2,8 +2,8 @@ Exe - net6.0;net7.0 - net471;net6.0;net7.0 + net6.0;net7.0;net8.0 + net471;net6.0;net7.0;net8.0 enable enable 10 @@ -11,6 +11,7 @@ + diff --git a/tests/MiniValidation.Benchmarks/Program.cs b/tests/MiniValidation.Benchmarks/Program.cs index e4ad3bd..6a5645d 100644 --- a/tests/MiniValidation.Benchmarks/Program.cs +++ b/tests/MiniValidation.Benchmarks/Program.cs @@ -2,6 +2,7 @@ using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Running; +using Microsoft.Extensions.DependencyInjection; using MiniValidation; BenchmarkRunner.Run(); @@ -9,10 +10,15 @@ #pragma warning disable CA1050 // Declare types in namespaces //[SimpleJob(RuntimeMoniker.Net472)] //[SimpleJob(RuntimeMoniker.Net60)] -[SimpleJob(RuntimeMoniker.Net70, baseline: true)] +[SimpleJob(RuntimeMoniker.Net80, baseline: true)] [MemoryDiagnoser] public class Benchmarks { + private IServiceProvider _serviceProvider = null!; + private IServiceProvider _serviceProviderWithValidator = null!; + private IMiniValidator _validator = null!; + private IMiniValidator _validatorWithClassValidator = null!; + [GlobalSetup] #pragma warning disable CA1822 // Mark members as static public void Initialize() @@ -24,6 +30,11 @@ public void Initialize() var target = Activator.CreateInstance(type); MiniValidator.TryValidate(target, recurse: true, allowAsync: false, out var _); } + + _serviceProvider = new ServiceCollection().AddMiniValidator().BuildServiceProvider(); + _validator = _serviceProvider.GetRequiredService(); + _serviceProviderWithValidator = new ServiceCollection().AddMiniValidator().AddClassMiniValidator().BuildServiceProvider(); + _validatorWithClassValidator = _serviceProviderWithValidator.GetRequiredService(); } [Benchmark(Baseline = true)] @@ -34,6 +45,14 @@ public void Initialize() return (isValid, errors); } + [Benchmark] + public async ValueTask<(bool, IDictionary)> NothingToValidate_ServiceProvider() + { + var target = new BenchmarkTypes.TodoWithNoValidation(); + var (isValid, errors) = await _validator.TryValidateAsync(target); + return (isValid, errors); + } + [Benchmark] public (bool, IDictionary) SinglePropertyToValidate_NoRecursion_Valid() { @@ -42,6 +61,22 @@ public void Initialize() return (isValid, errors); } + [Benchmark] + public async ValueTask<(bool, IDictionary)> SinglePropertyToValidate_ServiceProvider_NoRecursion_Valid() + { + var target = new BenchmarkTypes.Todo { Title = "This is the title" }; + var (isValid, errors) = await _validator.TryValidateAsync(target, false); + return (isValid, errors); + } + + [Benchmark] + public async ValueTask<(bool, IDictionary)> SinglePropertyToValidate_ClassValidator_ServiceProvider_NoRecursion_Valid() + { + var target = new BenchmarkTypes.Todo { Title = "This is the title" }; + var (isValid, errors) = await _validatorWithClassValidator.TryValidateAsync(target, false); + return (isValid, errors); + } + [Benchmark] public (bool, IDictionary) SinglePropertyToValidate_NoRecursion_Invalid() { @@ -70,6 +105,21 @@ public void Initialize() #pragma warning restore CA1822 // Mark members as static } +public class TodoValidator : IAsyncValidate +{ +#if NET6_0_OR_GREATER + public ValueTask> ValidateAsync(BenchmarkTypes.Todo instance, ValidationContext validationContext) + { + return new ValueTask>(Array.Empty()); + } +#else + public Task> ValidateAsync(BenchmarkTypes.Todo instance, ValidationContext validationContext) + { + return Task.FromResult>(Array.Empty()); + } +#endif +} + public class BenchmarkTypes { public class TodoWithNoValidation diff --git a/tests/MiniValidation.UnitTests/TestTypes.cs b/tests/MiniValidation.UnitTests/TestTypes.cs index 24dc04c..8161951 100644 --- a/tests/MiniValidation.UnitTests/TestTypes.cs +++ b/tests/MiniValidation.UnitTests/TestTypes.cs @@ -91,7 +91,12 @@ class TestClassLevelAsyncValidatableOnlyType : IAsyncValidatableObject { public int TwentyOrMore { get; set; } = 20; +#if NET6_0_OR_GREATER + + public async ValueTask> ValidateAsync(ValidationContext validationContext) +#else public async Task> ValidateAsync(ValidationContext validationContext) +#endif { await Task.Yield(); @@ -107,9 +112,72 @@ public async Task> ValidateAsync(ValidationContext } } +class TestClassLevel +{ + public int TwentyOrMore { get; set; } = 20; +} + +class TestClassLevelValidator : IValidate +{ + public IEnumerable Validate(TestClassLevel instance, ValidationContext validationContext) + { + List? errors = null; + + if (instance.TwentyOrMore < 20) + { + errors ??= new List(); + errors.Add(new ValidationResult($"The field {validationContext.DisplayName} must have a value greater than 20.", new[] { nameof(TestClassLevel.TwentyOrMore) })); + } + + return errors ?? Enumerable.Empty(); + } +} + +class TestClassLevelAsyncValidator : IAsyncValidate +{ +#if NET6_0_OR_GREATER + public async ValueTask> ValidateAsync(TestClassLevel instance, ValidationContext validationContext) +#else + public async Task> ValidateAsync(TestClassLevel instance, ValidationContext validationContext) +#endif + { + await Task.Yield(); + + List? errors = null; + + if (instance.TwentyOrMore < 20) + { + errors ??= new List(); + errors.Add(new ValidationResult($"The field {validationContext.DisplayName} must have a value greater than 20.", new[] { nameof(TestClassLevel.TwentyOrMore) })); + } + + return errors ?? Enumerable.Empty(); + } +} + +class ExtraTestClassLevelValidator : IValidate +{ + public IEnumerable Validate(TestClassLevel instance, ValidationContext validationContext) + { + List? errors = null; + + if (instance.TwentyOrMore > 20) + { + errors ??= new List(); + errors.Add(new ValidationResult($"The field {validationContext.DisplayName} must have a value less than 20.", new[] { nameof(TestClassLevel.TwentyOrMore) })); + } + + return errors ?? Enumerable.Empty(); + } +} + class TestClassLevelAsyncValidatableOnlyTypeWithServiceProvider : IAsyncValidatableObject { +#if NET6_0_OR_GREATER + public async ValueTask> ValidateAsync(ValidationContext validationContext) +#else public async Task> ValidateAsync(ValidationContext validationContext) +#endif { await Task.Yield(); @@ -182,7 +250,11 @@ class TestAsyncValidatableChildType : TestChildType, IAsyncValidatableObject { public int TwentyOrMore { get; set; } = 20; +#if NET6_0_OR_GREATER + public async ValueTask> ValidateAsync(ValidationContext validationContext) +#else public async Task> ValidateAsync(ValidationContext validationContext) +#endif { var taskToAwait = validationContext.GetService(); if (taskToAwait is not null) diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index e7713d3..dfcd525 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -361,12 +361,15 @@ public void Invalid_When_Target_Has_Required_Uri_Property_With_Null_Value() public void TryValidate_With_ServiceProvider() { var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); serviceCollection.AddSingleton(); var serviceProvider = serviceCollection.BuildServiceProvider(); var thingToValidate = new TestClassLevelValidatableOnlyTypeWithServiceProvider(); - var result = MiniValidator.TryValidate(thingToValidate, serviceProvider, out var errors); + var validator = serviceProvider.GetRequiredService(); + + var result = validator.TryValidate(thingToValidate, out var errors); Assert.True(result); errors.Clear(); @@ -376,16 +379,41 @@ public void TryValidate_With_ServiceProvider() Assert.Equal(nameof(IServiceProvider), errors.Keys.First()); } + [Fact] + public void TryValidate_With_ServiceProvider_TypedValidator() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); + serviceCollection.AddSingleton(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + + var thingToValidate = new TestClassLevelValidatableOnlyTypeWithServiceProvider(); + + var validator = serviceProvider.GetRequiredService>(); + + var result = validator.TryValidate(thingToValidate, out var errors); + Assert.True(result); + + errors.Clear(); + result = MiniValidator.TryValidate(thingToValidate, out errors); + Assert.False(result); + Assert.Single(errors); + Assert.Equal(nameof(IServiceProvider), errors.Keys.First()); + } + [Fact] public async Task TryValidateAsync_With_ServiceProvider() { var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); serviceCollection.AddSingleton(); var serviceProvider = serviceCollection.BuildServiceProvider(); var thingToValidate = new TestClassLevelValidatableOnlyTypeWithServiceProvider(); + + var validator = serviceProvider.GetRequiredService(); - var (isValid, errors) = await MiniValidator.TryValidateAsync(thingToValidate, serviceProvider); + var (isValid, errors) = await validator.TryValidateAsync(thingToValidate); Assert.True(isValid); Assert.Empty(errors); @@ -397,12 +425,186 @@ public async Task TryValidateAsync_With_ServiceProvider() Assert.Equal(nameof(IServiceProvider), errors.Keys.First()); } + [Fact] + public void TryValidate_With_Validator() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); + serviceCollection.AddClassMiniValidator(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + var validator = serviceProvider.GetRequiredService(); + + var thingToValidate = new TestClassLevel + { + TwentyOrMore = 12 + }; + + var result = validator.TryValidate(thingToValidate, true, true, out var errors); + + Assert.False(result); + Assert.Single(errors); + Assert.Equal(nameof(TestValidatableType.TwentyOrMore), errors.Keys.First()); + } + + [Fact] + public void TryValidate_With_Validator_And_TypedValidator() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); + serviceCollection.AddClassMiniValidator(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + var validator = serviceProvider.GetRequiredService>(); + + var thingToValidate = new TestClassLevel + { + TwentyOrMore = 12 + }; + + var result = validator.TryValidate(thingToValidate, true, true, out var errors); + + Assert.False(result); + Assert.Single(errors); + Assert.Equal(nameof(TestValidatableType.TwentyOrMore), errors.Keys.First()); + } + + [Fact] + public void TryValidate_With_Validator_And_TypedValidator_In_Scope() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); + serviceCollection.AddClassMiniValidator(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var validator = scope.ServiceProvider.GetRequiredService>(); + + var thingToValidate = new TestClassLevel + { + TwentyOrMore = 12 + }; + + var result = validator.TryValidate(thingToValidate, true, true, out var errors); + + Assert.False(result); + Assert.Single(errors); + Assert.Equal(nameof(TestValidatableType.TwentyOrMore), errors.Keys.First()); + } + + [Fact] + public async Task TryValidateAsync_With_Validator() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); + serviceCollection.AddClassMiniValidator(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + var validator = serviceProvider.GetRequiredService(); + + var thingToValidate = new TestClassLevel + { + TwentyOrMore = 12 + }; + + var (isValid, errors) = await validator.TryValidateAsync(thingToValidate); + + Assert.False(isValid); + Assert.Single(errors); + Assert.Equal(nameof(TestValidatableType.TwentyOrMore), errors.Keys.First()); + } + + [Fact] + public async Task TryValidateAsync_With_Validator_And_TypedValidator() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); + serviceCollection.AddClassMiniValidator(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + var validator = serviceProvider.GetRequiredService>(); + + var thingToValidate = new TestClassLevel + { + TwentyOrMore = 12 + }; + + var (isValid, errors) = await validator.TryValidateAsync(thingToValidate); + + Assert.False(isValid); + Assert.Single(errors); + Assert.Equal(nameof(TestValidatableType.TwentyOrMore), errors.Keys.First()); + } + + [Fact] + public async Task TryValidateAsync_With_Validator_And_TypedValidator_In_Scope() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); + serviceCollection.AddClassMiniValidator(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + await using var scope = serviceProvider.CreateAsyncScope(); + var validator = scope.ServiceProvider.GetRequiredService>(); + + var thingToValidate = new TestClassLevel + { + TwentyOrMore = 12 + }; + + var (isValid, errors) = await validator.TryValidateAsync(thingToValidate); + + Assert.False(isValid); + Assert.Single(errors); + Assert.Equal(nameof(TestValidatableType.TwentyOrMore), errors.Keys.First()); + } + + [Fact] + public async Task TryValidateAsync_With_Multiple_Validators() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); + serviceCollection.AddClassMiniValidator(); + serviceCollection.AddClassMiniValidator(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + var validator = serviceProvider.GetRequiredService(); + + var thingToValidate = new TestClassLevel + { + TwentyOrMore = 22 + }; + + var (isValid, errors) = await validator.TryValidateAsync(thingToValidate); + + Assert.False(isValid); + Assert.Single(errors); + Assert.Equal(nameof(TestValidatableType.TwentyOrMore), errors.Keys.First()); + } + + [Fact] + public async Task TryValidateAsync_With_Multiple_AsyncValidators() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); + serviceCollection.AddClassMiniValidator(); + serviceCollection.AddClassMiniValidator(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + var validator = serviceProvider.GetRequiredService(); + + var thingToValidate = new TestClassLevel + { + TwentyOrMore = 22 + }; + + var (isValid, errors) = await validator.TryValidateAsync(thingToValidate); + + Assert.False(isValid); + Assert.Single(errors); + Assert.Equal(nameof(TestValidatableType.TwentyOrMore), errors.Keys.First()); + } + [Fact] public void TryValidate_Enumerable_With_ServiceProvider() { var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); serviceCollection.AddSingleton(); var serviceProvider = serviceCollection.BuildServiceProvider(); + var validator = serviceProvider.GetRequiredService(); var thingToValidate = new TestClassWithEnumerable { @@ -412,7 +614,7 @@ public void TryValidate_Enumerable_With_ServiceProvider() } }; - var result = MiniValidator.TryValidate(thingToValidate, serviceProvider, out var errors); + var result = validator.TryValidate(thingToValidate, out var errors); Assert.True(result); errors.Clear(); @@ -426,8 +628,10 @@ public void TryValidate_Enumerable_With_ServiceProvider() public async Task TryValidateAsync_Enumerable_With_ServiceProvider() { var serviceCollection = new ServiceCollection(); + serviceCollection.AddMiniValidator(); serviceCollection.AddSingleton(); var serviceProvider = serviceCollection.BuildServiceProvider(); + var validator = serviceProvider.GetRequiredService(); var thingToValidate = new TestClassWithEnumerable { @@ -437,7 +641,7 @@ public async Task TryValidateAsync_Enumerable_With_ServiceProvider() } }; - var (isValid, errors) = await MiniValidator.TryValidateAsync(thingToValidate, serviceProvider); + var (isValid, errors) = await validator.TryValidateAsync(thingToValidate); Assert.True(isValid); errors.Clear();