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 Try methods to JsonObject #111229

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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/ref/System.Text.Json.cs
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,8 @@ public sealed partial class JsonObject : System.Text.Json.Nodes.JsonNode, System
System.Collections.Generic.KeyValuePair<string, System.Text.Json.Nodes.JsonNode?> System.Collections.Generic.IList<System.Collections.Generic.KeyValuePair<string, System.Text.Json.Nodes.JsonNode?>>.this[int index] { get { throw null; } set { } }
public void Add(System.Collections.Generic.KeyValuePair<string, System.Text.Json.Nodes.JsonNode?> property) { }
public void Add(string propertyName, System.Text.Json.Nodes.JsonNode? value) { }
public bool TryAdd(string propertyName, JsonNode? value) { throw null; }
public bool TryAdd(string propertyName, JsonNode? value, out int index) { throw null; }
public void Clear() { }
public bool ContainsKey(string propertyName) { throw null; }
public static System.Text.Json.Nodes.JsonObject? Create(System.Text.Json.JsonElement element, System.Text.Json.Nodes.JsonNodeOptions? options = default(System.Text.Json.Nodes.JsonNodeOptions?)) { throw null; }
Expand All @@ -862,6 +864,7 @@ public void SetAt(int index, System.Text.Json.Nodes.JsonNode? value) { }
void System.Collections.Generic.IList<System.Collections.Generic.KeyValuePair<string, System.Text.Json.Nodes.JsonNode?>>.RemoveAt(int index) { }
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() { throw null; }
public bool TryGetPropertyValue(string propertyName, out System.Text.Json.Nodes.JsonNode? jsonNode) { throw null; }
public bool TryGetPropertyValue(string propertyName, out System.Text.Json.Nodes.JsonNode? jsonNode, out int index) { throw null; }
public override void WriteTo(System.Text.Json.Utf8JsonWriter writer, System.Text.Json.JsonSerializerOptions? options = null) { }
}
public abstract partial class JsonValue : System.Text.Json.Nodes.JsonNode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,47 @@ public void Add(string propertyName, JsonNode? value)
value?.AssignParent(this);
}

/// <summary>
/// Adds an element with the provided name and value to the <see cref="JsonObject"/>, if a property named <paramref name="propertyName"/> doesn't already exist.
/// </summary>
/// <param name="propertyName">The property name of the element to add.</param>
/// <param name="value">The value of the element to add.</param>
/// <exception cref="ArgumentNullException"><paramref name="propertyName"/> is null.</exception>
/// <returns>
/// <see langword="true"/> if the property didn't exist and the element was added; otherwise, <see langword="false"/>.
/// </returns>
public bool TryAdd(string propertyName, JsonNode? value) => TryAdd(propertyName, value, out _);

