Skip to content

Commit 48ef7bd

Browse files
author
Dante DG
committed
Implemented option to account for array entry moves in ArrayDiff.
Before, when an array entry moved, it was always documented in the result as an add and a delete. Now, an add and delete of the same entry can be treated as a single operation: a move. This implements a feature that already exists in the original jsondiffpatch.
1 parent c2b2458 commit 48ef7bd

File tree

3 files changed

+226
-81
lines changed

3 files changed

+226
-81
lines changed

Src/JsonDiffPatchDotNet.UnitTests/DiffUnitTests.cs

Lines changed: 87 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,49 @@ public void Diff_EfficientArrayDiffDifferentHeadAdded_ValidDiff()
193193
Assert.IsNotNull(diff["0"]);
194194
}
195195

196+
[Test]
197+
public void Diff_EfficientArrayDiffTailMovedToHead_IgnoreMove_NoChange()
198+
{
199+
var jdp = new JsonDiffPatch(new Options { ArrayDiff = ArrayDiffMode.Efficient, DiffArrayOptions = new ArrayOptions { DetectMove = true, IncludeValueOnMove = false } });
200+
var left = JToken.Parse(@"[1,2,3,4,5,6,7,8,9,10]");
201+
var right = JToken.Parse(@"[4,1,2,3,7,5,6,8,10,9]");
202+
203+
var diff = jdp.Diff(left, right);
204+
205+
Assert.IsNull(diff);
206+
}
207+
208+
[Test]
209+
public void Diff_EfficientArrayDiffTailHeadMovedToTail_IncludeMove_ValidDiff()
210+
{
211+
var jdp = new JsonDiffPatch(new Options { ArrayDiff = ArrayDiffMode.Efficient, DiffArrayOptions = new ArrayOptions { DetectMove = true, IncludeValueOnMove = true } });
212+
var left = JToken.Parse(@"[1,2,3,4]");
213+
var right = JToken.Parse(@"[2,3,4,1]");
214+
215+
JObject diff = jdp.Diff(left, right) as JObject;
216+
217+
Assert.IsNotNull(diff);
218+
Assert.AreEqual(2, diff.Properties().Count());
219+
Assert.AreEqual(diff["_0"], JToken.Parse("['', 3, 3]"));
220+
}
221+
222+
[Test]
223+
public void Diff_EfficientArrayDiffWithComplexObjects_IncludeMove_ValidDiff()
224+
{
225+
var jdp = new JsonDiffPatch(new Options { ArrayDiff = ArrayDiffMode.Efficient, ObjectHash = (jObj) => jObj["Id"].Value<string>(), DiffArrayOptions = new ArrayOptions { DetectMove = true, IncludeValueOnMove = true } });
226+
//var jdp = new JsonDiffPatch(new Options { ArrayDiff = ArrayDiffMode.Efficient });
227+
var left = JToken.Parse(@"[{""Id"" : ""F12B21EF-F57D-4958-ADDC-A3F52EC25EC8"", ""p"":false}, {""Id"" : ""F12B21EF-F57D-4958-ADDC-A3F52EC25EC9"", ""p"":true}, {""Id"" : ""F12B21EF-F57D-4958-ADDC-A3F52EC25EC10"", ""p"":true}]");
228+
var right = JToken.Parse(@"[{""Id"" : ""F12B21EF-F57D-4958-ADDC-A3F52EC25EC10"", ""p"":false}, {""Id"" : ""F12B21EF-F57D-4958-ADDC-A3F52EC25EC8"", ""p"":true}, {""Id"" : ""F12B21EF-F57D-4958-ADDC-A3F52EC25EC9"", ""p"":true}]");
229+
230+
JObject diff = jdp.Diff(left, right) as JObject;
231+
232+
Assert.IsNotNull(diff);
233+
Assert.AreEqual(4, diff.Properties().Count());
234+
Assert.AreEqual(diff["_2"], JToken.Parse("['', 0, 3]"));
235+
Assert.AreEqual(diff["0"]["p"], JToken.Parse("[true, false]"));
236+
Assert.AreEqual(diff["1"]["p"], JToken.Parse("[false, true]"));
237+
}
238+
196239
[Test]
197240
public void Diff_EfficientArrayDiffDifferentTailAdded_ValidDiff()
198241
{
@@ -250,22 +293,6 @@ public void Diff_EfficientArrayDiffWithComplexObject_ValidDiff()
250293
Assert.AreEqual(4, diff.Properties().Count());
251294
}
252295

