Added JsonDeltaFormatter

pull/36/head
Justin Hopper 2020-03-30 13:58:31 -05:00
parent ca0e88aa56
commit 14ed050e05
11 changed files with 537 additions and 0 deletions

View File

@ -0,0 +1,112 @@
using JsonDiffPatchDotNet.Formatters.JsonPatch;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
namespace JsonDiffPatchDotNet.UnitTests
{
[TestFixture]
public class JsonDeltaFormatterUnitTests
{
[Test]
public void Format_SupportsRemove_Success()
{
var jdp = new JsonDiffPatch();
var formatter = new JsonDeltaFormatter();
var left = JObject.Parse(@"{ ""p"" : true }");
var right = JObject.Parse(@"{ }");
var patch = jdp.Diff(left, right);
var operations = formatter.Format(patch);
Assert.AreEqual(1, operations.Count);
AssertOperation(operations[0], OperationTypes.Remove, "/p");
}
[Test]
public void Format_SupportsAdd_Success()
{
var jdp = new JsonDiffPatch();
var formatter = new JsonDeltaFormatter();
var left = JObject.Parse(@"{ }");
var right = JObject.Parse(@"{ ""p"" : true }");
var patch = jdp.Diff(left, right);
var operations = formatter.Format(patch);
Assert.AreEqual(1, operations.Count);
AssertOperation(operations[0], OperationTypes.Add, "/p", new JValue(true));
}
[Test]
public void Format_SupportsReplace_Success()
{
var jdp = new JsonDiffPatch();
var formatter = new JsonDeltaFormatter();
var left = JObject.Parse(@"{ ""p"" : false }");
var right = JObject.Parse(@"{ ""p"" : true }");
var patch = jdp.Diff(left, right);
var operations = formatter.Format(patch);
Assert.AreEqual(1, operations.Count);
AssertOperation(operations[0], OperationTypes.Replace, "/p", new JValue(true));
}
[Test]
public void Format_SupportsArrayAdd_Success()
{
var jdp = new JsonDiffPatch();
var formatter = new JsonDeltaFormatter();
var left = JObject.Parse(@"{ ""items"" : [""car"", ""bus""] }");
var right = JObject.Parse(@"{ ""items"" : [""bike"", ""car"", ""bus""] }");
var patch = jdp.Diff(left, right);
var operations = formatter.Format(patch);
Assert.AreEqual(1, operations.Count);
AssertOperation(operations[0], OperationTypes.Add, "/items/0", JValue.CreateString("bike"));
}
[Test]
public void Format_SupportsArrayRemove_Success()
{
var jdp = new JsonDiffPatch();
var formatter = new JsonDeltaFormatter();
var left = JObject.Parse(@"{ ""items"" : [""bike"", ""car"", ""bus""] }");
var right = JObject.Parse(@"{ ""items"" : [""car"", ""bus""] }");
var patch = jdp.Diff(left, right);
var operations = formatter.Format(patch);
Assert.AreEqual(1, operations.Count);
AssertOperation(operations[0], OperationTypes.Remove, "/items/0");
}
[Test]
public void Format_SupportsArrayMove_Success()
{
var jdp = new JsonDiffPatch();
var formatter = new JsonDeltaFormatter();
var left = JObject.Parse(@"{ ""items"" : [""bike"", ""car"", ""bus""] }");
var right = JObject.Parse(@"{ ""items"" : [""bike"", ""bus"", ""car""] }");
var patch = jdp.Diff(left, right);
var operations = formatter.Format(patch);
Assert.AreEqual(2, operations.Count);
AssertOperation(operations[0], OperationTypes.Remove, "/items/2");
AssertOperation(operations[1], OperationTypes.Add, "/items/1", JValue.CreateString("bus"));
}
private void AssertOperation(Operation operation, string expectedOp, string expectedPath, JValue expectedValue = null)
{
Assert.AreEqual(expectedOp, operation.Op);
Assert.AreEqual(expectedPath, operation.Path);
if (expectedValue != null)
{
var value = operation.Value as JValue;
Assert.IsNotNull(value, "Operation value was expected to be a JValue");
Assert.AreEqual(expectedValue, value);
}
else
{
Assert.IsNull(operation.Value);
}
}
}
}

View File

@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace JsonDiffPatchDotNet.Formatters
{
internal class ArrayKeyComparer : IComparer<string>
{
public int Compare(string x, string y)
{
// This purposefully REVERSED from benjamine/jsondiffpatch,
// In order to match logic found in JsonDiffPatch.ArrayPatch,
// which applies operations in reverse order to avoid shifting floor
return ArrayKeyToSortNumber(y) - ArrayKeyToSortNumber(x);
}
private static int ArrayKeyToSortNumber(string key)
{
if (key == "_t")
return -1;
if (key.Length > 1 && key[0] == '_')
return int.Parse(key.Substring(1)) * 10;
return (int.Parse(key) * 10) + 1;
}
}
}

View File

