Search code examples
c#json.net.net-6.0system.text.json

Clone a JsonNode and attach it to another one in .NET 6


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.


Solution

  • Prior to .NET 8, JsonNode has no Clone() method, so 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();
    }
    

    Update for .NET 8: .NET 8 introduces JsonNode.DeepClone() so in .NET 8 and later JsonExtensions.CopyNode() may be eliminated and the second concQuest() method written as follows:

    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?.DeepClone();
    }
    

    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: