From ffec1e7591c8d79f2d1ae0a663b291c76c7b3366 Mon Sep 17 00:00:00 2001 From: lukascivil Date: Sun, 23 Jul 2023 00:18:54 -0300 Subject: [PATCH 1/9] feat: add json patch generator --- .../src/core/generate-json-patch.spec.ts | 59 +++++++++++++++++++ .../src/core/generate-json-patch.ts | 31 ++++++++++ .../src/models/jsondiffer.model.ts | 19 ++++++ 3 files changed, 109 insertions(+) create mode 100644 libs/json-difference/src/core/generate-json-patch.spec.ts create mode 100644 libs/json-difference/src/core/generate-json-patch.ts diff --git a/libs/json-difference/src/core/generate-json-patch.spec.ts b/libs/json-difference/src/core/generate-json-patch.spec.ts new file mode 100644 index 0000000..51ee43d --- /dev/null +++ b/libs/json-difference/src/core/generate-json-patch.spec.ts @@ -0,0 +1,59 @@ +// Packages +import { getDiff } from '.' + +// Models +import { JsonPatch } from '../models/jsondiffer.model' +import { generateJsonPatch } from './generate-json-patch' + +describe('GenerateJsonPatch function', () => { + test('Should return the difference between two basic structures', () => { + const struct1 = { '0': [{ '0': 1 }] } + const struct2 = { '0': { '0': [1] } } + const expectedResult: Array = [ + { op: 'remove', path: '0/0' }, + { op: 'remove', path: '0/0/0[]' }, + { op: 'replace', path: '0', value: [] }, + { op: 'add', path: '0/0[]', value: {} }, + { op: 'add', path: '0/0[]/0', value: 1 } + ] + const delta = getDiff(struct1, struct2) + const result = generateJsonPatch(delta) + + expect(result).toEqual(expectedResult) + }) + + test('Should return the difference between two basic structures', () => { + const struct1 = { + baz: 'qux', + foo: 'bar' + } + const struct2 = { + baz: 'boo', + hello: ['world'] + } + const expectedResult: Array = [ + { + op: 'remove', + path: 'hello' + }, + { + op: 'remove', + path: 'hello/0[]' + }, + { + op: 'replace', + path: 'baz', + value: 'qux' + }, + { + op: 'add', + path: 'foo', + value: 'bar' + } + ] + const delta = getDiff(struct1, struct2) + const result = generateJsonPatch(delta) + + expect(result).toEqual(expectedResult) + }) +}) diff --git a/libs/json-difference/src/core/generate-json-patch.ts b/libs/json-difference/src/core/generate-json-patch.ts new file mode 100644 index 0000000..890563f --- /dev/null +++ b/libs/json-difference/src/core/generate-json-patch.ts @@ -0,0 +1,31 @@ +// Models +import { Delta, JsonPatch } from '../models/jsondiffer.model' + +export const generateJsonPatch = (delta: Delta): Array => { + const operations: Array = [] + + delta.added.forEach((path) => { + operations.push({ + op: 'remove', + path: path[0] + }) + }) + + delta.edited.forEach((path) => { + operations.push({ + op: 'replace', + path: path[0], + value: path[1] + }) + }) + + delta.removed.forEach((path) => { + operations.push({ + op: 'add', + path: path[0], + value: path[1] + }) + }) + + return operations +} diff --git a/libs/json-difference/src/models/jsondiffer.model.ts b/libs/json-difference/src/models/jsondiffer.model.ts index 659fa7c..5254c98 100644 --- a/libs/json-difference/src/models/jsondiffer.model.ts +++ b/libs/json-difference/src/models/jsondiffer.model.ts @@ -13,3 +13,22 @@ export interface Delta { export interface JsonDiffOptions { isLodashLike?: boolean } + +export interface JsonPatchRemove { + op: 'remove' + path: string +} + +export interface JsonPatchReplace { + op: 'replace' + path: string + value: any +} + +export interface JsonPatchAdd { + op: 'add' + path: string + value: any +} + +export type JsonPatch = JsonPatchRemove | JsonPatchReplace | JsonPatchAdd From d6c8a83d88fe1fc4db53ac999e9a6b7c242d960b Mon Sep 17 00:00:00 2001 From: lukascivil Date: Sun, 23 Jul 2023 00:50:01 -0300 Subject: [PATCH 2/9] Update generate-json-patch.spec.ts --- libs/json-difference/src/core/generate-json-patch.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/json-difference/src/core/generate-json-patch.spec.ts b/libs/json-difference/src/core/generate-json-patch.spec.ts index 51ee43d..471abe5 100644 --- a/libs/json-difference/src/core/generate-json-patch.spec.ts +++ b/libs/json-difference/src/core/generate-json-patch.spec.ts @@ -6,7 +6,7 @@ import { JsonPatch } from '../models/jsondiffer.model' import { generateJsonPatch } from './generate-json-patch' describe('GenerateJsonPatch function', () => { - test('Should return the difference between two basic structures', () => { + test('Should generate JSON Patch operations from delta', () => { const struct1 = { '0': [{ '0': 1 }] } const struct2 = { '0': { '0': [1] } } const expectedResult: Array = [ @@ -22,7 +22,7 @@ describe('GenerateJsonPatch function', () => { expect(result).toEqual(expectedResult) }) - test('Should return the difference between two basic structures', () => { + test('Should generate JSON Patch operations from delta', () => { const struct1 = { baz: 'qux', foo: 'bar' From 1848b985c73e75d4ae3601170265349169f61216 Mon Sep 17 00:00:00 2001 From: lukascivil Date: Sun, 23 Jul 2023 00:53:47 -0300 Subject: [PATCH 3/9] refactor: adjust path --- .../src/core/generate-json-patch.spec.ts | 2 +- .../src/core/generate-json-patch.ts | 2 +- .../json-difference/src/core/get-diff.spec.ts | 2 +- libs/json-difference/src/core/get-diff.ts | 2 +- .../src/core/get-edited-paths.spec.ts | 2 +- .../src/core/get-paths-diff.spec.ts | 2 +- .../src/core/get-struct-paths.ts | 2 +- libs/json-difference/src/models/index.ts | 2 +- .../src/models/json-difference.model.ts | 34 +++++++++++++++++++ 9 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 libs/json-difference/src/models/json-difference.model.ts diff --git a/libs/json-difference/src/core/generate-json-patch.spec.ts b/libs/json-difference/src/core/generate-json-patch.spec.ts index 471abe5..fc5cd61 100644 --- a/libs/json-difference/src/core/generate-json-patch.spec.ts +++ b/libs/json-difference/src/core/generate-json-patch.spec.ts @@ -2,7 +2,7 @@ import { getDiff } from '.' // Models -import { JsonPatch } from '../models/jsondiffer.model' +import { JsonPatch } from '../models/json-difference.model' import { generateJsonPatch } from './generate-json-patch' describe('GenerateJsonPatch function', () => { diff --git a/libs/json-difference/src/core/generate-json-patch.ts b/libs/json-difference/src/core/generate-json-patch.ts index 890563f..3412aa8 100644 --- a/libs/json-difference/src/core/generate-json-patch.ts +++ b/libs/json-difference/src/core/generate-json-patch.ts @@ -1,5 +1,5 @@ // Models -import { Delta, JsonPatch } from '../models/jsondiffer.model' +import { Delta, JsonPatch } from '../models/json-difference.model' export const generateJsonPatch = (delta: Delta): Array => { const operations: Array = [] diff --git a/libs/json-difference/src/core/get-diff.spec.ts b/libs/json-difference/src/core/get-diff.spec.ts index 942cc08..6ce14ad 100644 --- a/libs/json-difference/src/core/get-diff.spec.ts +++ b/libs/json-difference/src/core/get-diff.spec.ts @@ -4,7 +4,7 @@ import { join } from 'path' import { getDiff } from '.' // Models -import { Delta } from '../models/jsondiffer.model' +import { Delta } from '../models/json-difference.model' const FIXTURE_DIR = join(__dirname, '..', '..', 'fixture') const loadFixture = (name: string): any => JSON.parse(readFileSync(join(FIXTURE_DIR, name), 'utf8')) diff --git a/libs/json-difference/src/core/get-diff.ts b/libs/json-difference/src/core/get-diff.ts index 9b223d7..8c7a542 100644 --- a/libs/json-difference/src/core/get-diff.ts +++ b/libs/json-difference/src/core/get-diff.ts @@ -4,8 +4,8 @@ import { getPathsDiff } from './get-paths-diff' import { getStructPaths } from './get-struct-paths' // Models -import { Delta, JsonDiffOptions } from '../models/jsondiffer.model' import sanitizeDelta from '../helpers/sanitize-delta' +import { Delta, JsonDiffOptions } from '../models/json-difference.model' const defaultOptions: JsonDiffOptions = { isLodashLike: false diff --git a/libs/json-difference/src/core/get-edited-paths.spec.ts b/libs/json-difference/src/core/get-edited-paths.spec.ts index 33b8473..1c6556a 100644 --- a/libs/json-difference/src/core/get-edited-paths.spec.ts +++ b/libs/json-difference/src/core/get-edited-paths.spec.ts @@ -1,5 +1,5 @@ import { getEditedPaths } from '.' -import { EditedPath } from '../models/jsondiffer.model' +import { EditedPath } from '../models/json-difference.model' describe('GetEditedPaths function', () => { test('Should return empty when there is no edited value', () => { diff --git a/libs/json-difference/src/core/get-paths-diff.spec.ts b/libs/json-difference/src/core/get-paths-diff.spec.ts index 821d8d9..63a7a12 100644 --- a/libs/json-difference/src/core/get-paths-diff.spec.ts +++ b/libs/json-difference/src/core/get-paths-diff.spec.ts @@ -1,6 +1,6 @@ // Packages import { getPathsDiff } from '.' -import { PathsDiff } from '../models/jsondiffer.model' +import { PathsDiff } from '../models/json-difference.model' describe('GetPathsDiff function', () => { test('Should return empty when there is no key difference', () => { diff --git a/libs/json-difference/src/core/get-struct-paths.ts b/libs/json-difference/src/core/get-struct-paths.ts index d64786d..a896348 100644 --- a/libs/json-difference/src/core/get-struct-paths.ts +++ b/libs/json-difference/src/core/get-struct-paths.ts @@ -1,5 +1,5 @@ // Models -import { StructPaths } from '../models/jsondiffer.model' +import { StructPaths } from '../models/json-difference.model' const generatePath = (isArray: boolean, currentPath: string, newPath: string, lodashLike: boolean): string => { const prefix = lodashLike ? (isArray ? '[' : '.') : '/' diff --git a/libs/json-difference/src/models/index.ts b/libs/json-difference/src/models/index.ts index 62d3b9a..f8f1f66 100644 --- a/libs/json-difference/src/models/index.ts +++ b/libs/json-difference/src/models/index.ts @@ -1 +1 @@ -export * from './jsondiffer.model' +export * from './json-difference.model' diff --git a/libs/json-difference/src/models/json-difference.model.ts b/libs/json-difference/src/models/json-difference.model.ts new file mode 100644 index 0000000..5254c98 --- /dev/null +++ b/libs/json-difference/src/models/json-difference.model.ts @@ -0,0 +1,34 @@ +export type EditedPath = [string, any, any] + +export type StructPaths = Record + +export type PathsDiff = [string, any] + +export interface Delta { + added: Array + removed: Array + edited: Array +} + +export interface JsonDiffOptions { + isLodashLike?: boolean +} + +export interface JsonPatchRemove { + op: 'remove' + path: string +} + +export interface JsonPatchReplace { + op: 'replace' + path: string + value: any +} + +export interface JsonPatchAdd { + op: 'add' + path: string + value: any +} + +export type JsonPatch = JsonPatchRemove | JsonPatchReplace | JsonPatchAdd From 7b4c26406030f3797f08a5ec54b36ed253273745 Mon Sep 17 00:00:00 2001 From: lukascivil Date: Sat, 30 Dec 2023 12:23:26 -0300 Subject: [PATCH 4/9] Delete jsondiffer.model.ts --- .../src/models/jsondiffer.model.ts | 34 ------------------- 1 file changed, 34 deletions(-) delete mode 100644 libs/json-difference/src/models/jsondiffer.model.ts diff --git a/libs/json-difference/src/models/jsondiffer.model.ts b/libs/json-difference/src/models/jsondiffer.model.ts deleted file mode 100644 index 5254c98..0000000 --- a/libs/json-difference/src/models/jsondiffer.model.ts +++ /dev/null @@ -1,34 +0,0 @@ -export type EditedPath = [string, any, any] - -export type StructPaths = Record - -export type PathsDiff = [string, any] - -export interface Delta { - added: Array - removed: Array - edited: Array -} - -export interface JsonDiffOptions { - isLodashLike?: boolean -} - -export interface JsonPatchRemove { - op: 'remove' - path: string -} - -export interface JsonPatchReplace { - op: 'replace' - path: string - value: any -} - -export interface JsonPatchAdd { - op: 'add' - path: string - value: any -} - -export type JsonPatch = JsonPatchRemove | JsonPatchReplace | JsonPatchAdd From cc96178b2c0e5d2fee426a7677f21bcfe8820f73 Mon Sep 17 00:00:00 2001 From: lukascivil Date: Sat, 30 Dec 2023 12:26:10 -0300 Subject: [PATCH 5/9] Update sanitize-delta.spec.ts --- libs/json-difference/src/helpers/sanitize-delta.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/json-difference/src/helpers/sanitize-delta.spec.ts b/libs/json-difference/src/helpers/sanitize-delta.spec.ts index 630ddaf..a5cd9da 100644 --- a/libs/json-difference/src/helpers/sanitize-delta.spec.ts +++ b/libs/json-difference/src/helpers/sanitize-delta.spec.ts @@ -2,7 +2,7 @@ import sanitizeDelta from './sanitize-delta' // Models -import { Delta } from '../models/jsondiffer.model' +import { Delta } from '../models/json-difference.model' describe('sanitizeDelta helper', () => { test.only('Should remove unnecessary @{}', () => { From adf08b6ba2b05dbeca4b7e254d977e99d2a7c482 Mon Sep 17 00:00:00 2001 From: lukascivil Date: Thu, 23 Apr 2026 02:21:27 -0300 Subject: [PATCH 6/9] feat: implement rfc --- .../src/core/generate-json-patch.spec.ts | 65 ++++++++++++------- .../src/core/generate-json-patch.ts | 57 +++++++++------- libs/json-difference/src/core/index.ts | 1 + libs/json-difference/src/helpers/index.ts | 1 + .../src/helpers/to-json-pointer.spec.ts | 33 ++++++++++ .../src/helpers/to-json-pointer.ts | 27 ++++++++ 6 files changed, 135 insertions(+), 49 deletions(-) create mode 100644 libs/json-difference/src/helpers/to-json-pointer.spec.ts create mode 100644 libs/json-difference/src/helpers/to-json-pointer.ts diff --git a/libs/json-difference/src/core/generate-json-patch.spec.ts b/libs/json-difference/src/core/generate-json-patch.spec.ts index fc5cd61..95d294f 100644 --- a/libs/json-difference/src/core/generate-json-patch.spec.ts +++ b/libs/json-difference/src/core/generate-json-patch.spec.ts @@ -6,15 +6,15 @@ import { JsonPatch } from '../models/json-difference.model' import { generateJsonPatch } from './generate-json-patch' describe('GenerateJsonPatch function', () => { - test('Should generate JSON Patch operations from delta', () => { + test('Should generate JSON Patch operations with nested array/object swap', () => { const struct1 = { '0': [{ '0': 1 }] } const struct2 = { '0': { '0': [1] } } const expectedResult: Array = [ - { op: 'remove', path: '0/0' }, - { op: 'remove', path: '0/0/0[]' }, - { op: 'replace', path: '0', value: [] }, - { op: 'add', path: '0/0[]', value: {} }, - { op: 'add', path: '0/0[]/0', value: 1 } + { op: 'remove', path: '/0/0/0' }, + { op: 'remove', path: '/0/0' }, + { op: 'replace', path: '/0', value: {} }, + { op: 'add', path: '/0/0', value: [] }, + { op: 'add', path: '/0/0/0', value: 1 } ] const delta = getDiff(struct1, struct2) const result = generateJsonPatch(delta) @@ -22,7 +22,7 @@ describe('GenerateJsonPatch function', () => { expect(result).toEqual(expectedResult) }) - test('Should generate JSON Patch operations from delta', () => { + test('Should generate JSON Patch operations with flat add/remove/replace', () => { const struct1 = { baz: 'qux', foo: 'bar' @@ -32,24 +32,39 @@ describe('GenerateJsonPatch function', () => { hello: ['world'] } const expectedResult: Array = [ - { - op: 'remove', - path: 'hello' - }, - { - op: 'remove', - path: 'hello/0[]' - }, - { - op: 'replace', - path: 'baz', - value: 'qux' - }, - { - op: 'add', - path: 'foo', - value: 'bar' - } + { op: 'remove', path: '/foo' }, + { op: 'replace', path: '/baz', value: 'boo' }, + { op: 'add', path: '/hello', value: [] }, + { op: 'add', path: '/hello/0', value: 'world' } + ] + const delta = getDiff(struct1, struct2) + const result = generateJsonPatch(delta) + + expect(result).toEqual(expectedResult) + }) + + test('Should emit a replace at the root pointer when the top-level type changes', () => { + const struct1 = [] as any + const struct2 = {} + const expectedResult: Array = [{ op: 'replace', path: '', value: {} }] + const delta = getDiff(struct1, struct2) + const result = generateJsonPatch(delta) + + expect(result).toEqual(expectedResult) + }) + + test('Should return an empty array when the delta is empty', () => { + const result = generateJsonPatch({ added: [], removed: [], edited: [] }) + + expect(result).toEqual([]) + }) + + test('Should remove the deepest paths first', () => { + const struct1 = { a: { b: { c: 1 } } } + const struct2 = { a: {} } + const expectedResult: Array = [ + { op: 'remove', path: '/a/b/c' }, + { op: 'remove', path: '/a/b' } ] const delta = getDiff(struct1, struct2) const result = generateJsonPatch(delta) diff --git a/libs/json-difference/src/core/generate-json-patch.ts b/libs/json-difference/src/core/generate-json-patch.ts index 3412aa8..c7bd748 100644 --- a/libs/json-difference/src/core/generate-json-patch.ts +++ b/libs/json-difference/src/core/generate-json-patch.ts @@ -1,31 +1,40 @@ // Models -import { Delta, JsonPatch } from '../models/json-difference.model' +import { Delta, JsonPatch, JsonPatchAdd, JsonPatchRemove, JsonPatchReplace } from '../models/json-difference.model' -export const generateJsonPatch = (delta: Delta): Array => { - const operations: Array = [] +// Helpers +import { toJsonPointer } from '../helpers/to-json-pointer' + +const byDepthDesc = (a: JsonPatchRemove, b: JsonPatchRemove): number => b.path.split('/').length - a.path.split('/').length - delta.added.forEach((path) => { - operations.push({ - op: 'remove', - path: path[0] - }) - }) +/** + * Build an RFC 6902 JSON Patch that describes the transformation from the + * original structure to the modified one, given a Delta produced by getDiff. + * + * Only `add`, `remove` and `replace` operations are emitted. Paths are + * converted from the internal path format to RFC 6901 JSON Pointers. + * + * Operation ordering is chosen so that the patch can be applied sequentially + * against the original document: + * 1. `remove` ops, deepest path first (so a parent is not removed before its children) + * 2. `replace` ops in delta order + * 3. `add` ops in delta order (DFS already places parents before children) + */ +export const generateJsonPatch = (delta: Delta): Array => { + const removeOps: Array = delta.removed + .map(([path]) => ({ op: 'remove' as const, path: toJsonPointer(path) })) + .sort(byDepthDesc) - delta.edited.forEach((path) => { - operations.push({ - op: 'replace', - path: path[0], - value: path[1] - }) - }) + const replaceOps: Array = delta.edited.map(([path, , newValue]) => ({ + op: 'replace' as const, + path: toJsonPointer(path), + value: newValue + })) - delta.removed.forEach((path) => { - operations.push({ - op: 'add', - path: path[0], - value: path[1] - }) - }) + const addOps: Array = delta.added.map(([path, value]) => ({ + op: 'add' as const, + path: toJsonPointer(path), + value + })) - return operations + return [...removeOps, ...replaceOps, ...addOps] } diff --git a/libs/json-difference/src/core/index.ts b/libs/json-difference/src/core/index.ts index 0617ebe..c758e50 100644 --- a/libs/json-difference/src/core/index.ts +++ b/libs/json-difference/src/core/index.ts @@ -2,3 +2,4 @@ export * from './get-diff' export * from './get-paths-diff' export * from './get-struct-paths' export * from './get-edited-paths' +export * from './generate-json-patch' diff --git a/libs/json-difference/src/helpers/index.ts b/libs/json-difference/src/helpers/index.ts index 7a2268e..2553d93 100644 --- a/libs/json-difference/src/helpers/index.ts +++ b/libs/json-difference/src/helpers/index.ts @@ -1,2 +1,3 @@ export * from './sanitize-delta' export * from './unwrap-sentinel' +export * from './to-json-pointer' diff --git a/libs/json-difference/src/helpers/to-json-pointer.spec.ts b/libs/json-difference/src/helpers/to-json-pointer.spec.ts new file mode 100644 index 0000000..693e437 --- /dev/null +++ b/libs/json-difference/src/helpers/to-json-pointer.spec.ts @@ -0,0 +1,33 @@ +// Packages +import { toJsonPointer } from './to-json-pointer' + +describe('toJsonPointer helper', () => { + test('Should return an empty string for the root sentinel', () => { + expect(toJsonPointer('__root__')).toBe('') + }) + + test('Should convert a simple key', () => { + expect(toJsonPointer('foo')).toBe('/foo') + }) + + test('Should convert a nested path', () => { + expect(toJsonPointer('foo/bar/baz')).toBe('/foo/bar/baz') + }) + + test('Should strip the array index marker', () => { + expect(toJsonPointer('0[]')).toBe('/0') + expect(toJsonPointer('foo/0[]')).toBe('/foo/0') + expect(toJsonPointer('foo/0[]/bar/1[]')).toBe('/foo/0/bar/1') + }) + + test('Should escape "~" in keys per RFC 6901', () => { + expect(toJsonPointer('a~b')).toBe('/a~0b') + expect(toJsonPointer('foo/a~b')).toBe('/foo/a~0b') + }) + + test('Should preserve empty keys', () => { + expect(toJsonPointer('')).toBe('/') + expect(toJsonPointer('/')).toBe('//') + expect(toJsonPointer('/a/')).toBe('//a/') + }) +}) diff --git a/libs/json-difference/src/helpers/to-json-pointer.ts b/libs/json-difference/src/helpers/to-json-pointer.ts new file mode 100644 index 0000000..ef1f1c4 --- /dev/null +++ b/libs/json-difference/src/helpers/to-json-pointer.ts @@ -0,0 +1,27 @@ +const ROOT_SENTINEL = '__root__' +const ARRAY_INDEX_MARKER = '[]' + +/** + * Convert a json-difference internal path to an RFC 6901 JSON Pointer. + * + * Internal format: + * - `/` as segment separator, no leading `/` + * - Array indices suffixed with `[]` (e.g. `0[]`, `foo/1[]`) + * - `__root__` refers to the whole document + * + * JSON Pointer format (RFC 6901): + * - Must start with `/` (except the empty string, which refers to the whole document) + * - `~` escaped as `~0`, `/` in a key escaped as `~1` + */ +export const toJsonPointer = (libPath: string): string => { + if (libPath === ROOT_SENTINEL) { + return '' + } + + const tokens = libPath.split('/').map((token) => { + const stripped = token.endsWith(ARRAY_INDEX_MARKER) ? token.slice(0, -ARRAY_INDEX_MARKER.length) : token + return stripped.replace(/~/g, '~0') + }) + + return '/' + tokens.join('/') +} From 2638a42a643a1d8da8926aba996106f26c9e88e3 Mon Sep 17 00:00:00 2001 From: lukascivil Date: Thu, 23 Apr 2026 02:36:33 -0300 Subject: [PATCH 7/9] feat: implement rfc --- libs/json-difference/fixture/jsonPatch.json | 615 ++++++++++++++++++ .../src/core/generate-json-patch.spec.ts | 24 + 2 files changed, 639 insertions(+) create mode 100644 libs/json-difference/fixture/jsonPatch.json diff --git a/libs/json-difference/fixture/jsonPatch.json b/libs/json-difference/fixture/jsonPatch.json new file mode 100644 index 0000000..3e06ad7 --- /dev/null +++ b/libs/json-difference/fixture/jsonPatch.json @@ -0,0 +1,615 @@ +[ + { + "op": "remove", + "path": "/contatos_emergencia/0/telefones/1" + }, + { + "op": "remove", + "path": "/produtos/1/atributos/congelado" + }, + { + "op": "remove", + "path": "/palavras_especiais/idiomas/3" + }, + { + "op": "remove", + "path": "/palavras_especiais/idiomas/4" + }, + { + "op": "remove", + "path": "/documentos/passaporte/numero" + }, + { + "op": "remove", + "path": "/documentos/passaporte/validade" + }, + { + "op": "remove", + "path": "/preferencias/categorias/3" + }, + { + "op": "remove", + "path": "/preferencias/cores/2" + }, + { + "op": "remove", + "path": "/preferencias/newsletter_ids/2" + }, + { + "op": "remove", + "path": "/documentos/passaporte" + }, + { + "op": "remove", + "path": "/pontuacoes/4" + }, + { + "op": "remove", + "path": "/pontuacoes/5" + }, + { + "op": "replace", + "path": "/uuid", + "value": "550e8400-e29b-41d4-a716-446655440001" + }, + { + "op": "replace", + "path": "/bloqueado", + "value": true + }, + { + "op": "replace", + "path": "/nome", + "value": "João da Silva Santos" + }, + { + "op": "replace", + "path": "/nome_social", + "value": "" + }, + { + "op": "replace", + "path": "/apelido", + "value": "João" + }, + { + "op": "replace", + "path": "/descricao", + "value": "Usuário com acentuação: á, à, ã, â, é, ê, í, ó, ô, õ, ú, ü, ç e caracteres extras." + }, + { + "op": "replace", + "path": "/observacao", + "value": "Linha 1\nLinha 2 alterada\nLinha 3" + }, + { + "op": "replace", + "path": "/citacao", + "value": "Ele disse: \"ação concluída com sucesso\"." + }, + { + "op": "replace", + "path": "/caminho", + "value": "/arquivos/joao/dados.json" + }, + { + "op": "replace", + "path": "/url", + "value": "https://exemplo.com.br/perfil/joao?origem=acao&status=valido" + }, + { + "op": "replace", + "path": "/email", + "value": "joao.santos@example.com" + }, + { + "op": "replace", + "path": "/telefone", + "value": "+55-11-97777-6655" + }, + { + "op": "replace", + "path": "/idade", + "value": 35 + }, + { + "op": "replace", + "path": "/peso", + "value": 81.9 + }, + { + "op": "replace", + "path": "/saldo", + "value": 9800.15 + }, + { + "op": "replace", + "path": "/percentual_conclusao", + "value": 87.4 + }, + { + "op": "replace", + "path": "/numero_negativo", + "value": -40 + }, + { + "op": "replace", + "path": "/unicode", + "value": "São Tomé e Príncipe | mañana | garçon | coração | infância | pinguim | 🚀" + }, + { + "op": "replace", + "path": "/palavras_especiais/com_til", + "value": "não, maçã, órfão, limão" + }, + { + "op": "replace", + "path": "/palavras_especiais/idiomas/2", + "value": "italiano" + }, + { + "op": "replace", + "path": "/datas_serializadas/iso", + "value": "2026-04-24T08:00:00.000Z" + }, + { + "op": "replace", + "path": "/datas_serializadas/br", + "value": "24/04/2026 05:00:00" + }, + { + "op": "replace", + "path": "/datas_serializadas/somente_data", + "value": "2026-04-24" + }, + { + "op": "replace", + "path": "/configuracoes/tema", + "value": "claro" + }, + { + "op": "replace", + "path": "/configuracoes/notificacoes/sms", + "value": true + }, + { + "op": "replace", + "path": "/configuracoes/notificacoes/push", + "value": false + }, + { + "op": "replace", + "path": "/configuracoes/notificacoes/frequencia", + "value": "semanal" + }, + { + "op": "replace", + "path": "/configuracoes/privacidade/perfil_publico", + "value": true + }, + { + "op": "replace", + "path": "/configuracoes/privacidade/mostrar_email", + "value": true + }, + { + "op": "replace", + "path": "/configuracoes/privacidade/mostrar_telefone", + "value": false + }, + { + "op": "replace", + "path": "/enderecos/0/logradouro", + "value": "Rua das Acácias" + }, + { + "op": "replace", + "path": "/enderecos/0/complemento", + "value": "Casa 2" + }, + { + "op": "replace", + "path": "/enderecos/0/referencia", + "value": "Em frente à praça" + }, + { + "op": "replace", + "path": "/enderecos/1/tipo", + "value": "entrega" + }, + { + "op": "replace", + "path": "/enderecos/1/logradouro", + "value": "Rua do Comércio" + }, + { + "op": "replace", + "path": "/enderecos/1/numero", + "value": "500" + }, + { + "op": "replace", + "path": "/enderecos/1/bairro", + "value": "Centro" + }, + { + "op": "replace", + "path": "/enderecos/1/cidade", + "value": "Santos" + }, + { + "op": "replace", + "path": "/enderecos/1/cep", + "value": "11010-001" + }, + { + "op": "replace", + "path": "/enderecos/1/referencia", + "value": null + }, + { + "op": "replace", + "path": "/contatos_emergencia/1/nome", + "value": "Carlos" + }, + { + "op": "replace", + "path": "/contatos_emergencia/1/parentesco", + "value": "amigo" + }, + { + "op": "replace", + "path": "/contatos_emergencia/1/telefones/0", + "value": "+55-31-96666-5555" + }, + { + "op": "replace", + "path": "/tags/1", + "value": "vip" + }, + { + "op": "replace", + "path": "/tags/2", + "value": "acao" + }, + { + "op": "replace", + "path": "/tags/3", + "value": "Rio de Janeiro" + }, + { + "op": "replace", + "path": "/pontuacoes/2", + "value": 8.25 + }, + { + "op": "replace", + "path": "/pontuacoes/3", + "value": 0 + }, + { + "op": "replace", + "path": "/preferencias/categorias/1", + "value": "cinema" + }, + { + "op": "replace", + "path": "/preferencias/categorias/2", + "value": "viagens" + }, + { + "op": "replace", + "path": "/preferencias/cores/1", + "value": "preto" + }, + { + "op": "replace", + "path": "/preferencias/newsletter_ids/1", + "value": 300 + }, + { + "op": "replace", + "path": "/historico_acessos/0/dispositivo/sistema", + "value": "Windows" + }, + { + "op": "replace", + "path": "/historico_acessos/0/dispositivo/navegador", + "value": "Edge" + }, + { + "op": "replace", + "path": "/historico_acessos/1/data", + "value": "2026-04-22T21:10:10Z" + }, + { + "op": "replace", + "path": "/historico_acessos/1/ip", + "value": "172.16.1.20" + }, + { + "op": "replace", + "path": "/historico_acessos/1/dispositivo/tipo", + "value": "tablet" + }, + { + "op": "replace", + "path": "/historico_acessos/1/dispositivo/sistema", + "value": "iPadOS" + }, + { + "op": "replace", + "path": "/historico_acessos/1/dispositivo/navegador", + "value": "Safari" + }, + { + "op": "replace", + "path": "/historico_acessos/1/localizacao/cidade", + "value": "Rio de Janeiro" + }, + { + "op": "replace", + "path": "/historico_acessos/1/localizacao/coords/lat", + "value": -22.906847 + }, + { + "op": "replace", + "path": "/historico_acessos/1/localizacao/coords/lng", + "value": -43.172896 + }, + { + "op": "replace", + "path": "/produtos/0/nome", + "value": "Café Torrado e Moído Premium" + }, + { + "op": "replace", + "path": "/produtos/0/preco", + "value": 21.9 + }, + { + "op": "replace", + "path": "/produtos/0/estoque", + "value": 95 + }, + { + "op": "replace", + "path": "/produtos/0/atributos/origem", + "value": "Sul de Minas" + }, + { + "op": "replace", + "path": "/produtos/0/atributos/organico", + "value": false + }, + { + "op": "replace", + "path": "/produtos/0/variacoes/1/nome", + "value": "extra forte" + }, + { + "op": "replace", + "path": "/produtos/0/variacoes/1/ativo", + "value": true + }, + { + "op": "replace", + "path": "/produtos/1/sku", + "value": "SKU-003" + }, + { + "op": "replace", + "path": "/produtos/1/nome", + "value": "Bolo de Cenoura" + }, + { + "op": "replace", + "path": "/produtos/1/preco", + "value": 32 + }, + { + "op": "replace", + "path": "/produtos/1/estoque", + "value": 15 + }, + { + "op": "replace", + "path": "/produtos/1/atributos/peso_gramas", + "value": 750 + }, + { + "op": "replace", + "path": "/produtos/1/variacoes", + "value": [] + }, + { + "op": "replace", + "path": "/matriz_mista/0/2", + "value": 4 + }, + { + "op": "replace", + "path": "/matriz_mista/1/2", + "value": "tres" + }, + { + "op": "replace", + "path": "/matriz_mista/2/1", + "value": null + }, + { + "op": "replace", + "path": "/matriz_mista/2/2", + "value": false + }, + { + "op": "replace", + "path": "/matriz_mista/3/0/chave", + "value": "valor alterado" + }, + { + "op": "replace", + "path": "/matriz_mista/3/1/outra", + "value": 3 + }, + { + "op": "replace", + "path": "/chaves_especiais/nome completo", + "value": "João da Silva Santos" + }, + { + "op": "replace", + "path": "/chaves_especiais/endereço-principal", + "value": "Rua das Acácias, 123" + }, + { + "op": "replace", + "path": "/chaves_especiais/123campo", + "value": "valor alterado na chave" + }, + { + "op": "replace", + "path": "/chaves_especiais/@metadata/versão", + "value": "1.1.0" + }, + { + "op": "replace", + "path": "/chaves_especiais/@metadata/origem", + "value": "novo-sistema" + }, + { + "op": "replace", + "path": "/workflow/etapas/1/status", + "value": "concluído" + }, + { + "op": "replace", + "path": "/workflow/responsaveis/primario/nome", + "value": "Ana Claudia" + }, + { + "op": "replace", + "path": "/workflow/responsaveis/secundario/id", + "value": 503 + }, + { + "op": "replace", + "path": "/workflow/responsaveis/secundario/nome", + "value": "Marcos" + }, + { + "op": "replace", + "path": "/limites/saque/diario", + "value": 2000 + }, + { + "op": "replace", + "path": "/limites/saque/mensal", + "value": 12000 + }, + { + "op": "replace", + "path": "/limites/transferencia/diario", + "value": 4500 + }, + { + "op": "replace", + "path": "/limites/transferencia/mensal", + "value": 35000 + }, + { + "op": "replace", + "path": "/metadata/atualizado_em", + "value": "2026-04-23T08:00:00Z" + }, + { + "op": "replace", + "path": "/metadata/versao", + "value": 8 + }, + { + "op": "replace", + "path": "/metadata/hash", + "value": "xyz789def000" + }, + { + "op": "replace", + "path": "/metadata/origem/sistema", + "value": "portal-admin-v2" + }, + { + "op": "replace", + "path": "/metadata/origem/modulo", + "value": "cadastro-avancado" + }, + { + "op": "replace", + "path": "/metadata/origem/ambiente", + "value": "prod" + }, + { + "op": "add", + "path": "/documentos/cnh", + "value": {} + }, + { + "op": "add", + "path": "/documentos/cnh/numero", + "value": "9988776655" + }, + { + "op": "add", + "path": "/documentos/cnh/categoria", + "value": "B" + }, + { + "op": "add", + "path": "/documentos/cnh/validade", + "value": "2029-05-30" + }, + { + "op": "add", + "path": "/tags/5", + "value": "novo" + }, + { + "op": "add", + "path": "/produtos/1/atributos/sem_gluten", + "value": false + }, + { + "op": "add", + "path": "/produtos/1/variacoes/0", + "value": {} + }, + { + "op": "add", + "path": "/produtos/1/variacoes/0/nome", + "value": "com cobertura" + }, + { + "op": "add", + "path": "/produtos/1/variacoes/0/ativo", + "value": true + }, + { + "op": "add", + "path": "/workflow/etapas/2", + "value": {} + }, + { + "op": "add", + "path": "/workflow/etapas/2/id", + "value": 3 + }, + { + "op": "add", + "path": "/workflow/etapas/2/nome", + "value": "publicação" + }, + { + "op": "add", + "path": "/workflow/etapas/2/status", + "value": "em_andamento" + } +] diff --git a/libs/json-difference/src/core/generate-json-patch.spec.ts b/libs/json-difference/src/core/generate-json-patch.spec.ts index 95d294f..023eb9b 100644 --- a/libs/json-difference/src/core/generate-json-patch.spec.ts +++ b/libs/json-difference/src/core/generate-json-patch.spec.ts @@ -1,10 +1,15 @@ // Packages +import { readFileSync } from 'fs' +import { join } from 'path' import { getDiff } from '.' // Models import { JsonPatch } from '../models/json-difference.model' import { generateJsonPatch } from './generate-json-patch' +const FIXTURE_DIR = join(__dirname, '..', '..', 'fixture') +const loadFixture = (name: string): any => JSON.parse(readFileSync(join(FIXTURE_DIR, name), 'utf8')) + describe('GenerateJsonPatch function', () => { test('Should generate JSON Patch operations with nested array/object swap', () => { const struct1 = { '0': [{ '0': 1 }] } @@ -71,4 +76,23 @@ describe('GenerateJsonPatch function', () => { expect(result).toEqual(expectedResult) }) + + /** + * Integration tests covering the large stress fixtures. Patches from these + * deltas contain hundreds of ops — structural invariants are asserted + * instead of concrete values. + */ + describe('with fixture files', () => { + const oldJson = loadFixture('oldJson.json') + const newJson = loadFixture('newJson.json') + + test('Should generate the full JSON Patch that transforms oldJson into newJson', () => { + const expectedResult: Array = loadFixture('jsonPatch.json') + + const delta = getDiff(oldJson, newJson) + const result = generateJsonPatch(delta) + + expect(result).toEqual(expectedResult) + }) + }) }) From 29672ed4b5a23aef3b6ff341be888c2d45adfbb6 Mon Sep 17 00:00:00 2001 From: lukascivil Date: Thu, 23 Apr 2026 02:53:50 -0300 Subject: [PATCH 8/9] feat: implement inplement json patch --- libs/json-difference/fixture/jsonPatch.json | 22 +-- .../src/core/generate-json-patch.ts | 17 ++- .../src/core/implement-json-patch.spec.ts | 143 ++++++++++++++++++ .../src/core/implement-json-patch.ts | 92 +++++++++++ libs/json-difference/src/core/index.ts | 1 + 5 files changed, 261 insertions(+), 14 deletions(-) create mode 100644 libs/json-difference/src/core/implement-json-patch.spec.ts create mode 100644 libs/json-difference/src/core/implement-json-patch.ts diff --git a/libs/json-difference/fixture/jsonPatch.json b/libs/json-difference/fixture/jsonPatch.json index 3e06ad7..9d493d0 100644 --- a/libs/json-difference/fixture/jsonPatch.json +++ b/libs/json-difference/fixture/jsonPatch.json @@ -1,43 +1,43 @@ [ { "op": "remove", - "path": "/contatos_emergencia/0/telefones/1" + "path": "/produtos/1/atributos/congelado" }, { "op": "remove", - "path": "/produtos/1/atributos/congelado" + "path": "/contatos_emergencia/0/telefones/1" }, { "op": "remove", - "path": "/palavras_especiais/idiomas/3" + "path": "/preferencias/newsletter_ids/2" }, { "op": "remove", - "path": "/palavras_especiais/idiomas/4" + "path": "/preferencias/cores/2" }, { "op": "remove", - "path": "/documentos/passaporte/numero" + "path": "/preferencias/categorias/3" }, { "op": "remove", - "path": "/documentos/passaporte/validade" + "path": "/palavras_especiais/idiomas/4" }, { "op": "remove", - "path": "/preferencias/categorias/3" + "path": "/palavras_especiais/idiomas/3" }, { "op": "remove", - "path": "/preferencias/cores/2" + "path": "/documentos/passaporte/validade" }, { "op": "remove", - "path": "/preferencias/newsletter_ids/2" + "path": "/documentos/passaporte/numero" }, { "op": "remove", - "path": "/documentos/passaporte" + "path": "/pontuacoes/5" }, { "op": "remove", @@ -45,7 +45,7 @@ }, { "op": "remove", - "path": "/pontuacoes/5" + "path": "/documentos/passaporte" }, { "op": "replace", diff --git a/libs/json-difference/src/core/generate-json-patch.ts b/libs/json-difference/src/core/generate-json-patch.ts index c7bd748..a754745 100644 --- a/libs/json-difference/src/core/generate-json-patch.ts +++ b/libs/json-difference/src/core/generate-json-patch.ts @@ -4,7 +4,17 @@ import { Delta, JsonPatch, JsonPatchAdd, JsonPatchRemove, JsonPatchReplace } fro // Helpers import { toJsonPointer } from '../helpers/to-json-pointer' -const byDepthDesc = (a: JsonPatchRemove, b: JsonPatchRemove): number => b.path.split('/').length - a.path.split('/').length +const byRemoveOrder = (jsonPatchRemoveA: JsonPatchRemove, jsonPatchRemoveB: JsonPatchRemove): number => { + const depthA = jsonPatchRemoveA.path.split('/').length + const depthB = jsonPatchRemoveB.path.split('/').length + if (depthA !== depthB) { + return depthB - depthA + } + // Same depth: order descending so that higher array indices are removed + // first and subsequent removes at lower indices stay valid (splice shifts + // later elements left). + return jsonPatchRemoveB.path.localeCompare(jsonPatchRemoveA.path, undefined, { numeric: true }) +} /** * Build an RFC 6902 JSON Patch that describes the transformation from the @@ -15,14 +25,15 @@ const byDepthDesc = (a: JsonPatchRemove, b: JsonPatchRemove): number => b.path.s * * Operation ordering is chosen so that the patch can be applied sequentially * against the original document: - * 1. `remove` ops, deepest path first (so a parent is not removed before its children) + * 1. `remove` ops, deepest path first; on ties, descending by numeric token + * so array indices shift correctly (remove `/arr/4` before `/arr/3`) * 2. `replace` ops in delta order * 3. `add` ops in delta order (DFS already places parents before children) */ export const generateJsonPatch = (delta: Delta): Array => { const removeOps: Array = delta.removed .map(([path]) => ({ op: 'remove' as const, path: toJsonPointer(path) })) - .sort(byDepthDesc) + .sort(byRemoveOrder) const replaceOps: Array = delta.edited.map(([path, , newValue]) => ({ op: 'replace' as const, diff --git a/libs/json-difference/src/core/implement-json-patch.spec.ts b/libs/json-difference/src/core/implement-json-patch.spec.ts new file mode 100644 index 0000000..88de294 --- /dev/null +++ b/libs/json-difference/src/core/implement-json-patch.spec.ts @@ -0,0 +1,143 @@ +// Packages +import { readFileSync } from 'fs' +import { join } from 'path' +import { implementJsonPatch } from './implement-json-patch' + +// Models +import { JsonPatch } from '../models/json-difference.model' + +const FIXTURE_DIR = join(__dirname, '..', '..', 'fixture') +const loadFixture = (name: string): any => JSON.parse(readFileSync(join(FIXTURE_DIR, name), 'utf8')) + +describe('ImplementJsonPatch function', () => { + test('Should add a new property to an object', () => { + const document = { a: 1 } + const patch: Array = [{ op: 'add', path: '/b', value: 2 }] + const expectedResult = { a: 1, b: 2 } + + const result = implementJsonPatch(document, patch) + + expect(result).toEqual(expectedResult) + }) + + test('Should remove a property from an object', () => { + const document = { a: 1, b: 2 } + const patch: Array = [{ op: 'remove', path: '/b' }] + const expectedResult = { a: 1 } + + const result = implementJsonPatch(document, patch) + + expect(result).toEqual(expectedResult) + }) + + test('Should replace a property value', () => { + const document = { a: 1 } + const patch: Array = [{ op: 'replace', path: '/a', value: 99 }] + const expectedResult = { a: 99 } + + const result = implementJsonPatch(document, patch) + + expect(result).toEqual(expectedResult) + }) + + test('Should insert into an array shifting subsequent elements', () => { + const document = { arr: [1, 3] } + const patch: Array = [{ op: 'add', path: '/arr/1', value: 2 }] + const expectedResult = { arr: [1, 2, 3] } + + const result = implementJsonPatch(document, patch) + + expect(result).toEqual(expectedResult) + }) + + test('Should remove from an array shifting subsequent elements', () => { + const document = { arr: [1, 2, 3] } + const patch: Array = [{ op: 'remove', path: '/arr/1' }] + const expectedResult = { arr: [1, 3] } + + const result = implementJsonPatch(document, patch) + + expect(result).toEqual(expectedResult) + }) + + test('Should replace an array element', () => { + const document = { arr: [1, 2, 3] } + const patch: Array = [{ op: 'replace', path: '/arr/1', value: 99 }] + const expectedResult = { arr: [1, 99, 3] } + + const result = implementJsonPatch(document, patch) + + expect(result).toEqual(expectedResult) + }) + + test('Should replace the entire document when the path is empty', () => { + const document = { a: 1 } + const patch: Array = [{ op: 'replace', path: '', value: [1, 2, 3] }] + const expectedResult = [1, 2, 3] + + const result = implementJsonPatch(document, patch) + + expect(result).toEqual(expectedResult) + }) + + test('Should return an unchanged document for an empty patch', () => { + const document = { a: 1, b: [1, 2] } + const expectedResult = { a: 1, b: [1, 2] } + + const result = implementJsonPatch(document, []) + + expect(result).toEqual(expectedResult) + }) + + test('Should not mutate the input document', () => { + const document = { a: 1, arr: [1, 2] } + const expectedResult = { a: 1, arr: [1, 2] } + const patch: Array = [ + { op: 'add', path: '/b', value: 2 }, + { op: 'remove', path: '/arr/0' } + ] + + implementJsonPatch(document, patch) + + expect(document).toEqual(expectedResult) + }) + + test('Should unescape "~1" to "/" and "~0" to "~" in pointer tokens', () => { + const document = { 'a/b': 1, 'c~d': 2 } + const patch: Array = [ + { op: 'replace', path: '/a~1b', value: 'updated' }, + { op: 'remove', path: '/c~0d' } + ] + const expectedResult = { 'a/b': 'updated' } + + const result = implementJsonPatch(document, patch) + + expect(result).toEqual(expectedResult) + }) + + test('Should apply a mixed patch (remove + replace + add) in order', () => { + const document = { keep: 1, toReplace: 'old', toRemove: 'gone' } + const patch: Array = [ + { op: 'remove', path: '/toRemove' }, + { op: 'replace', path: '/toReplace', value: 'new' }, + { op: 'add', path: '/added', value: [1, 2] } + ] + const expectedResult = { keep: 1, toReplace: 'new', added: [1, 2] } + + const result = implementJsonPatch(document, patch) + + expect(result).toEqual(expectedResult) + }) + + describe('with fixture files', () => { + test('Should transform oldJson into newJson when applying the generated patch', () => { + const oldJson = loadFixture('oldJson.json') + const expectedResult = loadFixture('newJson.json') + const patch: Array = loadFixture('jsonPatch.json') + + const result = implementJsonPatch(oldJson, patch) + + expect(result).toEqual(expectedResult) + }) + }) +}) diff --git a/libs/json-difference/src/core/implement-json-patch.ts b/libs/json-difference/src/core/implement-json-patch.ts new file mode 100644 index 0000000..798895a --- /dev/null +++ b/libs/json-difference/src/core/implement-json-patch.ts @@ -0,0 +1,92 @@ +// Models +import { JsonPatch } from '../models/json-difference.model' + +const deepClone = (value: T): T => (value === undefined ? value : JSON.parse(JSON.stringify(value))) + +/** + * Parse an RFC 6901 JSON Pointer into a list of decoded tokens. + * "" → [] (root) + * "/foo" → ["foo"] + * "/a~1b/c" → ["a/b", "c"] + * "/a~0b" → ["a~b"] + */ +const parsePointer = (pointer: string): Array => { + if (pointer === '') { + return [] + } + + if (!pointer.startsWith('/')) { + throw new Error(`Invalid JSON Pointer: "${pointer}" (must be empty or start with "/")`) + } + + return pointer + .slice(1) + .split('/') + .map((token) => token.replace(/~1/g, '/').replace(/~0/g, '~')) +} + +const navigateToParent = (document: any, tokens: Array): any => { + let current = document + + for (let i = 0; i < tokens.length - 1; i++) { + const token = tokens[i] + + current = Array.isArray(current) ? current[Number(token)] : current[token] + } + + return current +} + +/** + * Apply an RFC 6902 JSON Patch to a document and return the resulting + * document. The input `document` is cloned — it is never mutated. + * + * Supports the `add`, `remove` and `replace` operations that + * `generateJsonPatch` emits. Other RFC 6902 operations (`move`, `copy`, + * `test`) are intentionally not implemented. + * + * @example + * const oldDoc = { a: 1 } + * const patch = [{ op: 'add', path: '/b', value: 2 }] + * implementJsonPatch(oldDoc, patch) // → { a: 1, b: 2 } + */ +export const implementJsonPatch = (document: any, patch: Array): any => { + let doc = deepClone(document) + + for (const op of patch) { + const tokens = parsePointer(op.path) + + // Root operation: the whole document is the target. + if (tokens.length === 0) { + doc = op.op === 'remove' ? null : deepClone(op.value) + continue + } + + const parent = navigateToParent(doc, tokens) + const lastToken = tokens[tokens.length - 1] + const isArrayParent = Array.isArray(parent) + + if (op.op === 'add') { + if (isArrayParent) { + const index = lastToken === '-' ? parent.length : Number(lastToken) + parent.splice(index, 0, deepClone(op.value)) + } else { + parent[lastToken] = deepClone(op.value) + } + } else if (op.op === 'remove') { + if (isArrayParent) { + parent.splice(Number(lastToken), 1) + } else { + delete parent[lastToken] + } + } else if (op.op === 'replace') { + if (isArrayParent) { + parent[Number(lastToken)] = deepClone(op.value) + } else { + parent[lastToken] = deepClone(op.value) + } + } + } + + return doc +} diff --git a/libs/json-difference/src/core/index.ts b/libs/json-difference/src/core/index.ts index c758e50..393f893 100644 --- a/libs/json-difference/src/core/index.ts +++ b/libs/json-difference/src/core/index.ts @@ -3,3 +3,4 @@ export * from './get-paths-diff' export * from './get-struct-paths' export * from './get-edited-paths' export * from './generate-json-patch' +export * from './implement-json-patch' From 877d2beb6db05060e830a5b4e3a08657f2ea2c58 Mon Sep 17 00:00:00 2001 From: lukascivil Date: Thu, 23 Apr 2026 02:56:31 -0300 Subject: [PATCH 9/9] fix: adjust models --- libs/json-difference/src/core/get-edited-paths.ts | 2 +- libs/json-difference/src/core/get-paths-diff.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/json-difference/src/core/get-edited-paths.ts b/libs/json-difference/src/core/get-edited-paths.ts index 863ff30..cf9542c 100644 --- a/libs/json-difference/src/core/get-edited-paths.ts +++ b/libs/json-difference/src/core/get-edited-paths.ts @@ -1,5 +1,5 @@ // Models -import { EditedPath, StructPaths } from '../models/jsondiffer.model' +import { EditedPath, StructPaths } from '../models/json-difference.model' import { ARRAY_SENTINEL, OBJECT_SENTINEL, unwrapSentinel } from '../helpers/unwrap-sentinel' /** diff --git a/libs/json-difference/src/core/get-paths-diff.ts b/libs/json-difference/src/core/get-paths-diff.ts index df02460..e9e3516 100644 --- a/libs/json-difference/src/core/get-paths-diff.ts +++ b/libs/json-difference/src/core/get-paths-diff.ts @@ -1,5 +1,5 @@ // Models -import { PathsDiff, StructPaths } from '../models/jsondiffer.model' +import { PathsDiff, StructPaths } from '../models/json-difference.model' import { unwrapSentinel } from '../helpers/unwrap-sentinel' /** @@ -17,7 +17,7 @@ import { unwrapSentinel } from '../helpers/unwrap-sentinel' * const result = getPathsDiff(oldStruct, newStruct) * * console.log(result) - * // Output: ["2": "tea"] + * // Output: [["2", "tea"]] */ export const getPathsDiff = (oldStructPaths: StructPaths, newStructPaths: StructPaths): Array => { const diff: Array = []