@ -0,0 +1,167 @@
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
namespace JsonDiffPatchDotNet.Formatters
{
public abstract class BaseDeltaFormatter<TContext, TResult>
where TContext : IFormatContext<TResult>, new()
{
public delegate void DeltaKeyIterator(string key, string leftKey, MoveDestination movedFrom, bool isLast);
private static readonly IComparer<string> s_arrayKeyComparer = new ArrayKeyComparer();
public TResult Format(JToken delta)
{
var context = new TContext();
Recurse(context, delta, left: null, key: null, leftKey: null, movedFrom: null, isLast: false);
return context.Result();
}
protected abstract bool IncludeMoveDestinations { get; }
protected abstract void NodeBegin(TContext context, string key, string leftKey, DeltaType type, NodeType nodeType, bool isLast);
protected abstract void NodeEnd(TContext context, string key, string leftKey, DeltaType type, NodeType nodeType, bool isLast);
protected abstract void RootBegin(TContext context, DeltaType type, NodeType nodeType);
protected abstract void RootEnd(TContext context, DeltaType type, NodeType nodeType);
protected abstract void Format(DeltaType type, TContext context, JToken delta, JToken leftValue, string key, string leftKey, MoveDestination movedFrom);
protected void Recurse(TContext context, JToken delta, JToken left, string key, string leftKey, MoveDestination movedFrom, bool isLast)
{
var useMoveOriginHere = delta != null && movedFrom != null;
var leftValue = useMoveOriginHere ? movedFrom.Value : left;
if (delta == null && string.IsNullOrEmpty(key))
return;
var type = GetDeltaType(delta, movedFrom);
var nodeType = type == DeltaType.Node ? (delta["_t"]?.Value<string>() == "a" ? NodeType.Array : NodeType.Object) : NodeType.Unknown;
if (!string.IsNullOrEmpty(key))
NodeBegin(context, key, leftKey, type, nodeType, isLast);
else
RootBegin(context, type, nodeType);
Format(type, context, delta, leftValue, key, leftKey, movedFrom);
if (!string.IsNullOrEmpty(key))
NodeEnd(context, key, leftKey, type, nodeType, isLast);
else
RootEnd(context, type, nodeType);
}
protected void FormatDeltaChildren(TContext context, JToken delta, JToken left)
{
ForEachDeltaKey(delta, left, Iterator);
void Iterator(string key, string leftKey, MoveDestination movedFrom, bool isLast)
{
Recurse(context, delta[key], left?[leftKey], key, leftKey, movedFrom, isLast);
}
}
protected void ForEachDeltaKey(JToken delta, JToken left, DeltaKeyIterator iterator)
{
var keys = new List<string>();
var arrayKeys = false;
var movedDestinations = new Dictionary<string, MoveDestination>();
if (delta is JObject jObject)
{
keys = jObject.Properties().Select(p => p.Name).ToList();
arrayKeys = jObject["_t"]?.Value<string>() == "a";
}
if (left != null && left is JObject leftObject)
{
foreach (var kvp in leftObject)
{
if (delta[kvp.Key] == null && (!arrayKeys || delta["_"+ kvp.Key] == null))
{
keys.Add(kvp.Key);
}
}
}
if (delta is JObject deltaObject)
{
foreach (var kvp in deltaObject)
{
var value = kvp.Value;
if (value is JArray valueArray && valueArray.Count == 3)
{
var diffOp = valueArray[2].Value<int>();
if (diffOp == (int)DiffOperation.ArrayMove)
{
var moveKey = valueArray[1].ToString();
movedDestinations[moveKey] = new MoveDestination(kvp.Key, left?[kvp.Key.Substring(1)]);
if (IncludeMoveDestinations && left == null && deltaObject.Property(moveKey) == null)
keys.Add(moveKey);
}
}
}
}
if (arrayKeys)
keys.Sort(s_arrayKeyComparer);
else
keys.Sort();
for (var index = 0; index < keys.Count; index++)
{
var key = keys[index];
if (arrayKeys && key == "_t")
continue;
var leftKey = arrayKeys
? key.TrimStart('_')
: key;
var isLast = index == keys.Count - 1;
var movedFrom = movedDestinations.ContainsKey(leftKey) ? movedDestinations[leftKey] : null;
iterator(key, leftKey, movedFrom, isLast);
}
}
protected static DeltaType GetDeltaType(JToken delta = null, MoveDestination movedFrom = null)
{
if (delta == null)
return movedFrom != null ? DeltaType.MoveDestination : DeltaType.Unchanged;
switch (delta.Type)
{
case JTokenType.Array:
{
var deltaArray = (JArray)delta;
switch (deltaArray.Count)
{
case 1: return DeltaType.Added;
case 2: return DeltaType.Modified;
case 3:
{
switch ((DiffOperation)deltaArray[2].Value<int>())
{
case DiffOperation.Deleted: return DeltaType.Deleted;
case DiffOperation.TextDiff: return DeltaType.TextDiff;
case DiffOperation.ArrayMove: return DeltaType.Moved;
}
break;
}
}
break;
}
case JTokenType.Object:
return DeltaType.Node;
}
return DeltaType.Unknown;
}
}
}

View File

