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 JsonNode feature #51025

Merged
merged 10 commits into from
Apr 14, 2021
Merged

Add JsonNode feature #51025

merged 10 commits into from
Apr 14, 2021

Conversation

steveharter
Copy link
Member

@steveharter steveharter commented Apr 9, 2021

Implementation of writeable JSON DOM including support for C# dynamic.

Fixes #47649

Notes:

  • The ability to have extension properties by of type JsonObject is not implemented in the PR to help reduce review size. That support will come after this PR is in and will allow an extension property of the form:
    [JsonExtensionData] public JsonObject ExtensionData {get; set;}
  • MetaDynamic.cs contains internal code to support C# dynamic. As a follow-up to this PR, additional tests may be added to increase coverage here and\or that code refactored to reduce some lower-level expression-based code.
  • Test coverage is nearly 100% except for MetaDynamic.cs as mentioned above.
  • The internal use of JsonObject's System.Collections.Generic.Dictionary<,> will be replaced with a dictionary that supports ordering semantics so inserts\removes are deterministic with list-based semantics; ordering is nondeterministic with Dictionary<,>.

@dotnet-issue-labeler
Copy link

Note regarding the new-api-needs-documentation label:

This serves as a reminder for when your PR is modifying a ref *.cs file and adding/modifying public APIs, to please make sure the API implementation in the src *.cs file is documented with triple slash comments, so the PR reviewers can sign off that change.

@ghost
Copy link

ghost commented Apr 9, 2021

Tagging subscribers to this area: @eiriktsarpalis, @layomia
See info in area-owners.md if you want to be subscribed.

Issue Details

Implementation of writeable JSON DOM including support for C# dynamic.

Fixes #47649

Notes:

  • The ability to have extension properties by of type JsonObject is not implemented in the PR to help reduce review size. That support will come after this PR is in and will allow an extension property of the form:
    [JsonExtensionData] public JsonObject ExtensionData {get; set;}
  • MetaDynamic.cs contains internal code to support C# dynamic. As a follow-up to this PR, additional tests may be added to increase coverage here and\or that code refactored to reduce some lower-level expression-based code.
  • Test coverage is nearly 100% except for MetaDynamic.cs as mentioned above.
Author: steveharter
Assignees: steveharter
Labels:

area-System.Text.Json

Milestone: 6.0.0

@danmoseley
Copy link
Member

🥳

Copy link

@tdykstra tdykstra left a comment

Choose a reason for hiding this comment

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

LGTM, made some suggestions for what look like copy/paste errors.

src/libraries/System.Text.Json/tests/JsonNode/Common.cs Outdated Show resolved Hide resolved
Assert.Equal("Hello", (string)obj.MyString);

// Verify other string-based types.
// Since this requires a custom converter, an exception is thrown.
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm isn't this why JsonStringEnumConverter was added above on L64? Is it not honored? Also, is the specific RuntimeBinderException known or can it vary?

Copy link
Member Author

Choose a reason for hiding this comment

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

Currently the semantics don't go through the serializer when calling GetValue(), so another step is needed to get the enum. We can revisit this based on feedback, but per discussion decided to start with more restrictive semantics.

Copy link
Contributor

Choose a reason for hiding this comment

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

In the future, would we need more overloads that take the options (to retrieve converters and such), or will the existing API surface suffice?


// Numbers must specify the type through a cast or assignment.
Assert.IsAssignableFrom<JsonValue>(obj.MyInt);
Assert.ThrowsAny<Exception>(() => obj.MyInt == 42L);
Copy link
Contributor

Choose a reason for hiding this comment

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

What exception is thrown?

On a separate note - can I dynamically change the CLR type of a property on a dynamic object? e.g. could I say obj.MyInt = "Hello"?

Copy link
Member Author

Choose a reason for hiding this comment

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

Changed to RuntimeBinderException. We could catch and throw InvalidOperation in this case; I'll look at that for consistency sake with non-dynamic.

Yes you can change the value with obj.MyInt = "Hello" since that uses the indexer to create a new node, and then replace the previous node.

Copy link
Contributor

Choose a reason for hiding this comment

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

+1 to consistency with non-dynamic.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'll address this after the PR. To support this will require changes to the lower-level dynamic interop in MetaDynamic.cs and adding more extensive testing for failure cases.

