From 044b6c5089c10d1aedf6abf6ed22027306ff5ad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Rozto=C4=8Dil?= Date: Mon, 4 Aug 2025 17:56:05 +0200 Subject: [PATCH 1/3] Add SkipValidationAttribute to Microsoft.Extensions.Validation --- src/Shared/RoslynUtils/WellKnownTypeData.cs | 2 + .../ValidationsGenerator.TypesParser.cs | 21 ++ src/Validation/src/PublicAPI.Unshipped.txt | 2 + src/Validation/src/SkipValidationAttribute.cs | 13 + .../ValidationsGenerator.SkipValidation.cs | 322 ++++++++++++++++++ .../ValidationsGenerator.ValidatableType.cs | 4 +- .../ValidationsGeneratorTestBase.cs | 4 +- ...bute#ValidatableInfoResolver.g.verified.cs | 221 ++++++++++++ ...bute#ValidatableInfoResolver.g.verified.cs | 203 +++++++++++ 9 files changed, 788 insertions(+), 4 deletions(-) create mode 100644 src/Validation/src/SkipValidationAttribute.cs create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.SkipValidation.cs create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsObjectPropertyWithSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs diff --git a/src/Shared/RoslynUtils/WellKnownTypeData.cs b/src/Shared/RoslynUtils/WellKnownTypeData.cs index 1afb045fc713..eb4813a67384 100644 --- a/src/Shared/RoslynUtils/WellKnownTypeData.cs +++ b/src/Shared/RoslynUtils/WellKnownTypeData.cs @@ -123,6 +123,7 @@ public enum WellKnownType System_ComponentModel_DataAnnotations_ValidationAttribute, System_ComponentModel_DataAnnotations_RequiredAttribute, System_ComponentModel_DataAnnotations_CustomValidationAttribute, + Microsoft_Extensions_Validation_SkipValidationAttribute, System_Type, } @@ -244,6 +245,7 @@ public enum WellKnownType "System.ComponentModel.DataAnnotations.ValidationAttribute", "System.ComponentModel.DataAnnotations.RequiredAttribute", "System.ComponentModel.DataAnnotations.CustomValidationAttribute", + "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 b0617d79b3a6..103bfc0ef7fb 100644 --- a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs +++ b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs @@ -5,6 +5,7 @@ using System.Collections.Immutable; using System.Linq; using Microsoft.AspNetCore.Analyzers.Infrastructure; +using Microsoft.AspNetCore.Analyzers.RouteEmbeddedLanguage.Infrastructure; using Microsoft.AspNetCore.App.Analyzers.Infrastructure; using Microsoft.AspNetCore.Http.RequestDelegateGenerator.StaticRouteHandlerModel; using Microsoft.CodeAnalysis; @@ -114,6 +115,8 @@ internal ImmutableArray ExtractValidatableMembers(ITypeSymb WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Http_Metadata_IFromServiceMetadata); var fromKeyedServiceAttributeSymbol = wellKnownTypes.Get( WellKnownTypeData.WellKnownType.Microsoft_Extensions_DependencyInjection_FromKeyedServicesAttribute); + var skipValidationAttributeSymbol = wellKnownTypes.Get( + WellKnownTypeData.WellKnownType.Microsoft_Extensions_Validation_SkipValidationAttribute); // Special handling for record types to extract properties from // the primary constructor. @@ -148,6 +151,12 @@ internal ImmutableArray ExtractValidatableMembers(ITypeSymb continue; } + // Skip parameter if it or its type are annotated with SkipValidationAttribute + if (SymbolOrTypeHasAttribute(parameter, parameter.Type, skipValidationAttributeSymbol)) + { + continue; + } + // Check if the property's type is validatable, this resolves // validatable types in the inheritance hierarchy var hasValidatableType = TryExtractValidatableType( @@ -186,6 +195,12 @@ internal ImmutableArray ExtractValidatableMembers(ITypeSymb continue; } + // Skip property if it or its type are annotated with SkipValidationAttribute + if (SymbolOrTypeHasAttribute(member, member.Type, skipValidationAttributeSymbol)) + { + continue; + } + var hasValidatableType = TryExtractValidatableType(member.Type, wellKnownTypes, ref validatableTypes, ref visitedTypes); var attributes = ExtractValidationAttributes(member, wellKnownTypes, out var isRequired); @@ -228,4 +243,10 @@ internal static ImmutableArray ExtractValidationAttributes( NamedArguments: attribute.NamedArguments.ToDictionary(namedArgument => namedArgument.Key, namedArgument => namedArgument.Value.ToCSharpString()), IsCustomValidationAttribute: SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_CustomValidationAttribute))))]; } + + private static bool SymbolOrTypeHasAttribute(ISymbol symbol, ITypeSymbol typeSymbol, INamedTypeSymbol attributeSymbol) + { + return symbol.HasAttribute(attributeSymbol) + || typeSymbol.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, attributeSymbol)); + } } diff --git a/src/Validation/src/PublicAPI.Unshipped.txt b/src/Validation/src/PublicAPI.Unshipped.txt index d7f657e38875..e2e20423b5ea 100644 --- a/src/Validation/src/PublicAPI.Unshipped.txt +++ b/src/Validation/src/PublicAPI.Unshipped.txt @@ -5,6 +5,8 @@ Microsoft.Extensions.Validation.IValidatableInfo.ValidateAsync(object? value, Mi Microsoft.Extensions.Validation.IValidatableInfoResolver Microsoft.Extensions.Validation.IValidatableInfoResolver.TryGetValidatableParameterInfo(System.Reflection.ParameterInfo! parameterInfo, out Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) -> bool Microsoft.Extensions.Validation.IValidatableInfoResolver.TryGetValidatableTypeInfo(System.Type! type, out Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) -> bool +Microsoft.Extensions.Validation.SkipValidationAttribute +Microsoft.Extensions.Validation.SkipValidationAttribute.SkipValidationAttribute() -> void Microsoft.Extensions.Validation.ValidatableParameterInfo Microsoft.Extensions.Validation.ValidatableParameterInfo.ValidatableParameterInfo(System.Type! parameterType, string! name, string! displayName) -> void Microsoft.Extensions.Validation.ValidatablePropertyInfo diff --git a/src/Validation/src/SkipValidationAttribute.cs b/src/Validation/src/SkipValidationAttribute.cs new file mode 100644 index 000000000000..b377fe83443b --- /dev/null +++ b/src/Validation/src/SkipValidationAttribute.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Validation; + +/// +/// Indicates that an object property should be skipped for validation. +/// The generator will not generate validation code for this property and its type. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field)] +public sealed class SkipValidationAttribute : Attribute +{ +} diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.SkipValidation.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.SkipValidation.cs new file mode 100644 index 000000000000..2ea2adc5d8d4 --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.SkipValidation.cs @@ -0,0 +1,322 @@ +#pragma warning disable ASP0029 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Validation; + +namespace Microsoft.Extensions.Validation.GeneratorTests; + +public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase +{ + [Fact] + public async Task DoesNotEmit_ForSkipValidationAttribute_OnProperties() + { + var source = """ +#pragma warning disable ASP0029 + +using System; +using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Validation; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddValidation(); + +var app = builder.Build(); + +app.Run(); + +[ValidatableType] +public class ComplexType +{ + [SkipValidation] + [Range(10, 100)] + public int IntegerWithRange { get; set; } = 10; + + public NestedType ObjectProperty { get; set; } = new NestedType(); + + [SkipValidation] + public NestedType SkippedObjectProperty { get; set; } = new NestedType(); + + public List ListOfObjects { get; set; } = [new NestedType()]; + + [SkipValidation] + public List SkippedListOfObjects { get; set; } = [new NestedType()]; + + [SkipValidation] + public NonSkippedBaseType SkippedBaseTypeProperty { get; set; } = new NonSkippedBaseType(); + + public NonSkippedSubType NonSkippedSubTypeProperty { get; set; } = new NonSkippedSubType(); + + public AlwaysSkippedType AlwaysSkippedProperty { get; set; } = new AlwaysSkippedType(); +} + +public class NestedType +{ + [Range(10, 100)] + public int IntegerWithRange { get; set; } = 10; +} + +public class NonSkippedBaseType +{ + [Range(10, 100)] + public int IntegerWithRange1 { get; set; } = 10; +} + +public class NonSkippedSubType : NonSkippedBaseType +{ + [Range(10, 100)] + public int IntegerWithRange2 { get; set; } = 10; +} + +[SkipValidation] +public class AlwaysSkippedType +{ + public NestedType ObjectProperty { get; set; } = new NestedType(); +} +"""; + await Verify(source, out var compilation); + await VerifyValidatableType(compilation, "ComplexType", async (validationOptions, type) => + { + Assert.True(validationOptions.TryGetValidatableTypeInfo(type, out var validatableTypeInfo)); + + await InvalidNestedIntegerWithRangeProducesError(validatableTypeInfo); + await InvalidSubTypeNestedIntegersWithRangeProduceErrors(validatableTypeInfo); + await InvalidAlwaysSkippedTypeDoesNotProducesError(validatableTypeInfo); + + async Task InvalidNestedIntegerWithRangeProducesError(IValidatableInfo validatableInfo) + { + var instance = Activator.CreateInstance(type); + var objectPropertyInstance = type.GetProperty("ObjectProperty").GetValue(instance); + var nestedIntProperty = objectPropertyInstance.GetType().GetProperty("IntegerWithRange"); + nestedIntProperty?.SetValue(objectPropertyInstance, 5); // Set invalid value + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(instance) + }; + + await validatableTypeInfo.ValidateAsync(instance, context, CancellationToken.None); + + Assert.Collection(context.ValidationErrors, kvp => + { + Assert.Equal("ObjectProperty.IntegerWithRange", kvp.Key); + Assert.Equal("The field IntegerWithRange must be between 10 and 100.", kvp.Value.Single()); + }); + } + + async Task InvalidSubTypeNestedIntegersWithRangeProduceErrors(IValidatableInfo validatableInfo) + { + var instance = Activator.CreateInstance(type); + var objectPropertyInstance = type.GetProperty("NonSkippedSubTypeProperty").GetValue(instance); + var nestedIntProperty1 = objectPropertyInstance.GetType().GetProperty("IntegerWithRange1"); + nestedIntProperty1?.SetValue(objectPropertyInstance, 5); // Set invalid value + var nestedIntProperty2 = objectPropertyInstance.GetType().GetProperty("IntegerWithRange2"); + nestedIntProperty2?.SetValue(objectPropertyInstance, 6); // Set invalid value + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(instance) + }; + + await validatableTypeInfo.ValidateAsync(instance, context, CancellationToken.None); + + // Errors are (currently) reported in the order from derived to base type. + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("NonSkippedSubTypeProperty.IntegerWithRange2", kvp.Key); + Assert.Equal("The field IntegerWithRange2 must be between 10 and 100.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("NonSkippedSubTypeProperty.IntegerWithRange1", kvp.Key); + Assert.Equal("The field IntegerWithRange1 must be between 10 and 100.", kvp.Value.Single()); + }); + } + + async Task InvalidAlwaysSkippedTypeDoesNotProducesError(IValidatableInfo validatableInfo) + { + var instance = Activator.CreateInstance(type); + var objectPropertyInstance = type.GetProperty("AlwaysSkippedProperty").GetValue(instance); + var nestedIntProperty = objectPropertyInstance.GetType().GetProperty("IntegerWithRange"); + nestedIntProperty?.SetValue(objectPropertyInstance, 5); // Set invalid value + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(instance) + }; + + await validatableTypeInfo.ValidateAsync(instance, context, CancellationToken.None); + + Assert.Null(context.ValidationErrors); + } + }); + } + + [Fact] + public async Task DoesNotEmit_ForSkipValidationAttribute_OnParameters() + { + var source = """ +#pragma warning disable ASP0029 + +using System; +using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Validation; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddValidation(); + +var app = builder.Build(); + +app.Run(); + +[ValidatableType] +public class ComplexType +{ + [SkipValidation] + [Range(10, 100)] + public int IntegerWithRange { get; set; } = 10; + + public NestedType ObjectProperty { get; set; } = new NestedType(); + + [SkipValidation] + public NestedType SkippedObjectProperty { get; set; } = new NestedType(); + + public List ListOfObjects { get; set; } = [new NestedType()]; + + [SkipValidation] + public List SkippedListOfObjects { get; set; } = [new NestedType()]; + + [SkipValidation] + public NonSkippedBaseType SkippedBaseTypeProperty { get; set; } = new NonSkippedBaseType(); + + public NonSkippedSubType NonSkippedSubTypeProperty { get; set; } = new NonSkippedSubType(); + + public AlwaysSkippedType AlwaysSkippedProperty { get; set; } = new AlwaysSkippedType(); +} + +public class NestedType +{ + [Range(10, 100)] + public int IntegerWithRange { get; set; } = 10; +} + +public class NonSkippedBaseType +{ + [Range(10, 100)] + public int IntegerWithRange1 { get; set; } = 10; +} + +public class NonSkippedSubType : NonSkippedBaseType +{ + [Range(10, 100)] + public int IntegerWithRange2 { get; set; } = 10; +} + +[SkipValidation] +public class AlwaysSkippedType +{ + public NestedType ObjectProperty { get; set; } = new NestedType(); +} +"""; + await Verify(source, out var compilation); + await VerifyValidatableType(compilation, "ComplexType", async (validationOptions, type) => + { + Assert.True(validationOptions.TryGetValidatableTypeInfo(type, out var validatableTypeInfo)); + + await InvalidNestedIntegerWithRangeProducesError(validatableTypeInfo); + await InvalidSubTypeNestedIntegersWithRangeProduceErrors(validatableTypeInfo); + await InvalidAlwaysSkippedTypeDoesNotProducesError(validatableTypeInfo); + + async Task InvalidNestedIntegerWithRangeProducesError(IValidatableInfo validatableInfo) + { + var instance = Activator.CreateInstance(type); + var objectPropertyInstance = type.GetProperty("ObjectProperty").GetValue(instance); + var nestedIntProperty = objectPropertyInstance.GetType().GetProperty("IntegerWithRange"); + nestedIntProperty?.SetValue(objectPropertyInstance, 5); // Set invalid value + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(instance) + }; + + await validatableTypeInfo.ValidateAsync(instance, context, CancellationToken.None); + + Assert.Collection(context.ValidationErrors, kvp => + { + Assert.Equal("ObjectProperty.IntegerWithRange", kvp.Key); + Assert.Equal("The field IntegerWithRange must be between 10 and 100.", kvp.Value.Single()); + }); + } + + async Task InvalidSubTypeNestedIntegersWithRangeProduceErrors(IValidatableInfo validatableInfo) + { + var instance = Activator.CreateInstance(type); + var objectPropertyInstance = type.GetProperty("NonSkippedSubTypeProperty").GetValue(instance); + var nestedIntProperty1 = objectPropertyInstance.GetType().GetProperty("IntegerWithRange1"); + nestedIntProperty1?.SetValue(objectPropertyInstance, 5); // Set invalid value + var nestedIntProperty2 = objectPropertyInstance.GetType().GetProperty("IntegerWithRange2"); + nestedIntProperty2?.SetValue(objectPropertyInstance, 6); // Set invalid value + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(instance) + }; + + await validatableTypeInfo.ValidateAsync(instance, context, CancellationToken.None); + + // Errors are (currently) reported in the order from derived to base type. + Assert.Collection(context.ValidationErrors, + kvp => + { + Assert.Equal("NonSkippedSubTypeProperty.IntegerWithRange2", kvp.Key); + Assert.Equal("The field IntegerWithRange2 must be between 10 and 100.", kvp.Value.Single()); + }, + kvp => + { + Assert.Equal("NonSkippedSubTypeProperty.IntegerWithRange1", kvp.Key); + Assert.Equal("The field IntegerWithRange1 must be between 10 and 100.", kvp.Value.Single()); + }); + } + + async Task InvalidAlwaysSkippedTypeDoesNotProducesError(IValidatableInfo validatableInfo) + { + var instance = Activator.CreateInstance(type); + var objectPropertyInstance = type.GetProperty("AlwaysSkippedProperty").GetValue(instance); + var nestedIntProperty = objectPropertyInstance.GetType().GetProperty("IntegerWithRange"); + nestedIntProperty?.SetValue(objectPropertyInstance, 5); // Set invalid value + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(instance) + }; + + await validatableTypeInfo.ValidateAsync(instance, context, CancellationToken.None); + + Assert.Null(context.ValidationErrors); + } + }); + } +} diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ValidatableType.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ValidatableType.cs index f4041f62b979..aff6b34be47a 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ValidatableType.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ValidatableType.cs @@ -80,7 +80,7 @@ public class SubTypeWithInheritance : SubType } """; await Verify(source, out var compilation); - VerifyValidatableType(compilation, "ComplexType", async (validationOptions, type) => + await VerifyValidatableType(compilation, "ComplexType", async (validationOptions, type) => { Assert.True(validationOptions.TryGetValidatableTypeInfo(type, out var validatableTypeInfo)); @@ -449,7 +449,7 @@ public record SubTypeWithInheritance : SubType } """; await Verify(source, out var compilation); - VerifyValidatableType(compilation, "ComplexType", async (validationOptions, type) => + await VerifyValidatableType(compilation, "ComplexType", async (validationOptions, type) => { Assert.True(validationOptions.TryGetValidatableTypeInfo(type, out var validatableTypeInfo)); diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGeneratorTestBase.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGeneratorTestBase.cs index 81500f090c96..d742d5de21e0 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGeneratorTestBase.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGeneratorTestBase.cs @@ -86,7 +86,7 @@ internal static Task Verify(string source, out Compilation compilation) : "snapshots"); } - internal static void VerifyValidatableType(Compilation compilation, string typeName, Action verifyFunc) + internal static async Task VerifyValidatableType(Compilation compilation, string typeName, Func verifyFunc) { if (TryResolveServicesFromCompilation(compilation, targetAssemblyName: "Microsoft.Extensions.Validation", typeName: "Microsoft.Extensions.Validation.ValidationOptions", out var services, out var serviceType, out var outputAssemblyName) is false) { @@ -103,7 +103,7 @@ internal static void VerifyValidatableType(Compilation compilation, string typeN // Then access the Value property var valueProperty = optionsType.GetProperty("Value"); var service = (ValidationOptions)valueProperty.GetValue(optionsInstance) ?? throw new InvalidOperationException("Could not resolve ValidationOptions."); - verifyFunc(service, type); + await verifyFunc(service, type); } internal static async Task VerifyEndpoint(Compilation compilation, string routePattern, Func verifyFunc) diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs new file mode 100644 index 000000000000..701840c737bd --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs @@ -0,0 +1,221 @@ +//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.GetValidationAttributes(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) { } + } + + [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::NestedType)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::NestedType), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::NestedType), + propertyType: typeof(int), + name: "IntegerWithRange", + displayName: "IntegerWithRange" + ), + ] + ); + return true; + } + if (type == typeof(global::NonSkippedBaseType)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::NonSkippedBaseType), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::NonSkippedBaseType), + propertyType: typeof(int), + name: "IntegerWithRange1", + displayName: "IntegerWithRange1" + ), + ] + ); + return true; + } + if (type == typeof(global::NonSkippedSubType)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::NonSkippedSubType), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::NonSkippedSubType), + propertyType: typeof(int), + name: "IntegerWithRange2", + displayName: "IntegerWithRange2" + ), + ] + ); + return true; + } + if (type == typeof(global::ComplexType)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::ComplexType), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ComplexType), + propertyType: typeof(global::NestedType), + name: "ObjectProperty", + displayName: "ObjectProperty" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ComplexType), + propertyType: typeof(global::System.Collections.Generic.List), + name: "ListOfObjects", + displayName: "ListOfObjects" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ComplexType), + propertyType: typeof(global::NonSkippedSubType), + name: "NonSkippedSubTypeProperty", + displayName: "NonSkippedSubTypeProperty" + ), + ] + ); + 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 _cache = new(); + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes( + [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 _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/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsObjectPropertyWithSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsObjectPropertyWithSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs new file mode 100644 index 000000000000..394be88e9232 --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsObjectPropertyWithSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs @@ -0,0 +1,203 @@ +//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.GetValidationAttributes(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) { } + } + + [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::NestedType)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::NestedType), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::NestedType), + propertyType: typeof(string), + name: "RequiredProperty", + displayName: "RequiredProperty" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::NestedType), + propertyType: typeof(string), + name: "StringWithLength", + displayName: "StringWithLength" + ), + ] + ); + return true; + } + if (type == typeof(global::ComplexType)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::ComplexType), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ComplexType), + propertyType: typeof(global::NestedType), + name: "ObjectProperty", + displayName: "ObjectProperty" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ComplexType), + propertyType: typeof(global::NestedType), + name: "SkippedObjectProperty", + displayName: "SkippedObjectProperty" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ComplexType), + propertyType: typeof(global::System.Collections.Generic.List), + name: "ListOfObjects", + displayName: "ListOfObjects" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::ComplexType), + propertyType: typeof(global::System.Collections.Generic.List), + name: "SkippedListOfObjects", + displayName: "SkippedListOfObjects" + ), + ] + ); + 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 _cache = new(); + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes( + [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 _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 From f5ef45760aa1b4da21e209aa2a93dc8d22209005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Rozto=C4=8Dil?= Date: Mon, 4 Aug 2025 17:58:02 +0200 Subject: [PATCH 2/3] Mark SkipValidationAttribute as Experimental --- src/Validation/src/SkipValidationAttribute.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Validation/src/SkipValidationAttribute.cs b/src/Validation/src/SkipValidationAttribute.cs index b377fe83443b..4447b723aca6 100644 --- a/src/Validation/src/SkipValidationAttribute.cs +++ b/src/Validation/src/SkipValidationAttribute.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.Extensions.Validation; /// @@ -8,6 +10,7 @@ namespace Microsoft.Extensions.Validation; /// The generator will not generate validation code for this property and its type. /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field)] +[Experimental("ASP0029", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] public sealed class SkipValidationAttribute : Attribute { } From 2ab24bb4425672bf326ec8425abba7766a59c63a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Rozto=C4=8Dil?= Date: Tue, 5 Aug 2025 17:31:07 +0200 Subject: [PATCH 3/3] WIP --- .../ValidationsGenerator.TypesParser.cs | 20 +- ...RuntimeValidatableParameterInfoResolver.cs | 8 + .../ValidationsGenerator.SkipValidation.cs | 249 +++++++++++------- .../ValidationsGeneratorTestBase.cs | 29 +- .../rec.cs | 16 ++ ...ies#ValidatableInfoResolver.g.verified.cs} | 6 - ...ters#ValidatableInfoResolver.g.verified.cs | 164 ++++++++++++ ...ies#ValidatableInfoResolver.g.verified.cs} | 30 +-- 8 files changed, 395 insertions(+), 127 deletions(-) create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/rec.cs rename src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/{ValidationsGeneratorTests.DoesNotEmitForSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs => ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnClassProperties#ValidatableInfoResolver.g.verified.cs} (96%) create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnEndpointParameters#ValidatableInfoResolver.g.verified.cs rename src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/{ValidationsGeneratorTests.SkipsObjectPropertyWithSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs => ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnRecordProperties#ValidatableInfoResolver.g.verified.cs} (85%) diff --git a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs index 103bfc0ef7fb..c95c354a071d 100644 --- a/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs +++ b/src/Validation/gen/Parsers/ValidationsGenerator.TypesParser.cs @@ -20,6 +20,8 @@ public sealed partial class ValidationsGenerator : IIncrementalGenerator globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces); + private static INamedTypeSymbol? _skipValidationAttribute; + internal ImmutableArray ExtractValidatableTypes(IInvocationOperation operation, WellKnownTypes wellKnownTypes) { AnalyzerDebug.Assert(operation.SemanticModel != null, "SemanticModel should not be null."); @@ -43,6 +45,12 @@ internal ImmutableArray ExtractValidatableTypes(IInvocationOper continue; } + // Skip parameter if it or its type are annotated with SkipValidationAttribute + if (SymbolOrTypeHasAttribute(parameter, parameter.Type, GetSkipValidationAttribute(wellKnownTypes))) + { + continue; + } + _ = TryExtractValidatableType(parameter.Type, wellKnownTypes, ref validatableTypes, ref visitedTypes); } return [.. validatableTypes]; @@ -115,8 +123,6 @@ internal ImmutableArray ExtractValidatableMembers(ITypeSymb WellKnownTypeData.WellKnownType.Microsoft_AspNetCore_Http_Metadata_IFromServiceMetadata); var fromKeyedServiceAttributeSymbol = wellKnownTypes.Get( WellKnownTypeData.WellKnownType.Microsoft_Extensions_DependencyInjection_FromKeyedServicesAttribute); - var skipValidationAttributeSymbol = wellKnownTypes.Get( - WellKnownTypeData.WellKnownType.Microsoft_Extensions_Validation_SkipValidationAttribute); // Special handling for record types to extract properties from // the primary constructor. @@ -152,7 +158,7 @@ internal ImmutableArray ExtractValidatableMembers(ITypeSymb } // Skip parameter if it or its type are annotated with SkipValidationAttribute - if (SymbolOrTypeHasAttribute(parameter, parameter.Type, skipValidationAttributeSymbol)) + if (SymbolOrTypeHasAttribute(parameter, parameter.Type, GetSkipValidationAttribute(wellKnownTypes))) { continue; } @@ -196,7 +202,7 @@ internal ImmutableArray ExtractValidatableMembers(ITypeSymb } // Skip property if it or its type are annotated with SkipValidationAttribute - if (SymbolOrTypeHasAttribute(member, member.Type, skipValidationAttributeSymbol)) + if (SymbolOrTypeHasAttribute(member, member.Type, GetSkipValidationAttribute(wellKnownTypes))) { continue; } @@ -244,6 +250,12 @@ internal static ImmutableArray ExtractValidationAttributes( IsCustomValidationAttribute: SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, wellKnownTypes.Get(WellKnownTypeData.WellKnownType.System_ComponentModel_DataAnnotations_CustomValidationAttribute))))]; } + private static INamedTypeSymbol GetSkipValidationAttribute(WellKnownTypes wellKnownTypes) + { + _skipValidationAttribute ??= wellKnownTypes.Get(WellKnownTypeData.WellKnownType.Microsoft_Extensions_Validation_SkipValidationAttribute); + return _skipValidationAttribute; + } + private static bool SymbolOrTypeHasAttribute(ISymbol symbol, ITypeSymbol typeSymbol, INamedTypeSymbol attributeSymbol) { return symbol.HasAttribute(attributeSymbol) diff --git a/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs b/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs index d8f0c3699dbf..94e2f621853e 100644 --- a/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs +++ b/src/Validation/src/RuntimeValidatableParameterInfoResolver.cs @@ -28,6 +28,14 @@ public bool TryGetValidatableParameterInfo(ParameterInfo parameterInfo, [NotNull throw new InvalidOperationException($"Encountered a parameter of type '{parameterInfo.ParameterType}' without a name. Parameters must have a name."); } + // Skip parameter if it is annotated with [SkipValidation] or its type is annotated with [SkipValidation]. + if (parameterInfo.GetCustomAttribute() != null || + parameterInfo.ParameterType.GetCustomAttribute() != null) + { + validatableInfo = null; + return false; + } + var validationAttributes = parameterInfo .GetCustomAttributes() .ToArray(); diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.SkipValidation.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.SkipValidation.cs index 2ea2adc5d8d4..8ceb5a227a08 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.SkipValidation.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.SkipValidation.cs @@ -3,7 +3,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; using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Validation; namespace Microsoft.Extensions.Validation.GeneratorTests; @@ -11,7 +13,7 @@ namespace Microsoft.Extensions.Validation.GeneratorTests; public partial class ValidationsGeneratorTests : ValidationsGeneratorTestBase { [Fact] - public async Task DoesNotEmit_ForSkipValidationAttribute_OnProperties() + public async Task DoesNotEmit_ForSkipValidationAttribute_OnClassProperties() { var source = """ #pragma warning disable ASP0029 @@ -37,19 +39,10 @@ public async Task DoesNotEmit_ForSkipValidationAttribute_OnProperties() [ValidatableType] public class ComplexType { - [SkipValidation] - [Range(10, 100)] - public int IntegerWithRange { get; set; } = 10; - - public NestedType ObjectProperty { get; set; } = new NestedType(); - [SkipValidation] public NestedType SkippedObjectProperty { get; set; } = new NestedType(); - public List ListOfObjects { get; set; } = [new NestedType()]; - - [SkipValidation] - public List SkippedListOfObjects { get; set; } = [new NestedType()]; + public NestedType ObjectProperty { get; set; } = new NestedType(); [SkipValidation] public NonSkippedBaseType SkippedBaseTypeProperty { get; set; } = new NonSkippedBaseType(); @@ -57,6 +50,10 @@ public class ComplexType public NonSkippedSubType NonSkippedSubTypeProperty { get; set; } = new NonSkippedSubType(); public AlwaysSkippedType AlwaysSkippedProperty { get; set; } = new AlwaysSkippedType(); + + [SkipValidation] + [Range(10, 100)] + public int IntegerWithRange { get; set; } = 10; } public class NestedType @@ -89,8 +86,10 @@ await VerifyValidatableType(compilation, "ComplexType", async (validationOptions Assert.True(validationOptions.TryGetValidatableTypeInfo(type, out var validatableTypeInfo)); await InvalidNestedIntegerWithRangeProducesError(validatableTypeInfo); + await InvalidSkippedNestedIntegerWithRangeDoesNotProduceProduceError(validatableTypeInfo); + await InvalidSkippedIntegerWithRangeDoesNotProduceError(validatableTypeInfo); await InvalidSubTypeNestedIntegersWithRangeProduceErrors(validatableTypeInfo); - await InvalidAlwaysSkippedTypeDoesNotProducesError(validatableTypeInfo); + await InvalidAlwaysSkippedTypeDoesNotProduceError(validatableTypeInfo); async Task InvalidNestedIntegerWithRangeProducesError(IValidatableInfo validatableInfo) { @@ -114,6 +113,41 @@ async Task InvalidNestedIntegerWithRangeProducesError(IValidatableInfo validatab }); } + async Task InvalidSkippedNestedIntegerWithRangeDoesNotProduceProduceError(IValidatableInfo validatableInfo) + { + var instance = Activator.CreateInstance(type); + var objectPropertyInstance = type.GetProperty("SkippedObjectProperty").GetValue(instance); + var nestedIntProperty = objectPropertyInstance.GetType().GetProperty("IntegerWithRange"); + nestedIntProperty?.SetValue(objectPropertyInstance, 5); // Set invalid value + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(instance) + }; + + await validatableTypeInfo.ValidateAsync(instance, context, CancellationToken.None); + + Assert.Null(context.ValidationErrors); + } + + async Task InvalidSkippedIntegerWithRangeDoesNotProduceError(IValidatableInfo validatableInfo) + { + var instance = Activator.CreateInstance(type); + var intProperty = type.GetProperty("IntegerWithRange"); + intProperty?.SetValue(instance, 5); // Set invalid value + + var context = new ValidateContext + { + ValidationOptions = validationOptions, + ValidationContext = new ValidationContext(instance) + }; + + await validatableTypeInfo.ValidateAsync(instance, context, CancellationToken.None); + + Assert.Null(context.ValidationErrors); + } + async Task InvalidSubTypeNestedIntegersWithRangeProduceErrors(IValidatableInfo validatableInfo) { var instance = Activator.CreateInstance(type); @@ -145,7 +179,7 @@ async Task InvalidSubTypeNestedIntegersWithRangeProduceErrors(IValidatableInfo v }); } - async Task InvalidAlwaysSkippedTypeDoesNotProducesError(IValidatableInfo validatableInfo) + async Task InvalidAlwaysSkippedTypeDoesNotProduceError(IValidatableInfo validatableInfo) { var instance = Activator.CreateInstance(type); var objectPropertyInstance = type.GetProperty("AlwaysSkippedProperty").GetValue(instance); @@ -166,7 +200,7 @@ async Task InvalidAlwaysSkippedTypeDoesNotProducesError(IValidatableInfo validat } [Fact] - public async Task DoesNotEmit_ForSkipValidationAttribute_OnParameters() + public async Task DoesNotEmit_ForSkipValidationAttribute_OnRecordProperties() { var source = """ #pragma warning disable ASP0029 @@ -181,59 +215,32 @@ public async Task DoesNotEmit_ForSkipValidationAttribute_OnParameters() using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; -var builder = WebApplication.CreateBuilder(); - -builder.Services.AddValidation(); - -var app = builder.Build(); - -app.Run(); - -[ValidatableType] -public class ComplexType -{ - [SkipValidation] - [Range(10, 100)] - public int IntegerWithRange { get; set; } = 10; - - public NestedType ObjectProperty { get; set; } = new NestedType(); - - [SkipValidation] - public NestedType SkippedObjectProperty { get; set; } = new NestedType(); - - public List ListOfObjects { get; set; } = [new NestedType()]; - - [SkipValidation] - public List SkippedListOfObjects { get; set; } = [new NestedType()]; - - [SkipValidation] - public NonSkippedBaseType SkippedBaseTypeProperty { get; set; } = new NonSkippedBaseType(); - - public NonSkippedSubType NonSkippedSubTypeProperty { get; set; } = new NonSkippedSubType(); - - public AlwaysSkippedType AlwaysSkippedProperty { get; set; } = new AlwaysSkippedType(); -} - -public class NestedType +static class Program { - [Range(10, 100)] - public int IntegerWithRange { get; set; } = 10; + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddValidation(); + var app = builder.Build(); + app.Run(); + } } -public class NonSkippedBaseType -{ - [Range(10, 100)] - public int IntegerWithRange1 { get; set; } = 10; -} +[ValidatableType] +public record ComplexType( + [Range(10, 100)][SkipValidation] int IntegerWithRange, + NestedType ObjectProperty, + [SkipValidation] NestedType SkippedObjectProperty +); -public class NonSkippedSubType : NonSkippedBaseType +public record NestedType { [Range(10, 100)] - public int IntegerWithRange2 { get; set; } = 10; + public int IntegerWithRange { get; set; } = 10; } [SkipValidation] -public class AlwaysSkippedType +public record AlwaysSkippedType { public NestedType ObjectProperty { get; set; } = new NestedType(); } @@ -244,15 +251,17 @@ await VerifyValidatableType(compilation, "ComplexType", async (validationOptions Assert.True(validationOptions.TryGetValidatableTypeInfo(type, out var validatableTypeInfo)); await InvalidNestedIntegerWithRangeProducesError(validatableTypeInfo); - await InvalidSubTypeNestedIntegersWithRangeProduceErrors(validatableTypeInfo); - await InvalidAlwaysSkippedTypeDoesNotProducesError(validatableTypeInfo); + await InvalidSkippedNestedIntegerWithRangeDoesNotProduceProduceError(validatableTypeInfo); + await InvalidSkippedIntegerWithRangeDoesNotProduceError(validatableTypeInfo); async Task InvalidNestedIntegerWithRangeProducesError(IValidatableInfo validatableInfo) { - var instance = Activator.CreateInstance(type); - var objectPropertyInstance = type.GetProperty("ObjectProperty").GetValue(instance); - var nestedIntProperty = objectPropertyInstance.GetType().GetProperty("IntegerWithRange"); - nestedIntProperty?.SetValue(objectPropertyInstance, 5); // Set invalid value + var objectProperty = type.GetProperty("ObjectProperty"); + var nestedType = objectProperty.PropertyType; + var nestedTypeInstance = Activator.CreateInstance(nestedType); + var skippedNestedTypeInstance = Activator.CreateInstance(nestedType); + nestedTypeInstance.GetType().GetProperty("IntegerWithRange")?.SetValue(nestedTypeInstance, 5); // Set invalid value + var instance = Activator.CreateInstance(type, 10, nestedTypeInstance, skippedNestedTypeInstance); var context = new ValidateContext { @@ -269,14 +278,14 @@ async Task InvalidNestedIntegerWithRangeProducesError(IValidatableInfo validatab }); } - async Task InvalidSubTypeNestedIntegersWithRangeProduceErrors(IValidatableInfo validatableInfo) + async Task InvalidSkippedNestedIntegerWithRangeDoesNotProduceProduceError(IValidatableInfo validatableInfo) { - var instance = Activator.CreateInstance(type); - var objectPropertyInstance = type.GetProperty("NonSkippedSubTypeProperty").GetValue(instance); - var nestedIntProperty1 = objectPropertyInstance.GetType().GetProperty("IntegerWithRange1"); - nestedIntProperty1?.SetValue(objectPropertyInstance, 5); // Set invalid value - var nestedIntProperty2 = objectPropertyInstance.GetType().GetProperty("IntegerWithRange2"); - nestedIntProperty2?.SetValue(objectPropertyInstance, 6); // Set invalid value + var objectProperty = type.GetProperty("ObjectProperty"); + var nestedType = objectProperty.PropertyType; + var nestedTypeInstance = Activator.CreateInstance(nestedType); + var skippedNestedTypeInstance = Activator.CreateInstance(nestedType); + skippedNestedTypeInstance.GetType().GetProperty("IntegerWithRange")?.SetValue(skippedNestedTypeInstance, 5); // Set invalid value + var instance = Activator.CreateInstance(type, 10, nestedTypeInstance, skippedNestedTypeInstance); var context = new ValidateContext { @@ -286,26 +295,15 @@ async Task InvalidSubTypeNestedIntegersWithRangeProduceErrors(IValidatableInfo v await validatableTypeInfo.ValidateAsync(instance, context, CancellationToken.None); - // Errors are (currently) reported in the order from derived to base type. - Assert.Collection(context.ValidationErrors, - kvp => - { - Assert.Equal("NonSkippedSubTypeProperty.IntegerWithRange2", kvp.Key); - Assert.Equal("The field IntegerWithRange2 must be between 10 and 100.", kvp.Value.Single()); - }, - kvp => - { - Assert.Equal("NonSkippedSubTypeProperty.IntegerWithRange1", kvp.Key); - Assert.Equal("The field IntegerWithRange1 must be between 10 and 100.", kvp.Value.Single()); - }); + Assert.Null(context.ValidationErrors); } - async Task InvalidAlwaysSkippedTypeDoesNotProducesError(IValidatableInfo validatableInfo) + async Task InvalidSkippedIntegerWithRangeDoesNotProduceError(IValidatableInfo validatableInfo) { - var instance = Activator.CreateInstance(type); - var objectPropertyInstance = type.GetProperty("AlwaysSkippedProperty").GetValue(instance); - var nestedIntProperty = objectPropertyInstance.GetType().GetProperty("IntegerWithRange"); - nestedIntProperty?.SetValue(objectPropertyInstance, 5); // Set invalid value + var objectProperty = type.GetProperty("ObjectProperty"); + var nestedType = objectProperty.PropertyType; + var nestedTypeInstance = Activator.CreateInstance(nestedType); + var instance = Activator.CreateInstance(type, 5, nestedTypeInstance, nestedTypeInstance); // Create with invalid value var context = new ValidateContext { @@ -319,4 +317,79 @@ async Task InvalidAlwaysSkippedTypeDoesNotProducesError(IValidatableInfo validat } }); } + + [Fact] + public async Task DoesNotEmit_ForSkipValidationAttribute_OnEndpointParameters() + { + var source = """ +#pragma warning disable ASP0029 + +using System; +using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Validation; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +static class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddValidation(); + var app = builder.Build(); + + app.MapPost("/params", ( + [FromForm][Range(10, 100)] int intParam, + [FromForm][SkipValidation][Range(10, 100)] int skippedIntParam, + [FromForm] NestedType objectParam + ) => "OK"); + + app.Run(); + } +} + +public class NestedType +{ + [Range(10, 100)] + public int IntegerWithRange { get; set; } = 10; +} + +[SkipValidation] +public class AlwaysSkippedType +{ + public NestedType ObjectProperty { get; set; } = new NestedType(); +} +"""; + await Verify(source, out var compilation); + await VerifyEndpoint(compilation, "/params", async (endpoint, serviceProvider) => + { + var formData = new Dictionary + { + { "intParam", "5" }, // Invalid value + { "skippedIntParam", "5" }, // Invalid value but should be skipped + { "objectParam.IntegerWithRange", "5" } // Invalid value + }; + var context = CreateHttpContextWithFormData(formData, serviceProvider); + await endpoint.RequestDelegate(context); + var problemDetails = await AssertBadRequest(context); + + Assert.Collection(problemDetails.Errors, + error => + { + Assert.Equal("intParam", error.Key); + Assert.Equal("The field intParam must be between 10 and 100.", error.Value.Single()); + }, + error => + { + Assert.Equal("objectParam.IntegerWithRange", error.Key); + Assert.Equal("The field IntegerWithRange must be between 10 and 100.", error.Value.Single()); + } + ); + }); + } } diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGeneratorTestBase.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGeneratorTestBase.cs index d742d5de21e0..3b76d1e76148 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGeneratorTestBase.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGeneratorTestBase.cs @@ -5,21 +5,22 @@ using System.Diagnostics; using System.Globalization; +using System.IO.Pipelines; +using System.Net.Http; using System.Reflection; using System.Runtime.Loader; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; -using System.IO.Pipelines; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http.Features.Authentication; using Microsoft.AspNetCore.InternalTesting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Routing; -using Microsoft.AspNetCore.Http.Features.Authentication; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Emit; @@ -585,6 +586,30 @@ internal HttpContext CreateHttpContextWithPayload(string requestData, IServicePr return httpContext; } + internal HttpContext CreateHttpContextWithFormData(Dictionary formData, IServiceProvider serviceProvider = null) + { + var httpContext = CreateHttpContext(serviceProvider); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var multipartContent = new MultipartFormDataContent(); + foreach (var keyValuePair in formData) + { + multipartContent.Add(new StringContent(keyValuePair.Value), keyValuePair.Key); + } + + httpContext.Request.Headers["Content-Type"] = multipartContent.Headers.ContentType.ToString(); + + // The CopyToAsync method writes the multipart content to a memory stream. + // We then reset the stream's position to the beginning to be read by the server. + var stream = new MemoryStream(); + multipartContent.CopyToAsync(stream).Wait(); + stream.Position = 0; + + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Length"] = stream.Length.ToString(CultureInfo.InvariantCulture); + return httpContext; + } + internal static async Task AssertBadRequest(HttpContext context) { Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/rec.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/rec.cs new file mode 100644 index 000000000000..b1772ad265db --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/rec.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text; + +namespace Microsoft.Extensions.Validation.GeneratorTests; + +public record Rec([Range(10, 100)] int X); + +public class C +{ + public Rec Rec { get; } = new(42); +} diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnClassProperties#ValidatableInfoResolver.g.verified.cs similarity index 96% rename from src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs rename to src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnClassProperties#ValidatableInfoResolver.g.verified.cs index 701840c737bd..d93be19aeaa5 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmitForSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnClassProperties#ValidatableInfoResolver.g.verified.cs @@ -118,12 +118,6 @@ public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System. name: "ObjectProperty", displayName: "ObjectProperty" ), - new GeneratedValidatablePropertyInfo( - containingType: typeof(global::ComplexType), - propertyType: typeof(global::System.Collections.Generic.List), - name: "ListOfObjects", - displayName: "ListOfObjects" - ), new GeneratedValidatablePropertyInfo( containingType: typeof(global::ComplexType), propertyType: typeof(global::NonSkippedSubType), diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnEndpointParameters#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnEndpointParameters#ValidatableInfoResolver.g.verified.cs new file mode 100644 index 000000000000..513dd7d82749 --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnEndpointParameters#ValidatableInfoResolver.g.verified.cs @@ -0,0 +1,164 @@ +//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.GetValidationAttributes(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) { } + } + + [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::NestedType)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::NestedType), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::NestedType), + propertyType: typeof(int), + name: "IntegerWithRange", + displayName: "IntegerWithRange" + ), + ] + ); + 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 _cache = new(); + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes( + [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 _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/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsObjectPropertyWithSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnRecordProperties#ValidatableInfoResolver.g.verified.cs similarity index 85% rename from src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsObjectPropertyWithSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs rename to src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnRecordProperties#ValidatableInfoResolver.g.verified.cs index 394be88e9232..5fff86fac3f1 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.SkipsObjectPropertyWithSkipValidationAttribute#ValidatableInfoResolver.g.verified.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.DoesNotEmit_ForSkipValidationAttribute_OnRecordProperties#ValidatableInfoResolver.g.verified.cs @@ -69,15 +69,9 @@ public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System. members: [ new GeneratedValidatablePropertyInfo( containingType: typeof(global::NestedType), - propertyType: typeof(string), - name: "RequiredProperty", - displayName: "RequiredProperty" - ), - new GeneratedValidatablePropertyInfo( - containingType: typeof(global::NestedType), - propertyType: typeof(string), - name: "StringWithLength", - displayName: "StringWithLength" + propertyType: typeof(int), + name: "IntegerWithRange", + displayName: "IntegerWithRange" ), ] ); @@ -94,24 +88,6 @@ public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System. name: "ObjectProperty", displayName: "ObjectProperty" ), - new GeneratedValidatablePropertyInfo( - containingType: typeof(global::ComplexType), - propertyType: typeof(global::NestedType), - name: "SkippedObjectProperty", - displayName: "SkippedObjectProperty" - ), - new GeneratedValidatablePropertyInfo( - containingType: typeof(global::ComplexType), - propertyType: typeof(global::System.Collections.Generic.List), - name: "ListOfObjects", - displayName: "ListOfObjects" - ), - new GeneratedValidatablePropertyInfo( - containingType: typeof(global::ComplexType), - propertyType: typeof(global::System.Collections.Generic.List), - name: "SkippedListOfObjects", - displayName: "SkippedListOfObjects" - ), ] ); return true;