diff --git a/README.md b/README.md index 0344569..8ca2f79 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ deepmerge(options) - `symbols` (`boolean`, optional) - should also merge object-keys which are symbols, default is false - `all` (`boolean`, optional) - merges all parameters, default is false +- `nullPrototype` (`boolean, optional) - created Objects have no prototype so keys like constructor and prototype are allowed without having the risk of a prototype pollution but has a ca. 20-30 % performance penalty, default is false ```js const deepmerge = require('@fastify/deepmegre')() @@ -42,22 +43,32 @@ The benchmarks are available in the benchmark-folder. `npm run bench` - benchmark various use cases of deepmerge: ``` -@fastify/deepmerge: merge regex with date x 1,266,447,885 ops/sec ±0.14% (97 runs sampled) -@fastify/deepmerge: merge object with a primitive x 1,266,435,016 ops/sec ±0.33% (97 runs sampled) -@fastify/deepmerge: merge two arrays containing strings x 25,591,739 ops/sec ±0.24% (98 runs sampled) -@fastify/deepmerge: two merge arrays containing objects x 976,182 ops/sec ±0.46% (98 runs sampled) -@fastify/deepmerge: merge two flat objects x 10,027,879 ops/sec ±0.36% (94 runs sampled) -@fastify/deepmerge: merge nested objects x 5,341,227 ops/sec ±0.67% (94 runs sampled) +@fastify/deepmerge: merge regex with date x 1,245,263,207 ops/sec ±0.41% (99 runs sampled) +@fastify/deepmerge: merge object with a primitive x 1,241,361,757 ops/sec ±0.40% (99 runs sampled) +@fastify/deepmerge: merge two arrays containing strings x 24,765,578 ops/sec ±0.50% (94 runs sampled) +@fastify/deepmerge: merge two arrays containing objects x 1,600,598 ops/sec ±0.64% (96 runs sampled) +@fastify/deepmerge: merge two flat objects x 14,327,729 ops/sec ±0.57% (93 runs sampled) +@fastify/deepmerge: merge nested objects x 7,319,512 ops/sec ±0.45% (96 runs sampled) +``` + +`npm run bench:nullprototype` - benchmark deepmerge with nullPrototype set to true: +``` +@fastify/deepmerge: merge regex with date x 1,241,641,471 ops/sec ±0.19% (99 runs sampled) +@fastify/deepmerge: merge object with a primitive x 1,232,239,900 ops/sec ±0.49% (93 runs sampled) +@fastify/deepmerge: merge two arrays containing strings x 24,893,133 ops/sec ±0.56% (95 runs sampled) +@fastify/deepmerge: merge two arrays containing objects x 1,521,465 ops/sec ±0.83% (94 runs sampled) +@fastify/deepmerge: merge two flat objects x 9,414,407 ops/sec ±0.61% (97 runs sampled) +@fastify/deepmerge: merge nested objects x 5,865,123 ops/sec ±0.50% (93 runs sampled) ``` `npm run bench:compare` - comparison of @fastify/deepmerge with other popular deepmerge implementation: ``` -@fastify/deepmerge x 403,777 ops/sec ±0.22% (98 runs sampled) -deepmerge x 21,143 ops/sec ±0.83% (93 runs sampled) -merge-deep x 89,447 ops/sec ±0.59% (95 runs sampled) -ts-deepmerge x 185,601 ops/sec ±0.59% (96 runs sampled) -deepmerge-ts x 185,310 ops/sec ±0.50% (92 runs sampled) -lodash.merge x 89,053 ops/sec ±0.37% (99 runs sampled) +@fastify/deepmerge x 618,229 ops/sec ±0.19% (99 runs sampled) +deepmerge x 21,326 ops/sec ±0.43% (95 runs sampled) +merge-deep x 86,034 ops/sec ±0.49% (98 runs sampled) +ts-deepmerge x 183,827 ops/sec ±0.41% (99 runs sampled) +deepmerge-ts x 179,950 ops/sec ±0.62% (95 runs sampled) +lodash.merge x 89,655 ops/sec ±0.44% (99 runs sampled) ``` ## License diff --git a/benchmark/bench.all.js b/benchmark/bench.all.js index 2c9b86d..ffaa30a 100644 --- a/benchmark/bench.all.js +++ b/benchmark/bench.all.js @@ -38,7 +38,7 @@ new Benchmark.Suite() .add('@fastify/deepmerge: merge two arrays containing strings', function () { deepmerge(simpleArrayTarget, simpleArraySource) }) - .add('@fastify/deepmerge: two merge arrays containing objects', function () { + .add('@fastify/deepmerge: merge two arrays containing objects', function () { deepmerge(complexArrayTarget, complexArraySource) }) .add('@fastify/deepmerge: merge two flat objects', function () { diff --git a/benchmark/bench.compare.detailed.js b/benchmark/bench.compare.detailed.js index b991637..cb13bb9 100644 --- a/benchmark/bench.compare.detailed.js +++ b/benchmark/bench.compare.detailed.js @@ -43,7 +43,7 @@ new Benchmark.Suite() .add('@fastify/deepmerge: merge two arrays containing strings', function () { fastifyDeepmerge(simpleArrayTarget, simpleArraySource) }) - .add('@fastify/deepmerge: two merge arrays containing objects', function () { + .add('@fastify/deepmerge: merge two arrays containing objects', function () { fastifyDeepmerge(complexArrayTarget, complexArraySource) }) .add('@fastify/deepmerge: merge two flat objects', function () { @@ -61,7 +61,7 @@ new Benchmark.Suite() .add('deepmerge: merge two arrays containing strings', function () { deepmerge(simpleArrayTarget, simpleArraySource) }) - .add('deepmerge: two merge arrays containing objects', function () { + .add('deepmerge: merge two arrays containing objects', function () { deepmerge(complexArrayTarget, complexArraySource) }) .add('deepmerge: merge two flat objects', function () { @@ -79,7 +79,7 @@ new Benchmark.Suite() .add('merge-deep: merge two arrays containing strings', function () { mergedeep(simpleArrayTarget, simpleArraySource) }) - .add('merge-deep: two merge arrays containing objects', function () { + .add('merge-deep: merge two arrays containing objects', function () { mergedeep(complexArrayTarget, complexArraySource) }) .add('merge-deep: merge two flat objects', function () { @@ -97,7 +97,7 @@ new Benchmark.Suite() .add('ts-deepmerge: merge two arrays containing strings', function () { tsDeepmerge(simpleArrayTarget, simpleArraySource) }) - .add('ts-deepmerge: two merge arrays containing objects', function () { + .add('ts-deepmerge: merge two arrays containing objects', function () { tsDeepmerge(complexArrayTarget, complexArraySource) }) .add('ts-deepmerge: merge two flat objects', function () { @@ -115,7 +115,7 @@ new Benchmark.Suite() .add('deepmerge-ts: merge two arrays containing strings', function () { deepmergeTs(simpleArrayTarget, simpleArraySource) }) - .add('deepmerge-ts: two merge arrays containing objects', function () { + .add('deepmerge-ts: merge two arrays containing objects', function () { deepmergeTs(complexArrayTarget, complexArraySource) }) .add('deepmerge-ts: merge two flat objects', function () { @@ -133,7 +133,7 @@ new Benchmark.Suite() .add('lodash.merge: merge two arrays containing strings', function () { lodashMerge(simpleArrayTarget, simpleArraySource) }) - .add('lodash.merge: two merge arrays containing objects', function () { + .add('lodash.merge: merge two arrays containing objects', function () { lodashMerge(complexArrayTarget, complexArraySource) }) .add('lodash.merge: merge two flat objects', function () { diff --git a/benchmark/bench.js b/benchmark/bench.js index baaffe0..b842a01 100644 --- a/benchmark/bench.js +++ b/benchmark/bench.js @@ -38,7 +38,7 @@ new Benchmark.Suite() .add('@fastify/deepmerge: merge two arrays containing strings', function () { deepmerge(simpleArrayTarget, simpleArraySource) }) - .add('@fastify/deepmerge: two merge arrays containing objects', function () { + .add('@fastify/deepmerge: merge two arrays containing objects', function () { deepmerge(complexArrayTarget, complexArraySource) }) .add('@fastify/deepmerge: merge two flat objects', function () { diff --git a/benchmark/bench.nullPrototype.js b/benchmark/bench.nullPrototype.js new file mode 100644 index 0000000..3600d26 --- /dev/null +++ b/benchmark/bench.nullPrototype.js @@ -0,0 +1,53 @@ +'use strict' + +const Benchmark = require('benchmark') +const deepmerge = require('..')({ nullPrototype: true }) + +const sourceSimple = { key1: 'changed', key2: 'value2' } +const targetSimple = { key1: 'value1', key3: 'value3' } + +const sourceNested = { + key1: { + subkey1: 'subvalue1', + subkey2: 'subvalue2' + } +} +const targetNested = { + key1: 'value1', + key2: 'value2' +} + +const primitive = 'primitive' + +const date = new Date() +const regex = /a/g + +const simpleArrayTarget = ['a1', 'a2', 'c1', 'f1', 'p1'] +const simpleArraySource = ['t1', 's1', 'c2', 'r1', 'p2', 'p3'] + +const complexArraySource = [{ ...sourceSimple }, { ...sourceSimple }, { ...sourceSimple }, { ...sourceSimple }, { ...sourceSimple }] +const complexArrayTarget = [{ ...targetSimple }, { ...targetSimple }, { ...targetSimple }, { ...targetSimple }, { ...targetSimple }] + +new Benchmark.Suite() + .add('@fastify/deepmerge: merge regex with date', function () { + deepmerge(regex, date) + }) + .add('@fastify/deepmerge: merge object with a primitive', function () { + deepmerge(targetSimple, primitive) + }) + .add('@fastify/deepmerge: merge two arrays containing strings', function () { + deepmerge(simpleArrayTarget, simpleArraySource) + }) + .add('@fastify/deepmerge: merge two arrays containing objects', function () { + deepmerge(complexArrayTarget, complexArraySource) + }) + .add('@fastify/deepmerge: merge two flat objects', function () { + deepmerge(targetSimple, sourceSimple) + }) + .add('@fastify/deepmerge: merge nested objects', function () { + deepmerge(targetNested, sourceNested) + }) + .on('cycle', function (event) { + console.log(String(event.target)) + }) + .run() diff --git a/benchmark/package.json b/benchmark/package.json index cc9327b..fa06176 100644 --- a/benchmark/package.json +++ b/benchmark/package.json @@ -8,7 +8,8 @@ "bench": "node ./bench.js", "bench:all": "node ./bench.all.js", "bench:compare": "node ./bench.compare.js", - "bench:compare:detailed": "node ./bench.compare.detailed.js" + "bench:compare:detailed": "node ./bench.compare.detailed.js", + "bench:nullprototype": "node ./bench.nullPrototype.js" }, "author": "", "license": "ISC", diff --git a/index.js b/index.js index 00684ec..7f24ca4 100644 --- a/index.js +++ b/index.js @@ -5,10 +5,20 @@ // Copyright (c) 2012 - 2022 James Halliday, Josh Duff, and other contributors of deepmerge function deepmergeConstructor (options) { - const prototypeKeys = ['constructor', '__proto__', 'prototype'] + function isPrototypeKey (value) { + return ( + value === 'constructor' || + value === 'prototype' || + value === '__proto__' + ) + } function isNotPrototypeKey (value) { - return prototypeKeys.indexOf(value) === -1 + return ( + value !== 'constructor' && + value !== 'prototype' && + value !== '__proto__' + ) } function cloneArray (value) { @@ -65,30 +75,88 @@ function deepmergeConstructor (options) { return isMergeableObject(entry) ? Array.isArray(entry) ? cloneArray(entry) - : mergeObject({}, entry) + : cloneObject(entry) : entry } - function mergeObject (target, source) { + function mergeObjectNullPrototype (target, source) { + const result = Object.create(null) + const targetKeys = getKeys(target) + const sourceKeys = getKeys(source) + let i, il, key + for (i = 0, il = targetKeys.length; i < il; ++i) { + (key = targetKeys[i], true) && + (sourceKeys.indexOf(key) === -1) && + (result[key] = clone(target[key])) + } + + for (i = 0, il = sourceKeys.length; i < il; ++i) { + key = sourceKeys[i] + if (key in target) { + if (targetKeys.indexOf(key) !== -1) { + result[key] = _deepmerge(target[key], source[key]) + } else if (isPrototypeKey(key)) { + result[key] = clone(source[key]) + } + } else { + result[key] = clone(source[key]) + } + } + return result + } + + function cloneObjectWithPrototype (target) { const result = {} + + const targetKeys = getKeys(target) + let i, il, key + for (i = 0, il = targetKeys.length; i < il; ++i) { + isNotPrototypeKey(key = targetKeys[i]) && + (result[key] = clone(target[key])) + } + return result + } + + function cloneObjectNullPrototype (target) { + const result = Object.create(null) + + const targetKeys = getKeys(target) + let i, il, key + for (i = 0, il = targetKeys.length; i < il; ++i) { + key = targetKeys[i] + result[key] = clone(target[key]) + } + return result + } + + const cloneObject = options && options.nullPrototype === true + ? cloneObjectNullPrototype + : cloneObjectWithPrototype + + function mergeObjectWithPrototype (target, source) { + const result = {} + const targetKeys = getKeys(target) const sourceKeys = getKeys(source) let i, il, key for (i = 0, il = targetKeys.length; i < il; ++i) { isNotPrototypeKey(key = targetKeys[i]) && - (sourceKeys.indexOf(key) === -1) && - (result[key] = clone(target[key])) + (sourceKeys.indexOf(key) === -1) && + (result[key] = clone(target[key])) } for (i = 0, il = sourceKeys.length; i < il; ++i) { isNotPrototypeKey(key = sourceKeys[i]) && - ( - key in target && (targetKeys.indexOf(key) !== -1 && (result[key] = _deepmerge(target[key], source[key])), true) || // eslint-disable-line no-mixed-operators - (result[key] = clone(source[key])) - ) + ( + key in target && (targetKeys.indexOf(key) !== -1 && (result[key] = _deepmerge(target[key], source[key])), true) || // eslint-disable-line no-mixed-operators + (result[key] = clone(source[key])) + ) } return result } + const mergeObject = options && options.nullPrototype === true + ? mergeObjectNullPrototype + : mergeObjectWithPrototype function _deepmerge (target, source) { const sourceIsArray = Array.isArray(source) @@ -107,12 +175,16 @@ function deepmergeConstructor (options) { } } + const createObject = options && options.nullPrototype === true + ? function () { return Object.create(null) } + : function () { return {} } + function _deepmergeAll () { switch (arguments.length) { case 0: - return {} + return createObject() case 1: - return clone(arguments[0]) + return _deepmerge({}, arguments[0]) case 2: return _deepmerge(arguments[0], arguments[1]) } diff --git a/test/all.test.js b/test/all.test.js index 37e4938..b903753 100644 --- a/test/all.test.js +++ b/test/all.test.js @@ -5,6 +5,7 @@ // Copyright (c) 2012 - 2022 James Halliday, Josh Duff, and other contributors of deepmerge const deepmerge = require('../index')({ all: true }) +const deepmergeNull = require('../index')({ all: true, nullPrototype: true }) const test = require('tap').test test('return an empty object if first argument is an array with no elements', function (t) { @@ -51,9 +52,9 @@ test('invoke merge on every item in array with clone should clone all elements', const mergedWithClone = deepmerge(firstObject, secondObject, thirdObject) - t.not(mergedWithClone.a, firstObject.a) - t.not(mergedWithClone.b, secondObject.b) - t.not(mergedWithClone.c, thirdObject.c) + t.same(mergedWithClone.a, firstObject.a) + t.same(mergedWithClone.b, secondObject.b) + t.same(mergedWithClone.c, thirdObject.c) t.end() }) @@ -65,9 +66,75 @@ test('invoke merge on every item in array without clone should clone all element const mergedWithoutClone = deepmerge(firstObject, secondObject, thirdObject) - t.not(mergedWithoutClone.a, firstObject.a) - t.not(mergedWithoutClone.b, secondObject.b) - t.not(mergedWithoutClone.c, thirdObject.c) + t.same(mergedWithoutClone.a, firstObject.a) + t.same(mergedWithoutClone.b, secondObject.b) + t.same(mergedWithoutClone.c, thirdObject.c) + + t.end() +}) + +test('return an empty object if first argument is an array with no elements', function (t) { + t.same(deepmergeNull(), {}) + t.end() +}) + +test('Work just fine if first argument is an array with least than two elements', function (t) { + const actual = deepmergeNull({ example: true }) + const expected = { example: true } + t.same(actual, expected) + t.notOk(actual.toString) + t.end() +}) + +test('execute correctly if options object were not passed', function (t) { + t.doesNotThrow(deepmergeNull.bind(null, { example: true }, { another: '123' })) + t.end() +}) + +test('execute correctly if options object were passed', function (t) { + t.doesNotThrow(deepmergeNull.bind(null, { example: true }, { another: '123' })) + t.end() +}) + +test('invoke merge on every item in array should result with all props', function (t) { + const firstObject = { first: true } + const secondObject = { second: false } + const thirdObject = { third: 123 } + const fourthObject = { fourth: 'some string' } + + const mergedObject = deepmergeNull(firstObject, secondObject, thirdObject, fourthObject) + + t.ok(mergedObject.first === true) + t.ok(mergedObject.second === false) + t.ok(mergedObject.third === 123) + t.ok(mergedObject.fourth === 'some string') + t.end() +}) + +test('invoke merge on every item in array with clone should clone all elements', function (t) { + const firstObject = { a: { d: 123 } } + const secondObject = { b: { e: true } } + const thirdObject = { c: { f: 'string' } } + + const mergedWithClone = deepmergeNull(firstObject, secondObject, thirdObject) + + t.same(mergedWithClone.a, firstObject.a) + t.same(mergedWithClone.b, secondObject.b) + t.same(mergedWithClone.c.f, thirdObject.c.f) + + t.end() +}) + +test('invoke merge on every item in array without clone should clone all elements', function (t) { + const firstObject = { a: { d: 123 } } + const secondObject = { b: { e: true } } + const thirdObject = { c: { f: 'string' } } + + const mergedWithoutClone = deepmergeNull(firstObject, secondObject, thirdObject) + + t.same(mergedWithoutClone.a, firstObject.a) + t.same(mergedWithoutClone.b, secondObject.b) + t.same(mergedWithoutClone.c.f, thirdObject.c.f) t.end() }) diff --git a/test/index.test.js b/test/index.test.js index 3d43867..129344c 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -603,3 +603,24 @@ test('merging objects with null prototype', function (t) { t.same(expected, deepmerge(target, source)) t.end() }) + +test('OpenAPI Schema with prototype polluting properties', function (t) { + const schema = { + type: 'object', + properties: { + constructor: 'constructor', + __proto__: 'proto', + prototype: 'prototype' + } + } + const mergedObject = deepmerge({}, schema) + t.same(mergedObject, { type: 'object', properties: {} }) + t.end() +}) + +test('merging objects with empty string as key', function (t) { + const user = { '': 1 } + t.same(deepmerge({}, user), user) + t.same(deepmerge(user, {}), user) + t.end() +}) diff --git a/test/nullPrototype.test.js b/test/nullPrototype.test.js new file mode 100644 index 0000000..338e171 --- /dev/null +++ b/test/nullPrototype.test.js @@ -0,0 +1,634 @@ +'use strict' + +// based on https://github.com/TehShrike/deepmerge/tree/3c39fb376158fa3cfc75250cfc4414064a90f582/test +// MIT License +// Copyright (c) 2012 - 2022 James Halliday, Josh Duff, and other contributors of deepmerge + +const deepmerge = require('../index')({ nullPrototype: true }) +const test = require('tap').test + +test('OpenAPI Schema with prototype polluting properties', function (t) { + const schema = JSON.parse('{"type":"object","properties":{"constructor":"constructor", "__proto__": "__proto__", "prototype":"prototype"}}') + t.same(Object.keys(schema), Object.keys(deepmerge({}, schema))) + t.same(deepmerge({}, schema).properties.constructor, 'constructor') + t.same(deepmerge({}, schema).properties.prototype, 'prototype') + t.same(deepmerge({}, schema).properties.__proto__, '__proto__') // eslint-disable-line no-proto + t.same(Object.keys(schema), Object.keys(deepmerge(schema, {}))) + t.same(deepmerge(schema, {}).properties.constructor, 'constructor') + t.same(deepmerge(schema, {}).properties.prototype, 'prototype') + t.same(deepmerge(schema, {}).properties.__proto__, '__proto__') // eslint-disable-line no-proto + t.same(Object.keys(schema), Object.keys(deepmerge(schema, Object.create(null)))) + t.same(deepmerge(schema, Object.create(null)).properties.constructor, 'constructor') + t.same(deepmerge(schema, Object.create(null)).properties.prototype, 'prototype') + t.same(deepmerge(schema, Object.create(null)).properties.__proto__, '__proto__') // eslint-disable-line no-proto + t.same(Object.keys(schema), Object.keys(deepmerge(Object.create(null), schema))) + t.same(deepmerge(Object.create(null), schema).properties.constructor, 'constructor') + t.same(deepmerge(Object.create(null), schema).properties.prototype, 'prototype') + t.same(deepmerge(Object.create(null), schema).properties.__proto__, '__proto__') // eslint-disable-line no-proto + t.end() +}) + +test('merging objects with empty string as key', function (t) { + const user = { '': 1 } + t.same(deepmerge({}, user), user) + t.same(deepmerge(user, {}), user) + t.end() +}) + +test('merging objects with __proto__ in target', function (t) { + const user = {} + const malicious = JSON.parse('{ "__proto__": { "admin": true } }') + const mergedObject = deepmerge(malicious, user) + t.ok(mergedObject.__proto__.admin === true, 'non-plain properties should be merged') // eslint-disable-line no-proto + t.notOk(mergedObject.admin, 'the destination should have an unmodified prototype') + t.end() +}) + +test('merging objects with prototype in target', function (t) { + const user = {} + const malicious = JSON.parse('{ "prototype": { "admin": true } }') + const mergedObject = deepmerge(malicious, user) + t.ok(mergedObject.prototype.admin === true) + t.notOk(mergedObject.admin, 'the destination should have an unmodified prototype') + t.end() +}) + +test('merging objects with __proto__ in source', function (t) { + const user = {} + const malicious = JSON.parse('{ "prototype": { "admin": true } }') + const mergedObject = deepmerge(user, malicious) + t.ok(mergedObject.prototype.admin === true, 'non-plain properties should be merged') // eslint-disable-line no-proto + t.notOk(mergedObject.admin, 'the destination should have an unmodified prototype') + t.end() +}) + +test('merging objects with __proto__ in source', function (t) { + const user = {} + const malicious = JSON.parse('{ "__proto__": { "admin": true } }') + const mergedObject = deepmerge(user, malicious) + t.ok(mergedObject.__proto__.admin === true, 'non-plain properties should be merged') // eslint-disable-line no-proto + t.notOk(mergedObject.admin, 'the destination should have an unmodified prototype') + t.end() +}) + +test('merging objects with constructor in source', function (t) { + const user = {} + const malicious = JSON.parse('{ "constructor": { "admin": true } }') + const mergedObject = deepmerge(user, malicious) + t.ok(mergedObject.constructor.admin === true, 'non-plain properties should be merged') + t.notOk(mergedObject.admin, 'the destination should have an unmodified prototype') + t.end() +}) + +test('merging objects with prototype in source', function (t) { + const user = {} + const malicious = JSON.parse('{ "prototype": { "admin": true } }') + const mergedObject = deepmerge(user, malicious) + t.ok(mergedObject.prototype.admin === true) + t.notOk(mergedObject.admin, 'the destination should have an unmodified prototype') + t.end() +}) + +test('add keys in target that do not exist at the root', function (t) { + const src = { key1: 'value1', key2: 'value2' } + const target = {} + + const res = deepmerge(target, src) + + t.same(target, {}, 'merge should be immutable') + t.same(res, src) + t.end() +}) + +test('merge existing simple keys in target at the roots', function (t) { + const src = { key1: 'changed', key2: 'value2' } + const target = { key1: 'value1', key3: 'value3' } + + const expected = { + key1: 'changed', + key2: 'value2', + key3: 'value3' + } + + t.same(target, { key1: 'value1', key3: 'value3' }) + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('merge nested objects into target', function (t) { + const src = { + key1: { + subkey1: 'changed', + subkey3: 'added' + } + } + const target = { + key1: { + subkey1: 'value1', + subkey2: 'value2' + } + } + + const expected = { + key1: { + subkey1: 'changed', + subkey2: 'value2', + subkey3: 'added' + } + } + + t.same(target, { + key1: { + subkey1: 'value1', + subkey2: 'value2' + } + }) + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('replace simple key with nested object in target', function (t) { + const src = { + key1: { + subkey1: 'subvalue1', + subkey2: 'subvalue2' + } + } + const target = { + key1: 'value1', + key2: 'value2' + } + + const expected = { + key1: { + subkey1: 'subvalue1', + subkey2: 'subvalue2' + }, + key2: 'value2' + } + + t.same(target, { key1: 'value1', key2: 'value2' }) + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('should add nested object in target', function (t) { + const src = { + b: { + c: {} + } + } + + const target = { + a: {} + } + + const expected = { + a: {}, + b: { + c: {} + } + } + + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('should clone source and target', function (t) { + const src = { + b: { + c: 'foo' + } + } + + const target = { + a: { + d: 'bar' + } + } + + const expected = { + a: { + d: 'bar' + }, + b: { + c: 'foo' + } + } + + const merged = deepmerge(target, src) + + t.same(merged, expected) + + t.not(merged.a, target.a) + t.not(merged.b, src.b) + + t.end() +}) + +test('should clone source and target', function (t) { + const src = { + b: { + c: 'foo' + } + } + + const target = { + a: { + d: 'bar' + } + } + + const merged = deepmerge(target, src) + t.not(merged.a, target.a) + t.not(merged.b, src.b) + + t.end() +}) + +test('should replace object with simple key in target', function (t) { + const src = { key1: 'value1' } + const target = { + key1: { + subkey1: 'subvalue1', + subkey2: 'subvalue2' + }, + key2: 'value2' + } + + const expected = { key1: 'value1', key2: 'value2' } + + t.same(target, { + key1: { + subkey1: 'subvalue1', + subkey2: 'subvalue2' + }, + key2: 'value2' + }) + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('should replace objects with arrays', function (t) { + const target = { key1: { subkey: 'one' } } + + const src = { key1: ['subkey'] } + + const expected = { key1: ['subkey'] } + + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('should replace arrays with objects', function (t) { + const target = { key1: ['subkey'] } + + const src = { key1: { subkey: 'one' } } + + const expected = { key1: { subkey: 'one' } } + + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('should replace object with primitive', function (t) { + const target = { key1: new Date() } + + const src = 'test' + + const expected = 'test' + + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('should replace Date with RegExp', function (t) { + const target = new Date() + + const src = /a/g + + const expected = /a/g + + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('should replace dates with arrays', function (t) { + const target = { key1: new Date() } + + const src = { key1: ['subkey'] } + + const expected = { key1: ['subkey'] } + + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('should replace null with arrays', function (t) { + const target = { + key1: null + } + + const src = { + key1: ['subkey'] + } + + const expected = { + key1: ['subkey'] + } + + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('should work on simple array', function (t) { + const src = ['one', 'three'] + const target = ['one', 'two'] + + const expected = ['one', 'two', 'one', 'three'] + + t.same(deepmerge(target, src), expected) + t.ok(Array.isArray(deepmerge(target, src))) + t.end() +}) + +test('should work on another simple array', function (t) { + const target = ['a1', 'a2', 'c1', 'f1', 'p1'] + const src = ['t1', 's1', 'c2', 'r1', 'p2', 'p3'] + + const expected = ['a1', 'a2', 'c1', 'f1', 'p1', 't1', 's1', 'c2', 'r1', 'p2', 'p3'] + t.same(target, ['a1', 'a2', 'c1', 'f1', 'p1']) + t.same(deepmerge(target, src), expected) + t.ok(Array.isArray(deepmerge(target, src))) + t.end() +}) + +test('should work on array properties', function (t) { + const src = { + key1: ['one', 'three'], + key2: ['four'] + } + const target = { + key1: ['one', 'two'] + } + + const expected = { + key1: ['one', 'two', 'one', 'three'], + key2: ['four'] + } + + t.same(deepmerge(target, src), expected) + t.ok(Array.isArray(deepmerge(target, src).key1)) + t.ok(Array.isArray(deepmerge(target, src).key2)) + t.end() +}) + +test('should work on array properties with clone option', function (t) { + const src = { + key1: ['one', 'three'], + key2: ['four'] + } + const target = { + key1: ['one', 'two'] + } + + t.same(target, { + key1: ['one', 'two'] + }) + const merged = deepmerge(target, src) + t.not(merged.key1, src.key1) + t.not(merged.key1, target.key1) + t.not(merged.key2, src.key2) + t.end() +}) + +test('should work on array of objects', function (t) { + const src = [ + { key1: ['one', 'three'], key2: ['one'] }, + { key3: ['five'] } + ] + const target = [ + { key1: ['one', 'two'] }, + { key3: ['four'] } + ] + + const expected = [ + { key1: ['one', 'two'] }, + { key3: ['four'] }, + { key1: ['one', 'three'], key2: ['one'] }, + { key3: ['five'] } + ] + + t.same(deepmerge(target, src), expected) + t.ok(Array.isArray(deepmerge(target, src)), 'result should be an array') + t.ok(Array.isArray(deepmerge(target, src)[0].key1), 'subkey should be an array too') + + t.end() +}) + +test('should work on array of objects with clone option', function (t) { + const src = [ + { key1: ['one', 'three'], key2: ['one'] }, + { key3: ['five'] } + ] + const target = [ + { key1: ['one', 'two'] }, + { key3: ['four'] } + ] + + const expected = [ + { key1: ['one', 'two'] }, + { key3: ['four'] }, + { key1: ['one', 'three'], key2: ['one'] }, + { key3: ['five'] } + ] + + const merged = deepmerge(target, src) + t.same(merged, expected) + t.ok(Array.isArray(deepmerge(target, src)), 'result should be an array') + t.ok(Array.isArray(deepmerge(target, src)[0].key1), 'subkey should be an array too') + t.not(merged[0].key1, src[0].key1) + t.not(merged[0].key1, target[0].key1) + t.not(merged[0].key2, src[0].key2) + t.not(merged[1].key3, src[1].key3) + t.not(merged[1].key3, target[1].key3) + t.end() +}) + +test('should treat regular expressions like primitive values', function (t) { + const target = { key1: /abc/ } + const src = { key1: /efg/ } + const expected = { key1: /efg/ } + + t.same(deepmerge(target, src), expected) + t.same(deepmerge(target, src).key1.test('efg'), true) + t.end() +}) + +test('should treat regular expressions like primitive values and should not' + + ' clone even with clone option', function (t) { + const target = { key1: /abc/ } + const src = { key1: /efg/ } + + const output = deepmerge(target, src) + + t.equal(output.key1, src.key1) + t.end() +} +) + +test('should treat dates like primitives', function (t) { + const monday = new Date('2016-09-27T01:08:12.761Z') + const tuesday = new Date('2016-09-28T01:18:12.761Z') + + const target = { + key: monday + } + const source = { + key: tuesday + } + + const expected = { + key: tuesday + } + const actual = deepmerge(target, source) + + t.same(actual, expected) + t.equal(actual.key.valueOf(), tuesday.valueOf()) + t.end() +}) + +test('should treat dates like primitives and should not clone even with clone' + + ' option', function (t) { + const monday = new Date('2016-09-27T01:08:12.761Z') + const tuesday = new Date('2016-09-28T01:18:12.761Z') + + const target = { + key: monday + } + const source = { + key: tuesday + } + + const actual = deepmerge(target, source) + + t.equal(actual.key, tuesday) + t.end() +}) + +test('should work on array with null in it', function (t) { + const target = [] + + const src = [null] + + const expected = [null] + + t.same(deepmerge(target, src), expected) + t.end() +}) + +test('should clone array\'s element if it is object', function (t) { + const a = { key: 'yup' } + const target = [] + const source = [a] + + const output = deepmerge(target, source) + + t.not(output[0], a) + t.equal(output[0].key, 'yup') + t.end() +}) + +test('should clone an array property when there is no target array', function (t) { + const someObject = {} + const target = {} + const source = { ary: [someObject] } + const output = deepmerge(target, source) + + t.same(output, { ary: [{}] }) + t.not(output.ary[0], someObject) + t.end() +}) + +test('should overwrite values when property is initialised but undefined', function (t) { + const target1 = { value: [] } + const target2 = { value: null } + const target3 = { value: 2 } + + const src = { value: undefined } + + function hasUndefinedProperty (o) { + t.ok(Object.hasOwnProperty.call(o, 'value')) + t.equal(typeof o.value, 'undefined') + } + + hasUndefinedProperty(deepmerge(target1, src)) + hasUndefinedProperty(deepmerge(target2, src)) + hasUndefinedProperty(deepmerge(target3, src)) + + t.end() +}) + +test('should overwrite null with the source', function (t) { + const expected = { a: 'string' } + const actual = deepmerge(null, { a: 'string' }) + + t.same(actual, expected) + t.end() +}) + +test('dates should copy correctly in an array', function (t) { + const monday = new Date('2016-09-27T01:08:12.761Z') + const tuesday = new Date('2016-09-28T01:18:12.761Z') + + const target = [monday, 'dude'] + const source = [tuesday, 'lol'] + + const expected = [monday, 'dude', tuesday, 'lol'] + const actual = deepmerge(target, source) + + t.same(actual, expected) + t.end() +}) + +test('merging objects with plain and non-plain properties in target', function (t) { + const parent = { + parentKey: 'should be undefined' + } + + const target = Object.create(parent) + target.plainKey = 'should be replaced' + + const source = { + parentKey: 'foo', + plainKey: 'bar', + newKey: 'baz' + } + + const mergedObject = deepmerge(target, source) + t.equal(undefined, mergedObject.parentKey, 'inherited properties of target should be removed, not merged or ignored') + t.equal('bar', mergedObject.plainKey, 'enumerable own properties of target should be merged') + t.equal('baz', mergedObject.newKey, 'properties not yet on target should be merged') + t.end() +}) + +test('merging objects with plain and non-plain properties in source', function (t) { + const parent = { + parentKey: 'should be foo' + } + + const source = Object.create(parent) + source.plainKey = 'bar' + + const target = { + parentKey: 'foo', + plainKey: 'should be bar', + newKey: 'baz' + } + + const mergedObject = deepmerge(target, source) + t.equal('foo', mergedObject.parentKey, 'inherited properties of source should not be merged') + t.equal('bar', mergedObject.plainKey, 'enumerable own properties of source should be merged') + t.equal('baz', mergedObject.newKey, 'properties set on target should not be modified') + t.end() +}) diff --git a/types/index.d.ts b/types/index.d.ts index 1d175e2..cf1cc76 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -55,6 +55,7 @@ type DeepMergeAll = First extends never : DeepMergeAll>, Rest>; interface Options { + nullPrototype?: boolean; symbols?: boolean; all?: boolean; } diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 7a68e0d..34821c7 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -12,6 +12,8 @@ expectError(deepmerge({ symbols: 2 })) expectError(deepmerge({ symbol: 2 })) expectAssignable(deepmerge({ symbols: true })) +expectAssignable(deepmerge({ nullPrototype: true })) +expectAssignable(deepmerge({ nullPrototype: false })) expectType(deepmerge()('string', { a: 'string' }).a) expectType(deepmerge()(1, { a: 'string' }).a)