Skip to content

Commit a46b98e

Browse files
oroztocilgithub-actions
authored andcommitted
Emit validation info for types that only have IValidatableObject method and no validation attributes
1 parent ab0cf6a commit a46b98e

File tree

4 files changed

+297
-2
lines changed

4 files changed

+297
-2
lines changed

src/Shared/RoslynUtils/WellKnownTypeData.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ public enum WellKnownType
124124
System_ComponentModel_DataAnnotations_ValidationAttribute,
125125
System_ComponentModel_DataAnnotations_RequiredAttribute,
126126
System_ComponentModel_DataAnnotations_CustomValidationAttribute,
127+
System_ComponentModel_DataAnnotations_IValidatableObject,
127128
Microsoft_Extensions_Validation_SkipValidationAttribute,
128129
System_Type,
129130
}
@@ -247,6 +248,7 @@ public enum WellKnownType
247248
"System.ComponentModel.DataAnnotations.ValidationAttribute",
248249
"System.ComponentModel.DataAnnotations.RequiredAttribute",
249250
"System.ComponentModel.DataAnnotations.CustomValidationAttribute",
251+
"System.ComponentModel.DataAnnotations.IValidatableObject",
250252
"Microsoft.Extensions.Validation.SkipValidationAttribute",
251253
"System.Type",
252254
];

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnow
8282

8383
visitedTypes.Add(typeSymbol);
8484

85-
var hasValidationAttributes = HasValidationAttributes(typeSymbol, wellKnownTypes);
85+
var hasTypeLevelValidation = HasValidationAttributes(typeSymbol, wellKnownTypes) || HasIValidatableObjectInterface(typeSymbol, wellKnownTypes);
8686

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

111111
// No validatable members or derived types found, so we don't need to add this type.
112-
if (members.IsDefaultOrEmpty && !hasValidationAttributes && !hasValidatableBaseType && !hasValidatableDerivedTypes)
112+
if (members.IsDefaultOrEmpty && !hasTypeLevelValidation && !hasValidatableBaseType && !hasValidatableDerivedTypes)
113113
{
114114
return false;
115115
}
@@ -301,4 +301,10 @@ internal static bool HasValidationAttributes(ISymbol symbol, WellKnownTypes well
301301

302302
return false;
303303
}
304+
305+
internal static bool HasIValidatableObjectInterface(ITypeSymbol typeSymbol, WellKnownTypes wellKnownTypes)
306+
{
307+
return typeSymbol.AllInterfaces.Any(i =>
308+
SymbolEqualityComparer.Default.Equals(i, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_IValidatableObject)));
309+
}
304310
}

