├── .gitignore ├── .npmrc ├── .tm_properties ├── .travis.yml ├── Gruntfile.coffee ├── LICENSE ├── README.md ├── bin ├── coffee └── grunt ├── lib └── index.js ├── package.json ├── spec ├── spec-diff.coffee ├── spec-index.coffee └── spec-resolve.coffee └── src └── index.coffee /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | doc -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.tm_properties: -------------------------------------------------------------------------------- 1 | excludeInFileChooser = "{$excludeInFileChooser,node_modules}" 2 | excludeInBrowser = "{$excludeInBrowser}" 3 | excludeInFolderSearch = "{$excludeInFolderSearch,node_modules}" 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.11" 4 | - "0.10" 5 | -------------------------------------------------------------------------------- /Gruntfile.coffee: -------------------------------------------------------------------------------- 1 | 2 | module.exports = (grunt) -> 3 | 4 | grunt.loadNpmTasks 'grunt-mocha-test' 5 | grunt.loadNpmTasks 'grunt-contrib-coffee' 6 | grunt.loadNpmTasks 'grunt-contrib-watch' 7 | grunt.loadNpmTasks 'grunt-codo' 8 | 9 | grunt.initConfig 10 | pkg: grunt.file.readJSON("package.json") 11 | 12 | coffee: 13 | compile: 14 | files: 15 | 'lib/index.js': 'src/index.coffee' 16 | 17 | watch: 18 | coffee: 19 | files: [ 'src/index.coffee' ] 20 | tasks: [ 'coffee' ] 21 | 22 | mochaTest: 23 | test: 24 | options: 25 | reporter: 'spec' 26 | require: [ 27 | 'coffee-script' 28 | 'babel/polyfill' 29 | ] 30 | src: ['spec/**/*.coffee'] 31 | 32 | grunt.registerTask 'doc', ['codo'] 33 | grunt.registerTask 'test', ['mochaTest'] 34 | grunt.registerTask "compile", ["coffee"] 35 | grunt.registerTask "default", ["compile"] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Mirek Rusin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Bugs 2 | 3 | * current version will not dive into nested arrays; if there is tiniest bit different between nested arrays, the diff will emit replacement for the whole nested array as a single op. 4 | * there is no way of specifying if an array should be compared as unordered set of elements or ordered list of elements 5 | * the code is in dated coffeescript, needs rewrite in modern js, ideally with ts/flow annotations. 6 | 7 | ## Summary [![Build Status](https://travis-ci.org/mirek/node-json-criteria.png?branch=master)](https://travis-ci.org/mirek/node-rus-diff) 8 | 9 | (R)emove-(U)pdate-(S)et JSON diff library can be used standalone to compute difference between two JSON objects. 10 | 11 | Produced diff is MongoDB compatible and can be used to modify documents with `collection.update(...)`. 12 | 13 | ## Examples 14 | 15 | ### Diff 16 | 17 | | a | b | diff(a, b) | options | 18 | |---------|-----------|--------------------------------|--------------| 19 | | `{a:1}` | `{b:2}` | `{$unset:{a:true},$set:{b:2}}` | | 20 | | `{a:1}` | `{b:1}` | `{$rename:{a:'b'}}` | | 21 | | `{a:1}` | `{}` | `{$unset:{a:true}}` | | 22 | | `{a:1}` | `{a:2.5}` | `{$set:{a:2.5}}` | | 23 | | `{a:1}` | `{a:2.5}` | `{$inc:{a:1.5}}` | `{inc:true}` | 24 | 25 | ### Apply 26 | 27 | | a | diff | apply(a, diff) | 28 | |-------------|------------------------------------|-----------------| 29 | | `{}` | `{$inc:{'a.b':1}}` | `{a:{b:1}}` | 30 | | `{a:1.5}` | `{$inc:{a:-2.5}}` | `{a:-1}` | 31 | | `{a:true}` | `{$rename:{a:'b'}}` | `{ b: true }` | 32 | | `{a:1,b:2}` | `{$unset:{a:true},$set:{'c.d':3}}` | `{b:2,c:{d:3}}` | 33 | 34 | ## Usage 35 | 36 | Install `rus-diff` in your project: 37 | 38 | npm install rus-diff --save 39 | 40 | Install ES6 compatibility layer: 41 | 42 | npm install babel-polyfill --save 43 | 44 | Usage example: 45 | 46 | // Add ES6 polyfills. 47 | require('babel-polyfill') 48 | 49 | var diff = require('rus-diff').diff 50 | 51 | var a = { 52 | foo: { 53 | bb: { 54 | inner: { 55 | this_is_a: 1, 56 | to_rename: "Hello" 57 | } 58 | }, 59 | aa: 1 60 | }, 61 | bar: 1, 62 | replace_me: 1 63 | } 64 | 65 | var b = { 66 | foo: { 67 | bb: { 68 | inner: { 69 | this_is_b: 2 70 | } 71 | }, 72 | cc: { 73 | new_val: 2 74 | } 75 | }, 76 | bar2: 2, 77 | zz: 2, 78 | renamed: "Hello", 79 | replace_me: 2 80 | } 81 | 82 | console.log(diff(a, b)) 83 | 84 | Produces diff: 85 | 86 | { '$rename': { 'foo.bb.inner.to_rename': 'renamed' }, 87 | '$unset': { bar: true, 'foo.aa': true, 'foo.bb.inner.this_is_a': true }, 88 | '$set': 89 | { bar2: 2, 90 | 'foo.bb.inner.this_is_b': 2, 91 | 'foo.cc': { new_val: 2 }, 92 | replace_me: 2, 93 | zz: 2 } } 94 | 95 | For more usage examples please see [spec](spec) directory. 96 | 97 | Exported functions: 98 | 99 | // Generate diff between a and b JSON objects. 100 | // prefix can be set to an array or string to scope (prefix) keys, 101 | // ie. 'foo.bar' means all changes will have keys starting with 'foo.bar...'. 102 | // options.inc = true can be set to enable $inc part for number changes. 103 | diff(a, b, prefix = [], options = {}) 104 | 105 | // Apply delta diff on the JSON object a. If you don't want to mutate a you 106 | // can clone it before passing to apply: 107 | // apply(clone(a), delta) 108 | apply(a, delta) 109 | 110 | And some less important, utility functions: 111 | 112 | // JSON object deep copy. 113 | clone(a) 114 | 115 | // Resolve key path on the object. Returns a tuple [a, path] where a 116 | // is resolved object and path is an array of last component or multiple 117 | // unresolved components. 118 | // 119 | // a - object 120 | // path - an array or dot separated key path 121 | // 122 | // For example, having a document: 123 | // 124 | // var a = { hello: { in: { nested: { world: '!' } } } } 125 | // 126 | // resolve a, 'hello.in.nested' 127 | // 128 | // Returns [ { nested: { world: '!' } }, [ 'nested' ] ] 129 | // 130 | // resolve a, 'hello.in.nested.something.other' 131 | // 132 | // Returns [ { world: '!' }, [ 'something', 'other' ] ] 133 | // 134 | resolve(a, path) 135 | 136 | // Convert non array path into array based key path. 137 | arrize(path) 138 | 139 | # License 140 | 141 | The MIT License (MIT) 142 | 143 | Copyright (c) 2014 Mirek Rusin 144 | 145 | Permission is hereby granted, free of charge, to any person obtaining a copy 146 | of this software and associated documentation files (the "Software"), to deal 147 | in the Software without restriction, including without limitation the rights 148 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 149 | copies of the Software, and to permit persons to whom the Software is 150 | furnished to do so, subject to the following conditions: 151 | 152 | The above copyright notice and this permission notice shall be included in 153 | all copies or substantial portions of the Software. 154 | 155 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 156 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 157 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 158 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 159 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 160 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 161 | THE SOFTWARE. 162 | -------------------------------------------------------------------------------- /bin/coffee: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | `dirname $0`/../node_modules/coffee-script/bin/coffee $@ 4 | -------------------------------------------------------------------------------- /bin/grunt: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | `dirname $0`/../node_modules/grunt-cli/bin/grunt $@ 4 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var apply, arrize, clone, diff, digest, isPlainObject, isRealNumber, resolve, 3 | slice = [].slice; 4 | 5 | digest = require('json-hash').digest; 6 | 7 | isRealNumber = function() { 8 | var args; 9 | args = 1 <= arguments.length ? slice.call(arguments, 0) : []; 10 | return args.every(function(e) { 11 | return (typeof e === 'number') && (isNaN(e) === false) && (e !== +Infinity) && (e !== -Infinity); 12 | }); 13 | }; 14 | 15 | isPlainObject = function(a) { 16 | return a !== null && typeof a === 'object' && a.constructor === Object; 17 | }; 18 | 19 | diff = function(a, b, stack, options, top, garbage) { 20 | var aI, aKey, aKeys, aN, aVal, bI, bKey, bKeys, bN, bVal, collect, delta, e, h, incA, j, k, k2, key, len, ref, setB, unsetA, v, v2; 21 | if (stack == null) { 22 | stack = []; 23 | } 24 | if (options == null) { 25 | options = {}; 26 | } 27 | if (top == null) { 28 | top = true; 29 | } 30 | if (garbage == null) { 31 | garbage = {}; 32 | } 33 | stack = arrize(stack); 34 | aKeys = Object.keys(a).sort(); 35 | bKeys = Object.keys(b).sort(); 36 | aN = aKeys.length; 37 | bN = bKeys.length; 38 | aI = 0; 39 | bI = 0; 40 | delta = { 41 | $rename: {}, 42 | $unset: {}, 43 | $set: {}, 44 | $inc: {} 45 | }; 46 | unsetA = function(i) { 47 | var h, key; 48 | key = (stack.concat(aKeys[i])).join('.'); 49 | delta.$unset[key] = true; 50 | h = digest(a[aKeys[i]]); 51 | return (garbage[h] || (garbage[h] = [])).push(key); 52 | }; 53 | setB = function(i) { 54 | var key; 55 | key = (stack.concat(bKeys[i])).join('.'); 56 | return delta.$set[key] = b[bKeys[i]]; 57 | }; 58 | incA = function(i, d) { 59 | var key; 60 | key = (stack.concat(aKeys[i])).join('.'); 61 | return delta.$inc[key] = d; 62 | }; 63 | while ((aI < aN) && (bI < bN)) { 64 | aKey = aKeys[aI]; 65 | bKey = bKeys[bI]; 66 | if (aKey === bKey) { 67 | aVal = a[aKey]; 68 | bVal = b[bKey]; 69 | switch (false) { 70 | case aVal !== bVal: 71 | void 0; 72 | break; 73 | case !(((aVal != null) && (bVal == null)) || ((aVal == null) && (bVal != null))): 74 | setB(bI); 75 | break; 76 | case !((aVal instanceof Date) && (bVal instanceof Date)): 77 | if (+aVal !== +bVal) { 78 | setB(bI); 79 | } 80 | break; 81 | case !((aVal instanceof RegExp) && (bVal instanceof RegExp)): 82 | if (("" + aVal) !== ("" + bVal)) { 83 | setB(bI); 84 | } 85 | break; 86 | case !(isPlainObject(aVal) && isPlainObject(bVal)): 87 | ref = diff(aVal, bVal, stack.concat([aKey]), options, false, garbage); 88 | for (k in ref) { 89 | v = ref[k]; 90 | for (k2 in v) { 91 | v2 = v[k2]; 92 | delta[k][k2] = v2; 93 | } 94 | } 95 | break; 96 | case !(!isPlainObject(aVal) && !isPlainObject(bVal) && digest(aVal) === digest(bVal)): 97 | void 0; 98 | break; 99 | default: 100 | if ((options.inc === true) && isRealNumber(aVal, bVal)) { 101 | incA(aI, bVal - aVal); 102 | } else { 103 | setB(bI); 104 | } 105 | } 106 | ++aI; 107 | ++bI; 108 | } else { 109 | if (aKey < bKey) { 110 | unsetA(aI); 111 | ++aI; 112 | } else { 113 | setB(bI); 114 | ++bI; 115 | } 116 | } 117 | } 118 | while (aI < aN) { 119 | unsetA(aI++); 120 | } 121 | while (bI < bN) { 122 | setB(bI++); 123 | } 124 | if (top) { 125 | collect = (function() { 126 | var ref1, results; 127 | ref1 = delta.$set; 128 | results = []; 129 | for (k in ref1) { 130 | v = ref1[k]; 131 | if ((h = digest(v), (garbage[h] != null) && (key = garbage[h].pop()))) { 132 | results.push([k, key]); 133 | } 134 | } 135 | return results; 136 | })(); 137 | for (j = 0, len = collect.length; j < len; j++) { 138 | e = collect[j]; 139 | k = e[0], key = e[1]; 140 | delta.$rename[key] = k; 141 | delete delta.$unset[key]; 142 | delete delta.$set[k]; 143 | } 144 | } 145 | for (k in delta) { 146 | if (Object.keys(delta[k]).length === 0) { 147 | delete delta[k]; 148 | } 149 | } 150 | if (Object.keys(delta).length === 0) { 151 | delta = false; 152 | } 153 | return delta; 154 | }; 155 | 156 | clone = function(a) { 157 | var b, f, k, v; 158 | switch (false) { 159 | case !((a == null) || (typeof a !== 'object')): 160 | return a; 161 | case !(a instanceof Date): 162 | return new Date(a.getTime()); 163 | case !(a instanceof RegExp): 164 | f = ''; 165 | if (a.global != null) { 166 | f += 'g'; 167 | } 168 | if (a.ignoreCase != null) { 169 | f += 'i'; 170 | } 171 | if (a.multiline != null) { 172 | f += 'm'; 173 | } 174 | if (a.sticky != null) { 175 | f += 'y'; 176 | } 177 | return new RegExp(a.source, f); 178 | default: 179 | b = new a.constructor; 180 | for (k in a) { 181 | v = a[k]; 182 | b[k] = clone(v); 183 | } 184 | return b; 185 | } 186 | }; 187 | 188 | arrize = function(path, glue) { 189 | if (glue == null) { 190 | glue = '.'; 191 | } 192 | return ((function() { 193 | if (Array.isArray(path)) { 194 | return path.slice(0); 195 | } else { 196 | switch (path) { 197 | case void 0: 198 | case null: 199 | case false: 200 | case '': 201 | return []; 202 | default: 203 | return path.toString().split(glue); 204 | } 205 | } 206 | })()).map(function(e) { 207 | switch (e) { 208 | case void 0: 209 | case null: 210 | case false: 211 | case '': 212 | return null; 213 | default: 214 | return e.toString(); 215 | } 216 | }).filter(function(e) { 217 | return e != null; 218 | }); 219 | }; 220 | 221 | resolve = function(a, path, options) { 222 | var e, k, last, stack; 223 | if (options == null) { 224 | options = {}; 225 | } 226 | stack = arrize(path); 227 | last = []; 228 | if (stack.length > 0) { 229 | last.unshift(stack.pop()); 230 | } 231 | e = a; 232 | if (e !== null) { 233 | while ((k = stack.shift()) !== void 0) { 234 | if (e[k] !== void 0) { 235 | e = e[k]; 236 | } else { 237 | stack.unshift(k); 238 | break; 239 | } 240 | } 241 | } 242 | if (options.force) { 243 | while ((k = stack.shift()) !== void 0) { 244 | if ((typeof stack[0] === 'number') || ((stack.length === 0) && (typeof last[0] === 'number'))) { 245 | e[k] = []; 246 | } else { 247 | e[k] = {}; 248 | } 249 | e = e[k]; 250 | } 251 | } else { 252 | while ((k = stack.pop()) !== void 0) { 253 | last.unshift(k); 254 | } 255 | } 256 | return [e, last]; 257 | }; 258 | 259 | apply = function(a, delta) { 260 | var k, n, n1, n2, name, o, o1, o2, ref, ref1, ref2, ref3, ref4, ref5, ref6, ref7, ref8, v; 261 | if (delta != null) { 262 | if (delta.$rename != null) { 263 | ref = delta.$rename; 264 | for (k in ref) { 265 | v = ref[k]; 266 | ref1 = resolve(a, k), o1 = ref1[0], n1 = ref1[1]; 267 | ref2 = resolve(a, v), o2 = ref2[0], n2 = ref2[1]; 268 | if ((o1 != null) && n1.length === 1) { 269 | if ((o2 != null) && n2.length === 1) { 270 | o2[n2[0]] = o1[n1[0]]; 271 | delete o1[n1[0]]; 272 | } else { 273 | throw new Error(o2 + "/" + n2 + " - couldn't resolve first for " + a + " " + v); 274 | } 275 | } else { 276 | throw new Error(o1 + "/" + n1 + " - couldn't resolve second for " + a + " " + k); 277 | } 278 | } 279 | } 280 | if (delta.$set != null) { 281 | ref3 = delta.$set; 282 | for (k in ref3) { 283 | v = ref3[k]; 284 | ref4 = resolve(a, k, { 285 | force: true 286 | }), o = ref4[0], n = ref4[1]; 287 | if ((o != null) && n.length === 1) { 288 | o[n[0]] = v; 289 | } else { 290 | throw new Error(o + "/" + n + " - couldn't set for " + a + " " + k); 291 | } 292 | } 293 | } 294 | if (delta.$inc != null) { 295 | ref5 = delta.$inc; 296 | for (k in ref5) { 297 | v = ref5[k]; 298 | ref6 = resolve(a, k, { 299 | force: true 300 | }), o = ref6[0], n = ref6[1]; 301 | if ((o != null) && n.length === 1) { 302 | if (o[name = n[0]] == null) { 303 | o[name] = 0; 304 | } 305 | o[n[0]] += v; 306 | } else { 307 | throw new Error(o + "/" + n + " - couldn't set for " + a + " " + k); 308 | } 309 | } 310 | } 311 | if (delta.$unset != null) { 312 | ref7 = delta.$unset; 313 | for (k in ref7) { 314 | v = ref7[k]; 315 | ref8 = resolve(a, k), o = ref8[0], n = ref8[1]; 316 | if ((o != null) && n.length === 1) { 317 | delete o[n[0]]; 318 | } else { 319 | throw new Error(o + "/" + n + " - couldn't unset for " + a + " " + k); 320 | } 321 | } 322 | } 323 | } 324 | return a; 325 | }; 326 | 327 | module.exports = { 328 | apply: apply, 329 | arrize: arrize, 330 | clone: clone, 331 | diff: diff, 332 | isRealNumber: isRealNumber, 333 | resolve: resolve, 334 | rusDiff: diff 335 | }; 336 | 337 | }).call(this); 338 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rus-diff", 3 | "version": "1.1.0", 4 | "description": "MongoDB compatible JSON diff.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "grunt test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/mirek/node-rus-diff.git" 12 | }, 13 | "keywords": [ 14 | "json", 15 | "diff", 16 | "mongo", 17 | "mongodb" 18 | ], 19 | "author": "Mirek Rusin http://github.com/mirek", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/mirek/node-rus-diff/issues" 23 | }, 24 | "homepage": "https://github.com/mirek/node-rus-diff", 25 | "devDependencies": { 26 | "babel": "^5.2.16", 27 | "bson": "^0.3.1", 28 | "coffee-script": "^1.9.1", 29 | "grunt": "^0.4.5", 30 | "grunt-cli": "^0.1.13", 31 | "grunt-codo": "^0.2.0", 32 | "grunt-contrib-coffee": "^0.13.0", 33 | "grunt-contrib-watch": "^0.6.1", 34 | "grunt-mocha-test": "^0.12.7", 35 | "mocha": "^2.2.1" 36 | }, 37 | "dependencies": { 38 | "json-hash": "^1.1.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /spec/spec-diff.coffee: -------------------------------------------------------------------------------- 1 | 2 | assert = require 'assert' 3 | $ = require '../src' 4 | 5 | y = (a, b) -> 6 | assert.deepEqual a, b 7 | 8 | describe 'diff', -> 9 | 10 | it 'should produce no difference', -> 11 | y false, $.diff [], [] 12 | y false, $.diff [1], [1] 13 | y false, $.diff [1, 2, 3], [1, 2, 3] 14 | y false, $.diff {}, {} 15 | y false, $.diff {foo:bar:1}, {foo:bar:1} 16 | y false, $.diff {foo:1}, {foo:1} 17 | y false, $.diff {foo:1}, {foo:1}, 'my.scope', inc: true 18 | y false, $.diff {foo:undefined}, {foo:undefined} 19 | 20 | it 'should produce diff with undefined', -> 21 | y { $set: { foo: undefined } }, $.diff { foo: 1 }, { foo: undefined } 22 | 23 | it 'should work on nested arrays', -> 24 | y { $set: { 'x.0.z': false } }, $.diff { x: [ { y: true, z: true } ] }, { x: [ { y: true, z: false } ] } 25 | -------------------------------------------------------------------------------- /spec/spec-index.coffee: -------------------------------------------------------------------------------- 1 | 2 | assert = require 'assert' 3 | bson = require 'bson' 4 | $ = require '../src' 5 | 6 | describe 'diff', -> 7 | 8 | it 'should produce no difference', -> 9 | assert.equal false, $.diff [], [] 10 | assert.equal false, $.diff [1], [1] 11 | assert.equal false, $.diff [1, 2, 3], [1, 2, 3] 12 | assert.equal false, $.diff {}, {} 13 | assert.equal false, $.diff {foo:bar:1}, {foo:bar:1} 14 | assert.equal false, $.diff {foo:1}, {foo:1} 15 | assert.equal false, $.diff {foo:1}, {foo:1}, 'my.scope', inc: true 16 | 17 | it 'should produce simple diffs', -> 18 | assert.deepEqual { '$set': { '0': 2, '1': 1 } }, $.diff [1, 2], [2, 1] 19 | assert.deepEqual { '$inc': { '0': 1, '1': -1 } }, $.diff [1, 2], [2, 1], null, inc: true 20 | assert.deepEqual { '$set': { bar: 1, foo: 2 } }, $.diff {foo:1,bar:2}, {bar:1,foo:2} 21 | assert.deepEqual { '$rename': { foo: 'bar' } }, $.diff {foo:1}, {bar:1} 22 | 23 | it 'should produce scoped diff', -> 24 | a = 25 | foo: 26 | bb: 27 | inner: 28 | this_is_a: 1 29 | to_rename: "Hello" 30 | aa: 1 31 | bar: 1 32 | replace_me: 1 33 | 34 | b = 35 | foo: 36 | bb: 37 | inner: 38 | this_is_b: 2 39 | cc: 40 | new_val: 2 41 | bar2: 2 42 | zz: 2 43 | renamed: "Hello" 44 | replace_me: 2 45 | 46 | r = 47 | $rename: 48 | "my.value.foo.bb.inner.to_rename": "my.value.renamed" 49 | $unset: 50 | "my.value.bar": true 51 | "my.value.foo.aa": true 52 | "my.value.foo.bb.inner.this_is_a": true 53 | $set: 54 | "my.value.bar2": 2 55 | "my.value.foo.bb.inner.this_is_b": 2 56 | "my.value.foo.cc": 57 | new_val: 2 58 | "my.value.replace_me": 2 59 | "my.value.zz": 2 60 | 61 | assert.deepEqual r, $.diff a, b, ['my', 'value'] 62 | 63 | it 'should work with dates', -> 64 | a = new Date 1417434298178 65 | a2 = new Date 1417434298178 66 | b = new Date 1417434298179 67 | assert.equal false, $.diff { foo: a }, { foo: a2 } 68 | assert.deepEqual { $set: { foo: b } }, $.diff { foo: a }, { foo: b } 69 | 70 | it 'should work with regexps', -> 71 | a = /foo/ 72 | a2 = /foo/ 73 | b = /foo/g 74 | assert.equal false, $.diff { foo: a }, { foo: a2 } 75 | assert.deepEqual { $set: { foo: b } }, $.diff { foo: a }, { foo: b } 76 | 77 | it 'should work with nulls', -> 78 | a = new Date 79 | assert.deepEqual { $set: { foo: null } }, $.diff { foo: 1 }, { foo: null } 80 | assert.deepEqual { $set: { foo: null } }, $.diff { foo: a }, { foo: null } 81 | assert.deepEqual { $set: { foo: 1 } }, $.diff { foo: null }, { foo: 1 } 82 | assert.deepEqual { $set: { foo: a } }, $.diff { foo: null }, { foo: a } 83 | 84 | # it 'should remove array elements', -> 85 | # assert.deepEqual {foo:[1,2,3,5]}, $.apply {foo:[1,2,3,5,7]}, {$rename:{'foo.4':'foo.3'}} 86 | # assert.deepEqual {foo:[1,2,5,7]}, $.apply {foo:[1,2,3,5,7]}, {$unset:{'foo.2':true}} 87 | 88 | it 'should rename nested objects', -> 89 | assert.deepEqual { $rename: { foo: 'bar' } }, $.diff { foo: { a: 1, b: 2 } }, { bar: { a: 1, b: 2 } } 90 | assert.deepEqual { $rename: { foo: 'bar', foo2: 'bar2' } }, $.diff { foo: {a:1}, foo2: {a:2} }, { bar: {a:1}, bar2: {a:2} } 91 | 92 | it 'should apply diff correctly on cloned objects', -> 93 | 94 | f = (a, b) -> 95 | d = $.diff a, b 96 | assert.equal false, $.diff $.apply($.clone(a), d), b 97 | 98 | f {foo:1}, {foo:1} 99 | f {foo:1}, {foo:2} 100 | f {foo:1}, {foo:'x'} 101 | f {foo:1}, {bar:1} 102 | f {foo:{bar:'z'}}, {bar:1} 103 | f {foo:{bar:'z'}}, {foo:{foo:'z'}} 104 | 105 | it 'should apply $inc to non-existing nested value', -> 106 | assert.deepEqual { "foo": { "bar": 1 } }, $.apply({}, { "$inc": { "foo.bar": 1 } }) 107 | 108 | it 'should resolve with forced creation of containers', -> 109 | a = {foo:1} 110 | assert.deepEqual [{}, ['one']], $.resolve a, 'bar.force.one', force: true 111 | assert.deepEqual {foo:1,bar:{force:{}}}, a 112 | assert.deepEqual [{force:{}}, ['force2', 'one2']], $.resolve a, 'bar.force2.one2', force: false 113 | assert.deepEqual [{}, ['name']], $.resolve a, ['alist', 0, 'insidelist', 0, 'name'], force: true 114 | assert.deepEqual {foo:1,bar:{force:{}},alist:[{insidelist:[{}]}]}, a 115 | assert.deepEqual [[], [0]], $.resolve a, ['alist2', 0], force: true 116 | assert.deepEqual {foo:1,bar:{force:{}},alist:[{insidelist:[{}]}],alist2:[]}, a 117 | 118 | it 'should arrize', -> 119 | assert.deepEqual [], $.arrize [] 120 | assert.deepEqual [], $.arrize [''] 121 | assert.deepEqual [], $.arrize [null] 122 | assert.deepEqual [], $.arrize [false] 123 | assert.deepEqual [], $.arrize [undefined] 124 | assert.deepEqual [], $.arrize() 125 | assert.deepEqual [], $.arrize '' 126 | assert.deepEqual [], $.arrize null 127 | assert.deepEqual [], $.arrize false 128 | assert.deepEqual [], $.arrize undefined 129 | 130 | it 'should resolve empty keypath', -> 131 | assert.deepEqual [{foo:1}, []], $.resolve {foo:1}, [''] 132 | assert.deepEqual [{foo:1}, []], $.resolve {foo:1}, [null] 133 | assert.deepEqual [{foo:1}, []], $.resolve {foo:1}, [false] 134 | assert.deepEqual [{foo:1}, []], $.resolve {foo:1}, [undefined] 135 | assert.deepEqual [{foo:1}, []], $.resolve {foo:1}, [] 136 | assert.deepEqual [{foo:1}, []], $.resolve {foo:1}, '' 137 | assert.deepEqual [{foo:1}, []], $.resolve {foo:1}, null 138 | assert.deepEqual [{foo:1}, []], $.resolve {foo:1}, false 139 | assert.deepEqual [{foo:1}, []], $.resolve {foo:1}, undefined 140 | 141 | it 'should work with undefined', -> 142 | assert.deepEqual $.diff({ foo: null }, { foo: undefined }), { $set: { foo: undefined } } 143 | 144 | it 'should work with bson types', -> 145 | a = new bson.ObjectId 146 | b = new bson.ObjectId 147 | assert.deepEqual $.diff({foo:a}, {foo:b}), { $set: { 'foo.id': b.id } } 148 | 149 | a = new bson.ObjectId '5516058702c536d6068cabb7' 150 | b = new bson.ObjectId '5516058702c536d6068cabb7' 151 | assert.equal $.diff({foo:a}, {foo:b}), false 152 | 153 | # describe 'others', -> 154 | # 155 | # it 'should work with BSON ObjectId', -> 156 | # { ObjectId } = require 'bson/lib/bson/objectid' 157 | # d = { 158 | # _id: ObjectId() 159 | # foo: 'FOO' 160 | # bar: undefined 161 | # } 162 | # console.log $.diff d, {} 163 | 164 | describe 'isRealNumber', -> 165 | 166 | it 'should work with real numbers', -> 167 | assert.equal true, $.isRealNumber 0 168 | assert.equal true, $.isRealNumber 0, 1.1 169 | 170 | it 'should catch NaN', -> 171 | assert.equal false, $.isRealNumber 0, NaN 172 | 173 | it 'should catch +/-Infinity', -> 174 | assert.equal false, $.isRealNumber Infinity 175 | assert.equal false, $.isRealNumber -Infinity 176 | assert.equal false, $.isRealNumber 1, Infinity 177 | assert.equal false, $.isRealNumber -Infinity, 1 178 | -------------------------------------------------------------------------------- /spec/spec-resolve.coffee: -------------------------------------------------------------------------------- 1 | 2 | assert = require 'assert' 3 | $ = require '../src' 4 | 5 | y = (a, b) -> 6 | assert.deepEqual a, b 7 | 8 | describe 'resolve', -> 9 | 10 | it 'should not throw', -> 11 | y [ null, [ 'foo', 'bar' ] ], $.resolve null, 'foo.bar' 12 | 13 | it 'should resolve', -> 14 | y [ { bar: 'baz' }, [ 'bar' ] ], $.resolve { foo: bar: 'baz' }, 'foo.bar' 15 | -------------------------------------------------------------------------------- /src/index.coffee: -------------------------------------------------------------------------------- 1 | 2 | { digest } = require 'json-hash' 3 | 4 | # Check if one or more arguments are real numbers (no NaN or +/-Infinity). 5 | isRealNumber = (args...) -> 6 | args.every (e) -> 7 | (typeof e is 'number') and (isNaN(e) is false) and (e isnt +Infinity) and (e isnt -Infinity) 8 | 9 | # Check if object is plain object. 10 | isPlainObject = (a) -> 11 | a isnt null and typeof a is 'object' 12 | 13 | # Compute difference between two JSON objects. 14 | # 15 | # @param [Object, Array] a 16 | # @param [Object, Array] b 17 | # @param [Array, String] stack Optional scope, ie. 'foo.bar', or ['foo', 'bar']. 18 | # @param [Object] options Options 19 | # @option options [Boolean] inc When true $inc diff result is enabled for 20 | # numbers, default to false. 21 | # @param [Boolean] top Internal, marks root invocation. Used to invoke rename. 22 | # @param [Object] garbage Internal, holds removed values and their keys, used 23 | # for renaming. 24 | # @return [Object] Difference between b and a JSON objects or false if they are 25 | # the same. 26 | diff = (a, b, stack = [], options = {}, top = true, garbage = {}) -> 27 | 28 | # Make sure we're working on an array stack. At the root invocation it can be 29 | # string, null, false, undefined or an array. 30 | stack = arrize(stack) 31 | 32 | aKeys = Object.keys(a).sort() 33 | bKeys = Object.keys(b).sort() 34 | 35 | aN = aKeys.length 36 | bN = bKeys.length 37 | 38 | aI = 0 39 | bI = 0 40 | 41 | delta = 42 | $rename: {} 43 | $unset: {} 44 | $set: {} 45 | $inc: {} 46 | 47 | unsetA = (i) -> 48 | key = (stack.concat aKeys[i]).join('.') 49 | delta.$unset[key] = true 50 | h = digest a[aKeys[i]] 51 | (garbage[h] ||= []).push key 52 | 53 | setB = (i) -> 54 | key = (stack.concat bKeys[i]).join('.') 55 | delta.$set[key] = b[bKeys[i]] 56 | 57 | incA = (i, d) -> 58 | key = (stack.concat aKeys[i]).join('.') 59 | delta.$inc[key] = d 60 | 61 | while (aI < aN) and (bI < bN) 62 | aKey = aKeys[aI] 63 | bKey = bKeys[bI] 64 | 65 | if aKey is bKey 66 | aVal = a[aKey] 67 | bVal = b[bKey] 68 | switch 69 | 70 | # Skip if values (scalars) are the same 71 | when aVal is bVal 72 | undefined # pass 73 | 74 | # Hack around typeof null is 'object' weirdness 75 | when (aVal? and not bVal?) or (not aVal? and bVal?) 76 | setB bI 77 | 78 | # Special case for Date support 79 | when (aVal instanceof Date) and (bVal instanceof Date) 80 | if +aVal isnt +bVal 81 | setB bI 82 | 83 | # Special case for RegExp support 84 | when (aVal instanceof RegExp) and (bVal instanceof RegExp) 85 | if "#{aVal}" isnt "#{bVal}" 86 | setB bI 87 | 88 | # Dive into any other objects 89 | when isPlainObject(aVal) and isPlainObject(bVal) 90 | for k, v of diff(aVal, bVal, stack.concat([aKey]), options, false, garbage) 91 | delta[k][k2] = v2 for k2, v2 of v # Merge changes 92 | 93 | # Skip non-plain, same objects 94 | when not isPlainObject(aVal) and not isPlainObject(bVal) and digest(aVal) is digest(bVal) 95 | undefined 96 | 97 | else 98 | 99 | # Support $inc if it was (explicitly) enabled. 100 | if (options.inc is true) and isRealNumber(aVal, bVal) 101 | incA aI, bVal - aVal 102 | else 103 | 104 | # NOTE: aVal doesn't go to garbage (as a potential rename) because 105 | # MongoDB 2.4.x doesn't allow $set and $rename for the same 106 | # key paths giving MongoDB error 10150: "exception: Field 107 | # name duplication not allowed with modifiers" 108 | setB bI 109 | ++aI 110 | ++bI 111 | else 112 | if aKey < bKey 113 | unsetA aI 114 | ++aI 115 | else 116 | setB bI 117 | ++bI 118 | 119 | # Finish remaining a keys if any left. 120 | while aI < aN 121 | unsetA aI++ 122 | 123 | # Finish remaining b keys if any left. 124 | while bI < bN 125 | setB bI++ 126 | 127 | if top 128 | 129 | # Diff has been completed, root invocation wants to do the rename, collect 130 | # from garbage whatever we can. 131 | collect = ( 132 | [k, key] for k, v of delta.$set when ( 133 | h = digest v 134 | garbage[h]? and (key = garbage[h].pop()) 135 | ) 136 | ) 137 | for e in collect 138 | [k, key] = e 139 | delta.$rename[key] = k 140 | delete delta.$unset[key] 141 | delete delta.$set[k] 142 | 143 | # Return non-empty modifications only. 144 | for k of delta 145 | if Object.keys(delta[k]).length is 0 146 | delete delta[k] 147 | 148 | # Return false if there are no differences. 149 | if Object.keys(delta).length == 0 150 | delta = false 151 | 152 | delta 153 | 154 | # Deep copy for JSON objects. 155 | # 156 | # @param [Object, Array] a Object to clone 157 | # @return [Object, Array] Cloned a object 158 | clone = (a) -> 159 | switch 160 | when (not a?) or (typeof(a) isnt 'object') 161 | a 162 | when (a instanceof Date) 163 | new Date(a.getTime()) 164 | when (a instanceof RegExp) 165 | f = '' 166 | f += 'g' if a.global? 167 | f += 'i' if a.ignoreCase? 168 | f += 'm' if a.multiline? 169 | f += 'y' if a.sticky? 170 | new RegExp(a.source, f) 171 | else 172 | b = new a.constructor 173 | for k, v of a 174 | b[k] = clone v 175 | b 176 | 177 | # Convert a path into an array of components (key path). 178 | # 179 | # @param [Array, String] path 180 | # @param [String] glue Glue/separator. 181 | # @return [Array] Cloned or created array. 182 | arrize = (path, glue = '.') -> 183 | ( 184 | if Array.isArray(path) 185 | path.slice 0 186 | else 187 | switch path 188 | when undefined, null, false, '' 189 | [] 190 | else 191 | path.toString().split(glue) 192 | ).map (e) -> 193 | switch e 194 | when undefined, null, false, '' 195 | null 196 | else 197 | e.toString() 198 | .filter (e) -> e? 199 | 200 | # Resolve key path on an object. 201 | # 202 | # @example Example 203 | # a = hello: in: nested: world: '!' 204 | # console.log resolve a, 'hello.in.nested' 205 | # # [ { nested: { world: '!' } }, [ 'nested' ] ] 206 | # 207 | # @param [Object] a An object to perform resolve on. 208 | # @param [Array, String] path Key path. 209 | # @param [Object] options 210 | # @option options [Boolean] force Force creation of nested objects (or arrays 211 | # for strictly number keys) if they don't exist. Default to false. 212 | # @return [Array] [obj, path] tuple where obj is a resolved object and path an 213 | # array with last component or multiple unresolved components. 214 | resolve = (a, path, options = {}) -> 215 | stack = arrize path 216 | 217 | last = [] 218 | 219 | if stack.length > 0 220 | last.unshift stack.pop() 221 | 222 | # Please note we can stop resolve before reaching 223 | # last element. If this is the case last will have 224 | # multiple components if not forced. 225 | e = a 226 | if e isnt null 227 | while (k = stack.shift()) isnt undefined 228 | if e[k] isnt undefined 229 | e = e[k] 230 | else 231 | stack.unshift(k) 232 | break 233 | 234 | if options.force 235 | while (k = stack.shift()) isnt undefined 236 | 237 | # If the key is a number, we're creating array container, othwerwise 238 | # an object. Number components can only be set explicitly and will never 239 | # come from splitting a string so this behaviour is somehow explicitly 240 | # controlled by the caller (by using numbers vs strings). 241 | if ( 242 | (typeof stack[0] is 'number') or 243 | ((stack.length == 0) and (typeof last[0] is 'number')) 244 | ) 245 | e[k] = [] 246 | else 247 | e[k] = {} 248 | e = e[k] 249 | 250 | else 251 | 252 | # Put all unresolved components into last. 253 | while (k = stack.pop()) isnt undefined 254 | last.unshift(k) 255 | 256 | [e, last] 257 | 258 | # Apply delta diff on JSON object. 259 | # 260 | # @param [Object] a An object to apply delta on 261 | # @param [Object] delta Diff to apply to a 262 | # @return [Object] a object with applied diff. 263 | apply = (a, delta) -> 264 | if delta? 265 | 266 | if delta.$rename? 267 | for k, v of delta.$rename 268 | [o1, n1] = resolve a, k 269 | [o2, n2] = resolve a, v 270 | if o1? and n1.length == 1 271 | if o2? and n2.length == 1 272 | o2[n2[0]] = o1[n1[0]] 273 | delete o1[n1[0]] 274 | else 275 | throw new Error "#{o2}/#{n2} - couldn't resolve first for #{a} #{v}" 276 | else 277 | throw new Error "#{o1}/#{n1} - couldn't resolve second for #{a} #{k}" 278 | 279 | if delta.$set? 280 | for k, v of delta.$set 281 | [o, n] = resolve a, k, force: true 282 | if o? and n.length == 1 283 | o[n[0]] = v 284 | else 285 | throw new Error "#{o}/#{n} - couldn't set for #{a} #{k}" 286 | 287 | if delta.$inc? 288 | for k, v of delta.$inc 289 | [o, n] = resolve a, k, force: true 290 | if o? and n.length == 1 291 | o[n[0]] ?= 0 292 | o[n[0]] += v 293 | else 294 | throw new Error "#{o}/#{n} - couldn't set for #{a} #{k}" 295 | 296 | if delta.$unset? 297 | for k, v of delta.$unset 298 | [o, n] = resolve a, k 299 | if o? and n.length == 1 300 | delete o[n[0]] 301 | else 302 | throw new Error "#{o}/#{n} - couldn't unset for #{a} #{k}" 303 | 304 | a 305 | 306 | module.exports = { 307 | apply 308 | arrize 309 | clone 310 | diff 311 | isRealNumber 312 | resolve 313 | 314 | # NOTE: For compatibility, will be removed on next non api compatible release. 315 | rusDiff: diff 316 | } 317 | --------------------------------------------------------------------------------