diff --git a/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs b/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs index a59e673e557889..e8935677ba99bf 100644 --- a/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs +++ b/src/libraries/System.Text.Json/gen/Helpers/KnownTypeSymbols.cs @@ -231,6 +231,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 147419c2d5c62f..e70fd9f01ef341 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 JsonIgnoreCondition? typeIgnoreCondition, out bool foundJsonConverterAttribute, out TypeRef? customConverterType, @@ -692,7 +693,7 @@ private TypeGenerationSpec ParseTypeGenerationSpec(in TypeToGenerate typeToGener implementsIJsonOnSerialized = _knownSymbols.IJsonOnSerializedType.IsAssignableFrom(type); ctorParamSpecs = ParseConstructorParameters(typeToGenerate, constructor, out constructionStrategy, out constructorSetsRequiredMembers); - propertySpecs = ParsePropertyGenerationSpecs(contextType, typeToGenerate, typeIgnoreCondition, options, out hasExtensionDataProperty, out fastPathPropertyIndices); + propertySpecs = ParsePropertyGenerationSpecs(contextType, typeToGenerate, typeIgnoreCondition, options, typeNamingPolicy, out hasExtensionDataProperty, out fastPathPropertyIndices); propertyInitializerSpecs = ParsePropertyInitializers(ctorParamSpecs, propertySpecs, constructorSetsRequiredMembers, ref constructionStrategy); } @@ -749,6 +750,7 @@ private void ProcessTypeCustomAttributes( out JsonNumberHandling? numberHandling, out JsonUnmappedMemberHandling? unmappedMemberHandling, out JsonObjectCreationHandling? objectCreationHandling, + out JsonKnownNamingPolicy? namingPolicy, out JsonIgnoreCondition? typeIgnoreCondition, out bool foundJsonConverterAttribute, out TypeRef? customConverterType, @@ -757,6 +759,7 @@ private void ProcessTypeCustomAttributes( numberHandling = null; unmappedMemberHandling = null; objectCreationHandling = null; + namingPolicy = null; typeIgnoreCondition = null; customConverterType = null; foundJsonConverterAttribute = false; @@ -781,6 +784,22 @@ 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; + } + 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; + } else if (!foundJsonConverterAttribute && _knownSymbols.JsonConverterAttributeType.IsAssignableFrom(attributeType)) { customConverterType = GetConverterTypeFromJsonConverterAttribute(contextType, typeToGenerate.Type, attributeData); @@ -1004,6 +1023,7 @@ private List ParsePropertyGenerationSpecs( in TypeToGenerate typeToGenerate, JsonIgnoreCondition? typeIgnoreCondition, SourceGenerationOptionsSpec? options, + JsonKnownNamingPolicy? typeNamingPolicy, out bool hasExtensionDataProperty, out List? fastPathPropertyIndices) { @@ -1088,7 +1108,8 @@ void AddMember( typeIgnoreCondition, ref hasExtensionDataProperty, generationMode, - options); + options, + typeNamingPolicy); if (propertySpec is null) { @@ -1237,7 +1258,8 @@ private bool IsValidDataExtensionPropertyType(ITypeSymbol type) JsonIgnoreCondition? typeIgnoreCondition, ref bool typeHasExtensionDataProperty, JsonSourceGenerationMode? generationMode, - SourceGenerationOptionsSpec? options) + SourceGenerationOptionsSpec? options, + JsonKnownNamingPolicy? typeNamingPolicy) { Debug.Assert(memberInfo is IFieldSymbol or IPropertySymbol); @@ -1250,6 +1272,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, @@ -1319,9 +1342,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) @@ -1334,7 +1366,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, @@ -1366,6 +1398,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, @@ -1378,6 +1411,7 @@ private void ProcessMemberCustomAttributes( ignoreCondition = default; numberHandling = default; objectCreationHandling = default; + memberNamingPolicy = default; converterType = null; order = 0; isExtensionData = false; @@ -1396,6 +1430,20 @@ private void ProcessMemberCustomAttributes( { converterType = GetConverterTypeFromJsonConverterAttribute(contextType, memberInfo, attributeData, memberType); } + 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 + { + // 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) { switch (attributeType.ToDisplayString()) @@ -1843,14 +1891,23 @@ private static int GetTotalTypeParameterCount(INamedTypeSymbol unboundType) return constructedContainingType; } - 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 fbd2ae7ab48ec5..fd5ff9984c9e29 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..da0b2e246876c4 --- /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 ebbd49dcc82158..a7c6f55261539a 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; + // Resolve type-level [JsonIgnore] once per type, rather than per-member. JsonIgnoreCondition? typeIgnoreCondition = typeInfo.Type.GetUniqueCustomAttribute(inherit: false)?.Condition; if (typeIgnoreCondition == JsonIgnoreCondition.Always) @@ -124,6 +127,7 @@ private static void PopulateProperties(JsonTypeInfo typeInfo, NullabilityInfoCon AddMembersDeclaredBySuperType( typeInfo, currentType, + typeNamingPolicy, nullabilityCtx, typeIgnoreCondition, constructorHasSetsRequiredMembersAttribute, @@ -148,6 +152,7 @@ private static void PopulateProperties(JsonTypeInfo typeInfo, NullabilityInfoCon private static void AddMembersDeclaredBySuperType( JsonTypeInfo typeInfo, Type currentType, + JsonNamingPolicy? typeNamingPolicy, NullabilityInfoContext nullabilityCtx, JsonIgnoreCondition? typeIgnoreCondition, bool constructorHasSetsRequiredMembersAttribute, @@ -180,6 +185,7 @@ private static void AddMembersDeclaredBySuperType( typeInfo, typeToConvert: propertyInfo.PropertyType, memberInfo: propertyInfo, + typeNamingPolicy, nullabilityCtx, typeIgnoreCondition, shouldCheckMembersForRequiredMemberAttribute, @@ -197,6 +203,7 @@ private static void AddMembersDeclaredBySuperType( typeInfo, typeToConvert: fieldInfo.FieldType, memberInfo: fieldInfo, + typeNamingPolicy, nullabilityCtx, typeIgnoreCondition, shouldCheckMembersForRequiredMemberAttribute, @@ -212,13 +219,14 @@ private static void AddMember( JsonTypeInfo typeInfo, Type typeToConvert, MemberInfo memberInfo, + JsonNamingPolicy? typeNamingPolicy, NullabilityInfoContext nullabilityCtx, JsonIgnoreCondition? typeIgnoreCondition, bool shouldCheckForRequiredKeyword, bool hasJsonIncludeAttribute, ref JsonTypeInfo.PropertyHierarchyResolutionState state) { - JsonPropertyInfo? jsonPropertyInfo = CreatePropertyInfo(typeInfo, typeToConvert, memberInfo, nullabilityCtx, typeIgnoreCondition, typeInfo.Options, shouldCheckForRequiredKeyword, hasJsonIncludeAttribute); + JsonPropertyInfo? jsonPropertyInfo = CreatePropertyInfo(typeInfo, typeToConvert, memberInfo, typeNamingPolicy, nullabilityCtx, typeIgnoreCondition, typeInfo.Options, shouldCheckForRequiredKeyword, hasJsonIncludeAttribute); if (jsonPropertyInfo == null) { // ignored invalid property @@ -235,6 +243,7 @@ private static void AddMember( JsonTypeInfo typeInfo, Type typeToConvert, MemberInfo memberInfo, + JsonNamingPolicy? typeNamingPolicy, NullabilityInfoContext nullabilityCtx, JsonIgnoreCondition? typeIgnoreCondition, JsonSerializerOptions options, @@ -274,7 +283,7 @@ private static void AddMember( } JsonPropertyInfo jsonPropertyInfo = typeInfo.CreatePropertyUsingReflection(typeToConvert, declaringType: memberInfo.DeclaringType); - PopulatePropertyInfo(jsonPropertyInfo, memberInfo, customConverter, ignoreCondition, nullabilityCtx, shouldCheckForRequiredKeyword, hasJsonIncludeAttribute); + PopulatePropertyInfo(jsonPropertyInfo, memberInfo, customConverter, ignoreCondition, nullabilityCtx, shouldCheckForRequiredKeyword, hasJsonIncludeAttribute, typeNamingPolicy); return jsonPropertyInfo; } @@ -349,7 +358,8 @@ private static void PopulatePropertyInfo( JsonIgnoreCondition? ignoreCondition, NullabilityInfoContext nullabilityCtx, bool shouldCheckForRequiredKeyword, - bool hasJsonIncludeAttribute) + bool hasJsonIncludeAttribute, + JsonNamingPolicy? typeNamingPolicy) { Debug.Assert(jsonPropertyInfo.AttributeProvider == null); @@ -371,7 +381,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); @@ -396,7 +406,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; @@ -404,13 +414,15 @@ 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 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 eff2078db0e8c8..1e59c17fa72740 100644 --- a/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs +++ b/src/libraries/System.Text.Json/tests/Common/PropertyNameTests.cs @@ -612,5 +612,142 @@ 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.Equal("""{"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.Equal("""{"myValue":"test"}""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_MemberLevel_Serialize() + { + string json = await Serializer.SerializeWrapper(new ClassWithMemberNamingPolicyAttribute { MyFirstProperty = "first", MySecondProperty = "second" }); + 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"}"""); + 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.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.Equal("""{"myFirstProperty":"first","my-second-property":"second"}""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_JsonPropertyNameTakesPrecedence() + { + string json = await Serializer.SerializeWrapper(new ClassWithNamingPolicyAndPropertyName { MyValue = "test" }); + Assert.Equal("""{"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)); + } + } + + [JsonNamingPolicyAttribute(JsonKnownNamingPolicy.CamelCase)] + public class ClassWithCamelCaseNamingPolicyAttribute + { + public string MyValue { get; set; } + } + + public class ClassWithMemberNamingPolicyAttribute + { + public string MyFirstProperty { get; set; } + + [JsonNamingPolicyAttribute(JsonKnownNamingPolicy.KebabCaseLower)] + public string MySecondProperty { get; set; } + } + + [JsonNamingPolicyAttribute(JsonKnownNamingPolicy.CamelCase)] + public class ClassWithMixedNamingPolicies + { + public string MyFirstProperty { get; set; } + + [JsonNamingPolicyAttribute(JsonKnownNamingPolicy.KebabCaseLower)] + public string MySecondProperty { get; set; } + } + + public class ClassWithNamingPolicyAndPropertyName + { + [JsonNamingPolicyAttribute(JsonKnownNamingPolicy.CamelCase)] + [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 e512451eed72bc..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 @@ -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,40 @@ 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.Equal("""{"MyValue":"test"}""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_FallsBackToClrName() + { + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedMemberNamingPolicyAttribute { MyValue = "test" }); + 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))] @@ -29,6 +65,12 @@ 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))] + [JsonSerializable(typeof(ClassWithCustomDerivedNamingPolicyAttribute))] + [JsonSerializable(typeof(ClassWithCustomDerivedMemberNamingPolicyAttribute))] internal sealed partial class PropertyNameTestsContext_Metadata : JsonSerializerContext { } @@ -41,6 +83,36 @@ public PropertyNameTests_Default() { } + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_TypeLevel_FallsBackToClrName() + { + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedNamingPolicyAttribute { MyValue = "test" }); + Assert.Equal("""{"MyValue":"test"}""", json); + } + + [Fact] + public async Task JsonNamingPolicyAttribute_CustomDerived_MemberLevel_FallsBackToClrName() + { + string json = await Serializer.SerializeWrapper(new ClassWithCustomDerivedMemberNamingPolicyAttribute { MyValue = "test" }); + 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))] @@ -55,6 +127,12 @@ 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))] + [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 ddf4151467adf4..46afe5346b5fba 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.Equal("""{"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.Equal("""{"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.Equal("""{"MYVALUE":"test"}""", json); + } } }