@ -0,0 +1,15 @@
namespace JsonDiffPatchDotNet.Formatters
{
public enum DeltaType
{
Unknown,
Unchanged,
Added,
Moved,
Deleted,
MoveDestination,
Modified,
Node,
TextDiff
}
}

View File

@ -0,0 +1,7 @@
namespace JsonDiffPatchDotNet.Formatters
{
public interface IFormatContext<out TResult>
{
TResult Result();
}
}

View File

@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
namespace JsonDiffPatchDotNet.Formatters.JsonPatch
{
public class JsonDeltaFormatter : BaseDeltaFormatter<JsonFormatContext, IList<Operation>>
{
protected override bool IncludeMoveDestinations => true;
protected override void Format(DeltaType type, JsonFormatContext context, JToken delta, JToken leftValue, string key, string leftKey, MoveDestination movedFrom)
{
switch (type)
{
case DeltaType.Added:
FormatAdded(context, delta);
break;
case DeltaType.Node:
FormatNode(context, delta, leftValue);
break;
case DeltaType.Modified:
FormatModified(context, delta);
break;
case DeltaType.Deleted:
FormatDeleted(context);
break;
case DeltaType.Moved:
FormatMoved(context, delta);
break;
case DeltaType.Unknown:
case DeltaType.Unchanged:
case DeltaType.MoveDestination:
break;
case DeltaType.TextDiff:
throw new InvalidOperationException("JSON RFC 6902 does not support TextDiff.");
}
}
protected override void NodeBegin(JsonFormatContext context, string key, string leftKey, DeltaType type, NodeType nodeType, bool isLast)
{
context.Path.Add(leftKey);
}
protected override void NodeEnd(JsonFormatContext context, string key, string leftKey, DeltaType type, NodeType nodeType, bool isLast)
{
if (context.Path.Count > 0)
context.Path.RemoveAt(context.Path.Count - 1);
}
protected override void RootBegin(JsonFormatContext context, DeltaType type, NodeType nodeType) { }
protected override void RootEnd(JsonFormatContext context, DeltaType type, NodeType nodeType) { }
private void FormatNode(JsonFormatContext context, JToken delta, JToken left)
{
FormatDeltaChildren(context, delta, left);
}
private void FormatAdded(JsonFormatContext context, JToken delta)
{
context.PushCurrentOp(OperationTypes.Add, delta[0]);
}
private void FormatModified(JsonFormatContext context, JToken delta)
{
context.PushCurrentOp(OperationTypes.Replace, delta[1]);
}
private void FormatDeleted(JsonFormatContext context)
{
context.PushCurrentOp(OperationTypes.Remove);
}
private void FormatMoved(JsonFormatContext context, JToken delta)
{
context.PushMoveOp(delta[1].ToString());
}
}
}

View File

@ -0,0 +1,50 @@
using System.Collections.Generic;
using System.Linq;
namespace JsonDiffPatchDotNet.Formatters.JsonPatch
{
public class JsonFormatContext : IFormatContext<IList<Operation>>
{
public JsonFormatContext()
{
Operations = new List<Operation>();
Path = new List<string>();
}
public IList<Operation> Operations { get; }
public IList<string> Path { get; }
public IList<Operation> Result()
{
return Operations;
}
public void PushCurrentOp(string op)
{
Operations.Add(new Operation(op, CurrentPath(), null));
}
public void PushCurrentOp(string op, object value)
{
Operations.Add(new Operation(op, CurrentPath(), null, value));
}
public void PushMoveOp(string to)
{
Operations.Add(new Operation(OperationTypes.Move, ToPath(to), CurrentPath()));
}
private string CurrentPath()
{
return $"/{string.Join("/", Path)}";
}
private string ToPath(string toPath)
{
var to = Path.ToList();
to[to.Count - 1] = toPath;
return $"/{string.Join("/", to)}";
}
}
}

View File

@ -0,0 +1,36 @@
using Newtonsoft.Json;
namespace JsonDiffPatchDotNet.Formatters.JsonPatch
{
public class Operation
{
public Operation() { }
public Operation(string op, string path, string from)
{
Op = op;
Path = path;
From = from;
}
public Operation(string op, string path, string from, object value)
{
Op = op;
Path = path;
From = from;
Value = value;
}
[JsonProperty("path")]
public string Path { get; set; }
[JsonProperty("op")]
public string Op { get; set; }
[JsonProperty("from")]
public string From { get; set; }
[JsonProperty("value")]
public object Value { get; set; }
}
}

View File

@ -0,0 +1,13 @@
namespace JsonDiffPatchDotNet.Formatters.JsonPatch
{
public static class OperationTypes
{
public const string Add = "add";
public const string Replace = "replace";
public const string Move = "move";
public const string Remove = "remove";
}
}

View File

@ -0,0 +1,17 @@
using Newtonsoft.Json.Linq;
namespace JsonDiffPatchDotNet.Formatters
{
public class MoveDestination
{
public MoveDestination(string key, JToken value)
{
Key = key;
Value = value;
}
public string Key { get; }
public JToken Value { get; }
}
}

View File

@ -0,0 +1,9 @@
namespace JsonDiffPatchDotNet.Formatters
{
public enum NodeType
{
Unknown,
Object,
Array
}
}