From 043ac1daeb5f10285f4d53b928c68996a35ea1c6 Mon Sep 17 00:00:00 2001 From: damianedwards Date: Thu, 21 May 2026 13:56:38 -0700 Subject: [PATCH] Add field validation support Support validating public fields alongside public properties, including recursive validation and readonly struct fields. Bump package version to 0.11.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- src/Directory.Build.props | 2 +- src/MiniValidation/MiniValidator.cs | 84 +++++---- src/MiniValidation/PropertyHelper.cs | 19 ++ src/MiniValidation/SkipRecursionAttribute.cs | 4 +- src/MiniValidation/TypeDetailsCache.cs | 172 +++++++++++------- tests/MiniValidation.UnitTests/Recursion.cs | 52 ++++++ tests/MiniValidation.UnitTests/TestTypes.cs | 34 ++++ tests/MiniValidation.UnitTests/TryValidate.cs | 71 ++++++++ 9 files changed, 331 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 483377b..3eaf7bf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # MiniValidation -A minimalistic validation library built atop the existing features in .NET's `System.ComponentModel.DataAnnotations` namespace. Adds support for single-line validation calls and recursion with cycle detection. +A minimalistic validation library built atop the existing features in .NET's `System.ComponentModel.DataAnnotations` namespace. Adds support for single-line validation calls for public properties and fields, plus recursion with cycle detection. Supports .NET Standard 2.0 compliant runtimes. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3ce316f..4032f67 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,7 @@ - 0.10.0 + 0.11.0 dev diff --git a/src/MiniValidation/MiniValidator.cs b/src/MiniValidation/MiniValidator.cs index 99c8f36..96380c3 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -10,7 +10,7 @@ namespace MiniValidation; /// -/// Contains methods and properties for performing validation operations with on objects whos properties +/// Contains methods for performing validation operations with on objects whose public properties or fields /// are decorated with s. /// public static class MiniValidator @@ -44,7 +44,7 @@ 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).Members.Any(m => m.HasValidationAttributes || recurse); } /// @@ -389,46 +389,46 @@ private static async Task TryValidateImpl( // Add current target to tracking dictionary in null (validating) state validatedObjects.Add(target, null); - var (typeProperties, _) = _typeDetailsCache.Get(targetType); + var (typeMembers, _) = _typeDetailsCache.Get(targetType); var isValid = true; - var propertiesToRecurse = recurse ? new Dictionary() : null; + var membersToRecurse = recurse ? new Dictionary() : null; var validationContext = new ValidationContext(target, serviceProvider: serviceProvider, items: null); - foreach (var property in typeProperties) + foreach (var member in typeMembers) { - // Skip properties that don't have validation attributes if we're not recursing - if (!(property.HasValidationAttributes || recurse)) + // Skip members that don't have validation attributes if we're not recursing + if (!(member.HasValidationAttributes || recurse)) { continue; } - var propertyValue = property.GetValue(target); - var propertyValueType = propertyValue?.GetType(); - var (properties, _) = _typeDetailsCache.Get(propertyValueType); + var memberValue = member.GetValue(target); + var memberValueType = memberValue?.GetType(); + var (members, _) = _typeDetailsCache.Get(memberValueType); - if (property.HasValidationAttributes) + if (member.HasValidationAttributes) { - validationContext.MemberName = property.Name; - validationContext.DisplayName = GetDisplayName(property); + validationContext.MemberName = member.Name; + validationContext.DisplayName = GetDisplayName(member); validationResults ??= new(); - var propertyIsValid = Validator.TryValidateValue(propertyValue!, validationContext, validationResults, property.ValidationAttributes); + var memberIsValid = Validator.TryValidateValue(memberValue!, validationContext, validationResults, member.ValidationAttributes); - if (!propertyIsValid) + if (!memberIsValid) { - ProcessValidationResults(property.Name, validationResults, workingErrors, prefix); + ProcessValidationResults(member.Name, validationResults, workingErrors, prefix); isValid = false; } } - if (recurse && propertyValue is not null && - !TypeDetailsCache.IsNonValidatableType(propertyValueType!) && - (property.Recurse - || typeof(IValidatableObject).IsAssignableFrom(propertyValueType) - || typeof(IAsyncValidatableObject).IsAssignableFrom(propertyValueType) - || properties.Any(p => p.Recurse))) + if (recurse && memberValue is not null && + !TypeDetailsCache.IsNonValidatableType(memberValueType!) && + (member.Recurse + || typeof(IValidatableObject).IsAssignableFrom(memberValueType) + || typeof(IAsyncValidatableObject).IsAssignableFrom(memberValueType) + || members.Any(p => p.Recurse))) { - propertiesToRecurse!.Add(property, propertyValue); + membersToRecurse!.Add(member, memberValue); } } @@ -455,23 +455,23 @@ private static async Task TryValidateImpl( isValid = await validateTask.ConfigureAwait(false) && isValid; } - // Validate complex properties - if (propertiesToRecurse!.Count > 0) + // Validate complex members + if (membersToRecurse!.Count > 0) { - foreach (var property in propertiesToRecurse) + foreach (var member in membersToRecurse) { - var propertyDetails = property.Key; - var propertyValue = property.Value; + var memberDetails = member.Key; + var memberValue = member.Value; - if (propertyValue != null) + if (memberValue != null) { RuntimeHelpers.EnsureSufficientExecutionStack(); - if (propertyDetails.IsEnumerable && propertyValue is IEnumerable propertyValues) + if (memberDetails.IsEnumerable && memberValue is IEnumerable memberValues) { - var thePrefix = $"{prefix}{propertyDetails.Name}"; + var thePrefix = $"{prefix}{memberDetails.Name}"; - var validateTask = TryValidateEnumerable(propertyValues, serviceProvider, recurse, allowAsync, workingErrors, validatedObjects, validationResults, thePrefix, currentDepth); + var validateTask = TryValidateEnumerable(memberValues, serviceProvider, recurse, allowAsync, workingErrors, validatedObjects, validationResults, thePrefix, currentDepth); try { ThrowIfAsyncNotAllowed(validateTask.IsCompleted, allowAsync); @@ -485,11 +485,11 @@ private static async Task TryValidateImpl( isValid = await validateTask.ConfigureAwait(false) && isValid; } - else if (!propertyDetails.IsEnumerable) + else if (!memberDetails.IsEnumerable) { - var thePrefix = $"{prefix}{propertyDetails.Name}."; // <-- Note trailing '.' here + var thePrefix = $"{prefix}{memberDetails.Name}."; // <-- Note trailing '.' here - var validateTask = TryValidateImpl(propertyValue, serviceProvider, recurse, allowAsync, workingErrors, validatedObjects, validationResults, thePrefix, currentDepth + 1); + var validateTask = TryValidateImpl(memberValue, serviceProvider, recurse, allowAsync, workingErrors, validatedObjects, validationResults, thePrefix, currentDepth + 1); try { ThrowIfAsyncNotAllowed(validateTask.IsCompleted, allowAsync); @@ -542,9 +542,9 @@ private static async Task TryValidateImpl( return isValid; - static string GetDisplayName(PropertyDetails property) + static string GetDisplayName(MemberDetails member) { - return property.DisplayAttribute?.GetName() ?? property.Name; + return member.DisplayAttribute?.GetName() ?? member.Name; } } @@ -670,21 +670,25 @@ static string GetClassLevelKey(string? prefix) } } - private static void ProcessValidationResults(string propertyName, ICollection validationResults, Dictionary> errors, string? prefix) + private static void ProcessValidationResults(string memberName, ICollection validationResults, Dictionary> errors, string? prefix) { if (validationResults.Count == 0) { return; } - var errorsList = new List(validationResults.Count); + var key = $"{prefix}{memberName}"; + if (!errors.TryGetValue(key, out var errorsList)) + { + errorsList = new List(validationResults.Count); + errors.Add(key, errorsList); + } foreach (var result in validationResults) { errorsList.Add(result.ErrorMessage ?? ""); } - errors.Add($"{prefix}{propertyName}", errorsList); validationResults.Clear(); } } diff --git a/src/MiniValidation/PropertyHelper.cs b/src/MiniValidation/PropertyHelper.cs index 74a1e90..ad72674 100644 --- a/src/MiniValidation/PropertyHelper.cs +++ b/src/MiniValidation/PropertyHelper.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; +using System.Linq.Expressions; using System.Reflection; namespace MiniValidation @@ -37,6 +38,24 @@ internal static class PropertyHelper CallNullSafePropertyGetterByReferenceOpenGenericMethod); } + public static Func MakeNullSafeFastFieldGetter(FieldInfo fieldInfo) + { + Debug.Assert(fieldInfo != null); + Debug.Assert(!fieldInfo!.IsStatic); + + var target = Expression.Parameter(typeof(object), "target"); + var body = Expression.Condition( + Expression.Equal(target, Expression.Constant(null)), + Expression.Constant(null, typeof(object)), + Expression.Convert( + Expression.Field( + Expression.Convert(target, fieldInfo.DeclaringType!), + fieldInfo), + typeof(object))); + + return Expression.Lambda>(body, target).Compile(); + } + private static Func MakeFastPropertyGetter( PropertyInfo propertyInfo, MethodInfo propertyGetterWrapperMethod, diff --git a/src/MiniValidation/SkipRecursionAttribute.cs b/src/MiniValidation/SkipRecursionAttribute.cs index 3e4287a..f80f23c 100644 --- a/src/MiniValidation/SkipRecursionAttribute.cs +++ b/src/MiniValidation/SkipRecursionAttribute.cs @@ -3,9 +3,9 @@ namespace MiniValidation; /// -/// Indicates that a property should be ignored during recursive validation when using +/// Indicates that a property or field should be ignored during recursive validation when using /// . -/// Note that any validation attributes on the property itself will still be validated. +/// Note that any validation attributes on the property or field itself will still be validated. /// [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] public class SkipRecursionAttribute : Attribute diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index 2086de3..c858607 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -11,8 +11,8 @@ namespace MiniValidation; internal class TypeDetailsCache { - private static readonly PropertyDetails[] _emptyPropertyDetails = Array.Empty(); - private readonly ConcurrentDictionary _cache = new(); + private static readonly MemberDetails[] _emptyMemberDetails = Array.Empty(); + private readonly ConcurrentDictionary _cache = new(); public TypeDetailsCache() { @@ -29,14 +29,14 @@ public TypeDetailsCache() }; } - public (PropertyDetails[] Properties, bool RequiresAsync) Get(Type? type) + public (MemberDetails[] Members, bool RequiresAsync) Get(Type? type) { if (type is null) { - return (_emptyPropertyDetails, false); + return (_emptyMemberDetails, false); } - (PropertyDetails[] Properties, bool RequiresAsync) details; + (MemberDetails[] Members, bool RequiresAsync) details; while (!_cache.TryGetValue(type, out details)) { Visit(type); @@ -64,9 +64,9 @@ private void Visit(Type type, HashSet visited, ref bool requiresAsync) return; } - if (DoNotRecurseIntoPropertiesOf(type) || IsNonValidatableType(type)) + if (DoNotRecurseIntoMembersOf(type) || IsNonValidatableType(type)) { - _cache[type] = (_emptyPropertyDetails, false); + _cache[type] = (_emptyMemberDetails, false); return; } @@ -89,9 +89,9 @@ private void Visit(Type type, HashSet visited, ref bool requiresAsync) } } - List? propertiesToValidate = null; - var hasPropertiesOfOwnType = false; - var hasValidatableProperties = false; + List? membersToValidate = null; + var hasMembersOfOwnType = false; + var hasValidatableMembers = false; foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy)) { @@ -102,69 +102,101 @@ private void Visit(Type type, HashSet visited, ref bool requiresAsync) } var (validationAttributes, displayAttribute, skipRecursionAttribute) = TypeDetailsCache.GetPropertyAttributes(primaryCtorParams, property); + VisitMember( + property.Name, + property.PropertyType, + PropertyHelper.MakeNullSafeFastPropertyGetter(property), + validationAttributes, + displayAttribute, + skipRecursionAttribute, + ref requiresAsync); + } + + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy)) + { + var (validationAttributes, displayAttribute, skipRecursionAttribute) = TypeDetailsCache.GetFieldAttributes(field); + VisitMember( + field.Name, + field.FieldType, + PropertyHelper.MakeNullSafeFastFieldGetter(field), + validationAttributes, + displayAttribute, + skipRecursionAttribute, + ref requiresAsync); + } + + if (hasMembersOfOwnType && membersToValidate != null) + { + // Remove members of same type if there's nothing to validate on them + for (var i = membersToValidate.Count - 1; i >= 0; i--) + { + var member = membersToValidate[i]; + var enumerableTypeHasMembers = member.EnumerableType != null + && _cache.TryGetValue(member.EnumerableType, out var typeCache) + && typeCache.Members.Length > 0; + var keepMember = member.Type != type || (hasValidatableMembers || enumerableTypeHasMembers); + if (!keepMember) + { + membersToValidate.RemoveAt(i); + } + } + } + + _cache[type] = (membersToValidate?.ToArray() ?? _emptyMemberDetails, requiresAsync); + + void VisitMember( + string memberName, + Type memberType, + Func memberGetter, + ValidationAttribute[]? validationAttributes, + DisplayAttribute? displayAttribute, + SkipRecursionAttribute? skipRecursionAttribute, + ref bool requiresAsync) + { validationAttributes ??= Array.Empty(); - var hasValidationOnProperty = validationAttributes.Length > 0; - var hasSkipRecursionOnProperty = skipRecursionAttribute is not null; - var propertyTypeIsNonValidatable = IsNonValidatableType(property.PropertyType); - var enumerableType = GetEnumerableType(property.PropertyType); + var hasValidationOnMember = validationAttributes.Length > 0; + var hasSkipRecursionOnMember = skipRecursionAttribute is not null; + var memberTypeIsNonValidatable = IsNonValidatableType(memberType); + var enumerableType = GetEnumerableType(memberType); if (enumerableType != null) { Visit(enumerableType, visited, ref requiresAsync); } - // Defer fully checking properties that are of the same type we're currently building the cache for. - // We'll remove them at the end if any other validatable properties are present. - if (type == property.PropertyType && !hasSkipRecursionOnProperty) + // Defer fully checking members that are of the same type we're currently building the cache for. + // We'll remove them at the end if any other validatable members are present. + if (type == memberType && !hasSkipRecursionOnMember) { - propertiesToValidate ??= new List(); - propertiesToValidate.Add(new(property.Name, displayAttribute, property.PropertyType, PropertyHelper.MakeNullSafeFastPropertyGetter(property), validationAttributes, true, enumerableType)); - hasPropertiesOfOwnType = true; - continue; + membersToValidate ??= new List(); + membersToValidate.Add(new(memberName, displayAttribute, memberType, memberGetter, validationAttributes, true, enumerableType)); + hasMembersOfOwnType = true; + return; } - Visit(property.PropertyType, visited, ref requiresAsync); - var propertyTypeHasProperties = _cache.TryGetValue(property.PropertyType, out var typeCache) && typeCache.Properties.Length > 0; - var propertyTypeIsValidatableObject = typeof(IValidatableObject).IsAssignableFrom(property.PropertyType) - || typeof(IAsyncValidatableObject).IsAssignableFrom(property.PropertyType); - var propertyTypeSupportsPolymorphism = !propertyTypeIsNonValidatable && !property.PropertyType.IsSealed; - var enumerableTypeHasProperties = enumerableType != null - && _cache.TryGetValue(enumerableType, out var enumProperties) - && enumProperties.Properties.Length > 0; - var recurse = !propertyTypeIsNonValidatable - && (enumerableTypeHasProperties || propertyTypeHasProperties - || propertyTypeIsValidatableObject - || propertyTypeSupportsPolymorphism) - && !hasSkipRecursionOnProperty; - - if (recurse || hasValidationOnProperty) + Visit(memberType, visited, ref requiresAsync); + var memberTypeHasMembers = _cache.TryGetValue(memberType, out var typeCache) && typeCache.Members.Length > 0; + var memberTypeIsValidatableObject = typeof(IValidatableObject).IsAssignableFrom(memberType) + || typeof(IAsyncValidatableObject).IsAssignableFrom(memberType); + var memberTypeSupportsPolymorphism = !memberTypeIsNonValidatable && !memberType.IsSealed; + var enumerableTypeHasMembers = enumerableType != null + && _cache.TryGetValue(enumerableType, out var enumMembers) + && enumMembers.Members.Length > 0; + var recurse = !memberTypeIsNonValidatable + && (enumerableTypeHasMembers || memberTypeHasMembers + || memberTypeIsValidatableObject + || memberTypeSupportsPolymorphism) + && !hasSkipRecursionOnMember; + + if (recurse || hasValidationOnMember) { - propertiesToValidate ??= new List(); - propertiesToValidate.Add(new(property.Name, displayAttribute, property.PropertyType, PropertyHelper.MakeNullSafeFastPropertyGetter(property), validationAttributes, recurse, enumerableTypeHasProperties ? enumerableType : null)); - hasValidatableProperties = true; + membersToValidate ??= new List(); + membersToValidate.Add(new(memberName, displayAttribute, memberType, memberGetter, validationAttributes, recurse, enumerableTypeHasMembers ? enumerableType : null)); + hasValidatableMembers = true; } } - - if (hasPropertiesOfOwnType && propertiesToValidate != null) - { - // Remove properties of same type if there's nothing to validate on them - for (var i = propertiesToValidate.Count - 1; i >= 0; i--) - { - var property = propertiesToValidate[i]; - var enumerableTypeHasProperties = property.EnumerableType != null - && _cache.TryGetValue(property.EnumerableType, out var typeCache) - && typeCache.Properties.Length > 0; - var keepProperty = property.Type != type || (hasValidatableProperties || enumerableTypeHasProperties); - if (!keepProperty) - { - propertiesToValidate.RemoveAt(i); - } - } - } - - _cache[type] = (propertiesToValidate?.ToArray() ?? _emptyPropertyDetails, requiresAsync); } - private static bool DoNotRecurseIntoPropertiesOf(Type type) => + private static bool DoNotRecurseIntoMembersOf(Type type) => type == typeof(object) || type.IsPrimitive || type.IsArray @@ -204,10 +236,6 @@ private static bool IsKnownNonValidatableFrameworkType(Type type) private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribute?) GetPropertyAttributes(ParameterInfo[]? primaryCtorParameters, PropertyInfo property) { - List? validationAttributes = null; - DisplayAttribute? displayAttribute = null; - SkipRecursionAttribute? skipRecursionAttribute = null; - IEnumerable? paramAttributes = null; if (primaryCtorParameters is { } ctorParams) { @@ -236,6 +264,20 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut .Where(attr => !IsDuplicateTypeDescriptorAttribute(attr, propertyAttributes))); } + return GetValidationMetadata(customAttributes); + } + + private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribute?) GetFieldAttributes(FieldInfo field) + { + return GetValidationMetadata(field.GetCustomAttributes().Cast()); + } + + private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribute?) GetValidationMetadata(IEnumerable customAttributes) + { + List? validationAttributes = null; + DisplayAttribute? displayAttribute = null; + SkipRecursionAttribute? skipRecursionAttribute = null; + foreach (var attr in customAttributes) { if (attr is ValidationAttribute validationAttr) @@ -402,9 +444,9 @@ private static bool TryGetAttributesViaTypeDescriptor(PropertyInfo property, [No } } -internal record PropertyDetails(string Name, DisplayAttribute? DisplayAttribute, Type Type, Func PropertyGetter, ValidationAttribute[] ValidationAttributes, bool Recurse, Type? EnumerableType) +internal record MemberDetails(string Name, DisplayAttribute? DisplayAttribute, Type Type, Func MemberGetter, ValidationAttribute[] ValidationAttributes, bool Recurse, Type? EnumerableType) { - public object? GetValue(object target) => PropertyGetter(target); + public object? GetValue(object target) => MemberGetter(target); public bool IsEnumerable => EnumerableType != null; diff --git a/tests/MiniValidation.UnitTests/Recursion.cs b/tests/MiniValidation.UnitTests/Recursion.cs index 169374a..571ca42 100644 --- a/tests/MiniValidation.UnitTests/Recursion.cs +++ b/tests/MiniValidation.UnitTests/Recursion.cs @@ -64,6 +64,43 @@ public void Valid_When_Child_Invalid_And_Property_Decorated_With_SkipRecursion() Assert.Empty(errors); } + [Fact] + public void Invalid_When_Field_Child_Invalid_And_Recurse_Default() + { + var thingToValidate = new TestTypeWithFields { FieldChild = new TestChildType { RequiredCategory = null, MinLengthFive = "123" } }; + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.False(result); + Assert.Equal(2, errors.Count); + Assert.Collection(errors, + entry => Assert.Equal($"{nameof(TestTypeWithFields.FieldChild)}.{nameof(TestChildType.RequiredCategory)}", entry.Key), + entry => Assert.Equal($"{nameof(TestTypeWithFields.FieldChild)}.{nameof(TestChildType.MinLengthFive)}", entry.Key) + ); + } + + [Fact] + public void Valid_When_Field_Child_Invalid_And_Recurse_False() + { + var thingToValidate = new TestTypeWithFields { FieldChild = new TestChildType { RequiredCategory = null, MinLengthFive = "123" } }; + + var result = MiniValidator.TryValidate(thingToValidate, recurse: false, out var errors); + + Assert.True(result); + Assert.Empty(errors); + } + + [Fact] + public void Valid_When_Field_Child_Invalid_And_Field_Decorated_With_SkipRecursion() + { + var thingToValidate = new TestTypeWithFields { SkippedFieldChild = new TestChildType { RequiredCategory = null, MinLengthFive = "123" } }; + + var result = MiniValidator.TryValidate(thingToValidate, recurse: true, out var errors); + + Assert.True(result); + Assert.Empty(errors); + } + [Fact] public void Invalid_When_Enumerable_Item_Invalid_When_Recurse_Default() { @@ -191,6 +228,21 @@ public void All_Errors_In_Descendant_Enumerable_Are_Reported() Assert.Contains($"{nameof(TestType.Children)}[1].{nameof(TestChildType.RequiredCategory)}", errors.Keys); } + [Fact] + public void All_Errors_In_Descendant_Enumerable_Field_Are_Reported() + { + var thingToValidate = new TestTypeWithFields(); + thingToValidate.FieldChildren.Add(new() { MinLengthFive = "123" }); + thingToValidate.FieldChildren.Add(new() { RequiredCategory = null }); + + var result = MiniValidator.TryValidate(thingToValidate, recurse: true, out var errors); + + Assert.False(result); + Assert.Equal(2, errors.Count); + Assert.Contains($"{nameof(TestTypeWithFields.FieldChildren)}[0].{nameof(TestChildType.MinLengthFive)}", errors.Keys); + Assert.Contains($"{nameof(TestTypeWithFields.FieldChildren)}[1].{nameof(TestChildType.RequiredCategory)}", errors.Keys); + } + [Fact] public void Class_Level_Errors_In_Root_Enumerable_Are_Keyed_By_Item() { diff --git a/tests/MiniValidation.UnitTests/TestTypes.cs b/tests/MiniValidation.UnitTests/TestTypes.cs index bc55c88..540bf82 100644 --- a/tests/MiniValidation.UnitTests/TestTypes.cs +++ b/tests/MiniValidation.UnitTests/TestTypes.cs @@ -246,6 +246,40 @@ public TestStruct() public int TenOrMore { get; set; } = 10; } +class TestTypeWithFields +{ + [Required] + public string? RequiredField = "Default"; + + [Required, Display(Name = "Required field")] + public string? RequiredFieldWithDisplay = "Default"; + + [Range(10, 100)] + public int TenOrMoreField = 10; + + public TestChildType FieldChild = new(); + + [SkipRecursion] + public TestChildType SkippedFieldChild = new(); + + public IList FieldChildren = new List(); +} + +readonly struct TestReadonlyStructWithFields +{ + public TestReadonlyStructWithFields(string? requiredField = "Default", int tenOrMoreField = 10) + { + RequiredField = requiredField; + TenOrMoreField = tenOrMoreField; + } + + [Required] + public readonly string? RequiredField; + + [Range(10, 100)] + public readonly int TenOrMoreField; +} + interface IAnInterface { } #if NET6_0_OR_GREATER diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index 70a1759..5d578d3 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -50,6 +50,31 @@ public void RequiredValidator_Valid_When_NonEmpty_Value() Assert.Empty(errors); } + [Fact] + public void RequiredValidator_Invalid_When_Field_Null() + { + var thingToValidate = new TestTypeWithFields { RequiredField = null }; + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.False(result); + var entry = Assert.Single(errors); + Assert.Equal(nameof(TestTypeWithFields.RequiredField), entry.Key); + } + + [Fact] + public void Validator_DisplayAttribute_Name_Used_In_Error_Message_For_Field() + { + var thingToValidate = new TestTypeWithFields { RequiredFieldWithDisplay = null }; + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.False(result); + var entry = Assert.Single(errors); + var error = Assert.Single(entry.Value); + Assert.Contains("Required field", error); + } + [Fact] public void NonRequiredValidator_Invalid_When_Invalid() { @@ -233,6 +258,40 @@ public void Struct_Invalid_When_Invalid() Assert.Single(errors); } + [Fact] + public void ReadonlyStructWithFields_Invalid_When_Invalid() + { + var thingToValidate = new TestReadonlyStructWithFields(requiredField: null, tenOrMoreField: 5); + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.False(result); + Assert.Equal(2, errors.Count); + Assert.Contains(nameof(TestReadonlyStructWithFields.RequiredField), errors.Keys); + Assert.Contains(nameof(TestReadonlyStructWithFields.TenOrMoreField), errors.Keys); + } + + [Fact] + public void RequiresValidation_True_When_Field_Has_ValidationAttribute() + { + var result = MiniValidator.RequiresValidation(typeof(TestTypeWithFields), recurse: false); + + Assert.True(result); + } + + [Fact] + public void Duplicate_Member_Names_Aggregate_Field_And_Property_Errors() + { + var thingToValidate = new TestTypeWithHiddenRequiredField(); + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.False(result); + var entry = Assert.Single(errors); + Assert.Equal(nameof(TestTypeWithHiddenRequiredField.Name), entry.Key); + Assert.Equal(2, entry.Value.Length); + } + [Fact] public void Invalid_When_ValidatableObject_Validate_Is_Invalid() { @@ -558,4 +617,16 @@ public AlwaysInvalidAttribute(string id) public override bool IsValid(object? value) => false; } + + class TestTypeWithRequiredProperty + { + [Required] + public string? Name { get; set; } + } + + class TestTypeWithHiddenRequiredField : TestTypeWithRequiredProperty + { + [Required] + public new string? Name = null; + } }