diff --git a/README.md b/README.md index 483377b..e45dac6 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,39 @@ class Widget : IValidatableObject } ``` +### Use external validators + +External validators can be resolved from the `IServiceProvider` passed to `MiniValidator`. Register one or more `IValidate` services (or `IAsyncValidate` services when calling `TryValidateAsync`): + +```csharp +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.DependencyInjection; +using MiniValidation; + +var services = new ServiceCollection(); +services.AddSingleton, WidgetValidator>(); +using var serviceProvider = services.BuildServiceProvider(); + +var widget = new Widget { Name = "" }; +var isValid = MiniValidator.TryValidate(widget, serviceProvider, out var errors); + +class Widget +{ + public string? Name { get; set; } +} + +class WidgetValidator : IValidate +{ + public IEnumerable Validate(Widget target, ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(target.Name)) + { + yield return new ValidationResult("Name is required.", new[] { nameof(Widget.Name) }); + } + } +} +``` + ### Console app ```csharp diff --git a/src/MiniValidation/IAsyncValidate.cs b/src/MiniValidation/IAsyncValidate.cs new file mode 100644 index 0000000..901e9e2 --- /dev/null +++ b/src/MiniValidation/IAsyncValidate.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; + +namespace MiniValidation; + +/// +/// Provides a way for an object to be validated asynchronously by an external validator. +/// +/// The type of object to validate. +public interface IAsyncValidate +{ + /// + /// Determines whether the specified object is valid. + /// + /// The object to validate. + /// The validation context. + /// A collection that holds failed-validation information. + Task> ValidateAsync(TTarget target, ValidationContext validationContext); +} diff --git a/src/MiniValidation/IValidate.cs b/src/MiniValidation/IValidate.cs new file mode 100644 index 0000000..9e36aee --- /dev/null +++ b/src/MiniValidation/IValidate.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace MiniValidation; + +/// +/// Provides a way for an object to be validated by an external validator. +/// +/// The type of object to validate. +public interface IValidate +{ + /// + /// Determines whether the specified object is valid. + /// + /// The object to validate. + /// The validation context. + /// A collection that holds failed-validation information. + IEnumerable Validate(TTarget target, ValidationContext validationContext); +} diff --git a/src/MiniValidation/MiniValidator.cs b/src/MiniValidation/MiniValidator.cs index 99c8f36..dedab86 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -1,9 +1,11 @@ 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.Reflection; using System.Runtime.CompilerServices; using System.Threading.Tasks; @@ -17,6 +19,11 @@ public static class MiniValidator { private static readonly TypeDetailsCache _typeDetailsCache = new(); private static readonly IDictionary _emptyErrors = new ReadOnlyDictionary(new Dictionary()); + private static readonly ConcurrentDictionary _externalValidateDelegates = new(); + private static readonly ConcurrentDictionary _externalValidateAsyncDelegates = new(); + + private delegate IEnumerable? ExternalValidateDelegate(object validator, object target, ValidationContext validationContext); + private delegate Task> ExternalValidateAsyncDelegate(object validator, object target, ValidationContext validationContext); /// /// Gets or sets the maximum depth allowed when validating an object with recursion enabled. @@ -47,6 +54,33 @@ public static bool RequiresValidation(Type targetType, bool recurse = true) || _typeDetailsCache.Get(targetType).Properties.Any(p => p.HasValidationAttributes || recurse); } + /// + /// Determines if the specified has anything to validate, including external validators resolved from a service provider. + /// + /// + /// Objects of types with nothing to validate will always return true when passed to . + /// + /// The . + /// The service provider to use when resolving external validators. + /// 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 or is null. + public static bool RequiresValidation(Type targetType, IServiceProvider serviceProvider, bool recurse = true) + { + if (targetType is null) + { + throw new ArgumentNullException(nameof(targetType)); + } + + if (serviceProvider is null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + + return RequiresValidation(targetType, recurse) + || RequiresExternalValidation(targetType, serviceProvider, recurse, new HashSet()); + } + /// /// Determines whether the specific object is valid. This method recursively validates descendant objects. /// @@ -163,7 +197,7 @@ private static bool TryValidateImpl(TTarget target, IServiceProvider? s throw new ArgumentNullException(nameof(target)); } - if (!RequiresValidation(target.GetType(), recurse)) + if (serviceProvider is null && !RequiresValidation(target.GetType(), recurse)) { errors = _emptyErrors; @@ -306,7 +340,7 @@ private static bool TryValidateImpl(TTarget target, IServiceProvider? s IDictionary? errors; - if (!RequiresValidation(target.GetType(), recurse)) + if (serviceProvider is null && !RequiresValidation(target.GetType(), recurse)) { errors = _emptyErrors; @@ -423,7 +457,9 @@ private static async Task TryValidateImpl( if (recurse && propertyValue is not null && !TypeDetailsCache.IsNonValidatableType(propertyValueType!) && + !TypeDetailsCache.DoNotRecurseIntoPropertiesOf(propertyValueType!) && (property.Recurse + || serviceProvider is not null || typeof(IValidatableObject).IsAssignableFrom(propertyValueType) || typeof(IAsyncValidatableObject).IsAssignableFrom(propertyValueType) || properties.Any(p => p.Recurse))) @@ -432,6 +468,11 @@ private static async Task TryValidateImpl( } } + if (recurse && serviceProvider is not null) + { + AddServiceProviderPropertiesToRecurse(target, serviceProvider, typeProperties, propertiesToRecurse!); + } + if (recurse && currentDepth <= MaxDepth) { // Validate IEnumerable @@ -521,6 +562,15 @@ private static async Task TryValidateImpl( } } + if (serviceProvider is not null) + { + var externalResults = ValidateExternal(target, targetType, serviceProvider, validationContext); + if (externalResults is not null) + { + isValid = ProcessValidationResults(externalResults, workingErrors, prefix) && isValid; + } + } + if ((isValid || allowAsync) && target is IAsyncValidatableObject asyncValidatable) { // Reset validation context @@ -537,6 +587,28 @@ private static async Task TryValidateImpl( } } + if (serviceProvider is not null) + { + var validateExternalAsyncTask = ValidateExternalAsync(target, targetType, serviceProvider, validationContext, allowAsync); + + try + { + ThrowIfAsyncNotAllowed(validateExternalAsyncTask.IsCompleted, allowAsync); + } + catch (Exception) + { + // Always observe the ValueTask + _ = await validateExternalAsyncTask.ConfigureAwait(false); + throw; + } + + var externalAsyncResults = await validateExternalAsyncTask.ConfigureAwait(false); + if (externalAsyncResults is not null) + { + isValid = ProcessValidationResults(externalAsyncResults, workingErrors, prefix) && isValid; + } + } + // Update state of target in tracking dictionary validatedObjects[target] = isValid; @@ -556,6 +628,233 @@ private static void ThrowIfAsyncNotAllowed(bool taskCompleted, bool allowAsync) } } + private static IEnumerable? ValidateExternal(object target, Type targetType, IServiceProvider serviceProvider, ValidationContext validationContext) + { + var validatorServiceType = typeof(IValidate<>).MakeGenericType(targetType); + var validators = GetExternalValidators(serviceProvider, validatorServiceType); + if (validators is null) + { + return null; + } + + List? results = null; + var validate = _externalValidateDelegates.GetOrAdd(targetType, CreateExternalValidateDelegate); + foreach (var validator in validators) + { + var validatorResults = validate(validator, target, validationContext); + if (validatorResults is null) + { + continue; + } + + results ??= new(); + results.AddRange(validatorResults); + } + + return results; + } + +#if NET6_0_OR_GREATER + private static async ValueTask?> ValidateExternalAsync( +#else + private static async Task?> ValidateExternalAsync( +#endif + object target, + Type targetType, + IServiceProvider serviceProvider, + ValidationContext validationContext, + bool allowAsync) + { + var validatorServiceType = typeof(IAsyncValidate<>).MakeGenericType(targetType); + var validators = GetExternalValidators(serviceProvider, validatorServiceType); + if (validators is null) + { + return null; + } + + ThrowIfAsyncNotAllowed(taskCompleted: false, allowAsync); + + List? results = null; + var validate = _externalValidateAsyncDelegates.GetOrAdd(targetType, CreateExternalValidateAsyncDelegate); + foreach (var validator in validators) + { + var validatorResults = await validate(validator, target, validationContext).ConfigureAwait(false); + if (validatorResults is null) + { + continue; + } + + results ??= new(); + results.AddRange(validatorResults); + } + + return results; + } + + private static List? GetExternalValidators(IServiceProvider serviceProvider, Type validatorServiceType) + { + var enumerableServiceType = typeof(IEnumerable<>).MakeGenericType(validatorServiceType); + var enumerableService = serviceProvider.GetService(enumerableServiceType); + if (enumerableService is IEnumerable enumerable) + { + List? validators = null; + foreach (var validator in enumerable) + { + if (validator is null) + { + continue; + } + + if (!validatorServiceType.IsInstanceOfType(validator)) + { + throw new InvalidOperationException($"Service provider returned a service that is not assignable to {validatorServiceType}."); + } + + validators ??= new(); + validators.Add(validator); + } + + if (validators is not null) + { + return validators; + } + } + else if (enumerableService is not null) + { + throw new InvalidOperationException($"Service provider returned a service that is not assignable to {enumerableServiceType}."); + } + + var service = serviceProvider.GetService(validatorServiceType); + if (service is null) + { + return null; + } + + if (!validatorServiceType.IsInstanceOfType(service)) + { + throw new InvalidOperationException($"Service provider returned a service that is not assignable to {validatorServiceType}."); + } + + return new List { service }; + } + + private static bool RequiresExternalValidation(Type targetType, IServiceProvider serviceProvider, bool recurse, HashSet visited) + { + if (!visited.Add(targetType)) + { + return false; + } + + if (GetExternalValidators(serviceProvider, typeof(IValidate<>).MakeGenericType(targetType)) is not null + || GetExternalValidators(serviceProvider, typeof(IAsyncValidate<>).MakeGenericType(targetType)) is not null) + { + return true; + } + + if (!recurse) + { + return false; + } + + var enumerableType = TypeDetailsCache.GetEnumerableType(targetType); + if (enumerableType is not null && RequiresExternalValidation(enumerableType, serviceProvider, recurse, visited)) + { + return true; + } + + if (TypeDetailsCache.DoNotRecurseIntoPropertiesOf(targetType) || TypeDetailsCache.IsNonValidatableType(targetType)) + { + return false; + } + + foreach (var property in targetType.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy)) + { + if (property.GetIndexParameters().Length > 0 + || property.GetCustomAttributes(typeof(SkipRecursionAttribute), inherit: true).Length > 0) + { + continue; + } + + if (RequiresExternalValidation(property.PropertyType, serviceProvider, recurse, visited)) + { + return true; + } + + var propertyEnumerableType = TypeDetailsCache.GetEnumerableType(property.PropertyType); + if (propertyEnumerableType is not null && RequiresExternalValidation(propertyEnumerableType, serviceProvider, recurse, visited)) + { + return true; + } + } + + return false; + } + + private static ExternalValidateDelegate CreateExternalValidateDelegate(Type targetType) + { + var method = typeof(MiniValidator).GetMethod(nameof(ValidateExternalCore), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(targetType); + return (ExternalValidateDelegate)Delegate.CreateDelegate(typeof(ExternalValidateDelegate), method); + } + + private static ExternalValidateAsyncDelegate CreateExternalValidateAsyncDelegate(Type targetType) + { + var method = typeof(MiniValidator).GetMethod(nameof(ValidateExternalAsyncCore), BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(targetType); + return (ExternalValidateAsyncDelegate)Delegate.CreateDelegate(typeof(ExternalValidateAsyncDelegate), method); + } + + private static IEnumerable? ValidateExternalCore(object validator, object target, ValidationContext validationContext) + { + return ((IValidate)validator).Validate((TTarget)target, validationContext); + } + + private static Task> ValidateExternalAsyncCore(object validator, object target, ValidationContext validationContext) + { + return ((IAsyncValidate)validator).ValidateAsync((TTarget)target, validationContext); + } + + private static void AddServiceProviderPropertiesToRecurse(object target, IServiceProvider serviceProvider, PropertyDetails[] cachedProperties, Dictionary propertiesToRecurse) + { + var cachedPropertyNames = new HashSet(cachedProperties.Select(property => property.Name), StringComparer.Ordinal); + + foreach (var property in target.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy)) + { + if (cachedPropertyNames.Contains(property.Name) + || property.GetIndexParameters().Length > 0 + || property.GetCustomAttributes(typeof(SkipRecursionAttribute), inherit: true).Length > 0) + { + continue; + } + + var propertyEnumerableType = TypeDetailsCache.GetEnumerableType(property.PropertyType); + if (!RequiresExternalValidation(property.PropertyType, serviceProvider, recurse: true, new HashSet()) + && (propertyEnumerableType is null || !RequiresExternalValidation(propertyEnumerableType, serviceProvider, recurse: true, new HashSet()))) + { + continue; + } + + var getter = PropertyHelper.MakeNullSafeFastPropertyGetter(property); + var propertyValue = getter(target); + if (propertyValue is null) + { + continue; + } + + var propertyValueType = propertyValue.GetType(); + var valueEnumerableType = TypeDetailsCache.GetEnumerableType(propertyValueType); + if (valueEnumerableType is null + && TypeDetailsCache.DoNotRecurseIntoPropertiesOf(propertyValueType) + || TypeDetailsCache.IsNonValidatableType(propertyValueType)) + { + continue; + } + + var enumerableType = propertyEnumerableType ?? valueEnumerableType; + propertiesToRecurse.Add( + new PropertyDetails(property.Name, null, property.PropertyType, getter, Array.Empty(), true, enumerableType), + propertyValue); + } + } + #if NET6_0_OR_GREATER private static async ValueTask TryValidateEnumerable( #else diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index 2086de3..4743d5d 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -164,7 +164,7 @@ private void Visit(Type type, HashSet visited, ref bool requiresAsync) _cache[type] = (propertiesToValidate?.ToArray() ?? _emptyPropertyDetails, requiresAsync); } - private static bool DoNotRecurseIntoPropertiesOf(Type type) => + internal static bool DoNotRecurseIntoPropertiesOf(Type type) => type == typeof(object) || type.IsPrimitive || type.IsArray @@ -383,7 +383,7 @@ private static bool TryGetAttributesViaTypeDescriptor(PropertyInfo property, [No return false; } - private static Type? GetEnumerableType(Type type) + internal static Type? GetEnumerableType(Type type) { if (type.IsInterface && type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { diff --git a/tests/MiniValidation.UnitTests/ExternalValidators.cs b/tests/MiniValidation.UnitTests/ExternalValidators.cs new file mode 100644 index 0000000..7268fea --- /dev/null +++ b/tests/MiniValidation.UnitTests/ExternalValidators.cs @@ -0,0 +1,331 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace MiniValidation.UnitTests; + +public class ExternalValidators +{ + [Fact] + public void TryValidate_Uses_External_Sync_Validator() + { + var serviceProvider = new ServiceCollection() + .AddSingleton, ExternalWidgetNameValidator>() + .BuildServiceProvider(); + var target = new ExternalWidget { Name = "no" }; + + var isValid = MiniValidator.TryValidate(target, serviceProvider, out var errors); + + Assert.False(isValid); + var entry = Assert.Single(errors); + Assert.Equal(nameof(ExternalWidget.Name), entry.Key); + } + + [Fact] + public async Task TryValidateAsync_Uses_External_Async_Validator() + { + var serviceProvider = new ServiceCollection() + .AddSingleton, ExternalWidgetAsyncNameValidator>() + .BuildServiceProvider(); + var target = new ExternalWidget { Name = "no" }; + + var (isValid, errors) = await MiniValidator.TryValidateAsync(target, serviceProvider); + + Assert.False(isValid); + var entry = Assert.Single(errors); + Assert.Equal(nameof(ExternalWidget.Name), entry.Key); + } + + [Fact] + public void TryValidate_Aggregates_Multiple_External_Validators() + { + var serviceProvider = new ServiceCollection() + .AddSingleton, ExternalWidgetNameValidator>() + .AddSingleton, ExternalWidgetCategoryValidator>() + .BuildServiceProvider(); + var target = new ExternalWidget { Name = "no", Category = "no" }; + + var isValid = MiniValidator.TryValidate(target, serviceProvider, out var errors); + + Assert.False(isValid); + Assert.Equal(2, errors.Count); + Assert.Contains(nameof(ExternalWidget.Name), errors.Keys); + Assert.Contains(nameof(ExternalWidget.Category), errors.Keys); + } + + [Fact] + public void TryValidate_Resolves_Single_External_Validator_From_ServiceProvider() + { + var validator = new ExternalWidgetNameValidator(); + var serviceProvider = new SingleServiceProvider(typeof(IValidate), validator); + var target = new ExternalWidget { Name = "no" }; + + var isValid = MiniValidator.TryValidate(target, serviceProvider, out var errors); + + Assert.False(isValid); + Assert.Single(errors); + Assert.Equal(nameof(ExternalWidget.Name), errors.Keys.First()); + } + + [Fact] + public void TryValidate_Uses_External_Validator_For_Nested_Object() + { + var serviceProvider = new ServiceCollection() + .AddSingleton, SealedExternalChildValidator>() + .BuildServiceProvider(); + var target = new ExternalContainer + { + Child = new SealedExternalChild { Code = "no" } + }; + + var isValid = MiniValidator.TryValidate(target, serviceProvider, out var errors); + + Assert.False(isValid); + var entry = Assert.Single(errors); + Assert.Equal($"{nameof(ExternalContainer.Child)}.{nameof(SealedExternalChild.Code)}", entry.Key); + } + + [Fact] + public void TryValidate_Uses_External_Validator_For_Enumerable_Element() + { + var serviceProvider = new ServiceCollection() + .AddSingleton, SealedExternalChildValidator>() + .BuildServiceProvider(); + var target = new ExternalCollectionContainer + { + Children = new List + { + new() { Code = "no" } + } + }; + + var isValid = MiniValidator.TryValidate(target, serviceProvider, out var errors); + + Assert.False(isValid); + var entry = Assert.Single(errors); + Assert.Equal($"{nameof(ExternalCollectionContainer.Children)}.[0].{nameof(SealedExternalChild.Code)}", entry.Key); + } + + [Fact] + public void TryValidate_Throws_When_Async_External_Validator_Is_Registered() + { + var serviceProvider = new ServiceCollection() + .AddSingleton, ExternalWidgetAsyncNameValidator>() + .BuildServiceProvider(); + var target = new ExternalWidget { Name = "no" }; + + Assert.Throws(() => MiniValidator.TryValidate(target, serviceProvider, out _)); + } + + [Fact] + public void TryValidate_Does_Not_Cache_Transient_External_Validator_Instances() + { + TransientExternalValidator.Reset(); + var serviceProvider = new ServiceCollection() + .AddTransient, TransientExternalValidator>() + .BuildServiceProvider(); + var target = new TransientExternalTarget(); + + MiniValidator.TryValidate(target, serviceProvider, out var firstErrors); + MiniValidator.TryValidate(target, serviceProvider, out var secondErrors); + + Assert.Equal("Validator 1", Assert.Single(firstErrors[""])); + Assert.Equal("Validator 2", Assert.Single(secondErrors[""])); + } + + [Fact] + public void TryValidate_Uses_Correct_Interface_For_Multi_Target_Validator() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton>(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + var serviceProvider = services.BuildServiceProvider(); + + MiniValidator.TryValidate(new FirstExternalTarget(), serviceProvider, out var firstErrors); + MiniValidator.TryValidate(new SecondExternalTarget(), serviceProvider, out var secondErrors); + + Assert.Equal(nameof(FirstExternalTarget.FirstName), firstErrors.Keys.Single()); + Assert.Equal(nameof(SecondExternalTarget.SecondName), secondErrors.Keys.Single()); + } + + [Fact] + public void TryValidate_Uses_Validator_That_Also_Implements_NonGeneric_Interface() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton>(sp => sp.GetRequiredService()); + var serviceProvider = services.BuildServiceProvider(); + + var isValid = MiniValidator.TryValidate(new MarkedExternalTarget(), serviceProvider, out var errors); + + Assert.False(isValid); + Assert.Equal(nameof(MarkedExternalTarget.Value), errors.Keys.Single()); + } + + [Fact] + public void RequiresValidation_With_ServiceProvider_Accounts_For_External_Validators() + { + var serviceProvider = new ServiceCollection() + .AddSingleton, ExternalOnlyValidator>() + .BuildServiceProvider(); + + Assert.False(MiniValidator.RequiresValidation(typeof(ExternalOnlyTarget), recurse: false)); + Assert.True(MiniValidator.RequiresValidation(typeof(ExternalOnlyTarget), serviceProvider, recurse: false)); + } + + private sealed class SingleServiceProvider : IServiceProvider + { + private readonly Type _serviceType; + private readonly object _service; + + public SingleServiceProvider(Type serviceType, object service) + { + _serviceType = serviceType; + _service = service; + } + + public object? GetService(Type serviceType) + { + return serviceType == _serviceType ? _service : null; + } + } + + private sealed class ExternalWidget + { + public string? Name { get; set; } + + public string? Category { get; set; } + } + + private sealed class ExternalWidgetNameValidator : IValidate + { + public IEnumerable Validate(ExternalWidget target, ValidationContext validationContext) + { + if (target.Name is null || target.Name.Length < 3) + { + yield return new ValidationResult("Name is too short.", new[] { nameof(ExternalWidget.Name) }); + } + } + } + + private sealed class ExternalWidgetCategoryValidator : IValidate + { + public IEnumerable Validate(ExternalWidget target, ValidationContext validationContext) + { + if (target.Category is null || target.Category.Length < 3) + { + yield return new ValidationResult("Category is too short.", new[] { nameof(ExternalWidget.Category) }); + } + } + } + + private sealed class ExternalWidgetAsyncNameValidator : IAsyncValidate + { + public async Task> ValidateAsync(ExternalWidget target, ValidationContext validationContext) + { + await Task.Yield(); + + return target.Name is null || target.Name.Length < 3 + ? new[] { new ValidationResult("Name is too short.", new[] { nameof(ExternalWidget.Name) }) } + : Enumerable.Empty(); + } + } + + private sealed class ExternalContainer + { + public SealedExternalChild? Child { get; set; } + } + + private sealed class ExternalCollectionContainer + { + public IEnumerable? Children { get; set; } + } + + private sealed class SealedExternalChild + { + public string? Code { get; set; } + } + + private sealed class SealedExternalChildValidator : IValidate + { + public IEnumerable Validate(SealedExternalChild target, ValidationContext validationContext) + { + if (target.Code is null || target.Code.Length < 3) + { + yield return new ValidationResult("Code is too short.", new[] { nameof(SealedExternalChild.Code) }); + } + } + } + + private sealed class TransientExternalTarget + { + } + + private sealed class TransientExternalValidator : IValidate + { + private static int s_instances; + private readonly int _instance = System.Threading.Interlocked.Increment(ref s_instances); + + public static void Reset() + { + s_instances = 0; + } + + public IEnumerable Validate(TransientExternalTarget target, ValidationContext validationContext) + { + yield return new ValidationResult($"Validator {_instance}"); + } + } + + private sealed class FirstExternalTarget + { + public string? FirstName { get; set; } + } + + private sealed class SecondExternalTarget + { + public string? SecondName { get; set; } + } + + private sealed class MultiTargetExternalValidator : IValidate, IValidate + { + IEnumerable IValidate.Validate(FirstExternalTarget target, ValidationContext validationContext) + { + yield return new ValidationResult("First target is invalid.", new[] { nameof(FirstExternalTarget.FirstName) }); + } + + IEnumerable IValidate.Validate(SecondExternalTarget target, ValidationContext validationContext) + { + yield return new ValidationResult("Second target is invalid.", new[] { nameof(SecondExternalTarget.SecondName) }); + } + } + + private interface INonGenericValidatorMarker + { + } + + private sealed class MarkedExternalTarget + { + public string? Value { get; set; } + } + + private sealed class MarkedExternalValidator : INonGenericValidatorMarker, IValidate + { + public IEnumerable Validate(MarkedExternalTarget target, ValidationContext validationContext) + { + yield return new ValidationResult("Value is invalid.", new[] { nameof(MarkedExternalTarget.Value) }); + } + } + + private sealed class ExternalOnlyTarget + { + } + + private sealed class ExternalOnlyValidator : IValidate + { + public IEnumerable Validate(ExternalOnlyTarget target, ValidationContext validationContext) + { + yield return new ValidationResult("External only target is invalid."); + } + } +}