Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/MiniValidation/MiniValidation.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<TargetFrameworks>netstandard2.0;net8.0</TargetFrameworks>
<PackageTags>ComponentModel DataAnnotations validation</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<LangVersion>10.0</LangVersion>
<LangVersion>11.0</LangVersion>
</PropertyGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
Expand Down
8 changes: 7 additions & 1 deletion src/MiniValidation/MiniValidator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
Expand Down Expand Up @@ -54,6 +54,12 @@ public static bool RequiresValidation(Type targetType, bool recurse = true)
/// <param name="errors">A dictionary that contains details of each failed validation.</param>
/// <returns><c>true</c> if <paramref name="target"/> is valid; otherwise <c>false</c>.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="target"/> is <c>null</c>.</exception>
/// <example>
/// <code>
/// var widget = new Widget { Name = "" };
/// var isValid = MiniValidator.TryValidate(widget, out var errors);
/// </code>
/// </example>
public static bool TryValidate<TTarget>(TTarget target, out IDictionary<string, string[]> errors)
{
return TryValidateImpl(target, null, recurse: true, allowAsync: false, out errors);
Expand Down
109 changes: 108 additions & 1 deletion src/MiniValidation/TypeDetailsCache.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("MiniValidation.UnitTests")]

namespace MiniValidation;

Expand Down Expand Up @@ -236,6 +239,8 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut
.Where(attr => !IsDuplicateTypeDescriptorAttribute(attr, propertyAttributes)));
}

var hasRequiredMemberAttribute = false;

foreach (var attr in customAttributes)
{
if (attr is ValidationAttribute validationAttr)
Expand All @@ -251,11 +256,113 @@ private static (ValidationAttribute[]?, DisplayAttribute?, SkipRecursionAttribut
{
skipRecursionAttribute = skipRecursionAttr;
}
else if (string.Equals(attr.GetType().FullName, "System.Runtime.CompilerServices.RequiredMemberAttribute", StringComparison.Ordinal))
{
hasRequiredMemberAttribute = true;
}
}

if (hasRequiredMemberAttribute && !property.PropertyType.IsValueType && !IsReferenceTypeNullable(property))
{
validationAttributes ??= new();
if (!validationAttributes.OfType<RequiredAttribute>().Any())
{
validationAttributes.Add(new RequiredAttribute());
}
}

return new(validationAttributes?.ToArray(), displayAttribute, skipRecursionAttribute);
}

internal static bool IsReferenceTypeNullable(PropertyInfo property)
{
#if NET6_0_OR_GREATER
// 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
return IsReferenceTypeNullableFallback(property);
#endif
}

internal static bool IsReferenceTypeNullableFallback(PropertyInfo property)
{
if (HasNullableFlowAttribute(property))
{
return true;
}

var nullableAttr = property.GetCustomAttributes(false)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The netstandard2.0 path still differs from NullabilityInfoContext for nullable flow annotations. For example, [AllowNull] public required string Value has nullable write state, and [MaybeNull] has nullable read state; this fallback currently sees neither and synthesizes [Required], so the same model validates differently depending on which target asset is loaded.

Please mirror the modern path before falling back to NullableAttribute/NullableContextAttribute: detect System.Diagnostics.CodeAnalysis.AllowNullAttribute for the write side and System.Diagnostics.CodeAnalysis.MaybeNullAttribute for the read side, inspecting the property and the setter value/getter return metadata that NullabilityInfoContext considers. Comparing full type names keeps this compatible where those attribute types are not directly referenceable. If either side permits null, return true.

Please also cover both annotations through the fallback path. Since the current test project targets only .NET 8+, it always selects the NullabilityInfoContext branch; either exercise the netstandard2.0 asset from a consumer test or extract the metadata parser into target-independent code that can be tested directly.

.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)
{
return flags[0] == 2;
}
}

var declaringType = property.DeclaringType;
while (declaringType != null)
{
var nullableContextAttr = declaringType.GetCustomAttributes(false)
.FirstOrDefault(attr => string.Equals(attr.GetType().FullName, "System.Runtime.CompilerServices.NullableContextAttribute", StringComparison.Ordinal));

if (nullableContextAttr != null)
{
var flagField = nullableContextAttr.GetType().GetField("Flag");
if (flagField?.GetValue(nullableContextAttr) is byte flag)
{
return flag == 2;
}
}
declaringType = declaringType.DeclaringType;
}

return false;
}

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)
{
foreach (var propertyAttribute in propertyAttributes)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
<LangVersion>10.0</LangVersion>
<LangVersion>11.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
Expand Down
104 changes: 104 additions & 0 deletions tests/MiniValidation.UnitTests/TryValidate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -558,4 +558,108 @@ public AlwaysInvalidAttribute(string id)

public override bool IsValid(object? value) => false;
}

[Fact]
public void RequiredMemberAttribute_On_NonNullable_Member_Treated_As_Required()
{
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(TestTypeWithNonNullableRequiredMember.Name), entry.Key);
}

[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);
}

[Fact]
public void Required_Value_Types_Do_Not_Trigger_RequiresValidation()
{
Assert.False(MiniValidator.RequiresValidation(typeof(TestTypeWithRequiredValueType)));
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; }
public required int? Count { get; set; }
}

class TestTypeWithRequiredValueType
{
public required int Value { get; set; }
}

class TestTypeWithCustomRequiredMemberAttr
{
[CustomRequiredMember]
public string? Name { get; set; }
}

[AttributeUsage(AttributeTargets.Property)]
class CustomRequiredMemberAttribute : Attribute { }
}