diff --git a/src/Validation/src/Microsoft.Extensions.Validation.csproj b/src/Validation/src/Microsoft.Extensions.Validation.csproj index 72d50e224f42..320c925bb0c2 100644 --- a/src/Validation/src/Microsoft.Extensions.Validation.csproj +++ b/src/Validation/src/Microsoft.Extensions.Validation.csproj @@ -12,6 +12,7 @@ + diff --git a/src/Validation/src/PublicAPI.Unshipped.txt b/src/Validation/src/PublicAPI.Unshipped.txt index d7f657e38875..bbc3ce87ca34 100644 --- a/src/Validation/src/PublicAPI.Unshipped.txt +++ b/src/Validation/src/PublicAPI.Unshipped.txt @@ -45,7 +45,11 @@ Microsoft.Extensions.Validation.ValidationOptions.TryGetValidatableTypeInfo(Syst Microsoft.Extensions.Validation.ValidationOptions.ValidationOptions() -> void abstract Microsoft.Extensions.Validation.ValidatableParameterInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]! abstract Microsoft.Extensions.Validation.ValidatablePropertyInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]! +Microsoft.Extensions.Validation.RuntimeValidatableTypeInfoResolver +Microsoft.Extensions.Validation.RuntimeValidatableTypeInfoResolver.RuntimeValidatableTypeInfoResolver() -> void +Microsoft.Extensions.Validation.RuntimeValidatableTypeInfoResolver.TryGetValidatableParameterInfo(System.Reflection.ParameterInfo! parameterInfo, out Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) -> bool +Microsoft.Extensions.Validation.RuntimeValidatableTypeInfoResolver.TryGetValidatableTypeInfo(System.Type! type, out Microsoft.Extensions.Validation.IValidatableInfo? info) -> bool static Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action? configureOptions = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! virtual Microsoft.Extensions.Validation.ValidatableParameterInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! virtual Microsoft.Extensions.Validation.ValidatablePropertyInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! -virtual Microsoft.Extensions.Validation.ValidatableTypeInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! \ No newline at end of file +virtual Microsoft.Extensions.Validation.ValidatableTypeInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! diff --git a/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs b/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs index d8f0c3699dbf..9c569c12b453 100644 --- a/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs +++ b/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs @@ -9,6 +9,8 @@ using System.Linq; using System.Reflection; using System.Security.Claims; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Extensions.Validation; @@ -28,6 +30,13 @@ public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNull throw new InvalidOperationException($"Encountered a parameter of type '{parameterInfo.ParameterType}' without a name. Parameters must have a name."); } + // Skip parameters marked with [FromService] or [FromKeyedService] attributes + if (HasFromServiceAttributes(parameterInfo.GetCustomAttributes())) + { + validatableInfo = null; + return false; + } + var validationAttributes = parameterInfo .GetCustomAttributes() .ToArray(); @@ -60,18 +69,6 @@ private static string GetDisplayName(ParameterInfo parameterInfo) return parameterInfo.Name!; } - internal sealed class RuntimeValidatableParameterInfo( - Type parameterType, - string name, - string displayName, - ValidationAttribute[] validationAttributes) : - ValidatableParameterInfo(parameterType, name, displayName) - { - protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes; - - private readonly ValidationAttribute[] _validationAttributes = validationAttributes; - } - private static bool IsClass(Type type) { // Skip primitives, enums, common built-in types, and types that are specially @@ -102,4 +99,23 @@ private static bool IsClass(Type type) return type.IsClass; } -} + + private static bool HasFromServiceAttributes(IEnumerable attributes) + { + return attributes.Any(attr => + attr is IFromServiceMetadata || + attr is FromKeyedServicesAttribute); + } + + internal sealed class RuntimeValidatableParameterInfo( + Type parameterType, + string name, + string displayName, + ValidationAttribute[] validationAttributes) : + ValidatableParameterInfo(parameterType, name, displayName) + { + protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes; + + private readonly ValidationAttribute[] _validationAttributes = validationAttributes; + } +} \ No newline at end of file diff --git a/src/Validation/src/RuntimeValidatableTypeInfoResolver.cs b/src/Validation/src/RuntimeValidatableTypeInfoResolver.cs new file mode 100644 index 000000000000..443a37954964 --- /dev/null +++ b/src/Validation/src/RuntimeValidatableTypeInfoResolver.cs @@ -0,0 +1,430 @@ +#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Concurrent; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Validation; + +/// +/// Experimental runtime implementation of for type validation. +/// +/// +/// This is an experimental API and may change in future versions. +/// +[RequiresUnreferencedCode("RuntimeValidatableTypeInfoResolver uses reflection to inspect types, properties, and attributes at runtime, including JsonDerivedTypeAttribute and record constructors. Trimming or AOT compilation may remove members required for validation.")] +[Experimental("ASP0029")] +public sealed class RuntimeValidatableTypeInfoResolver : IValidatableInfoResolver +{ + private static readonly ConcurrentDictionary _cache = new(); + + /// + /// Attempts to get validatable type information for the specified type using runtime reflection. + /// + /// The type to get validation information for. + /// When this method returns, contains the validatable type information if found; otherwise, . + /// if validatable type information was found; otherwise, . + public bool TryGetValidatableTypeInfo( + Type type, + [NotNullWhen(true)] out IValidatableInfo? info) + { + if (_cache.TryGetValue(type, out info)) + { + return info is not null; + } + + info = CreateValidatableTypeInfo(type, new HashSet()); + _cache.TryAdd(type, info); + return info is not null; + } + + /// + /// Attempts to get validatable parameter information for the specified parameter. + /// + /// The parameter to get validation information for. + /// When this method returns, contains the validatable parameter information if found; otherwise, . + /// if validatable parameter information was found; otherwise, . + /// + /// This implementation always returns as parameter resolution is handled by . + /// + public bool TryGetValidatableParameterInfo( + ParameterInfo parameterInfo, + [NotNullWhen(true)] out IValidatableInfo? validatableInfo) + { + validatableInfo = null; + return false; + } + + private static RuntimeValidatableTypeInfo? CreateValidatableTypeInfo(Type type, HashSet visitedTypes) + { + // Prevent infinite recursion by tracking visited types + if (!visitedTypes.Add(type)) + { + return null; + } + + try + { + // Skip types that don't need validation (same logic as parameter resolver) + if (!IsClassForType(type)) + { + return null; + } + + // Get validation attributes applied to the type + var typeValidationAttributes = type + .GetCustomAttributes() + .ToArray(); + + // Skip early if the type has no validation attributes and no validatable properties + var hasTypeValidationAttributes = typeValidationAttributes.Length > 0; + + // Get all public instance properties + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + var validatableProperties = new List(); + + foreach (var property in properties) + { + // Skip properties without setters (read-only properties) for normal classes + if (!property.CanWrite && !IsRecordType(type) && !IsRecordStruct(type)) + { + continue; + } + + // Skip properties marked with [FromService] or [FromKeyedService] attributes + if (HasFromServiceAttributes(property.GetCustomAttributes())) + { + continue; + } + + // Get validation attributes for this property + var propertyValidationAttributes = property + .GetCustomAttributes() + .ToArray(); + + // For record types, also check constructor parameters for validation attributes + if (IsRecordType(type) || IsRecordStruct(type)) + { + var constructorValidationAttributes = GetValidationAttributesFromConstructorParameter(type, property.Name); + if (constructorValidationAttributes.Length > 0) + { + // Merge property and constructor validation attributes + var allAttributes = new List(propertyValidationAttributes); + allAttributes.AddRange(constructorValidationAttributes); + propertyValidationAttributes = [.. allAttributes]; + } + } + + // Get display name + var displayName = GetDisplayNameForProperty(property); + + // Determine if this property has a validatable type + // Use the simpler check that doesn't cause recursion + var hasValidatableType = IsClassForType(property.PropertyType); + + // Create the property info if it has validation attributes or a validatable type + if (propertyValidationAttributes.Length > 0 || hasValidatableType) + { + var propertyInfo = new RuntimeValidatablePropertyInfo( + declaringType: type, + propertyType: property.PropertyType, + name: property.Name, + displayName: displayName, + validationAttributes: propertyValidationAttributes); + + validatableProperties.Add(propertyInfo); + } + } + + // Check for polymorphic derived types (JsonDerivedType attributes) + var derivedTypes = GetDerivedTypes(type); + foreach (var derivedType in derivedTypes) + { + // Ensure derived types are also available for validation + // We don't need to use the return value as it's automatically cached + if (!_cache.ContainsKey(derivedType)) + { + CreateValidatableTypeInfo(derivedType, visitedTypes); + } + } + + // Only create type info if there are validation attributes on the type or validatable properties + if (hasTypeValidationAttributes || validatableProperties.Count > 0) + { + return new RuntimeValidatableTypeInfo(type, validatableProperties); + } + + return null; + } + finally + { + visitedTypes.Remove(type); + } + } + + private static List GetDerivedTypes(Type baseType) + { + var derivedTypes = new List(); + + // Look for JsonDerivedType attributes on the base type + var jsonDerivedTypeAttributes = baseType.GetCustomAttributes(); + + foreach (var attr in jsonDerivedTypeAttributes) + { + if (attr.DerivedType != null && attr.DerivedType.IsSubclassOf(baseType)) + { + derivedTypes.Add(attr.DerivedType); + } + } + + return derivedTypes; + } + + private static string GetDisplayNameForProperty(PropertyInfo property) + { + var displayAttribute = property.GetCustomAttribute(); + if (displayAttribute?.Name is not null) + { + return displayAttribute.Name; + } + + // For record types, also check constructor parameter for Display attribute + if (IsRecordType(property.DeclaringType!) || IsRecordStruct(property.DeclaringType!)) + { + var constructorDisplayName = GetDisplayNameFromConstructorParameter(property.DeclaringType!, property.Name); + if (!string.IsNullOrEmpty(constructorDisplayName)) + { + return constructorDisplayName; + } + } + + return property.Name; + } + + private static string? GetDisplayNameFromConstructorParameter(Type type, string propertyName) + { + var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + foreach (var constructor in constructors) + { + var parameters = constructor.GetParameters(); + + // Find parameter that matches the property name (case-insensitive for records) + var matchingParameter = parameters.FirstOrDefault(p => + string.Equals(p.Name, propertyName, StringComparison.OrdinalIgnoreCase)); + + if (matchingParameter != null) + { + var displayAttribute = matchingParameter.GetCustomAttribute(); + if (displayAttribute?.Name is not null) + { + return displayAttribute.Name; + } + } + } + + return null; + } + + private static bool IsRecordType(Type type) + { + // Check if the type is a record by looking for specific record-related compiler-generated methods + // Records have a special $ method and EqualityContract property + return type.IsClass && + type.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Any(m => m.Name == "$" || m.Name == "get_EqualityContract"); + } + + private static bool IsRecordStruct(Type type) + { + // Check if the type is a record struct by looking for record-specific characteristics + // Record structs are value types with specific compiler-generated methods + if (!type.IsValueType || type.IsEnum || type.IsPrimitive) + { + return false; + } + + // Record structs have an EqualityContract property like classes but as static readonly + var equalityContract = type.GetProperty("EqualityContract", BindingFlags.Public | BindingFlags.Static); + if (equalityContract?.GetMethod?.IsStatic == true) + { + return true; + } + + // Alternative check: Record structs have a primary constructor pattern + // They typically have a ToString() override and specific constructor patterns + var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + var hasParameterizedConstructor = constructors.Any(c => c.GetParameters().Length > 0); + + if (hasParameterizedConstructor) + { + // Check for ToString override that's compiler generated for records + var toStringMethod = type.GetMethod("ToString", BindingFlags.Public | BindingFlags.Instance, null, Type.EmptyTypes, null); + return toStringMethod?.DeclaringType == type; + } + + return false; + } + + private static ValidationAttribute[] GetValidationAttributesFromConstructorParameter(Type type, string propertyName) + { + // Look for primary constructor parameters that match the property name + var constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + // For records, prefer the primary constructor (typically the one with the most parameters) + var primaryConstructor = constructors + .OrderByDescending(c => c.GetParameters().Length) + .FirstOrDefault(); + + if (primaryConstructor != null) + { + var parameters = primaryConstructor.GetParameters(); + + // Find parameter that matches the property name (case-insensitive for records) + var matchingParameter = parameters.FirstOrDefault(p => + string.Equals(p.Name, propertyName, StringComparison.OrdinalIgnoreCase)); + + if (matchingParameter != null) + { + var attributes = matchingParameter.GetCustomAttributes().ToArray(); + if (attributes.Length > 0) + { + return attributes; + } + } + } + + return []; + } + + private static Type UnwrapType(Type type) + { + // Handle Nullable + if (Nullable.GetUnderlyingType(type) is { } nullableType) + { + type = nullableType; + } + + // Handle collection types - extract element type + if (type.IsGenericType) + { + var genericDefinition = type.GetGenericTypeDefinition(); + if (genericDefinition == typeof(IEnumerable<>) || + genericDefinition == typeof(ICollection<>) || + genericDefinition == typeof(IList<>) || + genericDefinition == typeof(List<>) || + genericDefinition == typeof(IReadOnlyCollection<>) || + genericDefinition == typeof(IReadOnlyList<>)) + { + type = type.GetGenericArguments()[0]; + return UnwrapType(type); // Recursively unwrap nested collections + } + } + + // Handle arrays + if (type.IsArray) + { + type = type.GetElementType()!; + return UnwrapType(type); // Recursively unwrap nested arrays + } + + return type; + } + + private static bool IsParsableType(Type type) + { + var unwrappedType = UnwrapType(type); + + // Check for built-in parsable types + if (unwrappedType.IsPrimitive || + unwrappedType.IsEnum || + unwrappedType == typeof(string) || + unwrappedType == typeof(decimal) || + unwrappedType == typeof(DateTime) || + unwrappedType == typeof(DateTimeOffset) || + unwrappedType == typeof(TimeOnly) || + unwrappedType == typeof(DateOnly) || + unwrappedType == typeof(TimeSpan) || + unwrappedType == typeof(Guid) || + unwrappedType == typeof(Uri)) + { + return true; + } + + try + { + // Check for IParsable interface + // Check if unwrappedType implements IParsable for itself + foreach (var iface in unwrappedType.GetInterfaces()) + { + // Look for IParsable in its generic-definition form + if (iface.IsGenericType && + iface.GetGenericTypeDefinition() == typeof(IParsable<>)) + { + return true; + } + } + } + catch + { + // If we can't construct the generic type, it's not parsable + } + + try + { + // Check for TryParse methods + var tryParseMethod = unwrappedType.GetMethod("TryParse", + BindingFlags.Public | BindingFlags.Static, + null, + [typeof(string), unwrappedType.MakeByRefType()], + null); + + if (tryParseMethod != null && tryParseMethod.ReturnType == typeof(bool)) + { + return true; + } + } + catch + { + // If we can't find the method, it's not parsable + } + + return false; + } + + private static bool IsClassForType(Type type) + => !IsParsableType(type) && (type.IsClass || IsRecordStruct(type)); + + private static bool HasFromServiceAttributes(IEnumerable attributes) + { + return attributes.Any(attr => + attr is IFromServiceMetadata || + attr is FromKeyedServicesAttribute); + } + + internal sealed class RuntimeValidatablePropertyInfo([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type declaringType, + Type propertyType, + string name, + string displayName, + ValidationAttribute[] validationAttributes) : + ValidatablePropertyInfo(declaringType, propertyType, name, displayName) + { + private readonly ValidationAttribute[] _validationAttributes = validationAttributes; + + protected override ValidationAttribute[] GetValidationAttributes() => _validationAttributes; + } + + internal sealed class RuntimeValidatableTypeInfo( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)] Type type, + IReadOnlyList members) : + ValidatableTypeInfo(type, [.. members]) + { } +} \ No newline at end of file diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs b/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs index eebe4032d9af..929658cdf58e 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableParameterInfoResolverTests.cs @@ -8,13 +8,14 @@ namespace Microsoft.Extensions.Validation.Tests; -public class RuntimeValidatableParameterInfoResolverTests +public class RuntimeValidatableInfoResolverTests { private readonly RuntimeValidatableParameterInfoResolver _resolver = new(); [Fact] - public void TryGetValidatableTypeInfo_AlwaysReturnsFalse() + public void TryGetValidatableTypeInfo_WithStringType_ReturnsFalse() { + // String types should not be validatable at the type level var result = _resolver.TryGetValidatableTypeInfo(typeof(string), out var validatableInfo); Assert.False(result); diff --git a/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableTypeInfoResolverTests.cs b/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableTypeInfoResolverTests.cs new file mode 100644 index 000000000000..419ca9139b6e --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.Tests/RuntimeValidatableTypeInfoResolverTests.cs @@ -0,0 +1,1181 @@ +#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace Microsoft.Extensions.Validation.Tests; + +public class RuntimeValidatableTypeInfoResolverTests +{ + private readonly RuntimeValidatableTypeInfoResolver _resolver = new(); + + [Fact] + public void TryGetValidatableParameterInfo_WithStringParameterNoAttributes_ReturnsFalse() + { + var methodInfo = typeof(RuntimeValidatableTypeInfoResolverTests).GetMethod(nameof(SampleMethod), BindingFlags.NonPublic | BindingFlags.Instance); + var parameterInfo = methodInfo!.GetParameters()[0]; + + var result = _resolver.TryGetValidatableParameterInfo(parameterInfo, out var validatableInfo); + + Assert.False(result); + Assert.Null(validatableInfo); + } + + [Fact] + public void TryGetValidatableTypeInfo_WithPrimitiveType_ReturnsFalse() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(int), out var validatableInfo); + + Assert.False(result); + Assert.Null(validatableInfo); + } + + [Fact] + public void TryGetValidatableTypeInfo_WithString_ReturnsFalse() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(string), out var validatableInfo); + + Assert.False(result); + Assert.Null(validatableInfo); + } + + [Fact] + public void TryGetValidatableTypeInfo_WithEnum_ReturnsFalse() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(SampleEnum), out var validatableInfo); + + Assert.False(result); + Assert.Null(validatableInfo); + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithSimplePocoWithValidationAttributes_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(SimplePocoWithValidation), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + Assert.IsType(validatableInfo); + + // Test validation with invalid data + var invalidPoco = new SimplePocoWithValidation + { + Name = "", // Required but empty + Age = 150 // Out of range (0-100) + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidPoco) + }; + + await validatableInfo.ValidateAsync(invalidPoco, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("Age", kvp.Key); + Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First()); + }); + + // Test validation with valid data + var validPoco = new SimplePocoWithValidation + { + Name = "John Doe", + Age = 25 + }; + + var validContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(validPoco) + }; + + await validatableInfo.ValidateAsync(validPoco, validContext, default); + + Assert.Null(validContext.ValidationErrors); // No validation errors for valid data + } + + [Fact] + public void TryGetValidatableTypeInfo_WithSimplePocoWithoutValidation_ReturnsFalse() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(SimplePocoWithoutValidation), out var validatableInfo); + + Assert.False(result); + Assert.Null(validatableInfo); + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithNestedComplexType_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithNestedType), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + // Test validation with invalid nested data + var invalidPoco = new PocoWithNestedType + { + Name = "", // Required but empty + NestedPoco = new SimplePocoWithValidation + { + Name = "", // Required but empty in nested object + Age = -5 // Out of range (0-100) in nested object + } + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidPoco) + }; + + await validatableInfo.ValidateAsync(invalidPoco, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("NestedPoco.Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("NestedPoco.Age", kvp.Key); + Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First()); + }); + + // Test validation with valid nested data + var validPoco = new PocoWithNestedType + { + Name = "John Doe", + NestedPoco = new SimplePocoWithValidation + { + Name = "Jane Smith", + Age = 30 + } + }; + + var validContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(validPoco) + }; + + await validatableInfo.ValidateAsync(validPoco, validContext, default); + + Assert.Null(validContext.ValidationErrors); // No validation errors for valid data + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithCyclicReference_DoesNotCauseStackOverflow_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(CyclicTypeA), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + // Test validation with invalid data in cyclic structure + var cyclicA = new CyclicTypeA + { + Name = "", // Required but empty + TypeB = new CyclicTypeB + { + Value = "", // Required but empty + TypeA = new CyclicTypeA + { + Name = "Valid Name", // This one is valid + TypeB = null // No further nesting + } + } + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(cyclicA) + }; + + await validatableInfo.ValidateAsync(cyclicA, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("TypeB.Value", kvp.Key); + Assert.Equal("The Value field is required.", kvp.Value.First()); + }); + + // Test validation with valid cyclic data + var validCyclicA = new CyclicTypeA + { + Name = "Valid A", + TypeB = new CyclicTypeB + { + Value = "Valid B", + TypeA = new CyclicTypeA + { + Name = "Valid Nested A", + TypeB = null // Stop the cycle + } + } + }; + + var validContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(validCyclicA) + }; + + await validatableInfo.ValidateAsync(validCyclicA, validContext, default); + + Assert.Null(validContext.ValidationErrors); // No validation errors for valid data + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithCollectionOfComplexTypes_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithCollection), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + // Test validation with invalid data in collection + var invalidPoco = new PocoWithCollection + { + Name = "", // Required but empty + Items = new List + { + new SimplePocoWithValidation { Name = "Valid Item", Age = 25 }, // Valid item + new SimplePocoWithValidation { Name = "", Age = 150 }, // Invalid: empty name and out of range age + new SimplePocoWithValidation { Name = "Another Valid", Age = 30 } // Valid item + } + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidPoco) + }; + + await validatableInfo.ValidateAsync(invalidPoco, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("Items[1].Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("Items[1].Age", kvp.Key); + Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First()); + }); + + // Test validation with valid collection data + var validPoco = new PocoWithCollection + { + Name = "Collection Owner", + Items = new List + { + new SimplePocoWithValidation { Name = "Item 1", Age = 25 }, + new SimplePocoWithValidation { Name = "Item 2", Age = 30 } + } + }; + + var validContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(validPoco) + }; + + await validatableInfo.ValidateAsync(validPoco, validContext, default); + + Assert.Null(validContext.ValidationErrors); // No validation errors for valid data + } + + [Fact] + public void TryGetValidatableTypeInfo_UsesCaching() + { + // First call + var result1 = _resolver.TryGetValidatableTypeInfo(typeof(SimplePocoWithValidation), out var validatableInfo1); + + // Second call + var result2 = _resolver.TryGetValidatableTypeInfo(typeof(SimplePocoWithValidation), out var validatableInfo2); + + Assert.True(result1); + Assert.True(result2); + Assert.Same(validatableInfo1, validatableInfo2); // Should be the same cached instance + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithReadOnlyProperty_IgnoresReadOnlyProperty_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithReadOnlyProperty), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + var typeInfo = Assert.IsType(validatableInfo); + + // Test validation with invalid writable property (read-only property should be ignored) + var invalidPoco = new PocoWithReadOnlyProperty + { + Name = "" // Required but empty (ReadOnlyValue should be ignored) + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidPoco) + }; + + await validatableInfo.ValidateAsync(invalidPoco, context, default); + + Assert.NotNull(context.ValidationErrors); + var error = Assert.Single(context.ValidationErrors); + Assert.Equal("Name", error.Key); + Assert.Equal("The Name field is required.", error.Value.First()); + // ReadOnlyValue should not generate validation errors even though it has [Required] + + // Test validation with valid writable property + var validPoco = new PocoWithReadOnlyProperty + { + Name = "Valid Name" // ReadOnlyValue is always "ReadOnly" and should be ignored + }; + + var validContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(validPoco) + }; + + await validatableInfo.ValidateAsync(validPoco, validContext, default); + + Assert.Null(validContext.ValidationErrors); // No validation errors for valid data + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithRecord_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(SimpleRecordWithValidation), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + // Test validation with invalid record data + var invalidRecord = new SimpleRecordWithValidation("", 150); // Empty name, out of range age + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidRecord) + }; + + await validatableInfo.ValidateAsync(invalidRecord, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("Age", kvp.Key); + Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First()); + }); + + // Test validation with valid record data + var validRecord = new SimpleRecordWithValidation("John Doe", 25); + + var validContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(validRecord) + }; + + await validatableInfo.ValidateAsync(validRecord, validContext, default); + + Assert.Null(validContext.ValidationErrors); + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithRecordContainingComplexProperty_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(RecordWithComplexProperty), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + // Test validation with invalid nested data in record + var invalidRecord = new RecordWithComplexProperty( + "", + new SimplePocoWithValidation { Name = "", Age = 150 }); + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidRecord) + }; + + await validatableInfo.ValidateAsync(invalidRecord, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("ComplexProperty.Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("ComplexProperty.Age", kvp.Key); + Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First()); + }); + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithIValidatableObject_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(ValidatableObject), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + // Test attribute validation fails first, then IValidatableObject.Validate is called + var invalidObject = new ValidatableObject + { + Name = "", // Required but empty - attribute validation + Value = 150 // Out of range - attribute validation + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidObject) + }; + + await validatableInfo.ValidateAsync(invalidObject, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("Value", kvp.Key); + Assert.Equal("The field Value must be between 0 and 100.", kvp.Value.First()); + }); + + // Test IValidatableObject.Validate custom logic + var customInvalidObject = new ValidatableObject + { + Name = "Invalid", // Triggers custom validation + Value = 25 + }; + + var customContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(customInvalidObject) + }; + + await validatableInfo.ValidateAsync(customInvalidObject, customContext, default); + + Assert.NotNull(customContext.ValidationErrors); + var error = Assert.Single(customContext.ValidationErrors); + Assert.Equal("Name", error.Key); + Assert.Equal("Name cannot be 'Invalid'", error.Value.First()); + + // Test complex IValidatableObject logic with multiple properties + var multiPropertyInvalidObject = new ValidatableObject + { + Name = "Joe", // Valid but short (< 5 chars) + Value = 75 // Valid range but > 50, triggers multi-property validation + }; + + var multiContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(multiPropertyInvalidObject) + }; + + await validatableInfo.ValidateAsync(multiPropertyInvalidObject, multiContext, default); + + Assert.NotNull(multiContext.ValidationErrors); + Assert.Equal(2, multiContext.ValidationErrors.Count); + Assert.True(multiContext.ValidationErrors.ContainsKey("Name")); + Assert.True(multiContext.ValidationErrors.ContainsKey("Value")); + Assert.Equal("When Value > 50, Name must be at least 5 characters", multiContext.ValidationErrors["Name"].First()); + Assert.Equal("When Value > 50, Name must be at least 5 characters", multiContext.ValidationErrors["Value"].First()); + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithNestedIValidatableObject_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithValidatableObject), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + var invalidObject = new PocoWithValidatableObject + { + Title = "", // Required but empty + ValidatableProperty = new ValidatableObject + { + Name = "Invalid", // Triggers custom validation + Value = 25 + } + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidObject) + }; + + await validatableInfo.ValidateAsync(invalidObject, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Title", kvp.Key); + Assert.Equal("The Title field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("ValidatableProperty.Name", kvp.Key); + Assert.Equal("Name cannot be 'Invalid'", kvp.Value.First()); + }); + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithCustomValidationAttribute_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithCustomValidation), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + var invalidObject = new PocoWithCustomValidation + { + Name = "", // Required but empty + EvenValue = 3, // Odd number - custom validation fails + MultipleAttributesValue = -1 // Odd number and out of range (-1 is not in range 1-100) + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidObject) + }; + + await validatableInfo.ValidateAsync(invalidObject, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("EvenValue", kvp.Key); + Assert.Equal("The field EvenValue must be an even number.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("MultipleAttributesValue", kvp.Key); + Assert.Equal(2, kvp.Value.Count()); + Assert.Contains(kvp.Value, error => error == "The field MultipleAttributesValue must be between 1 and 100."); + Assert.Contains(kvp.Value, error => error == "The field MultipleAttributesValue must be an even number."); + }); + + // Test valid custom validation + var validObject = new PocoWithCustomValidation + { + Name = "Valid Name", + EvenValue = 4, + MultipleAttributesValue = 50 + }; + + var validContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(validObject) + }; + + await validatableInfo.ValidateAsync(validObject, validContext, default); + + Assert.Null(validContext.ValidationErrors); + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithStringValidationAttributes_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithStringValidation), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + var invalidObject = new PocoWithStringValidation + { + Name = "AB", // Too short (min 3, max 10) + Email = "invalid-email", + Website = "not-a-url", + Phone = "123-456" // Invalid format + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidObject) + }; + + await validatableInfo.ValidateAsync(invalidObject, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Contains("length", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("Email", kvp.Key); + Assert.Contains("valid e-mail", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("Website", kvp.Key); + Assert.Contains("valid", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("Phone", kvp.Key); + Assert.Equal("Phone must be in format XXX-XXX-XXXX", kvp.Value.First()); + }); + + // Test valid string validation + var validObject = new PocoWithStringValidation + { + Name = "Valid Name", + Email = "test@example.com", + Website = "https://example.com", + Phone = "123-456-7890" + }; + + var validContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(validObject) + }; + + await validatableInfo.ValidateAsync(validObject, validContext, default); + + Assert.Null(validContext.ValidationErrors); + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithRangeValidationDifferentTypes_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithRangeValidation), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + var invalidObject = new PocoWithRangeValidation + { + DecimalValue = 150.75m, // Out of range (0.1 - 100.5) + DateValue = new DateTime(2024, 6, 1), // Out of range (2023 only) + DateOnlyValue = new DateOnly(2024, 6, 1) // Out of range (2023 only) + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidObject) + }; + + await validatableInfo.ValidateAsync(invalidObject, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Equal(3, context.ValidationErrors.Count); + Assert.All(context.ValidationErrors, kvp => Assert.Contains("must be between", kvp.Value.First())); + + // Test valid range validation + var validObject = new PocoWithRangeValidation + { + DecimalValue = 50.25m, + DateValue = new DateTime(2023, 6, 1), + DateOnlyValue = new DateOnly(2023, 6, 1) + }; + + var validContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(validObject) + }; + + await validatableInfo.ValidateAsync(validObject, validContext, default); + + Assert.Null(validContext.ValidationErrors); + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithDisplayAttributes_ReturnsTrue_AndUsesDisplayNamesInErrors() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithDisplayAttributes), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + var invalidObject = new PocoWithDisplayAttributes + { + Name = "", // Required but empty + Age = 150 // Out of range + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidObject) + }; + + await validatableInfo.ValidateAsync(invalidObject, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Full Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("Age", kvp.Key); + Assert.Equal("The field User Age must be between 0 and 100.", kvp.Value.First()); + }); + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithCustomValidationMethod_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithCustomValidationMethod), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + var invalidObject = new PocoWithCustomValidationMethod + { + FirstName = "John", + LastName = "Doe", + FullName = "Jane Smith" // Doesn't match FirstName + LastName + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidObject) + }; + + await validatableInfo.ValidateAsync(invalidObject, context, default); + + Assert.NotNull(context.ValidationErrors); + var error = Assert.Single(context.ValidationErrors); + Assert.Equal("FullName", error.Key); + Assert.Equal("FullName must be 'John Doe'", error.Value.First()); + + // Test valid custom validation method + var validObject = new PocoWithCustomValidationMethod + { + FirstName = "John", + LastName = "Doe", + FullName = "John Doe" + }; + + var validContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(validObject) + }; + + await validatableInfo.ValidateAsync(validObject, validContext, default); + + Assert.Null(validContext.ValidationErrors); + } + + [Fact] + public async Task TryGetValidatableTypeInfo_WithArrayValidation_ReturnsTrue_AndValidatesCorrectly() + { + var result = _resolver.TryGetValidatableTypeInfo(typeof(PocoWithArrayValidation), out var validatableInfo); + + Assert.True(result); + Assert.NotNull(validatableInfo); + + var invalidObject = new PocoWithArrayValidation + { + Name = "", // Required but empty + Items = new[] + { + new SimplePocoWithValidation { Name = "Valid", Age = 25 }, + new SimplePocoWithValidation { Name = "", Age = 150 }, // Invalid item + new SimplePocoWithValidation { Name = "Another Valid", Age = 30 } + } + }; + + var validationOptions = new ValidationOptions(); + validationOptions.Resolvers.Add(_resolver); + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(invalidObject) + }; + + await validatableInfo.ValidateAsync(invalidObject, context, default); + + Assert.NotNull(context.ValidationErrors); + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("Items[1].Name", kvp.Key); + Assert.Equal("The Name field is required.", kvp.Value.First()); + }, + kvp => + { + Assert.Equal("Items[1].Age", kvp.Key); + Assert.Equal("The field Age must be between 0 and 100.", kvp.Value.First()); + }); + + // Test valid array validation + var validObject = new PocoWithArrayValidation + { + Name = "Valid Name", + Items = new[] + { + new SimplePocoWithValidation { Name = "Item 1", Age = 25 }, + new SimplePocoWithValidation { Name = "Item 2", Age = 30 } + } + }; + + var validContext = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(validObject) + }; + + await validatableInfo.ValidateAsync(validObject, validContext, default); + + Assert.Null(validContext.ValidationErrors); + } + + // Helper method for parameter test + private void SampleMethod(string parameter) { } + + // Test classes + public enum SampleEnum + { + Value1, + Value2 + } + + public class SimplePocoWithValidation + { + [Required] + public string Name { get; set; } = string.Empty; + + [Range(0, 100)] + public int Age { get; set; } + } + + public class SimplePocoWithoutValidation + { + public string Name { get; set; } = string.Empty; + public int Age { get; set; } + } + + public class PocoWithNestedType + { + [Required] + public string Name { get; set; } = string.Empty; + + public SimplePocoWithValidation NestedPoco { get; set; } = new(); + } + + public class CyclicTypeA + { + [Required] + public string Name { get; set; } = string.Empty; + + public CyclicTypeB? TypeB { get; set; } + } + + public class CyclicTypeB + { + [Required] + public string Value { get; set; } = string.Empty; + + public CyclicTypeA? TypeA { get; set; } + } + + public class PocoWithCollection + { + [Required] + public string Name { get; set; } = string.Empty; + + public List Items { get; set; } = new(); + } + + public class PocoWithReadOnlyProperty + { + [Required] + public string Name { get; set; } = string.Empty; + + [Required] + public string ReadOnlyValue { get; } = "ReadOnly"; + } + + // Test record types + public record SimpleRecordWithValidation( + [Required] string Name, + [Range(0, 100)] int Age); + + public record RecordWithComplexProperty( + [Required] string Name, + SimplePocoWithValidation ComplexProperty); + + // Test IValidatableObject implementations + public class ValidatableObject : IValidatableObject + { + [Required] + public string Name { get; set; } = string.Empty; + + [Range(0, 100)] + public int Value { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Name == "Invalid") + { + yield return new ValidationResult("Name cannot be 'Invalid'", new[] { nameof(Name) }); + } + + if (Value > 50 && Name?.Length < 5) + { + yield return new ValidationResult("When Value > 50, Name must be at least 5 characters", new[] { nameof(Name), nameof(Value) }); + } + } + } + + public class PocoWithValidatableObject + { + [Required] + public string Title { get; set; } = string.Empty; + + public ValidatableObject ValidatableProperty { get; set; } = new(); + } + + // Test custom validation attributes + public class EvenNumberAttribute : ValidationAttribute + { + public override bool IsValid(object? value) + { + if (value is int number) + { + return number % 2 == 0; + } + return true; + } + + public override string FormatErrorMessage(string name) + { + return $"The field {name} must be an even number."; + } + } + + public class PocoWithCustomValidation + { + [Required] + public string Name { get; set; } = string.Empty; + + [EvenNumber] + public int EvenValue { get; set; } + + [Range(1, 100), EvenNumber] + public int MultipleAttributesValue { get; set; } + } + + // Test string-specific validation attributes + public class PocoWithStringValidation + { + [Required] + [StringLength(10, MinimumLength = 3)] + public string Name { get; set; } = string.Empty; + + [EmailAddress] + public string? Email { get; set; } + + [Url] + public string? Website { get; set; } + + [RegularExpression(@"^\d{3}-\d{3}-\d{4}$", ErrorMessage = "Phone must be in format XXX-XXX-XXXX")] + public string? Phone { get; set; } + } + + // Test range validation with different data types + public class PocoWithRangeValidation + { + [Range(0.1, 100.5)] + public decimal DecimalValue { get; set; } + + [Range(typeof(DateTime), "2023-01-01", "2023-12-31")] + public DateTime DateValue { get; set; } + + [Range(typeof(DateOnly), "2023-01-01", "2023-12-31")] + public DateOnly DateOnlyValue { get; set; } + } + + // Test Display attribute handling + public class PocoWithDisplayAttributes + { + [Required] + [Display(Name = "Full Name")] + public string Name { get; set; } = string.Empty; + + [Range(0, 100)] + [Display(Name = "User Age", Description = "Age in years")] + public int Age { get; set; } + } + + // Test CustomValidation attribute + public class PocoWithCustomValidationMethod + { + [Required] + public string FirstName { get; set; } = string.Empty; + + [Required] + public string LastName { get; set; } = string.Empty; + + [CustomValidation(typeof(PocoWithCustomValidationMethod), nameof(ValidateFullName))] + public string FullName { get; set; } = string.Empty; + + public static ValidationResult? ValidateFullName(string fullName, ValidationContext context) + { + if (context.ObjectInstance is PocoWithCustomValidationMethod instance) + { + var expectedFullName = $"{instance.FirstName} {instance.LastName}"; + if (fullName != expectedFullName) + { + return new ValidationResult($"FullName must be '{expectedFullName}'", new[] { context.MemberName! }); + } + } + return ValidationResult.Success; + } + } + + // Test array validation + public class PocoWithArrayValidation + { + [Required] + public string Name { get; set; } = string.Empty; + + public SimplePocoWithValidation[]? Items { get; set; } + } + + // Test service-like type that should be excluded from validation + public class TestService + { + [Range(10, 100)] + public int Value { get; set; } = 4; + + [Required] + public string Name { get; set; } = string.Empty; + } +}