/// <summary>
/// Adds an element with the provided name and value to the <see cref="JsonObject"/>, if a property named <paramref name="propertyName"/> doesn't already exist.
/// </summary>
/// <param name="propertyName">The property name of the element to add.</param>
/// <param name="value">The value of the element to add.</param>
/// <param name="index">The index of the added or existing <paramref name="propertyName"/>. This is always a valid index into the <see cref="JsonObject"/>.</param>
/// <exception cref="ArgumentNullException"><paramref name="propertyName"/> is null.</exception>
/// <returns>
/// <see langword="true"/> if the property didn't exist and the element was added; otherwise, <see langword="false"/>.
/// </returns>
public bool TryAdd(string propertyName, JsonNode? value, out int index)
{
if (propertyName is null)
{
ThrowHelper.ThrowArgumentNullException(nameof(propertyName));
}

#if NET10_0_OR_GREATER
Copy link
Member

Choose a reason for hiding this comment

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

We only need this for .NET 9. Earlier targets polyfill the current OrderedDictionary implementation.

Suggested change
#if NET10_0_OR_GREATER
#if NET9_0

Copy link
Author

Choose a reason for hiding this comment

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

I assume it becomes this then?

#if NET9_0
            var success = Dictionary.TryAdd(propertyName, value);
            index = Dictionary.IndexOf(propertyName);
#else
            var success = Dictionary.TryAdd(propertyName, value, out index);
#endif

Copy link
Member

Choose a reason for hiding this comment

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

Right.

var success = Dictionary.TryAdd(propertyName, value, out index);
Copy link
Member

Choose a reason for hiding this comment

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

This is probably failing to build in .NET 9 since that version of OrderedDictionary doesn't have the overload that exposes the index. I would recommend emulating the behavior using a second lookup via IndexOf in .NET 9 targets.

#else
var success = Dictionary.TryAdd(propertyName, value);
index = Dictionary.IndexOf(propertyName);
Comment on lines +68 to +69

Choose a reason for hiding this comment

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

Suggested change
var success = Dictionary.TryAdd(propertyName, value);
index = Dictionary.IndexOf(propertyName);
var success = Dictionary.TryAdd(propertyName, value);
index = success ? Dictionary.Count : Dictionary.IndexOf(propertyName);

Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't this be Count - 1? Also, this assumes that OD appends new entries at the end, but what if that changes in the future?

Copy link
Author

Choose a reason for hiding this comment

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

Yes, it should be Count - 1.
Even though I don't think it's likely for that to change (it's by definition ordered the way elements are added), the fix to not using Count - 1 is to just leave the code like it is now, right?

Choose a reason for hiding this comment

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

Yes that should've been Count - 1.

Also, this assumes that OD appends new entries at the end, but what if that changes in the future?

Hmm, independent of however it's currently implemented, what invariant is Ordered supposed to guarantee?

An alternative is to do IndexOf first and if it doesn't exist then do d.Insert(d.Count, key, value) (note: not Count - 1) which would add at the end.

Not optimizing this case is also fine.

Copy link
Member

Choose a reason for hiding this comment

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

To be clear, I don't think that it would ever change; I'm just pointing out that it's depending on an invariant from a separate component.

what invariant is Ordered supposed to guarantee?

It's a dictionary that also implements a deterministic IList with $\mathcal O(1)$ access.

Copy link
Author

Choose a reason for hiding this comment

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

So what is the consensus here? Do I leave it as it is?

Copy link
Member

@eiriktsarpalis eiriktsarpalis Jan 14, 2025

Choose a reason for hiding this comment

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

I'm fine to apply the optimisation provided there is test coverage, e.g. a test that verifies that the returned index is correct in that scenario.

Copy link
Member

Choose a reason for hiding this comment

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

A comment above the line explaining the arrangement might help as well.

#endif
if (success)
{
value?.AssignParent(this);
}
return success;
}

/// <summary>
/// Adds the specified property to the <see cref="JsonObject"/>.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,17 +115,42 @@ internal string GetPropertyName(JsonNode? node)
/// </summary>
/// <param name="propertyName">The name of the property to return.</param>
/// <param name="jsonNode">The JSON value of the property with the specified name.</param>
/// <exception cref="ArgumentNullException">
/// <paramref name="propertyName"/> is <see langword="null"/>.
/// </exception>
/// <returns>
/// <see langword="true"/> if a property with the specified name was found; otherwise, <see langword="false"/>.
/// </returns>
public bool TryGetPropertyValue(string propertyName, out JsonNode? jsonNode)
public bool TryGetPropertyValue(string propertyName, out JsonNode? jsonNode) => TryGetPropertyValue(propertyName, out jsonNode, out _);

/// <summary>
/// Gets the value associated with the specified property name.
/// </summary>
/// <param name="propertyName">The property name of the value to get.</param>
/// <param name="jsonNode">
/// When this method returns, it contains the value associated with the specified property name, if the property name is found;
/// otherwise <see langword="null"/>.
/// </param>
/// <param name="index">The index of <paramref name="propertyName"/> if found; otherwise, -1.</param>
/// <exception cref="ArgumentNullException">
/// <paramref name="propertyName"/> is <see langword="null"/>.
/// </exception>
/// <returns>
/// <see langword="true"/> if the <see cref="JsonObject"/> contains an element with the specified property name; otherwise, <see langword="false"/>.
/// </returns>
public bool TryGetPropertyValue(string propertyName, out JsonNode? jsonNode, out int index)
{
if (propertyName is null)
{
ThrowHelper.ThrowArgumentNullException(nameof(propertyName));
}

#if NET10_0_OR_GREATER
return Dictionary.TryGetValue(propertyName, out jsonNode, out index);
#else
index = Dictionary.IndexOf(propertyName);
return Dictionary.TryGetValue(propertyName, out jsonNode);
Comment on lines +151 to 152

Choose a reason for hiding this comment

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

Similar to above, the TryGetValue can be skipped if IndexOf returns false.

Copy link
Author

Choose a reason for hiding this comment

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

Yes, this one can be improved:

index = Dictionary.IndexOf(propertyName);
if (index == -1)
{
    return Dictionary.TryGetValue(propertyName, out jsonNode);
}

jsonNode = null;
return false;

Something like this for the .NET 9 part

Copy link
Member

Choose a reason for hiding this comment

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

Once you have the index you don't need to perform another key-based lookup:

index = Dictionary.IndexOf(propertyName);
if (index < 0)
{
    jsonNode = null;
    return false;
}

jsonNode = Dictionary.GetAt(index);
return true;

#endif
}

/// <inheritdoc/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1604,6 +1604,69 @@ public static void JsonObject_IsIList()
Assert.Equal(-1, jObject.IndexOf("Two"));
}

