Skip to content

Added snake and kebab naming policies to JSON serializer #69613

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

Merged
merged 18 commits into from
Oct 20, 2022
Merged
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
3 changes: 3 additions & 0 deletions src/libraries/System.Text.Json/Common/JsonConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@ internal static partial class JsonConstants
// Standard format for double and single on non-inbox frameworks.
public const string DoubleFormatString = "G17";
public const string SingleFormatString = "G9";

public const int StackallocByteThreshold = 256;
public const int StackallocCharThreshold = StackallocByteThreshold / 2;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.Text.Json
{
internal sealed class JsonKebabCaseLowerNamingPolicy : JsonSeparatorNamingPolicy
{
public JsonKebabCaseLowerNamingPolicy()
: base(lowercase: true, separator: '-')
{
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.Text.Json
{
internal sealed class JsonKebabCaseUpperNamingPolicy : JsonSeparatorNamingPolicy
{
public JsonKebabCaseUpperNamingPolicy()
: base(lowercase: false, separator: '-')
{
}
}
}
22 changes: 21 additions & 1 deletion src/libraries/System.Text.Json/Common/JsonKnownNamingPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ enum JsonKnownNamingPolicy
/// <summary>
/// Specifies that the built-in <see cref="Json.JsonNamingPolicy.CamelCase"/> be used to convert JSON property names.
/// </summary>
CamelCase = 1
CamelCase = 1,

/// <summary>
/// Specifies that the built-in <see cref="Json.JsonNamingPolicy.SnakeCaseLower"/> be used to convert JSON property names.
/// </summary>
SnakeCaseLower = 2,

/// <summary>
/// Specifies that the built-in <see cref="Json.JsonNamingPolicy.SnakeCaseUpper"/> be used to convert JSON property names.
/// </summary>
SnakeCaseUpper = 3,

/// <summary>
/// Specifies that the built-in <see cref="Json.JsonNamingPolicy.KebabCaseLower"/> be used to convert JSON property names.
/// </summary>
KebabCaseLower = 4,

/// <summary>
/// Specifies that the built-in <see cref="Json.JsonNamingPolicy.KebabCaseUpper"/> be used to convert JSON property names.
/// </summary>
KebabCaseUpper = 5
}
}
20 changes: 20 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,26 @@ protected JsonNamingPolicy() { }
/// </summary>
public static JsonNamingPolicy CamelCase { get; } = new JsonCamelCaseNamingPolicy();

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

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

/// <summary>
/// Returns the naming policy for lower kebab-casing.
/// </summary>
public static JsonNamingPolicy KebabCaseLower { get; } = new JsonKebabCaseLowerNamingPolicy();

/// <summary>
/// Returns the naming policy for upper kebab-casing.
/// </summary>
public static JsonNamingPolicy KebabCaseUpper { get; } = new JsonKebabCaseUpperNamingPolicy();

/// <summary>
/// When overridden in a derived class, converts the specified name according to the policy.
/// </summary>
Expand Down
164 changes: 164 additions & 0 deletions src/libraries/System.Text.Json/Common/JsonSeparatorNamingPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Globalization;

namespace System.Text.Json
{
internal abstract class JsonSeparatorNamingPolicy : JsonNamingPolicy
{
private readonly bool _lowercase;
private readonly char _separator;

internal JsonSeparatorNamingPolicy(bool lowercase, char separator) =>
(_lowercase, _separator) = (lowercase, separator);

public sealed override string ConvertName(string name)
{
// Rented buffer 20% longer that the input.
int rentedBufferLength = (12 * name.Length) / 10;
char[]? rentedBuffer = rentedBufferLength > JsonConstants.StackallocCharThreshold
? ArrayPool<char>.Shared.Rent(rentedBufferLength)
: null;

int resultUsedLength = 0;
Span<char> result = rentedBuffer is null
? stackalloc char[JsonConstants.StackallocCharThreshold]
: rentedBuffer;

void ExpandBuffer(ref Span<char> result)
{
char[] newBuffer = ArrayPool<char>.Shared.Rent(result.Length * 2);

result.CopyTo(newBuffer);

if (rentedBuffer is not null)
{
result.Slice(0, resultUsedLength).Clear();
ArrayPool<char>.Shared.Return(rentedBuffer);
}

rentedBuffer = newBuffer;
result = rentedBuffer;
}

void WriteWord(ReadOnlySpan<char> word, ref Span<char> result)
{
if (word.IsEmpty)
{
return;
}

int written;
while (true)
{
var destinationOffset = resultUsedLength != 0
? resultUsedLength + 1
: resultUsedLength;

if (destinationOffset < result.Length)
{
Span<char> destination = result.Slice(destinationOffset);

written = _lowercase
? word.ToLowerInvariant(destination)
: word.ToUpperInvariant(destination);

if (written > 0)
{
break;
}
}

ExpandBuffer(ref result);
}

if (resultUsedLength != 0)
{
result[resultUsedLength] = _separator;
resultUsedLength += 1;
}

resultUsedLength += written;
}

int first = 0;
ReadOnlySpan<char> chars = name.AsSpan();
CharCategory previousCategory = CharCategory.Boundary;

for (int index = 0; index < chars.Length; index++)
{
char current = chars[index];
UnicodeCategory currentCategoryUnicode = char.GetUnicodeCategory(current);

if (currentCategoryUnicode == UnicodeCategory.SpaceSeparator ||
currentCategoryUnicode >= UnicodeCategory.ConnectorPunctuation &&
currentCategoryUnicode <= UnicodeCategory.OtherPunctuation)
{
WriteWord(chars.Slice(first, index - first), ref result);

previousCategory = CharCategory.Boundary;
first = index + 1;

continue;
}

if (index + 1 < chars.Length)
{
char next = chars[index + 1];
CharCategory currentCategory = currentCategoryUnicode switch
{
UnicodeCategory.LowercaseLetter => CharCategory.Lowercase,
UnicodeCategory.UppercaseLetter => CharCategory.Uppercase,
_ => previousCategory
};

if (currentCategory == CharCategory.Lowercase && char.IsUpper(next) ||
next == '_')
{
WriteWord(chars.Slice(first, index - first + 1), ref result);

previousCategory = CharCategory.Boundary;
first = index + 1;

continue;
}

if (previousCategory == CharCategory.Uppercase &&
currentCategoryUnicode == UnicodeCategory.UppercaseLetter &&
char.IsLower(next))
{
WriteWord(chars.Slice(first, index - first), ref result);
Copy link
Member

Choose a reason for hiding this comment

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

I'm curious - should we be using identical rules to CamelCase except also adding - or _?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, I can do that, but knowing about "compatibility concerns" I expect to hear "no" from other reviewers (:

Copy link
Contributor Author

Choose a reason for hiding this comment

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

And actually, what you asked is how naming policies work in other projects and platforms. They always split the input and then transform each word and concatenate them back.

Copy link
Member

Choose a reason for hiding this comment

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

Please file an issue to consider joining this code with CamelCasing and I think we're ready to merge after that

Copy link
Member

Choose a reason for hiding this comment

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


previousCategory = CharCategory.Boundary;
first = index;

continue;
}

previousCategory = currentCategory;
}
}

WriteWord(chars.Slice(first), ref result);

name = result.Slice(0, resultUsedLength).ToString();

if (rentedBuffer is not null)
{
result.Slice(0, resultUsedLength).Clear();
ArrayPool<char>.Shared.Return(rentedBuffer);
}

return name;
}

private enum CharCategory
{
Boundary,
Lowercase,
Uppercase,
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.Text.Json
{
internal sealed class JsonSnakeCaseLowerNamingPolicy : JsonSeparatorNamingPolicy
{
public JsonSnakeCaseLowerNamingPolicy()
: base(lowercase: true, separator: '_')
{
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.Text.Json
{
internal sealed class JsonSnakeCaseUpperNamingPolicy : JsonSeparatorNamingPolicy
{
public JsonSnakeCaseUpperNamingPolicy()
: base(lowercase: false, separator: '_')
{
}
}
}
14 changes: 12 additions & 2 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1167,9 +1167,19 @@ private string GetLogicForDefaultSerializerOptionsInit()
{
JsonSourceGenerationOptionsAttribute options = _currentContext.GenerationOptions;

string? namingPolicyInit = options.PropertyNamingPolicy == JsonKnownNamingPolicy.CamelCase
string? namingPolicyName = options.PropertyNamingPolicy switch
{
JsonKnownNamingPolicy.CamelCase => nameof(JsonNamingPolicy.CamelCase),
JsonKnownNamingPolicy.SnakeCaseLower => nameof(JsonNamingPolicy.SnakeCaseLower),
JsonKnownNamingPolicy.SnakeCaseUpper => nameof(JsonNamingPolicy.SnakeCaseUpper),
JsonKnownNamingPolicy.KebabCaseLower => nameof(JsonNamingPolicy.KebabCaseLower),
JsonKnownNamingPolicy.KebabCaseUpper => nameof(JsonNamingPolicy.KebabCaseUpper),
_ => null,
};

string? namingPolicyInit = namingPolicyName != null
? $@"
PropertyNamingPolicy = {JsonNamingPolicyTypeRef}.CamelCase"
PropertyNamingPolicy = {JsonNamingPolicyTypeRef}.{namingPolicyName}"
: null;

return $@"
Expand Down
16 changes: 11 additions & 5 deletions src/libraries/System.Text.Json/gen/JsonSourceGenerator.Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1514,13 +1514,19 @@ private static string DetermineRuntimePropName(string clrPropName, string? jsonP
{
runtimePropName = jsonPropName;
}
else if (namingPolicy == JsonKnownNamingPolicy.CamelCase)
{
runtimePropName = JsonNamingPolicy.CamelCase.ConvertName(clrPropName);
}
else
{
runtimePropName = clrPropName;
JsonNamingPolicy? instance = namingPolicy switch
{
JsonKnownNamingPolicy.CamelCase => JsonNamingPolicy.CamelCase,
JsonKnownNamingPolicy.SnakeCaseLower => JsonNamingPolicy.SnakeCaseLower,
JsonKnownNamingPolicy.SnakeCaseUpper => JsonNamingPolicy.SnakeCaseUpper,
JsonKnownNamingPolicy.KebabCaseLower => JsonNamingPolicy.KebabCaseLower,
JsonKnownNamingPolicy.KebabCaseUpper => JsonNamingPolicy.KebabCaseUpper,
_ => null,
};

runtimePropName = instance?.ConvertName(clrPropName) ?? clrPropName;
}

return runtimePropName;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,14 @@
<Compile Include="..\Common\JsonConstants.cs" Link="Common\System\Text\Json\JsonConstants.cs" />
<Compile Include="..\Common\JsonHelpers.cs" Link="Common\System\Text\Json\JsonHelpers.cs" />
<Compile Include="..\Common\JsonIgnoreCondition.cs" Link="Common\System\Text\Json\Serialization\JsonIgnoreCondition.cs" />
<Compile Include="..\Common\JsonKebabCaseLowerNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonKebabCaseLowerNamingPolicy.cs" />
<Compile Include="..\Common\JsonKebabCaseUpperNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonKebabCaseUpperNamingPolicy.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\JsonSeparatorNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonSeparatorNamingPolicy.cs" />
<Compile Include="..\Common\JsonSerializableAttribute.cs" Link="Common\System\Text\Json\Serialization\JsonSerializableAttribute.cs" />
<Compile Include="..\Common\JsonSnakeCaseLowerNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonSnakeCaseLowerNamingPolicy.cs" />
<Compile Include="..\Common\JsonSnakeCaseUpperNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonSnakeCaseUpperNamingPolicy.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="..\Common\ReflectionExtensions.cs" Link="Common\System\Text\Json\Serialization\ReflectionExtensions.cs" />
Expand Down
8 changes: 8 additions & 0 deletions src/libraries/System.Text.Json/ref/System.Text.Json.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ public abstract partial class JsonNamingPolicy
{
protected JsonNamingPolicy() { }
public static System.Text.Json.JsonNamingPolicy CamelCase { get { throw null; } }
public static System.Text.Json.JsonNamingPolicy SnakeCaseLower { get { throw null; } }
public static System.Text.Json.JsonNamingPolicy SnakeCaseUpper { get { throw null; } }
public static System.Text.Json.JsonNamingPolicy KebabCaseLower { get { throw null; } }
public static System.Text.Json.JsonNamingPolicy KebabCaseUpper { get { throw null; } }
public abstract string ConvertName(string name);
}
public readonly partial struct JsonProperty
Expand Down Expand Up @@ -914,6 +918,10 @@ public enum JsonKnownNamingPolicy
{
Unspecified = 0,
CamelCase = 1,
SnakeCaseLower = 2,
SnakeCaseUpper = 3,
KebabCaseLower = 4,
KebabCaseUpper = 5,
}
[System.FlagsAttribute]
public enum JsonNumberHandling
Expand Down
5 changes: 5 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 @@ -30,9 +30,14 @@ The System.Text.Json library is built-in as part of the shared framework in .NET
<Compile Include="..\Common\JsonConstants.cs" Link="Common\System\Text\Json\JsonConstants.cs" />
<Compile Include="..\Common\JsonHelpers.cs" Link="Common\System\Text\Json\JsonHelpers.cs" />
<Compile Include="..\Common\JsonIgnoreCondition.cs" Link="Common\System\Text\Json\Serialization\JsonIgnoreCondition.cs" />
<Compile Include="..\Common\JsonKebabCaseLowerNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonKebabCaseLowerNamingPolicy.cs" />
<Compile Include="..\Common\JsonKebabCaseUpperNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonKebabCaseUpperNamingPolicy.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\JsonSeparatorNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonSeparatorNamingPolicy.cs" />
<Compile Include="..\Common\JsonSerializableAttribute.cs" Link="Common\System\Text\Json\Serialization\JsonSerializableAttribute.cs" />
<Compile Include="..\Common\JsonSnakeCaseLowerNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonSnakeCaseLowerNamingPolicy.cs" />
<Compile Include="..\Common\JsonSnakeCaseUpperNamingPolicy.cs" Link="Common\System\Text\Json\Serialization\JsonSnakeCaseUpperNamingPolicy.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="..\Common\ReflectionExtensions.cs" Link="Common\System\Text\Json\Serialization\ReflectionExtensions.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,6 @@ internal static partial class JsonConstants
public const int SpacesPerIndent = 2;
public const int RemoveFlagsBitMask = 0x7FFFFFFF;

public const int StackallocByteThreshold = 256;
public const int StackallocCharThreshold = StackallocByteThreshold / 2;

// In the worst case, an ASCII character represented as a single utf-8 byte could expand 6x when escaped.
// For example: '+' becomes '\u0043'
// Escaping surrogate pairs (represented by 3 or 4 utf-8 bytes) would expand to 12 bytes (which is still <= 6x).
Expand Down
Loading