253-
[Test]
254-
public void Diff_EfficientArrayDiffWithComplexObjectHash_ValidDiff()
255-
{
256-
var jdp = new JsonDiffPatch(new Options { ArrayDiff = ArrayDiffMode.Efficient, ObjectHash = (jObj) => jObj["Id"].Value<string>() });
257-
var left = JToken.Parse(@"[{""Id"": ""22ff56c7-2307-414b-8a3a-9bf1cdba2095"",""city"":""São Paulo""},{""Id"":""3fca9cdb-dd9b-4b7c-afc1-587751e25bd6"",""city"":""abc""},{""Id"":""1fe6a0f9-3974-427f-81cb-6004748cb179"",""city"":""xyz""}]");
258-
var right = JToken.Parse(@"[{""Id"":""3fca9cdb-dd9b-4b7c-afc1-587751e25bd6"",""city"":""abc""},{""Id"":""1fe6a0f9-3974-427f-81cb-6004748cb179"",""city"":""xyz""}, {""Id"":""3fca9cdb-dd9b-4b7c-afc1-587751e25b44"",""city"":""new""}]");
259-
260-
JObject diff = jdp.Diff(left, right) as JObject;
261-
262-
Assert.IsNotNull(diff);
263-
Assert.AreEqual(3, diff.Properties().Count());
264-
Assert.IsNotNull(diff["_0"]);
265-
Assert.IsNotNull(diff["2"]);
266-
Assert.AreEqual(((JArray)diff["2"])[0].Value<string>("city"), "new");
267-
}
268-
269296
[Test]
270297
public void Diff_EfficientArrayDiffSameWithObject_NoDiff()
271298
{
@@ -313,6 +340,50 @@ public void Diff_EfficientArrayDiffHugeArrays_NoStackOverflow()
313340
Assert.That(JToken.DeepEquals(restored, right));
314341
}
315342

