├── .travis.yml ├── .npmignore ├── .gitignore ├── test ├── tsconfig.json ├── _index.ts ├── arrays.ts ├── patch.ts ├── pointer.ts ├── spec.ts ├── createTests.ts ├── spec.yaml └── issues.ts ├── tsconfig.json ├── bower.json ├── package.json ├── util.ts ├── index.ts ├── pointer.ts ├── dist ├── rfc6902.min.js └── rfc6902.js ├── patch.ts ├── README.md └── diff.ts /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 14 4 | - 12 5 | - 10 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | .travis.yml 4 | tsconfig.json 5 | test/ 6 | coverage/ 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | coverage/ 4 | package-lock.json 5 | !dist/*.js 6 | .nyc_output/ 7 | node_modules/ 8 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES6" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2015"], 4 | "declaration": true, 5 | "module": "commonjs", 6 | "target": "ES5", 7 | "strict": true 8 | }, 9 | "exclude": [ 10 | "test" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /test/_index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | This module is prefixed with an underscore so that ava recognizes it as a helper, 3 | instead of failing the entire test suite with a "No tests found" error. 4 | */ 5 | 6 | export function resultName(result: T): string | T { 7 | return result ? result.name : result 8 | } 9 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rfc6902", 3 | "description": "Complete implementation of RFC6902 (patch and diff)", 4 | "main": "./dist/rfc6902.js", 5 | "authors": [ 6 | "Christopher Brown (http://henrian.com)" 7 | ], 8 | "license": "MIT", 9 | "keywords": [ 10 | "json", 11 | "patch", 12 | "diff", 13 | "rfc6902" 14 | ], 15 | "homepage": "https://github.com/chbrown/rfc6902", 16 | "moduleType": [ 17 | "amd", 18 | "es6", 19 | "globals", 20 | "node" 21 | ], 22 | "ignore": [ 23 | "**/.*", 24 | "CVS", 25 | "node_modules", 26 | "bower_components", 27 | "test" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /test/arrays.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import {applyPatch, createPatch} from '../index' 4 | import {clone} from '../util' 5 | 6 | const pairs = [ 7 | [ 8 | ['A', 'Z', 'Z'], 9 | ['A'], 10 | ], 11 | [ 12 | ['A', 'B'], 13 | ['B', 'A'], 14 | ], 15 | [ 16 | [], 17 | ['A', 'B'], 18 | ], 19 | [ 20 | ['B', 'A', 'M'], 21 | ['M', 'A', 'A'], 22 | ], 23 | [ 24 | ['A', 'A', 'R'], 25 | [], 26 | ], 27 | [ 28 | ['A', 'B', 'C'], 29 | ['B', 'C', 'D'], 30 | ], 31 | [ 32 | ['A', 'C'], 33 | ['A', 'B', 'C'], 34 | ], 35 | [ 36 | ['A', 'B', 'C'], 37 | ['A', 'Z'], 38 | ], 39 | ] 40 | 41 | 42 | pairs.forEach(([input, output]) => { 43 | test(`diff+patch: [${input}] => [${output}]`, t => { 44 | const patch = createPatch(input, output) 45 | const actual_output = clone(input) 46 | applyPatch(actual_output, patch) 47 | t.deepEqual(actual_output, output, 'should apply produced patch to arrive at output') 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rfc6902", 3 | "version": "5.1.2", 4 | "description": "Complete implementation of RFC6902 (patch and diff)", 5 | "keywords": [ 6 | "json", 7 | "patch", 8 | "diff", 9 | "rfc6902" 10 | ], 11 | "homepage": "https://github.com/chbrown/rfc6902", 12 | "repository": "github:chbrown/rfc6902", 13 | "author": "Christopher Brown (http://henrian.com)", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/js-yaml": "4.0.0", 17 | "@types/node": "^14.14.33", 18 | "ava": "^3.15.0", 19 | "coveralls": "^3.1.0", 20 | "js-yaml": "4.0.0", 21 | "nyc": "^15.1.0", 22 | "rollup": "^2.41.1", 23 | "typescript": "^4.2.3" 24 | }, 25 | "scripts": { 26 | "prepare": "tsc", 27 | "pretest": "tsc -b . test -f", 28 | "test": "nyc ava", 29 | "posttest": "nyc report --reporter=text-lcov | coveralls || true", 30 | "dist": "tsc -t ES2015 -m es2015 && rollup index.js --output.format umd --name rfc6902 --output.file dist/rfc6902.js && closure-compiler dist/rfc6902.js > dist/rfc6902.min.js", 31 | "clean": "tsc -b . test --clean" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /util.ts: -------------------------------------------------------------------------------- 1 | export const hasOwnProperty = Object.prototype.hasOwnProperty 2 | 3 | export function objectType(object: any) { 4 | if (object === undefined) { 5 | return 'undefined' 6 | } 7 | if (object === null) { 8 | return 'null' 9 | } 10 | if (Array.isArray(object)) { 11 | return 'array' 12 | } 13 | return typeof object 14 | } 15 | 16 | function isNonPrimitive(value: any): value is object { 17 | // loose-equality checking for null is faster than strict checking for each of null/undefined/true/false 18 | // checking null first, then calling typeof, is faster than vice-versa 19 | return value != null && typeof value == 'object' 20 | } 21 | 22 | /** 23 | Recursively copy a value. 24 | 25 | @param source - should be a JavaScript primitive, Array, Date, or (plain old) Object. 26 | @returns copy of source where every Array and Object have been recursively 27 | reconstructed from their constituent elements 28 | */ 29 | export function clone(source: T): T { 30 | if (!isNonPrimitive(source)) { 31 | // short-circuiting is faster than a single return 32 | return source 33 | } 34 | // x.constructor == Array is the fastest way to check if x is an Array 35 | if (source.constructor == Array) { 36 | // construction via imperative for-loop is faster than source.map(arrayVsObject) 37 | const length = (source as Array).length 38 | // setting the Array length during construction is faster than just `[]` or `new Array()` 39 | const arrayTarget: any = new Array(length) 40 | for (let i = 0; i < length; i++) { 41 | arrayTarget[i] = clone(source[i]) 42 | } 43 | return arrayTarget 44 | } 45 | // Date 46 | if (source.constructor == Date) { 47 | const dateTarget: any = new Date(+source) 48 | return dateTarget 49 | } 50 | // Object 51 | const objectTarget: any = {} 52 | // declaring the variable (with const) inside the loop is faster 53 | for (const key in source) { 54 | // hasOwnProperty costs a bit of performance, but it's semantically necessary 55 | // using a global helper is MUCH faster than calling source.hasOwnProperty(key) 56 | if (hasOwnProperty.call(source, key)) { 57 | objectTarget[key] = clone(source[key]) 58 | } 59 | } 60 | return objectTarget 61 | } 62 | -------------------------------------------------------------------------------- /test/patch.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import {applyPatch} from '../index' 4 | 5 | import {resultName} from './_index' 6 | 7 | test('broken add', t => { 8 | const user = {id: 'chbrown'} 9 | const results = applyPatch(user, [ 10 | {op: 'add', path: '/a/b', value: 1}, 11 | ]) 12 | t.deepEqual(user, {id: 'chbrown'}, 'should change nothing') 13 | t.deepEqual(results.map(resultName), ['MissingError'], 'should result in MissingError') 14 | }) 15 | 16 | test('broken remove', t => { 17 | const user = {id: 'chbrown'} 18 | const results = applyPatch(user, [ 19 | {op: 'remove', path: '/name'}, 20 | ]) 21 | t.deepEqual(user, {id: 'chbrown'}, 'should change nothing') 22 | t.deepEqual(results.map(resultName), ['MissingError'], 'should result in MissingError') 23 | }) 24 | 25 | test('broken replace', t => { 26 | const user = {id: 'chbrown'} 27 | const results = applyPatch(user, [ 28 | {op: 'replace', path: '/name', value: 1}, 29 | ]) 30 | t.deepEqual(user, {id: 'chbrown'}, 'should change nothing') 31 | t.deepEqual(results.map(resultName), ['MissingError'], 'should result in MissingError') 32 | }) 33 | 34 | test('broken replace (array)', t => { 35 | const users = [{id: 'chbrown'}] 36 | const results = applyPatch(users, [ 37 | {op: 'replace', path: '/1', value: {id: 'chbrown2'}}, 38 | ]) 39 | // cf. issues/36 40 | t.deepEqual(users, [{id: 'chbrown'}], 'should change nothing') 41 | t.deepEqual(results.map(resultName), ['MissingError'], 'should result in MissingError') 42 | }) 43 | 44 | test('broken move (from)', t => { 45 | const user = {id: 'chbrown'} 46 | const results = applyPatch(user, [ 47 | {op: 'move', from: '/name', path: '/id'}, 48 | ]) 49 | t.deepEqual(user, {id: 'chbrown'}, 'should change nothing') 50 | t.deepEqual(results.map(resultName), ['MissingError'], 'should result in MissingError') 51 | }) 52 | 53 | test('broken move (path)', t => { 54 | const user = {id: 'chbrown'} 55 | const results = applyPatch(user, [ 56 | {op: 'move', from: '/id', path: '/a/b'}, 57 | ]) 58 | t.deepEqual(user, {id: 'chbrown'}, 'should change nothing') 59 | t.deepEqual(results.map(resultName), ['MissingError'], 'should result in MissingError') 60 | }) 61 | 62 | test('broken copy (from)', t => { 63 | const user = {id: 'chbrown'} 64 | const results = applyPatch(user, [ 65 | {op: 'copy', from: '/name', path: '/id'}, 66 | ]) 67 | t.deepEqual(user, {id: 'chbrown'}, 'should change nothing') 68 | t.deepEqual(results.map(resultName), ['MissingError'], 'should result in MissingError') 69 | }) 70 | 71 | test('broken copy (path)', t => { 72 | const user = {id: 'chbrown'} 73 | const results = applyPatch(user, [ 74 | {op: 'copy', from: '/id', path: '/a/b'}, 75 | ]) 76 | t.deepEqual(user, {id: 'chbrown'}, 'should change nothing') 77 | t.deepEqual(results.map(resultName), ['MissingError'], 'should result in MissingError') 78 | }) 79 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import {Pointer} from './pointer' 2 | export {Pointer} 3 | 4 | import {apply} from './patch' 5 | import {Operation, TestOperation, isDestructive, Diff, VoidableDiff, diffAny} from './diff' 6 | 7 | export {Operation, TestOperation} 8 | export type Patch = Operation[] 9 | 10 | /** 11 | Apply a 'application/json-patch+json'-type patch to an object. 12 | 13 | `patch` *must* be an array of operations. 14 | 15 | > Operation objects MUST have exactly one "op" member, whose value 16 | > indicates the operation to perform. Its value MUST be one of "add", 17 | > "remove", "replace", "move", "copy", or "test"; other values are 18 | > errors. 19 | 20 | This method mutates the target object in-place. 21 | 22 | @returns list of results, one for each operation: `null` indicated success, 23 | otherwise, the result will be an instance of one of the Error classes: 24 | MissingError, InvalidOperationError, or TestError. 25 | */ 26 | export function applyPatch(object: any, patch: Operation[]) { 27 | return patch.map(operation => apply(object, operation)) 28 | } 29 | 30 | function wrapVoidableDiff(diff: VoidableDiff): Diff { 31 | function wrappedDiff(input: any, output: any, ptr: Pointer): Operation[] { 32 | const custom_patch = diff(input, output, ptr) 33 | // ensure an array is always returned 34 | return Array.isArray(custom_patch) ? custom_patch : diffAny(input, output, ptr, wrappedDiff) 35 | } 36 | return wrappedDiff 37 | } 38 | 39 | /** 40 | Produce a 'application/json-patch+json'-type patch to get from one object to 41 | another. 42 | 43 | This does not alter `input` or `output` unless they have a property getter with 44 | side-effects (which is not a good idea anyway). 45 | 46 | `diff` is called on each pair of comparable non-primitive nodes in the 47 | `input`/`output` object trees, producing nested patches. Return `undefined` 48 | to fall back to default behaviour. 49 | 50 | Returns list of operations to perform on `input` to produce `output`. 51 | */ 52 | export function createPatch(input: any, output: any, diff?: VoidableDiff): Operation[] { 53 | const ptr = new Pointer() 54 | // a new Pointer gets a default path of [''] if not specified 55 | return (diff ? wrapVoidableDiff(diff) : diffAny)(input, output, ptr) 56 | } 57 | 58 | /** 59 | Create a test operation based on `input`'s current evaluation of the JSON 60 | Pointer `path`; if such a pointer cannot be resolved, returns undefined. 61 | */ 62 | function createTest(input: any, path: string): TestOperation | undefined { 63 | const endpoint = Pointer.fromJSON(path).evaluate(input) 64 | if (endpoint !== undefined) { 65 | return {op: 'test', path, value: endpoint.value} 66 | } 67 | } 68 | 69 | /** 70 | Produce an 'application/json-patch+json'-type list of tests, to verify that 71 | existing values in an object are identical to the those captured at some 72 | checkpoint (whenever this function is called). 73 | 74 | This does not alter `input` or `output` unless they have a property getter with 75 | side-effects (which is not a good idea anyway). 76 | 77 | Returns list of test operations. 78 | */ 79 | export function createTests(input: any, patch: Operation[]): TestOperation[] { 80 | const tests = new Array() 81 | patch.filter(isDestructive).forEach(operation => { 82 | const pathTest = createTest(input, operation.path) 83 | if (pathTest) tests.push(pathTest) 84 | if ('from' in operation) { 85 | const fromTest = createTest(input, operation.from) 86 | if (fromTest) tests.push(fromTest) 87 | } 88 | }) 89 | return tests 90 | } 91 | -------------------------------------------------------------------------------- /pointer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | Unescape token part of a JSON Pointer string 3 | 4 | `token` should *not* contain any '/' characters. 5 | 6 | > Evaluation of each reference token begins by decoding any escaped 7 | > character sequence. This is performed by first transforming any 8 | > occurrence of the sequence '~1' to '/', and then transforming any 9 | > occurrence of the sequence '~0' to '~'. By performing the 10 | > substitutions in this order, an implementation avoids the error of 11 | > turning '~01' first into '~1' and then into '/', which would be 12 | > incorrect (the string '~01' correctly becomes '~1' after 13 | > transformation). 14 | 15 | Here's my take: 16 | 17 | ~1 is unescaped with higher priority than ~0 because it is a lower-order escape character. 18 | I say "lower order" because '/' needs escaping due to the JSON Pointer serialization technique. 19 | Whereas, '~' is escaped because escaping '/' uses the '~' character. 20 | */ 21 | export function unescapeToken(token: string): string { 22 | return token.replace(/~1/g, '/').replace(/~0/g, '~') 23 | } 24 | 25 | /** Escape token part of a JSON Pointer string 26 | 27 | > '~' needs to be encoded as '~0' and '/' 28 | > needs to be encoded as '~1' when these characters appear in a 29 | > reference token. 30 | 31 | This is the exact inverse of `unescapeToken()`, so the reverse replacements must take place in reverse order. 32 | */ 33 | export function escapeToken(token: string): string { 34 | return token.replace(/~/g, '~0').replace(/\//g, '~1') 35 | } 36 | 37 | export interface PointerEvaluation { 38 | parent: any 39 | key: string 40 | value: any 41 | } 42 | 43 | /** 44 | JSON Pointer representation 45 | */ 46 | export class Pointer { 47 | constructor(public tokens = ['']) { } 48 | /** 49 | `path` *must* be a properly escaped string. 50 | */ 51 | static fromJSON(path: string): Pointer { 52 | const tokens = path.split('/').map(unescapeToken) 53 | if (tokens[0] !== '') throw new Error(`Invalid JSON Pointer: ${path}`) 54 | return new Pointer(tokens) 55 | } 56 | toString(): string { 57 | return this.tokens.map(escapeToken).join('/') 58 | } 59 | /** 60 | Returns an object with 'parent', 'key', and 'value' properties. 61 | In the special case that this Pointer's path == "", 62 | this object will be {parent: null, key: '', value: object}. 63 | Otherwise, parent and key will have the property such that parent[key] == value. 64 | */ 65 | evaluate(object: any): PointerEvaluation { 66 | let parent: any = null 67 | let key = '' 68 | let value = object 69 | for (let i = 1, l = this.tokens.length; i < l; i++) { 70 | parent = value 71 | key = this.tokens[i] 72 | if (key == '__proto__' || key == 'constructor' || key == 'prototype') { 73 | continue 74 | } 75 | // not sure if this the best way to handle non-existant paths... 76 | value = (parent || {})[key] 77 | } 78 | return {parent, key, value} 79 | } 80 | get(object: any): any { 81 | return this.evaluate(object).value 82 | } 83 | set(object: any, value: any): void { 84 | const endpoint = this.evaluate(object) 85 | if (endpoint.parent) { 86 | endpoint.parent[endpoint.key] = value 87 | } 88 | } 89 | push(token: string): void { 90 | // mutable 91 | this.tokens.push(token) 92 | } 93 | /** 94 | `token` should be a String. It'll be coerced to one anyway. 95 | 96 | immutable (shallowly) 97 | */ 98 | add(token: string): Pointer { 99 | const tokens = this.tokens.concat(String(token)) 100 | return new Pointer(tokens) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/pointer.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import {Pointer} from '../pointer' 4 | import {clone} from '../util' 5 | 6 | test('Pointer.fromJSON empty', t => { 7 | t.notThrows(() => { 8 | Pointer.fromJSON('') 9 | }) 10 | }) 11 | test('Pointer.fromJSON slash', t => { 12 | t.notThrows(() => { 13 | Pointer.fromJSON('/') 14 | }) 15 | }) 16 | test('Pointer.fromJSON invalid', t => { 17 | const error = t.throws(() => { 18 | Pointer.fromJSON('a') 19 | }) 20 | t.regex(error.message, /Invalid JSON Pointer/, 'thrown error should have descriptive message') 21 | }) 22 | 23 | const example = {bool: false, arr: [10, 20, 30], obj: {a: 'A', b: 'B'}} 24 | 25 | test('Pointer#get bool', t => { 26 | t.deepEqual(Pointer.fromJSON('/bool').get(example), false, 'should get bool value') 27 | }) 28 | test('Pointer#get array', t => { 29 | t.deepEqual(Pointer.fromJSON('/arr/1').get(example), 20, 'should get array value') 30 | }) 31 | test('Pointer#get object', t => { 32 | t.deepEqual(Pointer.fromJSON('/obj/b').get(example), 'B', 'should get object value') 33 | }) 34 | test('Pointer#push', t => { 35 | const pointer = Pointer.fromJSON('/obj') 36 | pointer.push('a') 37 | t.is(pointer.toString(), '/obj/a', 'should add token') 38 | }) 39 | test('Pointer#get∘push', t => { 40 | const pointer = Pointer.fromJSON('/obj') 41 | pointer.push('a') 42 | t.deepEqual(pointer.get(example), 'A', 'should get object value after adding token') 43 | }) 44 | 45 | test('Pointer#set bool', t => { 46 | const input = {bool: true} 47 | Pointer.fromJSON('/bool').set(input, false) 48 | t.deepEqual(input.bool, false, 'should set bool value in-place') 49 | }) 50 | 51 | test('Pointer#set array middle', t => { 52 | const input: any = {arr: ['10', '20', '30']} 53 | Pointer.fromJSON('/arr/1').set(input, 0) 54 | t.deepEqual(input.arr[1], 0, 'should set array value in-place') 55 | }) 56 | 57 | test('Pointer#set array beyond', t => { 58 | const input: any = {arr: ['10', '20', '30']} 59 | Pointer.fromJSON('/arr/3').set(input, 40) 60 | t.deepEqual(input.arr[3], 40, 'should set array value in-place') 61 | }) 62 | 63 | test('Pointer#set top-level', t => { 64 | const input: any = {obj: {a: 'A', b: 'B'}} 65 | const original = clone(input) 66 | Pointer.fromJSON('').set(input, {other: {c: 'C'}}) 67 | t.deepEqual(input, original, 'should not mutate object for top-level pointer') 68 | // You might think, well, why? Why shouldn't we do it and then have a test: 69 | // t.deepEqual(input, {other: {c: 'C'}}, 'should replace whole object') 70 | // And true, we could hack that by removing the current properties and setting the new ones, 71 | // but that only works for the case of object-replacing-object; 72 | // the following is just as valid (though clearly impossible)... 73 | Pointer.fromJSON('').set(input, 'root') 74 | t.deepEqual(input, original, 'should not mutate object for top-level pointer') 75 | // ...and it'd be weird to have it work for one but not the other. 76 | // See Issue #92 for more discussion of this limitation / behavior. 77 | }) 78 | 79 | test('Pointer#set object existing', t => { 80 | const input = {obj: {a: 'A', b: 'B'}} 81 | Pointer.fromJSON('/obj/b').set(input, 'BBB') 82 | t.deepEqual(input.obj.b, 'BBB', 'should set object value in-place') 83 | }) 84 | 85 | test('Pointer#set object new', t => { 86 | const input: any = {obj: {a: 'A', b: 'B'}} 87 | Pointer.fromJSON('/obj/c').set(input, 'C') 88 | t.deepEqual(input.obj.c, 'C', 'should add object value in-place') 89 | }) 90 | 91 | test('Pointer#set deep object new', t => { 92 | const input: any = {obj: {subobj: {a: 'A', b: 'B'}}} 93 | Pointer.fromJSON('/obj/subobj/c').set(input, 'C') 94 | t.deepEqual(input.obj.subobj.c, 'C', 'should add deep object value in-place') 95 | }) 96 | 97 | test('Pointer#set not found', t => { 98 | const input: any = {obj: {a: 'A', b: 'B'}} 99 | const original = clone(input) 100 | Pointer.fromJSON('/notfound/c').set(input, 'C') 101 | t.deepEqual(input, original, 'should not mutate object if parent not found') 102 | Pointer.fromJSON('/obj/notfound/c').set(input, 'C') 103 | t.deepEqual(input, original, 'should not mutate object if parent not found') 104 | Pointer.fromJSON('/notfound/subobj/c').set(input, 'C') 105 | t.deepEqual(input, original, 'should not mutate object if parent not found') 106 | }) 107 | -------------------------------------------------------------------------------- /test/spec.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import {join} from 'path' 4 | import {readFileSync} from 'fs' 5 | import * as yaml from 'js-yaml' 6 | 7 | import {applyPatch, createPatch} from '../index' 8 | import {Operation} from '../diff' 9 | import {Pointer} from '../pointer' 10 | import {clone} from '../util' 11 | 12 | interface Spec { 13 | name: string 14 | input: any 15 | patch: Operation[] 16 | output: any 17 | results: (string | null)[], 18 | diffable: boolean 19 | } 20 | 21 | const spec_data = yaml.load(readFileSync(join(__dirname, 'spec.yaml'), 22 | {encoding: 'utf8'})) as Spec[] 23 | 24 | test('JSON Pointer - rfc-examples', t => { 25 | // > For example, given the JSON document 26 | const obj = { 27 | 'foo': ['bar', 'baz'], 28 | '': 0, 29 | 'a/b': 1, 30 | 'c%d': 2, 31 | 'e^f': 3, 32 | 'g|h': 4, 33 | 'i\\j': 5, 34 | 'k\'l': 6, 35 | ' ': 7, 36 | 'm~n': 8, 37 | } 38 | 39 | // > The following JSON strings evaluate to the accompanying values 40 | const pointers = [ 41 | {path: '' , expected: obj}, 42 | {path: '/foo' , expected: ['bar', 'baz']}, 43 | {path: '/foo/0', expected: 'bar'}, 44 | {path: '/' , expected: 0}, 45 | {path: '/a~1b' , expected: 1}, 46 | {path: '/c%d' , expected: 2}, 47 | {path: '/e^f' , expected: 3}, 48 | {path: '/g|h' , expected: 4}, 49 | {path: '/i\\j' , expected: 5}, 50 | {path: '/k\'l' , expected: 6}, 51 | {path: '/ ' , expected: 7}, 52 | {path: '/m~0n' , expected: 8}, 53 | ] 54 | 55 | pointers.forEach(pointer => { 56 | const actual = Pointer.fromJSON(pointer.path).evaluate(obj).value 57 | t.deepEqual(actual, pointer.expected, `pointer "${pointer.path}" should evaluate to expected output`) 58 | }) 59 | }) 60 | 61 | test('JSON Pointer - package example', t => { 62 | const obj = { 63 | 'first': 'chris', 64 | 'last': 'brown', 65 | 'github': { 66 | account: { 67 | id: 'chbrown', 68 | handle: '@chbrown', 69 | }, 70 | repos: [ 71 | 'amulet', 'twilight', 'rfc6902', 72 | ], 73 | stars: [ 74 | { 75 | owner: 'raspberrypi', 76 | repo: 'userland', 77 | }, 78 | { 79 | owner: 'angular', 80 | repo: 'angular.js', 81 | }, 82 | ], 83 | }, 84 | 'github/account': 'deprecated', 85 | } 86 | 87 | const pointers = [ 88 | {path: '/first', expected: 'chris'}, 89 | {path: '/github~1account', expected: 'deprecated'}, 90 | {path: '/github/account/handle', expected: '@chbrown'}, 91 | {path: '/github/repos', expected: ['amulet', 'twilight', 'rfc6902']}, 92 | {path: '/github/repos/2', expected: 'rfc6902'}, 93 | {path: '/github/stars/0/repo', expected: 'userland'}, 94 | ] 95 | 96 | pointers.forEach(pointer => { 97 | const actual = Pointer.fromJSON(pointer.path).evaluate(obj).value 98 | t.deepEqual(actual, pointer.expected, `pointer "${pointer.path}" should evaluate to expected output`) 99 | }) 100 | }) 101 | 102 | test('Specification format', t => { 103 | t.deepEqual(spec_data.length, 19, 'should have 19 items') 104 | // use sorted values and sort() to emulate set equality 105 | const props = ['diffable', 'input', 'name', 'output', 'patch', 'results'] 106 | spec_data.forEach(spec => { 107 | t.deepEqual(Object.keys(spec).sort(), props, `"${spec.name}" should have items with specific properties`) 108 | }) 109 | }) 110 | 111 | // take the input, apply the patch, and check the actual result against the 112 | // expected output 113 | spec_data.forEach(spec => { 114 | test(`patch ${spec.name}`, t => { 115 | // patch operations are applied to object in-place 116 | const actual = clone(spec.input) 117 | const expected = spec.output 118 | const results = applyPatch(actual, spec.patch) 119 | t.deepEqual(actual, expected, `should equal expected output after applying patches`) 120 | // since errors are object instances, reduce them to strings to match 121 | // the spec's results, which has the type `Array` 122 | const results_names = results.map(error => error ? error.name : error) 123 | t.deepEqual(results_names, spec.results, `should produce expected results`) 124 | }) 125 | }) 126 | 127 | spec_data.filter(spec => spec.diffable).forEach(spec => { 128 | test(`diff ${spec.name}`, t => { 129 | // we read this separately because patch is destructive and it's easier just to start with a blank slate 130 | // ignore spec items that are marked as not diffable 131 | // perform diff (create patch = list of operations) and check result against non-test patches in spec 132 | const actual = createPatch(spec.input, spec.output) 133 | const expected = spec.patch.filter(operation => operation.op !== 'test') 134 | t.deepEqual(actual, expected, `should produce diff equal to spec patch`) 135 | }) 136 | }) 137 | -------------------------------------------------------------------------------- /dist/rfc6902.min.js: -------------------------------------------------------------------------------- 1 | (function(n,r){"object"===typeof exports&&"undefined"!==typeof module?r(exports):"function"===typeof define&&define.amd?define(["exports"],r):(n="undefined"!==typeof globalThis?globalThis:n||self,r(n.rfc6902={}))})(this,function(n){function r(a){return a.replace(/~1/g,"/").replace(/~0/g,"~")}function E(a){return a.replace(/~/g,"~0").replace(/\//g,"~1")}function z(a){return void 0===a?"undefined":null===a?"null":Array.isArray(a)?"array":typeof a}function v(a){if(null==a||"object"!=typeof a)return a; 2 | if(a.constructor==Array){var b=a.length,c=Array(b);for(let d=0;dI.cost-J.cost)[0]}f[h]=l}return l}const f={"0,0":{operations:[],cost:0}},B=isNaN(a.length)||0>=a.length?0:a.length;var u=isNaN(b.length)||0>=b.length?0:b.length;u=e(B,u).operations;[u]=u.reduce(([k,g],h)=>{if("add"===h.op){var l=h.index+1+g;h={op:h.op,path:c.add(l{e.push({op:"remove",path:c.add(f).toString()})});A(b,a).forEach(f=>{e.push({op:"add",path:c.add(f).toString(),value:b[f]})});G([a,b]).forEach(f=>{e.push(...d(a[f],b[f],c.add(f)))});return e}function q(a,b,c,d=q){if(a===b)return[];const e=z(a),f=z(b);return"array"==e&&"array"==f?H(a,b,c,d):"object"==e&&"object"==f?K(a,b,c,d):[{op:"replace",path:c.toString(),value:b}]}function y(a, 6 | b,c){Array.isArray(a)?"-"==b?a.push(c):(b=parseInt(b,10),a.splice(b,0,c)):a[b]=c}function C(a,b){Array.isArray(a)?(b=parseInt(b,10),a.splice(b,1)):delete a[b]}function L(a,b){a=m.fromJSON(b.path).evaluate(a);if(null===a.parent)return new p(b.path);if(Array.isArray(a.parent)){if(parseInt(a.key,10)>=a.parent.length)return new p(b.path)}else if(void 0===a.value)return new p(b.path);a.parent[a.key]=b.value;return null}function M(a,b){switch(b.op){case "add":return a=m.fromJSON(b.path).evaluate(a),void 0=== 7 | a.parent?b=new p(b.path):(y(a.parent,a.key,v(b.value)),b=null),b;case "remove":return a=m.fromJSON(b.path).evaluate(a),void 0===a.value?b=new p(b.path):(C(a.parent,a.key),b=null),b;case "replace":return L(a,b);case "move":var c=m.fromJSON(b.from).evaluate(a);void 0===c.value?b=new p(b.from):(a=m.fromJSON(b.path).evaluate(a),void 0===a.parent?b=new p(b.path):(C(c.parent,c.key),y(a.parent,a.key,c.value),b=null));return b;case "copy":return c=m.fromJSON(b.from).evaluate(a),void 0===c.value?b=new p(b.from): 8 | (a=m.fromJSON(b.path).evaluate(a),void 0===a.parent?b=new p(b.path):(y(a.parent,a.key,v(c.value)),b=null)),b;case "test":return a=m.fromJSON(b.path).evaluate(a),b=q(a.value,b.value,new m).length?new N(a.value,b.value):null,b}return new O(b)}function P(a){function b(c,d,e){const f=a(c,d,e);return Array.isArray(f)?f:q(c,d,e,b)}return b}function D(a,b){a=m.fromJSON(b).evaluate(a);if(void 0!==a)return{op:"test",path:b,value:a.value}}class m{constructor(a=[""]){this.tokens=a}static fromJSON(a){const b= 9 | a.split("/").map(r);if(""!==b[0])throw Error(`Invalid JSON Pointer: ${a}`);return new m(b)}toString(){return this.tokens.map(E).join("/")}evaluate(a){let b=null,c="";for(let d=1,e=this.tokens.length;dM(a,c))};n.createPatch=function(a,b,c){const d= 11 | new m;return(c?P(c):q)(a,b,d)};n.createTests=function(a,b){const c=[];b.filter(F).forEach(d=>{const e=D(a,d.path);e&&c.push(e);"from"in d&&(d=D(a,d.from))&&c.push(d)});return c};Object.defineProperty(n,"__esModule",{value:!0})}); 12 | -------------------------------------------------------------------------------- /test/createTests.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import {applyPatch, createTests} from '../index' 4 | import {AddOperation, RemoveOperation, ReplaceOperation, MoveOperation, CopyOperation, TestOperation} from '../diff' 5 | 6 | test('simple patch', t => { 7 | // > For example, given the JSON document 8 | const obj = {itemCodes: ['123', '456', '789']} 9 | 10 | // > and the following patch 11 | const patch: RemoveOperation[] = [{op: 'remove', path: '/itemCodes/1'}] 12 | 13 | // > should generate the following test 14 | const expected: TestOperation[] = [{op: 'test', path: '/itemCodes/1', value: '456'}] 15 | 16 | const actual = createTests(obj, patch) 17 | t.deepEqual(actual, expected, `patch "${JSON.stringify(patch)}" should generate the expected test`) 18 | 19 | const actualApply = applyPatch(obj, actual) 20 | t.deepEqual(actualApply, [null], `tests "${JSON.stringify(actual)}" should apply without errors`) 21 | }) 22 | 23 | test('complex patch', t => { 24 | // > For example, given the JSON document 25 | const obj = { 26 | items: [ 27 | { 28 | code: '123', 29 | description: 'item # 123', 30 | componentCodes: ['456', '789'], 31 | }, { 32 | code: '456', 33 | description: 'item # 456', 34 | componentCodes: ['789'], 35 | }, { 36 | code: '789', 37 | description: 'item # 789', 38 | componentCodes: [], 39 | }, 40 | ], 41 | } 42 | 43 | // > and the following patch 44 | const patch: RemoveOperation[] = [{op: 'remove', path: '/items/1'}] 45 | 46 | // > should generate the following test 47 | const expected: TestOperation[] = [ 48 | { 49 | op: 'test', 50 | path: '/items/1', 51 | value: { 52 | code: '456', 53 | description: 'item # 456', 54 | componentCodes: ['789'], 55 | }, 56 | }, 57 | ] 58 | 59 | const actual = createTests(obj, patch) 60 | t.deepEqual(actual, expected, `patch "${JSON.stringify(patch)}" should generate the expected test`) 61 | 62 | const actualApply = applyPatch(obj, actual) 63 | t.deepEqual(actualApply, [null], `tests "${JSON.stringify(actual)}" should apply without errors`) 64 | }) 65 | 66 | test('simple patch with add', t => { 67 | // > For example, given the JSON document 68 | const obj = {itemCodes: ['123', '456', '789']} 69 | 70 | // > and the following patch 71 | const patch: AddOperation[] = [{op: 'add', path: '/itemCodes/-', value: 'abc'}] 72 | 73 | // > should generate the following test 74 | const expected: TestOperation[] = [] 75 | 76 | const actual = createTests(obj, patch) 77 | t.deepEqual(actual, expected, `patch "${JSON.stringify(patch)}" should generate no tests`) 78 | }) 79 | 80 | test('simple patch with move', t => { 81 | // > For example, given the JSON document 82 | const obj = {itemCodes: ['123', '456', '789'], alternateItemCodes: ['abc']} 83 | 84 | // > and the following patch 85 | const patch: MoveOperation[] = [{op: 'move', from: '/itemCodes/1', path: '/alternateItemCodes/0'}] 86 | 87 | // > should generate the following test 88 | const expected: TestOperation[] = [ 89 | {op: 'test', path: '/alternateItemCodes/0', value: 'abc'}, 90 | {op: 'test', path: '/itemCodes/1', value: '456'}, 91 | ] 92 | 93 | const actual = createTests(obj, patch) 94 | t.deepEqual(actual, expected, `patch "${JSON.stringify(patch)}" should generate no tests`) 95 | 96 | const actualApply = applyPatch(obj, actual) 97 | t.deepEqual(actualApply, [null, null], `tests "${JSON.stringify(actual)}" should apply without errors`) 98 | }) 99 | 100 | test('simple patch with copy', t => { 101 | // > For example, given the JSON document 102 | const obj = {itemCodes: ['123', '456', '789'], alternateItemCodes: []} 103 | 104 | // > and the following patch 105 | const patch: CopyOperation[] = [ 106 | { 107 | op: 'copy', 108 | from: '/itemCodes/1', 109 | path: '/alternateItemCodes/0', 110 | }, 111 | ] 112 | 113 | // > should generate the following test 114 | const expected: TestOperation[] = [ 115 | {op: 'test', path: '/alternateItemCodes/0', value: undefined}, 116 | {op: 'test', path: '/itemCodes/1', value: '456'}, 117 | ] 118 | 119 | const actual = createTests(obj, patch) 120 | t.deepEqual(actual, expected, `patch "${JSON.stringify(patch)}" should generate no tests`) 121 | 122 | const actualApply = applyPatch(obj, actual) 123 | t.deepEqual(actualApply, [null, null], `tests "${JSON.stringify(actual)}" should apply without errors`) 124 | }) 125 | 126 | test('simple patch with replace', t => { 127 | // > For example, given the JSON document 128 | const obj = {itemCodes: ['123', '456', '789']} 129 | 130 | // > and the following patch 131 | const patch: ReplaceOperation[] = [{op: 'replace', path: '/itemCodes/1', value: 'abc'}] 132 | 133 | // > should generate the following test 134 | const expected: TestOperation[] = [{op: 'test', path: '/itemCodes/1', value: '456'}] 135 | 136 | const actual = createTests(obj, patch) 137 | t.deepEqual(actual, expected, `patch "${JSON.stringify(patch)}" should generate the expected test`) 138 | 139 | const actualApply = applyPatch(obj, actual) 140 | t.deepEqual(actualApply, [null], `tests "${JSON.stringify(actual)}" should apply without errors`) 141 | }) 142 | -------------------------------------------------------------------------------- /test/spec.yaml: -------------------------------------------------------------------------------- 1 | # Many of these (those labeled A.*) are pulled almost directly from the appendix of the RFC 2 | - name: A.1. Adding an Object Member 3 | input: 4 | {"foo": "bar"} 5 | patch: 6 | - {op: "add", path: "/baz", value: "qux"} 7 | output: 8 | {"baz": "qux", "foo": "bar"} 9 | results: [null] 10 | diffable: true 11 | 12 | - name: A.2. Adding an Array Element 13 | input: 14 | {"foo": [ "bar", "baz" ]} 15 | patch: 16 | - {op: "add", path: "/foo/1", value: "qux"} 17 | output: 18 | {"foo": [ "bar", "qux", "baz" ]} 19 | results: [null] 20 | diffable: true 21 | 22 | - name: A.3. Removing an Object Member 23 | input: 24 | {"baz": "qux", "foo": "bar"} 25 | patch: 26 | - {op: "remove", path: "/baz"} 27 | output: 28 | {"foo": "bar"} 29 | results: [null] 30 | diffable: true 31 | 32 | - name: A.4. Removing an Array Element 33 | input: 34 | {"foo": [ "bar", "qux", "baz" ]} 35 | patch: 36 | - {op: "remove", path: "/foo/1"} 37 | output: 38 | {"foo": [ "bar", "baz" ]} 39 | results: [null] 40 | diffable: true 41 | 42 | - name: A.5. Replacing a Value 43 | input: 44 | {"baz": "qux", "foo": "bar"} 45 | patch: 46 | - {op: "replace", path: "/baz", value: "boo"} 47 | output: 48 | {"baz": "boo", "foo": "bar"} 49 | results: [null] 50 | diffable: true 51 | 52 | - name: A.6. Moving a Value 53 | input: 54 | { 55 | "foo": { 56 | "bar": "baz", 57 | "waldo": "fred" 58 | }, 59 | "qux": { 60 | "corge": "grault" 61 | } 62 | } 63 | patch: 64 | - {op: "move", from: "/foo/waldo", path: "/qux/thud"} 65 | output: 66 | { 67 | "foo": { 68 | "bar": "baz" 69 | }, 70 | "qux": { 71 | "corge": "grault", 72 | "thud": "fred" 73 | } 74 | } 75 | results: [null] 76 | diffable: false 77 | 78 | - name: A.7. Moving an Array Element 79 | input: 80 | {"foo": [ "all", "grass", "cows", "eat" ]} 81 | patch: 82 | - {op: "move", from: "/foo/1", path: "/foo/3"} 83 | output: 84 | {"foo": [ "all", "cows", "eat", "grass" ]} 85 | results: [null] 86 | diffable: false 87 | 88 | - name: "A.8. Testing a Value: Success" 89 | input: 90 | {"baz": "qux", "foo": [ "a", 2, "c" ]} 91 | patch: 92 | - {op: "test", path: "/baz", value: "qux"} 93 | - {op: "test", path: "/foo/1", value: 2} 94 | output: 95 | {"baz": "qux", "foo": [ "a", 2, "c" ]} 96 | results: [null, null] 97 | diffable: true 98 | 99 | - name: "A.9. Testing a Value: Error" 100 | input: 101 | {"baz": "qux"} 102 | patch: 103 | - {op: "test", path: "/baz", value: "bar"} 104 | output: 105 | {"baz": "qux"} 106 | results: [TestError] 107 | diffable: false 108 | 109 | - name: A.10. Adding a Nested Member Object 110 | input: 111 | {"foo": "bar"} 112 | patch: 113 | - {op: "add", path: "/child", value: {"grandchild": {}}} 114 | output: 115 | { 116 | "foo": "bar", 117 | "child": { 118 | "grandchild": {} 119 | } 120 | } 121 | results: [null] 122 | diffable: true 123 | 124 | - name: A.11. Ignoring Unrecognized Elements 125 | input: 126 | {"foo": "bar"} 127 | patch: 128 | - {op: "add", path: "/baz", value: "qux", "xyz": 123} 129 | output: 130 | {"foo": "bar", "baz": "qux"} 131 | results: [null] 132 | diffable: false 133 | 134 | - name: A.12. Adding to a Nonexistent Target 135 | input: 136 | {"foo": "bar"} 137 | patch: 138 | - {op: "add", path: "/baz/bat", value: "qux"} 139 | output: 140 | {"foo": "bar"} 141 | results: [MissingError] 142 | diffable: false 143 | 144 | # # Due to JSON.parse's laxity, this type of error is not supported yet. 145 | # You know what, I'm gonnna go out on a limb here and say that this is not 146 | # an invalid patch, but has an undefined operation, which itself may be invalid. 147 | # See http://www.ietf.org/rfc/rfc4627.txt section 2.2, and then 148 | # http://www.ietf.org/rfc/rfc2119.txt section 3. 149 | # It's up to your JSON implementation how to handle duplicate keys. 150 | # In V8's JSON.parse, for example, the last key wins: 151 | # JSON.parse('[{op: "add", path: "/baz", value: "qux", op: "remove"}]') 152 | # >>> [ { op: 'remove', path: '/baz', value: 'qux' } ] 153 | # - name: A.13. Invalid JSON Patch Document 154 | # input: 155 | # {"foo": "bar"} 156 | # patch: 157 | # [{op: "add", path: "/baz", value: "qux", op: "remove"}] 158 | # output: 159 | # {"foo": "bar"} 160 | # results: [InvalidOperationError] 161 | - name: A.13.2 Invalid JSON Patch Document 162 | input: 163 | {"foo": "bar"} 164 | patch: 165 | - {op: "transcend", path: "/baz", value: "qux"} 166 | output: 167 | {"foo": "bar"} 168 | results: [InvalidOperationError] 169 | diffable: false 170 | 171 | # JSON Pointer encodes ~ as ~0 and / as ~1 172 | # thus /~01 is unescaped to ['', '~1'] 173 | # and /~10 is unescaped to ['', '/0'] 174 | - name: A.14. ~ Escape Ordering 175 | input: 176 | {"/": 9, "~1": 10} 177 | patch: 178 | - {op: "test", path: "/~01", value: 10} 179 | output: 180 | {"/": 9, "~1": 10} 181 | results: [null] 182 | diffable: true 183 | 184 | - name: A.15. Comparing Strings and Numbers 185 | input: 186 | {"/": 9, "~1": 10} 187 | patch: 188 | - {op: "test", path: "/~01", value: "10"} 189 | output: 190 | {"/": 9, "~1": 10} 191 | results: [TestError] 192 | diffable: false 193 | 194 | - name: A.16. Adding an Array Value 195 | input: 196 | {"foo": ["bar"]} 197 | patch: 198 | - {op: "add", path: "/foo/-", value: ["abc", "def"]} 199 | output: 200 | {"foo": ["bar", ["abc", "def"]]} 201 | results: [null] 202 | diffable: true 203 | 204 | # tests 205 | 206 | - name: Test types (failure) 207 | input: &test-types-input 208 | whole: 3 209 | ish: "3.14" 210 | parts: [3, 141, 592, 654] 211 | exact: false 212 | natural: null 213 | approximation: "true" 214 | float: 215 | significand: 314 216 | exponent: -2 217 | # careful, js-yaml passes anchors by reference! 218 | output: *test-types-input 219 | patch: 220 | - {op: "test", path: "/whole", value: "3"} 221 | - {op: "test", path: "/ish", value: 3.14} 222 | - {op: "test", path: "/parts", value: "3,141,592,654"} 223 | - {op: "test", path: "/parts/3", value: 654.001} 224 | - {op: "test", path: "/natural"} # YAML interprets `undefined` as "undefined", so `value` is left actually undefined 225 | - {op: "test", path: "/approximation", value: true} 226 | - {op: "test", path: "/float", value: [['significand', 314], ['exponent', -2]]} 227 | results: [TestError, TestError, TestError, TestError, TestError, TestError, TestError] 228 | diffable: false 229 | 230 | - name: Test types (success) 231 | # careful, js-yaml passes anchors by reference! 232 | input: *test-types-input 233 | output: *test-types-input 234 | patch: 235 | - {op: "test", path: "/whole", value: 3} 236 | - {op: "test", path: "/ish", value: "3.14"} 237 | - {op: "test", path: "/parts", value: [3, 141, 592, 654]} 238 | - {op: "test", path: "/parts/3", value: 654} 239 | - {op: "test", path: "/natural", value: null} 240 | - {op: "test", path: "/approximation", value: "true"} 241 | - {op: "test", path: "/float", value: {significand: 314, exponent: -2}} 242 | results: [null, null, null, null, null, null, null] 243 | diffable: true 244 | 245 | - name: Array vs. Object 246 | input: 247 | repositories: 248 | - amulet 249 | - flickr-with-uploads 250 | output: 251 | repositories: {} 252 | patch: 253 | - {op: "replace", path: "/repositories", value: {}} 254 | results: [null] 255 | diffable: true 256 | -------------------------------------------------------------------------------- /patch.ts: -------------------------------------------------------------------------------- 1 | import {Pointer} from './pointer' 2 | import {clone} from './util' 3 | import {AddOperation, 4 | RemoveOperation, 5 | ReplaceOperation, 6 | MoveOperation, 7 | CopyOperation, 8 | TestOperation, 9 | Operation, 10 | diffAny} from './diff' 11 | 12 | export class MissingError extends Error { 13 | constructor(public path: string) { 14 | super(`Value required at path: ${path}`) 15 | this.name = 'MissingError' 16 | } 17 | } 18 | 19 | export class TestError extends Error { 20 | constructor(public actual: any, public expected: any) { 21 | super(`Test failed: ${actual} != ${expected}`) 22 | this.name = 'TestError' 23 | } 24 | } 25 | 26 | function _add(object: any, key: string, value: any): void { 27 | if (Array.isArray(object)) { 28 | // `key` must be an index 29 | if (key == '-') { 30 | object.push(value) 31 | } 32 | else { 33 | const index = parseInt(key, 10) 34 | object.splice(index, 0, value) 35 | } 36 | } 37 | else { 38 | object[key] = value 39 | } 40 | } 41 | 42 | function _remove(object: any, key: string): void { 43 | if (Array.isArray(object)) { 44 | // '-' syntax doesn't make sense when removing 45 | const index = parseInt(key, 10) 46 | object.splice(index, 1) 47 | } 48 | else { 49 | // not sure what the proper behavior is when path = '' 50 | delete object[key] 51 | } 52 | } 53 | 54 | /** 55 | > o If the target location specifies an array index, a new value is 56 | > inserted into the array at the specified index. 57 | > o If the target location specifies an object member that does not 58 | > already exist, a new member is added to the object. 59 | > o If the target location specifies an object member that does exist, 60 | > that member's value is replaced. 61 | */ 62 | export function add(object: any, operation: AddOperation): MissingError | null { 63 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object) 64 | // it's not exactly a "MissingError" in the same way that `remove` is -- more like a MissingParent, or something 65 | if (endpoint.parent === undefined) { 66 | return new MissingError(operation.path) 67 | } 68 | _add(endpoint.parent, endpoint.key, clone(operation.value)) 69 | return null 70 | } 71 | 72 | /** 73 | > The "remove" operation removes the value at the target location. 74 | > The target location MUST exist for the operation to be successful. 75 | */ 76 | export function remove(object: any, operation: RemoveOperation): MissingError | null { 77 | // endpoint has parent, key, and value properties 78 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object) 79 | if (endpoint.value === undefined) { 80 | return new MissingError(operation.path) 81 | } 82 | // not sure what the proper behavior is when path = '' 83 | _remove(endpoint.parent, endpoint.key) 84 | return null 85 | } 86 | 87 | /** 88 | > The "replace" operation replaces the value at the target location 89 | > with a new value. The operation object MUST contain a "value" member 90 | > whose content specifies the replacement value. 91 | > The target location MUST exist for the operation to be successful. 92 | 93 | > This operation is functionally identical to a "remove" operation for 94 | > a value, followed immediately by an "add" operation at the same 95 | > location with the replacement value. 96 | 97 | Even more simply, it's like the add operation with an existence check. 98 | */ 99 | export function replace(object: any, operation: ReplaceOperation): MissingError | null { 100 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object) 101 | if (endpoint.parent === null) { 102 | return new MissingError(operation.path) 103 | } 104 | // this existence check treats arrays as a special case 105 | if (Array.isArray(endpoint.parent)) { 106 | if (parseInt(endpoint.key, 10) >= endpoint.parent.length) { 107 | return new MissingError(operation.path) 108 | } 109 | } 110 | else if (endpoint.value === undefined) { 111 | return new MissingError(operation.path) 112 | } 113 | endpoint.parent[endpoint.key] = clone(operation.value) 114 | return null 115 | } 116 | 117 | /** 118 | > The "move" operation removes the value at a specified location and 119 | > adds it to the target location. 120 | > The operation object MUST contain a "from" member, which is a string 121 | > containing a JSON Pointer value that references the location in the 122 | > target document to move the value from. 123 | > This operation is functionally identical to a "remove" operation on 124 | > the "from" location, followed immediately by an "add" operation at 125 | > the target location with the value that was just removed. 126 | 127 | > The "from" location MUST NOT be a proper prefix of the "path" 128 | > location; i.e., a location cannot be moved into one of its children. 129 | 130 | TODO: throw if the check described in the previous paragraph fails. 131 | */ 132 | export function move(object: any, operation: MoveOperation): MissingError | null { 133 | const from_endpoint = Pointer.fromJSON(operation.from).evaluate(object) 134 | if (from_endpoint.value === undefined) { 135 | return new MissingError(operation.from) 136 | } 137 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object) 138 | if (endpoint.parent === undefined) { 139 | return new MissingError(operation.path) 140 | } 141 | _remove(from_endpoint.parent, from_endpoint.key) 142 | _add(endpoint.parent, endpoint.key, from_endpoint.value) 143 | return null 144 | } 145 | 146 | /** 147 | > The "copy" operation copies the value at a specified location to the 148 | > target location. 149 | > The operation object MUST contain a "from" member, which is a string 150 | > containing a JSON Pointer value that references the location in the 151 | > target document to copy the value from. 152 | > The "from" location MUST exist for the operation to be successful. 153 | 154 | > This operation is functionally identical to an "add" operation at the 155 | > target location using the value specified in the "from" member. 156 | 157 | Alternatively, it's like 'move' without the 'remove'. 158 | */ 159 | export function copy(object: any, operation: CopyOperation): MissingError | null { 160 | const from_endpoint = Pointer.fromJSON(operation.from).evaluate(object) 161 | if (from_endpoint.value === undefined) { 162 | return new MissingError(operation.from) 163 | } 164 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object) 165 | if (endpoint.parent === undefined) { 166 | return new MissingError(operation.path) 167 | } 168 | _add(endpoint.parent, endpoint.key, clone(from_endpoint.value)) 169 | return null 170 | } 171 | 172 | /** 173 | > The "test" operation tests that a value at the target location is 174 | > equal to a specified value. 175 | > The operation object MUST contain a "value" member that conveys the 176 | > value to be compared to the target location's value. 177 | > The target location MUST be equal to the "value" value for the 178 | > operation to be considered successful. 179 | */ 180 | export function test(object: any, operation: TestOperation): TestError | null { 181 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object) 182 | // TODO: this diffAny(...).length usage could/should be lazy 183 | if (diffAny(endpoint.value, operation.value, new Pointer()).length) { 184 | return new TestError(endpoint.value, operation.value) 185 | } 186 | return null 187 | } 188 | 189 | export class InvalidOperationError extends Error { 190 | constructor(public operation: Operation) { 191 | super(`Invalid operation: ${operation.op}`) 192 | this.name = 'InvalidOperationError' 193 | } 194 | } 195 | 196 | /** 197 | Switch on `operation.op`, applying the corresponding patch function for each 198 | case to `object`. 199 | */ 200 | export function apply(object: any, operation: Operation): MissingError | InvalidOperationError | TestError | null { 201 | // not sure why TypeScript can't infer typesafety of: 202 | // {add, remove, replace, move, copy, test}[operation.op](object, operation) 203 | // (seems like a bug) 204 | switch (operation.op) { 205 | case 'add': return add(object, operation) 206 | case 'remove': return remove(object, operation) 207 | case 'replace': return replace(object, operation) 208 | case 'move': return move(object, operation) 209 | case 'copy': return copy(object, operation) 210 | case 'test': return test(object, operation) 211 | } 212 | return new InvalidOperationError(operation) 213 | } 214 | -------------------------------------------------------------------------------- /test/issues.ts: -------------------------------------------------------------------------------- 1 | import test, {ExecutionContext} from 'ava' 2 | 3 | import {applyPatch, createPatch} from '../index' 4 | import {Operation, VoidableDiff} from '../diff' 5 | import {Pointer} from '../pointer' 6 | import {clone} from '../util' 7 | 8 | import {resultName} from './_index' 9 | 10 | function checkRoundtrip(t: ExecutionContext, 11 | input: any, 12 | output: any, 13 | expected_patch: Operation[], 14 | diff?: VoidableDiff, 15 | actual_patch: Operation[] = createPatch(input, output, diff)) { 16 | t.deepEqual(actual_patch, expected_patch, 'should produce patch equal to expectation') 17 | const actual_output = clone(input) 18 | const patch_results = applyPatch(actual_output, actual_patch) 19 | t.deepEqual(actual_output, output, 'should apply patch to arrive at output') 20 | t.deepEqual(patch_results.length, actual_patch.length, 'should apply all patches') 21 | t.true(patch_results.every(result => result == null), 'should apply patch successfully') 22 | } 23 | 24 | test('issues/3', t => { 25 | const input = {arr: ['1', '2', '2']} 26 | const output = {arr: ['1']} 27 | const expected_patch: Operation[] = [ 28 | {op: 'remove', path: '/arr/1'}, 29 | {op: 'remove', path: '/arr/1'}, 30 | ] 31 | checkRoundtrip(t, input, output, expected_patch) 32 | }) 33 | 34 | test('issues/4', t => { 35 | const input = ['A', 'B'] 36 | const output = ['B', 'A'] 37 | const expected_patch: Operation[] = [ 38 | {op: 'add', path: '/0', value: 'B'}, 39 | {op: 'remove', path: '/2'}, 40 | ] 41 | checkRoundtrip(t, input, output, expected_patch) 42 | }) 43 | 44 | test('issues/5', t => { 45 | const input: string[] = [] 46 | const output = ['A', 'B'] 47 | const expected_patch: Operation[] = [ 48 | {op: 'add', path: '/-', value: 'A'}, 49 | {op: 'add', path: '/-', value: 'B'}, 50 | ] 51 | checkRoundtrip(t, input, output, expected_patch) 52 | }) 53 | 54 | test('issues/9', t => { 55 | const input = [{A: 1, B: 2}, {C: 3}] 56 | const output = [{A: 1, B: 20}, {C: 3}] 57 | const expected_patch: Operation[] = [ 58 | {op: 'replace', path: '/0/B', value: 20}, 59 | ] 60 | checkRoundtrip(t, input, output, expected_patch) 61 | }) 62 | 63 | test('issues/12', t => { 64 | const input = {name: 'ABC', repositories: ['a', 'e']} 65 | const output = {name: 'ABC', repositories: ['a', 'b', 'c', 'd', 'e']} 66 | const expected_patch: Operation[] = [ 67 | {op: 'add', path: '/repositories/1', value: 'b'}, 68 | {op: 'add', path: '/repositories/2', value: 'c'}, 69 | {op: 'add', path: '/repositories/3', value: 'd'}, 70 | ] 71 | checkRoundtrip(t, input, output, expected_patch) 72 | }) 73 | 74 | test('issues/15', t => { 75 | const customDiff: VoidableDiff = (input: any, output: any, ptr: Pointer) => { 76 | if (input instanceof Date && output instanceof Date && input.valueOf() != output.valueOf()) { 77 | return [{op: 'replace', path: ptr.toString(), value: output}] 78 | } 79 | } 80 | const input = {date: new Date(0)} 81 | const output = {date: new Date(1)} 82 | const expected_patch: Operation[] = [ 83 | {op: 'replace', path: '/date', value: new Date(1)}, 84 | ] 85 | checkRoundtrip(t, input, output, expected_patch, customDiff) 86 | }) 87 | 88 | test('issues/15/array', t => { 89 | const customDiff: VoidableDiff = (input: any, output: any, ptr: Pointer) => { 90 | if (input instanceof Date && output instanceof Date && input.valueOf() != output.valueOf()) { 91 | return [{op: 'replace', path: ptr.toString(), value: output}] 92 | } 93 | } 94 | const input = [new Date(0)] 95 | const output = [new Date(1)] 96 | const expected_patch: Operation[] = [ 97 | {op: 'replace', path: '/0', value: new Date(1)}, 98 | ] 99 | checkRoundtrip(t, input, output, expected_patch, customDiff) 100 | }) 101 | 102 | test('issues/29', t => { 103 | /** 104 | Custom diff function that short-circuits recursion when the last token 105 | in the current pointer is the key "stop_recursing", such that that key's 106 | values are compared as primitives rather than objects/arrays. 107 | */ 108 | const customDiff: VoidableDiff = (input: any, output: any, ptr: Pointer) => { 109 | if (ptr.tokens[ptr.tokens.length - 1] === 'stop_recursing') { 110 | // do not compare arrays, replace instead 111 | return [{op: 'replace', path: ptr.toString(), value: output}] 112 | } 113 | } 114 | 115 | const input = { 116 | normal: ['a', 'b'], 117 | stop_recursing: ['a', 'b'], 118 | } 119 | const output = { 120 | normal: ['a'], 121 | stop_recursing: ['a'], 122 | } 123 | const expected_patch: Operation[] = [ 124 | {op: 'remove', path: '/normal/1'}, 125 | {op: 'replace', path: '/stop_recursing', value: ['a']}, 126 | ] 127 | const actual_patch = createPatch(input, output, customDiff) 128 | checkRoundtrip(t, input, output, expected_patch, null, actual_patch) 129 | 130 | const nested_input = {root: input} 131 | const nested_output = {root: output} 132 | const nested_expected_patch: Operation[] = [ 133 | {op: 'remove', path: '/root/normal/1'}, 134 | {op: 'replace', path: '/root/stop_recursing', value: ['a']}, 135 | ] 136 | const nested_actual_patch = createPatch(nested_input, nested_output, customDiff) 137 | checkRoundtrip(t, nested_input, nested_output, nested_expected_patch, null, nested_actual_patch) 138 | }) 139 | 140 | test('issues/32', t => { 141 | const input = 'a' 142 | const output = 'b' 143 | const expected_patch: Operation[] = [ 144 | {op: 'replace', path: '', value: 'b'}, 145 | ] 146 | const actual_patch = createPatch(input, output) 147 | t.deepEqual(actual_patch, expected_patch, 'should produce patch equal to expectation') 148 | const actual_output = clone(input) 149 | const results = applyPatch(input, actual_patch) 150 | t.deepEqual(actual_output, 'a', 'should not change input') 151 | t.deepEqual(results.map(resultName), ['MissingError'], 'should result in MissingError') 152 | }) 153 | 154 | test('issues/33', t => { 155 | const object = {root: {0: 4}} 156 | const array = {root: [4]} 157 | checkRoundtrip(t, object, array, [ 158 | {op: 'replace', path: '/root', value: [4]}, 159 | ]) 160 | checkRoundtrip(t, array, object, [ 161 | {op: 'replace', path: '/root', value: {0: 4}}, 162 | ]) 163 | }) 164 | 165 | test('issues/34', t => { 166 | const input = [3, 4] 167 | const output = [3, 4] 168 | delete output[0] 169 | const expected_patch: Operation[] = [ 170 | {op: 'replace', path: '/0', value: undefined}, 171 | ] 172 | checkRoundtrip(t, input, output, expected_patch) 173 | }) 174 | 175 | test('issues/35', t => { 176 | const input = {name: 'bob', image: undefined, cat: null} 177 | const output = {name: 'bob', image: 'foo.jpg', cat: 'nikko'} 178 | const expected_patch: Operation[] = [ 179 | {op: 'add', path: '/image', value: 'foo.jpg'}, 180 | {op: 'replace', path: '/cat', value: 'nikko'}, 181 | ] 182 | checkRoundtrip(t, input, output, expected_patch) 183 | }) 184 | 185 | test('issues/36', t => { 186 | const input = [undefined, 'B'] // same as: const input = ['A', 'B']; delete input[0] 187 | const output = ['A', 'B'] 188 | const expected_patch: Operation[] = [ 189 | // could also be {op: 'add', ...} -- the spec isn't clear on what constitutes existence for arrays 190 | {op: 'replace', path: '/0', value: 'A'}, 191 | ] 192 | checkRoundtrip(t, input, output, expected_patch) 193 | }) 194 | 195 | test('issues/37', t => { 196 | const value = {id: 'chbrown'} 197 | const patch_results = applyPatch(value, [ 198 | {op: 'copy', from: '/id', path: '/name'}, 199 | ]) 200 | const expected_value = {id: 'chbrown', name: 'chbrown'} 201 | t.deepEqual(value, expected_value, 'should apply patch to arrive at output') 202 | t.true(patch_results.every(result => result == null), 'should apply patch successfully') 203 | }) 204 | 205 | test('issues/38', t => { 206 | const value = { 207 | current: {timestamp: 23}, 208 | history: [], 209 | } 210 | const patch_results = applyPatch(value, [ 211 | {op: 'copy', from: '/current', path: '/history/-'}, 212 | {op: 'replace', path: '/current/timestamp', value: 24}, 213 | {op: 'copy', from: '/current', path: '/history/-'}, 214 | ]) 215 | const expected_value = { 216 | current: {timestamp: 24}, 217 | history: [ 218 | {timestamp: 23}, 219 | {timestamp: 24}, 220 | ], 221 | } 222 | t.deepEqual(value, expected_value, 'should apply patch to arrive at output') 223 | t.true(patch_results.every(result => result == null), 'should apply patch successfully') 224 | }) 225 | 226 | test('issues/44', t => { 227 | const value = {} 228 | const author = {firstName: 'Chris'} 229 | const patch_results = applyPatch(value, [ 230 | {op: 'add', path: '/author', value: author}, 231 | {op: 'add', path: '/author/lastName', value: 'Brown'}, 232 | ]) 233 | const expected_value = { 234 | author: {firstName: 'Chris', lastName: 'Brown'}, 235 | } 236 | t.deepEqual(value, expected_value, 'should apply patch to arrive at output') 237 | t.true(patch_results.every(result => result == null), 'should apply patch successfully') 238 | t.deepEqual(author, {firstName: 'Chris'}, 'patch reference should not be changed') 239 | }) 240 | 241 | test('issues/76', t => { 242 | t.true(({} as any).polluted === undefined, 'Object prototype should not be polluted') 243 | const value = {} 244 | applyPatch(value, [ 245 | {op: 'add', path: '/__proto__/polluted', value: 'Hello!'} 246 | ]) 247 | t.true(({} as any).polluted === undefined, 'Object prototype should still not be polluted') 248 | }) 249 | 250 | test('issues/78', t => { 251 | const user = {firstName: 'Chris'} 252 | const patch_results = applyPatch(user, [ 253 | {op: 'add', path: '/createdAt', value: new Date('2010-08-10T22:10:48Z')}, 254 | ]) 255 | t.true(patch_results.every(result => result == null), 'should apply patch successfully') 256 | t.deepEqual(user['createdAt'].getTime(), 1281478248000, 'should add Date recoverably') 257 | }) 258 | 259 | test('issues/97', t => { 260 | const commits = [] 261 | const user = {firstName: 'Chris', commits: ['80f1243']} 262 | const patch_results = applyPatch(user, [ 263 | {op: 'replace', path: '/commits', value: commits}, 264 | {op: 'add', path: '/commits/-', value: '5d565c8'}, 265 | ]) 266 | t.true(patch_results.every(result => result == null), 'should apply patch successfully') 267 | t.deepEqual(user, {firstName: 'Chris', commits: ['5d565c8']}, 'should alter user correctly') 268 | t.is(commits.length, 0, 'original array should not be modified') 269 | }) 270 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rfc6902 2 | 3 | [![latest version published to npm](https://badge.fury.io/js/rfc6902.svg)](https://www.npmjs.com/package/rfc6902) 4 | [![monthly downloads from npm](https://img.shields.io/npm/dm/rfc6902.svg?style=flat)](https://www.npmjs.com/package/rfc6902) 5 | [![Travis CI build status](https://travis-ci.org/chbrown/rfc6902.svg?branch=master)](https://travis-ci.org/chbrown/rfc6902) 6 | [![Coverage status on Coveralls](https://coveralls.io/repos/github/chbrown/rfc6902/badge.svg?branch=master)](https://coveralls.io/github/chbrown/rfc6902?branch=master) 7 | 8 | Complete implementation of [RFC6902](http://tools.ietf.org/html/rfc6902) "JavaScript Object Notation (JSON) Patch" 9 | (including [RFC6901](http://tools.ietf.org/html/rfc6901) "JavaScript Object Notation (JSON) Pointer"), 10 | for creating and consuming `application/json-patch+json` documents. 11 | Also offers "diff" functionality without using `Object.observe`. 12 | 13 | 14 | ## Demo 15 | 16 | Simple [web app](https://chbrown.github.io/rfc6902/) using the browser-compiled version of the code. 17 | 18 | 19 | ## Quickstart 20 | 21 | ### Install locally 22 | 23 | ```sh 24 | npm install --save rfc6902 25 | ``` 26 | 27 | ### Import in your script 28 | 29 | ```js 30 | const rfc6902 = require('rfc6902') 31 | ``` 32 | 33 | ### Calculate diff between two objects 34 | 35 | ```js 36 | rfc6902.createPatch({first: 'Chris'}, {first: 'Chris', last: 'Brown'}) 37 | //⇒ [ { op: 'add', path: '/last', value: 'Brown' } ] 38 | ``` 39 | 40 | ### Apply a patch to some object 41 | 42 | ```js 43 | const users = [{first: 'Chris', last: 'Brown', age: 20}] 44 | rfc6902.applyPatch(users, [ 45 | {op: 'replace', path: '/0/age', value: 21}, 46 | {op: 'add', path: '/-', value: {first: 'Raphael', age: 37}}, 47 | ]) 48 | ``` 49 | The `applyPatch` function returns `[null, null]`, 50 | indicating there were two patches, both applied successfully. 51 | 52 | The `users` variable is modified in place; evaluate it to examine the end result: 53 | ```js 54 | users 55 | //⇒ [ { first: 'Chris', last: 'Brown', age: 21 }, 56 | // { first: 'Raphael', age: 37 } ] 57 | ``` 58 | 59 | 60 | ## API 61 | 62 | In ES6 syntax: 63 | ```js 64 | import {applyPatch, createPatch} from 'rfc6902' 65 | ``` 66 | 67 | Using [TypeScript](https://www.typescriptlang.org/) annotations for clarity: 68 | 69 | ### `applyPatch(object: any, patch: Operation[]): Array` 70 | 71 | The operations in `patch` are applied to `object` in-place. 72 | Returns a list of results as long as the given `patch`. 73 | If all operations were successful, each item in the returned list will be `null`. 74 | If any of them failed, the corresponding item in the returned list will be an Error instance 75 | with descriptive `.name` and `.message` properties. 76 | 77 | ### `createPatch(input: any, output: any, diff?: VoidableDiff): Operation[]` 78 | 79 | Returns a list of operations (a JSON Patch) of the required operations to make `input` equal to `output`. 80 | In most cases, there is more than one way to transform an object into another. 81 | This method is more efficient than wholesale replacement, 82 | but does not always provide the optimal list of patches. 83 | It uses a simple Levenshtein-type implementation with Arrays, 84 | but it doesn't try for anything much smarter than that, 85 | so it's limited to `remove`, `add`, and `replace` operations. 86 | 87 |
88 | Optional diff argument 89 | 90 | The optional `diff` argument allows the user to specify a partial function 91 | that's called before the built-in `diffAny` function. 92 | For example, to avoid recursing into instances of a custom class, say, `MyObject`: 93 | ```js 94 | function myDiff(input: any, output: any, ptr: Pointer) { 95 | if ((input instanceof MyObject || output instanceof MyObject) && input != output) { 96 | return [{op: 'replace', path: ptr.toString(), value: output}] 97 | } 98 | } 99 | const my_patch = createPatch(input, output, myDiff) 100 | ``` 101 | This will short-circuit on encountering an instance of `MyObject`, but otherwise recurse as usual. 102 | 103 |
104 | 105 | ### `Operation` 106 | 107 | ```typescript 108 | interface Operation { 109 | op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test' 110 | from?: string 111 | path?: string 112 | value?: string 113 | } 114 | ``` 115 | 116 | Different operations use different combinations of `from` / `value`; 117 | see [JSON Patch (RFC6902)](#json-patch-rfc6902) below. 118 | 119 | 120 | ## Changelog 121 | 122 | I'm not going to copy & paste my relatively descriptive commit messages into groups here; 123 | rather, these are just the changes that merited major version bumps: 124 | 125 | ### `4.x.x` → `5.0.0` (2021-12-15) 126 | 127 | * Short-circuits JSON pointer traversal over the prototype-polluting tokens `__proto__`, `constructor`, and `prototype`. I.e., `/a/__proto__/b` and `/a/b` evaluate to the same thing. 128 | This is in violation of the spec, 129 | which makes no special provisions for this idiosyncrasy of the JavaScript language, 130 | but AFAIK there's no way to strictly comply with the spec in JavaScript. 131 | It would probably be more correct to throw an error in those cases, 132 | but this 'solution' feels less disruptive / more in line with workarounds implemented by other libraries. 133 | 134 | ### `3.x.x` → `4.0.0` (2020-07-27) 135 | 136 | * Potential performance regression due to consolidating separate `compare(a, b): boolean` and `diff(a, b): Operation[]` logic into basically defining `compare(a, b)` as `!diff(a, b).length` (i.e., `diff(a, b)` returns empty array). 137 | This simplifies the codebase and ensures underlying semantics do not diverge, 138 | but potentially does unnecessary work in computing a full diff when all we really care about is whether there is at least one difference. 139 | It also facilitates the user completely specifying custom diff functionality with just one `diff` function, 140 | as opposed to a `diff` and corresponding `compare` 141 | (and avoids the headache of having to propagate both of those around internally). 142 | 143 | ### `2.x.x` → `3.0.0` (2018-09-17) 144 | 145 | * Corrects improper behavior in a few buggy edge cases, 146 | which might conceivably break consumers relying on incorrect behavior. 147 | (Tbh [that applies to most bugfixes](https://xkcd.com/1172/) but I felt there were enough to add up to incrementing the major version.) 148 | * Also moves around some of the internal API that was not intended to be used externally, 149 | but technically _was_ exported. 150 | If you're only importing the public API of `applyPatch`, `createPatch`, and `createTests` from `'rfc6902'`, 151 | nothing has changed. 152 | 153 | 154 | ## Implementation details 155 | 156 | ### Determinism 157 | 158 | If you've ever implemented Levenshtein's algorithm, 159 | or played tricks with `git rebase` to get a reasonable sequence of commits, 160 | you'll realize that computing diffs is rarely deterministic. 161 | E.g., to transform the string `ab` → `bc`, you could: 162 | 1. Delete `a` (⇒ `b`) 163 | 2. and then append `c` (⇒ `bc`) 164 | 165 | _Or..._ 166 | 1. Replace `b` with `c` (⇒ `ac`) 167 | 2. and then replace `a` with `b` (⇒ `bc`) 168 | 169 | Both consist of two operations, so either one is a valid solution. 170 | 171 | Applying `json-patch` documents is much easier than generating them, 172 | which might explain why, when I started this project, 173 | there were more than five patch-applying RFC6902 implementations in NPM, 174 | but none for generating a patch from two distinct objects. 175 | (There was one that used `Object.observe()`, which only works when you're the one making the changes, 176 | and only as long as `Object.observe()` hasn't been deprecated, which it has.) 177 | 178 | So when comparing _your_ data objects, you'll want to ensure that the patches it generates meet your needs. 179 | The algorithm used by this library is not optimal, 180 | but it's more efficient than the strategy of wholesale replacing everything that's not an exact match. 181 | 182 | Of course, this only applies to generating the patches. 183 | Applying them is deterministic and unambiguously specified by [RFC6902](http://tools.ietf.org/html/rfc6902). 184 | 185 | ### JSON Pointer (RFC6901) 186 | 187 | The [RFC](http://tools.ietf.org/html/rfc6901) is a quick and easy read, but here's the gist: 188 | 189 | * JSON Pointer is a system for pointing to some fragment of a JSON document. 190 | * A pointer is a string that is composed of zero or more /reference-token parts. 191 | - When there are zero (the empty string), the pointer indicates the entire JSON document. 192 | - Otherwise, the parts are read from left to right, each one selecting part of the current document, 193 | and presenting only that fragment of the document to the next part. 194 | * The reference-token bits are usually Object keys, 195 | but may also be (decimal) numerals, to indicate array indices. 196 | 197 | E.g., consider the NPM registry: 198 | 199 | ```js 200 | { 201 | "_updated": 1417985649051, 202 | "flickr-with-uploads": { 203 | "name": "flickr-with-uploads", 204 | "description": "Flickr API with OAuth 1.0A and uploads", 205 | "repository": { 206 | "type": "git", 207 | "url": "git://github.com/chbrown/flickr-with-uploads.git" 208 | }, 209 | "homepage": "https://github.com/chbrown/flickr-with-uploads", 210 | "keywords": [ 211 | "flickr", 212 | "api", 213 | "backup" 214 | ], 215 | ... 216 | }, 217 | ... 218 | } 219 | ``` 220 | 1. `/_updated`: this selects the value of that key, which is just a number: `1417985649051` 221 | 2. `/flickr-with-uploads`: This selects the entire object: 222 | ```js 223 | { 224 | "name": "flickr-with-uploads", 225 | "description": "Flickr API with OAuth 1.0A and uploads", 226 | "repository": { 227 | "type": "git", 228 | "url": "git://github.com/chbrown/flickr-with-uploads.git" 229 | }, 230 | "homepage": "https://github.com/chbrown/flickr-with-uploads", 231 | "keywords": [ 232 | "flickr", 233 | "api", 234 | "backup" 235 | ], 236 | ... 237 | } 238 | ``` 239 | 3. `/flickr-with-uploads/name`: this effectively applies the `/name` pointer to the result of the previous item, 240 | which selects the string, `"flickr-with-uploads"`. 241 | 4. `/flickr-with-uploads/keywords/1`: Array indices start at 0, 242 | so this selects the second item from the `keywords` array, namely, `"api"`. 243 | 244 | #### Rules: 245 | 246 | * A pointer, if it is not empty, must always start with a slash; 247 | otherwise, it is an "Invalid pointer syntax" error. 248 | * If a key within the JSON document contains a forward slash character 249 | (which is totally valid JSON, but not very nice), 250 | the `/` in the desired key should be replaced by the escape sequence, `~1`. 251 | * If a key within the JSON document contains a tilde (again valid JSON, but not very common), 252 | the `~` should be replaced by the other escape sequence, `~0`. 253 | This allows keys containing the literal string `~1` (which is especially cruel) 254 | to be referenced by a JSON pointer (e.g., `/~01` should return `true` when applied to the object `{"~1":true}`). 255 | * All double quotation marks, reverse slashes, 256 | and control characters _must_ escaped, since a JSON Pointer is a JSON string. 257 | * A pointer that refers to a non-existent value counts as an error, too. 258 | But not necessarily as fatal as a syntax error. 259 | 260 | #### Example 261 | 262 | This project implements JSON Pointer functionality; e.g.: 263 | 264 | ```js 265 | const {Pointer} = require('rfc6902') 266 | const repository = { 267 | contributors: ['chbrown', 'diachedelic', 'nathanrobinson', 'kbiedrzycki', 'stefanmaric'] 268 | } 269 | const pointer = Pointer.fromJSON('/contributors/0') 270 | //⇒ Pointer { tokens: [ '', 'contributors', '0' ] } 271 | pointer.get(repository) 272 | //⇒ 'chbrown' 273 | ``` 274 | 275 | ### JSON Patch (RFC6902) 276 | 277 | The [RFC](http://tools.ietf.org/html/rfc6902) is only 18 pages long, but here are the basics: 278 | 279 | A JSON Patch document is a JSON document such that: 280 | 281 | * The MIME Type is `application/json-patch+json` 282 | * The file extension is `.json-patch` 283 | * It is an array of patch objects, potentially empty. 284 | * Each patch object has a key, `op`, with one of the following six values, 285 | and an operator-specific set of other keys. 286 | - **`add`**: Insert the given `value` at `path`. Or replace it, if it already exists. 287 | If the parent of the intended target does not exist, produce an error. 288 | If the final reference-token of `path` is "`-`", and the parent is an array, append `value` to it. 289 | + `path`: JSON Pointer 290 | + `value`: JSON object 291 | - **`remove`**: Remove the value at `path`. Produces an error if it does not exist. 292 | If `path` refers to an element within an array, 293 | splice it out so that subsequent elements fill in the gap (decrementing the length of the array). 294 | + `path`: JSON Pointer 295 | - **`replace`**: Replace the current value at `path` with `value`. 296 | It's exactly the same as performing a `remove` operation and then an `add` operation on the same path, 297 | since there _must_ be a pre-existing value. 298 | + `path`: JSON Pointer 299 | + `value`: JSON object 300 | - **`move`**: Remove the value at `from`, and set `path` to that value. 301 | There _must_ be a value at `from`, but not necessarily at `path`; 302 | it's the same as performing a `remove` operation, and then an `add` operation, but on different paths. 303 | + `from`: JSON Pointer 304 | + `path`: JSON Pointer 305 | - **`copy`**: Get the value at `from` and set `path` to that value. 306 | Same as `move`, but doesn't remove the original value. 307 | + `from`: JSON Pointer 308 | + `path`: JSON Pointer 309 | - **`test`**: Check that the value at `path` is equal to `value`. 310 | If it is not, the entire patch is considered to be a failure. 311 | + `path`: JSON Pointer 312 | + `value`: JSON object 313 | 314 | 315 | ## License 316 | 317 | Copyright 2014-2021 Christopher Brown. 318 | [MIT Licensed](https://chbrown.github.io/licenses/MIT/#2014-2021). 319 | -------------------------------------------------------------------------------- /diff.ts: -------------------------------------------------------------------------------- 1 | import {Pointer} from './pointer' // we only need this for type inference 2 | import {hasOwnProperty, objectType} from './util' 3 | 4 | /** 5 | All diff* functions should return a list of operations, often empty. 6 | 7 | Each operation should be an object with two to four fields: 8 | * `op`: the name of the operation; one of "add", "remove", "replace", "move", 9 | "copy", or "test". 10 | * `path`: a JSON pointer string 11 | * `from`: a JSON pointer string 12 | * `value`: a JSON value 13 | 14 | The different operations have different arguments. 15 | * "add": [`path`, `value`] 16 | * "remove": [`path`] 17 | * "replace": [`path`, `value`] 18 | * "move": [`from`, `path`] 19 | * "copy": [`from`, `path`] 20 | * "test": [`path`, `value`] 21 | 22 | Currently this only really differentiates between Arrays, Objects, and 23 | Everything Else, which is pretty much just what JSON substantially 24 | differentiates between. 25 | */ 26 | 27 | export interface AddOperation { op: 'add', path: string, value: any } 28 | export interface RemoveOperation { op: 'remove', path: string } 29 | export interface ReplaceOperation { op: 'replace', path: string, value: any } 30 | export interface MoveOperation { op: 'move', from: string, path: string } 31 | export interface CopyOperation { op: 'copy', from: string, path: string } 32 | export interface TestOperation { op: 'test', path: string, value: any } 33 | 34 | export type Operation = AddOperation | 35 | RemoveOperation | 36 | ReplaceOperation | 37 | MoveOperation | 38 | CopyOperation | 39 | TestOperation 40 | 41 | export function isDestructive({op}: Operation): boolean { 42 | return op === 'remove' || op === 'replace' || op === 'copy' || op === 'move' 43 | } 44 | 45 | export type Diff = (input: any, output: any, ptr: Pointer) => Operation[] 46 | /** 47 | VoidableDiff exists to allow the user to provide a partial diff(...) function, 48 | falling back to the built-in diffAny(...) function if the user-provided function 49 | returns void. 50 | */ 51 | export type VoidableDiff = (input: any, output: any, ptr: Pointer) => Operation[] | void 52 | 53 | /** 54 | List the keys in `minuend` that are not in `subtrahend`. 55 | 56 | A key is only considered if it is both 1) an own-property (o.hasOwnProperty(k)) 57 | of the object, and 2) has a value that is not undefined. This is to match JSON 58 | semantics, where JSON object serialization drops keys with undefined values. 59 | 60 | @param minuend Object of interest 61 | @param subtrahend Object of comparison 62 | @returns Array of keys that are in `minuend` but not in `subtrahend`. 63 | */ 64 | export function subtract(minuend: {[index: string]: any}, subtrahend: {[index: string]: any}): string[] { 65 | const keys: string[] = [] 66 | for (const key in minuend) { 67 | if ( 68 | hasOwnProperty.call(minuend, key) && 69 | minuend[key] !== undefined && 70 | !(hasOwnProperty.call(subtrahend, key) && subtrahend[key] !== undefined) 71 | ) { 72 | keys.push(key) 73 | } 74 | } 75 | return keys 76 | } 77 | 78 | /** 79 | List the keys that shared by all `objects`. 80 | 81 | The semantics of what constitutes a "key" is described in {@link subtract}. 82 | 83 | @param objects Array of objects to compare 84 | @returns Array of keys that are in ("own-properties" of) every object in `objects`. 85 | */ 86 | export function intersection(objects: ArrayLike<{[index: string]: any}>): string[] { 87 | const length = objects.length 88 | // prepare empty counter to keep track of how many objects each key occurred in 89 | const counter: {[index: string]: number} = {} 90 | // go through each object and increment the counter for each key in that object 91 | for (let i = 0; i < length; i++) { 92 | const object = objects[i] 93 | for (const key in object) { 94 | if (hasOwnProperty.call(object, key) && object[key] !== undefined) { 95 | counter[key] = (counter[key] || 0) + 1 96 | } 97 | } 98 | } 99 | // now delete all keys from the counter that were not seen in every object 100 | for (const key in counter) { 101 | if (counter[key] < length) { 102 | delete counter[key] 103 | } 104 | } 105 | // finally, extract whatever keys remain in the counter 106 | return Object.keys(counter) 107 | } 108 | 109 | /** 110 | List the keys that shared by all `a` and `b`. 111 | 112 | The semantics of what constitutes a "key" is described in {@link subtract}. 113 | 114 | @param a First object to compare 115 | @param b Second object to compare 116 | @returns Array of keys that are in ("own-properties" of) `a` and `b`. 117 | */ 118 | function intersection2(a: {[index: string]: any}, b: {[index: string]: any}): string[] { 119 | const keys: string[] = [] 120 | for (const key in a) { 121 | if ( 122 | hasOwnProperty.call(a, key) && 123 | a[key] !== undefined && 124 | hasOwnProperty.call(b, key) && 125 | b[key] !== undefined 126 | ) { 127 | keys.push(key) 128 | } 129 | } 130 | return keys 131 | } 132 | 133 | interface ArrayAdd { op: 'add', index: number, value: any } 134 | interface ArrayRemove { op: 'remove', index: number } 135 | interface ArrayReplace { op: 'replace', index: number, original: any, value: any } 136 | /** These are not proper Operation objects, but will be converted into 137 | Operation objects eventually. {index} indicates the actual target position, 138 | never 'end-of-array' */ 139 | type ArrayOperation = ArrayAdd | ArrayRemove | ArrayReplace 140 | function isArrayAdd(array_operation: ArrayOperation): array_operation is ArrayAdd { 141 | return array_operation.op === 'add' 142 | } 143 | function isArrayRemove(array_operation: ArrayOperation): array_operation is ArrayRemove { 144 | return array_operation.op === 'remove' 145 | } 146 | 147 | interface DynamicAlternative { 148 | operations: ArrayOperation[] 149 | /** 150 | cost indicates the total cost of getting to this position. 151 | */ 152 | cost: number 153 | } 154 | 155 | function appendArrayOperation(base: DynamicAlternative, operation: ArrayOperation): DynamicAlternative { 156 | return { 157 | // the new operation must be pushed on the end 158 | operations: base.operations.concat(operation), 159 | cost: base.cost + 1, 160 | } 161 | } 162 | 163 | /** 164 | Calculate the shortest sequence of operations to get from `input` to `output`, 165 | using a dynamic programming implementation of the Levenshtein distance algorithm. 166 | 167 | To get from the input ABC to the output AZ we could just delete all the input 168 | and say "insert A, insert Z" and be done with it. That's what we do if the 169 | input is empty. But we can be smarter. 170 | 171 | output 172 | A Z 173 | - - 174 | [0] 1 2 175 | input A | 1 [0] 1 176 | B | 2 [1] 1 177 | C | 3 2 [2] 178 | 179 | 1) start at 0,0 (+0) 180 | 2) keep A (+0) 181 | 3) remove B (+1) 182 | 4) replace C with Z (+1) 183 | 184 | If the `input` (source) is empty, they'll all be in the top row, resulting in an 185 | array of 'add' operations. 186 | If the `output` (target) is empty, everything will be in the left column, 187 | resulting in an array of 'remove' operations. 188 | 189 | @returns A list of add/remove/replace operations. 190 | */ 191 | export function diffArrays(input: T[], output: T[], ptr: Pointer, diff: Diff = diffAny): Operation[] { 192 | // set up cost matrix (very simple initialization: just a map) 193 | const max_length = Math.max(input.length, output.length) 194 | const memo = new Map( 195 | [[0, {operations: [], cost: 0}]], 196 | ); 197 | /** 198 | Calculate the cheapest sequence of operations required to get from 199 | input.slice(0, i) to output.slice(0, j). 200 | There may be other valid sequences with the same cost, but none cheaper. 201 | 202 | @param i The row in the layout above 203 | @param j The column in the layout above 204 | @returns An object containing a list of operations, along with the total cost 205 | of applying them (+1 for each add/remove/replace operation) 206 | */ 207 | function dist(i: number, j: number): DynamicAlternative { 208 | // memoized 209 | const memo_key = i * max_length + j; 210 | let memoized = memo.get(memo_key) 211 | if (memoized === undefined) { 212 | // TODO: this !diff(...).length usage could/should be lazy 213 | if (i > 0 && j > 0 && !diff(input[i - 1], output[j - 1], ptr.add(String(i - 1))).length) { 214 | // equal (no operations => no cost) 215 | memoized = dist(i - 1, j - 1) 216 | } 217 | else { 218 | const alternatives: DynamicAlternative[] = [] 219 | if (i > 0) { 220 | // NOT topmost row 221 | const remove_base = dist(i - 1, j) 222 | const remove_operation: ArrayRemove = { 223 | op: 'remove', 224 | index: i - 1, 225 | } 226 | alternatives.push(appendArrayOperation(remove_base, remove_operation)) 227 | } 228 | if (j > 0) { 229 | // NOT leftmost column 230 | const add_base = dist(i, j - 1) 231 | const add_operation: ArrayAdd = { 232 | op: 'add', 233 | index: i - 1, 234 | value: output[j - 1], 235 | } 236 | alternatives.push(appendArrayOperation(add_base, add_operation)) 237 | } 238 | if (i > 0 && j > 0) { 239 | // TABLE MIDDLE 240 | // supposing we replaced it, compute the rest of the costs: 241 | const replace_base = dist(i - 1, j - 1) 242 | // okay, the general plan is to replace it, but we can be smarter, 243 | // recursing into the structure and replacing only part of it if 244 | // possible, but to do so we'll need the original value 245 | const replace_operation: ArrayReplace = { 246 | op: 'replace', 247 | index: i - 1, 248 | original: input[i - 1], 249 | value: output[j - 1], 250 | } 251 | alternatives.push(appendArrayOperation(replace_base, replace_operation)) 252 | } 253 | // the only other case, i === 0 && j === 0, has already been memoized 254 | 255 | // the meat of the algorithm: 256 | // sort by cost to find the lowest one (might be several ties for lowest) 257 | // [4, 6, 7, 1, 2].sort((a, b) => a - b) -> [ 1, 2, 4, 6, 7 ] 258 | const best = alternatives.sort((a, b) => a.cost - b.cost)[0] 259 | memoized = best 260 | } 261 | memo.set(memo_key, memoized) 262 | } 263 | return memoized 264 | } 265 | // handle weird objects masquerading as Arrays that don't have proper length 266 | // properties by using 0 for everything but positive numbers 267 | const input_length = (isNaN(input.length) || input.length <= 0) ? 0 : input.length 268 | const output_length = (isNaN(output.length) || output.length <= 0) ? 0 : output.length 269 | const array_operations = dist(input_length, output_length).operations 270 | const [padded_operations] = array_operations.reduce<[Operation[], number]>(([operations, padding], array_operation) => { 271 | if (isArrayAdd(array_operation)) { 272 | const padded_index = array_operation.index + 1 + padding 273 | const index_token = padded_index < (input_length + padding) ? String(padded_index) : '-' 274 | const operation = { 275 | op: array_operation.op, 276 | path: ptr.add(index_token).toString(), 277 | value: array_operation.value, 278 | } 279 | // padding++ // maybe only if array_operation.index > -1 ? 280 | return [operations.concat(operation), padding + 1] 281 | } 282 | else if (isArrayRemove(array_operation)) { 283 | const operation = { 284 | op: array_operation.op, 285 | path: ptr.add(String(array_operation.index + padding)).toString(), 286 | } 287 | // padding-- 288 | return [operations.concat(operation), padding - 1] 289 | } 290 | else { // replace 291 | const replace_ptr = ptr.add(String(array_operation.index + padding)) 292 | const replace_operations = diff(array_operation.original, array_operation.value, replace_ptr) 293 | return [operations.concat(...replace_operations), padding] 294 | } 295 | }, [[], 0]) 296 | return padded_operations 297 | } 298 | 299 | export function diffObjects(input: any, output: any, ptr: Pointer, diff: Diff = diffAny): Operation[] { 300 | // if a key is in input but not output -> remove it 301 | const operations: Operation[] = [] 302 | subtract(input, output).forEach(key => { 303 | operations.push({op: 'remove', path: ptr.add(key).toString()}) 304 | }) 305 | // if a key is in output but not input -> add it 306 | subtract(output, input).forEach(key => { 307 | operations.push({op: 'add', path: ptr.add(key).toString(), value: output[key]}) 308 | }) 309 | // if a key is in both, diff it recursively 310 | intersection2(input, output).forEach(key => { 311 | operations.push(...diff(input[key], output[key], ptr.add(key))) 312 | }) 313 | return operations 314 | } 315 | 316 | /** 317 | `diffAny()` returns an empty array if `input` and `output` are materially equal 318 | (i.e., would produce equivalent JSON); otherwise it produces an array of patches 319 | that would transform `input` into `output`. 320 | 321 | > Here, "equal" means that the value at the target location and the 322 | > value conveyed by "value" are of the same JSON type, and that they 323 | > are considered equal by the following rules for that type: 324 | > o strings: are considered equal if they contain the same number of 325 | > Unicode characters and their code points are byte-by-byte equal. 326 | > o numbers: are considered equal if their values are numerically 327 | > equal. 328 | > o arrays: are considered equal if they contain the same number of 329 | > values, and if each value can be considered equal to the value at 330 | > the corresponding position in the other array, using this list of 331 | > type-specific rules. 332 | > o objects: are considered equal if they contain the same number of 333 | > members, and if each member can be considered equal to a member in 334 | > the other object, by comparing their keys (as strings) and their 335 | > values (using this list of type-specific rules). 336 | > o literals (false, true, and null): are considered equal if they are 337 | > the same. 338 | */ 339 | export function diffAny(input: any, output: any, ptr: Pointer, diff: Diff = diffAny): Operation[] { 340 | // strict equality handles literals, numbers, and strings (a sufficient but not necessary cause) 341 | if (input === output) { 342 | return [] 343 | } 344 | const input_type = objectType(input) 345 | const output_type = objectType(output) 346 | if (input_type == 'array' && output_type == 'array') { 347 | return diffArrays(input, output, ptr, diff) 348 | } 349 | if (input_type == 'object' && output_type == 'object') { 350 | return diffObjects(input, output, ptr, diff) 351 | } 352 | // at this point we know that input and output are materially different; 353 | // could be array -> object, object -> array, boolean -> undefined, 354 | // number -> string, or some other combination, but nothing that can be split 355 | // up into multiple patches: so `output` must replace `input` wholesale. 356 | return [{op: 'replace', path: ptr.toString(), value: output}] 357 | } 358 | -------------------------------------------------------------------------------- /dist/rfc6902.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : 3 | typeof define === 'function' && define.amd ? define(['exports'], factory) : 4 | (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.rfc6902 = {})); 5 | })(this, (function (exports) { 'use strict'; 6 | 7 | /** 8 | Unescape token part of a JSON Pointer string 9 | 10 | `token` should *not* contain any '/' characters. 11 | 12 | > Evaluation of each reference token begins by decoding any escaped 13 | > character sequence. This is performed by first transforming any 14 | > occurrence of the sequence '~1' to '/', and then transforming any 15 | > occurrence of the sequence '~0' to '~'. By performing the 16 | > substitutions in this order, an implementation avoids the error of 17 | > turning '~01' first into '~1' and then into '/', which would be 18 | > incorrect (the string '~01' correctly becomes '~1' after 19 | > transformation). 20 | 21 | Here's my take: 22 | 23 | ~1 is unescaped with higher priority than ~0 because it is a lower-order escape character. 24 | I say "lower order" because '/' needs escaping due to the JSON Pointer serialization technique. 25 | Whereas, '~' is escaped because escaping '/' uses the '~' character. 26 | */ 27 | function unescape(token) { 28 | return token.replace(/~1/g, '/').replace(/~0/g, '~'); 29 | } 30 | /** Escape token part of a JSON Pointer string 31 | 32 | > '~' needs to be encoded as '~0' and '/' 33 | > needs to be encoded as '~1' when these characters appear in a 34 | > reference token. 35 | 36 | This is the exact inverse of `unescape()`, so the reverse replacements must take place in reverse order. 37 | */ 38 | function escape(token) { 39 | return token.replace(/~/g, '~0').replace(/\//g, '~1'); 40 | } 41 | /** 42 | JSON Pointer representation 43 | */ 44 | class Pointer { 45 | constructor(tokens = ['']) { 46 | this.tokens = tokens; 47 | } 48 | /** 49 | `path` *must* be a properly escaped string. 50 | */ 51 | static fromJSON(path) { 52 | const tokens = path.split('/').map(unescape); 53 | if (tokens[0] !== '') 54 | throw new Error(`Invalid JSON Pointer: ${path}`); 55 | return new Pointer(tokens); 56 | } 57 | toString() { 58 | return this.tokens.map(escape).join('/'); 59 | } 60 | /** 61 | Returns an object with 'parent', 'key', and 'value' properties. 62 | In the special case that this Pointer's path == "", 63 | this object will be {parent: null, key: '', value: object}. 64 | Otherwise, parent and key will have the property such that parent[key] == value. 65 | */ 66 | evaluate(object) { 67 | let parent = null; 68 | let key = ''; 69 | let value = object; 70 | for (let i = 1, l = this.tokens.length; i < l; i++) { 71 | parent = value; 72 | key = this.tokens[i]; 73 | if (key == '__proto__' || key == 'constructor' || key == 'prototype') { 74 | continue; 75 | } 76 | // not sure if this the best way to handle non-existant paths... 77 | value = (parent || {})[key]; 78 | } 79 | return { parent, key, value }; 80 | } 81 | get(object) { 82 | return this.evaluate(object).value; 83 | } 84 | set(object, value) { 85 | const endpoint = this.evaluate(object); 86 | if (endpoint.parent) { 87 | endpoint.parent[endpoint.key] = value; 88 | } 89 | } 90 | push(token) { 91 | // mutable 92 | this.tokens.push(token); 93 | } 94 | /** 95 | `token` should be a String. It'll be coerced to one anyway. 96 | 97 | immutable (shallowly) 98 | */ 99 | add(token) { 100 | const tokens = this.tokens.concat(String(token)); 101 | return new Pointer(tokens); 102 | } 103 | } 104 | 105 | const hasOwnProperty = Object.prototype.hasOwnProperty; 106 | function objectType(object) { 107 | if (object === undefined) { 108 | return 'undefined'; 109 | } 110 | if (object === null) { 111 | return 'null'; 112 | } 113 | if (Array.isArray(object)) { 114 | return 'array'; 115 | } 116 | return typeof object; 117 | } 118 | function isNonPrimitive(value) { 119 | // loose-equality checking for null is faster than strict checking for each of null/undefined/true/false 120 | // checking null first, then calling typeof, is faster than vice-versa 121 | return value != null && typeof value == 'object'; 122 | } 123 | /** 124 | Recursively copy a value. 125 | 126 | @param source - should be a JavaScript primitive, Array, Date, or (plain old) Object. 127 | @returns copy of source where every Array and Object have been recursively 128 | reconstructed from their constituent elements 129 | */ 130 | function clone(source) { 131 | if (!isNonPrimitive(source)) { 132 | // short-circuiting is faster than a single return 133 | return source; 134 | } 135 | // x.constructor == Array is the fastest way to check if x is an Array 136 | if (source.constructor == Array) { 137 | // construction via imperative for-loop is faster than source.map(arrayVsObject) 138 | const length = source.length; 139 | // setting the Array length during construction is faster than just `[]` or `new Array()` 140 | const arrayTarget = new Array(length); 141 | for (let i = 0; i < length; i++) { 142 | arrayTarget[i] = clone(source[i]); 143 | } 144 | return arrayTarget; 145 | } 146 | // Date 147 | if (source.constructor == Date) { 148 | const dateTarget = new Date(+source); 149 | return dateTarget; 150 | } 151 | // Object 152 | const objectTarget = {}; 153 | // declaring the variable (with const) inside the loop is faster 154 | for (const key in source) { 155 | // hasOwnProperty costs a bit of performance, but it's semantically necessary 156 | // using a global helper is MUCH faster than calling source.hasOwnProperty(key) 157 | if (hasOwnProperty.call(source, key)) { 158 | objectTarget[key] = clone(source[key]); 159 | } 160 | } 161 | return objectTarget; 162 | } 163 | 164 | function isDestructive({ op }) { 165 | return op === 'remove' || op === 'replace' || op === 'copy' || op === 'move'; 166 | } 167 | /** 168 | List the keys in `minuend` that are not in `subtrahend`. 169 | 170 | A key is only considered if it is both 1) an own-property (o.hasOwnProperty(k)) 171 | of the object, and 2) has a value that is not undefined. This is to match JSON 172 | semantics, where JSON object serialization drops keys with undefined values. 173 | 174 | @param minuend Object of interest 175 | @param subtrahend Object of comparison 176 | @returns Array of keys that are in `minuend` but not in `subtrahend`. 177 | */ 178 | function subtract(minuend, subtrahend) { 179 | // initialize empty object; we only care about the keys, the values can be anything 180 | const obj = {}; 181 | // build up obj with all the properties of minuend 182 | for (const add_key in minuend) { 183 | if (hasOwnProperty.call(minuend, add_key) && minuend[add_key] !== undefined) { 184 | obj[add_key] = 1; 185 | } 186 | } 187 | // now delete all the properties of subtrahend from obj 188 | // (deleting a missing key has no effect) 189 | for (const del_key in subtrahend) { 190 | if (hasOwnProperty.call(subtrahend, del_key) && subtrahend[del_key] !== undefined) { 191 | delete obj[del_key]; 192 | } 193 | } 194 | // finally, extract whatever keys remain in obj 195 | return Object.keys(obj); 196 | } 197 | /** 198 | List the keys that shared by all `objects`. 199 | 200 | The semantics of what constitutes a "key" is described in {@link subtract}. 201 | 202 | @param objects Array of objects to compare 203 | @returns Array of keys that are in ("own-properties" of) every object in `objects`. 204 | */ 205 | function intersection(objects) { 206 | const length = objects.length; 207 | // prepare empty counter to keep track of how many objects each key occurred in 208 | const counter = {}; 209 | // go through each object and increment the counter for each key in that object 210 | for (let i = 0; i < length; i++) { 211 | const object = objects[i]; 212 | for (const key in object) { 213 | if (hasOwnProperty.call(object, key) && object[key] !== undefined) { 214 | counter[key] = (counter[key] || 0) + 1; 215 | } 216 | } 217 | } 218 | // now delete all keys from the counter that were not seen in every object 219 | for (const key in counter) { 220 | if (counter[key] < length) { 221 | delete counter[key]; 222 | } 223 | } 224 | // finally, extract whatever keys remain in the counter 225 | return Object.keys(counter); 226 | } 227 | function isArrayAdd(array_operation) { 228 | return array_operation.op === 'add'; 229 | } 230 | function isArrayRemove(array_operation) { 231 | return array_operation.op === 'remove'; 232 | } 233 | function appendArrayOperation(base, operation) { 234 | return { 235 | // the new operation must be pushed on the end 236 | operations: base.operations.concat(operation), 237 | cost: base.cost + 1, 238 | }; 239 | } 240 | /** 241 | Calculate the shortest sequence of operations to get from `input` to `output`, 242 | using a dynamic programming implementation of the Levenshtein distance algorithm. 243 | 244 | To get from the input ABC to the output AZ we could just delete all the input 245 | and say "insert A, insert Z" and be done with it. That's what we do if the 246 | input is empty. But we can be smarter. 247 | 248 | output 249 | A Z 250 | - - 251 | [0] 1 2 252 | input A | 1 [0] 1 253 | B | 2 [1] 1 254 | C | 3 2 [2] 255 | 256 | 1) start at 0,0 (+0) 257 | 2) keep A (+0) 258 | 3) remove B (+1) 259 | 4) replace C with Z (+1) 260 | 261 | If the `input` (source) is empty, they'll all be in the top row, resulting in an 262 | array of 'add' operations. 263 | If the `output` (target) is empty, everything will be in the left column, 264 | resulting in an array of 'remove' operations. 265 | 266 | @returns A list of add/remove/replace operations. 267 | */ 268 | function diffArrays(input, output, ptr, diff = diffAny) { 269 | // set up cost matrix (very simple initialization: just a map) 270 | const memo = { 271 | '0,0': { operations: [], cost: 0 }, 272 | }; 273 | /** 274 | Calculate the cheapest sequence of operations required to get from 275 | input.slice(0, i) to output.slice(0, j). 276 | There may be other valid sequences with the same cost, but none cheaper. 277 | 278 | @param i The row in the layout above 279 | @param j The column in the layout above 280 | @returns An object containing a list of operations, along with the total cost 281 | of applying them (+1 for each add/remove/replace operation) 282 | */ 283 | function dist(i, j) { 284 | // memoized 285 | const memo_key = `${i},${j}`; 286 | let memoized = memo[memo_key]; 287 | if (memoized === undefined) { 288 | // TODO: this !diff(...).length usage could/should be lazy 289 | if (i > 0 && j > 0 && !diff(input[i - 1], output[j - 1], ptr.add(String(i - 1))).length) { 290 | // equal (no operations => no cost) 291 | memoized = dist(i - 1, j - 1); 292 | } 293 | else { 294 | const alternatives = []; 295 | if (i > 0) { 296 | // NOT topmost row 297 | const remove_base = dist(i - 1, j); 298 | const remove_operation = { 299 | op: 'remove', 300 | index: i - 1, 301 | }; 302 | alternatives.push(appendArrayOperation(remove_base, remove_operation)); 303 | } 304 | if (j > 0) { 305 | // NOT leftmost column 306 | const add_base = dist(i, j - 1); 307 | const add_operation = { 308 | op: 'add', 309 | index: i - 1, 310 | value: output[j - 1], 311 | }; 312 | alternatives.push(appendArrayOperation(add_base, add_operation)); 313 | } 314 | if (i > 0 && j > 0) { 315 | // TABLE MIDDLE 316 | // supposing we replaced it, compute the rest of the costs: 317 | const replace_base = dist(i - 1, j - 1); 318 | // okay, the general plan is to replace it, but we can be smarter, 319 | // recursing into the structure and replacing only part of it if 320 | // possible, but to do so we'll need the original value 321 | const replace_operation = { 322 | op: 'replace', 323 | index: i - 1, 324 | original: input[i - 1], 325 | value: output[j - 1], 326 | }; 327 | alternatives.push(appendArrayOperation(replace_base, replace_operation)); 328 | } 329 | // the only other case, i === 0 && j === 0, has already been memoized 330 | // the meat of the algorithm: 331 | // sort by cost to find the lowest one (might be several ties for lowest) 332 | // [4, 6, 7, 1, 2].sort((a, b) => a - b) -> [ 1, 2, 4, 6, 7 ] 333 | const best = alternatives.sort((a, b) => a.cost - b.cost)[0]; 334 | memoized = best; 335 | } 336 | memo[memo_key] = memoized; 337 | } 338 | return memoized; 339 | } 340 | // handle weird objects masquerading as Arrays that don't have proper length 341 | // properties by using 0 for everything but positive numbers 342 | const input_length = (isNaN(input.length) || input.length <= 0) ? 0 : input.length; 343 | const output_length = (isNaN(output.length) || output.length <= 0) ? 0 : output.length; 344 | const array_operations = dist(input_length, output_length).operations; 345 | const [padded_operations] = array_operations.reduce(([operations, padding], array_operation) => { 346 | if (isArrayAdd(array_operation)) { 347 | const padded_index = array_operation.index + 1 + padding; 348 | const index_token = padded_index < (input_length + padding) ? String(padded_index) : '-'; 349 | const operation = { 350 | op: array_operation.op, 351 | path: ptr.add(index_token).toString(), 352 | value: array_operation.value, 353 | }; 354 | // padding++ // maybe only if array_operation.index > -1 ? 355 | return [operations.concat(operation), padding + 1]; 356 | } 357 | else if (isArrayRemove(array_operation)) { 358 | const operation = { 359 | op: array_operation.op, 360 | path: ptr.add(String(array_operation.index + padding)).toString(), 361 | }; 362 | // padding-- 363 | return [operations.concat(operation), padding - 1]; 364 | } 365 | else { // replace 366 | const replace_ptr = ptr.add(String(array_operation.index + padding)); 367 | const replace_operations = diff(array_operation.original, array_operation.value, replace_ptr); 368 | return [operations.concat(...replace_operations), padding]; 369 | } 370 | }, [[], 0]); 371 | return padded_operations; 372 | } 373 | function diffObjects(input, output, ptr, diff = diffAny) { 374 | // if a key is in input but not output -> remove it 375 | const operations = []; 376 | subtract(input, output).forEach(key => { 377 | operations.push({ op: 'remove', path: ptr.add(key).toString() }); 378 | }); 379 | // if a key is in output but not input -> add it 380 | subtract(output, input).forEach(key => { 381 | operations.push({ op: 'add', path: ptr.add(key).toString(), value: output[key] }); 382 | }); 383 | // if a key is in both, diff it recursively 384 | intersection([input, output]).forEach(key => { 385 | operations.push(...diff(input[key], output[key], ptr.add(key))); 386 | }); 387 | return operations; 388 | } 389 | /** 390 | `diffAny()` returns an empty array if `input` and `output` are materially equal 391 | (i.e., would produce equivalent JSON); otherwise it produces an array of patches 392 | that would transform `input` into `output`. 393 | 394 | > Here, "equal" means that the value at the target location and the 395 | > value conveyed by "value" are of the same JSON type, and that they 396 | > are considered equal by the following rules for that type: 397 | > o strings: are considered equal if they contain the same number of 398 | > Unicode characters and their code points are byte-by-byte equal. 399 | > o numbers: are considered equal if their values are numerically 400 | > equal. 401 | > o arrays: are considered equal if they contain the same number of 402 | > values, and if each value can be considered equal to the value at 403 | > the corresponding position in the other array, using this list of 404 | > type-specific rules. 405 | > o objects: are considered equal if they contain the same number of 406 | > members, and if each member can be considered equal to a member in 407 | > the other object, by comparing their keys (as strings) and their 408 | > values (using this list of type-specific rules). 409 | > o literals (false, true, and null): are considered equal if they are 410 | > the same. 411 | */ 412 | function diffAny(input, output, ptr, diff = diffAny) { 413 | // strict equality handles literals, numbers, and strings (a sufficient but not necessary cause) 414 | if (input === output) { 415 | return []; 416 | } 417 | const input_type = objectType(input); 418 | const output_type = objectType(output); 419 | if (input_type == 'array' && output_type == 'array') { 420 | return diffArrays(input, output, ptr, diff); 421 | } 422 | if (input_type == 'object' && output_type == 'object') { 423 | return diffObjects(input, output, ptr, diff); 424 | } 425 | // at this point we know that input and output are materially different; 426 | // could be array -> object, object -> array, boolean -> undefined, 427 | // number -> string, or some other combination, but nothing that can be split 428 | // up into multiple patches: so `output` must replace `input` wholesale. 429 | return [{ op: 'replace', path: ptr.toString(), value: output }]; 430 | } 431 | 432 | class MissingError extends Error { 433 | constructor(path) { 434 | super(`Value required at path: ${path}`); 435 | this.path = path; 436 | this.name = 'MissingError'; 437 | } 438 | } 439 | class TestError extends Error { 440 | constructor(actual, expected) { 441 | super(`Test failed: ${actual} != ${expected}`); 442 | this.actual = actual; 443 | this.expected = expected; 444 | this.name = 'TestError'; 445 | } 446 | } 447 | function _add(object, key, value) { 448 | if (Array.isArray(object)) { 449 | // `key` must be an index 450 | if (key == '-') { 451 | object.push(value); 452 | } 453 | else { 454 | const index = parseInt(key, 10); 455 | object.splice(index, 0, value); 456 | } 457 | } 458 | else { 459 | object[key] = value; 460 | } 461 | } 462 | function _remove(object, key) { 463 | if (Array.isArray(object)) { 464 | // '-' syntax doesn't make sense when removing 465 | const index = parseInt(key, 10); 466 | object.splice(index, 1); 467 | } 468 | else { 469 | // not sure what the proper behavior is when path = '' 470 | delete object[key]; 471 | } 472 | } 473 | /** 474 | > o If the target location specifies an array index, a new value is 475 | > inserted into the array at the specified index. 476 | > o If the target location specifies an object member that does not 477 | > already exist, a new member is added to the object. 478 | > o If the target location specifies an object member that does exist, 479 | > that member's value is replaced. 480 | */ 481 | function add(object, operation) { 482 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object); 483 | // it's not exactly a "MissingError" in the same way that `remove` is -- more like a MissingParent, or something 484 | if (endpoint.parent === undefined) { 485 | return new MissingError(operation.path); 486 | } 487 | _add(endpoint.parent, endpoint.key, clone(operation.value)); 488 | return null; 489 | } 490 | /** 491 | > The "remove" operation removes the value at the target location. 492 | > The target location MUST exist for the operation to be successful. 493 | */ 494 | function remove(object, operation) { 495 | // endpoint has parent, key, and value properties 496 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object); 497 | if (endpoint.value === undefined) { 498 | return new MissingError(operation.path); 499 | } 500 | // not sure what the proper behavior is when path = '' 501 | _remove(endpoint.parent, endpoint.key); 502 | return null; 503 | } 504 | /** 505 | > The "replace" operation replaces the value at the target location 506 | > with a new value. The operation object MUST contain a "value" member 507 | > whose content specifies the replacement value. 508 | > The target location MUST exist for the operation to be successful. 509 | 510 | > This operation is functionally identical to a "remove" operation for 511 | > a value, followed immediately by an "add" operation at the same 512 | > location with the replacement value. 513 | 514 | Even more simply, it's like the add operation with an existence check. 515 | */ 516 | function replace(object, operation) { 517 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object); 518 | if (endpoint.parent === null) { 519 | return new MissingError(operation.path); 520 | } 521 | // this existence check treats arrays as a special case 522 | if (Array.isArray(endpoint.parent)) { 523 | if (parseInt(endpoint.key, 10) >= endpoint.parent.length) { 524 | return new MissingError(operation.path); 525 | } 526 | } 527 | else if (endpoint.value === undefined) { 528 | return new MissingError(operation.path); 529 | } 530 | endpoint.parent[endpoint.key] = operation.value; 531 | return null; 532 | } 533 | /** 534 | > The "move" operation removes the value at a specified location and 535 | > adds it to the target location. 536 | > The operation object MUST contain a "from" member, which is a string 537 | > containing a JSON Pointer value that references the location in the 538 | > target document to move the value from. 539 | > This operation is functionally identical to a "remove" operation on 540 | > the "from" location, followed immediately by an "add" operation at 541 | > the target location with the value that was just removed. 542 | 543 | > The "from" location MUST NOT be a proper prefix of the "path" 544 | > location; i.e., a location cannot be moved into one of its children. 545 | 546 | TODO: throw if the check described in the previous paragraph fails. 547 | */ 548 | function move(object, operation) { 549 | const from_endpoint = Pointer.fromJSON(operation.from).evaluate(object); 550 | if (from_endpoint.value === undefined) { 551 | return new MissingError(operation.from); 552 | } 553 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object); 554 | if (endpoint.parent === undefined) { 555 | return new MissingError(operation.path); 556 | } 557 | _remove(from_endpoint.parent, from_endpoint.key); 558 | _add(endpoint.parent, endpoint.key, from_endpoint.value); 559 | return null; 560 | } 561 | /** 562 | > The "copy" operation copies the value at a specified location to the 563 | > target location. 564 | > The operation object MUST contain a "from" member, which is a string 565 | > containing a JSON Pointer value that references the location in the 566 | > target document to copy the value from. 567 | > The "from" location MUST exist for the operation to be successful. 568 | 569 | > This operation is functionally identical to an "add" operation at the 570 | > target location using the value specified in the "from" member. 571 | 572 | Alternatively, it's like 'move' without the 'remove'. 573 | */ 574 | function copy(object, operation) { 575 | const from_endpoint = Pointer.fromJSON(operation.from).evaluate(object); 576 | if (from_endpoint.value === undefined) { 577 | return new MissingError(operation.from); 578 | } 579 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object); 580 | if (endpoint.parent === undefined) { 581 | return new MissingError(operation.path); 582 | } 583 | _add(endpoint.parent, endpoint.key, clone(from_endpoint.value)); 584 | return null; 585 | } 586 | /** 587 | > The "test" operation tests that a value at the target location is 588 | > equal to a specified value. 589 | > The operation object MUST contain a "value" member that conveys the 590 | > value to be compared to the target location's value. 591 | > The target location MUST be equal to the "value" value for the 592 | > operation to be considered successful. 593 | */ 594 | function test(object, operation) { 595 | const endpoint = Pointer.fromJSON(operation.path).evaluate(object); 596 | // TODO: this diffAny(...).length usage could/should be lazy 597 | if (diffAny(endpoint.value, operation.value, new Pointer()).length) { 598 | return new TestError(endpoint.value, operation.value); 599 | } 600 | return null; 601 | } 602 | class InvalidOperationError extends Error { 603 | constructor(operation) { 604 | super(`Invalid operation: ${operation.op}`); 605 | this.operation = operation; 606 | this.name = 'InvalidOperationError'; 607 | } 608 | } 609 | /** 610 | Switch on `operation.op`, applying the corresponding patch function for each 611 | case to `object`. 612 | */ 613 | function apply(object, operation) { 614 | // not sure why TypeScript can't infer typesafety of: 615 | // {add, remove, replace, move, copy, test}[operation.op](object, operation) 616 | // (seems like a bug) 617 | switch (operation.op) { 618 | case 'add': return add(object, operation); 619 | case 'remove': return remove(object, operation); 620 | case 'replace': return replace(object, operation); 621 | case 'move': return move(object, operation); 622 | case 'copy': return copy(object, operation); 623 | case 'test': return test(object, operation); 624 | } 625 | return new InvalidOperationError(operation); 626 | } 627 | 628 | /** 629 | Apply a 'application/json-patch+json'-type patch to an object. 630 | 631 | `patch` *must* be an array of operations. 632 | 633 | > Operation objects MUST have exactly one "op" member, whose value 634 | > indicates the operation to perform. Its value MUST be one of "add", 635 | > "remove", "replace", "move", "copy", or "test"; other values are 636 | > errors. 637 | 638 | This method mutates the target object in-place. 639 | 640 | @returns list of results, one for each operation: `null` indicated success, 641 | otherwise, the result will be an instance of one of the Error classes: 642 | MissingError, InvalidOperationError, or TestError. 643 | */ 644 | function applyPatch(object, patch) { 645 | return patch.map(operation => apply(object, operation)); 646 | } 647 | function wrapVoidableDiff(diff) { 648 | function wrappedDiff(input, output, ptr) { 649 | const custom_patch = diff(input, output, ptr); 650 | // ensure an array is always returned 651 | return Array.isArray(custom_patch) ? custom_patch : diffAny(input, output, ptr, wrappedDiff); 652 | } 653 | return wrappedDiff; 654 | } 655 | /** 656 | Produce a 'application/json-patch+json'-type patch to get from one object to 657 | another. 658 | 659 | This does not alter `input` or `output` unless they have a property getter with 660 | side-effects (which is not a good idea anyway). 661 | 662 | `diff` is called on each pair of comparable non-primitive nodes in the 663 | `input`/`output` object trees, producing nested patches. Return `undefined` 664 | to fall back to default behaviour. 665 | 666 | Returns list of operations to perform on `input` to produce `output`. 667 | */ 668 | function createPatch(input, output, diff) { 669 | const ptr = new Pointer(); 670 | // a new Pointer gets a default path of [''] if not specified 671 | return (diff ? wrapVoidableDiff(diff) : diffAny)(input, output, ptr); 672 | } 673 | /** 674 | Create a test operation based on `input`'s current evaluation of the JSON 675 | Pointer `path`; if such a pointer cannot be resolved, returns undefined. 676 | */ 677 | function createTest(input, path) { 678 | const endpoint = Pointer.fromJSON(path).evaluate(input); 679 | if (endpoint !== undefined) { 680 | return { op: 'test', path, value: endpoint.value }; 681 | } 682 | } 683 | /** 684 | Produce an 'application/json-patch+json'-type list of tests, to verify that 685 | existing values in an object are identical to the those captured at some 686 | checkpoint (whenever this function is called). 687 | 688 | This does not alter `input` or `output` unless they have a property getter with 689 | side-effects (which is not a good idea anyway). 690 | 691 | Returns list of test operations. 692 | */ 693 | function createTests(input, patch) { 694 | const tests = new Array(); 695 | patch.filter(isDestructive).forEach(operation => { 696 | const pathTest = createTest(input, operation.path); 697 | if (pathTest) 698 | tests.push(pathTest); 699 | if ('from' in operation) { 700 | const fromTest = createTest(input, operation.from); 701 | if (fromTest) 702 | tests.push(fromTest); 703 | } 704 | }); 705 | return tests; 706 | } 707 | 708 | exports.Pointer = Pointer; 709 | exports.applyPatch = applyPatch; 710 | exports.createPatch = createPatch; 711 | exports.createTests = createTests; 712 | 713 | Object.defineProperty(exports, '__esModule', { value: true }); 714 | 715 | })); 716 | --------------------------------------------------------------------------------