src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,101 @@ async Task ValidateForTopLevelInvoked()
164164
}
165165
});
166166
}
167+
168+
[Fact]
169+
public async Task CanValidateIValidatableObject_WithoutPropertyValidations()
170+
{
171+
var source = """
172+
using System.Collections.Generic;
173+
using System.ComponentModel.DataAnnotations;
174+
using System.Threading.Tasks;
175+
using Microsoft.AspNetCore.Builder;
176+
using Microsoft.AspNetCore.Http;
177+
using Microsoft.Extensions.Validation;
178+
using Microsoft.AspNetCore.Mvc;
179+
using Microsoft.AspNetCore.Routing;
180+
using Microsoft.Extensions.DependencyInjection;
181+
182+
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
183+
184+
builder.Services.AddValidation();
185+
186+
WebApplication app = builder. Build();
187+
188+
app.MapPost("/base", (BaseClass model) => Results.Ok(model));
189+
app.MapPost("/derived", (DerivedClass model) => Results.Ok(model));
190+
191+
app.Run();
192+
193+
public class BaseClass : IValidatableObject
194+
{
195+
public string? Value { get; set; }
196+
197+
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
198+
{
199+
if (string.IsNullOrEmpty(Value))
200+
{
201+
yield return new ValidationResult("Value cannot be null or empty.", [nameof(Value)]);
202+
}
203+
}
204+
}
205+
206+
public class DerivedClass : BaseClass
207+
{
208+
}
209+
""";
210+
211+
await Verify(source, out var compilation);
212+
213+
await VerifyEndpoint(compilation, "/base", async (endpoint, serviceProvider) =>
214+
{
215+
await ValidateMethodCalled();
216+
217+
async Task ValidateMethodCalled()
218+
{
219+
var httpContext = CreateHttpContextWithPayload("""
220+
{
221+
"Value": ""
222+
}
223+
""", serviceProvider);
224+
225+
await endpoint.RequestDelegate(httpContext);
226+
227+
var problemDetails = await AssertBadRequest(httpContext);
228+
Assert.Collection(problemDetails.Errors,
229+
error =>
230+
{
231+
Assert.Equal("Value", error.Key);
232+
Assert.Collection(error.Value,
233+
msg => Assert.Equal("Value cannot be null or empty.", msg));
234+
});
235+
}
236+
});
237+
238+
await VerifyEndpoint(compilation, "/derived", async (endpoint, serviceProvider) =>
239+
{
240+
await ValidateMethodCalled();
241+
242+
async Task ValidateMethodCalled()
243+
{
244+
var httpContext = CreateHttpContextWithPayload("""
245+
{
246+
"Value": ""
247+
}
248+
""", serviceProvider);
249+
250+
await endpoint.RequestDelegate(httpContext);
251+
252+
var problemDetails = await AssertBadRequest(httpContext);
253+
Assert.Collection(problemDetails.Errors,
254+
error =>
255+
{
256+
Assert.Equal("Value", error.Key);
257+
Assert.Collection(error.Value,
258+
msg => Assert.Equal("Value cannot be null or empty.", msg));
259+
});
260+
}
261+
});
262+
}
263+
167264
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
//HintName: ValidatableInfoResolver.g.cs
2+
#nullable enable annotations
3+
//------------------------------------------------------------------------------
4+
// <auto-generated>
5+
// This code was generated by a tool.
6+
//
7+
// Changes to this file may cause incorrect behavior and will be lost if
8+
// the code is regenerated.
9+
// </auto-generated>
10+
//------------------------------------------------------------------------------
11+
#nullable enable
12+
#pragma warning disable ASP0029
13+
14+
namespace System.Runtime.CompilerServices
15+
{
16+
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
17+
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
18+
file sealed class InterceptsLocationAttribute : System.Attribute
19+
{
20+
public InterceptsLocationAttribute(int version, string data)
21+
{
22+
}
23+
}
24+
}
25+
26+
namespace Microsoft.Extensions.Validation.Generated
27+
{
28+
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
29+
file sealed class GeneratedValidatablePropertyInfo : global::Microsoft.Extensions.Validation.ValidatablePropertyInfo
30+
{
31+
public GeneratedValidatablePropertyInfo(
32+
[param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
33+
global::System.Type containingType,
34+
global::System.Type propertyType,
35+
string name,
36+
string displayName) : base(containingType, propertyType, name, displayName)
37+
{
38+
ContainingType = containingType;
39+
Name = name;
40+
}
41+
42+
[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
43+
internal global::System.Type ContainingType { get; }
44+
internal string Name { get; }
45+
46+
protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes()
47+
=> ValidationAttributeCache.GetPropertyValidationAttributes(ContainingType, Name);
48+
}
49+
50+
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
51+
file sealed class GeneratedValidatableTypeInfo : global::Microsoft.Extensions.Validation.ValidatableTypeInfo
52+
{
53+
public GeneratedValidatableTypeInfo(
54+
[param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
55+
global::System.Type type,
56+
ValidatablePropertyInfo[] members) : base(type, members)
57+
{
58+
Type = type;
59+
}
60+
61+
[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
62+
internal global::System.Type Type { get; }
63+
64+
protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes()
65+
=> ValidationAttributeCache.GetTypeValidationAttributes(Type);
66+
}
67+
68+
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
69+
file class GeneratedValidatableInfoResolver : global::Microsoft.Extensions.Validation.IValidatableInfoResolver
70+
{
71+
public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo)
72+
{
73+
validatableInfo = null;
74+
if (type == typeof(global::BaseClass))
75+
{
76+
validatableInfo = new GeneratedValidatableTypeInfo(
77+
type: typeof(global::BaseClass),
78+
members: []
79+
);
80+
return true;
81+
}
82+
if (type == typeof(global::DerivedClass))
83+
{
84+
validatableInfo = new GeneratedValidatableTypeInfo(
85+
type: typeof(global::DerivedClass),
86+
members: []
87+
);
88+
return true;
89+
}
90+
91+
return false;
92+
}
93+
94+
// No-ops, rely on runtime code for ParameterInfo-based resolution
95+
public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo)
96+
{
97+
validatableInfo = null;
98+
return false;
99+
}
100+
}
101+
102+
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
103+
file static class GeneratedServiceCollectionExtensions
104+
{
105+
[InterceptsLocation]
106+
public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action<global::Microsoft.Extensions.Validation.ValidationOptions>? configureOptions = null)
107+
{
108+
// Use non-extension method to avoid infinite recursion.
109+
return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options =>
110+
{
111+
options.Resolvers.Insert(0, new GeneratedValidatableInfoResolver());
112+
if (configureOptions is not null)
113+
{
114+
configureOptions(options);
115+
}
116+
});
117+
}
118+
}
119+
120+
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")]
121+
file static class ValidationAttributeCache
122+
{
123+
private sealed record CacheKey(
124+
[param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
125+
[property: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
126+
global::System.Type ContainingType,
127+
string PropertyName);
128+
private static readonly global::System.Collections.Concurrent.ConcurrentDictionary<CacheKey, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]> _propertyCache = new();
129+
private static readonly global::System.Lazy<global::System.Collections.Concurrent.ConcurrentDictionary<global::System.Type, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]>> _lazyTypeCache = new (() => new ());
130+
private static global::System.Collections.Concurrent.ConcurrentDictionary<global::System.Type, global::System.ComponentModel.DataAnnotations.ValidationAttribute[]> TypeCache => _lazyTypeCache.Value;
131+
132+
public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetPropertyValidationAttributes(
133+
[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)]
134+
global::System.Type containingType,
135+
string propertyName)
136+
{
137+
var key = new CacheKey(containingType, propertyName);
138+
return _propertyCache.GetOrAdd(key, static k =>
139+
{
140+
var results = new global::System.Collections.Generic.List<global::System.ComponentModel.DataAnnotations.ValidationAttribute>();
141+
142+
// Get attributes from the property
143+
var property = k.ContainingType.GetProperty(k.PropertyName);
144+
if (property != null)
145+
{
146+
var propertyAttributes = global::System.Reflection.CustomAttributeExtensions
147+
.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(property, inherit: true);
148+
149+
results.AddRange(propertyAttributes);
150+
}
151+
152+
// Check constructors for parameters that match the property name
153+
// to handle record scenarios
154+
foreach (var constructor in k.ContainingType.GetConstructors())
155+
{
156+
// Look for parameter with matching name (case insensitive)
157+
var parameter = global::System.Linq.Enumerable.FirstOrDefault(
158+
constructor.GetParameters(),
159+
p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase));
160+
161+
if (parameter != null)
162+
{
163+
var paramAttributes = global::System.Reflection.CustomAttributeExtensions
164+
.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(parameter, inherit: true);
165+
166+
results.AddRange(paramAttributes);
167+
168+
break;
169+
}
170+
}
171+
172+
return results.ToArray();
173+
});
174+
}
175+
176+
177+
public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetTypeValidationAttributes(
178+
[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)]
179+
global::System.Type type
180+
)
181+
{
182+
return TypeCache.GetOrAdd(type, static t =>
183+
{
184+
var typeAttributes = global::System.Reflection.CustomAttributeExtensions
185+
.GetCustomAttributes<global::System.ComponentModel.DataAnnotations.ValidationAttribute>(t, inherit: true);
186+
return global::System.Linq.Enumerable.ToArray(typeAttributes);
187+
});
188+
}
189+
}
190+
}

0 commit comments

Comments
 (0)