Skip to content

Commit

Permalink
Added snake and kebab naming policies to JSON serializer (#69613)
Browse files Browse the repository at this point in the history
* Added snake and kebab naming policies to JSON serializer

* Code styling issues

* Explicit types

* Fixed range slicing issue

* Fixed tests

* Forgotten conversion in tests

* Fixed docs

Co-authored-by: Daniel Stockhammer <daniel@stockhammer.it>

* Used nameof instead of hardcoded names in source generator

* Updated public API

* Fixed kebab case lower policy

* Added tests for long inputs

* Performance improvements

* Made ConvertName sealed

Co-authored-by: Eirik Tsarpalis <eirik.tsarpalis@gmail.com>

* Explicit variable type

* Clear only a dirty part of the buffer

* Fixed exception on slicing more that exists

* Better variable name

* End-to-end serialization tests

Co-authored-by: Daniel Stockhammer <daniel@stockhammer.it>
Co-authored-by: Eirik Tsarpalis <eirik.tsarpalis@gmail.com>
  • Loading branch information
3 people authored Oct 20, 2022
1 parent 95d36a9 commit 24813dc
Show file tree
Hide file tree
Showing 18 changed files with 572 additions and 85 deletions.
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);

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 @@ -1170,9 +1170,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

0 comments on commit 24813dc

Please sign in to comment.