diff --git a/src/core/merge.js b/src/core/merge.js index 7867a0b..17b39d8 100644 --- a/src/core/merge.js +++ b/src/core/merge.js @@ -1,54 +1,76 @@ +const assert = require('assert'); const objectScan = require('object-scan'); +const last = (arr) => arr[arr.length - 1]; +const mkChild = (ref) => { + if (!(ref instanceof Object)) { + return ref; + } + return Array.isArray(ref) ? [] : {}; +}; +const populate = (obj, key, fn) => { + if (!(key in obj)) { + // eslint-disable-next-line no-param-reassign + obj[key] = fn(); + return true; + } + return false; +}; +const incompatible = (a, b) => ( + !(a instanceof Object) + || !(b instanceof Object) + || Array.isArray(a) !== Array.isArray(b) +); + module.exports = (logic_ = {}) => { const logic = { '**': null, ...logic_ }; - const last = (arr) => arr[arr.length - 1]; - const mkChild = (ref) => { - if (!(ref instanceof Object)) { - return ref; - } - return Array.isArray(ref) ? [] : {}; - }; - const populate = (obj, key, fn, force = false) => { - if (force === true || !(key in obj)) { - // eslint-disable-next-line no-param-reassign - obj[key] = fn(); - return true; - } - return false; - }; const scanner = objectScan(Object.keys(logic), { reverse: false, breakFn: ({ isMatch, property, value, matchedBy, context }) => { - if (!isMatch) return; const { stack, groups, path } = context; const current = last(stack); + + if (!isMatch) { + if (incompatible(current, value)) { + stack[0] = mkChild(value); + } + return false; + } + if (!(current instanceof Object)) { + stack.push(null); + return true; + } + if (!Array.isArray(current)) { + if (!(property in current) || incompatible(current[property], value)) { + current[property] = mkChild(value); + } + stack.push(current[property]); + return false; + } + const bestNeedle = last(matchedBy); const groupBy = typeof logic[bestNeedle] === 'function' ? logic[bestNeedle](value) : logic[bestNeedle]; - if (!Array.isArray(current) || groupBy === null) { - if (Array.isArray(current)) { - current.push(mkChild(value)); - stack.push(last(current)); - } else { - populate(current, property, () => mkChild(value), !(value instanceof Object)); - stack.push(current[property]); - } - } else { - const groupId = `${bestNeedle}.${groupBy}: ${path.join('.')}`; - populate(groups, groupId, () => ({})); - const groupEntryId = value[groupBy]; - if (populate(groups[groupId], groupEntryId, () => mkChild(value))) { - current.push(groups[groupId][groupEntryId]); - } - path.push(`${groupBy}=${groupEntryId}`); - stack.push(groups[groupId][groupEntryId]); + if (groupBy === null) { + current.push(value); + stack.push(null); + return true; + } + + const groupId = `${bestNeedle}.${groupBy}: ${path.join('.')}`; + populate(groups, groupId, () => ({})); + const groupEntryId = value instanceof Object ? value[groupBy] : undefined; + if (populate(groups[groupId], groupEntryId, () => mkChild(value))) { + current.push(groups[groupId][groupEntryId]); } + path.push(`${groupBy}=${groupEntryId}`); + stack.push(groups[groupId][groupEntryId]); + return false; }, filterFn: ({ matchedBy, context }) => { const { stack, path } = context; @@ -59,9 +81,10 @@ module.exports = (logic_ = {}) => { } }); return (...args) => { - const result = mkChild(last(args)); + const stack = [undefined]; const groups = {}; - args.forEach((arg) => scanner(arg, { stack: [result], groups, path: [] })); - return result; + args.forEach((arg) => scanner(arg, { stack, groups, path: [] })); + assert(stack.length === 1); + return stack[0]; }; }; diff --git a/test/core/merge.spec.js b/test/core/merge.spec.js index 0d80f02..890a100 100644 --- a/test/core/merge.spec.js +++ b/test/core/merge.spec.js @@ -1,45 +1,131 @@ const expect = require('chai').expect; const { describe } = require('node-tdd'); +const clonedeep = require('lodash.clonedeep'); const Merge = require('../../src/core/merge'); +const genData = require('./gen-data'); describe('Testing Merge', () => { - it('Testing SO question: https://stackoverflow.com/questions/65822248', ({ fixture }) => { - const json1 = fixture('json1'); - const json2 = fixture('json2'); - const merge = Merge({ - '[*]': 'id', - '[*].addresses[*]': 'type' - }); - expect(merge(json1, json2)).to.deep.equal(fixture('result')); - }); + describe('Default Merge', () => { + let merge; + before(() => { + merge = Merge(); + }); - it('Testing string merge', () => { - const d1 = 'A'; - const d2 = 'B'; - expect(Merge()(d1, d2)).to.deep.equal('B'); - }); + it('Batch test', ({ fixture }) => { + const mergeRec = fixture('merge-rec'); + for (let x = 0; x < 10000; x += 1) { + const tree = genData(); + const subtree = genData(); + const treeX = clonedeep(tree); + const subtreeX = clonedeep(subtree); + const r1 = merge(tree, subtree); + expect(treeX).to.deep.equal(tree); + expect(subtreeX).to.deep.equal(subtree); + const r2 = mergeRec(tree, subtree); + expect(treeX).to.deep.equal(tree); + expect(subtreeX).to.deep.equal(subtree); + expect(r1).to.deep.equal(r2); + } + }); - it('Testing array concat', () => { - const d1 = [{ a: 1 }]; - const d2 = [{ a: 2 }]; - expect(Merge()(d1, d2)).to.deep.equal([{ a: 1 }, { a: 2 }]); - }); + it('Nested empty array concat', () => { + expect(merge([[]], [[]])).to.deep.equal([[], []]); + }); + + it('Nested arrays in object concat', () => { + expect(merge( + { B: [{}, [2]] }, + { B: [1] } + )).to.deep.equal({ B: [{}, [2], 1] }); + }); + + it('Nested array concat objects', () => { + expect(merge([{ A: [] }], [0])).to.deep.equal([{ A: [] }, 0]); + }); + + it('Nested array overwrite with object', () => { + expect(merge({ B: [{}, {}] }, { B: {} })).to.deep.equal({ B: {} }); + }); + + it('Nested nested array concat', () => { + expect(merge({ B: [[2]] }, { B: [[2]] })).to.deep.equal({ B: [[2], [2]] }); + }); + + it('Object overwrite array', () => { + expect(merge([1], { a: 1 })).to.deep.equal({ a: 1 }); + }); - it('Testing array merge', () => { - const d1 = [{ a: 1, b: 1, c: 3 }]; - const d2 = [{ a: 1, b: 2, d: 4 }]; - expect(Merge({ '[*]': 'a' })(d1, d2)).to.deep.equal([{ ...d1[0], ...d2[0] }]); + it('Array overwrite object', () => { + expect(merge({ a: 1 }, [1])).to.deep.equal([1]); + }); + + it('Testing string merge', () => { + const d1 = 'A'; + const d2 = 'B'; + expect(merge(d1, d2)).to.deep.equal('B'); + }); + + it('Testing array concat', () => { + const d1 = [{ a: 1 }]; + const d2 = [{ a: 2 }]; + expect(merge(d1, d2)).to.deep.equal([{ a: 1 }, { a: 2 }]); + }); }); - it('Testing merge by sum', () => { - const d1 = [[1, 2, 3], [2, 4], [1, 2]]; - const d2 = [[3, 3], [1, 5], [3, 2]]; - expect(Merge({ - '[*]': (o) => o.reduce((a, b) => a + b, 0) - })(d1, d2)).to.deep.equal([ - [1, 2, 3, 2, 4, 3, 3, 1, 5], - [1, 2], - [3, 2] - ]); + describe('Custom Merge', () => { + it('Batch test', () => { + const merge = Merge({ '**': 'A' }); + for (let x = 0; x < 10000; x += 1) { + const tree = genData(); + const subtree = genData(); + const treeX = clonedeep(tree); + const subtreeX = clonedeep(subtree); + expect(() => merge(tree, subtree)).to.not.throw(); + expect(treeX).to.deep.equal(tree); + expect(subtreeX).to.deep.equal(subtree); + } + }); + + it('Testing SO question: https://stackoverflow.com/questions/65822248', ({ fixture }) => { + const json1 = fixture('json1'); + const json2 = fixture('json2'); + const merge = Merge({ + '[*]': 'id', + '[*].addresses[*]': 'type' + }); + expect(merge(json1, json2)).to.deep.equal(fixture('result')); + }); + + it('Testing array merge', () => { + const d1 = [{ a: 1, b: 1, c: 3 }]; + const d2 = [{ a: 1, b: 2, d: 4 }]; + expect(Merge({ '[*]': 'a' })(d1, d2)).to.deep.equal([{ ...d1[0], ...d2[0] }]); + }); + + it('Testing merge by sum', () => { + const d1 = [[1, 2, 3], [2, 4], [1, 2]]; + const d2 = [[3, 3], [1, 5], [3, 2]]; + expect(Merge({ + '[*]': (o) => o.reduce((a, b) => a + b, 0) + })(d1, d2)).to.deep.equal([ + [1, 2, 3, 2, 4, 3, 3, 1, 5], + [1, 2], + [3, 2] + ]); + }); + + it('Testing incompatible', () => { + const d1 = [{ a: 1 }]; + const d2 = [undefined]; + const d3 = ['A']; + expect(Merge({ '[*]': 'a' })(d1, d2, d3)).to.deep.equal([{ a: 1 }, undefined]); + }); + + it('Testing overwrite', () => { + const d1 = { a: [{ a: 1 }] }; + const d2 = { a: [{ a: 2 }] }; + const d3 = { a: null }; + expect(Merge({ '[*]': 'a' })(d1, d2, d3)).to.deep.equal({ a: null }); + }); }); }); diff --git a/test/core/merge.spec.js__fixtures/merge-rec.js b/test/core/merge.spec.js__fixtures/merge-rec.js new file mode 100644 index 0000000..69a9d2f --- /dev/null +++ b/test/core/merge.spec.js__fixtures/merge-rec.js @@ -0,0 +1,28 @@ +const mergeRec = (tree, subtree) => { + if (!(subtree instanceof Object)) { + return subtree; + } + const treeIsArray = Array.isArray(tree); + const subtreeIsArray = Array.isArray(subtree); + if (treeIsArray !== subtreeIsArray) { + return subtreeIsArray ? [...subtree] : { ...subtree }; + } + if (tree instanceof Object && subtree instanceof Object) { + const e1 = Object.entries(tree); + const e2 = Object.entries(subtree); + const r = Array.isArray(subtree) ? [] : {}; + [e1, e2].forEach((e) => { + e.forEach(([k, v]) => { + if (Array.isArray(r)) { + r.push(v); + } else { + r[k] = k in r ? mergeRec(r[k], v) : v; + } + }); + }); + return r; + } + return subtree; +}; + +module.exports = mergeRec;