From cc66599a28af6a878d435c8653230cddddabd96f Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Mon, 14 Oct 2024 15:18:12 -0500 Subject: [PATCH 1/6] Add support for external class validators --- src/MiniValidation/IValidatable.cs | 20 +++++++ src/MiniValidation/MiniValidator.cs | 52 +++++++++++++++-- tests/MiniValidation.UnitTests/TestTypes.cs | 41 +++++++++++++ tests/MiniValidation.UnitTests/TryValidate.cs | 57 +++++++++++++++++++ 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 src/MiniValidation/IValidatable.cs diff --git a/src/MiniValidation/IValidatable.cs b/src/MiniValidation/IValidatable.cs new file mode 100644 index 0000000..66d46bb --- /dev/null +++ b/src/MiniValidation/IValidatable.cs @@ -0,0 +1,20 @@ +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. +/// +/// +public interface IValidatable +{ + /// + /// Determines whether the specified object is valid. + /// + /// The object instance to validate. + /// The validation context. + /// A collection that holds failed-validation information. + Task> ValidateAsync(T instance, ValidationContext validationContext); +} \ No newline at end of file diff --git a/src/MiniValidation/MiniValidator.cs b/src/MiniValidation/MiniValidator.cs index ca7c3b6..20a125a 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -32,9 +32,10 @@ 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) { @@ -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; } /// @@ -163,7 +165,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 +308,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 +417,7 @@ private static async Task 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 TryValidateImpl( } } + if (isValid) + { + var validators = (IEnumerable?)serviceProvider?.GetService(typeof(IEnumerable<>).MakeGenericType(typeof(IValidatable<>).MakeGenericType(targetType))); + if (validators != null) + { + foreach (var validator in validators) + { + if (!isValid) + continue; + + var validatorMethod = validator.GetType().GetMethod(nameof(IValidatable.ValidateAsync)); + if (validatorMethod is null) + { + throw new InvalidOperationException( + $"The type {validators.GetType().Name} does not implement the required method 'Task> ValidateAsync(object, ValidationContext)'."); + } + + var validateTask = (Task>?)validatorMethod.Invoke(validator, + new[] { target, validationContext }); + if (validateTask is null) + { + throw new InvalidOperationException( + $"The type {validators.GetType().Name} does not implement the required method 'Task> 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; diff --git a/tests/MiniValidation.UnitTests/TestTypes.cs b/tests/MiniValidation.UnitTests/TestTypes.cs index 24dc04c..cb213f4 100644 --- a/tests/MiniValidation.UnitTests/TestTypes.cs +++ b/tests/MiniValidation.UnitTests/TestTypes.cs @@ -107,6 +107,47 @@ public async Task> ValidateAsync(ValidationContext } } +class TestClassLevel +{ + public int TwentyOrMore { get; set; } = 20; +} + +class TestClassLevelValidator : IValidatable +{ + public async Task> ValidateAsync(TestClassLevel instance, ValidationContext validationContext) + { + 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 : IValidatable +{ + public async Task> ValidateAsync(TestClassLevel instance, ValidationContext validationContext) + { + 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 less than 20.", new[] { nameof(TestClassLevel.TwentyOrMore) })); + } + + return errors ?? Enumerable.Empty(); + } +} + class TestClassLevelAsyncValidatableOnlyTypeWithServiceProvider : IAsyncValidatableObject { public async Task> ValidateAsync(ValidationContext validationContext) diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index e7713d3..e9945f2 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -397,6 +397,63 @@ public async Task TryValidateAsync_With_ServiceProvider() Assert.Equal(nameof(IServiceProvider), errors.Keys.First()); } + [Fact] + public void TryValidate_With_Validator() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton, TestClassLevelValidator>(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + + var thingToValidate = new TestClassLevel + { + TwentyOrMore = 12 + }; + + Assert.Throws(() => + { + var isValid = MiniValidator.TryValidate(thingToValidate, serviceProvider, out var errors); + }); + } + + [Fact] + public async Task TryValidateAsync_With_Validator() + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton, TestClassLevelValidator>(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + + var thingToValidate = new TestClassLevel + { + TwentyOrMore = 12 + }; + + var (isValid, errors) = await MiniValidator.TryValidateAsync(thingToValidate, serviceProvider); + + 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.AddSingleton, TestClassLevelValidator>(); + serviceCollection.AddSingleton, ExtraTestClassLevelValidator>(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + + var thingToValidate = new TestClassLevel + { + TwentyOrMore = 22 + }; + + var (isValid, errors) = await MiniValidator.TryValidateAsync(thingToValidate, serviceProvider); + + Assert.False(isValid); + Assert.Single(errors); + Assert.Equal(nameof(TestValidatableType.TwentyOrMore), errors.Keys.First()); + } + [Fact] public void TryValidate_Enumerable_With_ServiceProvider() { From 24d867e742a9fe2c6a105e8ea8c2c6132d7456e5 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 3 Jan 2025 14:15:38 -0600 Subject: [PATCH 2/6] Address PR feedback --- Directory.Packages.props | 1 + .../{IValidatable.cs => IValidate.cs} | 2 +- src/MiniValidation/MiniValidation.csproj | 1 + src/MiniValidation/MiniValidator.cs | 48 ++++++++++++++----- tests/MiniValidation.UnitTests/TestTypes.cs | 4 +- tests/MiniValidation.UnitTests/TryValidate.cs | 8 ++-- 6 files changed, 45 insertions(+), 19 deletions(-) rename src/MiniValidation/{IValidatable.cs => IValidate.cs} (95%) 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/src/MiniValidation/IValidatable.cs b/src/MiniValidation/IValidate.cs similarity index 95% rename from src/MiniValidation/IValidatable.cs rename to src/MiniValidation/IValidate.cs index 66d46bb..6e5d742 100644 --- a/src/MiniValidation/IValidatable.cs +++ b/src/MiniValidation/IValidate.cs @@ -8,7 +8,7 @@ namespace MiniValidation; /// Provides a way to add a validator for a type outside the class. /// /// -public interface IValidatable +public interface IValidate { /// /// Determines whether the specified object is valid. 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 20a125a..122aa91 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -1,11 +1,13 @@ 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.Runtime.CompilerServices; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; namespace MiniValidation; @@ -16,7 +18,10 @@ namespace MiniValidation; public static class MiniValidator { private static readonly TypeDetailsCache _typeDetailsCache = new(); + private static readonly ConcurrentDictionary _validateTypesCache = new(); + private static readonly ConcurrentDictionary _validateMethodCache = new(); private static readonly IDictionary _emptyErrors = new ReadOnlyDictionary(new Dictionary()); + private delegate Task> ValidateAsync(object instance, ValidationContext validationContext); /// /// Gets or sets the maximum depth allowed when validating an object with recursion enabled. @@ -46,7 +51,7 @@ public static bool RequiresValidation(Type targetType, bool recurse = true, ISer || typeof(IAsyncValidatableObject).IsAssignableFrom(targetType) || (recurse && typeof(IEnumerable).IsAssignableFrom(targetType)) || _typeDetailsCache.Get(targetType).Properties.Any(p => p.HasValidationAttributes || recurse) - || serviceProvider?.GetService(typeof(IValidatable<>).MakeGenericType(targetType)) != null; + || serviceProvider?.GetService(typeof(IValidate<>).MakeGenericType(targetType)) != null; } /// @@ -417,7 +422,7 @@ private static async Task TryValidateImpl( (property.Recurse || typeof(IValidatableObject).IsAssignableFrom(propertyValueType) || typeof(IAsyncValidatableObject).IsAssignableFrom(propertyValueType) - || serviceProvider?.GetService(typeof(IValidatable<>).MakeGenericType(propertyValueType!)) != null + || serviceProvider?.GetService(typeof(IValidate<>).MakeGenericType(propertyValueType!)) != null || properties.Any(p => p.Recurse))) { propertiesToRecurse!.Add(property, propertyValue); @@ -537,23 +542,42 @@ private static async Task TryValidateImpl( if (isValid) { - var validators = (IEnumerable?)serviceProvider?.GetService(typeof(IEnumerable<>).MakeGenericType(typeof(IValidatable<>).MakeGenericType(targetType))); + var validatorType = _validateTypesCache.GetOrAdd(targetType, t => typeof(IEnumerable<>).MakeGenericType(typeof(IValidate<>).MakeGenericType(t))); + + IEnumerable? validators = null; + if (serviceProvider is IServiceProviderIsService serviceProviderIsService) + { + if (serviceProviderIsService.IsService(validatorType)) + { + validators = (IEnumerable?)serviceProvider.GetService(validatorType); + } + } + else if (serviceProvider is not null) + { + validators = (IEnumerable?)serviceProvider.GetService(validatorType); + } + if (validators != null) { foreach (var validator in validators) { - if (!isValid) + if (!isValid || validator is null) continue; - - var validatorMethod = validator.GetType().GetMethod(nameof(IValidatable.ValidateAsync)); - if (validatorMethod is null) + + var validateDelegate = _validateMethodCache.GetOrAdd(validator.GetType(), t => { - throw new InvalidOperationException( - $"The type {validators.GetType().Name} does not implement the required method 'Task> ValidateAsync(object, ValidationContext)'."); - } + var validateMethod = t.GetMethod(nameof(IValidate.ValidateAsync)); + if (validateMethod is null) + { + throw new InvalidOperationException( + $"The type {validators.GetType().Name} does not implement the required method 'Task> ValidateAsync(object, ValidationContext)'."); + } + + // NOTE would be ideal if we could use validateMethod.CreateDelegate(typeof(ValidateAsync)) here, but type casting doesn't work + return (i, context) => (Task>)validateMethod.Invoke(validator, new[] { i, context }); + }); - var validateTask = (Task>?)validatorMethod.Invoke(validator, - new[] { target, validationContext }); + var validateTask = validateDelegate(target, validationContext); if (validateTask is null) { throw new InvalidOperationException( diff --git a/tests/MiniValidation.UnitTests/TestTypes.cs b/tests/MiniValidation.UnitTests/TestTypes.cs index cb213f4..a5b0b9f 100644 --- a/tests/MiniValidation.UnitTests/TestTypes.cs +++ b/tests/MiniValidation.UnitTests/TestTypes.cs @@ -112,7 +112,7 @@ class TestClassLevel public int TwentyOrMore { get; set; } = 20; } -class TestClassLevelValidator : IValidatable +class TestClassLevelValidator : IValidate { public async Task> ValidateAsync(TestClassLevel instance, ValidationContext validationContext) { @@ -130,7 +130,7 @@ public async Task> ValidateAsync(TestClassLevel in } } -class ExtraTestClassLevelValidator : IValidatable +class ExtraTestClassLevelValidator : IValidate { public async Task> ValidateAsync(TestClassLevel instance, ValidationContext validationContext) { diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index e9945f2..e4a5f52 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -401,7 +401,7 @@ public async Task TryValidateAsync_With_ServiceProvider() public void TryValidate_With_Validator() { var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton, TestClassLevelValidator>(); + serviceCollection.AddSingleton, TestClassLevelValidator>(); var serviceProvider = serviceCollection.BuildServiceProvider(); var thingToValidate = new TestClassLevel @@ -419,7 +419,7 @@ public void TryValidate_With_Validator() public async Task TryValidateAsync_With_Validator() { var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton, TestClassLevelValidator>(); + serviceCollection.AddSingleton, TestClassLevelValidator>(); var serviceProvider = serviceCollection.BuildServiceProvider(); var thingToValidate = new TestClassLevel @@ -438,8 +438,8 @@ public async Task TryValidateAsync_With_Validator() public async Task TryValidateAsync_With_Multiple_Validators() { var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton, TestClassLevelValidator>(); - serviceCollection.AddSingleton, ExtraTestClassLevelValidator>(); + serviceCollection.AddSingleton, TestClassLevelValidator>(); + serviceCollection.AddSingleton, ExtraTestClassLevelValidator>(); var serviceProvider = serviceCollection.BuildServiceProvider(); var thingToValidate = new TestClassLevel From 47b4f6b43c13eb5bd4f50706b83c1b43da7dff4a Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 3 Jan 2025 14:45:16 -0600 Subject: [PATCH 3/6] Missed a spot for using IsService --- src/MiniValidation/MiniValidator.cs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/MiniValidation/MiniValidator.cs b/src/MiniValidation/MiniValidator.cs index 122aa91..e6cf0b2 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -46,12 +46,32 @@ public static bool RequiresValidation(Type targetType, bool recurse = true, ISer { 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) - || serviceProvider?.GetService(typeof(IValidate<>).MakeGenericType(targetType)) != null; + || HasValidatorsRegistered(targetType, serviceProvider); + } + + private static bool HasValidatorsRegistered(Type targetType, IServiceProvider? serviceProvider) + { + if (serviceProvider == null) + return false; + + var validatorType = GetValidatorType(targetType); + if (serviceProvider is IServiceProviderIsService serviceProviderIsService) + { + return serviceProviderIsService.IsService(validatorType); + } + + return serviceProvider.GetService(validatorType) != null; + } + + private static Type GetValidatorType(Type targetType) + { + return _validateTypesCache.GetOrAdd(targetType, t => typeof(IEnumerable<>).MakeGenericType(typeof(IValidate<>).MakeGenericType(t))); } /// @@ -542,7 +562,7 @@ private static async Task TryValidateImpl( if (isValid) { - var validatorType = _validateTypesCache.GetOrAdd(targetType, t => typeof(IEnumerable<>).MakeGenericType(typeof(IValidate<>).MakeGenericType(t))); + var validatorType = GetValidatorType(targetType); IEnumerable? validators = null; if (serviceProvider is IServiceProviderIsService serviceProviderIsService) @@ -574,7 +594,7 @@ private static async Task TryValidateImpl( } // NOTE would be ideal if we could use validateMethod.CreateDelegate(typeof(ValidateAsync)) here, but type casting doesn't work - return (i, context) => (Task>)validateMethod.Invoke(validator, new[] { i, context }); + return (i, context) => (Task>)validateMethod.Invoke(validator, new[] { i, context })!; }); var validateTask = validateDelegate(target, validationContext); From 86a610b7f2ca2a50a5880bdc250a501d9c5d4ca4 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 3 Jan 2025 16:08:38 -0600 Subject: [PATCH 4/6] Use linq expressions to build validator delegate --- src/MiniValidation/MiniValidator.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/MiniValidation/MiniValidator.cs b/src/MiniValidation/MiniValidator.cs index e6cf0b2..05446d9 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -5,6 +5,7 @@ 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; @@ -593,8 +594,13 @@ private static async Task TryValidateImpl( $"The type {validators.GetType().Name} does not implement the required method 'Task> ValidateAsync(object, ValidationContext)'."); } - // NOTE would be ideal if we could use validateMethod.CreateDelegate(typeof(ValidateAsync)) here, but type casting doesn't work - return (i, context) => (Task>)validateMethod.Invoke(validator, new[] { i, context })!; + 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); From f5e39c01a62ba4e6a1f9b97b33ce0b4ecea108aa Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Fri, 3 Jan 2025 23:37:30 -0600 Subject: [PATCH 5/6] Minor naming --- src/MiniValidation/MiniValidator.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/MiniValidation/MiniValidator.cs b/src/MiniValidation/MiniValidator.cs index 05446d9..a48a6a1 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; @@ -20,7 +20,7 @@ public static class MiniValidator { private static readonly TypeDetailsCache _typeDetailsCache = new(); private static readonly ConcurrentDictionary _validateTypesCache = new(); - private static readonly ConcurrentDictionary _validateMethodCache = new(); + private static readonly ConcurrentDictionary _validateMethodsCache = new(); private static readonly IDictionary _emptyErrors = new ReadOnlyDictionary(new Dictionary()); private delegate Task> ValidateAsync(object instance, ValidationContext validationContext); @@ -585,7 +585,7 @@ private static async Task TryValidateImpl( if (!isValid || validator is null) continue; - var validateDelegate = _validateMethodCache.GetOrAdd(validator.GetType(), t => + var validateDelegate = _validateMethodsCache.GetOrAdd(validator.GetType(), t => { var validateMethod = t.GetMethod(nameof(IValidate.ValidateAsync)); if (validateMethod is null) From 471248ca57416ab5c611717be93da4a79d3e67e3 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sun, 5 Jan 2025 01:30:32 -0600 Subject: [PATCH 6/6] Adding service provider extensions. Adding non-async IValidate. --- README.md | 7 +- .../Samples.Console/Samples.Console.csproj | 2 +- samples/Samples.Web/Program.cs | 62 ++++++- samples/Samples.Web/Samples.Web.csproj | 2 +- src/MiniValidation/IAsyncValidatableObject.cs | 4 + src/MiniValidation/IAsyncValidate.cs | 24 +++ src/MiniValidation/IMiniValidator.cs | 150 +++++++++++++++ src/MiniValidation/IValidate.cs | 5 +- .../Internal/MiniValidatorImpl.cs | 101 ++++++++++ src/MiniValidation/MiniValidator.cs | 169 +++++++++++------ .../ServiceProviderExtensions.cs | 47 +++++ src/MiniValidation/TypeDetailsCache.cs | 16 +- .../MiniValidation.Benchmarks.csproj | 5 +- tests/MiniValidation.Benchmarks/Program.cs | 52 +++++- tests/MiniValidation.UnitTests/TestTypes.cs | 37 +++- tests/MiniValidation.UnitTests/TryValidate.cs | 173 ++++++++++++++++-- 16 files changed, 758 insertions(+), 98 deletions(-) create mode 100644 src/MiniValidation/IAsyncValidate.cs create mode 100644 src/MiniValidation/IMiniValidator.cs create mode 100644 src/MiniValidation/Internal/MiniValidatorImpl.cs create mode 100644 src/MiniValidation/ServiceProviderExtensions.cs 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 index 6e5d742..cddfc22 100644 --- a/src/MiniValidation/IValidate.cs +++ b/src/MiniValidation/IValidate.cs @@ -1,13 +1,12 @@ 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 IValidate { /// @@ -16,5 +15,5 @@ public interface IValidate /// The object instance to validate. /// The validation context. /// A collection that holds failed-validation information. - Task> ValidateAsync(T instance, ValidationContext validationContext); + 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/MiniValidator.cs b/src/MiniValidation/MiniValidator.cs index a48a6a1..bece8c2 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -20,9 +20,17 @@ public static class MiniValidator { private static readonly TypeDetailsCache _typeDetailsCache = new(); private static readonly ConcurrentDictionary _validateTypesCache = new(); - private static readonly ConcurrentDictionary _validateMethodsCache = 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. @@ -48,7 +56,6 @@ public static bool RequiresValidation(Type targetType, bool recurse = true, ISer throw new ArgumentNullException(nameof(targetType)); } - return typeof(IValidatableObject).IsAssignableFrom(targetType) || typeof(IAsyncValidatableObject).IsAssignableFrom(targetType) || (recurse && typeof(IEnumerable).IsAssignableFrom(targetType)) @@ -62,18 +69,25 @@ private static bool HasValidatorsRegistered(Type targetType, IServiceProvider? s return false; var validatorType = GetValidatorType(targetType); - if (serviceProvider is IServiceProviderIsService serviceProviderIsService) + var asyncValidatorType = GetAsyncValidatorType(targetType); + var serviceProviderIsService = serviceProvider.GetService(); + if (serviceProviderIsService != null) { - return serviceProviderIsService.IsService(validatorType); + return serviceProviderIsService.IsService(validatorType) || serviceProviderIsService.IsService(asyncValidatorType); } - return serviceProvider.GetService(validatorType) != null; + 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))); + } /// /// Determines whether the specific object is valid. This method recursively validates descendant objects. @@ -443,7 +457,7 @@ private static async Task TryValidateImpl( (property.Recurse || typeof(IValidatableObject).IsAssignableFrom(propertyValueType) || typeof(IAsyncValidatableObject).IsAssignableFrom(propertyValueType) - || serviceProvider?.GetService(typeof(IValidate<>).MakeGenericType(propertyValueType!)) != null + || serviceProvider?.GetService(typeof(IAsyncValidate<>).MakeGenericType(propertyValueType!)) != null || properties.Any(p => p.Recurse))) { propertiesToRecurse!.Add(property, propertyValue); @@ -535,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)) @@ -554,8 +565,58 @@ 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; } @@ -563,66 +624,57 @@ private static async Task TryValidateImpl( if (isValid) { - var validatorType = GetValidatorType(targetType); + var validatorType = GetAsyncValidatorType(targetType); - IEnumerable? validators = null; - if (serviceProvider is IServiceProviderIsService serviceProviderIsService) + var validators = new List(); + var serviceProviderIsService = serviceProvider?.GetService(); + if (serviceProviderIsService != null) { if (serviceProviderIsService.IsService(validatorType)) { - validators = (IEnumerable?)serviceProvider.GetService(validatorType); + validators.AddRange(((IEnumerable)serviceProvider!.GetService(validatorType)!).Cast()!); } } else if (serviceProvider is not null) { - validators = (IEnumerable?)serviceProvider.GetService(validatorType); + var validatorServices = serviceProvider.GetService(validatorType) as IEnumerable; + validators.AddRange(validatorServices?.Cast() ?? Array.Empty()); } - if (validators != null) + foreach (var validator in validators) { - foreach (var validator in validators) + if (!isValid || validator is null) + continue; + + var validateDelegate = _asyncValidateMethodsCache.GetOrAdd(validator.GetType(), t => { - if (!isValid || validator is null) - continue; - - var validateDelegate = _validateMethodsCache.GetOrAdd(validator.GetType(), t => - { - var validateMethod = t.GetMethod(nameof(IValidate.ValidateAsync)); - if (validateMethod is null) - { - throw new InvalidOperationException( - $"The type {validators.GetType().Name} does not implement the required method 'Task> ValidateAsync(object, ValidationContext)'."); - } - - 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); - if (validateTask is null) + 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 'Task> ValidateAsync(object, ValidationContext)'."); + $"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; + }); - // Reset validation context - validationContext.MemberName = null; - validationContext.DisplayName = validationContext.ObjectType.Name; + var validateTask = validateDelegate(target, validationContext); - ThrowIfAsyncNotAllowed(validateTask.IsCompleted, allowAsync); + // Reset validation context + validationContext.MemberName = null; + validationContext.DisplayName = validationContext.ObjectType.Name; - var validatableResults = await validateTask.ConfigureAwait(false); - if (validatableResults is not null) - { - ProcessValidationResults(validatableResults, workingErrors, prefix); - isValid = workingErrors.Count == 0 && isValid; - } - } + ThrowIfAsyncNotAllowed(validateTask.IsCompleted, allowAsync); + + var validatableResults = await validateTask.ConfigureAwait(false); + ProcessValidationResults(validatableResults, workingErrors, prefix); + isValid = workingErrors.Count == 0 && isValid; } } @@ -720,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 a5b0b9f..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(); @@ -114,7 +119,27 @@ class TestClassLevel 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(); @@ -132,10 +157,8 @@ public async Task> ValidateAsync(TestClassLevel in class ExtraTestClassLevelValidator : IValidate { - public async Task> ValidateAsync(TestClassLevel instance, ValidationContext validationContext) + public IEnumerable Validate(TestClassLevel instance, ValidationContext validationContext) { - await Task.Yield(); - List? errors = null; if (instance.TwentyOrMore > 20) @@ -150,7 +173,11 @@ public async Task> ValidateAsync(TestClassLevel in 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(); @@ -223,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 e4a5f52..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); @@ -401,33 +429,124 @@ public async Task TryValidateAsync_With_ServiceProvider() public void TryValidate_With_Validator() { var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton, TestClassLevelValidator>(); + 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()); + } - Assert.Throws(() => + [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 { - var isValid = MiniValidator.TryValidate(thingToValidate, serviceProvider, out var errors); - }); + 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.AddSingleton, TestClassLevelValidator>(); + serviceCollection.AddMiniValidator(); + serviceCollection.AddClassMiniValidator(); var serviceProvider = serviceCollection.BuildServiceProvider(); + var validator = serviceProvider.GetRequiredService(); var thingToValidate = new TestClassLevel { TwentyOrMore = 12 }; - var (isValid, errors) = await MiniValidator.TryValidateAsync(thingToValidate, serviceProvider); + 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); @@ -438,16 +557,40 @@ public async Task TryValidateAsync_With_Validator() public async Task TryValidateAsync_With_Multiple_Validators() { var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton, TestClassLevelValidator>(); - serviceCollection.AddSingleton, ExtraTestClassLevelValidator>(); + 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 MiniValidator.TryValidateAsync(thingToValidate, serviceProvider); + var (isValid, errors) = await validator.TryValidateAsync(thingToValidate); Assert.False(isValid); Assert.Single(errors); @@ -458,8 +601,10 @@ public async Task TryValidateAsync_With_Multiple_Validators() 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 { @@ -469,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(); @@ -483,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 { @@ -494,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();