Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
615 changes: 615 additions & 0 deletions libs/json-difference/fixture/jsonPatch.json

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions libs/json-difference/src/core/generate-json-patch.spec.ts
Original file line number Diff line number Diff line change
@@ -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<JsonPatch> = [
{ 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<JsonPatch> = [
{ 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<JsonPatch> = [{ 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<JsonPatch> = [
{ 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<JsonPatch> = loadFixture('jsonPatch.json')

const delta = getDiff(oldJson, newJson)
const result = generateJsonPatch(delta)

expect(result).toEqual(expectedResult)
})
})
})
51 changes: 51 additions & 0 deletions libs/json-difference/src/core/generate-json-patch.ts
Original file line number Diff line number Diff line change
@@ -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<JsonPatch> => {
const removeOps: Array<JsonPatchRemove> = delta.removed
.map(([path]) => ({ op: 'remove' as const, path: toJsonPointer(path) }))
.sort(byRemoveOrder)

const replaceOps: Array<JsonPatchReplace> = delta.edited.map(([path, , newValue]) => ({
op: 'replace' as const,
path: toJsonPointer(path),
value: newValue
}))

const addOps: Array<JsonPatchAdd> = delta.added.map(([path, value]) => ({
op: 'add' as const,
path: toJsonPointer(path),
value
}))

return [...removeOps, ...replaceOps, ...addOps]
}
2 changes: 1 addition & 1 deletion libs/json-difference/src/core/get-diff.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
2 changes: 1 addition & 1 deletion libs/json-difference/src/core/get-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion libs/json-difference/src/core/get-edited-paths.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion libs/json-difference/src/core/get-edited-paths.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down
2 changes: 1 addition & 1 deletion libs/json-difference/src/core/get-paths-diff.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions libs/json-difference/src/core/get-paths-diff.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand All @@ -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<PathsDiff> => {
const diff: Array<PathsDiff> = []
Expand Down
2 changes: 1 addition & 1 deletion libs/json-difference/src/core/get-struct-paths.ts
Original file line number Diff line number Diff line change
@@ -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 ? '[' : '.') : '/'
Expand Down
143 changes: 143 additions & 0 deletions libs/json-difference/src/core/implement-json-patch.spec.ts
Original file line number Diff line number Diff line change
@@ -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<JsonPatch> = [{ 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<JsonPatch> = [{ 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<JsonPatch> = [{ 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<JsonPatch> = [{ 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<JsonPatch> = [{ 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<JsonPatch> = [{ 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<JsonPatch> = [{ 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<JsonPatch> = [
{ 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<JsonPatch> = [
{ 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<JsonPatch> = [
{ 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<JsonPatch> = loadFixture('jsonPatch.json')

const result = implementJsonPatch(oldJson, patch)

expect(result).toEqual(expectedResult)
})
})
})
Loading
Loading