Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JSON snake_case naming policy #54128

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/libraries/System.Text.Json/Common/JsonNamingPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ protected JsonNamingPolicy() { }
/// </summary>
public static JsonNamingPolicy CamelCase { get; } = new JsonCamelCaseNamingPolicy();

/// <summary>
/// Returns the naming policy for snake-casing.
/// </summary>
public static JsonNamingPolicy SnakeCase { get; } = new JsonSnakeCaseNamingPolicy();

/// <summary>
/// When overridden in a derived class, converts the specified name according to the policy.
/// </summary>
Expand Down
118 changes: 118 additions & 0 deletions src/libraries/System.Text.Json/Common/JsonSnakeCaseNamingPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// 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
{
internal sealed class JsonSnakeCaseNamingPolicy : JsonNamingPolicy
{
public override string ConvertName(string name)
{
if (string.IsNullOrEmpty(name))
{
return name;
}

if (name.Length == 1)
{
return name.ToLowerInvariant();
}

var result = new ValueStringBuilder(2 * name.Length);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure 2 * length is a reasonable heuristic here, but I wasn't sure how much bigger the "average" property name would be, so I felt just doubling was safer.

Copy link

@AmrAlSayed0 AmrAlSayed0 Jun 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ABC would turn into a_b_c so the absolute maximum would be (2 * name.Length) - 1

Copy link
Author

@FiniteReality FiniteReality Jun 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ABC should turn into abc since the entire string is all the same case; it should be almost the case as ID

Copy link

@AmrAlSayed0 AmrAlSayed0 Jun 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so AbCdEf would be ab_cd_ef which gives us name.Length + (int)Math.Ceiling(name.Length/(double)2) - 1

Edit: Actually it is more complicated than that. There are also single char numbers. I would still say that (2 * name.Length) - 1 is the absolute maximum in all cases.


bool wroteUnderscorePreviously = false;
for (int x = 0; x < name.Length; x++)
{
var current = name[x];

if (x > 0 && x < name.Length - 1 && char.IsLetter(current))
{
// text somewhere in the middle of the string
var previous = name[x - 1];
var next = name[x + 1];

if (char.IsLetter(previous) && char.IsLetter(next))
{
// in the middle of a bit of text
var previousUpper = char.IsUpper(previous);
var currentUpper = char.IsUpper(current);
var nextUpper = char.IsUpper(next);

switch ((previousUpper, currentUpper, nextUpper))
{
case (false, false, false): // aaa
case ( true, true, true): // AAA
case ( true, false, false): // Aaa
{
// same word
result.Append(char.ToLowerInvariant(current));
wroteUnderscorePreviously = false;
break;
}

case (false, false, true): // aaA
case ( true, false, true): // AaA
{
// end of word
result.Append(char.ToLowerInvariant(current));
result.Append('_');
wroteUnderscorePreviously = true;
break;
}

case (false, true, true): // aAA
case ( true, true, false): // AAa
case (false, true, false): // aAa
{
// beginning of word
if (!wroteUnderscorePreviously)
{
result.Append('_');
}
result.Append(char.ToLowerInvariant(current));
wroteUnderscorePreviously = false;
break;
}
}
}
else
{
// beginning or end of text
result.Append(char.ToLowerInvariant(current));
wroteUnderscorePreviously = false;
}
}
else if (char.IsLetter(current))
{
// text at the beginning or the end of the string
result.Append(char.ToLowerInvariant(current));
wroteUnderscorePreviously = false;
}
else if (char.IsNumber(current))
{
// a number at any point in the string
if (x > 0 && !wroteUnderscorePreviously)
{
result.Append('_');
}

result.Append(current);
wroteUnderscorePreviously = false;

if (x < name.Length - 1)
Copy link
Author

@FiniteReality FiniteReality Jun 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about it more, this condition will likely accidentally trigger if two or more numbers are hit in sequence. In this scenario, what would a user expect the name to be converted to? I know abc_1__2_def is most likely incorrect, but should it be abc_12_def, or abc_1_2_def?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now I've just left this as abc_1_2_def, and have added a relevant test case.

{
result.Append('_');
wroteUnderscorePreviously = true;
}
}
else if (!wroteUnderscorePreviously)
{
// any punctuation at any point in the string
result.Append('_');
wroteUnderscorePreviously = true;
}
}

return result.ToString();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<TargetFrameworks>netstandard2.0</TargetFrameworks>
<CLSCompliant>false</CLSCompliant>
<Nullable>enable</Nullable>
Expand All @@ -22,13 +23,15 @@

<ItemGroup>
<Compile Include="$(CommonPath)System\Runtime\CompilerServices\IsExternalInit.cs" Link="Common\System\Runtime\CompilerServices\IsExternalInit.cs" />
<Compile Include="$(CommonPath)System\Text\ValueStringBuilder.cs" Link="Common\System\Text\ValueStringBuilder.cs" />
<Compile Include="..\Common\JsonCamelCaseNamingPolicy.cs" Link="Common\System\Text\Json\JsonCamelCaseNamingPolicy.cs" />
<Compile Include="..\Common\JsonNamingPolicy.cs" Link="Common\System\Text\Json\JsonNamingPolicy.cs" />
<Compile Include="..\Common\JsonAttribute.cs" Link="Common\System\Text\Json\Serialization\JsonAttribute.cs" />
<Compile Include="..\Common\JsonIgnoreCondition.cs" Link="Common\System\Text\Json\Serialization\JsonIgnoreCondition.cs" />
<Compile Include="..\Common\JsonKnownNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonKnownNamingPolicy.cs" />
<Compile Include="..\Common\JsonNumberHandling.cs" Link="Common\System\Text\Json\Serialization\JsonNumberHandling.cs" />
<Compile Include="..\Common\JsonSerializableAttribute.cs" Link="Common\System\Text\Json\Serialization\JsonSerializableAttribute.cs" />
<Compile Include="..\Common\JsonSnakeCaseNamingPolicy.cs" Link="Common\System\Text\Json\JsonSnakeCaseNamingPolicy.cs" />
<Compile Include="..\Common\JsonSourceGenerationMode.cs" Link="Common\System\Text\Json\Serialization\JsonSourceGenerationMode.cs" />
<Compile Include="..\Common\JsonSourceGenerationOptionsAttribute.cs" Link="Common\System\Text\Json\Serialization\JsonSourceGenerationOptionsAttribute.cs" />
<Compile Include="ClassType.cs" />
Expand Down
1 change: 1 addition & 0 deletions src/libraries/System.Text.Json/ref/System.Text.Json.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ public abstract partial class JsonNamingPolicy
{
protected JsonNamingPolicy() { }
public static System.Text.Json.JsonNamingPolicy CamelCase { get { throw null; } }
public static System.Text.Json.JsonNamingPolicy SnakeCase { get { throw null; } }
public abstract string ConvertName(string name);
}
public readonly partial struct JsonProperty
Expand Down
2 changes: 2 additions & 0 deletions src/libraries/System.Text.Json/src/System.Text.Json.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@
<ItemGroup>
<Compile Include="$(CommonPath)System\HexConverter.cs" Link="Common\System\HexConverter.cs" />
<Compile Include="$(CommonPath)System\Text\Json\PooledByteBufferWriter.cs" Link="Common\System\Text\Json\PooledByteBufferWriter.cs" />
<Compile Include="$(CommonPath)System\Text\ValueStringBuilder.cs" Link="Common\System\Text\ValueStringBuilder.cs" />
<Compile Include="..\Common\JsonCamelCaseNamingPolicy.cs" Link="Common\System\Text\Json\JsonCamelCaseNamingPolicy.cs" />
<Compile Include="..\Common\JsonNamingPolicy.cs" Link="Common\System\Text\Json\JsonNamingPolicy.cs" />
<Compile Include="..\Common\JsonAttribute.cs" Link="Common\System\Text\Json\Serialization\JsonAttribute.cs" />
<Compile Include="..\Common\JsonIgnoreCondition.cs" Link="Common\System\Text\Json\Serialization\JsonIgnoreCondition.cs" />
<Compile Include="..\Common\JsonKnownNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonKnownNamingPolicy.cs" />
<Compile Include="..\Common\JsonNumberHandling.cs" Link="Common\System\Text\Json\Serialization\JsonNumberHandling.cs" />
<Compile Include="..\Common\JsonSerializableAttribute.cs" Link="Common\System\Text\Json\Serialization\JsonSerializableAttribute.cs" />
<Compile Include="..\Common\JsonSnakeCaseNamingPolicy.cs" Link="Common\System\Text\Json\JsonSnakeCaseNamingPolicy.cs" />
<Compile Include="..\Common\JsonSourceGenerationMode.cs" Link="Common\System\Text\Json\Serialization\JsonSourceGenerationMode.cs" />
<Compile Include="..\Common\JsonSourceGenerationOptionsAttribute.cs" Link="Common\System\Text\Json\Serialization\JsonSourceGenerationOptionsAttribute.cs" />
<Compile Include="System\Text\Json\BitStack.cs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Xunit;

namespace System.Text.Json.Serialization.Tests
{
public static class SnakeCaseUnitTests
{
[Fact]
public static void ToSnakeCaseTest()
{
// These test cases were copied from Json.NET.
Assert.Equal("url_value", ConvertToSnakeCase("URLValue"));
Assert.Equal("url", ConvertToSnakeCase("URL"));
Assert.Equal("id", ConvertToSnakeCase("ID"));
Assert.Equal("i", ConvertToSnakeCase("I"));
Assert.Equal("", ConvertToSnakeCase(""));
Assert.Null(ConvertToSnakeCase(null));
Assert.Equal("person", ConvertToSnakeCase("Person"));
Assert.Equal("i_phone", ConvertToSnakeCase("iPhone"));
Assert.Equal("i_phone", ConvertToSnakeCase("IPhone"));
Assert.Equal("i_phone", ConvertToSnakeCase("I Phone"));
Assert.Equal("i_phone", ConvertToSnakeCase("I Phone"));
Assert.Equal("_i_phone", ConvertToSnakeCase(" IPhone"));
Assert.Equal("_i_phone_", ConvertToSnakeCase(" IPhone "));
Assert.Equal("is_cia", ConvertToSnakeCase("IsCIA"));
Assert.Equal("vm_q", ConvertToSnakeCase("VmQ"));
Assert.Equal("xml_2_json", ConvertToSnakeCase("Xml2Json"));
Assert.Equal("xml_2_net_6_0_json", ConvertToSnakeCase("Xml2Net60Json"));
Assert.Equal("sn_ak_ec_as_e", ConvertToSnakeCase("SnAkEcAsE"));
Assert.Equal("sn_a_k_ec_as_e", ConvertToSnakeCase("SnA__kEcAsE"));
Assert.Equal("sn_a_k_ec_as_e", ConvertToSnakeCase("SnA__ kEcAsE"));
Assert.Equal("already_snake_case_", ConvertToSnakeCase("already_snake_case_ "));
Assert.Equal("is_json_property", ConvertToSnakeCase("IsJSONProperty"));
Assert.Equal("shouting_case", ConvertToSnakeCase("SHOUTING_CASE"));
Assert.Equal("hi_this_is_text_time_to_test_", ConvertToSnakeCase("Hi!! This is text. Time to test."));
Assert.Equal("building", ConvertToSnakeCase("BUILDING"));
Assert.Equal("building_property", ConvertToSnakeCase("BUILDING Property"));
Assert.Equal("building_property", ConvertToSnakeCase("Building Property"));
Assert.Equal("building_property", ConvertToSnakeCase("BUILDING PROPERTY"));
}

// Use a helper method since the method is not public.
private static string ConvertToSnakeCase(string name)
{
JsonNamingPolicy policy = JsonNamingPolicy.SnakeCase;
string value = policy.ConvertName(name);
return value;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
<Compile Include="Serialization\ReferenceHandlerTests.IgnoreCycles.cs" />
<Compile Include="Serialization\ReferenceHandlerTests.Serialize.cs" />
<Compile Include="Serialization\SampleTestData.OrderPayload.cs" />
<Compile Include="Serialization\SnakeCaseUnitTests.cs" />
<Compile Include="Serialization\SpanTests.cs" />
<Compile Include="Serialization\StreamTests.cs" />
<Compile Include="Serialization\Stream.Collections.cs" />
Expand Down