Skip to content

Commit 09ad011

Browse files
oroztocilBrennanConroycaptainsafia
authored
Make new validations consistent with System.ComponentModel.DataAnnotations behavior (#63231)
* Add support for type-level validation attributes, update validation ordering * Code review fix, test fix * Fix trimming annotation * Fix trimming annotation * Separate caches for property and type attributes * Fix typo Co-authored-by: Brennan <[email protected]> * Fix typo Co-authored-by: Brennan <[email protected]> * Fix and simplify the emitted code * Update src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGeneratorTestBase.cs --------- Co-authored-by: Brennan <[email protected]> Co-authored-by: Safia Abdalla <[email protected]>
1 parent 8fd0151 commit 09ad011

File tree

32 files changed

+1200
-214
lines changed

32 files changed

+1200
-214
lines changed

src/Http/Http/perf/Microbenchmarks/ValidatableTypesBenchmark.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ public IEnumerable<ValidationResult> Validate(ValidationContext validationContex
244244

245245
private class MockValidatableTypeInfo(Type type, ValidatablePropertyInfo[] members) : ValidatableTypeInfo(type, members)
246246
{
247+
protected override ValidationAttribute[] GetValidationAttributes() => [];
247248
}
248249

249250
private class MockValidatablePropertyInfo(

src/Validation/gen/Emitters/ValidationsGenerator.Emitter.cs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public GeneratedValidatablePropertyInfo(
7272
internal string Name { get; }
7373
7474
protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes()
75-
=> ValidationAttributeCache.GetValidationAttributes(ContainingType, Name);
75+
=> ValidationAttributeCache.GetPropertyValidationAttributes(ContainingType, Name);
7676
}
7777
7878
{{GeneratedCodeAttribute}}
@@ -81,7 +81,16 @@ public GeneratedValidatablePropertyInfo(
8181
public GeneratedValidatableTypeInfo(
8282
[param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
8383
global::System.Type type,
84-
ValidatablePropertyInfo[] members) : base(type, members) { }
84+
ValidatablePropertyInfo[] members) : base(type, members)
85+
{
86+
Type = type;
87+
}
88+
89+
[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
90+
internal global::System.Type Type { get; }
91+
92+
protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes()
93+
=> ValidationAttributeCache.GetTypeValidationAttributes(Type);
8594
}
8695
8796
{{GeneratedCodeAttribute}}
@@ -128,15 +137,17 @@ private sealed record CacheKey(
128137
[property: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
129138
global::System.Type ContainingType,
130139
string PropertyName);
131-
private static readonly global::System.Collections.Concurrent.ConcurrentDictionary<CacheKey, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]> _cache = new();
140+
private static readonly global::System.Collections.Concurrent.ConcurrentDictionary<CacheKey, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]> _propertyCache = new();
141+
private static readonly global::System.Lazy<global::System.Collections.Concurrent.ConcurrentDictionary<global::System.Type, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]>> _lazyTypeCache = new (() => new ());
142+
private static global::System.Collections.Concurrent.ConcurrentDictionary<global::System.Type, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]> TypeCache => _lazyTypeCache.Value;
132143
133-
public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes(
144+
public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetPropertyValidationAttributes(
134145
[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
135146
global::System.Type containingType,
136147
string propertyName)
137148
{
138149
var key = new CacheKey(containingType, propertyName);
139-
return _cache.GetOrAdd(key, static k =>
150+
return _propertyCache.GetOrAdd(key, static k =>
140151
{
141152
var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
142153
@@ -173,6 +184,20 @@ private sealed record CacheKey(
173184
return results.ToArray();
174185
});
175186
}
187+
188+
189+
public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetTypeValidationAttributes(
190+
[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
191+
global::System.Type type
192+
)
193+
{
194+
return TypeCache.GetOrAdd(type, static t =>
195+
{
196+
var typeAttributes = global::System.Reflection.CustomAttributeExtensions
197+
.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(t, inherit: true);
198+
return global::System.Linq.Enumerable.ToArray(typeAttributes);
199+
});
200+
}
176201
}
177202
}
178203
""";

src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnow
8282

8383
visitedTypes.Add(typeSymbol);
8484

85+
var hasValidationAttributes = HasValidationAttributes(typeSymbol, wellKnownTypes);
86+
8587
// Extract validatable types discovered in base types of this type and add them to the top-level list.
8688
var current = typeSymbol.BaseType;
8789
var hasValidatableBaseType = false;
@@ -107,7 +109,7 @@ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnow
107109
}
108110

109111
// No validatable members or derived types found, so we don't need to add this type.
110-
if (members.IsDefaultOrEmpty && !hasValidatableBaseType && !hasValidatableDerivedTypes)
112+
if (members.IsDefaultOrEmpty && !hasValidationAttributes && !hasValidatableBaseType && !hasValidatableDerivedTypes)
111113
{
112114
return false;
113115
}
@@ -283,4 +285,20 @@ internal static ImmutableArray<ValidationAttribute> ExtractValidationAttributes(
283285
NamedArguments: attribute.NamedArguments.ToDictionary(namedArgument => namedArgument.Key, namedArgument => namedArgument.Value.ToCSharpString()),
284286
IsCustomValidationAttribute: SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_CustomValidationAttribute))))];
285287
}
288+
289+
internal static bool HasValidationAttributes(ISymbol symbol, WellKnownTypes wellKnownTypes)
290+
{
291+
var validationAttributeSymbol = wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_ValidationAttribute);
292+
293+
foreach (var attribute in symbol.GetAttributes())
294+
{
295+
if (attribute.AttributeClass is not null &&
296+
attribute.AttributeClass.ImplementsValidationAttribute(validationAttributeSymbol))
297+
{
298+
return true;
299+
}
300+
}
301+
302+
return false;
303+
}
286304
}

src/Validation/src/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#nullable enable
2+
abstract Microsoft.Extensions.Validation.ValidatableTypeInfo.GetValidationAttributes() -> System.ComponentModel.DataAnnotations.ValidationAttribute![]!
23
Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions
34
Microsoft.Extensions.Validation.IValidatableInfo
45
Microsoft.Extensions.Validation.IValidatableInfo.ValidateAsync(object? value, Microsoft.Extensions.Validation.ValidateContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!

src/Validation/src/ValidatableTypeInfo.cs

Lines changed: 118 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ namespace Microsoft.Extensions.Validation;
1414
public abstract class ValidatableTypeInfo : IValidatableInfo
1515
{
1616
private readonly int _membersCount;
17-
private readonly List<Type> _subTypes;
17+
private readonly List<Type> _superTypes;
1818

1919
/// <summary>
2020
/// Creates a new instance of <see cref="ValidatableTypeInfo"/>.
@@ -28,9 +28,15 @@ protected ValidatableTypeInfo(
2828
Type = type;
2929
Members = members;
3030
_membersCount = members.Count;
31-
_subTypes = type.GetAllImplementedTypes();
31+
_superTypes = type.GetAllImplementedTypes();
3232
}
3333

34+
/// <summary>
35+
/// Gets the validation attributes for this member.
36+
/// </summary>
37+
/// <returns>An array of validation attributes to apply to this member.</returns>
38+
protected abstract ValidationAttribute[] GetValidationAttributes();
39+
3440
/// <summary>
3541
/// The type being validated.
3642
/// </summary>
@@ -59,75 +65,139 @@ public virtual async Task ValidateAsync(object? value, ValidateContext context,
5965
}
6066

6167
var originalPrefix = context.CurrentValidationPath;
68+
var originalErrorCount = context.ValidationErrors?.Count ?? 0;
6269

6370
try
6471
{
72+
// First validate direct members
73+
await ValidateMembersAsync(value, context, cancellationToken);
74+
6575
var actualType = value.GetType();
6676

67-
// First validate members
68-
for (var i = 0; i < _membersCount; i++)
77+
// Then validate inherited members
78+
foreach (var superTypeInfo in GetSuperTypeInfos(actualType, context))
79+
{
80+
await superTypeInfo.ValidateMembersAsync(value, context, cancellationToken);
81+
}
82+
83+
// If any property-level validation errors were found, return early
84+
if (context.ValidationErrors is not null && context.ValidationErrors.Count > originalErrorCount)
85+
{
86+
return;
87+
}
88+
89+
// Validate type-level attributes
90+
ValidateTypeAttributes(value, context);
91+
92+
// If any type-level attribute errors were found, return early
93+
if (context.ValidationErrors is not null && context.ValidationErrors.Count > originalErrorCount)
94+
{
95+
return;
96+
}
97+
98+
// Finally validate IValidatableObject if implemented
99+
ValidateValidatableObjectInterface(value, context);
100+
}
101+
finally
102+
{
103+
context.CurrentValidationPath = originalPrefix;
104+
}
105+
}
106+
107+
private async Task ValidateMembersAsync(object? value, ValidateContext context, CancellationToken cancellationToken)
108+
{
109+
var originalPrefix = context.CurrentValidationPath;
110+
111+
for (var i = 0; i < _membersCount; i++)
112+
{
113+
try
69114
{
70115
await Members[i].ValidateAsync(value, context, cancellationToken);
116+
117+
}
118+
finally
119+
{
71120
context.CurrentValidationPath = originalPrefix;
72121
}
122+
}
123+
}
124+
125+
private void ValidateTypeAttributes(object? value, ValidateContext context)
126+
{
127+
var validationAttributes = GetValidationAttributes();
128+
var errorPrefix = context.CurrentValidationPath;
73129

74-
// Then validate sub-types if any
75-
foreach (var subType in _subTypes)
130+
for (var i = 0; i < validationAttributes.Length; i++)
131+
{
132+
var attribute = validationAttributes[i];
133+
var result = attribute.GetValidationResult(value, context.ValidationContext);
134+
if (result is not null && result != ValidationResult.Success && result.ErrorMessage is not null)
76135
{
77-
// Check if the actual type is assignable to the sub-type
78-
// and validate it if it is
79-
if (subType.IsAssignableFrom(actualType))
136+
// Create a validation error for each member name that is provided
137+
foreach (var memberName in result.MemberNames)
80138
{
81-
if (context.ValidationOptions.TryGetValidatableTypeInfo(subType, out var subTypeInfo))
82-
{
83-
await subTypeInfo.ValidateAsync(value, context, cancellationToken);
84-
context.CurrentValidationPath = originalPrefix;
85-
}
139+
var key = string.IsNullOrEmpty(errorPrefix) ? memberName : $"{errorPrefix}.{memberName}";
140+
context.AddOrExtendValidationError(memberName, key, result.ErrorMessage, value);
141+
}
142+
143+
if (!result.MemberNames.Any())
144+
{
145+
// If no member names are specified, then treat this as a top-level error
146+
context.AddOrExtendValidationError(string.Empty, errorPrefix, result.ErrorMessage, value);
86147
}
87148
}
149+
}
150+
}
88151

89-
// Finally validate IValidatableObject if implemented
90-
if (Type.ImplementsInterface(typeof(IValidatableObject)) && value is IValidatableObject validatable)
152+
private void ValidateValidatableObjectInterface(object? value, ValidateContext context)
153+
{
154+
if (Type.ImplementsInterface(typeof(IValidatableObject)) && value is IValidatableObject validatable)
155+
{
156+
// Important: Set the DisplayName to the type name for top-level validations
157+
// and restore the original validation context properties
158+
var originalDisplayName = context.ValidationContext.DisplayName;
159+
var originalMemberName = context.ValidationContext.MemberName;
160+
var errorPrefix = context.CurrentValidationPath;
161+
162+
// Set the display name to the class name for IValidatableObject validation
163+
context.ValidationContext.DisplayName = Type.Name;
164+
context.ValidationContext.MemberName = null;
165+
166+
var validationResults = validatable.Validate(context.ValidationContext);
167+
foreach (var validationResult in validationResults)
91168
{
92-
// Important: Set the DisplayName to the type name for top-level validations
93-
// and restore the original validation context properties
94-
var originalDisplayName = context.ValidationContext.DisplayName;
95-
var originalMemberName = context.ValidationContext.MemberName;
96-
97-
// Set the display name to the class name for IValidatableObject validation
98-
context.ValidationContext.DisplayName = Type.Name;
99-
context.ValidationContext.MemberName = null;
100-
101-
var validationResults = validatable.Validate(context.ValidationContext);
102-
foreach (var validationResult in validationResults)
169+
if (validationResult != ValidationResult.Success && validationResult.ErrorMessage is not null)
103170
{
104-
if (validationResult != ValidationResult.Success && validationResult.ErrorMessage is not null)
171+
// Create a validation error for each member name that is provided
172+
foreach (var memberName in validationResult.MemberNames)
105173
{
106-
// Create a validation error for each member name that is provided
107-
foreach (var memberName in validationResult.MemberNames)
108-
{
109-
var key = string.IsNullOrEmpty(originalPrefix) ?
110-
memberName :
111-
$"{originalPrefix}.{memberName}";
112-
context.AddOrExtendValidationError(memberName, key, validationResult.ErrorMessage, value);
113-
}
114-
115-
if (!validationResult.MemberNames.Any())
116-
{
117-
// If no member names are specified, then treat this as a top-level error
118-
context.AddOrExtendValidationError(string.Empty, string.Empty, validationResult.ErrorMessage, value);
119-
}
174+
var key = string.IsNullOrEmpty(errorPrefix) ? memberName : $"{errorPrefix}.{memberName}";
175+
context.AddOrExtendValidationError(memberName, key, validationResult.ErrorMessage, value);
120176
}
121-
}
122177

123-
// Restore the original validation context properties
124-
context.ValidationContext.DisplayName = originalDisplayName;
125-
context.ValidationContext.MemberName = originalMemberName;
178+
if (!validationResult.MemberNames.Any())
179+
{
180+
// If no member names are specified, then treat this as a top-level error
181+
context.AddOrExtendValidationError(string.Empty, string.Empty, validationResult.ErrorMessage, value);
182+
}
183+
}
126184
}
185+
186+
// Restore the original validation context properties
187+
context.ValidationContext.DisplayName = originalDisplayName;
188+
context.ValidationContext.MemberName = originalMemberName;
127189
}
128-
finally
190+
}
191+
192+
private IEnumerable<ValidatableTypeInfo> GetSuperTypeInfos(Type actualType, ValidateContext context)
193+
{
194+
foreach (var superType in _superTypes.Where(t => t.IsAssignableFrom(actualType)))
129195
{
130-
context.CurrentValidationPath = originalPrefix;
196+
if (context.ValidationOptions.TryGetValidatableTypeInfo(superType, out var found)
197+
&& found is ValidatableTypeInfo superTypeInfo)
198+
{
199+
yield return superTypeInfo;
200+
}
131201
}
132202
}
133203
}

src/Validation/startvscode.sh

100644100755
File mode changed.

0 commit comments

Comments
 (0)