From 012df363db2bd8d5bed36944dd372f166b6bacfb Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Tue, 8 Apr 2025 15:29:27 -0700 Subject: [PATCH 1/2] Fix handling of record types in validations source generator --- .../Emitters/ValidationsGenerator.Emitter.cs | 34 +- .../Extensions/ITypeSymbolExtensions.cs | 37 ++ .../ValidationsGenerator.TypesParser.cs | 59 ++++ .../ValidationsGenerator.RecordType.cs | 332 ++++++++++++++++++ ...ypes#ValidatableInfoResolver.g.verified.cs | 34 +- ...ject#ValidatableInfoResolver.g.verified.cs | 34 +- ...ters#ValidatableInfoResolver.g.verified.cs | 34 +- ...ypes#ValidatableInfoResolver.g.verified.cs | 34 +- ...ypes#ValidatableInfoResolver.g.verified.cs | 255 ++++++++++++++ ...ypes#ValidatableInfoResolver.g.verified.cs | 34 +- ...bute#ValidatableInfoResolver.g.verified.cs | 34 +- ...ypes#ValidatableInfoResolver.g.verified.cs | 34 +- 12 files changed, 923 insertions(+), 32 deletions(-) create mode 100644 src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs create mode 100644 src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs index e578c6abe9b9..b67a844ccc0b 100644 --- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs +++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs @@ -106,7 +106,7 @@ public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterIn file static class GeneratedServiceCollectionExtensions { {{addValidation.GetInterceptsLocationAttributeSyntax()}} - public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action? configureOptions = null) + 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 => @@ -133,13 +133,39 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper var key = new CacheKey(containingType, propertyName); return _cache.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) + 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()) { - return []; + // 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 [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes(property, inherit: true)]; + return results.ToArray(); }); } } diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Extensions/ITypeSymbolExtensions.cs b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Extensions/ITypeSymbolExtensions.cs index a09a575e2782..794fca82ed37 100644 --- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Extensions/ITypeSymbolExtensions.cs +++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Extensions/ITypeSymbolExtensions.cs @@ -1,7 +1,9 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using Microsoft.CodeAnalysis; namespace Microsoft.AspNetCore.Http.ValidationsGenerator; @@ -101,4 +103,39 @@ internal static bool IsExemptType(this ITypeSymbol type, RequiredSymbols require || SymbolEqualityComparer.Default.Equals(type, requiredSymbols.Stream) || SymbolEqualityComparer.Default.Equals(type, requiredSymbols.PipeReader); } + + internal static IPropertySymbol? FindPropertyIncludingBaseTypes(this INamedTypeSymbol typeSymbol, string propertyName) + { + var property = typeSymbol.GetMembers() + .OfType() + .FirstOrDefault(p => string.Equals(p.Name, propertyName, System.StringComparison.OrdinalIgnoreCase)); + + if (property != null) + { + return property; + } + + // If not found, recursively search base types + if (typeSymbol.BaseType is INamedTypeSymbol baseType) + { + return FindPropertyIncludingBaseTypes(baseType, propertyName); + } + + return null; + } + + // Helper method to get all properties including inherited ones + internal static IEnumerable GetAllProperties(this ITypeSymbol typeSymbol) + { + var current = typeSymbol; + var properties = new List(); + + while (current != null && current.SpecialType != SpecialType.System_Object) + { + properties.AddRange(current.GetMembers().OfType()); + current = current.BaseType; + } + + return properties; + } } diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Parsers/ValidationsGenerator.TypesParser.cs b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Parsers/ValidationsGenerator.TypesParser.cs index ecba1bd124ef..b887399d4c4a 100644 --- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Parsers/ValidationsGenerator.TypesParser.cs +++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Parsers/ValidationsGenerator.TypesParser.cs @@ -89,15 +89,74 @@ internal bool TryExtractValidatableType(ITypeSymbol typeSymbol, RequiredSymbols internal ImmutableArray ExtractValidatableMembers(ITypeSymbol typeSymbol, RequiredSymbols requiredSymbols, ref HashSet validatableTypes, ref List visitedTypes) { var members = new List(); + var resolvedRecordProperty = new List(); + + // Special handling for record types to extract properties from + // the primary constructor. + if (typeSymbol is INamedTypeSymbol { IsRecord: true } namedType) + { + // Find the primary constructor for the record, account + // for members that are in base types to account for + // record inheritance scenarios + var primaryConstructor = namedType.Constructors + .FirstOrDefault(c => c.Parameters.Length > 0 && c.Parameters.All(p => + namedType.FindPropertyIncludingBaseTypes(p.Name) != null)); + + if (primaryConstructor != null) + { + // Process all parameters in constructor order to maintain parameter ordering + foreach (var parameter in primaryConstructor.Parameters) + { + // Find the corresponding property in this type, we ignore + // base types here since that will be handled by the inheritance + // checks in the default ValidatableTypeInfo implementation. + var correspondingProperty = typeSymbol.GetMembers() + .OfType() + .FirstOrDefault(p => string.Equals(p.Name, parameter.Name, System.StringComparison.OrdinalIgnoreCase)); + + if (correspondingProperty != null) + { + resolvedRecordProperty.Add(correspondingProperty); + + // Check if the property's type is validatable, this resolves + // validatable types in the inheritance hierarchy + var hasValidatableType = TryExtractValidatableType( + correspondingProperty.Type.UnwrapType(requiredSymbols.IEnumerable), + requiredSymbols, + ref validatableTypes, + ref visitedTypes); + + members.Add(new ValidatableProperty( + ContainingType: correspondingProperty.ContainingType, + Type: correspondingProperty.Type, + Name: correspondingProperty.Name, + DisplayName: parameter.GetDisplayName(requiredSymbols.DisplayAttribute) ?? + correspondingProperty.GetDisplayName(requiredSymbols.DisplayAttribute), + Attributes: [])); + } + } + } + } + + // Handle properties for classes and any properties not handled by the constructor foreach (var member in typeSymbol.GetMembers().OfType()) { + // Skip compiler generated properties and properties already processed via + // the record processing logic above. + if (member.IsImplicitlyDeclared || resolvedRecordProperty.Contains(member, SymbolEqualityComparer.Default)) + { + continue; + } + var hasValidatableType = TryExtractValidatableType(member.Type.UnwrapType(requiredSymbols.IEnumerable), requiredSymbols, ref validatableTypes, ref visitedTypes); var attributes = ExtractValidationAttributes(member, requiredSymbols, out var isRequired); + // If the member has no validation attributes or validatable types and is not required, skip it. if (attributes.IsDefaultOrEmpty && !hasValidatableType && !isRequired) { continue; } + members.Add(new ValidatableProperty( ContainingType: member.ContainingType, Type: member.Type, diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs new file mode 100644 index 000000000000..2e787c48aae2 --- /dev/null +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs @@ -0,0 +1,332 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Http.ValidationsGenerator.Tests; + +public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase +{ + [Fact] + public async Task CanValidateRecordTypes() + { + // Arrange + var source = """ +using System; +using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Validation; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddValidation(); + +var app = builder.Build(); + +app.MapPost("/validatable-record", (ValidatableRecord validatableRecord) => Results.Ok("Passed"!)); + +app.Run(); + +public class DerivedValidationAttribute : ValidationAttribute +{ + public override bool IsValid(object? value) => value is int number && number % 2 == 0; +} + +public record SubType([Required] string RequiredProperty = "some-value", [StringLength(10)] string? StringWithLength = default); + +public record SubTypeWithInheritance([EmailAddress] string? EmailString, string RequiredProperty, string? StringWithLength) : SubType(RequiredProperty, StringWithLength); + +public static class CustomValidators +{ + public static ValidationResult Validate(int number, ValidationContext validationContext) + { + var parent = (ValidatableRecord)validationContext.ObjectInstance; + if (number == parent.IntegerWithRange) + { + return new ValidationResult( + "Can't use the same number value in two properties on the same class.", + new[] { validationContext.MemberName }); + } + + return ValidationResult.Success; + } +} + +public record ValidatableRecord( + [Range(10, 100)] + int IntegerWithRange = 10, + [Range(10, 100), Display(Name = "Valid identifier")] + int IntegerWithRangeAndDisplayName = 50, + SubType PropertyWithMemberAttributes = default, + SubType PropertyWithoutMemberAttributes = default, + SubTypeWithInheritance PropertyWithInheritance = default, + List ListOfSubTypes = default, + [DerivedValidation(ErrorMessage = "Value must be an even number")] + int IntegerWithDerivedValidationAttribute = 0, + [CustomValidation(typeof(CustomValidators), nameof(CustomValidators.Validate))] + int IntegerWithCustomValidation = 0, + [DerivedValidation, Range(10, 100)] + int PropertyWithMultipleAttributes = 10 +); +"""; + await Verify(source, out var compilation); + await VerifyEndpoint(compilation, "/validatable-record", async (endpoint, serviceProvider) => + { + await InvalidIntegerWithRangeProducesError(endpoint); + await InvalidIntegerWithRangeAndDisplayNameProducesError(endpoint); + await InvalidRequiredSubtypePropertyProducesError(endpoint); + await InvalidSubTypeWithInheritancePropertyProducesError(endpoint); + await InvalidListOfSubTypesProducesError(endpoint); + await InvalidPropertyWithDerivedValidationAttributeProducesError(endpoint); + await InvalidPropertyWithMultipleAttributesProducesError(endpoint); + await InvalidPropertyWithCustomValidationProducesError(endpoint); + await ValidInputProducesNoWarnings(endpoint); + + async Task InvalidIntegerWithRangeProducesError(Endpoint endpoint) + { + + var payload = """ + { + "IntegerWithRange": 5 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("IntegerWithRange", kvp.Key); + Assert.Equal("The field IntegerWithRange must be between 10 and 100.", kvp.Value.Single()); + }); + } + + async Task InvalidIntegerWithRangeAndDisplayNameProducesError(Endpoint endpoint) + { + var payload = """ + { + "IntegerWithRangeAndDisplayName": 5 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("IntegerWithRangeAndDisplayName", kvp.Key); + Assert.Equal("The field Valid identifier must be between 10 and 100.", kvp.Value.Single()); + }); + } + + async Task InvalidRequiredSubtypePropertyProducesError(Endpoint endpoint) + { + var payload = """ + { + "PropertyWithMemberAttributes": { + "RequiredProperty": "", + "StringWithLength": "way-too-long" + } + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, + kvp => + { + Assert.Equal("PropertyWithMemberAttributes.RequiredProperty", kvp.Key); + Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("PropertyWithMemberAttributes.StringWithLength", kvp.Key); + Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + }); + } + + async Task InvalidSubTypeWithInheritancePropertyProducesError(Endpoint endpoint) + { + var payload = """ + { + "PropertyWithInheritance": { + "RequiredProperty": "", + "StringWithLength": "way-too-long", + "EmailString": "not-an-email" + } + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, + kvp => + { + Assert.Equal("PropertyWithInheritance.EmailString", kvp.Key); + Assert.Equal("The EmailString field is not a valid e-mail address.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("PropertyWithInheritance.RequiredProperty", kvp.Key); + Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("PropertyWithInheritance.StringWithLength", kvp.Key); + Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + }); + } + + async Task InvalidListOfSubTypesProducesError(Endpoint endpoint) + { + var payload = """ + { + "ListOfSubTypes": [ + { + "RequiredProperty": "", + "StringWithLength": "way-too-long" + }, + { + "RequiredProperty": "valid", + "StringWithLength": "way-too-long" + }, + { + "RequiredProperty": "valid", + "StringWithLength": "valid" + } + ] + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, + kvp => + { + Assert.Equal("ListOfSubTypes[0].RequiredProperty", kvp.Key); + Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("ListOfSubTypes[0].StringWithLength", kvp.Key); + Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("ListOfSubTypes[1].StringWithLength", kvp.Key); + Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + }); + } + + async Task InvalidPropertyWithDerivedValidationAttributeProducesError(Endpoint endpoint) + { + var payload = """ + { + "IntegerWithDerivedValidationAttribute": 5 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("IntegerWithDerivedValidationAttribute", kvp.Key); + Assert.Equal("Value must be an even number", kvp.Value.Single()); + }); + } + + async Task InvalidPropertyWithMultipleAttributesProducesError(Endpoint endpoint) + { + var payload = """ + { + "PropertyWithMultipleAttributes": 5 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("PropertyWithMultipleAttributes", kvp.Key); + Assert.Collection(kvp.Value, + error => + { + Assert.Equal("The field PropertyWithMultipleAttributes is invalid.", error); + }, + error => + { + Assert.Equal("The field PropertyWithMultipleAttributes must be between 10 and 100.", error); + }); + }); + } + + async Task InvalidPropertyWithCustomValidationProducesError(Endpoint endpoint) + { + var payload = """ + { + "IntegerWithRange": 42, + "IntegerWithCustomValidation": 42 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("IntegerWithCustomValidation", kvp.Key); + var error = Assert.Single(kvp.Value); + Assert.Equal("Can't use the same number value in two properties on the same class.", error); + }); + } + + async Task ValidInputProducesNoWarnings(Endpoint endpoint) + { + var payload = """ + { + "IntegerWithRange": 50, + "IntegerWithRangeAndDisplayName": 50, + "PropertyWithMemberAttributes": { + "RequiredProperty": "valid", + "StringWithLength": "valid" + }, + "PropertyWithoutMemberAttributes": { + "RequiredProperty": "valid", + "StringWithLength": "valid" + }, + "PropertyWithInheritance": { + "RequiredProperty": "valid", + "StringWithLength": "valid", + "EmailString": "test@example.com" + }, + "ListOfSubTypes": [], + "IntegerWithDerivedValidationAttribute": 2, + "IntegerWithCustomValidation": 0, + "PropertyWithMultipleAttributes": 12 + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + await endpoint.RequestDelegate(context); + + Assert.Equal(200, context.Response.StatusCode); + } + }); + + } +} diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs index 6469f6e496a6..29b739eaf3ad 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs @@ -189,7 +189,7 @@ private ValidatableTypeInfo CreateComplexType() 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) + 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 => @@ -216,13 +216,39 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper var key = new CacheKey(containingType, propertyName); return _cache.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) + 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()) { - return []; + // 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 [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes(property, inherit: true)]; + return results.ToArray(); }); } } diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs index ed4dec7b1676..15d27a64e2f4 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs @@ -140,7 +140,7 @@ private ValidatableTypeInfo CreateComplexValidatableType() 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) + 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 => @@ -167,13 +167,39 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper var key = new CacheKey(containingType, propertyName); return _cache.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) + 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()) { - return []; + // 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 [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes(property, inherit: true)]; + return results.ToArray(); }); } } diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs index 930500e7a4ff..867c3ece0e77 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs @@ -78,7 +78,7 @@ public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterIn 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) + 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 => @@ -105,13 +105,39 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper var key = new CacheKey(containingType, propertyName); return _cache.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) + 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()) { - return []; + // 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 [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes(property, inherit: true)]; + return results.ToArray(); }); } } diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs index a33248f6abae..cec9e7e0a8ad 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs @@ -178,7 +178,7 @@ private ValidatableTypeInfo CreateContainerType() 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) + 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 => @@ -205,13 +205,39 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper var key = new CacheKey(containingType, propertyName); return _cache.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) + 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()) { - return []; + // 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 [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes(property, inherit: true)]; + return results.ToArray(); }); } } diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs new file mode 100644 index 000000000000..c6605ca3492f --- /dev/null +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs @@ -0,0 +1,255 @@ +//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 + +namespace System.Runtime.CompilerServices +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.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.AspNetCore.Http.Validation.Generated +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatablePropertyInfo : global::Microsoft.AspNetCore.Http.Validation.ValidatablePropertyInfo + { + public GeneratedValidatablePropertyInfo( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties)] + global::System.Type containingType, + global::System.Type propertyType, + string name, + string displayName) : base(containingType, propertyType, name, displayName) + { + ContainingType = containingType; + Name = name; + } + + internal global::System.Type ContainingType { get; } + internal string Name { get; } + + protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes() + => ValidationAttributeCache.GetValidationAttributes(ContainingType, Name); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatableTypeInfo : global::Microsoft.AspNetCore.Http.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) { } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class GeneratedValidatableInfoResolver : global::Microsoft.AspNetCore.Http.Validation.IValidatableInfoResolver + { + public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.AspNetCore.Http.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + if (type == typeof(global::SubType)) + { + validatableInfo = CreateSubType(); + return true; + } + if (type == typeof(global::SubTypeWithInheritance)) + { + validatableInfo = CreateSubTypeWithInheritance(); + return true; + } + if (type == typeof(global::ValidatableRecord)) + { + validatableInfo = CreateValidatableRecord(); + 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.AspNetCore.Http.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + return false; + } + + private ValidatableTypeInfo CreateSubType() + { + return new GeneratedValidatableTypeInfo( + type: typeof(global::SubType), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::SubType), + propertyType: typeof(string), + name: "RequiredProperty", + displayName: "RequiredProperty" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::SubType), + propertyType: typeof(string), + name: "StringWithLength", + displayName: "StringWithLength" + ), + ] + ); + } + private ValidatableTypeInfo CreateSubTypeWithInheritance() + { + return new GeneratedValidatableTypeInfo( + type: typeof(global::SubTypeWithInheritance), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::SubTypeWithInheritance), + propertyType: typeof(string), + name: "EmailString", + displayName: "EmailString" + ), + ] + ); + } + private ValidatableTypeInfo CreateValidatableRecord() + { + return new GeneratedValidatableTypeInfo( + type: typeof(global::ValidatableRecord), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ValidatableRecord), + propertyType: typeof(int), + name: "IntegerWithRange", + displayName: "IntegerWithRange" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ValidatableRecord), + propertyType: typeof(int), + name: "IntegerWithRangeAndDisplayName", + displayName: "Valid identifier" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ValidatableRecord), + propertyType: typeof(global::SubType), + name: "PropertyWithMemberAttributes", + displayName: "PropertyWithMemberAttributes" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ValidatableRecord), + propertyType: typeof(global::SubType), + name: "PropertyWithoutMemberAttributes", + displayName: "PropertyWithoutMemberAttributes" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ValidatableRecord), + propertyType: typeof(global::SubTypeWithInheritance), + name: "PropertyWithInheritance", + displayName: "PropertyWithInheritance" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ValidatableRecord), + propertyType: typeof(global::System.Collections.Generic.List), + name: "ListOfSubTypes", + displayName: "ListOfSubTypes" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ValidatableRecord), + propertyType: typeof(int), + name: "IntegerWithDerivedValidationAttribute", + displayName: "IntegerWithDerivedValidationAttribute" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ValidatableRecord), + propertyType: typeof(int), + name: "IntegerWithCustomValidation", + displayName: "IntegerWithCustomValidation" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ValidatableRecord), + propertyType: typeof(int), + name: "PropertyWithMultipleAttributes", + displayName: "PropertyWithMultipleAttributes" + ), + ] + ); + } + + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.AspNetCore.Http.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.AspNetCore.Http.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class ValidationAttributeCache + { + private sealed record CacheKey(global::System.Type ContainingType, string PropertyName); + private static readonly global::System.Collections.Concurrent.ConcurrentDictionary _cache = new(); + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes( + global::System.Type containingType, + string propertyName) + { + var key = new CacheKey(containingType, propertyName); + return _cache.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(); + }); + } + } +} \ No newline at end of file diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs index 0b4f3d115dc3..494120f637ba 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs @@ -103,7 +103,7 @@ private ValidatableTypeInfo CreateRecursiveType() 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) + 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 => @@ -130,13 +130,39 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper var key = new CacheKey(containingType, propertyName); return _cache.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) + 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()) { - return []; + // 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 [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes(property, inherit: true)]; + return results.ToArray(); }); } } diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateTypesWithAttribute#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateTypesWithAttribute#ValidatableInfoResolver.g.verified.cs index 95744d10baea..ca78be03f007 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateTypesWithAttribute#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateTypesWithAttribute#ValidatableInfoResolver.g.verified.cs @@ -183,7 +183,7 @@ private ValidatableTypeInfo CreateComplexType() 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) + 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 => @@ -210,13 +210,39 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper var key = new CacheKey(containingType, propertyName); return _cache.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) + 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()) { - return []; + // 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 [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes(property, inherit: true)]; + return results.ToArray(); }); } } diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs index 49a45c919778..c567b02cbf95 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs @@ -97,7 +97,7 @@ private ValidatableTypeInfo CreateComplexType() 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) + 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 => @@ -124,13 +124,39 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper var key = new CacheKey(containingType, propertyName); return _cache.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) + 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()) { - return []; + // 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 [.. global::System.Reflection.CustomAttributeExtensions.GetCustomAttributes(property, inherit: true)]; + return results.ToArray(); }); } } From f4926bdb577a92edd6c5c342aacd2dd0afedd421 Mon Sep 17 00:00:00 2001 From: Safia Abdalla Date: Wed, 16 Apr 2025 17:35:16 -0700 Subject: [PATCH 2/2] Address feedback and add tests --- .../Emitters/ValidationsGenerator.Emitter.cs | 4 +- .../Extensions/ITypeSymbolExtensions.cs | 16 -------- .../ValidationsGenerator.ComplexType.cs | 14 +++---- .../ValidationsGenerator.RecordType.cs | 39 +++++++++++++++++++ ...ypes#ValidatableInfoResolver.g.verified.cs | 4 +- ...ject#ValidatableInfoResolver.g.verified.cs | 4 +- ...ters#ValidatableInfoResolver.g.verified.cs | 4 +- ...ypes#ValidatableInfoResolver.g.verified.cs | 4 +- ...ypes#ValidatableInfoResolver.g.verified.cs | 35 ++++++++++++++++- ...ypes#ValidatableInfoResolver.g.verified.cs | 4 +- ...bute#ValidatableInfoResolver.g.verified.cs | 4 +- ...ypes#ValidatableInfoResolver.g.verified.cs | 4 +- 12 files changed, 95 insertions(+), 41 deletions(-) diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs index b67a844ccc0b..73a0271eee8b 100644 --- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs +++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Emitters/ValidationsGenerator.Emitter.cs @@ -145,8 +145,8 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper results.AddRange(propertyAttributes); } - // Check constructors for parameters that match the property name to handle - // record scenarios + // 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) diff --git a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Extensions/ITypeSymbolExtensions.cs b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Extensions/ITypeSymbolExtensions.cs index 794fca82ed37..3158896d3e59 100644 --- a/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Extensions/ITypeSymbolExtensions.cs +++ b/src/Http/Http.Extensions/gen/Microsoft.AspNetCore.Http.ValidationsGenerator/Extensions/ITypeSymbolExtensions.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using Microsoft.CodeAnalysis; @@ -123,19 +122,4 @@ internal static bool IsExemptType(this ITypeSymbol type, RequiredSymbols require return null; } - - // Helper method to get all properties including inherited ones - internal static IEnumerable GetAllProperties(this ITypeSymbol typeSymbol) - { - var current = typeSymbol; - var properties = new List(); - - while (current != null && current.SpecialType != SpecialType.System_Object) - { - properties.AddRange(current.GetMembers().OfType()); - current = current.BaseType; - } - - return properties; - } } diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.ComplexType.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.ComplexType.cs index 5dd548cf184c..75ebb1c81652 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.ComplexType.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.ComplexType.cs @@ -39,11 +39,11 @@ public class ComplexType public int IntegerWithRangeAndDisplayName { get; set; } = 50; [Required] - public SubType PropertyWithMemberAttributes { get; set; } = new SubType(); + public SubType PropertyWithMemberAttributes { get; set; } = new SubType("some-value", default); - public SubType PropertyWithoutMemberAttributes { get; set; } = new SubType(); + public SubType PropertyWithoutMemberAttributes { get; set; } = new SubType("some-value", default); - public SubTypeWithInheritance PropertyWithInheritance { get; set; } = new SubTypeWithInheritance(); + public SubTypeWithInheritance PropertyWithInheritance { get; set; } = new SubTypeWithInheritance("some-value", default); public List ListOfSubTypes { get; set; } = []; @@ -62,16 +62,16 @@ public class DerivedValidationAttribute : ValidationAttribute public override bool IsValid(object? value) => value is int number && number % 2 == 0; } -public class SubType +public class SubType(string? requiredProperty, string? stringWithLength) { [Required] - public string RequiredProperty { get; set; } = "some-value"; + public string RequiredProperty { get; } = requiredProperty; [StringLength(10)] - public string? StringWithLength { get; set; } + public string? StringWithLength { get; } = stringWithLength; } -public class SubTypeWithInheritance : SubType +public class SubTypeWithInheritance(string? requiredProperty, string? stringWithLength) : SubType(requiredProperty, stringWithLength) { [EmailAddress] public string? EmailString { get; set; } diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs index 2e787c48aae2..4f296c66d648 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/ValidationsGenerator.RecordType.cs @@ -39,6 +39,15 @@ public record SubType([Required] string RequiredProperty = "some-value", [String public record SubTypeWithInheritance([EmailAddress] string? EmailString, string RequiredProperty, string? StringWithLength) : SubType(RequiredProperty, StringWithLength); +public record SubTypeWithoutConstructor +{ + [Required] + public string RequiredProperty { get; set; } = "some-value"; + + [StringLength(10)] + public string? StringWithLength { get; set; } +} + public static class CustomValidators { public static ValidationResult Validate(int number, ValidationContext validationContext) @@ -63,6 +72,7 @@ public record ValidatableRecord( SubType PropertyWithMemberAttributes = default, SubType PropertyWithoutMemberAttributes = default, SubTypeWithInheritance PropertyWithInheritance = default, + SubTypeWithoutConstructor PropertyOfSubtypeWithoutConstructor = default, List ListOfSubTypes = default, [DerivedValidation(ErrorMessage = "Value must be an even number")] int IntegerWithDerivedValidationAttribute = 0, @@ -83,6 +93,7 @@ await VerifyEndpoint(compilation, "/validatable-record", async (endpoint, servic await InvalidPropertyWithDerivedValidationAttributeProducesError(endpoint); await InvalidPropertyWithMultipleAttributesProducesError(endpoint); await InvalidPropertyWithCustomValidationProducesError(endpoint); + await InvalidPropertyOfSubtypeWithoutConstructorProducesError(endpoint); await ValidInputProducesNoWarnings(endpoint); async Task InvalidIntegerWithRangeProducesError(Endpoint endpoint) @@ -296,6 +307,34 @@ async Task InvalidPropertyWithCustomValidationProducesError(Endpoint endpoint) }); } + async Task InvalidPropertyOfSubtypeWithoutConstructorProducesError(Endpoint endpoint) + { + var payload = """ + { + "PropertyOfSubtypeWithoutConstructor": { + "RequiredProperty": "", + "StringWithLength": "way-too-long" + } + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, + kvp => + { + Assert.Equal("PropertyOfSubtypeWithoutConstructor.RequiredProperty", kvp.Key); + Assert.Equal("The RequiredProperty field is required.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("PropertyOfSubtypeWithoutConstructor.StringWithLength", kvp.Key); + Assert.Equal("The field StringWithLength must be a string with a maximum length of 10.", kvp.Value.Single()); + }); + } + async Task ValidInputProducesNoWarnings(Endpoint endpoint) { var payload = """ diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs index 29b739eaf3ad..16e4957dd2ae 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateComplexTypes#ValidatableInfoResolver.g.verified.cs @@ -228,8 +228,8 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper results.AddRange(propertyAttributes); } - // Check constructors for parameters that match the property name to handle - // record scenarios + // 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) diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs index 15d27a64e2f4..82b35b51711d 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateIValidatableObject#ValidatableInfoResolver.g.verified.cs @@ -179,8 +179,8 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper results.AddRange(propertyAttributes); } - // Check constructors for parameters that match the property name to handle - // record scenarios + // 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) diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs index 867c3ece0e77..2c1052c8f20a 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateParameters#ValidatableInfoResolver.g.verified.cs @@ -117,8 +117,8 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper results.AddRange(propertyAttributes); } - // Check constructors for parameters that match the property name to handle - // record scenarios + // 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) diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs index cec9e7e0a8ad..9549ef313872 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidatePolymorphicTypes#ValidatableInfoResolver.g.verified.cs @@ -217,8 +217,8 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper results.AddRange(propertyAttributes); } - // Check constructors for parameters that match the property name to handle - // record scenarios + // 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) diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs index c6605ca3492f..4c0a10963495 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecordTypes#ValidatableInfoResolver.g.verified.cs @@ -70,6 +70,11 @@ public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System. validatableInfo = CreateSubTypeWithInheritance(); return true; } + if (type == typeof(global::SubTypeWithoutConstructor)) + { + validatableInfo = CreateSubTypeWithoutConstructor(); + return true; + } if (type == typeof(global::ValidatableRecord)) { validatableInfo = CreateValidatableRecord(); @@ -120,6 +125,26 @@ private ValidatableTypeInfo CreateSubTypeWithInheritance() ] ); } + private ValidatableTypeInfo CreateSubTypeWithoutConstructor() + { + return new GeneratedValidatableTypeInfo( + type: typeof(global::SubTypeWithoutConstructor), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::SubTypeWithoutConstructor), + propertyType: typeof(string), + name: "RequiredProperty", + displayName: "RequiredProperty" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::SubTypeWithoutConstructor), + propertyType: typeof(string), + name: "StringWithLength", + displayName: "StringWithLength" + ), + ] + ); + } private ValidatableTypeInfo CreateValidatableRecord() { return new GeneratedValidatableTypeInfo( @@ -155,6 +180,12 @@ private ValidatableTypeInfo CreateValidatableRecord() name: "PropertyWithInheritance", displayName: "PropertyWithInheritance" ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ValidatableRecord), + propertyType: typeof(global::SubTypeWithoutConstructor), + name: "PropertyOfSubtypeWithoutConstructor", + displayName: "PropertyOfSubtypeWithoutConstructor" + ), new GeneratedValidatablePropertyInfo( containingType: typeof(global::ValidatableRecord), propertyType: typeof(global::System.Collections.Generic.List), @@ -228,8 +259,8 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper results.AddRange(propertyAttributes); } - // Check constructors for parameters that match the property name to handle - // record scenarios + // 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) diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs index 494120f637ba..3b04d8843a8b 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateRecursiveTypes#ValidatableInfoResolver.g.verified.cs @@ -142,8 +142,8 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper results.AddRange(propertyAttributes); } - // Check constructors for parameters that match the property name to handle - // record scenarios + // 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) diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateTypesWithAttribute#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateTypesWithAttribute#ValidatableInfoResolver.g.verified.cs index ca78be03f007..aa1862d646c5 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateTypesWithAttribute#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.CanValidateTypesWithAttribute#ValidatableInfoResolver.g.verified.cs @@ -222,8 +222,8 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper results.AddRange(propertyAttributes); } - // Check constructors for parameters that match the property name to handle - // record scenarios + // 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) diff --git a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs index c567b02cbf95..d67637d110a8 100644 --- a/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs +++ b/src/Http/Http.Extensions/test/ValidationsGenerator/snapshots/ValidationsGeneratorTests.DoesNotEmitForExemptTypes#ValidatableInfoResolver.g.verified.cs @@ -136,8 +136,8 @@ private sealed record CacheKey(global::System.Type ContainingType, string Proper results.AddRange(propertyAttributes); } - // Check constructors for parameters that match the property name to handle - // record scenarios + // 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)