From 3f50173cb0d504db70867622827388bdf155fc78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:41:31 +0000 Subject: [PATCH 1/8] Initial plan From 338a49f89912ba27c282662d550de066437b1dee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:26:41 +0000 Subject: [PATCH 2/8] Add JsonNamingPolicyAttribute with runtime resolver and source generator support Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../gen/Helpers/KnownTypeSymbols.cs | 3 + .../gen/JsonSourceGenerator.Parser.cs | 59 +++++++++++++-- .../System.Text.Json/ref/System.Text.Json.cs | 7 ++ .../src/System.Text.Json.csproj | 1 + .../Attributes/JsonNamingPolicyAttribute.cs | 72 +++++++++++++++++++ .../DefaultJsonTypeInfoResolver.Helpers.cs | 20 +++--- 6 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonNamingPolicyAttribute.cs diff --git a/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs b/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs index 7abf6d32cddbf1..e5087e94c63131 100644 --- a/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs +++ b/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs @@ -228,6 +228,9 @@ public KnownTypeSymbols(Compilation compilation) public INamedTypeSymbol? JsonNumberHandlingAttributeType => GetOrResolveType("System.Text.Json.Serialization.JsonNumberHandlingAttribute", ref _JsonNumberHandlingAttributeType); private Option _JsonNumberHandlingAttributeType; + public INamedTypeSymbol? JsonNamingPolicyAttributeType => GetOrResolveType("System.Text.Json.Serialization.JsonNamingPolicyAttribute", ref _JsonNamingPolicyAttributeType); + private Option _JsonNamingPolicyAttributeType; + public INamedTypeSymbol? JsonObjectCreationHandlingAttributeType => GetOrResolveType("System.Text.Json.Serialization.JsonObjectCreationHandlingAttribute", ref _JsonObjectCreationHandlingAttributeType); private Option _JsonObjectCreationHandlingAttributeType; diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 329a369b4a0135..404765aeb85cc0 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -587,6 +587,7 @@ private TypeGenerationSpec ParseTypeGenerationSpec(in TypeToGenerate typeToGener out JsonNumberHandling? numberHandling, out JsonUnmappedMemberHandling? unmappedMemberHandling, out JsonObjectCreationHandling? preferredPropertyObjectCreationHandling, + out JsonKnownNamingPolicy? typeNamingPolicy, out bool foundJsonConverterAttribute, out TypeRef? customConverterType, out bool isPolymorphic); @@ -691,7 +692,7 @@ private TypeGenerationSpec ParseTypeGenerationSpec(in TypeToGenerate typeToGener implementsIJsonOnSerialized = _knownSymbols.IJsonOnSerializedType.IsAssignableFrom(type); ctorParamSpecs = ParseConstructorParameters(typeToGenerate, constructor, out constructionStrategy, out constructorSetsRequiredMembers); - propertySpecs = ParsePropertyGenerationSpecs(contextType, typeToGenerate, options, out hasExtensionDataProperty, out fastPathPropertyIndices); + propertySpecs = ParsePropertyGenerationSpecs(contextType, typeToGenerate, options, typeNamingPolicy, out hasExtensionDataProperty, out fastPathPropertyIndices); propertyInitializerSpecs = ParsePropertyInitializers(ctorParamSpecs, propertySpecs, constructorSetsRequiredMembers, ref constructionStrategy); } @@ -748,6 +749,7 @@ private void ProcessTypeCustomAttributes( out JsonNumberHandling? numberHandling, out JsonUnmappedMemberHandling? unmappedMemberHandling, out JsonObjectCreationHandling? objectCreationHandling, + out JsonKnownNamingPolicy? namingPolicy, out bool foundJsonConverterAttribute, out TypeRef? customConverterType, out bool isPolymorphic) @@ -755,6 +757,7 @@ private void ProcessTypeCustomAttributes( numberHandling = null; unmappedMemberHandling = null; objectCreationHandling = null; + namingPolicy = null; customConverterType = null; foundJsonConverterAttribute = false; isPolymorphic = false; @@ -778,6 +781,16 @@ private void ProcessTypeCustomAttributes( objectCreationHandling = (JsonObjectCreationHandling)attributeData.ConstructorArguments[0].Value!; continue; } + else if (_knownSymbols.JsonNamingPolicyAttributeType?.IsAssignableFrom(attributeType) == true) + { + if (attributeData.ConstructorArguments.Length == 1 && + attributeData.ConstructorArguments[0].Value is int knownPolicyValue) + { + namingPolicy = (JsonKnownNamingPolicy)knownPolicyValue; + } + + continue; + } else if (!foundJsonConverterAttribute && _knownSymbols.JsonConverterAttributeType.IsAssignableFrom(attributeType)) { customConverterType = GetConverterTypeFromJsonConverterAttribute(contextType, typeToGenerate.Type, attributeData); @@ -979,6 +992,7 @@ private List ParsePropertyGenerationSpecs( INamedTypeSymbol contextType, in TypeToGenerate typeToGenerate, SourceGenerationOptionsSpec? options, + JsonKnownNamingPolicy? typeNamingPolicy, out bool hasExtensionDataProperty, out List? fastPathPropertyIndices) { @@ -1062,7 +1076,8 @@ void AddMember( memberInfo, ref hasExtensionDataProperty, generationMode, - options); + options, + typeNamingPolicy); if (propertySpec is null) { @@ -1210,7 +1225,8 @@ private bool IsValidDataExtensionPropertyType(ITypeSymbol type) ISymbol memberInfo, ref bool typeHasExtensionDataProperty, JsonSourceGenerationMode? generationMode, - SourceGenerationOptionsSpec? options) + SourceGenerationOptionsSpec? options, + JsonKnownNamingPolicy? typeNamingPolicy) { Debug.Assert(memberInfo is IFieldSymbol or IPropertySymbol); @@ -1222,6 +1238,7 @@ private bool IsValidDataExtensionPropertyType(ITypeSymbol type) out JsonIgnoreCondition? ignoreCondition, out JsonNumberHandling? numberHandling, out JsonObjectCreationHandling? objectCreationHandling, + out JsonKnownNamingPolicy? memberNamingPolicy, out TypeRef? converterType, out int order, out bool isExtensionData, @@ -1281,9 +1298,18 @@ private bool IsValidDataExtensionPropertyType(ITypeSymbol type) return null; } - string effectiveJsonPropertyName = DetermineEffectiveJsonPropertyName(memberInfo.Name, jsonPropertyName, options); + string effectiveJsonPropertyName = DetermineEffectiveJsonPropertyName(memberInfo.Name, jsonPropertyName, memberNamingPolicy, typeNamingPolicy, options); string propertyNameFieldName = DeterminePropertyNameFieldName(effectiveJsonPropertyName); + // For metadata-based serialization, embed the effective property name when a naming policy + // attribute is present so that the runtime DeterminePropertyName uses it instead of + // falling back to the global PropertyNamingPolicy. JsonPropertyNameAttribute values are + // already captured in jsonPropertyName and take highest precedence. + string? metadataJsonPropertyName = jsonPropertyName + ?? (memberNamingPolicy is not null || typeNamingPolicy is not null + ? effectiveJsonPropertyName + : null); + // Enqueue the property type for generation, unless the member is ignored. TypeRef propertyTypeRef = ignoreCondition != JsonIgnoreCondition.Always ? EnqueueType(memberType, generationMode) @@ -1296,7 +1322,7 @@ private bool IsValidDataExtensionPropertyType(ITypeSymbol type) IsProperty = memberInfo is IPropertySymbol, IsPublic = isAccessible, IsVirtual = memberInfo.IsVirtual(), - JsonPropertyName = jsonPropertyName, + JsonPropertyName = metadataJsonPropertyName, EffectiveJsonPropertyName = effectiveJsonPropertyName, PropertyNameFieldName = propertyNameFieldName, IsReadOnly = isReadOnly, @@ -1327,6 +1353,7 @@ private void ProcessMemberCustomAttributes( out JsonIgnoreCondition? ignoreCondition, out JsonNumberHandling? numberHandling, out JsonObjectCreationHandling? objectCreationHandling, + out JsonKnownNamingPolicy? memberNamingPolicy, out TypeRef? converterType, out int order, out bool isExtensionData, @@ -1339,6 +1366,7 @@ private void ProcessMemberCustomAttributes( ignoreCondition = default; numberHandling = default; objectCreationHandling = default; + memberNamingPolicy = default; converterType = null; order = 0; isExtensionData = false; @@ -1357,6 +1385,14 @@ private void ProcessMemberCustomAttributes( { converterType = GetConverterTypeFromJsonConverterAttribute(contextType, memberInfo, attributeData); } + else if (memberNamingPolicy is null && _knownSymbols.JsonNamingPolicyAttributeType?.IsAssignableFrom(attributeType) == true) + { + if (attributeData.ConstructorArguments.Length == 1 && + attributeData.ConstructorArguments[0].Value is int knownPolicyValue) + { + memberNamingPolicy = (JsonKnownNamingPolicy)knownPolicyValue; + } + } else if (attributeType.ContainingAssembly.Name == SystemTextJsonNamespace) { switch (attributeType.ToDisplayString()) @@ -1690,14 +1726,23 @@ bool MatchesConstructorParameter(ParameterGenerationSpec paramSpec) return new TypeRef(converterType); } - private static string DetermineEffectiveJsonPropertyName(string propertyName, string? jsonPropertyName, SourceGenerationOptionsSpec? options) + private static string DetermineEffectiveJsonPropertyName( + string propertyName, + string? jsonPropertyName, + JsonKnownNamingPolicy? memberNamingPolicy, + JsonKnownNamingPolicy? typeNamingPolicy, + SourceGenerationOptionsSpec? options) { if (jsonPropertyName != null) { return jsonPropertyName; } - JsonNamingPolicy? instance = options?.GetEffectivePropertyNamingPolicy() switch + JsonKnownNamingPolicy? effectiveKnownPolicy = memberNamingPolicy + ?? typeNamingPolicy + ?? options?.GetEffectivePropertyNamingPolicy(); + + JsonNamingPolicy? instance = effectiveKnownPolicy switch { JsonKnownNamingPolicy.CamelCase => JsonNamingPolicy.CamelCase, JsonKnownNamingPolicy.SnakeCaseLower => JsonNamingPolicy.SnakeCaseLower, diff --git a/src/libraries/System.Text.Json/ref/System.Text.Json.cs b/src/libraries/System.Text.Json/ref/System.Text.Json.cs index 1b971d9def83ae..45e53c8f5b7337 100644 --- a/src/libraries/System.Text.Json/ref/System.Text.Json.cs +++ b/src/libraries/System.Text.Json/ref/System.Text.Json.cs @@ -1079,6 +1079,13 @@ public enum JsonKnownNamingPolicy KebabCaseLower = 4, KebabCaseUpper = 5, } + [System.AttributeUsageAttribute(System.AttributeTargets.Class | System.AttributeTargets.Field | System.AttributeTargets.Interface | System.AttributeTargets.Property | System.AttributeTargets.Struct, AllowMultiple=false)] + public partial class JsonNamingPolicyAttribute : System.Text.Json.Serialization.JsonAttribute + { + public JsonNamingPolicyAttribute(System.Text.Json.Serialization.JsonKnownNamingPolicy namingPolicy) { } + protected JsonNamingPolicyAttribute(System.Text.Json.JsonNamingPolicy namingPolicy) { } + public System.Text.Json.JsonNamingPolicy NamingPolicy { get { throw null; } } + } public enum JsonKnownReferenceHandler { Unspecified = 0, diff --git a/src/libraries/System.Text.Json/src/System.Text.Json.csproj b/src/libraries/System.Text.Json/src/System.Text.Json.csproj index 76554aa7662fce..aa81492a67ca2f 100644 --- a/src/libraries/System.Text.Json/src/System.Text.Json.csproj +++ b/src/libraries/System.Text.Json/src/System.Text.Json.csproj @@ -116,6 +116,7 @@ The System.Text.Json library is built-in as part of the shared framework in .NET + diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonNamingPolicyAttribute.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonNamingPolicyAttribute.cs new file mode 100644 index 00000000000000..b2d4c68602910c --- /dev/null +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonNamingPolicyAttribute.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Text.Json.Serialization +{ + /// + /// When placed on a type, property, or field, indicates what + /// should be used to convert property names. + /// + /// + /// When placed on a property or field, the naming policy specified by this attribute + /// takes precedence over the type-level attribute and the . + /// When placed on a type, the naming policy specified by this attribute takes precedence + /// over the . + /// The takes precedence over this attribute. + /// + [AttributeUsage( + AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface | + AttributeTargets.Property | AttributeTargets.Field, + AllowMultiple = false)] + public class JsonNamingPolicyAttribute : JsonAttribute + { + /// + /// Initializes a new instance of + /// with the specified known naming policy. + /// + /// The known naming policy to use for name conversion. + /// + /// The specified is not a valid known naming policy value. + /// + public JsonNamingPolicyAttribute(JsonKnownNamingPolicy namingPolicy) + { + NamingPolicy = ResolveNamingPolicy(namingPolicy); + } + + /// + /// Initializes a new instance of + /// with a custom naming policy. + /// + /// The naming policy to use for name conversion. + /// + /// is . + /// + protected JsonNamingPolicyAttribute(JsonNamingPolicy namingPolicy) + { + if (namingPolicy is null) + { + ThrowHelper.ThrowArgumentNullException(nameof(namingPolicy)); + } + + NamingPolicy = namingPolicy; + } + + /// + /// Gets the naming policy to use for name conversion. + /// + public JsonNamingPolicy NamingPolicy { get; } + + internal static JsonNamingPolicy ResolveNamingPolicy(JsonKnownNamingPolicy namingPolicy) + { + return namingPolicy switch + { + JsonKnownNamingPolicy.CamelCase => JsonNamingPolicy.CamelCase, + JsonKnownNamingPolicy.SnakeCaseLower => JsonNamingPolicy.SnakeCaseLower, + JsonKnownNamingPolicy.SnakeCaseUpper => JsonNamingPolicy.SnakeCaseUpper, + JsonKnownNamingPolicy.KebabCaseLower => JsonNamingPolicy.KebabCaseLower, + JsonKnownNamingPolicy.KebabCaseUpper => JsonNamingPolicy.KebabCaseUpper, + _ => throw new ArgumentOutOfRangeException(nameof(namingPolicy)), + }; + } + } +} diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs index d7aef7399c0b49..6b85d32038a23d 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs @@ -251,7 +251,8 @@ private static void AddMember( } JsonPropertyInfo jsonPropertyInfo = typeInfo.CreatePropertyUsingReflection(typeToConvert, declaringType: memberInfo.DeclaringType); - PopulatePropertyInfo(jsonPropertyInfo, memberInfo, customConverter, ignoreCondition, nullabilityCtx, shouldCheckForRequiredKeyword, hasJsonIncludeAttribute); + JsonNamingPolicy? typeNamingPolicy = typeInfo.Type.GetUniqueCustomAttribute(inherit: false)?.NamingPolicy; + PopulatePropertyInfo(jsonPropertyInfo, memberInfo, customConverter, ignoreCondition, nullabilityCtx, shouldCheckForRequiredKeyword, hasJsonIncludeAttribute, typeNamingPolicy); return jsonPropertyInfo; } @@ -326,7 +327,8 @@ private static void PopulatePropertyInfo( JsonIgnoreCondition? ignoreCondition, NullabilityInfoContext nullabilityCtx, bool shouldCheckForRequiredKeyword, - bool hasJsonIncludeAttribute) + bool hasJsonIncludeAttribute, + JsonNamingPolicy? typeNamingPolicy) { Debug.Assert(jsonPropertyInfo.AttributeProvider == null); @@ -348,7 +350,7 @@ private static void PopulatePropertyInfo( jsonPropertyInfo.CustomConverter = customConverter; DeterminePropertyPolicies(jsonPropertyInfo, memberInfo); - DeterminePropertyName(jsonPropertyInfo, memberInfo); + DeterminePropertyName(jsonPropertyInfo, memberInfo, typeNamingPolicy); DeterminePropertyIsRequired(jsonPropertyInfo, memberInfo, shouldCheckForRequiredKeyword); DeterminePropertyNullability(jsonPropertyInfo, memberInfo, nullabilityCtx); @@ -373,7 +375,7 @@ private static void DeterminePropertyPolicies(JsonPropertyInfo propertyInfo, Mem propertyInfo.ObjectCreationHandling = objectCreationHandlingAttr?.Handling; } - private static void DeterminePropertyName(JsonPropertyInfo propertyInfo, MemberInfo memberInfo) + private static void DeterminePropertyName(JsonPropertyInfo propertyInfo, MemberInfo memberInfo, JsonNamingPolicy? typeNamingPolicy) { JsonPropertyNameAttribute? nameAttribute = memberInfo.GetCustomAttribute(inherit: false); string? name; @@ -381,13 +383,13 @@ private static void DeterminePropertyName(JsonPropertyInfo propertyInfo, MemberI { name = nameAttribute.Name; } - else if (propertyInfo.Options.PropertyNamingPolicy != null) - { - name = propertyInfo.Options.PropertyNamingPolicy.ConvertName(memberInfo.Name); - } else { - name = memberInfo.Name; + JsonNamingPolicy? effectivePolicy = memberInfo.GetCustomAttribute(inherit: false)?.NamingPolicy + ?? typeNamingPolicy + ?? propertyInfo.Options.PropertyNamingPolicy; + + name = effectivePolicy?.ConvertName(memberInfo.Name) ?? memberInfo.Name; } if (name == null) From 02dc0074fd63e8134699537be3976eee4455ca53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:33:59 +0000 Subject: [PATCH 3/8] Fix null naming policy regression and add tests for JsonNamingPolicyAttribute Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../DefaultJsonTypeInfoResolver.Helpers.cs | 4 +- .../tests/Common/PropertyNameTests.cs | 112 ++++++++++++++++++ .../Serialization/PropertyNameTests.cs | 8 ++ 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs index 6b85d32038a23d..de09cc83896eb7 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs @@ -389,7 +389,9 @@ private static void DeterminePropertyName(JsonPropertyInfo propertyInfo, MemberI ?? typeNamingPolicy ?? propertyInfo.Options.PropertyNamingPolicy; - name = effectivePolicy?.ConvertName(memberInfo.Name) ?? memberInfo.Name; + name = effectivePolicy is not null + ? effectivePolicy.ConvertName(memberInfo.Name) + : memberInfo.Name; } if (name == null) diff --git a/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs b/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs index c35822b41d22b2..40b8ce7c868d45 100644 --- a/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs @@ -578,5 +578,117 @@ public async Task DuplicatesByKebabCaseNamingPolicyIgnoreCase() Exception ex = await Assert.ThrowsAsync(() => Serializer.DeserializeWrapper(json, options)); Assert.Contains("Duplicate", ex.Message); } + + [Fact] + public async Task JsonNamingPolicyAttribute_TypeLevel_Serialize() + { + string json = await Serializer.SerializeWrapper(new ClassWithCamelCaseNamingPolicyAttribute { MyValue = "test" }); + Assert.Contains(@"""myValue"":""test""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_TypeLevel_Deserialize() + { + var obj = await Serializer.DeserializeWrapper(@"{""myValue"":""test""}"); + Assert.Equal("test", obj.MyValue); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_TypeLevel_OverridesGlobalPolicy() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + string json = await Serializer.SerializeWrapper(new ClassWithCamelCaseNamingPolicyAttribute { MyValue = "test" }, options); + Assert.Contains(@"""myValue"":""test""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_MemberLevel_Serialize() + { + string json = await Serializer.SerializeWrapper(new ClassWithMemberNamingPolicyAttribute { MyFirstProperty = "first", MySecondProperty = "second" }); + Assert.Contains(@"""MyFirstProperty"":""first""", json); + Assert.Contains(@"""my-second-property"":""second""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_MemberLevel_Deserialize() + { + var obj = await Serializer.DeserializeWrapper(@"{""MyFirstProperty"":""first"",""my-second-property"":""second""}"); + Assert.Equal("first", obj.MyFirstProperty); + Assert.Equal("second", obj.MySecondProperty); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_MemberLevel_OverridesTypeLevelAndGlobal() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + string json = await Serializer.SerializeWrapper(new ClassWithMixedNamingPolicies { MyFirstProperty = "first", MySecondProperty = "second" }, options); + Assert.Contains(@"""myFirstProperty"":""first""", json); + Assert.Contains(@"""my-second-property"":""second""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_MemberLevel_OverridesTypeLevel() + { + string json = await Serializer.SerializeWrapper(new ClassWithMixedNamingPolicies { MyFirstProperty = "first", MySecondProperty = "second" }); + Assert.Contains(@"""myFirstProperty"":""first""", json); + Assert.Contains(@"""my-second-property"":""second""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_JsonPropertyNameTakesPrecedence() + { + string json = await Serializer.SerializeWrapper(new ClassWithNamingPolicyAndPropertyName { MyValue = "test" }); + Assert.Contains(@"""custom_name"":""test""", json); + } + + [Theory] + [InlineData(JsonKnownNamingPolicy.CamelCase, @"""myTestProperty""")] + [InlineData(JsonKnownNamingPolicy.SnakeCaseLower, @"""my_test_property""")] + [InlineData(JsonKnownNamingPolicy.SnakeCaseUpper, @"""MY_TEST_PROPERTY""")] + [InlineData(JsonKnownNamingPolicy.KebabCaseLower, @"""my-test-property""")] + [InlineData(JsonKnownNamingPolicy.KebabCaseUpper, @"""MY-TEST-PROPERTY""")] + public void JsonNamingPolicyAttribute_AllKnownPolicies(JsonKnownNamingPolicy policy, string expectedPropertyName) + { + var attribute = new JsonNamingPolicyAttribute(policy); + string converted = attribute.NamingPolicy.ConvertName("MyTestProperty"); + Assert.Equal(expectedPropertyName.Trim('"'), converted); + } + + [Fact] + public void JsonNamingPolicyAttribute_InvalidPolicy_Throws() + { + Assert.Throws(() => new JsonNamingPolicyAttribute((JsonKnownNamingPolicy)999)); + Assert.Throws(() => new JsonNamingPolicyAttribute(JsonKnownNamingPolicy.Unspecified)); + } + } + + [JsonNamingPolicy(JsonKnownNamingPolicy.CamelCase)] + public class ClassWithCamelCaseNamingPolicyAttribute + { + public string MyValue { get; set; } + } + + public class ClassWithMemberNamingPolicyAttribute + { + public string MyFirstProperty { get; set; } + + [JsonNamingPolicy(JsonKnownNamingPolicy.KebabCaseLower)] + public string MySecondProperty { get; set; } + } + + [JsonNamingPolicy(JsonKnownNamingPolicy.CamelCase)] + public class ClassWithMixedNamingPolicies + { + public string MyFirstProperty { get; set; } + + [JsonNamingPolicy(JsonKnownNamingPolicy.KebabCaseLower)] + public string MySecondProperty { get; set; } + } + + public class ClassWithNamingPolicyAndPropertyName + { + [JsonNamingPolicy(JsonKnownNamingPolicy.CamelCase)] + [JsonPropertyName("custom_name")] + public string MyValue { get; set; } } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs index e512451eed72bc..0585774275d7f2 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs @@ -29,6 +29,10 @@ public PropertyNameTests_Metadata() [JsonSerializable(typeof(OverridePropertyNameDesignTime_TestClass))] [JsonSerializable(typeof(SimpleTestClass))] [JsonSerializable(typeof(ClassWithIgnoredCaseInsensitiveConflict))] + [JsonSerializable(typeof(ClassWithCamelCaseNamingPolicyAttribute))] + [JsonSerializable(typeof(ClassWithMemberNamingPolicyAttribute))] + [JsonSerializable(typeof(ClassWithMixedNamingPolicies))] + [JsonSerializable(typeof(ClassWithNamingPolicyAndPropertyName))] internal sealed partial class PropertyNameTestsContext_Metadata : JsonSerializerContext { } @@ -55,6 +59,10 @@ public PropertyNameTests_Default() [JsonSerializable(typeof(OverridePropertyNameDesignTime_TestClass))] [JsonSerializable(typeof(SimpleTestClass))] [JsonSerializable(typeof(ClassWithIgnoredCaseInsensitiveConflict))] + [JsonSerializable(typeof(ClassWithCamelCaseNamingPolicyAttribute))] + [JsonSerializable(typeof(ClassWithMemberNamingPolicyAttribute))] + [JsonSerializable(typeof(ClassWithMixedNamingPolicies))] + [JsonSerializable(typeof(ClassWithNamingPolicyAndPropertyName))] internal sealed partial class PropertyNameTestsContext_Default : JsonSerializerContext { } From 95b0107fca88db7d8d974dc35d5d5cb8b74c50bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:26:05 +0000 Subject: [PATCH 4/8] Address review: resolve type-level naming policy once per type, handle unresolvable derived attributes in source gen Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../gen/JsonSourceGenerator.Parser.cs | 12 ++++++++++++ .../Metadata/DefaultJsonTypeInfoResolver.Helpers.cs | 12 ++++++++++-- .../tests/Common/PropertyNameTests.cs | 10 +++++----- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs index 404765aeb85cc0..2a00aa25ada914 100644 --- a/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs +++ b/src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs @@ -788,6 +788,12 @@ private void ProcessTypeCustomAttributes( { namingPolicy = (JsonKnownNamingPolicy)knownPolicyValue; } + else + { + // The attribute uses a custom naming policy that can't be resolved at compile time. + // Use Unspecified to prevent the global naming policy from incorrectly applying. + namingPolicy = JsonKnownNamingPolicy.Unspecified; + } continue; } @@ -1392,6 +1398,12 @@ private void ProcessMemberCustomAttributes( { memberNamingPolicy = (JsonKnownNamingPolicy)knownPolicyValue; } + else + { + // The attribute uses a custom naming policy that can't be resolved at compile time. + // Use Unspecified to prevent the global naming policy from incorrectly applying. + memberNamingPolicy = JsonKnownNamingPolicy.Unspecified; + } } else if (attributeType.ContainingAssembly.Name == SystemTextJsonNamespace) { diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs index de09cc83896eb7..fc2699a72442ef 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Metadata/DefaultJsonTypeInfoResolver.Helpers.cs @@ -102,6 +102,9 @@ private static void PopulateProperties(JsonTypeInfo typeInfo, NullabilityInfoCon bool constructorHasSetsRequiredMembersAttribute = typeInfo.Converter.ConstructorInfo?.HasSetsRequiredMembersAttribute() ?? false; + // Resolve the type-level JsonNamingPolicyAttribute once for the entire type. + JsonNamingPolicy? typeNamingPolicy = typeInfo.Type.GetUniqueCustomAttribute(inherit: false)?.NamingPolicy; + JsonTypeInfo.PropertyHierarchyResolutionState state = new(typeInfo.Options); // Walk the type hierarchy starting from the current type up to the base type(s) @@ -117,6 +120,7 @@ private static void PopulateProperties(JsonTypeInfo typeInfo, NullabilityInfoCon AddMembersDeclaredBySuperType( typeInfo, currentType, + typeNamingPolicy, nullabilityCtx, constructorHasSetsRequiredMembersAttribute, ref state); @@ -140,6 +144,7 @@ private static void PopulateProperties(JsonTypeInfo typeInfo, NullabilityInfoCon private static void AddMembersDeclaredBySuperType( JsonTypeInfo typeInfo, Type currentType, + JsonNamingPolicy? typeNamingPolicy, NullabilityInfoContext nullabilityCtx, bool constructorHasSetsRequiredMembersAttribute, ref JsonTypeInfo.PropertyHierarchyResolutionState state) @@ -171,6 +176,7 @@ private static void AddMembersDeclaredBySuperType( typeInfo, typeToConvert: propertyInfo.PropertyType, memberInfo: propertyInfo, + typeNamingPolicy, nullabilityCtx, shouldCheckMembersForRequiredMemberAttribute, hasJsonIncludeAttribute, @@ -187,6 +193,7 @@ private static void AddMembersDeclaredBySuperType( typeInfo, typeToConvert: fieldInfo.FieldType, memberInfo: fieldInfo, + typeNamingPolicy, nullabilityCtx, shouldCheckMembersForRequiredMemberAttribute, hasJsonIncludeAttribute, @@ -201,12 +208,13 @@ private static void AddMember( JsonTypeInfo typeInfo, Type typeToConvert, MemberInfo memberInfo, + JsonNamingPolicy? typeNamingPolicy, NullabilityInfoContext nullabilityCtx, bool shouldCheckForRequiredKeyword, bool hasJsonIncludeAttribute, ref JsonTypeInfo.PropertyHierarchyResolutionState state) { - JsonPropertyInfo? jsonPropertyInfo = CreatePropertyInfo(typeInfo, typeToConvert, memberInfo, nullabilityCtx, typeInfo.Options, shouldCheckForRequiredKeyword, hasJsonIncludeAttribute); + JsonPropertyInfo? jsonPropertyInfo = CreatePropertyInfo(typeInfo, typeToConvert, memberInfo, typeNamingPolicy, nullabilityCtx, typeInfo.Options, shouldCheckForRequiredKeyword, hasJsonIncludeAttribute); if (jsonPropertyInfo == null) { // ignored invalid property @@ -223,6 +231,7 @@ private static void AddMember( JsonTypeInfo typeInfo, Type typeToConvert, MemberInfo memberInfo, + JsonNamingPolicy? typeNamingPolicy, NullabilityInfoContext nullabilityCtx, JsonSerializerOptions options, bool shouldCheckForRequiredKeyword, @@ -251,7 +260,6 @@ private static void AddMember( } JsonPropertyInfo jsonPropertyInfo = typeInfo.CreatePropertyUsingReflection(typeToConvert, declaringType: memberInfo.DeclaringType); - JsonNamingPolicy? typeNamingPolicy = typeInfo.Type.GetUniqueCustomAttribute(inherit: false)?.NamingPolicy; PopulatePropertyInfo(jsonPropertyInfo, memberInfo, customConverter, ignoreCondition, nullabilityCtx, shouldCheckForRequiredKeyword, hasJsonIncludeAttribute, typeNamingPolicy); return jsonPropertyInfo; } diff --git a/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs b/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs index 40b8ce7c868d45..0f4ffe6d8f78f4 100644 --- a/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs @@ -662,7 +662,7 @@ public void JsonNamingPolicyAttribute_InvalidPolicy_Throws() } } - [JsonNamingPolicy(JsonKnownNamingPolicy.CamelCase)] + [JsonNamingPolicyAttribute(JsonKnownNamingPolicy.CamelCase)] public class ClassWithCamelCaseNamingPolicyAttribute { public string MyValue { get; set; } @@ -672,22 +672,22 @@ public class ClassWithMemberNamingPolicyAttribute { public string MyFirstProperty { get; set; } - [JsonNamingPolicy(JsonKnownNamingPolicy.KebabCaseLower)] + [JsonNamingPolicyAttribute(JsonKnownNamingPolicy.KebabCaseLower)] public string MySecondProperty { get; set; } } - [JsonNamingPolicy(JsonKnownNamingPolicy.CamelCase)] + [JsonNamingPolicyAttribute(JsonKnownNamingPolicy.CamelCase)] public class ClassWithMixedNamingPolicies { public string MyFirstProperty { get; set; } - [JsonNamingPolicy(JsonKnownNamingPolicy.KebabCaseLower)] + [JsonNamingPolicyAttribute(JsonKnownNamingPolicy.KebabCaseLower)] public string MySecondProperty { get; set; } } public class ClassWithNamingPolicyAndPropertyName { - [JsonNamingPolicy(JsonKnownNamingPolicy.CamelCase)] + [JsonNamingPolicyAttribute(JsonKnownNamingPolicy.CamelCase)] [JsonPropertyName("custom_name")] public string MyValue { get; set; } } From 6d8bfda9db2796f66552a2d37352748729c876c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:55:04 +0000 Subject: [PATCH 5/8] Add tests for custom derived JsonNamingPolicyAttribute in both reflection and source gen paths Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../tests/Common/PropertyNameTests.cs | 28 +++++++++++++++ .../Serialization/PropertyNameTests.cs | 36 +++++++++++++++++++ .../Serialization/PropertyNameTests.cs | 36 +++++++++++++++++++ 3 files changed, 100 insertions(+) diff --git a/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs b/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs index 0f4ffe6d8f78f4..32d210fcc081a5 100644 --- a/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs @@ -691,4 +691,32 @@ public class ClassWithNamingPolicyAndPropertyName [JsonPropertyName("custom_name")] public string MyValue { get; set; } } + + /// + /// A custom naming policy that converts property names to all uppercase. + /// + public class UpperCaseNamingPolicy : JsonNamingPolicy + { + public override string ConvertName(string name) => name.ToUpperInvariant(); + } + + /// + /// A derived JsonNamingPolicyAttribute that uses a custom naming policy (not a known policy). + /// + public class JsonUpperCaseNamingPolicyAttribute : JsonNamingPolicyAttribute + { + public JsonUpperCaseNamingPolicyAttribute() : base(new UpperCaseNamingPolicy()) { } + } + + [JsonUpperCaseNamingPolicy] + public class ClassWithCustomDerivedNamingPolicyAttribute + { + public string MyValue { get; set; } + } + + public class ClassWithCustomDerivedMemberNamingPolicyAttribute + { + [JsonUpperCaseNamingPolicy] + public string MyValue { get; set; } + } } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs index 0585774275d7f2..596fe1a770c729 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Tests; +using System.Threading.Tasks; +using Xunit; namespace System.Text.Json.SourceGeneration.Tests { @@ -14,6 +16,22 @@ public PropertyNameTests_Metadata() { } + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_FallsBackToClrName() + { + // Source gen can't resolve custom derived naming policies at compile time, + // so the property name falls back to the original CLR name. + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedNamingPolicyAttribute { MyValue = "test" }); + Assert.Contains(@"""MyValue"":""test""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_FallsBackToClrName() + { + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedMemberNamingPolicyAttribute { MyValue = "test" }); + Assert.Contains(@"""MyValue"":""test""", json); + } + [JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] @@ -33,6 +51,8 @@ public PropertyNameTests_Metadata() [JsonSerializable(typeof(ClassWithMemberNamingPolicyAttribute))] [JsonSerializable(typeof(ClassWithMixedNamingPolicies))] [JsonSerializable(typeof(ClassWithNamingPolicyAndPropertyName))] + [JsonSerializable(typeof(ClassWithCustomDerivedNamingPolicyAttribute))] + [JsonSerializable(typeof(ClassWithCustomDerivedMemberNamingPolicyAttribute))] internal sealed partial class PropertyNameTestsContext_Metadata : JsonSerializerContext { } @@ -45,6 +65,20 @@ public PropertyNameTests_Default() { } + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_FallsBackToClrName() + { + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedNamingPolicyAttribute { MyValue = "test" }); + Assert.Contains(@"""MyValue"":""test""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_FallsBackToClrName() + { + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedMemberNamingPolicyAttribute { MyValue = "test" }); + Assert.Contains(@"""MyValue"":""test""", json); + } + [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(int))] @@ -63,6 +97,8 @@ public PropertyNameTests_Default() [JsonSerializable(typeof(ClassWithMemberNamingPolicyAttribute))] [JsonSerializable(typeof(ClassWithMixedNamingPolicies))] [JsonSerializable(typeof(ClassWithNamingPolicyAndPropertyName))] + [JsonSerializable(typeof(ClassWithCustomDerivedNamingPolicyAttribute))] + [JsonSerializable(typeof(ClassWithCustomDerivedMemberNamingPolicyAttribute))] internal sealed partial class PropertyNameTestsContext_Default : JsonSerializerContext { } diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PropertyNameTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PropertyNameTests.cs index 09fb2c31287578..b0f31dc7abfa88 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PropertyNameTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PropertyNameTests.cs @@ -64,5 +64,41 @@ public static IEnumerable JsonSeparatorNamingPolicyInstances() yield return new object[] { JsonNamingPolicy.KebabCaseLower }; yield return new object[] { JsonNamingPolicy.KebabCaseUpper }; } + + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_AppliesCustomPolicy() + { + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedNamingPolicyAttribute { MyValue = "test" }); + Assert.Contains(@"""MYVALUE"":""test""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_Deserialize() + { + var obj = await Serializer.DeserializeWrapper(@"{""MYVALUE"":""test""}"); + Assert.Equal("test", obj.MyValue); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_AppliesCustomPolicy() + { + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedMemberNamingPolicyAttribute { MyValue = "test" }); + Assert.Contains(@"""MYVALUE"":""test""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_Deserialize() + { + var obj = await Serializer.DeserializeWrapper(@"{""MYVALUE"":""test""}"); + Assert.Equal("test", obj.MyValue); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_OverridesGlobalPolicy() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedNamingPolicyAttribute { MyValue = "test" }, options); + Assert.Contains(@"""MYVALUE"":""test""", json); + } } } From 3a423225f060fb7bcf38f5bb6d061745a124b4ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:28:58 +0000 Subject: [PATCH 6/8] Use triple-quote strings and Assert.Equal on full JSON in tests Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../tests/Common/PropertyNameTests.cs | 19 ++++++++----------- .../Serialization/PropertyNameTests.cs | 8 ++++---- .../Serialization/PropertyNameTests.cs | 10 +++++----- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs b/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs index 32d210fcc081a5..5e31b1a0f75b01 100644 --- a/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs @@ -583,13 +583,13 @@ public async Task DuplicatesByKebabCaseNamingPolicyIgnoreCase() public async Task JsonNamingPolicyAttribute_TypeLevel_Serialize() { string json = await Serializer.SerializeWrapper(new ClassWithCamelCaseNamingPolicyAttribute { MyValue = "test" }); - Assert.Contains(@"""myValue"":""test""", json); + Assert.Equal("""{"myValue":"test"}""", json); } [Fact] public async Task JsonNamingPolicyAttribute_TypeLevel_Deserialize() { - var obj = await Serializer.DeserializeWrapper(@"{""myValue"":""test""}"); + var obj = await Serializer.DeserializeWrapper("""{"myValue":"test"}"""); Assert.Equal("test", obj.MyValue); } @@ -598,21 +598,20 @@ public async Task JsonNamingPolicyAttribute_TypeLevel_OverridesGlobalPolicy() { var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; string json = await Serializer.SerializeWrapper(new ClassWithCamelCaseNamingPolicyAttribute { MyValue = "test" }, options); - Assert.Contains(@"""myValue"":""test""", json); + Assert.Equal("""{"myValue":"test"}""", json); } [Fact] public async Task JsonNamingPolicyAttribute_MemberLevel_Serialize() { string json = await Serializer.SerializeWrapper(new ClassWithMemberNamingPolicyAttribute { MyFirstProperty = "first", MySecondProperty = "second" }); - Assert.Contains(@"""MyFirstProperty"":""first""", json); - Assert.Contains(@"""my-second-property"":""second""", json); + Assert.Equal("""{"MyFirstProperty":"first","my-second-property":"second"}""", json); } [Fact] public async Task JsonNamingPolicyAttribute_MemberLevel_Deserialize() { - var obj = await Serializer.DeserializeWrapper(@"{""MyFirstProperty"":""first"",""my-second-property"":""second""}"); + var obj = await Serializer.DeserializeWrapper("""{"MyFirstProperty":"first","my-second-property":"second"}"""); Assert.Equal("first", obj.MyFirstProperty); Assert.Equal("second", obj.MySecondProperty); } @@ -622,23 +621,21 @@ public async Task JsonNamingPolicyAttribute_MemberLevel_OverridesTypeLevelAndGlo { var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; string json = await Serializer.SerializeWrapper(new ClassWithMixedNamingPolicies { MyFirstProperty = "first", MySecondProperty = "second" }, options); - Assert.Contains(@"""myFirstProperty"":""first""", json); - Assert.Contains(@"""my-second-property"":""second""", json); + Assert.Equal("""{"myFirstProperty":"first","my-second-property":"second"}""", json); } [Fact] public async Task JsonNamingPolicyAttribute_MemberLevel_OverridesTypeLevel() { string json = await Serializer.SerializeWrapper(new ClassWithMixedNamingPolicies { MyFirstProperty = "first", MySecondProperty = "second" }); - Assert.Contains(@"""myFirstProperty"":""first""", json); - Assert.Contains(@"""my-second-property"":""second""", json); + Assert.Equal("""{"myFirstProperty":"first","my-second-property":"second"}""", json); } [Fact] public async Task JsonNamingPolicyAttribute_JsonPropertyNameTakesPrecedence() { string json = await Serializer.SerializeWrapper(new ClassWithNamingPolicyAndPropertyName { MyValue = "test" }); - Assert.Contains(@"""custom_name"":""test""", json); + Assert.Equal("""{"custom_name":"test"}""", json); } [Theory] diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs index 596fe1a770c729..034ee0c527972d 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs @@ -22,14 +22,14 @@ public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_FallsBackToC // Source gen can't resolve custom derived naming policies at compile time, // so the property name falls back to the original CLR name. string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedNamingPolicyAttribute { MyValue = "test" }); - Assert.Contains(@"""MyValue"":""test""", json); + Assert.Equal("""{"MyValue":"test"}""", json); } [Fact] public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_FallsBackToClrName() { string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedMemberNamingPolicyAttribute { MyValue = "test" }); - Assert.Contains(@"""MyValue"":""test""", json); + Assert.Equal("""{"MyValue":"test"}""", json); } [JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] @@ -69,14 +69,14 @@ public PropertyNameTests_Default() public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_FallsBackToClrName() { string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedNamingPolicyAttribute { MyValue = "test" }); - Assert.Contains(@"""MyValue"":""test""", json); + Assert.Equal("""{"MyValue":"test"}""", json); } [Fact] public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_FallsBackToClrName() { string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedMemberNamingPolicyAttribute { MyValue = "test" }); - Assert.Contains(@"""MyValue"":""test""", json); + Assert.Equal("""{"MyValue":"test"}""", json); } [JsonSerializable(typeof(Dictionary))] diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PropertyNameTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PropertyNameTests.cs index b0f31dc7abfa88..fd598b1bfadb61 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PropertyNameTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.Tests/Serialization/PropertyNameTests.cs @@ -69,13 +69,13 @@ public static IEnumerable JsonSeparatorNamingPolicyInstances() public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_AppliesCustomPolicy() { string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedNamingPolicyAttribute { MyValue = "test" }); - Assert.Contains(@"""MYVALUE"":""test""", json); + Assert.Equal("""{"MYVALUE":"test"}""", json); } [Fact] public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_Deserialize() { - var obj = await Serializer.DeserializeWrapper(@"{""MYVALUE"":""test""}"); + var obj = await Serializer.DeserializeWrapper("""{"MYVALUE":"test"}"""); Assert.Equal("test", obj.MyValue); } @@ -83,13 +83,13 @@ public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_Deserialize( public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_AppliesCustomPolicy() { string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedMemberNamingPolicyAttribute { MyValue = "test" }); - Assert.Contains(@"""MYVALUE"":""test""", json); + Assert.Equal("""{"MYVALUE":"test"}""", json); } [Fact] public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_Deserialize() { - var obj = await Serializer.DeserializeWrapper(@"{""MYVALUE"":""test""}"); + var obj = await Serializer.DeserializeWrapper("""{"MYVALUE":"test"}"""); Assert.Equal("test", obj.MyValue); } @@ -98,7 +98,7 @@ public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_OverridesGlo { var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedNamingPolicyAttribute { MyValue = "test" }, options); - Assert.Contains(@"""MYVALUE"":""test""", json); + Assert.Equal("""{"MYVALUE":"test"}""", json); } } } From d2ab2e774c7ec720f97f2e326e24d3e5020eef22 Mon Sep 17 00:00:00 2001 From: Eirik Tsarpalis Date: Wed, 18 Mar 2026 18:10:13 +0200 Subject: [PATCH 7/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Json/Serialization/Attributes/JsonNamingPolicyAttribute.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonNamingPolicyAttribute.cs b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonNamingPolicyAttribute.cs index b2d4c68602910c..da0b2e246876c4 100644 --- a/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonNamingPolicyAttribute.cs +++ b/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/Attributes/JsonNamingPolicyAttribute.cs @@ -4,7 +4,7 @@ namespace System.Text.Json.Serialization { /// - /// When placed on a type, property, or field, indicates what + /// When placed on a type, property, or field, indicates what /// should be used to convert property names. /// /// From f5934fd7b74854954e357a6932420eed3cfae9fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:45:25 +0000 Subject: [PATCH 8/8] Add source gen tests verifying custom derived attribute prevents global PropertyNamingPolicy from applying Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- .../Serialization/PropertyNameTests.cs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs index 034ee0c527972d..9e5788c86250a9 100644 --- a/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs +++ b/src/libraries/System.Text.Json/tests/System.Text.Json.SourceGeneration.Tests/Serialization/PropertyNameTests.cs @@ -32,6 +32,24 @@ public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_FallsBackT Assert.Equal("""{"MyValue":"test"}""", json); } + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_PreventsGlobalPolicyFromApplying() + { + // Even when a global naming policy is configured, the custom derived attribute + // should prevent it from applying — the CLR name should be used instead. + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedNamingPolicyAttribute { MyValue = "test" }, options); + Assert.Equal("""{"MyValue":"test"}""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_PreventsGlobalPolicyFromApplying() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedMemberNamingPolicyAttribute { MyValue = "test" }, options); + Assert.Equal("""{"MyValue":"test"}""", json); + } + [JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] @@ -79,6 +97,22 @@ public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_FallsBackT Assert.Equal("""{"MyValue":"test"}""", json); } + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_PreventsGlobalPolicyFromApplying() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedNamingPolicyAttribute { MyValue = "test" }, options); + Assert.Equal("""{"MyValue":"test"}""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_PreventsGlobalPolicyFromApplying() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedMemberNamingPolicyAttribute { MyValue = "test" }, options); + Assert.Equal("""{"MyValue":"test"}""", json); + } + [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(int))]