From e384a949905125a18b7b3266f046d3d58ba13ac5 Mon Sep 17 00:00:00 2001 From: Neil VanLandingham Date: Thu, 16 Mar 2023 19:36:29 -0500 Subject: [PATCH] add reverse --- packages/json-patch-plus/src/reverse.spec.ts | 155 ++++++++++ packages/json-patch-plus/src/reverse.ts | 291 +++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 packages/json-patch-plus/src/reverse.spec.ts create mode 100644 packages/json-patch-plus/src/reverse.ts diff --git a/packages/json-patch-plus/src/reverse.spec.ts b/packages/json-patch-plus/src/reverse.spec.ts new file mode 100644 index 00000000..006771ad --- /dev/null +++ b/packages/json-patch-plus/src/reverse.spec.ts @@ -0,0 +1,155 @@ +import { reverse } from "./reverse"; + +it("reverses a primitive delta (replace)", () => { + expect(reverse([1, 2])).toStrictEqual([2, 1]); +}); + +it("reverses a primitive delta (replace and no previous value)", () => { + expect(reverse([null, 2])).toStrictEqual([2, null]); +}); + +it("reverses a nested delta (replace)", () => { + expect(reverse({ a: [1, 2] })).toStrictEqual({ a: [2, 1] }); +}); + +it("reverses a nested delta (replace and no previous value)", () => { + expect(reverse({ a: [null, 2] })).toStrictEqual({ a: [2, null] }); +}); + +it("reverses a nested delta (add)", () => { + expect(reverse({ a: [2] })).toStrictEqual({ a: [2, 0, 0] }); +}); + +it("reverses a nested delta (key removed)", () => { + expect( + reverse({ + b: [2, 0, 0], + }) + ).toStrictEqual({ + b: [2], + }); +}); + +it("reverses a deeply nested delta (replace)", () => { + expect(reverse({ a: { a: [1, 2] } })).toStrictEqual({ a: { a: [2, 1] } }); +}); + +it("reverses a deeply nested delta (replace and no previous value)", () => { + expect(reverse({ a: { a: [null, 2] } })).toStrictEqual({ + a: { a: [2, null] }, + }); +}); + +it("reverses a deeply nested delta (add)", () => { + expect(reverse({ a: { a: [2] } })).toStrictEqual({ a: { a: [2, 0, 0] } }); +}); + +it("reverses a deeply nested delta (remove)", () => { + expect(reverse({ a: { a: [2, 0, 0] } })).toStrictEqual({ a: { a: [2] } }); +}); + +it("reverses a deeply nested delta (replace and no previous value)", () => { + expect(reverse({ a: { a: [null, 2] } })).toStrictEqual({ + a: { a: [2, null] }, + }); +}); + +it("reverses a text added delta", () => { + expect(reverse(["some text"])).toStrictEqual(["some text", 0, 0]); +}); + +it("reverses an array added delta", () => { + expect(reverse([[1, 2, 3]])).toStrictEqual([[1, 2, 3], 0, 0]); +}); + +it("reverses an array delta (simple values)", () => { + expect( + reverse({ + _t: "a", + _1: [2, 0, 0], + _5: [6, 0, 0], + _6: [7, 0, 0], + 6: [9.1], + }) + ).toStrictEqual({ + _t: "a", + 1: [2], + 5: [6], + 6: [7], + _6: [9.1, 0, 0], + }); +}); + +it("reverses array delta (remove first member)", () => { + expect( + reverse({ + // indicates that this is an array operation + _t: "a", + // _ + index of the item + _0: [ + // the item that is modified + 1, + // 0, 0 indicates that the item got deleted + 0, 0, + ], + }) + ).toStrictEqual([2]); +}); + +it("nested changes among array insertions and deletions", () => { + expect( + reverse({ + _t: "a", + 0: [{ id: 3 }], + 2: { + inner: { + property: ["abc", "abcd"], + }, + }, + 3: [{ id: 9 }], + _0: [{ id: 1 }, 0, 0], + _1: [{ id: 2 }, 0, 0], + _3: [{ id: 5 }, 0, 0], + _5: [{ id: 7 }, 0, 0], + _6: [{ id: 8 }, 0, 0], + _7: [{ id: 10 }, 0, 0], + _8: [{ id: 11 }, 0, 0], + _9: [{ id: 12 }, 0, 0], + }) + ).toStrictEqual({ + _t: "a", + 0: [{ id: 1 }], + 1: [{ id: 2 }], + 3: [{ id: 5 }], + 4: { + inner: { + property: ["abcd", "abc"], + }, + }, + 5: [{ id: 7 }], + 6: [{ id: 8 }], + 7: [{ id: 10 }], + 8: [{ id: 11 }], + 9: [{ id: 12 }], + _0: [{ id: 3 }, 0, 0], + _3: [{ id: 9 }, 0, 0], + }); +}); + +it("reverses text delta (larger than min length)", () => { + expect( + reverse(["@@ -1,10 +1,11 @@\n -\n-M\n+P\n adre,%0Acu\n+a\n", 0, 2]) + ).toStrictEqual(["@@ -1,11 +1,10 @@\n -\n-P\n+M\n adre,%0Acu\n-a\n", 0, 2]); +}); + +it("reverses text delta (shorter than min length)", () => { + expect(reverse(["-Madre,\nc", "-Padre,\ncua"])).toStrictEqual([ + "-Padre,\ncua", + "-Madre,\nc", + ]); +}); + +it("returns undefined when reversing undefined", () => { + // @ts-ignore + expect(reverse(undefined)).toStrictEqual(undefined); +}); diff --git a/packages/json-patch-plus/src/reverse.ts b/packages/json-patch-plus/src/reverse.ts new file mode 100644 index 00000000..2f010eee --- /dev/null +++ b/packages/json-patch-plus/src/reverse.ts @@ -0,0 +1,291 @@ +import type { Delta } from "./types.js"; + +type Context = { + delta: Delta; + children?: Array; + result?: Delta; + name?: string | number; + newName?: string | number; + nested?: boolean; + stopped: boolean; +}; + +let TEXT_DIFF = 2; +let DEFAULT_MIN_LENGTH = 60; +let cachedDiffPatch = null; + +const { isArray } = Array; +const ARRAY_MOVE = 3; + +export function reverse(delta: Delta): Delta { + const context: Context = { + delta, + children: undefined, + name: undefined, + nested: false, + stopped: false, + }; + + function process(context: Context) { + const steps = [ + nested_collectChildrenReverseFilter, + array_collectChildrenReverseFilter, + trivial_reverseFilter, + text_reverseFilter, + nested_reverseFilter, + array_reverseFilter, + ]; + + for (const step of steps) { + step(context); + if (context.stopped) { + context.stopped = false; + break; + } + } + + if (context.children) { + for (const childrenContext of context.children) { + process(childrenContext); + context.result = context.result ?? context.delta; + if ("result" in childrenContext === false) { + delete (context.result as object)[childrenContext.name!]; + } else { + (context.result as object)[childrenContext.name!] = + childrenContext.result; + } + } + } + } + + process(context); + + return context.result as Delta; +} + +function array_reverseFilter(context: Context) { + if (!context.nested) { + if (context.delta[2] === ARRAY_MOVE && context.name) { + context.newName = `_${context.delta[1]}`; + const name = context.name as string; + context.result = [ + context.delta[0], + parseInt(name.substr(1), 10), + ARRAY_MOVE, + ]; + context.stopped = true; + } + return; + } + if (context.delta._t !== "a") { + return; + } + + for (const name in context.delta) { + if (name === "_t") { + continue; + } + if (context.children === undefined) { + context.children = []; + } + context.children.push({ + delta: context.delta[name], + name, + stopped: false, + }); + } + context.stopped = true; +} + +let array_reverseDeltaIndex = ( + delta: Delta, + index: string | number, + itemDelta: Delta +) => { + if (typeof index === "string" && index[0] === "_") { + return parseInt(index.substr(1), 10); + } else if (isArray(itemDelta) && itemDelta[2] === 0) { + return `_${index}`; + } + + let reverseIndex = +index; + for (let deltaIndex in delta) { + let deltaItem = delta[deltaIndex]; + if (isArray(deltaItem)) { + if (deltaItem[2] === ARRAY_MOVE) { + let moveFromIndex = parseInt(deltaIndex.substr(1), 10); + let moveToIndex = deltaItem[1]; + if (moveToIndex === +index) { + return moveFromIndex; + } + if (moveFromIndex <= reverseIndex && moveToIndex > reverseIndex) { + reverseIndex++; + } else if ( + moveFromIndex >= reverseIndex && + moveToIndex < reverseIndex + ) { + reverseIndex--; + } + } else if (deltaItem[2] === 0) { + let deleteIndex = parseInt(deltaIndex.substr(1), 10); + if (deleteIndex <= reverseIndex) { + reverseIndex++; + } + } else if (deltaItem.length === 1 && Number(deltaIndex) <= reverseIndex) { + reverseIndex--; + } + } + } + + return reverseIndex; +}; + +export function array_collectChildrenReverseFilter(context: Context) { + if (!context?.children) { + return; + } + if (context.delta._t !== "a") { + return; + } + let length = context.children.length; + let child; + let delta = { + _t: "a", + }; + + for (let index = 0; index < length; index++) { + child = context.children[index] as Context; + let name = child.newName; + if (typeof name === "undefined" && child.name && child.result) { + name = array_reverseDeltaIndex( + context.delta, + child.name, + child.result + ) as string | number; + // @ts-ignore + } else if (delta[name] !== child.result) { + // @ts-ignore + delta[name] = child.result; + } + } + context.result = delta; + context.stopped = true; +} + +export function nested_collectChildrenReverseFilter(context: Context) { + if (!context?.children) { + return; + } + if (context.delta._t) { + return; + } + let length = context.children.length; + let child; + let delta = {}; + for (let index = 0; index < length; index++) { + child = context.children[index]; + // @ts-ignore + if (delta[child.name] !== child.result) { + // @ts-ignore + delta[child.name] = child.result; + } + } + context.result = delta; + context.stopped = true; +} + +export function nested_reverseFilter(context: Context) { + if (!context.nested) { + return; + } + if (context.delta._t) { + return; + } + + let child; + for (const name in context.delta) { + if (context.children === undefined) { + context.children = []; + } + context.children.push({ + delta: context.delta[name], + name, + stopped: false, + }); + } + context.stopped = true; +} + +export function trivial_reverseFilter(context: Context) { + if (typeof context.delta === "undefined") { + context.result = context.delta; + context.stopped = true; + return; + } + context.nested = !isArray(context.delta); + if (context.nested) { + return; + } + if (context.delta.length === 1) { + context.result = [context.delta[0], 0, 0]; + context.stopped = true; + return; + } + if (context.delta.length === 2) { + context.result = [context.delta[1], context.delta[0]]; + context.stopped = true; + return; + } + if (context.delta.length === 3 && context.delta[2] === 0) { + context.result = context.delta[0]; + } +} + +export function text_reverseFilter(context: Context) { + if (context.nested) { + return; + } + if (context.delta[2] !== TEXT_DIFF) { + return; + } + + // text-diff, use a text-diff algorithm + context.result = [textDeltaReverse(context.delta[0]), 0, TEXT_DIFF]; + context.stopped = true; +} + +const textDeltaReverse = function (delta: string) { + let i; + let l; + let lines; + let line; + let lineTmp; + let header = null; + const headerRegex = /^@@ +-(\d+),(\d+) +\+(\d+),(\d+) +@@$/; + let lineHeader; + lines = delta.split("\n"); + for (i = 0, l = lines.length; i < l; i++) { + line = lines[i]; + let lineStart = line.slice(0, 1); + if (lineStart === "@") { + header = headerRegex.exec(line) as string[]; + lineHeader = i; + + // fix header + lines[ + lineHeader + ] = `@@ -${header[3]},${header[4]} +${header[1]},${header[2]} @@`; + } else if (lineStart === "+") { + lines[i] = `-${lines[i].slice(1)}`; + if (lines[i - 1].slice(0, 1) === "+") { + // swap lines to keep default order (-+) + lineTmp = lines[i]; + lines[i] = lines[i - 1]; + lines[i - 1] = lineTmp; + } + } else if (lineStart === "-") { + lines[i] = `+${lines[i].slice(1)}`; + } + } + return lines.join("\n"); +};