7

I'm using System.Text.Json.Nodes in .NET 6.0 and what I'm trying to do is simple: Copy a JsonNode from one and attach the node to another JsonNode.
The following is my code.

public static string concQuest(string input, string allQuest, string questId) {
    JsonNode inputNode = JsonNode.Parse(input)!;
    JsonNode allQuestNode = JsonNode.Parse(allQuest)!;
    JsonNode quest = allQuestNode.AsArray().First(quest => 
        quest!["id"]!.GetValue<string>() == questId) ?? throw new KeyNotFoundException("No matching questId found.");
    inputNode["quest"] = quest;  // Exception occured
    return inputNode.ToJsonString(options);
}

But when I try to run it, I got a System.InvalidOperationException said "The node already has a parent."

I've tried edit

inputNode["quest"] = quest;

to

inputNode["quest"] = quest.Root; // quest.Root is also a JsonNode

Then the code runs well but it returns all nodes instead of the one I specified which is not the result I want. Also since the code works fine, I think it is feasible to set a JsonNode to another one directly.
According to the exception message, it seems if I want to add a JsonNode to another one, I must unattach it from its parent first, but how can I do this?

Note that my JSON file is quite big (more than 6MB), so I want to ensure there are no performance issues with my solution.

Vibbit
  • 73
  • 1
  • 5
  • 1
    What does your JSON look like? Can you share a [mcve]? – dbc Mar 23 '22 at 13:25
  • _"I want to ensure there are no performance issues with my solution."_ - if you want to be sure that there are no issues you need to take some implementation at test it's performance against real data. Actually in quite a lot of cases "good enough" is a way to go without need for premature optimisation. – Guru Stron Mar 23 '22 at 19:34

3 Answers3

3

As JsonNode has no Clone() method as of .NET 6, the easiest way to copy it is probably to invoke the serializer's JsonSerializer.Deserialize<TValue>(JsonNode, JsonSerializerOptions) extension method to deserialize your node directly into another node. First, introduce the following extension methods to copy or move a node:

public static partial class JsonExtensions
{
    public static TNode? CopyNode<TNode>(this TNode? node) where TNode : JsonNode => node?.Deserialize<TNode>();

    public static JsonNode? MoveNode(this JsonArray array, int id, JsonObject newParent, string name)
    {
        var node = array[id];
        array.RemoveAt(id); 
        return newParent[name] = node;
    }

    public static JsonNode? MoveNode(this JsonObject parent, string oldName, JsonObject newParent, string name)
    {
        parent.Remove(oldName, out var node);
        return newParent[name] = node;
    }

    public static TNode ThrowOnNull<TNode>(this TNode? value) where TNode : JsonNode => value ?? throw new JsonException("Null JSON value");
}

Now your code may be written as follows:

public static string concQuest(string input, string allQuest, string questId) 
{
    var inputObject = JsonNode.Parse(input).ThrowOnNull().AsObject();
    var allQuestArray = JsonNode.Parse(allQuest).ThrowOnNull().AsArray();
    concQuest(inputObject, allQuestArray, questId);
    return inputObject.ToJsonString();
}       

public static JsonNode? concQuest(JsonObject inputObject, JsonArray allQuestArray, string questId) 
{
    // Enumerable.First() will throw an InvalidOperationException if no element is found satisfying the predicate.
    var node = allQuestArray.First(quest => quest!["id"]!.GetValue<string>() == questId);
    return inputObject["quest"] = node.CopyNode();
}

Alternatively, if you aren't going to keep your array of quests around, you could just move the node from the array to the target like so:

public static string concQuest(string input, string allQuest, string questId) 
{
    var inputObject = JsonNode.Parse(input).ThrowOnNull().AsObject();
    var allQuestArray = JsonNode.Parse(allQuest).ThrowOnNull().AsArray();
    concQuest(inputObject, allQuestArray, questId);
    return inputObject.ToJsonString();
}       

public static JsonNode? concQuest(JsonObject inputObject, JsonArray allQuestArray, string questId) 
{
    // Enumerable.First() will throw an InvalidOperationException if no element is found satisfying the predicate.
    var (_, index) = allQuestArray.Select((quest, index) => (quest, index)).First(p => p.quest!["id"]!.GetValue<string>() == questId);
    return allQuestArray.MoveNode(index, inputObject, "quest");
}

Also, you wrote

since my json file is quite big (more than 6MB), I was worried there might be some performance issues.

In that case I would avoid loading the JSON files into the input and allQuest strings because strings larger than 85,000 bytes go on the large object heap which can cause subsequent performance degradation. Instead, deserialize directly from the relevant files into JsonNode arrays and objects like so:

var questId = "2"; // Or whatever

