From 8bf9293cf9e1471d3e566aa537c4ce51dd72af3b Mon Sep 17 00:00:00 2001 From: Moha-sami Date: Thu, 6 Aug 2026 14:56:09 +0300 Subject: [PATCH 1/6] docs: add XML code usage example for MiniValidator.TryValidate --- src/MiniValidation/MiniValidator.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/MiniValidation/MiniValidator.cs b/src/MiniValidation/MiniValidator.cs index 99c8f36..51e6db2 100644 --- a/src/MiniValidation/MiniValidator.cs +++ b/src/MiniValidation/MiniValidator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -54,6 +54,12 @@ public static bool RequiresValidation(Type targetType, bool recurse = true) /// A dictionary that contains details of each failed validation. /// true if is valid; otherwise false. /// Thrown when is null. + /// + /// + /// var widget = new Widget { Name = "" }; + /// var isValid = MiniValidator.TryValidate(widget, out var errors); + /// + /// public static bool TryValidate(TTarget target, out IDictionary errors) { return TryValidateImpl(target, null, recurse: true, allowAsync: false, out errors); From 1445c0f362b852a6b05aec05838124a663bf7996 Mon Sep 17 00:00:00 2001 From: Moha-sami Date: Thu, 6 Aug 2026 15:26:10 +0300 Subject: [PATCH 2/6] feat: support C# 11 required modifier during validation (Closes #76) --- src/MiniValidation/MiniValidation.csproj | 2 +- src/MiniValidation/TypeDetailsCache.cs | 10 +++++++++- .../MiniValidation.UnitTests.csproj | 4 ++-- tests/MiniValidation.UnitTests/TryValidate.cs | 17 +++++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/MiniValidation/MiniValidation.csproj b/src/MiniValidation/MiniValidation.csproj index b48eb90..ed40f1a 100644 --- a/src/MiniValidation/MiniValidation.csproj +++ b/src/MiniValidation/MiniValidation.csproj @@ -5,7 +5,7 @@ netstandard2.0;net8.0 ComponentModel DataAnnotations validation README.md - 10.0 + 11.0 diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index 2086de3..d349da0 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel; @@ -251,6 +251,14 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut { skipRecursionAttribute = skipRecursionAttr; } + else if (attr.GetType().Name == "RequiredMemberAttribute") + { + validationAttributes ??= new(); + if (!validationAttributes.OfType().Any()) + { + validationAttributes.Add(new RequiredAttribute()); + } + } } return new(validationAttributes?.ToArray(), displayAttribute, skipRecursionAttribute); diff --git a/tests/MiniValidation.UnitTests/MiniValidation.UnitTests.csproj b/tests/MiniValidation.UnitTests/MiniValidation.UnitTests.csproj index a640ad8..e07edf8 100644 --- a/tests/MiniValidation.UnitTests/MiniValidation.UnitTests.csproj +++ b/tests/MiniValidation.UnitTests/MiniValidation.UnitTests.csproj @@ -1,8 +1,8 @@ - + net8.0;net9.0;net10.0 - 10.0 + 11.0 enable enable diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index 70a1759..977e3f7 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -558,4 +558,21 @@ public AlwaysInvalidAttribute(string id) public override bool IsValid(object? value) => false; } + + [Fact] + public void RequiredMemberAttribute_Treated_As_Required() + { + var thingToValidate = new TestTypeWithRequiredMember { Name = null }; + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.False(result); + var entry = Assert.Single(errors); + Assert.Equal(nameof(TestTypeWithRequiredMember.Name), entry.Key); + } + + class TestTypeWithRequiredMember + { + public required string? Name { get; set; } + } } From d9f28430caaa80a3ab90be021a131800ea7b3438 Mon Sep 17 00:00:00 2001 From: Moha-sami Date: Thu, 13 Aug 2026 03:13:03 +0300 Subject: [PATCH 3/6] refactor: gate RequiredAttribute on non-nullable members, match full type name, and defer decision per review --- src/MiniValidation/TypeDetailsCache.cs | 40 ++++++++++++++--- tests/MiniValidation.UnitTests/TryValidate.cs | 45 +++++++++++++++++-- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index d349da0..1f6bdad 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -236,6 +236,8 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut .Where(attr => !IsDuplicateTypeDescriptorAttribute(attr, propertyAttributes))); } + var hasRequiredMemberAttribute = false; + foreach (var attr in customAttributes) { if (attr is ValidationAttribute validationAttr) @@ -251,19 +253,45 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut { skipRecursionAttribute = skipRecursionAttr; } - else if (attr.GetType().Name == "RequiredMemberAttribute") + else if (string.Equals(attr.GetType().FullName, "System.Runtime.CompilerServices.RequiredMemberAttribute", StringComparison.Ordinal)) { - validationAttributes ??= new(); - if (!validationAttributes.OfType().Any()) - { - validationAttributes.Add(new RequiredAttribute()); - } + hasRequiredMemberAttribute = true; + } + } + + if (hasRequiredMemberAttribute && !IsPropertyNullable(property)) + { + validationAttributes ??= new(); + if (!validationAttributes.OfType().Any()) + { + validationAttributes.Add(new RequiredAttribute()); } } return new(validationAttributes?.ToArray(), displayAttribute, skipRecursionAttribute); } + private static readonly NullabilityInfoContext _nullabilityContext = new(); + + private static bool IsPropertyNullable(PropertyInfo property) + { + if (Nullable.GetUnderlyingType(property.PropertyType) != null) + { + return true; + } + + if (!property.PropertyType.IsValueType) + { + var nullabilityInfo = _nullabilityContext.Create(property); + if (nullabilityInfo.WriteState == NullabilityState.Nullable || nullabilityInfo.ReadState == NullabilityState.Nullable) + { + return true; + } + } + + return false; + } + private static bool IsDuplicateTypeDescriptorAttribute(Attribute typeDescriptorAttribute, Attribute[] propertyAttributes) { foreach (var propertyAttribute in propertyAttributes) diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index 977e3f7..877e6dd 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -560,19 +560,56 @@ public AlwaysInvalidAttribute(string id) } [Fact] - public void RequiredMemberAttribute_Treated_As_Required() + public void RequiredMemberAttribute_On_NonNullable_Member_Treated_As_Required() { - var thingToValidate = new TestTypeWithRequiredMember { Name = null }; + var thingToValidate = new TestTypeWithNonNullableRequiredMember { Name = null! }; var result = MiniValidator.TryValidate(thingToValidate, out var errors); Assert.False(result); var entry = Assert.Single(errors); - Assert.Equal(nameof(TestTypeWithRequiredMember.Name), entry.Key); + Assert.Equal(nameof(TestTypeWithNonNullableRequiredMember.Name), entry.Key); } - class TestTypeWithRequiredMember + [Fact] + public void RequiredMemberAttribute_On_Nullable_Members_Ignored() + { + var thingToValidate = new TestTypeWithNullableRequiredMembers { Name = null, Count = null }; + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.True(result); + Assert.Empty(errors); + } + + [Fact] + public void Unrelated_RequiredMemberAttribute_Does_Not_Add_Required_Validation() + { + var thingToValidate = new TestTypeWithCustomRequiredMemberAttr { Name = null }; + + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + + Assert.True(result); + Assert.Empty(errors); + } + + class TestTypeWithNonNullableRequiredMember + { + public required string Name { get; set; } + } + + class TestTypeWithNullableRequiredMembers { public required string? Name { get; set; } + public required int? Count { get; set; } + } + + class TestTypeWithCustomRequiredMemberAttr + { + [CustomRequiredMember] + public string? Name { get; set; } } + + [AttributeUsage(AttributeTargets.Property)] + class CustomRequiredMemberAttribute : Attribute { } } From 2278ea18429d3936474156950140f063adcd49b1 Mon Sep 17 00:00:00 2001 From: Moha-sami Date: Thu, 13 Aug 2026 03:37:09 +0300 Subject: [PATCH 4/6] fix: multi-target NullabilityInfoContext under #if NET6_0_OR_GREATER for netstandard2.0 support --- src/MiniValidation/TypeDetailsCache.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index 1f6bdad..72df743 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -271,7 +271,9 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut return new(validationAttributes?.ToArray(), displayAttribute, skipRecursionAttribute); } +#if NET6_0_OR_GREATER private static readonly NullabilityInfoContext _nullabilityContext = new(); +#endif private static bool IsPropertyNullable(PropertyInfo property) { @@ -280,6 +282,7 @@ private static bool IsPropertyNullable(PropertyInfo property) return true; } +#if NET6_0_OR_GREATER if (!property.PropertyType.IsValueType) { var nullabilityInfo = _nullabilityContext.Create(property); @@ -288,6 +291,21 @@ private static bool IsPropertyNullable(PropertyInfo property) return true; } } +#else + if (!property.PropertyType.IsValueType) + { + var nullableAttr = property.GetCustomAttributes(false) + .FirstOrDefault(attr => string.Equals(attr.GetType().FullName, "System.Runtime.CompilerServices.NullableAttribute", StringComparison.Ordinal)); + if (nullableAttr != null) + { + var flagsField = nullableAttr.GetType().GetField("NullableFlags"); + if (flagsField?.GetValue(nullableAttr) is byte[] flags && flags.Length > 0 && flags[0] == 2) + { + return true; + } + } + } +#endif return false; } From c99142550cec0361f84ffdb69fb7ee935f2871b0 Mon Sep 17 00:00:00 2001 From: Moha-sami Date: Thu, 13 Aug 2026 06:42:38 +0300 Subject: [PATCH 5/6] refactor: exclude value types, ensure thread-safe NullabilityInfoContext, and handle ambient NullableContextAttribute for netstandard2.0 --- src/MiniValidation/TypeDetailsCache.cs | 50 ++++++++++--------- tests/MiniValidation.UnitTests/TryValidate.cs | 12 +++++ 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index 72df743..5cdbb2d 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -259,7 +259,7 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut } } - if (hasRequiredMemberAttribute && !IsPropertyNullable(property)) + if (hasRequiredMemberAttribute && !property.PropertyType.IsValueType && !IsReferenceTypeNullable(property)) { validationAttributes ??= new(); if (!validationAttributes.OfType().Any()) @@ -271,43 +271,45 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut return new(validationAttributes?.ToArray(), displayAttribute, skipRecursionAttribute); } -#if NET6_0_OR_GREATER - private static readonly NullabilityInfoContext _nullabilityContext = new(); -#endif - - private static bool IsPropertyNullable(PropertyInfo property) + private static bool IsReferenceTypeNullable(PropertyInfo property) { - if (Nullable.GetUnderlyingType(property.PropertyType) != null) - { - return true; - } - #if NET6_0_OR_GREATER - if (!property.PropertyType.IsValueType) + // Create context per lookup for thread safety during concurrent cache initialization + var nullabilityContext = new NullabilityInfoContext(); + var nullabilityInfo = nullabilityContext.Create(property); + return nullabilityInfo.WriteState == NullabilityState.Nullable || nullabilityInfo.ReadState == NullabilityState.Nullable; +#else + var nullableAttr = property.GetCustomAttributes(false) + .FirstOrDefault(attr => string.Equals(attr.GetType().FullName, "System.Runtime.CompilerServices.NullableAttribute", StringComparison.Ordinal)); + + if (nullableAttr != null) { - var nullabilityInfo = _nullabilityContext.Create(property); - if (nullabilityInfo.WriteState == NullabilityState.Nullable || nullabilityInfo.ReadState == NullabilityState.Nullable) + var flagsField = nullableAttr.GetType().GetField("NullableFlags"); + if (flagsField?.GetValue(nullableAttr) is byte[] flags && flags.Length > 0) { - return true; + return flags[0] == 2; } } -#else - if (!property.PropertyType.IsValueType) + + var declaringType = property.DeclaringType; + while (declaringType != null) { - var nullableAttr = property.GetCustomAttributes(false) - .FirstOrDefault(attr => string.Equals(attr.GetType().FullName, "System.Runtime.CompilerServices.NullableAttribute", StringComparison.Ordinal)); - if (nullableAttr != null) + var nullableContextAttr = declaringType.GetCustomAttributes(false) + .FirstOrDefault(attr => string.Equals(attr.GetType().FullName, "System.Runtime.CompilerServices.NullableContextAttribute", StringComparison.Ordinal)); + + if (nullableContextAttr != null) { - var flagsField = nullableAttr.GetType().GetField("NullableFlags"); - if (flagsField?.GetValue(nullableAttr) is byte[] flags && flags.Length > 0 && flags[0] == 2) + var flagField = nullableContextAttr.GetType().GetField("Flag"); + if (flagField?.GetValue(nullableContextAttr) is byte flag) { - return true; + return flag == 2; } } + declaringType = declaringType.DeclaringType; } -#endif return false; +#endif } private static bool IsDuplicateTypeDescriptorAttribute(Attribute typeDescriptorAttribute, Attribute[] propertyAttributes) diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index 877e6dd..b9de364 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -593,6 +593,13 @@ public void Unrelated_RequiredMemberAttribute_Does_Not_Add_Required_Validation() Assert.Empty(errors); } + [Fact] + public void Required_Value_Types_Do_Not_Trigger_RequiresValidation() + { + Assert.False(MiniValidator.RequiresValidation(typeof(TestTypeWithRequiredValueType))); + Assert.False(MiniValidator.RequiresValidation(typeof(TestTypeWithNullableRequiredMembers))); + } + class TestTypeWithNonNullableRequiredMember { public required string Name { get; set; } @@ -604,6 +611,11 @@ class TestTypeWithNullableRequiredMembers public required int? Count { get; set; } } + class TestTypeWithRequiredValueType + { + public required int Value { get; set; } + } + class TestTypeWithCustomRequiredMemberAttr { [CustomRequiredMember] From ca6b644701da60a0e1099f34d425201001e4a710 Mon Sep 17 00:00:00 2001 From: Moha-sami Date: Fri, 14 Aug 2026 21:47:30 +0300 Subject: [PATCH 6/6] refactor: extract target-independent IsReferenceTypeNullableFallback supporting AllowNull and MaybeNull flow attributes --- src/MiniValidation/TypeDetailsCache.cs | 55 ++++++++++++++++++- tests/MiniValidation.UnitTests/TryValidate.cs | 38 +++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/MiniValidation/TypeDetailsCache.cs b/src/MiniValidation/TypeDetailsCache.cs index 5cdbb2d..2e7e67e 100644 --- a/src/MiniValidation/TypeDetailsCache.cs +++ b/src/MiniValidation/TypeDetailsCache.cs @@ -6,6 +6,9 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MiniValidation.UnitTests")] namespace MiniValidation; @@ -271,7 +274,7 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut return new(validationAttributes?.ToArray(), displayAttribute, skipRecursionAttribute); } - private static bool IsReferenceTypeNullable(PropertyInfo property) + internal static bool IsReferenceTypeNullable(PropertyInfo property) { #if NET6_0_OR_GREATER // Create context per lookup for thread safety during concurrent cache initialization @@ -279,6 +282,17 @@ private static bool IsReferenceTypeNullable(PropertyInfo property) var nullabilityInfo = nullabilityContext.Create(property); return nullabilityInfo.WriteState == NullabilityState.Nullable || nullabilityInfo.ReadState == NullabilityState.Nullable; #else + return IsReferenceTypeNullableFallback(property); +#endif + } + + internal static bool IsReferenceTypeNullableFallback(PropertyInfo property) + { + if (HasNullableFlowAttribute(property)) + { + return true; + } + var nullableAttr = property.GetCustomAttributes(false) .FirstOrDefault(attr => string.Equals(attr.GetType().FullName, "System.Runtime.CompilerServices.NullableAttribute", StringComparison.Ordinal)); @@ -309,7 +323,44 @@ private static bool IsReferenceTypeNullable(PropertyInfo property) } return false; -#endif + } + + private static bool HasNullableFlowAttribute(PropertyInfo property) + { + if (HasAllowOrMaybeNullAttribute(property.GetCustomAttributes(false))) + { + return true; + } + + if (property.GetMethod is { } getMethod && HasAllowOrMaybeNullAttribute(getMethod.ReturnParameter.GetCustomAttributes(false))) + { + return true; + } + + if (property.SetMethod is { } setMethod) + { + var setParams = setMethod.GetParameters(); + if (setParams.Length > 0 && HasAllowOrMaybeNullAttribute(setParams[setParams.Length - 1].GetCustomAttributes(false))) + { + return true; + } + } + + return false; + } + + private static bool HasAllowOrMaybeNullAttribute(object[] attributes) + { + foreach (var attr in attributes) + { + var fullName = attr.GetType().FullName; + if (string.Equals(fullName, "System.Diagnostics.CodeAnalysis.AllowNullAttribute", StringComparison.Ordinal) + || string.Equals(fullName, "System.Diagnostics.CodeAnalysis.MaybeNullAttribute", StringComparison.Ordinal)) + { + return true; + } + } + return false; } private static bool IsDuplicateTypeDescriptorAttribute(Attribute typeDescriptorAttribute, Attribute[] propertyAttributes) diff --git a/tests/MiniValidation.UnitTests/TryValidate.cs b/tests/MiniValidation.UnitTests/TryValidate.cs index b9de364..95e72d6 100644 --- a/tests/MiniValidation.UnitTests/TryValidate.cs +++ b/tests/MiniValidation.UnitTests/TryValidate.cs @@ -600,11 +600,49 @@ public void Required_Value_Types_Do_Not_Trigger_RequiresValidation() Assert.False(MiniValidator.RequiresValidation(typeof(TestTypeWithNullableRequiredMembers))); } + [Fact] + public void RequiredMemberAttribute_With_AllowNull_Or_MaybeNull_Ignored() + { + var thingToValidate = new TestTypeWithAllowNullRequiredMember { Value = null! }; + var result = MiniValidator.TryValidate(thingToValidate, out var errors); + Assert.True(result); + Assert.Empty(errors); + + var thingToValidateMaybeNull = new TestTypeWithMaybeNullRequiredMember { Value = null! }; + var resultMaybeNull = MiniValidator.TryValidate(thingToValidateMaybeNull, out errors); + Assert.True(resultMaybeNull); + Assert.Empty(errors); + } + + [Fact] + public void IsReferenceTypeNullableFallback_Matches_Modern_Behavior() + { + var propAllowNull = typeof(TestTypeWithAllowNullRequiredMember).GetProperty(nameof(TestTypeWithAllowNullRequiredMember.Value))!; + var propMaybeNull = typeof(TestTypeWithMaybeNullRequiredMember).GetProperty(nameof(TestTypeWithMaybeNullRequiredMember.Value))!; + var propNonNullable = typeof(TestTypeWithNonNullableRequiredMember).GetProperty(nameof(TestTypeWithNonNullableRequiredMember.Name))!; + + Assert.True(TypeDetailsCache.IsReferenceTypeNullableFallback(propAllowNull)); + Assert.True(TypeDetailsCache.IsReferenceTypeNullableFallback(propMaybeNull)); + Assert.False(TypeDetailsCache.IsReferenceTypeNullableFallback(propNonNullable)); + } + class TestTypeWithNonNullableRequiredMember { public required string Name { get; set; } } + class TestTypeWithAllowNullRequiredMember + { + [System.Diagnostics.CodeAnalysis.AllowNull] + public required string Value { get; set; } + } + + class TestTypeWithMaybeNullRequiredMember + { + [System.Diagnostics.CodeAnalysis.MaybeNull] + public required string Value { get; set; } + } + class TestTypeWithNullableRequiredMembers { public required string? Name { get; set; }