From a23e846f03b6b115bcaf73225ecb318765550db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Rozto=C4=8Dil?= Date: Mon, 25 Aug 2025 16:39:37 +0200 Subject: [PATCH 1/3] Emit validation info for types that only have IValidatableObject method and no validation attributes --- src/Shared/RoslynUtils/WellKnownTypeData.cs | 2 + .../ValidationsGenerator.TypesParser.cs | 10 +- ...ValidationsGenerator.IValidatableObject.cs | 97 +++++++++ ...ions#ValidatableInfoResolver.g.verified.cs | 190 ++++++++++++++++++ 4 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs diff --git a/src/Shared/RoslynUtils/WellKnownTypeData.cs b/src/Shared/RoslynUtils/WellKnownTypeData.cs index 4311eed4c6ac..a64dd1a426e8 100644 --- a/src/Shared/RoslynUtils/WellKnownTypeData.cs +++ b/src/Shared/RoslynUtils/WellKnownTypeData.cs @@ -124,6 +124,7 @@ public enum WellKnownType System_ComponentModel_DataAnnotations_ValidationAttribute, System_ComponentModel_DataAnnotations_RequiredAttribute, System_ComponentModel_DataAnnotations_CustomValidationAttribute, + System_ComponentModel_DataAnnotations_IValidatableObject, Microsoft_Extensions_Validation_SkipValidationAttribute, System_Type, } @@ -247,6 +248,7 @@ public enum WellKnownType "System.ComponentModel.DataAnnotations.ValidationAttribute", "System.ComponentModel.DataAnnotations.RequiredAttribute", "System.ComponentModel.DataAnnotations.CustomValidationAttribute", + "System.ComponentModel.DataAnnotations.IValidatableObject", "Microsoft.Extensions.Validation.SkipValidationAttribute", "System.Type", ]; diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs index 7544c9d72feb..d39a50d5899d 100644 --- a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs +++ b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs @@ -82,7 +82,7 @@ internal bool TryExtractValidatableType(ITypeSymbol incomingTypeSymbol, WellKnow visitedTypes.Add(typeSymbol); - var hasValidationAttributes = HasValidationAttributes(typeSymbol, wellKnownTypes); + var hasTypeLevelValidation = HasValidationAttributes(typeSymbol, wellKnownTypes) || HasIValidatableObjectInterface(typeSymbol, wellKnownTypes); // Extract validatable types discovered in base types of this type and add them to the top-level list. var current = typeSymbol.BaseType; @@ -109,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 && !hasValidationAttributes && !hasValidatableBaseType && !hasValidatableDerivedTypes) + if (members.IsDefaultOrEmpty && !hasTypeLevelValidation && !hasValidatableBaseType && !hasValidatableDerivedTypes) { return false; } @@ -301,4 +301,10 @@ internal static bool HasValidationAttributes(ISymbol symbol, WellKnownTypes well return false; } + + internal static bool HasIValidatableObjectInterface(ITypeSymbol typeSymbol, WellKnownTypes wellKnownTypes) + { + return typeSymbol.AllInterfaces.Any(i => + SymbolEqualityComparer.Default.Equals(i, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_IValidatableObject))); + } } diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs index ea4c7c48fb63..d5245e3165c7 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs @@ -164,4 +164,101 @@ async Task ValidateForTopLevelInvoked() } }); } + + [Fact] + public async Task CanValidateIValidatableObject_WithoutPropertyValidations() + { + var source = """ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Validation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddValidation(); + +WebApplication app = builder. Build(); + +app.MapPost("/base", (BaseClass model) => Results.Ok(model)); +app.MapPost("/derived", (DerivedClass model) => Results.Ok(model)); + +app.Run(); + +public class BaseClass : IValidatableObject +{ + public string? Value { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrEmpty(Value)) + { + yield return new ValidationResult("Value cannot be null or empty.", [nameof(Value)]); + } + } +} + +public class DerivedClass : BaseClass +{ +} +"""; + + await Verify(source, out var compilation); + + await VerifyEndpoint(compilation, "/base", async (endpoint, serviceProvider) => + { + await ValidateMethodCalled(); + + async Task ValidateMethodCalled() + { + var httpContext = CreateHttpContextWithPayload(""" + { + "Value": "" + } + """, serviceProvider); + + await endpoint.RequestDelegate(httpContext); + + var problemDetails = await AssertBadRequest(httpContext); + Assert.Collection(problemDetails.Errors, + error => + { + Assert.Equal("Value", error.Key); + Assert.Collection(error.Value, + msg => Assert.Equal("Value cannot be null or empty.", msg)); + }); + } + }); + + await VerifyEndpoint(compilation, "/derived", async (endpoint, serviceProvider) => + { + await ValidateMethodCalled(); + + async Task ValidateMethodCalled() + { + var httpContext = CreateHttpContextWithPayload(""" + { + "Value": "" + } + """, serviceProvider); + + await endpoint.RequestDelegate(httpContext); + + var problemDetails = await AssertBadRequest(httpContext); + Assert.Collection(problemDetails.Errors, + error => + { + Assert.Equal("Value", error.Key); + Assert.Collection(error.Value, + msg => Assert.Equal("Value cannot be null or empty.", msg)); + }); + } + }); + } + } diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs new file mode 100644 index 000000000000..ade99042a977 --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs @@ -0,0 +1,190 @@ +//HintName: ValidatableInfoResolver.g.cs +#nullable enable annotations +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +#nullable enable +#pragma warning disable ASP0029 + +namespace System.Runtime.CompilerServices +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : System.Attribute + { + public InterceptsLocationAttribute(int version, string data) + { + } + } +} + +namespace Microsoft.Extensions.Validation.Generated +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatablePropertyInfo : global::Microsoft.Extensions.Validation.ValidatablePropertyInfo + { + public GeneratedValidatablePropertyInfo( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type containingType, + global::System.Type propertyType, + string name, + string displayName) : base(containingType, propertyType, name, displayName) + { + ContainingType = containingType; + Name = name; + } + + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + internal global::System.Type ContainingType { get; } + internal string Name { get; } + + protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes() + => ValidationAttributeCache.GetPropertyValidationAttributes(ContainingType, Name); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatableTypeInfo : global::Microsoft.Extensions.Validation.ValidatableTypeInfo + { + public GeneratedValidatableTypeInfo( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + global::System.Type type, + 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); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class GeneratedValidatableInfoResolver : global::Microsoft.Extensions.Validation.IValidatableInfoResolver + { + public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + if (type == typeof(global::BaseClass)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::BaseClass), + members: [] + ); + return true; + } + if (type == typeof(global::DerivedClass)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::DerivedClass), + members: [] + ); + return true; + } + + return false; + } + + // No-ops, rely on runtime code for ParameterInfo-based resolution + public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + return false; + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class GeneratedServiceCollectionExtensions + { + [InterceptsLocation] + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action? configureOptions = null) + { + // Use non-extension method to avoid infinite recursion. + return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options => + { + options.Resolvers.Insert(0, new GeneratedValidatableInfoResolver()); + if (configureOptions is not null) + { + configureOptions(options); + } + }); + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class ValidationAttributeCache + { + private sealed record CacheKey( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + [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 _propertyCache = new(); + private static readonly global::System.Lazy> _lazyTypeCache = new (() => new ()); + private static global::System.Collections.Concurrent.ConcurrentDictionary TypeCache => _lazyTypeCache.Value; + + 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 _propertyCache.GetOrAdd(key, static k => + { + var results = new global::System.Collections.Generic.List(); + + // Get attributes from the property + var property = k.ContainingType.GetProperty(k.PropertyName); + if (property != null) + { + var propertyAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(property, inherit: true); + + results.AddRange(propertyAttributes); + } + + // Check constructors for parameters that match the property name + // to handle record scenarios + foreach (var constructor in k.ContainingType.GetConstructors()) + { + // Look for parameter with matching name (case insensitive) + var parameter = global::System.Linq.Enumerable.FirstOrDefault( + constructor.GetParameters(), + p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase)); + + if (parameter != null) + { + var paramAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(parameter, inherit: true); + + results.AddRange(paramAttributes); + + break; + } + } + + 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(t, inherit: true); + return global::System.Linq.Enumerable.ToArray(typeAttributes); + }); + } + } +} \ No newline at end of file From 445d4c782527d9e70c4331f3436f1fc025892cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Rozto=C4=8Dil?= Date: Mon, 25 Aug 2025 16:49:47 +0200 Subject: [PATCH 2/3] Refactor the interface check to use explicit loop instead of LINQ --- .../gen/Parsers/ValidationsGenerator.TypesParser.cs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs index d39a50d5899d..23843ac83609 100644 --- a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs +++ b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs @@ -304,7 +304,16 @@ internal static bool HasValidationAttributes(ISymbol symbol, WellKnownTypes well internal static bool HasIValidatableObjectInterface(ITypeSymbol typeSymbol, WellKnownTypes wellKnownTypes) { - return typeSymbol.AllInterfaces.Any(i => - SymbolEqualityComparer.Default.Equals(i, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_IValidatableObject))); + var validatableObjectSymbol = wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_IValidatableObject); + + foreach (var inter in typeSymbol.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(inter, validatableObjectSymbol)) + { + return true; + } + } + + return false; } } From 7a646831070e397609e27bb3c27bbb4f474f24a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Rozto=C4=8Dil?= Date: Tue, 26 Aug 2025 10:56:03 +0200 Subject: [PATCH 3/3] Add test case, reuse ImplementsInterface method --- .../ValidationsGenerator.TypesParser.cs | 11 +---- ...ValidationsGenerator.IValidatableObject.cs | 46 ++++++++++++++++++- ...ions#ValidatableInfoResolver.g.verified.cs | 23 ++++++++++ 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs index 23843ac83609..cc0624fdfb93 100644 --- a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs +++ b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs @@ -305,15 +305,6 @@ internal static bool HasValidationAttributes(ISymbol symbol, WellKnownTypes well internal static bool HasIValidatableObjectInterface(ITypeSymbol typeSymbol, WellKnownTypes wellKnownTypes) { var validatableObjectSymbol = wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_IValidatableObject); - - foreach (var inter in typeSymbol.AllInterfaces) - { - if (SymbolEqualityComparer.Default.Equals(inter, validatableObjectSymbol)) - { - return true; - } - } - - return false; + return typeSymbol.ImplementsInterface(validatableObjectSymbol); } } diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs index d5245e3165c7..e3fe63ddca97 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.IValidatableObject.cs @@ -187,6 +187,7 @@ public async Task CanValidateIValidatableObject_WithoutPropertyValidations() app.MapPost("/base", (BaseClass model) => Results.Ok(model)); app.MapPost("/derived", (DerivedClass model) => Results.Ok(model)); +app.MapPost("/complex", (ComplexClass model) => Results.Ok(model)); app.Run(); @@ -206,6 +207,23 @@ public IEnumerable Validate(ValidationContext validationContex public class DerivedClass : BaseClass { } + +public class ComplexClass +{ + public NestedClass? NestedObject { get; set; } +} + +public class NestedClass : IValidatableObject +{ + public string? Value { get; set; } + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrEmpty(Value)) + { + yield return new ValidationResult("Value cannot be null or empty.", [nameof(Value)]); + } + } +} """; await Verify(source, out var compilation); @@ -259,6 +277,32 @@ async Task ValidateMethodCalled() }); } }); - } + await VerifyEndpoint(compilation, "/complex", async (endpoint, serviceProvider) => + { + await ValidateMethodCalled(); + + async Task ValidateMethodCalled() + { + var httpContext = CreateHttpContextWithPayload(""" + { + "NestedObject": { + "Value": "" + } + } + """, serviceProvider); + + await endpoint.RequestDelegate(httpContext); + + var problemDetails = await AssertBadRequest(httpContext); + Assert.Collection(problemDetails.Errors, + error => + { + Assert.Equal("NestedObject.Value", error.Key); + Assert.Collection(error.Value, + msg => Assert.Equal("Value cannot be null or empty.", msg)); + }); + } + }); + } } diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs index ade99042a977..ecd4ed6edd21 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject_WithoutPropertyValidations#ValidatableInfoResolver.g.verified.cs @@ -87,6 +87,29 @@ public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System. ); return true; } + if (type == typeof(global::NestedClass)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::NestedClass), + members: [] + ); + return true; + } + if (type == typeof(global::ComplexClass)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::ComplexClass), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ComplexClass), + propertyType: typeof(global::NestedClass), + name: "NestedObject", + displayName: "NestedObject" + ), + ] + ); + return true; + } return false; }