[Fact]
public static void TryAdd_NewKey_EmptyJsonObject()
{
JsonObject jObject = new();

Assert.True(jObject.TryAdd("First", "value", out var index));
Assert.Equal(0, index);
}

[Fact]
public static void TryAdd_NewKey()
{
JsonObject jObject = new()
{
["One"] = 1,
["Two"] = "str",
["Three"] = null,
};

Assert.True(jObject.TryAdd("Four", 33, out var index));
Assert.Equal(3, index);
}

[Fact]
public static void TryAdd_ExistingKey()
{
JsonObject jObject = new()
{
["One"] = 1,
["Two"] = "str",
["Three"] = null,
};

Assert.False(jObject.TryAdd("Two", 33, out var index));
Assert.Equal(1, index);
}

[Fact]
public static void TryAdd_ThrowsArgumentNullException()
{
JsonObject jObject = new()
{
["One"] = 1,
["Two"] = "str",
["Three"] = null,
};

Assert.Throws<ArgumentNullException>(() => { jObject.TryAdd(null, 33); });
}

[Fact]
public static void TryGetPropertyValue_ThrowsArgumentNullException()
{
JsonObject jObject = new()
{
["One"] = 1,
["Two"] = "str",
["Three"] = null,
};

Assert.Throws<ArgumentNullException>(() => { jObject.TryGetPropertyValue(null, out var jsonNode, out var index); });
}

[Theory]
[InlineData(10_000)]
[InlineData(50_000)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,60 @@ public static void Parse_TryGetPropertyValue()
Assert.IsType<JsonObject>(node);
}

[Fact]
public static void Parse_TryGetPropertyValueWithIndex()
{
JsonObject jObject = JsonNode.Parse(JsonNodeTests.ExpectedDomJson).AsObject();

JsonNode? node;
int index;

Assert.True(jObject.TryGetPropertyValue("MyString", out node, out index));
Assert.Equal("Hello!", node.GetValue<string>());
Assert.Equal(0, index);

Assert.True(jObject.TryGetPropertyValue("MyNull", out node, out index));
Assert.Null(node);
Assert.Equal(1, index);

Assert.True(jObject.TryGetPropertyValue("MyBoolean", out node, out index));
Assert.False(node.GetValue<bool>());
Assert.Equal(2, index);

Assert.True(jObject.TryGetPropertyValue("MyArray", out node, out index));
Assert.IsType<JsonArray>(node);
Assert.Equal(3, index);

Assert.True(jObject.TryGetPropertyValue("MyInt", out node, out index));
Assert.Equal(43, node.GetValue<int>());
Assert.Equal(4, index);

Assert.True(jObject.TryGetPropertyValue("MyDateTime", out node, out index));
Assert.Equal("2020-07-08T00:00:00", node.GetValue<string>());
Assert.Equal(5, index);

Assert.True(jObject.TryGetPropertyValue("MyGuid", out node, out index));
Assert.Equal("ed957609-cdfe-412f-88c1-02daca1b4f51", node.AsValue().GetValue<Guid>().ToString());
Assert.Equal(6, index);

Assert.True(jObject.TryGetPropertyValue("MyObject", out node, out index));
Assert.IsType<JsonObject>(node);
Assert.Equal(7, index);
}

[Fact]
public static void Parse_TryGetPropertyValueFail()
{
JsonObject jObject = JsonNode.Parse(JsonNodeTests.ExpectedDomJson).AsObject();

JsonNode? node;
int index;

Assert.False(jObject.TryGetPropertyValue("NonExistentKey", out node, out index));
Assert.Null(node);
Assert.Equal(-1, index);
}

[Fact]
public static void Parse_TryGetValue()
{
Expand Down
Loading