Skip to content

Option to disallow duplicate JSON properties #115856

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

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -170,5 +170,10 @@ public JsonSourceGenerationOptionsAttribute(JsonSerializerDefaults defaults)
/// Specifies the default value of <see cref="JsonSerializerOptions.NewLine"/> when set.
/// </summary>
public string? NewLine { get; set; }

/// <summary>
/// Specifies the default value of <see cref="JsonSerializerOptions.AllowDuplicateProperties"/> when set.
/// </summary>
public bool AllowDuplicateProperties { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1183,6 +1183,9 @@ private static void GetLogicForDefaultSerializerOptionsInit(SourceGenerationOpti
writer.WriteLine('{');
writer.Indentation++;

if (optionsSpec.AllowDuplicateProperties is bool allowDuplicateProperties)
writer.WriteLine($"AllowDuplicateProperties = {FormatBoolLiteral(allowDuplicateProperties)},");

if (optionsSpec.AllowOutOfOrderMetadataProperties is bool allowOutOfOrderMetadataProperties)
writer.WriteLine($"AllowOutOfOrderMetadataProperties = {FormatBoolLiteral(allowOutOfOrderMetadataProperties)},");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(IN
bool? writeIndented = null;
char? indentCharacter = null;
int? indentSize = null;
bool? allowDuplicateProperties = null;

if (attributeData.ConstructorArguments.Length > 0)
{
Expand Down Expand Up @@ -412,6 +413,10 @@ private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(IN
generationMode = (JsonSourceGenerationMode)namedArg.Value.Value!;
break;

case nameof(JsonSourceGenerationOptionsAttribute.AllowDuplicateProperties):
allowDuplicateProperties = (bool)namedArg.Value.Value!;
break;

default:
throw new InvalidOperationException();
}
Expand Down Expand Up @@ -446,6 +451,7 @@ private SourceGenerationOptionsSpec ParseJsonSourceGenerationOptionsAttribute(IN
WriteIndented = writeIndented,
IndentCharacter = indentCharacter,
IndentSize = indentSize,
AllowDuplicateProperties = allowDuplicateProperties,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ public sealed record SourceGenerationOptionsSpec

public required int? IndentSize { get; init; }

public required bool? AllowDuplicateProperties { get; init; }

public JsonKnownNamingPolicy? GetEffectivePropertyNamingPolicy()
=> PropertyNamingPolicy ?? (Defaults is JsonSerializerDefaults.Web ? JsonKnownNamingPolicy.CamelCase : null);
}
Expand Down
3 changes: 3 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 @@ -38,6 +38,7 @@ public void WriteTo(System.Text.Json.Utf8JsonWriter writer) { }
public partial struct JsonDocumentOptions
{
private int _dummyPrimitive;
public bool AllowDuplicateProperties { get { throw null; } set { } }
public bool AllowTrailingCommas { readonly get { throw null; } set { } }
public System.Text.Json.JsonCommentHandling CommentHandling { readonly get { throw null; } set { } }
public int MaxDepth { readonly get { throw null; } set { } }
Expand Down Expand Up @@ -393,6 +394,7 @@ public sealed partial class JsonSerializerOptions
public JsonSerializerOptions() { }
public JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults defaults) { }
public JsonSerializerOptions(System.Text.Json.JsonSerializerOptions options) { }
public bool AllowDuplicateProperties { get { throw null; } set { } }
public bool AllowOutOfOrderMetadataProperties { get { throw null; } set { } }
public bool AllowTrailingCommas { get { throw null; } set { } }
public System.Collections.Generic.IList<System.Text.Json.Serialization.JsonConverter> Converters { get { throw null; } }
Expand Down Expand Up @@ -1139,6 +1141,7 @@ public sealed partial class JsonSourceGenerationOptionsAttribute : System.Text.J
{
public JsonSourceGenerationOptionsAttribute() { }
public JsonSourceGenerationOptionsAttribute(System.Text.Json.JsonSerializerDefaults defaults) { }
public bool AllowDuplicateProperties { get { throw null; } set { } }
public bool AllowOutOfOrderMetadataProperties { get { throw null; } set { } }
public bool AllowTrailingCommas { get { throw null; } set { } }
public System.Type[]? Converters { get { throw null; } set { } }
Expand Down
8 changes: 7 additions & 1 deletion src/libraries/System.Text.Json/src/Resources/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -827,4 +827,10 @@
<data name="CannotMixEncodings" xml:space="preserve">
<value>Mixing UTF encodings in a single multi-segment JSON string is not supported. The previous segment's encoding was '{0}' and the current segment's encoding is '{1}'.</value>
</data>
</root>
<data name="DuplicatePropertiesNotAllowed_JsonPropertyInfo" xml:space="preserve">
<value>Duplicate property '{0}' encountered during deserialization of type '{1}'.</value>
Copy link
Member

Choose a reason for hiding this comment

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

Given that we've settled on a design where we don't just throw on duplicate JSON properties in the strictly syntactic sense (case insensitivity, dictionary key conflicts) I'm starting to wonder if the term "duplicate" is fit for purpose. Is the term "conflicting property" more appropriate perhaps?

Copy link
Member Author

Choose a reason for hiding this comment

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

Leaving as is so it matches the option AllowsDuplicateProperties for discoverability.

</data>
<data name="DuplicatePropertiesNotAllowed_Reader" xml:space="preserve">
<value>Duplicate properties not allowed during deserialization.</value>
</data>
</root>
1 change: 1 addition & 0 deletions src/libraries/System.Text.Json/src/System.Text.Json.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ The System.Text.Json library is built-in as part of the shared framework in .NET
<Compile Include="System\Runtime\InteropServices\JsonMarshal.cs" />
<Compile Include="System\Text\Json\AppContextSwitchHelper.cs" />
<Compile Include="System\Text\Json\BitStack.cs" />
<Compile Include="System\Text\Json\Document\JsonDocument.PropertyNameSet.cs" />
<Compile Include="System\Text\Json\Document\JsonDocument.cs" />
<Compile Include="System\Text\Json\Document\JsonDocument.DbRow.cs" />
<Compile Include="System\Text\Json\Document\JsonDocument.MetadataDb.cs" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
Expand Down Expand Up @@ -329,6 +330,52 @@ private int FindOpenElement(JsonTokenType lookupType)
return -1;
}

// This assumes that the next row will be either a property or end object.
// TODO can be non-allocating with struct enumerator
internal readonly IEnumerable<int> EnumeratePreviousProperties()
{
// TODO we can skip over complex objects by reading their length,
// but for now just do a full scan.

int depth = 0;

for (int i = Length - DbRow.Size; i >= 0; i -= DbRow.Size)
{
DbRow row = MemoryMarshal.Read<DbRow>(_data.AsSpan(i));

// Check if we're entering a nested object/array
if (row.TokenType is JsonTokenType.EndArray or JsonTokenType.EndObject)
{
depth++;
continue;
}

// Skip tokens in nested objects/arrays
if (depth > 0)
{
if (row.TokenType is JsonTokenType.StartArray or JsonTokenType.StartObject)
{
depth--;
}

continue;
}

if (row.TokenType == JsonTokenType.PropertyName)
{
yield return i;
}
else if (row.TokenType == JsonTokenType.StartObject)
{
yield break;
}
}

// We should never reach here.
Debug.Fail("Could not find start of object.");
yield break;
}

internal DbRow Get(int index)
{
AssertValidIndex(index);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public sealed partial class JsonDocument
/// </exception>
public static JsonDocument Parse(ReadOnlyMemory<byte> utf8Json, JsonDocumentOptions options = default)
{
return Parse(utf8Json, options.GetReaderOptions());
return Parse(utf8Json, options.GetReaderOptions(), allowDuplicateProperties: options.AllowDuplicateProperties);
}

/// <summary>
Expand Down Expand Up @@ -80,7 +80,7 @@ public static JsonDocument Parse(ReadOnlySequence<byte> utf8Json, JsonDocumentOp

if (utf8Json.IsSingleSegment)
{
return Parse(utf8Json.First, readerOptions);
return Parse(utf8Json.First, readerOptions, allowDuplicateProperties: options.AllowDuplicateProperties);
}

int length = checked((int)utf8Json.Length);
Expand All @@ -89,7 +89,11 @@ public static JsonDocument Parse(ReadOnlySequence<byte> utf8Json, JsonDocumentOp
try
{
utf8Json.CopyTo(utf8Bytes.AsSpan());
return Parse(utf8Bytes.AsMemory(0, length), readerOptions, utf8Bytes);
return Parse(
utf8Bytes.AsMemory(0, length),
readerOptions,
utf8Bytes,
allowDuplicateProperties: options.AllowDuplicateProperties);
}
catch
{
Expand Down Expand Up @@ -123,7 +127,7 @@ public static JsonDocument Parse(Stream utf8Json, JsonDocumentOptions options =
Debug.Assert(drained.Array != null);
try
{
return Parse(drained.AsMemory(), options.GetReaderOptions(), drained.Array);
return Parse(drained.AsMemory(), options.GetReaderOptions(), drained.Array, allowDuplicateProperties: options.AllowDuplicateProperties);
}
catch
{
Expand All @@ -140,7 +144,8 @@ internal static JsonDocument ParseRented(PooledByteBufferWriter utf8Json, JsonDo
utf8Json.WrittenMemory,
options.GetReaderOptions(),
extraRentedArrayPoolBytes: null,
extraPooledByteBufferWriter: utf8Json);
extraPooledByteBufferWriter: utf8Json,
allowDuplicateProperties: options.AllowDuplicateProperties);
}

internal static JsonDocument ParseValue(Stream utf8Json, JsonDocumentOptions options)
Expand All @@ -157,15 +162,21 @@ internal static JsonDocument ParseValue(Stream utf8Json, JsonDocumentOptions opt
drained.AsSpan().Clear();
ArrayPool<byte>.Shared.Return(drained.Array);

return ParseUnrented(owned.AsMemory(), options.GetReaderOptions());
return ParseUnrented(
owned.AsMemory(),
options.GetReaderOptions(),
allowDuplicateProperties: options.AllowDuplicateProperties);
}

internal static JsonDocument ParseValue(ReadOnlySpan<byte> utf8Json, JsonDocumentOptions options)
{
byte[] owned = new byte[utf8Json.Length];
utf8Json.CopyTo(owned);

return ParseUnrented(owned.AsMemory(), options.GetReaderOptions());
return ParseUnrented(
owned.AsMemory(),
options.GetReaderOptions(),
allowDuplicateProperties: options.AllowDuplicateProperties);
}

internal static JsonDocument ParseValue(string json, JsonDocumentOptions options)
Expand Down Expand Up @@ -209,7 +220,11 @@ private static async Task<JsonDocument> ParseAsyncCore(
Debug.Assert(drained.Array != null);
try
{
return Parse(drained.AsMemory(), options.GetReaderOptions(), drained.Array);
return Parse(
drained.AsMemory(),
options.GetReaderOptions(),
drained.Array,
allowDuplicateProperties: options.AllowDuplicateProperties);
}
catch
{
Expand All @@ -235,7 +250,10 @@ internal static async Task<JsonDocument> ParseAsyncCoreUnrented(
drained.AsSpan().Clear();
ArrayPool<byte>.Shared.Return(drained.Array);

return ParseUnrented(owned.AsMemory(), options.GetReaderOptions());
return ParseUnrented(
owned.AsMemory(),
options.GetReaderOptions(),
allowDuplicateProperties: options.AllowDuplicateProperties);
}

/// <summary>
Expand Down Expand Up @@ -271,7 +289,8 @@ public static JsonDocument Parse([StringSyntax(StringSyntaxAttribute.Json)] Read
return Parse(
utf8Bytes.AsMemory(0, actualByteCount),
options.GetReaderOptions(),
utf8Bytes);
utf8Bytes,
allowDuplicateProperties: options.AllowDuplicateProperties);
}
catch
{
Expand Down Expand Up @@ -304,7 +323,10 @@ internal static JsonDocument ParseValue(ReadOnlyMemory<char> json, JsonDocumentO
ArrayPool<byte>.Shared.Return(utf8Bytes);
}

return ParseUnrented(owned.AsMemory(), options.GetReaderOptions());
return ParseUnrented(
owned.AsMemory(),
options.GetReaderOptions(),
allowDuplicateProperties: options.AllowDuplicateProperties);
}

/// <summary>
Expand Down Expand Up @@ -419,7 +441,8 @@ internal static bool TryParseValue(
ref Utf8JsonReader reader,
[NotNullWhen(true)] out JsonDocument? document,
bool shouldThrow,
bool useArrayPools)
bool useArrayPools,
bool allowDuplicateProperties = true)
{
JsonReaderState state = reader.CurrentState;
CheckSupportedOptions(state.Options, nameof(reader));
Expand Down Expand Up @@ -629,7 +652,7 @@ internal static bool TryParseValue(
valueSpan.CopyTo(rentedSpan);
}

document = Parse(rented.AsMemory(0, length), state.Options, rented);
document = Parse(rented.AsMemory(0, length), state.Options, rented, allowDuplicateProperties: allowDuplicateProperties);
}
catch
{
Expand All @@ -654,7 +677,7 @@ internal static bool TryParseValue(
owned = valueSpan.ToArray();
}

document = ParseUnrented(owned, state.Options, reader.TokenType);
document = ParseUnrented(owned, state.Options, reader.TokenType, allowDuplicateProperties: allowDuplicateProperties);
}

return true;
Expand Down Expand Up @@ -688,15 +711,15 @@ private static JsonDocument Parse(
ReadOnlyMemory<byte> utf8Json,
JsonReaderOptions readerOptions,
byte[]? extraRentedArrayPoolBytes = null,
PooledByteBufferWriter? extraPooledByteBufferWriter = null)
PooledByteBufferWriter? extraPooledByteBufferWriter = null,
bool allowDuplicateProperties = true)
{
ReadOnlySpan<byte> utf8JsonSpan = utf8Json.Span;
var database = MetadataDb.CreateRented(utf8Json.Length, convertToAlloc: false);
var stack = new StackRowStack(JsonDocumentOptions.DefaultMaxDepth * StackRow.Size);
var stack = new StackRowStack(JsonDocumentOptions.DefaultMaxDepth);

try
{
Parse(utf8JsonSpan, readerOptions, ref database, ref stack);
Parse(utf8Json, readerOptions, ref database, ref stack, allowDuplicateProperties);
}
catch
{
Expand All @@ -714,31 +737,31 @@ private static JsonDocument Parse(
private static JsonDocument ParseUnrented(
ReadOnlyMemory<byte> utf8Json,
JsonReaderOptions readerOptions,
JsonTokenType tokenType = JsonTokenType.None)
JsonTokenType tokenType = JsonTokenType.None,
bool allowDuplicateProperties = true)
{
// These tokens should already have been processed.
Debug.Assert(
tokenType != JsonTokenType.Null &&
tokenType != JsonTokenType.False &&
tokenType != JsonTokenType.True);

ReadOnlySpan<byte> utf8JsonSpan = utf8Json.Span;
MetadataDb database;

if (tokenType == JsonTokenType.String || tokenType == JsonTokenType.Number)
{
// For primitive types, we can avoid renting MetadataDb and creating StackRowStack.
database = MetadataDb.CreateLocked(utf8Json.Length);
StackRowStack stack = default;
Parse(utf8JsonSpan, readerOptions, ref database, ref stack);
Parse(utf8Json, readerOptions, ref database, ref stack, allowDuplicateProperties);
}
else
{
database = MetadataDb.CreateRented(utf8Json.Length, convertToAlloc: true);
var stack = new StackRowStack(JsonDocumentOptions.DefaultMaxDepth * StackRow.Size);
var stack = new StackRowStack(JsonDocumentOptions.DefaultMaxDepth);
try
{
Parse(utf8JsonSpan, readerOptions, ref database, ref stack);
Parse(utf8Json, readerOptions, ref database, ref stack, allowDuplicateProperties);
}
finally
{
Expand Down
Loading
Loading