Skip to content

Make new validations consistent with System.ComponentModel.DataAnnotations behavior #63231

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Aug 16, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ public IEnumerable<ValidationResult> Validate(ValidationContext validationContex

private class MockValidatableTypeInfo(Type type, ValidatablePropertyInfo[] members) : ValidatableTypeInfo(type, members)
{
protected override ValidationAttribute[] GetValidationAttributes() => [];
Copy link
Member

Choose a reason for hiding this comment

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

Since the ValidatableTypeInfo is marked as experimental, we're OK to make this change without API review. Also, it follows the same patter as the ValidatablePropertyInfo that was already code reviewed so we should be good there anyways.

}

private class MockValidatablePropertyInfo(
Expand Down
35 changes: 30 additions & 5 deletions src/Validation/gen/Emitters/ValidationsGenerator.Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public GeneratedValidatablePropertyInfo(
internal string Name { get; }

protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes()
=> ValidationAttributeCache.GetValidationAttributes(ContainingType, Name);
=> ValidationAttributeCache.GetPropertyValidationAttributes(ContainingType, Name);
}

{{GeneratedCodeAttribute}}
Expand All @@ -81,7 +81,16 @@ public GeneratedValidatablePropertyInfo(
public GeneratedValidatableTypeInfo(
[param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
global::System.Type type,
ValidatablePropertyInfo[] members) : base(type, members) { }
ValidatablePropertyInfo[] members) : base(type, members)
{
Type = type;
}

[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
internal global::System.Type Type { get; }

protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes()
=> ValidationAttributeCache.GetTypeValidationAttributes(Type);
}

{{GeneratedCodeAttribute}}
Expand Down Expand Up @@ -128,15 +137,17 @@ private sealed record CacheKey(
[property: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
global::System.Type ContainingType,
string PropertyName);
private static readonly global::System.Collections.Concurrent.ConcurrentDictionary<CacheKey, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]> _cache = new();
private static readonly global::System.Collections.Concurrent.ConcurrentDictionary<CacheKey, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]> _propertyCache = new();
private static readonly global::System.Lazy<global::System.Collections.Concurrent.ConcurrentDictionary<global::System.Type, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]>> _lazyTypeCache = new (() => new ());
private static global::System.Collections.Concurrent.ConcurrentDictionary<global::System.Type, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]> TypeCache => _lazyTypeCache.Value;