public static void SetItem_Fail()
{
var jObject = new JsonObject();
Assert.Throws<ArgumentNullException>(() => jObject[null] = 42);
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this test any different from NullPropertyNameFail above?

Comment on lines 131 to 132
JsonNode node1 = jObject["One"];
JsonNode node2 = jObject["Two"];
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: these don't seem to be used

Comment on lines +188 to +193
// Dictionary is created when Add is called for the first time, so we need to be added first.
jArray = new JsonArray(options);
jObject = new JsonObject();
jObject.Add("MyProperty", 42);
jArray.Add(jObject);
jObject.Add("myproperty", 42); // no exception since options were not set in time.
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you imagine people will run into issues with the somewhat subtle semantics here?

Copy link
Member Author

Choose a reason for hiding this comment

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

With collection initializers this can occur although I don't see an easy alternative to "force" the options to be specified upfront before "Add()".

Originally, I had a design where the JsonSerializerOptions were used instead of JsonNodeOptions since those options have a concept of case insensitivity. However, that dependency was removed because tying those options to DOM creation was somewhat confusing since basically only the case insensitivity flag was used and nothing else. However, using JsonSerializerOptions like originally proposed could work in this scenario when we implement the ability to change the global options (as is scheduled for v6 I believe).

So if we add the ability to change the global JsonSerializerOptions perhaps we also add the ability to change\specify a global JsonNodeOptions and that could be used to address this scenario.

Copy link
Contributor

Choose a reason for hiding this comment

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

I see - yeah we should document this explicitly.

when we implement the ability to change the global options (as is scheduled for v6 I believe).

I'm getting more and more scared of doing this due to side effects with the defaults being changed in a way that affects all assemblies in a compilation.


JsonNode node = JsonSerializer.Deserialize<JsonNode>("\"42\"", options);
Assert.IsAssignableFrom<JsonValue>(node);
Assert.Throws<InvalidOperationException>(() => node.GetValue<int>());
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it possible to have this work in the future?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes if we decide we want to ease up on the GetValue() semantics and invoke the serializer. That would also require the JsonSerializerOptions to be passed in for things like quoted numbers.

We could also add a new method, like Json.NET's JNode.ToObject(), to deserialize.

{
JsonNode node = new JsonObject
{
["[Child"] = 1
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice. Are there other characters that are interesting enough to test?

Copy link
Member Author

Choose a reason for hiding this comment

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

Do you mean for the property name or for the value? The JsonNodeOperatorTests.cs has every value type possible (int, uint, string, char, etc)

{
if (Parent != null)
{
ThrowHelper.ThrowInvalidOperationException_NodeAlreadyHasParent();
Copy link
Contributor

Choose a reason for hiding this comment

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

Could it be valid in the future to want to replace/unbind from the parent?

Copy link
Member Author

Choose a reason for hiding this comment

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

Unbinding happens when an element is removed via Remove() or replaced via an indexer.


namespace System.Text.Json.Node
{
public partial class JsonObject
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems dynamic semantics are only supported when using JsonObject. What happens when other node types are treated as dynamic i.e setting/getting properties is attempted?

Copy link
Member Author

Choose a reason for hiding this comment

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

The dynamic feature allows you to call members on JsonArray and JsonValue (e.g. jArray.Add()). If an invalid member is specifies, RuntimeBinderException is thrown.


if (_value is JsonElement jsonElement)
{
return ConvertJsonElement<T>();
Copy link
Contributor

Choose a reason for hiding this comment

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

ConvertJsonElement and TryConvertJsonElement seem very similar. Could we instead call TryConvertJsonElement here, and throw the IOE if it returns false? Perhaps we could then delete ConvertJsonElement.

Copy link
Member Author

Choose a reason for hiding this comment

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

Perhaps. The reason is since we delegate to JsonElement to obtain the values in ConvertJsonElement we leverage the existing validation and exception logic in JsonElement. JsonElement in some cases throws FormatException and in other cases InvalidOperation which we would need to duplicate if we combine.

Comment on lines 11 to 15
public static JsonNodeConverter Default { get; } = new JsonNodeConverter();
public JsonArrayConverter ArrayConverter { get; } = new JsonArrayConverter();
public JsonObjectConverter ObjectConverter { get; } = new JsonObjectConverter();
public JsonValueConverter ValueConverter { get; } = new JsonValueConverter();
public ObjectConverter ElementConverter { get; } = new ObjectConverter();
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it worth it to instantiate these lazily and save on allocs?

Copy link
Member Author

Choose a reason for hiding this comment

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

They are already lazy. The JsonNodeConverterFactory will only access these properties when given a JsonNode Type.

Copy link
Member

Choose a reason for hiding this comment

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

I believe these properties will be initialized by the constructor.

Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor

@layomia layomia left a comment

Choose a reason for hiding this comment

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

LGTM

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

API for writeable DOM and C# dynamic support
7 participants