From 88227105ac0824e864299f1b5314bdc5fed87720 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 20:35:32 +0000 Subject: [PATCH 1/3] Initial plan From a58947a90876b3aaf11ab3c61fe55fcba433a873 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 20:47:50 +0000 Subject: [PATCH 2/3] Fix JsonIgnore validation to only skip for Always and WhenReading conditions Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../gen/Extensions/ITypeSymbolExtensions.cs | 36 +++- .../ValidationsGenerator.ComplexType.cs | 168 +++++++++++++++ ...ions#ValidatableInfoResolver.g.verified.cs | 201 ++++++++++++++++++ 3 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesPropertiesWithJsonIgnoreWhenWritingConditions#ValidatableInfoResolver.g.verified.cs diff --git a/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs b/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs index df12e81cc6e0..97d689b3a81c 100644 --- a/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs +++ b/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs @@ -155,15 +155,43 @@ attr.AttributeClass is not null && } /// - /// Checks if the property is marked with [JsonIgnore] attribute. + /// Checks if the property is marked with [JsonIgnore] attribute with a condition that affects deserialization. + /// Only skips validation when the condition is Always (1) or WhenReading (5). + /// Properties with WhenWritingDefault (2), WhenWritingNull (3), WhenWriting (4), or Never (0) are still validated. /// /// The property to check. /// The symbol representing the [JsonIgnore] attribute. internal static bool IsJsonIgnoredProperty(this IPropertySymbol property, INamedTypeSymbol jsonIgnoreAttributeSymbol) { - return property.GetAttributes().Any(attr => - attr.AttributeClass is not null && - SymbolEqualityComparer.Default.Equals(attr.AttributeClass, jsonIgnoreAttributeSymbol)); + foreach (var attr in property.GetAttributes()) + { + if (attr.AttributeClass is not null && + SymbolEqualityComparer.Default.Equals(attr.AttributeClass, jsonIgnoreAttributeSymbol)) + { + // Check if the Condition property is set + if (!attr.NamedArguments.IsDefaultOrEmpty) + { + foreach (var namedArgument in attr.NamedArguments) + { + if (string.Equals(namedArgument.Key, "Condition", System.StringComparison.Ordinal)) + { + // The value is an enum represented as an int + // JsonIgnoreCondition.Always = 1, JsonIgnoreCondition.WhenReading = 5 + if (namedArgument.Value.Value is int conditionValue) + { + // Only skip validation for Always (1) or WhenReading (5) + return conditionValue == 1 || conditionValue == 5; + } + } + } + } + + // If no Condition is specified, the default behavior is Always (skip validation) + return true; + } + } + + return false; } internal static bool IsSkippedValidationProperty(this IPropertySymbol property, INamedTypeSymbol skipValidationAttributeSymbol) diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs index d4bd0ef579f5..994591f7ef5e 100644 --- a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/ValidationsGenerator.ComplexType.cs @@ -623,4 +623,172 @@ async Task ValidPublicPropertyStillValidated(Endpoint endpoint) } }); } + + [Fact] + public async Task ValidatesPropertiesWithJsonIgnoreWhenWritingConditions() + { + // 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.Extensions.Validation; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddValidation(); + +var app = builder.Build(); + +app.MapPost("/json-ignore-conditions", (JsonIgnoreConditionsModel model) => Results.Ok("Passed"!)); + +app.Run(); + +public class JsonIgnoreConditionsModel +{ + // JsonIgnore without Condition defaults to Always - should be ignored + [JsonIgnore] + [MaxLength(10)] + public string? PropertyWithJsonIgnoreOnly { get; set; } + + // JsonIgnoreCondition.Always - should be ignored + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MaxLength(10)] + public string? PropertyWithAlways { get; set; } + + // JsonIgnoreCondition.WhenWritingDefault - should be validated (only affects writing) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [MaxLength(10)] + public string? PropertyWithWhenWritingDefault { get; set; } + + // JsonIgnoreCondition.WhenWritingNull - should be validated (only affects writing) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [MaxLength(10)] + public string? PropertyWithWhenWritingNull { get; set; } + + // JsonIgnoreCondition.Never - should be validated (never ignored) + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + [MaxLength(10)] + public string? PropertyWithNever { get; set; } + + // JsonIgnoreCondition.WhenReading - should be ignored (affects reading/deserialization) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenReading)] + [MaxLength(10)] + public string? PropertyWithWhenReading { get; set; } +} +"""; + await Verify(source, out var compilation); + await VerifyEndpoint(compilation, "/json-ignore-conditions", async (endpoint, serviceProvider) => + { + // Test that WhenWritingDefault is validated + await InvalidPropertyWithWhenWritingDefaultProducesError(endpoint); + + // Test that WhenWritingNull is validated + await InvalidPropertyWithWhenWritingNullProducesError(endpoint); + + // Test that Never is validated + await InvalidPropertyWithNeverProducesError(endpoint); + + // Test that Always and JsonIgnore (without condition) are NOT validated (no error expected) + await InvalidPropertiesWithAlwaysAndDefaultAreIgnored(endpoint); + + // Test that WhenReading is NOT validated (no error expected) + await InvalidPropertyWithWhenReadingIsIgnored(endpoint); + + async Task InvalidPropertyWithWhenWritingDefaultProducesError(Endpoint endpoint) + { + var payload = """ + { + "PropertyWithWhenWritingDefault": "ExceedsMaxLength" + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("PropertyWithWhenWritingDefault", kvp.Key); + Assert.Contains("maximum length", kvp.Value.Single()); + }); + } + + async Task InvalidPropertyWithWhenWritingNullProducesError(Endpoint endpoint) + { + var payload = """ + { + "PropertyWithWhenWritingNull": "ExceedsMaxLength" + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("PropertyWithWhenWritingNull", kvp.Key); + Assert.Contains("maximum length", kvp.Value.Single()); + }); + } + + async Task InvalidPropertyWithNeverProducesError(Endpoint endpoint) + { + var payload = """ + { + "PropertyWithNever": "ExceedsMaxLength" + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + var problemDetails = await AssertBadRequest(context); + Assert.Collection(problemDetails.Errors, kvp => + { + Assert.Equal("PropertyWithNever", kvp.Key); + Assert.Contains("maximum length", kvp.Value.Single()); + }); + } + + async Task InvalidPropertiesWithAlwaysAndDefaultAreIgnored(Endpoint endpoint) + { + var payload = """ + { + "PropertyWithJsonIgnoreOnly": "ExceedsMaxLength", + "PropertyWithAlways": "ExceedsMaxLength" + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + // Should succeed because these properties are ignored during validation + Assert.Equal(200, context.Response.StatusCode); + } + + async Task InvalidPropertyWithWhenReadingIsIgnored(Endpoint endpoint) + { + var payload = """ + { + "PropertyWithWhenReading": "ExceedsMaxLength" + } + """; + var context = CreateHttpContextWithPayload(payload, serviceProvider); + + await endpoint.RequestDelegate(context); + + // Should succeed because this property is ignored during reading/deserialization + Assert.Equal(200, context.Response.StatusCode); + } + }); + } } diff --git a/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesPropertiesWithJsonIgnoreWhenWritingConditions#ValidatableInfoResolver.g.verified.cs b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesPropertiesWithJsonIgnoreWhenWritingConditions#ValidatableInfoResolver.g.verified.cs new file mode 100644 index 000000000000..a1083cc086c1 --- /dev/null +++ b/src/Validation/test/Microsoft.Extensions.Validation.GeneratorTests/snapshots/ValidationsGeneratorTests.ValidatesPropertiesWithJsonIgnoreWhenWritingConditions#ValidatableInfoResolver.g.verified.cs @@ -0,0 +1,201 @@ +//HintName: ValidatableInfoResolver.g.cs +#nullable enable annotations +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ +#nullable enable +#pragma warning disable ASP0029 + +namespace System.Runtime.CompilerServices +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + file sealed class InterceptsLocationAttribute : System.Attribute + { + public InterceptsLocationAttribute(int version, string data) + { + } + } +} + +namespace Microsoft.Extensions.Validation.Generated +{ + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatablePropertyInfo : global::Microsoft.Extensions.Validation.ValidatablePropertyInfo + { + public GeneratedValidatablePropertyInfo( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type containingType, + global::System.Type propertyType, + string name, + string displayName) : base(containingType, propertyType, name, displayName) + { + ContainingType = containingType; + Name = name; + } + + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + internal global::System.Type ContainingType { get; } + internal string Name { get; } + + protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes() + => ValidationAttributeCache.GetPropertyValidationAttributes(ContainingType, Name); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file sealed class GeneratedValidatableTypeInfo : global::Microsoft.Extensions.Validation.ValidatableTypeInfo + { + public GeneratedValidatableTypeInfo( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + global::System.Type type, + ValidatablePropertyInfo[] members) : base(type, members) + { + Type = type; + } + + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + internal global::System.Type Type { get; } + + protected override global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetValidationAttributes() + => ValidationAttributeCache.GetTypeValidationAttributes(Type); + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file class GeneratedValidatableInfoResolver : global::Microsoft.Extensions.Validation.IValidatableInfoResolver + { + public bool TryGetValidatableTypeInfo(global::System.Type type, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + if (type == typeof(global::JsonIgnoreConditionsModel)) + { + validatableInfo = new GeneratedValidatableTypeInfo( + type: typeof(global::JsonIgnoreConditionsModel), + members: [ + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::JsonIgnoreConditionsModel), + propertyType: typeof(string), + name: "PropertyWithWhenWritingDefault", + displayName: "PropertyWithWhenWritingDefault" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::JsonIgnoreConditionsModel), + propertyType: typeof(string), + name: "PropertyWithWhenWritingNull", + displayName: "PropertyWithWhenWritingNull" + ), + new GeneratedValidatablePropertyInfo( + containingType: typeof(global::JsonIgnoreConditionsModel), + propertyType: typeof(string), + name: "PropertyWithNever", + displayName: "PropertyWithNever" + ), + ] + ); + return true; + } + + return false; + } + + // No-ops, rely on runtime code for ParameterInfo-based resolution + public bool TryGetValidatableParameterInfo(global::System.Reflection.ParameterInfo parameterInfo, [global::System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out global::Microsoft.Extensions.Validation.IValidatableInfo? validatableInfo) + { + validatableInfo = null; + return false; + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class GeneratedServiceCollectionExtensions + { + [InterceptsLocation] + public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddValidation(this global::Microsoft.Extensions.DependencyInjection.IServiceCollection services, global::System.Action? configureOptions = null) + { + // Use non-extension method to avoid infinite recursion. + return global::Microsoft.Extensions.DependencyInjection.ValidationServiceCollectionExtensions.AddValidation(services, options => + { + options.Resolvers.Insert(0, new GeneratedValidatableInfoResolver()); + if (configureOptions is not null) + { + configureOptions(options); + } + }); + } + } + + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Validation.ValidationsGenerator, Version=42.42.42.42, Culture=neutral, PublicKeyToken=adb9793829ddae60", "42.42.42.42")] + file static class ValidationAttributeCache + { + private sealed record CacheKey( + [param: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + [property: global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type ContainingType, + string PropertyName); + private static readonly global::System.Collections.Concurrent.ConcurrentDictionary _propertyCache = new(); + private static readonly global::System.Lazy> _lazyTypeCache = new (() => new ()); + private static global::System.Collections.Concurrent.ConcurrentDictionary TypeCache => _lazyTypeCache.Value; + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetPropertyValidationAttributes( + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties | global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] + global::System.Type containingType, + string propertyName) + { + var key = new CacheKey(containingType, propertyName); + return _propertyCache.GetOrAdd(key, static k => + { + var results = new global::System.Collections.Generic.List(); + + // Get attributes from the property + var property = k.ContainingType.GetProperty(k.PropertyName); + if (property != null) + { + var propertyAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(property, inherit: true); + + results.AddRange(propertyAttributes); + } + + // Check constructors for parameters that match the property name + // to handle record scenarios + foreach (var constructor in k.ContainingType.GetConstructors()) + { + // Look for parameter with matching name (case insensitive) + var parameter = global::System.Linq.Enumerable.FirstOrDefault( + constructor.GetParameters(), + p => string.Equals(p.Name, k.PropertyName, global::System.StringComparison.OrdinalIgnoreCase)); + + if (parameter != null) + { + var paramAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(parameter, inherit: true); + + results.AddRange(paramAttributes); + + break; + } + } + + return results.ToArray(); + }); + } + + + public static global::System.ComponentModel.DataAnnotations.ValidationAttribute[] GetTypeValidationAttributes( + [global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.Interfaces)] + global::System.Type type + ) + { + return TypeCache.GetOrAdd(type, static t => + { + var typeAttributes = global::System.Reflection.CustomAttributeExtensions + .GetCustomAttributes(t, inherit: true); + return global::System.Linq.Enumerable.ToArray(typeAttributes); + }); + } + } +} \ No newline at end of file From f7a7ad9ecf4e2009e4c9d8dfd0ec8904f0529647 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Dec 2025 20:50:44 +0000 Subject: [PATCH 3/3] Add constants for JsonIgnoreCondition values to improve code maintainability Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --- .../gen/Extensions/ITypeSymbolExtensions.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs b/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs index 97d689b3a81c..6f069411750e 100644 --- a/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs +++ b/src/Validation/gen/Extensions/ITypeSymbolExtensions.cs @@ -156,13 +156,17 @@ attr.AttributeClass is not null && /// /// Checks if the property is marked with [JsonIgnore] attribute with a condition that affects deserialization. - /// Only skips validation when the condition is Always (1) or WhenReading (5). - /// Properties with WhenWritingDefault (2), WhenWritingNull (3), WhenWriting (4), or Never (0) are still validated. + /// Only skips validation when the condition is Always or WhenReading, as these affect the reading/deserialization process. + /// Properties with conditions that only affect writing (WhenWritingDefault, WhenWritingNull, WhenWriting) or Never are still validated. /// /// The property to check. /// The symbol representing the [JsonIgnore] attribute. internal static bool IsJsonIgnoredProperty(this IPropertySymbol property, INamedTypeSymbol jsonIgnoreAttributeSymbol) { + // JsonIgnoreCondition enum values from System.Text.Json.Serialization + const int JsonIgnoreCondition_Always = 1; // Property is always ignored + const int JsonIgnoreCondition_WhenReading = 5; // Property is ignored during deserialization + foreach (var attr in property.GetAttributes()) { if (attr.AttributeClass is not null && @@ -176,11 +180,10 @@ internal static bool IsJsonIgnoredProperty(this IPropertySymbol property, INamed if (string.Equals(namedArgument.Key, "Condition", System.StringComparison.Ordinal)) { // The value is an enum represented as an int - // JsonIgnoreCondition.Always = 1, JsonIgnoreCondition.WhenReading = 5 if (namedArgument.Value.Value is int conditionValue) { - // Only skip validation for Always (1) or WhenReading (5) - return conditionValue == 1 || conditionValue == 5; + // Only skip validation for Always or WhenReading (conditions that affect reading/deserialization) + return conditionValue == JsonIgnoreCondition_Always || conditionValue == JsonIgnoreCondition_WhenReading; } } }