Skip to content

Commit 48cf20a

Browse files
committed
Add support for type-level validation attributes, update validation ordering
1 parent 64804f9 commit 48cf20a

File tree

31 files changed

+1507
-575
lines changed

31 files changed

+1507
-575
lines changed

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

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
90+
internal global::System.Type Type { get; }
91+
92+
protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes()
93+
=> ValidationAttributeCache.GetValidationAttributes(Type, null);
8594
}
8695
8796
{{GeneratedCodeAttribute}}
@@ -127,46 +136,60 @@ private sealed record CacheKey(
127136
[param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
128137
[property: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
129138
global::System.Type ContainingType,
130-
string PropertyName);
139+
string? PropertyName);
131140
private static readonly global::System.Collections.Concurrent.ConcurrentDictionary<CacheKey, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]> _cache = new();
132141
133142
public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes(
134143
[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
135144
global::System.Type containingType,
136-
string propertyName)
145+
string? propertyName)
137146
{
138147
var key = new CacheKey(containingType, propertyName);
139148
return _cache.GetOrAdd(key, static k =>
140149
{
141150
var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
142151
143-
// Get attributes from the property
144-
var property = k.ContainingType.GetProperty(k.PropertyName);
145-
if (property != null)
152+
if (k.PropertyName is not null)
146153
{
147-
var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
148-
.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
149-
150-
results.AddRange(propertyAttributes);
151-
}
154+
// Get attributes from the property
155+
var property = k.ContainingType.GetProperty(k.PropertyName);
156+
if (property != null)
157+
{
158+
var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
159+
.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
152160
153-
// Check constructors for parameters that match the property name
154-
// to handle record scenarios
155-
foreach (var constructor in k.ContainingType.GetConstructors())
156-
{
157-
// Look for parameter with matching name (case insensitive)
158-
var parameter = global::System.Linq.Enumerable.FirstOrDefault(
159-
constructor.GetParameters(),
160-
p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
161+
results.AddRange(propertyAttributes);
162+
}
161163
162-
if (parameter != null)
164+
// Check constructors for parameters that match the property name
165+
// to handle record scenarios
166+
foreach (var constructor in k.ContainingType.GetConstructors())
163167
{
164-
var paramAttributes = global::System.Reflection.CustomAttributeExtensions
165-
.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
168+
// Look for parameter with matching name (case insensitive)
169+
var parameter = global::System.Linq.Enumerable.FirstOrDefault(
170+
constructor.GetParameters(),
171+
p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
172+
173+
if (parameter != null)
174+
{
175+
var paramAttributes = global::System.Reflection.CustomAttributeExtensions
176+
.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
166177
167-
results.AddRange(paramAttributes);
178+
results.AddRange(paramAttributes);
168179
169-
break;
180+
break;
181+
}
182+
}
183+
}
184+
else
185+
{
186+
// Get attributes from the type itself and its super types
187+
foreach (var attr in k.ContainingType.GetCustomAttributes(typeof(global::System.ComponentModel.DataAnnotations.ValidationAttribute), true))
188+
{
189+
if (attr is global::System.ComponentModel.DataAnnotations.ValidationAttribute validationAttribute)
190+
{
191+
results.Add(validationAttribute);
192+
}
170193
}
171194
}
172195

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

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

8383
visitedTypes.Add(typeSymbol);
8484

85+
var hasValidationAttributes = typeSymbol.GetAttributes()
86+
.Any(attribute => attribute.AttributeClass != null &&
87+
attribute.AttributeClass.ImplementsValidationAttribute(
88+
wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_ValidationAttribute)));
89+
8590
// Extract validatable types discovered in base types of this type and add them to the top-level list.
8691
var current = typeSymbol.BaseType;
8792
var hasValidatableBaseType = false;
@@ -107,7 +112,7 @@ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnow
107112
}
108113

109114
// No validatable members or derived types found, so we don't need to add this type.
110-
if (members.IsDefaultOrEmpty && !hasValidatableBaseType && !hasValidatableDerivedTypes)
115+
if (members.IsDefaultOrEmpty && !hasValidationAttributes && !hasValidatableBaseType && !hasValidatableDerivedTypes)
111116
{
112117
return false;
113118
}

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)