JsonArray allQuest;
using (var stream = new FileStream(allQuestFileName, new FileStreamOptions { Mode = FileMode.Open, Access = FileAccess.Read }))
    allQuest = JsonNode.Parse(stream).ThrowOnNull().AsArray();

JsonObject input;
using (var stream = new FileStream(inputFileName, new FileStreamOptions { Mode = FileMode.Open, Access = FileAccess.Read }))
    input = JsonNode.Parse(stream).ThrowOnNull().AsObject();

JsonExtensions.concQuest(input, allQuest, questId);

using (var stream = new FileStream(inputFileName, new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write }))
using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }))
    input.WriteTo(writer);

Or, if your app is asynchronous, you can do:

JsonArray allQuest;
await using (var stream = new FileStream(allQuestFileName, new FileStreamOptions { Mode = FileMode.Open, Access = FileAccess.Read, Options = FileOptions.Asynchronous }))
    allQuest = (await JsonSerializer.DeserializeAsync<JsonArray>(stream)).ThrowOnNull();

JsonObject input;
await using (var stream = new FileStream(inputFileName, new FileStreamOptions { Mode = FileMode.Open, Access = FileAccess.Read, Options = FileOptions.Asynchronous }))
    input = (await JsonSerializer.DeserializeAsync<JsonObject>(stream)).ThrowOnNull();

JsonExtensions.concQuest(input, allQuest, questId);

await using (var stream = new FileStream(inputFileName, new FileStreamOptions { Mode = FileMode.Create, Access = FileAccess.Write, Options = FileOptions.Asynchronous }))
    await JsonSerializer.SerializeAsync(stream, input, new JsonSerializerOptions { WriteIndented = true });

Notes:

Demo fiddles:

dbc
  • 104,963
  • 20
  • 228
  • 340
  • 1
    Many thanks for your answers and suggestions, I think this is probably the best solution for now. There is one more thing I am curious about, why can't we copy a JsonNode to another one directly if it already has a parent? What's the problem if we do so? – Vibbit Mar 23 '22 at 19:06
  • 2
    @Vibbit - I don't know why MSFT designed their API that way. With Json.NET, when you assign a node with a parent to another node, the node is automatically cloned, see [nested json objects dont update / inherit using Json.NET](https://stackoverflow.com/a/29260565/3744182). Maybe the API designers at MSFT didn't like that so they decided to throw an exception instead as the simplest thing to do. – dbc Mar 23 '22 at 19:46
2

Easiest option would be to convert json node into string and parse it again (though possibly not the most performant one):

var destination = @"{}";
var source = "[{\"id\": 1, \"name\":\"some quest\"},{}]";
var sourceJson = JsonNode.Parse(source);
var destinationJson = JsonNode.Parse(destination);
var quest = sourceJson.AsArray().First();
destinationJson["quest"] = JsonNode.Parse(quest.ToJsonString());
Console.WriteLine(destinationJson.ToJsonString(new() { WriteIndented = true }));

Will print:

{
  "quest": {
    "id": 1,
    "name": "some quest"
  }
}

UPD

Another trick is to deserialize the JsonNode to JsonNode:

...
var quest = sourceJson.AsArray().First();    
var clone = quest.Deserialize<JsonNode>();
clone["name"] = "New name"; 
destinationJson["quest"] = clone;
Console.WriteLine(quest["name"]);
Console.WriteLine(destinationJson.ToJsonString(new() { WriteIndented = true }));

Prints:

some quest
{
  "quest": {
    "id": 1,
    "name": "New name"
  }
}
Guru Stron
  • 102,774
  • 10
  • 95
  • 132
0

The official API for creating a JsonNode from a .Net object (including another JsonNode) appears to be the JsonSerializer.SerializeToNode method which is available in .Net 6+. Digging under the covers, this method simply writes the .Net object to a memory buffer and then deserializes it. As such, there still might be performance degradation with cloning large structures.

If the allQuest JSON is quite large, you can avoid loading the whole content into memory as parsed JSON using the JsonSerializer.DeserializeAsyncEnumerable method. This technique is described elsewhere (requires log-in).

Combining these methods, the code could become.

public static string concQuest(string input, Stream allQuest, string questId) {
    JsonNode inputNode = JsonNode.Parse(input)!;
    JsonNode quest = JsonSerializer.DeserializeAsyncEnumerable<JsonNode>(allQuest)
        .ToBlockingEnumerable()
        .First(quest => quest!["id"]!.GetValue<string>() == questId) 
            ?? throw new KeyNotFoundException("No matching questId found.");
    inputNode["quest"] = JsonSerializer.SerializeToNode(quest);
    return inputNode.ToJsonString(options);
}
erdomke
  • 4,980
  • 1
  • 24
  • 30