diff --git a/libs/json-difference/fixture/jsonPatch.json b/libs/json-difference/fixture/jsonPatch.json new file mode 100644 index 0000000..9d493d0 --- /dev/null +++ b/libs/json-difference/fixture/jsonPatch.json @@ -0,0 +1,615 @@ +[ + { + "op": "remove", + "path": "/produtos/1/atributos/congelado" + }, + { + "op": "remove", + "path": "/contatos_emergencia/0/telefones/1" + }, + { + "op": "remove", + "path": "/preferencias/newsletter_ids/2" + }, + { + "op": "remove", + "path": "/preferencias/cores/2" + }, + { + "op": "remove", + "path": "/preferencias/categorias/3" + }, + { + "op": "remove", + "path": "/palavras_especiais/idiomas/4" + }, + { + "op": "remove", + "path": "/palavras_especiais/idiomas/3" + }, + { + "op": "remove", + "path": "/documentos/passaporte/validade" + }, + { + "op": "remove", + "path": "/documentos/passaporte/numero" + }, + { + "op": "remove", + "path": "/pontuacoes/5" + }, + { + "op": "remove", + "path": "/pontuacoes/4" + }, + { + "op": "remove", + "path": "/documentos/passaporte" + }, + { + "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 new file mode 100644 index 0000000..023eb9b --- /dev/null +++ b/libs/json-difference/src/core/generate-json-patch.spec.ts @@ -0,0 +1,98 @@ +// 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 }] } + const struct2 = { '0': { '0': [1] } } + const expectedResult: Array = [ + { 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) + + expect(result).toEqual(expectedResult) + }) + + test('Should generate JSON Patch operations with flat add/remove/replace', () => { + const struct1 = { + baz: 'qux', + foo: 'bar' + } + const struct2 = { + baz: 'boo', + hello: ['world'] + } + const expectedResult: Array = [ + { 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) + + 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) + }) + }) +}) 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..a754745 --- /dev/null +++ b/libs/json-difference/src/core/generate-json-patch.ts @@ -0,0 +1,51 @@ +// Models +import { Delta, JsonPatch, JsonPatchAdd, JsonPatchRemove, JsonPatchReplace } from '../models/json-difference.model' + +// Helpers +import { toJsonPointer } from '../helpers/to-json-pointer' + +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 + * 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; 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(byRemoveOrder) + + const replaceOps: Array = delta.edited.map(([path, , newValue]) => ({ + op: 'replace' as const, + path: toJsonPointer(path), + value: newValue + })) + + const addOps: Array = delta.added.map(([path, value]) => ({ + op: 'add' as const, + path: toJsonPointer(path), + value + })) + + return [...removeOps, ...replaceOps, ...addOps] +} 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-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.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-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 = [] 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/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 0617ebe..393f893 100644 --- a/libs/json-difference/src/core/index.ts +++ b/libs/json-difference/src/core/index.ts @@ -2,3 +2,5 @@ export * from './get-diff' 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' 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/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 @{}', () => { 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('/') +} 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/jsondiffer.model.ts b/libs/json-difference/src/models/json-difference.model.ts similarity index 50% rename from libs/json-difference/src/models/jsondiffer.model.ts rename to libs/json-difference/src/models/json-difference.model.ts index 659fa7c..5254c98 100644 --- a/libs/json-difference/src/models/jsondiffer.model.ts +++ b/libs/json-difference/src/models/json-difference.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