343+
[Test]
344+
public void Diff_EfficientArrayDiffHugeArrays_OnlyIgnoredMoves_NoStackOverflow()
345+
{
346+
const int arraySize = 1000;
347+
348+
Func<JToken> shuffledArrayFunc = () =>
349+
{
350+
Random rng = new Random();
351+
var builder = new StringBuilder("[");
352+
353+
var randomList = new List<int>();
354+
for (int i = 0; i < arraySize; i++)
355+
{
356+
randomList.Add(i);
357+
}
358+
359+
// Shuffle array
360+
int n = arraySize - 1;
361+
while (n > 1)
362+
{
363+
n--;
364+
int k = rng.Next(n + 1);
365+
int val = randomList[k];
366+
randomList[k] = randomList[n];
367+
randomList[n] = val;
368+
}
369+
370+
foreach (var i in randomList)
371+
{
372+
builder.Append($"{i},");
373+
}
374+
builder.Append("]");
375+
376+
return JToken.Parse(builder.ToString());
377+
};
378+
379+
var jdp = new JsonDiffPatch(new Options { ArrayDiff = ArrayDiffMode.Efficient, DiffArrayOptions = new ArrayOptions { DetectMove = true, IncludeValueOnMove = false } });
380+
var left = shuffledArrayFunc();
381+
var right = shuffledArrayFunc();
382+
383+
JToken diff = jdp.Diff(left, right);
384+
Assert.IsNull(diff);
385+
}
386+
316387
[Test]
317388
public void Diff_IntStringDiff_ValidPatch()
318389
{

Src/JsonDiffPatchDotNet/JsonDiffPatch.cs

Lines changed: 76 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Dynamic;
34
using System.Globalization;
45
using System.IO;
56
using System.Linq;
7+
using System.Xml.Linq;
68
using DiffMatchPatch;
79
using Newtonsoft.Json.Linq;
810

@@ -379,7 +381,8 @@ private JObject ArrayDiff(JArray left, JArray right)
379381
{
380382
var objectHash = this._options.ObjectHash;
381383
var itemMatch = new DefaultItemMatch(objectHash);
382-
var result = JObject.Parse(@"{ ""_t"": ""a"" }");
384+
385+
var result = JObject.Parse(@"{ ""_t"": ""a"" }");
383386

384387
int commonHead = 0;
385388
int commonTail = 0;
@@ -389,22 +392,20 @@ private JObject ArrayDiff(JArray left, JArray right)
389392

390393
var childContext = new List<JToken>();
391394

392-
// Find common head
393-
while (commonHead < left.Count
394-
&& commonHead < right.Count
395-
&& itemMatch.Match(left[commonHead], right[commonHead]))
396-
{
397-
var index = commonHead;
398-
var child = Diff(left[index], right[index]);
399-
if(child != null)
400-
{
401-
result[$"{index}"] = child;
402-
}
403-
commonHead++;
404-
}
395+
while (commonHead < left.Count
396+
&& commonHead < right.Count
397+
&& itemMatch.Match(left[commonHead], right[commonHead]))
398+
{
399+
var index = commonHead;
400+
var child = Diff(left[index], right[index]);
401+
if (child != null)
402+
{
403+
result[$"{index}"] = child;
404+
}
405+
commonHead++;
406+
}
405407

406-
// Find common tail
407-
while (commonTail + commonHead < left.Count
408+
while (commonTail + commonHead < left.Count
408409
&& commonTail + commonHead < right.Count
409410
&& itemMatch.Match(left[left.Count - 1 - commonTail], right[right.Count - 1 - commonTail]))
410411
{
@@ -428,35 +429,52 @@ private JObject ArrayDiff(JArray left, JArray right)
428429

429430
return result;
430431
}
432+
431433
if (commonHead + commonTail == right.Count)
432434
{
433435
// Trivial case, a block (1 or more consecutive items) was removed
434436
for (int index = commonHead; index < left.Count - commonTail; ++index)
435437
{
436-
if (result.ContainsKey(index.ToString()))
437-
{
438-
result.Remove(index.ToString());
439-
}
440-
result[$"_{index}"] = new JArray(left[index], 0, (int)DiffOperation.Deleted);
438+
if (result.ContainsKey(index.ToString()))
439+
{
440+
result.Remove(index.ToString());
441+
}
442+
443+
result[$"_{index}"] = new JArray(left[index], 0, (int)DiffOperation.Deleted);
441444
}
442445

443446
return result;
444447
}
445448

446-
// Complex Diff, find the LCS (Longest Common Subsequence)
447-
List<JToken> trimmedLeft = left.ToList().GetRange(commonHead, left.Count - commonTail - commonHead);
449+
// Keep track of items in the array that were deleted, as if they are added back,
450+
// they can be treated as moves
451+
Dictionary<object, int> deletes = new Dictionary<object, int>();
452+
453+
var comparer = new JTokenEqualityComparer();
454+
455+
// Complex Diff, find the LCS (Longest Common Subsequence)
456+
List<JToken> trimmedLeft = left.ToList().GetRange(commonHead, left.Count - commonTail - commonHead);
448457
List<JToken> trimmedRight = right.ToList().GetRange(commonHead, right.Count - commonTail - commonHead);
458+
449459
Lcs lcs = Lcs.Get(trimmedLeft, trimmedRight, itemMatch);
450460

451461
for (int index = commonHead; index < left.Count - commonTail; ++index)
452462
{
453463
if (lcs.Indices1.IndexOf(index - commonHead) < 0)
454464
{
455-
// Removed
456465
if (result.ContainsKey(index.ToString()))
457466
{
458467
result.Remove(index.ToString());
459468
}
469+
470+
// If handling moves, mark the delete
471+
if (_options.DiffArrayOptions.DetectMove)
472+
{
473+
var entryId = (objectHash == null || left[index].Type != JTokenType.Object) ? comparer.GetHashCode(left[index]) : objectHash.Invoke(left[index]);
474+
475+
deletes.Add(entryId, index);
476+
}
477+
460478
result[$"_{index}"] = new JArray(left[index], 0, (int)DiffOperation.Deleted);
461479
}
462480
}
@@ -465,10 +483,35 @@ private JObject ArrayDiff(JArray left, JArray right)
465483
{
466484
int indexRight = lcs.Indices2.IndexOf(index - commonHead);
467485

468-
if (indexRight < 0)
486+
if (indexRight < 0)
469487
{
470-
// Added
471-
result[$"{index}"] = new JArray(right[index]);
488+
// If handling moves, check to see if the entry was already marked as deleted
489+
var entryId = (objectHash == null || right[index].Type != JTokenType.Object) ? comparer.GetHashCode(right[index]) : objectHash.Invoke(right[index]);
490+
491+
if (_options.DiffArrayOptions.DetectMove && deletes.ContainsKey(entryId))
492+
{
493+
// If added and deleted, it was moved (though still potentially modified)
494+
var compareIndex = deletes[entryId];
495+
result.Remove("_" + compareIndex.ToString());
496+
497+
// Only include a move in final result if flag is set
498+
if (_options.DiffArrayOptions.IncludeValueOnMove)
499+
{
500+
result[$"_{compareIndex}"] = new JArray("", index, 3);
501+
}
502+
503+
JToken diff = Diff(left[compareIndex], right[index]);
504+
505+
if (diff != null)
506+
{
507+
result[$"{index}"] = diff;
508+
}
509+
}
510+
else
511+
{
512+
// Added
513+
result[$"{index}"] = new JArray(right[index]);
514+
}
472515
}
473516
else
474517
{
@@ -484,6 +527,12 @@ private JObject ArrayDiff(JArray left, JArray right)
484527
}
485528
}
486529

530+
if (JToken.DeepEquals(result, JObject.Parse(@"{ ""_t"": ""a"" }")))
531+
{
532+
// result is empty, so ignored moves were the only modifications
533+
return null;
534+
}
535+
487536
return result;
488537
}
489538

0 commit comments

Comments
 (0)