UNPKG

rus-diff

Version:
317 lines (267 loc) 8.54 kB
{ digest } = require 'json-hash' # Check if one or more arguments are real numbers (no NaN or +/-Infinity). isRealNumber = (args...) -> args.every (e) -> (typeof e is 'number') and (isNaN(e) is false) and (e isnt +Infinity) and (e isnt -Infinity) # Check if object is plain object. isPlainObject = (a) -> a isnt null and typeof a is 'object' and a.constructor is Object # Compute difference between two JSON objects. # # @param [Object, Array] a # @param [Object, Array] b # @param [Array, String] stack Optional scope, ie. 'foo.bar', or ['foo', 'bar']. # @param [Object] options Options # @option options [Boolean] inc When true $inc diff result is enabled for # numbers, default to false. # @param [Boolean] top Internal, marks root invocation. Used to invoke rename. # @param [Object] garbage Internal, holds removed values and their keys, used # for renaming. # @return [Object] Difference between b and a JSON objects or false if they are # the same. diff = (a, b, stack = [], options = {}, top = true, garbage = {}) -> # Make sure we're working on an array stack. At the root invocation it can be # string, null, false, undefined or an array. stack = arrize(stack) aKeys = Object.keys(a).sort() bKeys = Object.keys(b).sort() aN = aKeys.length bN = bKeys.length aI = 0 bI = 0 delta = $rename: {} $unset: {} $set: {} $inc: {} unsetA = (i) -> key = (stack.concat aKeys[i]).join('.') delta.$unset[key] = true h = digest a[aKeys[i]] (garbage[h] ||= []).push key setB = (i) -> key = (stack.concat bKeys[i]).join('.') delta.$set[key] = b[bKeys[i]] incA = (i, d) -> key = (stack.concat aKeys[i]).join('.') delta.$inc[key] = d while (aI < aN) and (bI < bN) aKey = aKeys[aI] bKey = bKeys[bI] if aKey is bKey aVal = a[aKey] bVal = b[bKey] switch # Skip if values (scalars) are the same when aVal is bVal undefined # pass # Hack around typeof null is 'object' weirdness when (aVal? and not bVal?) or (not aVal? and bVal?) setB bI # Special case for Date support when (aVal instanceof Date) and (bVal instanceof Date) if +aVal isnt +bVal setB bI # Special case for RegExp support when (aVal instanceof RegExp) and (bVal instanceof RegExp) if "#{aVal}" isnt "#{bVal}" setB bI # Dive into any other objects when isPlainObject(aVal) and isPlainObject(bVal) for k, v of diff(aVal, bVal, stack.concat([aKey]), options, false, garbage) delta[k][k2] = v2 for k2, v2 of v # Merge changes # Skip non-plain, same objects when not isPlainObject(aVal) and not isPlainObject(bVal) and digest(aVal) is digest(bVal) undefined else # Support $inc if it was (explicitly) enabled. if (options.inc is true) and isRealNumber(aVal, bVal) incA aI, bVal - aVal else # NOTE: aVal doesn't go to garbage (as a potential rename) because # MongoDB 2.4.x doesn't allow $set and $rename for the same # key paths giving MongoDB error 10150: "exception: Field # name duplication not allowed with modifiers" setB bI ++aI ++bI else if aKey < bKey unsetA aI ++aI else setB bI ++bI # Finish remaining a keys if any left. while aI < aN unsetA aI++ # Finish remaining b keys if any left. while bI < bN setB bI++ if top # Diff has been completed, root invocation wants to do the rename, collect # from garbage whatever we can. collect = ( [k, key] for k, v of delta.$set when ( h = digest v garbage[h]? and (key = garbage[h].pop()) ) ) for e in collect [k, key] = e delta.$rename[key] = k delete delta.$unset[key] delete delta.$set[k] # Return non-empty modifications only. for k of delta if Object.keys(delta[k]).length is 0 delete delta[k] # Return false if there are no differences. if Object.keys(delta).length == 0 delta = false delta # Deep copy for JSON objects. # # @param [Object, Array] a Object to clone # @return [Object, Array] Cloned a object clone = (a) -> switch when (not a?) or (typeof(a) isnt 'object') a when (a instanceof Date) new Date(a.getTime()) when (a instanceof RegExp) f = '' f += 'g' if a.global? f += 'i' if a.ignoreCase? f += 'm' if a.multiline? f += 'y' if a.sticky? new RegExp(a.source, f) else b = new a.constructor for k, v of a b[k] = clone v b # Convert a path into an array of components (key path). # # @param [Array, String] path # @param [String] glue Glue/separator. # @return [Array] Cloned or created array. arrize = (path, glue = '.') -> ( if Array.isArray(path) path.slice 0 else switch path when undefined, null, false, '' [] else path.toString().split(glue) ).map (e) -> switch e when undefined, null, false, '' null else e.toString() .filter (e) -> e? # Resolve key path on an object. # # @example Example # a = hello: in: nested: world: '!' # console.log resolve a, 'hello.in.nested' # # [ { nested: { world: '!' } }, [ 'nested' ] ] # # @param [Object] a An object to perform resolve on. # @param [Array, String] path Key path. # @param [Object] options # @option options [Boolean] force Force creation of nested objects (or arrays # for strictly number keys) if they don't exist. Default to false. # @return [Array] [obj, path] tuple where obj is a resolved object and path an # array with last component or multiple unresolved components. resolve = (a, path, options = {}) -> stack = arrize path last = [] if stack.length > 0 last.unshift stack.pop() # Please note we can stop resolve before reaching # last element. If this is the case last will have # multiple components if not forced. e = a if e isnt null while (k = stack.shift()) isnt undefined if e[k] isnt undefined e = e[k] else stack.unshift(k) break if options.force while (k = stack.shift()) isnt undefined # If the key is a number, we're creating array container, othwerwise # an object. Number components can only be set explicitly and will never # come from splitting a string so this behaviour is somehow explicitly # controlled by the caller (by using numbers vs strings). if ( (typeof stack[0] is 'number') or ((stack.length == 0) and (typeof last[0] is 'number')) ) e[k] = [] else e[k] = {} e = e[k] else # Put all unresolved components into last. while (k = stack.pop()) isnt undefined last.unshift(k) [e, last] # Apply delta diff on JSON object. # # @param [Object] a An object to apply delta on # @param [Object] delta Diff to apply to a # @return [Object] a object with applied diff. apply = (a, delta) -> if delta? if delta.$rename? for k, v of delta.$rename [o1, n1] = resolve a, k [o2, n2] = resolve a, v if o1? and n1.length == 1 if o2? and n2.length == 1 o2[n2[0]] = o1[n1[0]] delete o1[n1[0]] else throw new Error "#{o2}/#{n2} - couldn't resolve first for #{a} #{v}" else throw new Error "#{o1}/#{n1} - couldn't resolve second for #{a} #{k}" if delta.$set? for k, v of delta.$set [o, n] = resolve a, k, force: true if o? and n.length == 1 o[n[0]] = v else throw new Error "#{o}/#{n} - couldn't set for #{a} #{k}" if delta.$inc? for k, v of delta.$inc [o, n] = resolve a, k, force: true if o? and n.length == 1 o[n[0]] ?= 0 o[n[0]] += v else throw new Error "#{o}/#{n} - couldn't set for #{a} #{k}" if delta.$unset? for k, v of delta.$unset [o, n] = resolve a, k if o? and n.length == 1 delete o[n[0]] else throw new Error "#{o}/#{n} - couldn't unset for #{a} #{k}" a module.exports = { apply arrize clone diff isRealNumber resolve # NOTE: For compatibility, will be removed on next non api compatible release. rusDiff: diff }