public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes(
public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetPropertyValidationAttributes(
[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
global::System.Type containingType,
string propertyName)
{
var key = new CacheKey(containingType, propertyName);
return _cache.GetOrAdd(key, static k =>
return _propertyCache.GetOrAdd(key, static k =>
{
var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();

Expand Down Expand Up @@ -173,6 +184,20 @@ private sealed record CacheKey(
return results.ToArray();
});
}


public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetTypeValidationAttributes(
[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
global::System.Type type
)
{
return TypeCache.GetOrAdd(type, static t =>
{
var typeAttributes = global::System.Reflection.CustomAttributeExtensions
.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(t, inherit: true);
return global::System.Linq.Enumerable.ToArray(typeAttributes);
});
}
}
}
""";
Expand Down
20 changes: 19 additions & 1 deletion src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnow

visitedTypes.Add(typeSymbol);

var hasValidationAttributes = HasValidationAttributes(typeSymbol, wellKnownTypes);

// Extract validatable types discovered in base types of this type and add them to the top-level list.
var current = typeSymbol.BaseType;
var hasValidatableBaseType = false;
Expand All @@ -107,7 +109,7 @@ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnow
}

// No validatable members or derived types found, so we don't need to add this type.
if (members.IsDefaultOrEmpty && !hasValidatableBaseType && !hasValidatableDerivedTypes)
if (members.IsDefaultOrEmpty && !hasValidationAttributes && !hasValidatableBaseType && !hasValidatableDerivedTypes)
{
return false;
}
Expand Down Expand Up @@ -283,4 +285,20 @@ internal static ImmutableArray<ValidationAttribute> ExtractValidationAttributes(
NamedArguments: attribute.NamedArguments.ToDictionary(namedArgument => namedArgument.Key, namedArgument => namedArgument.Value.ToCSharpString()),
IsCustomValidationAttribute: SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_CustomValidationAttribute))))];
}

internal static bool HasValidationAttributes(ISymbol symbol, WellKnownTypes wellKnownTypes)
{
var validationAttributeSymbol = wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_ValidationAttribute);

foreach (var attribute in symbol.GetAttributes())
{
if (attribute.AttributeClass is not null &&
attribute.AttributeClass.ImplementsValidationAttribute(validationAttributeSymbol))
{
return true;
}
}

return false;
}
}
1 change: 1 addition & 0 deletions src/Validation/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#nullable enable
abstract Microsoft.Extensions.Validation.ValidatableTypeInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]!
Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions
Microsoft.Extensions.Validation.IValidatableInfo
Microsoft.Extensions.Validation.IValidatableInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
Expand Down
166 changes: 118 additions & 48 deletions src/Validation/src/ValidatableTypeInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace Microsoft.Extensions.Validation;
public abstract class ValidatableTypeInfo : IValidatableInfo
{
private readonly int _membersCount;
private readonly List<Type> _subTypes;
private readonly List<Type> _superTypes;

/// <summary>
/// Creates a new instance of <see cref="ValidatableTypeInfo"/>.
Expand All @@ -28,9 +28,15 @@ protected ValidatableTypeInfo(
Type = type;
Members = members;
_membersCount = members.Count;
_subTypes = type.GetAllImplementedTypes();
_superTypes = type.GetAllImplementedTypes();
}

/// <summary>
/// Gets the validation attributes for this member.
/// </summary>
/// <returns>An array of validation attributes to apply to this member.</returns>
protected abstract ValidationAttribute[] GetValidationAttributes();

/// <summary>
/// The type being validated.
/// </summary>
Expand Down Expand Up @@ -59,75 +65,139 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context,
}

var originalPrefix = context.CurrentValidationPath;
var originalErrorCount = context.ValidationErrors?.Count ?? 0;

try
{
// First validate direct members
await ValidateMembersAsync(value, context, cancellationToken);

var actualType = value.GetType();

// First validate members
for (var i = 0; i < _membersCount; i++)
// Then validate inherited members
foreach (var superTypeInfo in GetSuperTypeInfos(actualType, context))
{
await superTypeInfo.ValidateMembersAsync(value, context, cancellationToken);
}

// If any property-level validation errors were found, return early
if (context.ValidationErrors is not null && context.ValidationErrors.Count > originalErrorCount)
{
return;
}

// Validate type-level attributes
ValidateTypeAttributes(value, context);

// If any type-level attribute errors were found, return early
if (context.ValidationErrors is not null && context.ValidationErrors.Count > originalErrorCount)
{
return;
}

// Finally validate IValidatableObject if implemented
ValidateValidatableObjectInterface(value, context);
}
finally
{
context.CurrentValidationPath = originalPrefix;
}
}

private async Task ValidateMembersAsync(object? value, ValidateContext context, CancellationToken cancellationToken)
{
var originalPrefix = context.CurrentValidationPath;

for (var i = 0; i < _membersCount; i++)
{
try
{
await Members[i].ValidateAsync(value, context, cancellationToken);

}
finally
{
context.CurrentValidationPath = originalPrefix;
}
}
}

private void ValidateTypeAttributes(object? value, ValidateContext context)
{
var validationAttributes = GetValidationAttributes();
var errorPrefix = context.CurrentValidationPath;

// Then validate sub-types if any
foreach (var subType in _subTypes)
for (var i = 0; i < validationAttributes.Length; i++)
{
var attribute = validationAttributes[i];
var result = attribute.GetValidationResult(value, context.ValidationContext);
if (result is not null && result != ValidationResult.Success && result.ErrorMessage is not null)
{
// Check if the actual type is assignable to the sub-type
// and validate it if it is
if (subType.IsAssignableFrom(actualType))
// Create a validation error for each member name that is provided
foreach (var memberName in result.MemberNames)
{
if (context.ValidationOptions.TryGetValidatableTypeInfo(subType, out var subTypeInfo))
{
await subTypeInfo.ValidateAsync(value, context, cancellationToken);
context.CurrentValidationPath = originalPrefix;
}
var key = string.IsNullOrEmpty(errorPrefix) ? memberName : $"{errorPrefix}.{memberName}";
context.AddOrExtendValidationError(memberName, key, result.ErrorMessage, value);
}

if (!result.MemberNames.Any())
{
// If no member names are specified, then treat this as a top-level error
context.AddOrExtendValidationError(string.Empty, errorPrefix, result.ErrorMessage, value);
}
}
}
}

// Finally validate IValidatableObject if implemented
if (Type.ImplementsInterface(typeof(IValidatableObject)) && value is IValidatableObject validatable)
private void ValidateValidatableObjectInterface(object? value, ValidateContext context)
{
if (Type.ImplementsInterface(typeof(IValidatableObject)) && value is IValidatableObject validatable)
{
// Important: Set the DisplayName to the type name for top-level validations
// and restore the original validation context properties
var originalDisplayName = context.ValidationContext.DisplayName;
var originalMemberName = context.ValidationContext.MemberName;
var errorPrefix = context.CurrentValidationPath;

// Set the display name to the class name for IValidatableObject validation
context.ValidationContext.DisplayName = Type.Name;
context.ValidationContext.MemberName = null;

var validationResults = validatable.Validate(context.ValidationContext);
foreach (var validationResult in validationResults)
{
// Important: Set the DisplayName to the type name for top-level validations
// and restore the original validation context properties
var originalDisplayName = context.ValidationContext.DisplayName;
var originalMemberName = context.ValidationContext.MemberName;

// Set the display name to the class name for IValidatableObject validation
context.ValidationContext.DisplayName = Type.Name;
context.ValidationContext.MemberName = null;

var validationResults = validatable.Validate(context.ValidationContext);
foreach (var validationResult in validationResults)
if (validationResult != ValidationResult.Success && validationResult.ErrorMessage is not null)
{
if (validationResult != ValidationResult.Success && validationResult.ErrorMessage is not null)
// Create a validation error for each member name that is provided
foreach (var memberName in validationResult.MemberNames)
{
// Create a validation error for each member name that is provided
foreach (var memberName in validationResult.MemberNames)
{
var key = string.IsNullOrEmpty(originalPrefix) ?
memberName :
$"{originalPrefix}.{memberName}";
context.AddOrExtendValidationError(memberName, key, validationResult.ErrorMessage, value);
}

if (!validationResult.MemberNames.Any())
{
// If no member names are specified, then treat this as a top-level error
context.AddOrExtendValidationError(string.Empty, string.Empty, validationResult.ErrorMessage, value);
}
var key = string.IsNullOrEmpty(errorPrefix) ? memberName : $"{errorPrefix}.{memberName}";
context.AddOrExtendValidationError(memberName, key, validationResult.ErrorMessage, value);
}
}

// Restore the original validation context properties
context.ValidationContext.DisplayName = originalDisplayName;
context.ValidationContext.MemberName = originalMemberName;
if (!validationResult.MemberNames.Any())
{
// If no member names are specified, then treat this as a top-level error
context.AddOrExtendValidationError(string.Empty, string.Empty, validationResult.ErrorMessage, value);
}
}
}

// Restore the original validation context properties
context.ValidationContext.DisplayName = originalDisplayName;
context.ValidationContext.MemberName = originalMemberName;
}
finally
}

private IEnumerable<ValidatableTypeInfo> GetSuperTypeInfos(Type actualType, ValidateContext context)
{
foreach (var superType in _superTypes.Where(t => t.IsAssignableFrom(actualType)))
{
context.CurrentValidationPath = originalPrefix;
if (context.ValidationOptions.TryGetValidatableTypeInfo(superType, out var found)
&& found is ValidatableTypeInfo superTypeInfo)
{
yield return superTypeInfo;
}
}
}
}
Empty file modified src/Validation/startvscode.sh
100644 → 100755
Empty file.
Loading
Loading