-
-
Save cajuncoding/bf78bdcf790782090d231590cbc2438f to your computer and use it in GitHub Desktop.
| using System; | |
| using System.Collections.Generic; | |
| using System.Linq; | |
| using System.Text.Json; | |
| using System.Text.Json.Nodes; | |
| namespace CajunCoding | |
| { | |
| public static class SystemTextJsonMergeExtensions | |
| { | |
| /// <summary> | |
| /// Merges the specified Json Node into the base JsonNode for which this method is called. | |
| /// It is null safe and can be easily used with null-check & null coalesce operators for fluent calls. | |
| /// NOTE: JsonNodes are context aware and track their parent relationships therefore to merge the values both JsonNode objects | |
| /// specified are mutated. The Base is mutated with new data while the source is mutated to remove reverences to all | |
| /// fields so that they can be added to the base. | |
| /// | |
| /// Source taken directly from the open-source Gist here: | |
| /// https://gist.github.com/cajuncoding/bf78bdcf790782090d231590cbc2438f | |
| /// | |
| /// </summary> | |
| /// <param name="jsonBase"></param> | |
| /// <param name="jsonMerge"></param> | |
| /// <returns></returns> | |
| /// <exception cref="ArgumentException"></exception> | |
| public static JsonNode Merge(this JsonNode jsonBase, JsonNode jsonMerge) | |
| { | |
| if (jsonBase == null || jsonMerge == null) | |
| return jsonBase; | |
| switch (jsonBase) | |
| { | |
| case JsonObject jsonBaseObj when jsonMerge is JsonObject jsonMergeObj: | |
| { | |
| //NOTE: We must materialize the set (e.g. to an Array), and then clear the merge array so the node can then be | |
| // re-assigned to the target/base Json; clearing the Object seems to be the most efficient approach... | |
| var mergeNodesArray = jsonMergeObj.ToArray(); | |
| jsonMergeObj.Clear(); | |
| foreach (var prop in mergeNodesArray) | |
| { | |
| jsonBaseObj[prop.Key] = jsonBaseObj[prop.Key] switch | |
| { | |
| JsonObject jsonBaseChildObj when prop.Value is JsonObject jsonMergeChildObj => jsonBaseChildObj.Merge(jsonMergeChildObj), | |
| JsonArray jsonBaseChildArray when prop.Value is JsonArray jsonMergeChildArray => jsonBaseChildArray.Merge(jsonMergeChildArray), | |
| _ => prop.Value | |
| }; | |
| } | |
| break; | |
| } | |
| case JsonArray jsonBaseArray when jsonMerge is JsonArray jsonMergeArray: | |
| { | |
| //NOTE: We must materialize the set (e.g. to an Array), and then clear the merge array, | |
| // so they can then be re-assigned to the target/base Json... | |
| var mergeNodesArray = jsonMergeArray.ToArray(); | |
| jsonMergeArray.Clear(); | |
| foreach(var mergeNode in mergeNodesArray) jsonBaseArray.Add(mergeNode); | |
| break; | |
| } | |
| default: | |
| throw new ArgumentException($"The JsonNode type [{jsonBase.GetType().Name}] is incompatible for merging with the target/base " + | |
| $"type [{jsonMerge.GetType().Name}]; merge requires the types to be the same."); | |
| } | |
| return jsonBase; | |
| } | |
| /// <summary> | |
| /// Merges the specified Dictionary of values into the base JsonNode for which this method is called. | |
| /// </summary> | |
| /// <typeparam name="TKey"></typeparam> | |
| /// <typeparam name="TValue"></typeparam> | |
| /// <param name="jsonBase"></param> | |
| /// <param name="dictionary"></param> | |
| /// <param name="options"></param> | |
| /// <returns></returns> | |
| public static JsonNode MergeDictionary<TKey, TValue>(this JsonNode jsonBase, IDictionary<TKey, TValue> dictionary, JsonSerializerOptions options = null) | |
| => jsonBase.Merge(JsonSerializer.SerializeToNode(dictionary, options)); | |
| } | |
| } |
For future reference here is the Unit Test that validates a variety of common use cases . . .
using System.Text.Json;
using System.Text.Json.Serialization;
using CajunCoding;
namespace SystemTextJsonMergeExtensionTests
{
[TestFixture]
public class SystemTextJsonMergeExtensionTests
{
[JsonConverter(typeof(JsonStringEnumMemberConverter))]
private enum StarWarsCharacterType
{
Civilian,
Jedi,
StormTrooper,
Droid,
SithLord
}
[JsonConverter(typeof(JsonStringEnumMemberConverter))]
private enum LightsaberColor { Blue, Green, Yellow }
[Test]
public void TestSystemTextJsonObjectMerge()
{
var jedi = new
{
Name = "Luke Skywalker",
CharacterType = StarWarsCharacterType.Jedi,
LightsaberCount = -1, //Should get overwritten...
//Test merging of nested objects...
DroidFriends = new[]
{
new { Name = "R2D2", CharacterType = StarWarsCharacterType.Droid }
},
Enemies = new
{
DarthVader = StarWarsCharacterType.SithLord
}
};
var jediAugmentingData = new
{
LightsaberOriginalOwner = "Anikan Skywalker",
LightsaberCount = 3,
OriginalLightsaberColor = LightsaberColor.Blue,
ReplacementLightsaberColor = LightsaberColor.Green,
FoundLighSaberColor = LightsaberColor.Yellow,
DroidFriends = new[]
{
new { Name = "C-3PO", CharacterType = StarWarsCharacterType.Droid }
},
Enemies = new
{
SenatorPalpatine = StarWarsCharacterType.SithLord
}
};
var jediJson = JsonSerializer.SerializeToNode(jedi);
var jediAugmentingDataJson = JsonSerializer.SerializeToNode(jediAugmentingData);
var json = jediJson.Merge(jediAugmentingDataJson);
//NOTE: This assumes that the Macross.JsonExtensions StringEnumConverter is used on the Enums...
Assert.AreEqual("Jedi", json["CharacterType"]?.GetValue<string>());
Assert.AreEqual("Anikan Skywalker", json["LightsaberOriginalOwner"]?.GetValue<string>());
Assert.AreEqual(3, json["LightsaberCount"]?.GetValue<int>());
Assert.AreEqual("Blue", json["OriginalLightsaberColor"]?.GetValue<string>());
Assert.AreEqual("Green", json["ReplacementLightsaberColor"]?.GetValue<string>());
Assert.AreEqual(2, json["DroidFriends"]?.AsArray()?.Count);
foreach (var droidFriend in json["DroidFriends"]?.AsArray())
{
Assert.AreEqual("Droid", droidFriend["CharacterType"].GetValue<string>());
}
Assert.AreEqual(2, json["Enemies"]?.AsObject().ToArray().Length);
foreach (var enemyProp in json["Enemies"]?.AsObject())
{
Assert.AreEqual("SithLord", enemyProp.Value.GetValue<string>());
}
}
}
}Another option, if you want to be able to layer any number of JSON documents, in-memory dictionaries, etc into a single JSON document, is to (ab)use the ConfigurationBuilder to load your various data to merge and build it and optionally serialize it back out to whatever form you like from the resulting IConfigurationRoot.
Much less efficient than this, but also has a pretty rich feature set around it, including the GetDebugView() to see where each value actually came from in the merged result. π
However, if starting from JsonNodes or JsonElements, you'd have to write them out to a MemoryStream to load them with the basic Microsoft.Extensions.Configuration package since it doesn't have methods to directly load Json* types or JSON strings.
@dodexahedron that's an interesting idea and I appreciate your use of (ab)use π as it's quite true...
Assuming a key goal is performance (which was with this Gist π you can easily chain and/or call this in a loop to process an array of Jsons into one result... here's an extension that encapsulates something like that, in case it's helpful:
//NOTE: NOT TESTED; typed off the top of my head
public static JsonNode MergeMany(this JsonNode firstJsonNode, params JsonNode[] mergeJsonNodesArray)
{
JsonNode resultJsonNode = null;
foreach(var mergeJsonNode in mergeJsonNodesArray)
resultJsonNode = (resultJsonNode ?? firstJsonNode).Merge(jsonMergeNode);
return resultJsonNode;
}Yep!
I'm also working on an implementation right now that uses JsonElement, Span, and such to see if I can come up with a way that sticks to the stack more and outperforms the common shortcut of writing a string to a MemoryStream and adding that stream to the ConfigurationBuilder. I've got an implementation using strings and dictionaries and JsonNodes that has a few dozen bytes of extra memory overhead so far, but with equivalent performance for relatively small input, but scales better for larger (especially deeply nested) inputs than the MemoryStream way, and doesn't come with the issue of leaving an undisposed MemoryStream hanging around (and is parallelizable, too, but that's detrimental below a pretty large threshold in benchmarks I ran). But it's ugly since I was just kinda hacking away at the problem on a sleepless night. π
Hoping this new one I'm working on works out and benchmarks better than either of those solutions. If so, I'll share it for sure since merging JSON without resorting to large dependencies like NewtonSoft seems to be a pretty common problem.
Sounds interesting! π
Fix Log:
07/11/2024: As noted in this response here, there was a missing case for nested properties of an object that were actually an array that wasn't being handled, so that is now updated.