Skip to content

Instantly share code, notes, and snippets.

@IanMercer
Created July 5, 2016 21:17
Show Gist options
  • Save IanMercer/9fc34d49d02911bc6c1d72311d7c6c6c to your computer and use it in GitHub Desktop.
Save IanMercer/9fc34d49d02911bc6c1d72311d7c6c6c to your computer and use it in GitHub Desktop.
JSONPatch - a simple, crude implementation of calculating and applying patches to Json objects
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Abodit
{
public class JPatch
{
public string op { get; set; } // add, remove, replace
public string path { get; set; }
public JToken value { get; set; }
private JPatch() { }
public static string Extend(string path, string extension)
{
// TODO: JSON property name needs escaping for path ??
return path + "/" + extension;
}
private static JPatch Build(string op, string path, string key, JToken value)
{
if (string.IsNullOrEmpty(key))
return new JPatch { op = op, path = path, value = value };
else
return new JPatch { op = op, path = Extend(path, key), value = value };
}
public static JPatch Add(string path, string key, JToken value)
{
return Build("add", path, key, value);
}
public static JPatch Remove(string path, string key)
{
return Build("remove", path, key, null);
}
public static JPatch Replace(string path, string key, JToken value)
{
return Build("replace", path, key, value);
}
public static string CalculatePatch(string leftString, string rightString)
{
if (string.IsNullOrEmpty(leftString)) leftString = "{}";
var left = JToken.Parse(leftString);
var right = JToken.Parse(rightString);
var result = JPatch.CalculatePatch(left, right);
var pts = JsonConvert.SerializeObject(result);
return pts;
}
public static IEnumerable<JPatch> CalculatePatch(JToken left, JToken right, string path = "")
{
if (left.Type != right.Type)
{
yield return JPatch.Replace(path, "", right);
yield break;
}
if (left.Type == JTokenType.Array)
{
if (left.Children().SequenceEqual(right.Children())) // TODO: Need a DEEP EQUALS HERE
yield break;
// No array insert or delete operators in jpatch (yet?)
yield return JPatch.Replace(path, "", right);
yield break;
}
if (left.Type == JTokenType.Object)
{
var lprops = ((IDictionary<string, JToken>)left).OrderBy(p => p.Key);
var rprops = ((IDictionary<string, JToken>)right).OrderBy(p => p.Key);
foreach (var removed in lprops.Except(rprops, MatchesKey.Instance))
{
yield return JPatch.Remove(path, removed.Key);
}
foreach (var added in rprops.Except(lprops, MatchesKey.Instance))
{
yield return JPatch.Add(path, added.Key, added.Value);
}
var matchedKeys = lprops.Select(x => x.Key).Intersect(rprops.Select(y => y.Key));
var zipped = matchedKeys.Select(k => new { key = k, left = left[k], right = right[k] });
foreach (var match in zipped)
{
string newPath = path + "/" + match.key;
foreach (var patch in CalculatePatch(match.left, match.right, newPath))
yield return patch;
}
yield break;
}
else
{
// Two values, same type, not JObject so no properties
if (left.ToString() == right.ToString())
yield break;
else
yield return JPatch.Replace(path, "", right);
}
}
/// <summary>
/// Special sentinel value meaning that there has been no change
/// </summary>
public static readonly JToken NoChange = new JObject();
public static string CalculateDelta(string leftString, string rightString)
{
if (string.IsNullOrEmpty(leftString)) return rightString;
var left = JToken.Parse(leftString);
var right = JToken.Parse(rightString);
var result = JPatch.CalculateDelta(left, right);
var pts = JsonConvert.SerializeObject(result);
return pts;
}
public static JToken CalculateDelta(JToken left, JToken right)
{
if (left.Type != right.Type)
{
return right;
}
if (left.Type == JTokenType.Array)
{
if (left.Children().SequenceEqual(right.Children())) // TODO: Need a DEEP EQUALS HERE
return NoChange;
return right;
}
if (left.Type == JTokenType.Object)
{
// Both sides are JObjects with properties, build a delta from
// any properties that are different (or null for have gone away)
var newObject = new JObject();
var lprops = ((IDictionary<string, JToken>)left).OrderBy(p => p.Key);
var rprops = ((IDictionary<string, JToken>)right).OrderBy(p => p.Key);
foreach (var removed in lprops.Except(rprops, MatchesKey.Instance))
{
newObject.Add(removed.Key, null);
}
foreach (var added in rprops.Except(lprops, MatchesKey.Instance))
{
newObject.Add(added.Key, added.Value);
}
var matchedKeys = lprops.Select(x => x.Key).Intersect(rprops.Select(y => y.Key));
var zipped = matchedKeys.Select(k => new { key = k, left = left[k], right = right[k] });
foreach (var match in zipped)
{
var deltaSub = CalculateDelta(match.left, match.right);
if (object.ReferenceEquals(deltaSub, NoChange)) continue; // No difference
newObject.Add(match.key, deltaSub);
}
return newObject;
}
else
{
// Two values, same type, not JObject so no properties
if (left.ToString() == right.ToString())
return NoChange;
else
return right;
}
}
public static string ApplyDelta(string leftString, string deltaString)
{
if (string.IsNullOrEmpty(leftString)) return deltaString;
var left = JToken.Parse(leftString);
var delta = JToken.Parse(deltaString);
var result = JPatch.ApplyDelta(left, delta);
var pts = JsonConvert.SerializeObject(result);
return pts;
}
public static JToken ApplyDelta(JToken left, JToken right)
{
if (left.Type == JTokenType.Object && right.Type == JTokenType.Object)
{
JObject newObject = new JObject();
// Both sides are JObjects with properties
var lprops = ((IDictionary<string, JToken>)left).OrderBy(p => p.Key);
var rprops = ((IDictionary<string, JToken>)right).OrderBy(p => p.Key);
foreach (var keep in lprops.Except(rprops, MatchesKey.Instance))
{
newObject.Add(keep.Key, keep.Value);
}
foreach (var added in rprops.Except(lprops, MatchesKey.Instance))
{
newObject.Add(added.Key, added.Value);
}
var matchedKeys = lprops.Select(x => x.Key).Intersect(rprops.Select(y => y.Key));
var zipped = matchedKeys.Select(k => new { key = k, left = left[k], right = right[k] });
foreach (var match in zipped)
{
// Descend into the property and apply delta for value of property
var deltaSub = ApplyDelta(match.left, match.right);
newObject.Add(match.key, deltaSub);
}
return newObject;
}
else
{
// In all other cases, e.g. different types, same primitive type, both array, ...
return right;
}
}
private class MatchesKey : IEqualityComparer<KeyValuePair<string, JToken>>
{
public static MatchesKey Instance = new MatchesKey();
public bool Equals(KeyValuePair<string, JToken> x, KeyValuePair<string, JToken> y)
{
return x.Key.Equals(y.Key);
}
public int GetHashCode(KeyValuePair<string, JToken> obj)
{
return obj.Key.GetHashCode();
}
}
}
}
using NUnit.Framework;
using Abodit;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Linq;
namespace TestSync
{
[TestFixture]
public class TestJsonPatches
{
[TestCase("{a:1, b:2, c:3}",
"{a:1, b:2}",
ExpectedResult = "[{\"op\":\"remove\",\"path\":\"/c\",\"value\":null}]",
TestName = "CalculatePatch remove works for a simple value")]
[TestCase("{a:1, b:2, c:{d:1,e:2}}",
"{a:1, b:2}",
ExpectedResult = "[{\"op\":\"remove\",\"path\":\"/c\",\"value\":null}]",
TestName = "CalculatePatch remove works for a complex value")]
[TestCase("{a:1, b:2}",
"{a:1, b:2, c:3}",
ExpectedResult = "[{\"op\":\"add\",\"path\":\"/c\",\"value\":3}]",
TestName = "CalculatePatch add works for a simple value")]
[TestCase("{a:1, b:2}",
"{a:1, b:2, c:{d:1,e:2}}",
ExpectedResult = "[{\"op\":\"add\",\"path\":\"/c\",\"value\":{\"d\":1,\"e\":2}}]",
TestName = "CalculatePatch add works for a complex value")]
[TestCase("{a:1, b:2, c:3}",
"{a:1, b:2, c:4}",
ExpectedResult="[{\"op\":\"replace\",\"path\":\"/c\",\"value\":4}]",
TestName="JsonPatch replace works for int")]
[TestCase("{a:1, b:2, c:\"foo\"}",
"{a:1, b:2, c:\"bar\"}",
ExpectedResult = "[{\"op\":\"replace\",\"path\":\"/c\",\"value\":\"bar\"}]",
TestName = "CalculatePatch replace works for string")]
[TestCase("{a:1, b:2, c:{foo:1}}",
"{a:1, b:2, c:{bar:2}}",
ExpectedResult = "[{\"op\":\"remove\",\"path\":\"/c/foo\",\"value\":null},{\"op\":\"add\",\"path\":\"/c/bar\",\"value\":2}]",
TestName = "CalculatePatch replace works for object")]
[TestCase("{a:1, b:2, c:3}",
"{c:3, b:2, a:1}",
ExpectedResult = "[]",
TestName = "CalculatePatch order does not matter")]
[TestCase("{a:{b:{c:{d:1}}}}",
"{a:{b:{d:{c:1}}}}",
ExpectedResult = "[{\"op\":\"remove\",\"path\":\"/a/b/c\",\"value\":null},{\"op\":\"add\",\"path\":\"/a/b/d\",\"value\":{\"c\":1}}]",
TestName = "CalculatePatch handles deep nesting")]
[TestCase("[1,2,3,4]",
"[5,6,7]",
ExpectedResult = "[{\"op\":\"replace\",\"path\":\"\",\"value\":[5,6,7]}]",
TestName = "CalculatePatch handles a simple array and replaces it")]
[TestCase("{a:[1,2,3,4]}",
"{a:[5,6,7]}",
ExpectedResult = "[{\"op\":\"replace\",\"path\":\"/a\",\"value\":[5,6,7]}]",
TestName = "CalculatePatch handles a simple array under a property and replaces it")]
[TestCase("{a:[1,2,3,4]}",
"{a:[1,2,3,4]}",
ExpectedResult = "[]",
TestName = "CalculatePatch handles same array")]
[Category("JsonPatch")]
public string CalculatePatchesWorksAsExpected(string leftString, string rightString)
{
var left = JToken.Parse(leftString);
var right = JToken.Parse(rightString);
var patches = JPatch.CalculatePatch(left, right).ToList();
var pts = JsonConvert.SerializeObject(patches);
System.Diagnostics.Trace.WriteLine(pts);
return pts;
}
[TestCase("{a:1, b:2, c:3}",
"{a:1, b:2}",
ExpectedResult = "{\"c\":null}",
TestName = "CalculateDelta remove works for a simple value")]
[TestCase("{a:1, b:2, c:{d:1,e:2}}",
"{a:1, b:2}",
ExpectedResult = "{\"c\":null}",
TestName = "CalculateDelta remove works for a complex value")]
[TestCase("{a:1, b:2}",
"{a:1, b:2, c:3}",
ExpectedResult = "{\"c\":3}",
TestName = "CalculateDelta add works for a simple value")]
[TestCase("{a:1, b:2}",
"{a:1, b:2, c:{d:1,e:2}}",
ExpectedResult = "{\"c\":{\"d\":1,\"e\":2}}",
TestName = "CalculateDelta add works for a complex value")]
[TestCase("{a:1, b:2, c:3}",
"{a:1, b:2, c:4}",
ExpectedResult = "{\"c\":4}",
TestName = "CalculateDelta replace works for int")]
[TestCase("{a:1, b:2, c:\"foo\"}",
"{a:1, b:2, c:\"bar\"}",
ExpectedResult = "{\"c\":\"bar\"}",
TestName = "CalculateDelta replace works for string")]
[TestCase("{a:1, b:2, c:{foo:1}}",
"{a:1, b:2, c:{bar:2}}",
ExpectedResult = "{\"c\":{\"foo\":null,\"bar\":2}}",
TestName = "CalculateDelta replace works for object")]
[TestCase("{a:1, b:2, c:3}",
"{c:3, b:2, a:1}",
ExpectedResult = "{}",
TestName = "CalculateDelta order does not matter")]
[TestCase("{a:{b:{c:{d:1}}}}",
"{a:{b:{d:{c:1}}}}",
ExpectedResult = "{\"a\":{\"b\":{\"c\":null,\"d\":{\"c\":1}}}}",
TestName = "CalculateDelta handles deep nesting")]
[TestCase("[1,2,3,4]",
"[5,6,7]",
ExpectedResult = "[5,6,7]",
TestName = "CalculateDelta handles a simple array and replaces it")]
[TestCase("{a:[1,2,3,4]}",
"{a:[5,6,7]}",
ExpectedResult = "{\"a\":[5,6,7]}",
TestName = "CalculateDelta handles a simple array under a property and replaces it")]
[TestCase("{a:[1,2,3,4]}",
"{a:[1,2,3,4]}",
ExpectedResult = "{}",
TestName = "CalculateDelta handles same array")]
public string JsonPatchesWorks(string leftString, string rightString)
{
var left = JToken.Parse(leftString);
var right = JToken.Parse(rightString);
var delta = JPatch.CalculateDelta(left, right);
var pts = JsonConvert.SerializeObject(delta);
System.Diagnostics.Trace.WriteLine(pts);
return pts;
}
[TestCase("{a:1, b:2}", "{c:3}", ExpectedResult = "{a:1,b:2,c:3}",
TestName = "Apply delta can add a simple value")]
[TestCase("{a:1, b:2}", "{c:{d:1,e:2}}", ExpectedResult="{a:1,b:2,c:{d:1,e:2}}",
TestName = "ApplyDelta can add a complex value")]
[TestCase("{a:1, b:2, c:3}", "{c:4}", ExpectedResult = "{a:1,b:2,c:4}",
TestName = "ApplyDelta can replace a simple int value")]
[TestCase("{a:1, b:2, c:\"foo\"}", "{c:\"bar\"}", ExpectedResult = "{a:1,b:2,c:bar}",
TestName = "ApplyDelta can replace a simple string value")]
[TestCase("{a:1, b:2, c:3}", "{c:{d:1,e:2}}", ExpectedResult = "{a:1,b:2,c:{d:1,e:2}}",
TestName = "ApplyDelta can replace a simple value with a complex value")]
[TestCase("{a:1, b:2, c:{foo:1}}", "{c:{bar:2}}", ExpectedResult = "{a:1,b:2,c:{foo:1,bar:2}}",
TestName = "ApplyDelta can add a deeper nested value")]
[TestCase("{a:1, b:2, c:3}", "{c:4, d:5}", ExpectedResult = "{a:1,b:2,d:5,c:4}",
TestName = "ApplyDelta can mix add and replace")]
[TestCase("[1,2,3,4]", "[5,6,7]", ExpectedResult = "[5,6,7]",
TestName = "ApplyDelta handles a simple array and replaces it")]
[TestCase("{a:[1,2,3,4]}", "{a:[5,6,7]}", ExpectedResult = "{a:[5,6,7]}",
TestName = "ApplyDelta handles a simple array under a property and replaces it")]
[TestCase("{a:[1,2,3,4]}", "{}", ExpectedResult = "{a:[1,2,3,4]}",
TestName = "CalculateDelta handles identity operation")]
public string ApplyDeltaWorks(string leftString, string deltaString)
{
var left = JToken.Parse(leftString);
var delta = JToken.Parse(deltaString);
var result = JPatch.ApplyDelta(left, delta);
var pts = JsonConvert.SerializeObject(result);
System.Diagnostics.Trace.WriteLine(pts);
return pts.Replace("\"", ""); // Make specification easier by removing quotes
}
}
}
@ravibha
Copy link

ravibha commented Jul 20, 2017

Great utility! Any plans on adding the comparison for array of objects (JArray)?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment