Skip to content

Commit 7a96cce

Browse files
authored
Merge pull request #8 from marcolink/feat/move-by-lcs
feat: use LCS to find the least required move operations
2 parents 510d2cb + c0a1c83 commit 7a96cce

File tree

6 files changed

+171
-44
lines changed

6 files changed

+171
-44
lines changed

README.md

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,7 @@ Create [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902/) compliant JSON
1212
- Can diff any two [JSON](https://www.ecma-international.org/publications-and-standards/standards/ecma-404/) compliant objects - returns differences as [JSON Patch](http://jsonpatch.com/).
1313
- Elegant array diffing by providing an `objectHash` to match array elements
1414
- Ignore specific keys by providing a `propertyFilter`
15-
- `move` operations are ALWAYS **appended at the end**, therefore, they can be ignored (if wanted) when the patch gets applied.
16-
- :paw_prints: ***Is it small?*** Zero dependencies - it's ~**7 KB** (uncompressed).
15+
- :paw_prints: ***Is it small?*** Zero dependencies - it's ~**3 KB** (minified).
1716
- :crystal_ball: ***Is it fast?*** I haven't done any performance comparison yet.
1817
- :hatched_chick: ***Is it stable?*** Test coverage is high, but it's still in its early days - bugs are expected.
1918
- The interface is inspired by [jsondiffpatch](https://github.com/benjamine/jsondiffpatch)
@@ -41,7 +40,7 @@ console.log(patch) // => [{op: 'replace', path: '/year', value: 1974}]
4140
## Configuration
4241

4342
```typescript
44-
import { generateJSONPatch, JsonPatchConfig, JsonValue } from 'generate-json-patch';
43+
import { generateJSONPatch, JsonPatchConfig, JsonValue, ObjectHashContext } from 'generate-json-patch';
4544

4645
generateJSONPatch({/*...*/}, {/*...*/}, {
4746
// called when comparing array elements
@@ -53,7 +52,7 @@ generateJSONPatch({/*...*/}, {/*...*/}, {
5352
},
5453
// called for every property on objects. Can be used to ignore sensitive or irrelevant
5554
// properties when comparing data.
56-
propertyFilter: function (propertyName: string, context: GeneratePatchContext) {
55+
propertyFilter: function (propertyName: string, context: ObjectHashContext) {
5756
return !['sensitiveProperty'].includes(propertyName);
5857
},
5958
array: {
@@ -66,12 +65,12 @@ generateJSONPatch({/*...*/}, {/*...*/}, {
6665
```
6766

6867
### Patch Context
69-
Both config function (`objectHash`, `propertyFilter`), receive a patch context as second parameter.
68+
Both config function (`objectHash`, `propertyFilter`), receive a context as second parameter.
7069
This allows for granular decision-making on the provided data.
7170

7271
#### Example
7372
```typescript
74-
import {generateJSONPatch, JsonPatchConfig, JsonValue, pathInfo} from 'generate-json-patch';
73+
import {generateJSONPatch, JsonPatchConfig, JsonValue, ObjectHashContext, pathInfo} from 'generate-json-patch';
7574

7675
const before = {
7776
manufacturer: "Ford",
@@ -98,7 +97,7 @@ const after = {
9897
}
9998

10099
const patch = generateJSONPatch(before, after, {
101-
objectHash: function (value: JsonValue, context: GeneratePatchContext) {
100+
objectHash: function (value: JsonValue, context: ObjectHashContext) {
102101
const {length, last} = pathInfo(context.path)
103102
if (length === 2 && last === 'engine') {
104103
return value.name
@@ -109,9 +108,8 @@ const patch = generateJSONPatch(before, after, {
109108

110109
console.log(patch) // => [
111110
// { op: 'replace', path: '/engine/3/hp', value: 138 },
112-
// { op: 'move', from: '/engine/2', path: '/engine/3' },
113-
// { op: 'move', from: '/engine/1', path: '/engine/2' },
114-
// { op: 'move', from: '/engine/0', path: '/engine/1' }]
111+
// { op: 'move', from: '/engine/3', path: '/engine/0' }
112+
// ]
115113
```
116114

117115
> For more examples, check out the [tests](./src/index.spec.ts)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
],
3131
"scripts": {
3232
"build": "tsup src/index.ts",
33-
"test": "ts-mocha -p tsconfig.json src/index.spec.ts",
33+
"test": "ts-mocha -p tsconfig.json src/*.spec.ts",
3434
"test-watch": "npm run test -- -w --watch-files '**/*.ts'",
3535
"test-coverage": "nyc npm run test",
3636
"lint": "eslint --ext .ts ./src",

src/index.spec.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { JsonValue, Patch } from './index';
2-
import { generateJSONPatch, GeneratePatchContext, pathInfo } from './index';
1+
import type { JsonValue, ObjectHashContext, Patch } from './index';
2+
import { generateJSONPatch, pathInfo } from './index';
33
import { applyPatch, deepClone } from 'fast-json-patch';
44
import { assert, expect } from 'chai';
55

@@ -238,7 +238,6 @@ describe('a generate json patch function', () => {
238238
});
239239

240240
const patched = doPatch(before, patch);
241-
// as long as we do not support move, the result will be different from 'after' in its order
242241
expect(patched).to.be.eql([
243242
{ id: 2, paramOne: 'current' },
244243
{ id: 1, paramOne: 'current' },
@@ -324,9 +323,9 @@ describe('a generate json patch function', () => {
324323
},
325324
},
326325
{
326+
from: '/1',
327327
op: 'move',
328-
from: '/0',
329-
path: '/1',
328+
path: '/0',
330329
},
331330
]);
332331
});
@@ -535,24 +534,26 @@ describe('a generate json patch function', () => {
535534
};
536535

537536
const patch = generateJSONPatch(before, after, {
538-
objectHash: function (value: JsonValue, context: GeneratePatchContext) {
537+
objectHash: function (value: JsonValue, context: ObjectHashContext) {
539538
const { length, last } = pathInfo(context.path);
540539
if (length === 2 && last === 'engine') {
541540
// @ts-ignore
542541
return value?.name;
543542
}
544-
return JSON.stringify(value);
543+
return context.index.toString();
545544
},
546545
});
547546

547+
const patched = doPatch(before, patch);
548+
expect(patched).to.be.eql(after);
549+
548550
expect(patch).to.be.eql([
549551
{ op: 'replace', path: '/engine/3/hp', value: 138 },
550-
{ op: 'move', from: '/engine/2', path: '/engine/3' },
551-
{ op: 'move', from: '/engine/1', path: '/engine/2' },
552-
{ op: 'move', from: '/engine/0', path: '/engine/1' },
552+
{ op: 'move', from: '/engine/3', path: '/engine/0' },
553553
]);
554554
});
555555
});
556+
556557
describe('with property filter', () => {
557558
it('ignores property on root filter', () => {
558559
const before = {
@@ -655,7 +656,7 @@ describe('a generate json patch function', () => {
655656
});
656657

657658
function doPatch(json: JsonValue, patch: Patch) {
658-
return applyPatch(deepClone(json), patch, true, true).newDocument;
659+
return applyPatch(deepClone(json), patch, true, false).newDocument;
659660
}
660661

661662
function expectPatchedEqualsAfter(before: JsonValue, after: JsonValue) {

src/index.ts

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
export type JsonObject =
2-
| { [Key in string]: JsonValue }
3-
| { [Key in string]?: JsonValue };
1+
import { moveOperations } from './move-operations';
2+
3+
export type JsonObject = { [Key in string]: JsonValue | undefined };
44

55
export type JsonArray = JsonValue[] | readonly JsonValue[];
66

@@ -134,21 +134,8 @@ export function generateJSONPatch(
134134
return;
135135
}
136136

137-
// For movement, it's already iterating from back to front
138-
for (let i = rightHashes.length - 1; i >= 0; i--) {
139-
const hash = rightHashes[i];
140-
const targetIndex = rightHashes.indexOf(hash);
141-
const currentIndex = targetHashes.indexOf(hash);
142-
143-
if (currentIndex !== targetIndex) {
144-
patch.push({
145-
op: 'move',
146-
from: `${path}/${currentIndex}`,
147-
path: `${path}/${targetIndex}`,
148-
});
149-
moveArrayElement(targetHashes, currentIndex, targetIndex);
150-
}
151-
}
137+
const moveOps = moveOperations(targetHashes, rightHashes, path);
138+
patch.push(...moveOps);
152139
}
153140

154141
function compareObjects(
@@ -240,10 +227,6 @@ function isJsonObject(value: JsonValue): value is JsonObject {
240227
return value?.constructor === Object;
241228
}
242229

243-
function moveArrayElement(array: any[], from: number, to: number): void {
244-
array.splice(to, 0, array.splice(from, 1)[0]);
245-
}
246-
247230
export type PathInfoResult = {
248231
segments: string[];
249232
length: number;

src/move-operations.spec.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { expect } from 'chai';
2+
import { longestCommonSequence, moveOperations } from './move-operations';
3+
4+
describe('a moveOperations function', () => {
5+
it('should return an empty array when given two empty arrays', () => {
6+
expect(moveOperations([], [])).to.eql([]);
7+
});
8+
it('should return an empty array when given two arrays with the same values', () => {
9+
expect(moveOperations(['1', '2', '3'], ['1', '2', '3'])).to.eql([]);
10+
});
11+
it('should return a single move operation when given two arrays with one value moved', () => {
12+
expect(moveOperations(['1', '2', '3'], ['1', '3', '2'])).to.eql([
13+
{ op: 'move', from: '/2', path: '/1' },
14+
]);
15+
});
16+
it('should return a single move operation when given two arrays with one value moved and different length', () => {
17+
expect(moveOperations(['1', '2', '3', '4'], ['1', '3', '2'])).to.eql([
18+
{ op: 'move', from: '/2', path: '/1' },
19+
]);
20+
});
21+
it('should return an empty array when given two arrays with a removed key', () => {
22+
expect(moveOperations(['0', '1', '2'], ['0', '1', '2', '3'])).to.eql([]);
23+
});
24+
it('should return an empty array when given two arrays with aan added key', () => {
25+
expect(moveOperations(['0', '1', '2', '3'], ['0', '1', '2'])).to.eql([]);
26+
});
27+
});
28+
29+
describe('a longestCommonSequence function', () => {
30+
it('should return an empty array when given two empty arrays', () => {
31+
expect(longestCommonSequence([], [])).to.eql({
32+
length: 0,
33+
sequence: [],
34+
offset: null,
35+
});
36+
});
37+
it('should return the full sequence when given two identical arrays', () => {
38+
expect(longestCommonSequence(['1', '2', '3'], ['1', '2', '3'])).to.eql({
39+
length: 3,
40+
sequence: ['1', '2', '3'],
41+
offset: 0,
42+
});
43+
});
44+
it('should return the a sequence at the beginning of an array', () => {
45+
expect(
46+
longestCommonSequence(['1', '2', '3', '4'], ['4', '1', '2', '3'])
47+
).to.eql({
48+
length: 3,
49+
sequence: ['1', '2', '3'],
50+
offset: 0,
51+
});
52+
});
53+
it('should return the a sequence at the end of an array', () => {
54+
expect(
55+
longestCommonSequence(['4', '1', '2', '3'], ['1', '2', '3', '4'])
56+
).to.eql({
57+
length: 3,
58+
sequence: ['1', '2', '3'],
59+
offset: 1,
60+
});
61+
});
62+
63+
it('should return the a sequence at the end of an array', () => {
64+
expect(longestCommonSequence(['0', '1', '2'], ['0', '1', '2', '3'])).to.eql(
65+
{
66+
length: 3,
67+
sequence: ['0', '1', '2'],
68+
offset: 0,
69+
}
70+
);
71+
});
72+
});

src/move-operations.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { MoveOperation } from './index';
2+
3+
export function longestCommonSequence(
4+
leftHashes: string[],
5+
rightHashes: string[]
6+
) {
7+
const m = leftHashes.length;
8+
const n = rightHashes.length;
9+
const dp: number[][] = Array.from({ length: m + 1 }, () =>
10+
Array(n + 1).fill(0)
11+
);
12+
let longestSequence: string[] = [];
13+
let offset: number | null = null;
14+
15+
for (let i = 1; i <= m; i++) {
16+
for (let j = 1; j <= n; j++) {
17+
if (leftHashes[i - 1] === rightHashes[j - 1]) {
18+
dp[i][j] = dp[i - 1][j - 1] + 1;
19+
if (dp[i][j] > longestSequence.length) {
20+
longestSequence = leftHashes.slice(i - dp[i][j], i);
21+
offset = i - dp[i][j];
22+
}
23+
} else {
24+
dp[i][j] = 0;
25+
}
26+
}
27+
}
28+
return {
29+
length: longestSequence.length,
30+
sequence: longestSequence,
31+
offset,
32+
};
33+
}
34+
35+
export function moveOperations(
36+
leftHashes: string[],
37+
rightHashes: string[],
38+
currentPath = ''
39+
): MoveOperation[] {
40+
const { sequence } = longestCommonSequence(leftHashes, rightHashes);
41+
const operations: MoveOperation[] = [];
42+
let workingArr = [...leftHashes];
43+
let lcsIndex = 0;
44+
let targetIndex = 0;
45+
46+
while (targetIndex < rightHashes.length) {
47+
const targetValue = rightHashes[targetIndex];
48+
49+
if (sequence[lcsIndex] === targetValue) {
50+
lcsIndex++;
51+
targetIndex++;
52+
continue;
53+
}
54+
55+
const sourceIndex = workingArr.indexOf(targetValue);
56+
57+
if (sourceIndex !== targetIndex && sourceIndex !== -1) {
58+
operations.push({
59+
op: 'move',
60+
from: `${currentPath}/${sourceIndex}`,
61+
path: `${currentPath}/${targetIndex}`,
62+
});
63+
64+
// Update the working array to reflect the move
65+
const [movedItem] = workingArr.splice(sourceIndex, 1);
66+
workingArr.splice(targetIndex, 0, movedItem);
67+
}
68+
69+
targetIndex++;
70+
}
71+
72+
return operations;
73+
}

0 commit comments

Comments
 (0)