diff --git a/README.md b/README.md index 2bc56c4..622ebd6 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,45 @@ Jiff is an implementation of [JSON Patch RFC6902](https://tools.ietf.org/html/rfc6902), plus a Diff implementation that generates compliant patches. +It handles nuances of the RFC, such as: + +1. Checking for invalid paths in all operations +1. Allowing `add` to behave like `replace` for existing paths ([See bullet 3](https://tools.ietf.org/html/rfc6902#section-4.1)) +1. Appending to arrays when path ends with `"/-"` ([See bullet 6](https://tools.ietf.org/html/rfc6902#section-4.1)) + * `jiff.diff` also correctly generates patches with paths ending in `"/-"` +1. Deep comparisons for the `test` operation + ## Get it `npm install --save jiff` `bower install --save jiff` +## Example + +```js +var a = [ + { name: 'a' }, + { name: 'b' }, + { name: 'c' }, +] + +var b = a.slice(); +b.splice(1, 1); +b.push({ name: 'd' }); + +// Generate diff (ie JSON Patch) from a to b +var patch = jiff.diff(a, b); + +// [{"op":"add","path":"/-","value":{"name":"d"}},{"op":"remove","path":"/1"}] +console.log(JSON.stringify(patch)); + +var patched = jiff.patch(patch, a); + +// [{"name":"a"},{"name":"c"},{"name":"d"}] +console.log(JSON.stringify(patched); +``` + ## API ### patch @@ -18,10 +51,12 @@ var b = jiff.patch(patch, a); Given an rfc6902 JSON Patch, apply it to `a` and return a new patched JSON object/array/value. Patching is atomic, and is performed on a clone of `a`. Thus, if patching fails mid-patch, `a` will still be in a consistent state. +Throws [InvalidPatchOperationError](#invalidpatchoperationerror) and [TestFailedError](#testfailederror). + ### patchInPlace ```js -var b = jiff.patchInPlace(patch, a); +a = jiff.patchInPlace(patch, a); ``` Given an rfc6902 JSON Patch, apply it directly to `a`, *mutating `a`*. @@ -30,6 +65,8 @@ Note that this is an opt-in violation of the patching algorithm outlined in rfc6 However, if patching fails mid-patch, `a` will be left in an inconsistent state. +Throws [InvalidPatchOperationError](#invalidpatchoperationerror) and [TestFailedError](#testfailederror). + ### diff ```js @@ -40,7 +77,7 @@ Computes and returns a JSON Patch from `a` to `b`: `a` and `b` must be valid JSO If provided, the optional `hashFunction` will be used to recognize when two objects are the same. If not provided, `JSON.stringify` will be used. -The diff algorithm does not generate `move`, or `copy` operations, only `add`, `remove`, and `replace`. +While jiff's patch algorithm handles all the JSON Patch operations required by rfc6902, the diff algorithm currently does not generate `move`, or `copy` operations, only `add`, `remove`, and `replace`. ### clone @@ -52,7 +89,11 @@ Creates a deep copy of `a`, which must be a valid JSON object/array/value. ### InvalidPatchOperationError -When any invalid patch operation is encountered, jiff will throw an `InvalidPatchOperationError`. Invalid patch operations are outlined in sections 4.x in RFC 6902. +Thrown when any invalid patch operation is encountered. Invalid patch operations are outlined in [sections 4.x](https://tools.ietf.org/html/rfc6902#section-4) [and 5](https://tools.ietf.org/html/rfc6902#section-5) in rfc6902. For example: non-existent path in a remove operation, array path index out of bounds, etc. + +### TestFailedError + +Thrown when a [`test` operation](https://tools.ietf.org/html/rfc6902#section-4.6) fails. ## License diff --git a/bower.json b/bower.json index 054b8bf..3fb2702 100644 --- a/bower.json +++ b/bower.json @@ -1,7 +1,7 @@ { "name": "jiff", "main": "jiff.js", - "version": "0.2.0", + "version": "0.3.0", "authors": [ "Brian Cavalier " ], diff --git a/jiff.js b/jiff.js index 9f505a4..0800bcf 100644 --- a/jiff.js +++ b/jiff.js @@ -9,6 +9,7 @@ var encodeSegment = jsonPointer.encodeSegment; exports.diff = diff; exports.patch = patch.apply; +exports.patchInPlace = patch.applyInPlace; exports.clone = patch.clone; // Errors @@ -31,13 +32,15 @@ function diff(a, b, hasher) { } function appendChanges(a, b, path, state) { - if(Array.isArray(b)) { + if(Array.isArray(a) && Array.isArray(b)) { return appendListChanges(a, b, path, state); - } else if(b && typeof b === 'object') { + } + + if(isValidObject(a) && isValidObject(b)) { return appendObjectChanges(a, b, path, state); - } else { - return appendValueChanges(a, b, path, state); } + + return appendValueChanges(a, b, path, state); } function appendObjectChanges(o1, o2, path, state) { @@ -126,7 +129,11 @@ function appendValueChanges(a, b, path, state) { return state; } -function defaultHash(x, i) { +function defaultHash(x) { return x !== null && typeof x === 'object' && 'id' in x ? x.id : JSON.stringify(x); } + +function isValidObject (x) { + return x !== null && typeof x === 'object'; +} diff --git a/package.json b/package.json index 56f6c7b..ee96a1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jiff", - "version": "0.2.0", + "version": "0.3.0", "description": "JSON diff and patch based on rfc6902", "main": "jiff", "scripts": { diff --git a/test/jiff-test.js b/test/jiff-test.js index c2fbe8c..f401bb0 100644 --- a/test/jiff-test.js +++ b/test/jiff-test.js @@ -2,7 +2,6 @@ var buster = require('buster'); var gent = require('gent'); var json = require('gent/generator/json'); var assert = buster.assert; -var refute = buster.refute; var deepEquals = require('../lib/deepEquals'); var jiff = require('../jiff'); @@ -52,137 +51,15 @@ buster.testCase('jiff', { } }, - 'add': { - 'should add': function() { - var a = {}; - var result = jiff.patch([{ op: 'add', path: '/value', value: 1 }], a); - assert.equals(result.value, 1); - }, - - 'should replace': function() { - var a = { value: 0 }; - var result = jiff.patch([{ op: 'add', path: '/value', value: 1 }], a); - assert.equals(result.value, 1); - }, - - 'should throw': { - 'when path is invalid': function() { - assert.exception(function() { - jiff.patch([{ op: 'add', path: '/a/b', value: 1 }], {}); - }, 'InvalidPatchOperationError'); - }, - - 'when target is null': function() { - assert.exception(function() { - jiff.patch([{ op: 'add', path: '/a', value: 1 }], null); - }, 'InvalidPatchOperationError'); - }, - - 'when target is undefined': function() { - assert.exception(function() { - jiff.patch([{ op: 'add', path: '/a', value: 1 }], void 0); - }, 'InvalidPatchOperationError'); - }, - - 'when target is not an object or array': function() { - assert.exception(function() { - jiff.patch([{ op: 'add', path: '/a/b', value: 1 }], { a: 0 }); - }, 'InvalidPatchOperationError'); + 'diff': { + 'on arrays': { + 'should generate - for path suffix when appending': function() { + var patch = jiff.diff([], [1]); + assert.equals(patch[0].op, 'add'); + assert.equals(patch[0].path, '/-'); + assert.same(patch[0].value, 1); } } - }, - - - 'should throw when op is remove': { - 'and path is invalid': function() { - assert.exception(function() { - jiff.patch([{ op: 'remove', path: '/a', value: 1 }], {}); - }, 'InvalidPatchOperationError'); - }, - - 'and target is null': function() { - assert.exception(function() { - jiff.patch([{ op: 'remove', path: '/a', value: 1 }], null); - }, 'InvalidPatchOperationError'); - }, - - 'and target is undefined': function() { - assert.exception(function() { - jiff.patch([{ op: 'remove', path: '/a', value: 1 }], void 0); - }, 'InvalidPatchOperationError'); - } - }, - - 'should throw when op is replace': { - 'and path is invalid': function() { - assert.exception(function() { - jiff.patch([{ op: 'replace', path: '/a', value: 1 }], {}); - }, 'InvalidPatchOperationError'); - }, - - 'and target is null': function() { - assert.exception(function() { - jiff.patch([{ op: 'replace', path: '/a', value: 1 }], null); - }, 'InvalidPatchOperationError'); - }, - - 'and target is undefined': function() { - assert.exception(function() { - jiff.patch([{ op: 'replace', path: '/a', value: 1 }], void 0); - }, 'InvalidPatchOperationError'); - } - }, - - 'move': { - 'should move': function() { - var a = { x: 1 }; - var result = jiff.patch([{ op: 'move', path: '/y', from: '/x' }], a); - assert.equals(result.y, 1); - refute.defined(result.x); - } - }, - - 'copy': { - 'should copy': function() { - var a = { x: { value: 1 } }; - var result = jiff.patch([{ op: 'copy', path: '/y', from: '/x' }], a); - assert.equals(result.x.value, 1); - assert.equals(result.y.value, 1); - refute.same(result.x, result.y); - } - }, - - 'test': { - 'should pass when values are deep equal': function() { - var test = { - num: 1, - string: 'bar', - bool: true, - array: [1, { name: 'x' }, 'baz', true, false, null], - object: { - value: 2 - } - }; - var a = { x: test }; - var y = jiff.clone(test); - - refute.exception(function() { - var result = jiff.patch([{ op: 'test', path: '/x', value: y }], a); - assert.equals(JSON.stringify(a), JSON.stringify(result)); - }); - }, - - 'should fail when values are not deep equal': function() { - var test = { array: [1, { name: 'x' }] }; - var y = jiff.clone(test); - - test.array[1].name = 'y'; - var a = { x: test }; - - assert.exception(function() { - jiff.patch([{ op: 'test', path: '/x', value: y }], a); - }, 'TestFailedError'); - } } }); diff --git a/test/jsonPatch-test.js b/test/jsonPatch-test.js new file mode 100644 index 0000000..568f45d --- /dev/null +++ b/test/jsonPatch-test.js @@ -0,0 +1,156 @@ +var buster = require('buster'); +var assert = buster.assert; +var refute = buster.refute; + +var jsonPatch = require('../lib/jsonPatch'); + +buster.testCase('jsonPatch', { + 'add': { + 'should add': function() { + var a = {}; + var result = jsonPatch.apply([{ op: 'add', path: '/value', value: 1 }], a); + assert.equals(result.value, 1); + }, + + 'should replace': function() { + var a = { value: 0 }; + var result = jsonPatch.apply([{ op: 'add', path: '/value', value: 1 }], a); + assert.equals(result.value, 1); + }, + + 'should throw': { + 'when path is invalid': function() { + assert.exception(function() { + jsonPatch.apply([{ op: 'add', path: '/a/b', value: 1 }], {}); + }, 'InvalidPatchOperationError'); + }, + + 'when target is null': function() { + assert.exception(function() { + jsonPatch.apply([{ op: 'add', path: '/a', value: 1 }], null); + }, 'InvalidPatchOperationError'); + }, + + 'when target is undefined': function() { + assert.exception(function() { + jsonPatch.apply([{ op: 'add', path: '/a', value: 1 }], void 0); + }, 'InvalidPatchOperationError'); + }, + + 'when target is not an object or array': function() { + assert.exception(function() { + jsonPatch.apply([{ op: 'add', path: '/a/b', value: 1 }], { a: 0 }); + }, 'InvalidPatchOperationError'); + } + } + }, + + + 'remove': { + 'should remove': function() { + var a = { value: 0 }; + var result = jsonPatch.apply([{ op: 'remove', path: '/value'}], a); + refute.defined(result.value); + }, + + 'should throw': { + 'when path is invalid': function() { + assert.exception(function() { + jsonPatch.apply([{ op: 'remove', path: '/a', value: 1 }], {}); + }, 'InvalidPatchOperationError'); + }, + + 'when target is null': function() { + assert.exception(function() { + jsonPatch.apply([{ op: 'remove', path: '/a', value: 1 }], null); + }, 'InvalidPatchOperationError'); + }, + + 'when target is undefined': function() { + assert.exception(function() { + jsonPatch.apply([{ op: 'remove', path: '/a', value: 1 }], void 0); + }, 'InvalidPatchOperationError'); + } + } + }, + + 'replace': { + 'should replace': function() { + var a = { value: 0 }; + var result = jsonPatch.apply([{ op: 'add', path: '/value', value: 1 }], a); + assert.equals(result.value, 1); + }, + + 'should throw': { + 'when path is invalid': function() { + assert.exception(function() { + jsonPatch.apply([{ op: 'replace', path: '/a', value: 1 }], {}); + }, 'InvalidPatchOperationError'); + }, + + 'when target is null': function() { + assert.exception(function() { + jsonPatch.apply([{ op: 'replace', path: '/a', value: 1 }], null); + }, 'InvalidPatchOperationError'); + }, + + 'when target is undefined': function() { + assert.exception(function() { + jsonPatch.apply([{ op: 'replace', path: '/a', value: 1 }], void 0); + }, 'InvalidPatchOperationError'); + } + } + }, + + 'move': { + 'should move': function() { + var a = { x: 1 }; + var result = jsonPatch.apply([{ op: 'move', path: '/y', from: '/x' }], a); + assert.equals(result.y, 1); + refute.defined(result.x); + } + }, + + 'copy': { + 'should copy': function() { + var a = { x: { value: 1 } }; + var result = jsonPatch.apply([{ op: 'copy', path: '/y', from: '/x' }], a); + assert.equals(result.x.value, 1); + assert.equals(result.y.value, 1); + refute.same(result.x, result.y); + } + }, + + 'test': { + 'should pass when values are deep equal': function() { + var test = { + num: 1, + string: 'bar', + bool: true, + array: [1, { name: 'x' }, 'baz', true, false, null], + object: { + value: 2 + } + }; + var a = { x: test }; + var y = jsonPatch.clone(test); + + refute.exception(function() { + var result = jsonPatch.apply([{ op: 'test', path: '/x', value: y }], a); + assert.equals(JSON.stringify(a), JSON.stringify(result)); + }); + }, + + 'should fail when values are not deep equal': function() { + var test = { array: [1, { name: 'x' }] }; + var y = jsonPatch.clone(test); + + test.array[1].name = 'y'; + var a = { x: test }; + + assert.exception(function() { + jsonPatch.apply([{ op: 'test', path: '/x', value: y }], a); + }, 'TestFailedError'); + } + } +});