From b361e675b4d423cedaea23718e941487a64c6e98 Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Wed, 28 Jul 2021 19:12:39 +0200 Subject: [PATCH 01/16] feat(collections): deepMerge draft --- collections/deep_merge.ts | 89 ++++++++++++++++ collections/deep_merge_test.ts | 189 +++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 collections/deep_merge.ts create mode 100644 collections/deep_merge_test.ts diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts new file mode 100644 index 000000000000..04569e35d939 --- /dev/null +++ b/collections/deep_merge.ts @@ -0,0 +1,89 @@ +/** Deep merge options */ +interface deepMergeOptions { + /** Merging strategy for arrays */ + arrays?:"replace"|"concat", + /** Merging strategy for Maps */ + maps?:"replace"|"concat", + /** Merging strategy for Sets */ + sets?:"replace"|"concat", + /** Whether to include non enumerable properties */ + includeNonEnumerable?:boolean +} + +/** + * Merges the two given Records, recursively merging any nested Records with the second collection overriding the first in case of conflict + * + * For arrays, maps and sets, a merging strategy can be specified to either "replace" values, or "concat" them instead. + * Use "includeNonEnumerable" option to include non enumerable properties too. + */ +export function deepMerge>(collection: Partial, other: Partial, options?:deepMergeOptions): T +export function deepMerge, U extends Record>(collection: T, other: U, options?:deepMergeOptions): T & U +export function deepMerge(collection:any, other:any, {arrays = "replace", maps = "replace", sets = "replace", includeNonEnumerable = false} = {}) { + + //Extract property and symbols + const keys = [...Object.getOwnPropertyNames(other), ...Object.getOwnPropertySymbols(other)].filter(key => includeNonEnumerable || other.propertyIsEnumerable(key)) + + //Iterate through each key of other object and use correct merging strategy + for (const key of keys) { + const a = collection[key], b = other[key] + + //Handle arrays + if ((Array.isArray(a))&&(Array.isArray(b))) { + if (arrays === "concat") + collection[key] = a.concat(...b) + else + collection[key] = b + continue + } + + //Handle maps + if ((a instanceof Map)&&(b instanceof Map)) { + if (maps === "concat") { + for (const [k, v] of b.entries()) + a.set(k, v) + } + else + collection[key] = b + continue + } + + //Handle sets + if ((a instanceof Set)&&(b instanceof Set)) { + if (sets === "concat") { + for (const v of b.values()) + a.add(v) + } + else + collection[key] = b + continue + } + + //Recursively merge mergeable objects + if (isMergeable(a) && isMergeable(b)) { + collection[key] = deepMerge(collection[key] ?? {}, b) + continue + } + + //Override value + collection[key] = b + } + + return collection +} + +/** + * Test whether a value is mergeable or not + * Builtins object like, null and user classes are not considered mergeable + */ +function isMergeable(value:unknown) { + //Ignore null + if (value === null) + return false + //Ignore builtins + if ((value instanceof RegExp)||(value instanceof Date)) + return false + //Ignore classes + if ((typeof value === "object")&&("constructor" in value!)) + return !/^class /.test(value.constructor.toString()) + return typeof value === "object" +} diff --git a/collections/deep_merge_test.ts b/collections/deep_merge_test.ts new file mode 100644 index 000000000000..2c6cb67760c0 --- /dev/null +++ b/collections/deep_merge_test.ts @@ -0,0 +1,189 @@ +import { assertEquals } from "../testing/asserts.ts"; + +Deno.test("deepMerge: simple merge", () => { + assertEquals(deepMerge({ + foo:true + }, { + bar:true + }), { + foo:true, + bar:true + }) +}) + +Deno.test("deepMerge: symbol merge", () => { + assertEquals(deepMerge({}, { + [Symbol.for("deepmerge.test")]:true + }), { + [Symbol.for("deepmerge.test")]:true + }) +}) + +Deno.test("deepMerge: ignore non enumerable", () => { + assertEquals(deepMerge({}, Object.defineProperties({}, { + foo:{enumerable:false, value:true}, + bar:{enumerable:true, value:true} + })), { + bar:true + }) +}) + +Deno.test("deepMerge: include non enumerable", () => { + assertEquals(deepMerge({}, Object.defineProperties({}, { + foo:{enumerable:false, value:true}, + bar:{enumerable:true, value:true} + }), {includeNonEnumerable:true}), { + foo:true, + bar:true + }) +}) + +Deno.test("deepMerge: nested merge", () => { + assertEquals(deepMerge({ + foo:{ + bar:true + } + }, { + foo:{ + baz:true, + quux:{} + }, + qux:true + }), { + foo:{ + bar:true, + baz:true, + quux:{} + }, + qux:true + }) +}) + +Deno.test("deepMerge: override target (non-mergeable source)", () => { + assertEquals(deepMerge({ + foo:{ + bar:true + } + }, { + foo:true + }), { + foo:true + }) +}) + +Deno.test("deepMerge: override target (non-mergeable destination #1)", () => { + const CustomClass = class {} + assertEquals(deepMerge({ + foo:new CustomClass() + }, { + foo:true + }), { + foo:true + }) +}) + +Deno.test("deepMerge: override target (non-mergeable destination #2)", () => { + assertEquals(deepMerge({ + foo:[] + }, { + foo:true + }), { + foo:true + }) +}) + +Deno.test("deepMerge: primitive types handling", () => { + const CustomClass = class {} + const expected = { + boolean:true, + null:null, + undefined:undefined, + number:1, + bigint:1n, + string:"string", + symbol:Symbol.for("deepmerge.test"), + object:{foo:true}, + regexp:/regex/, + date:new Date(), + function() {}, + async async() {}, + arrow:() => {}, + class:new CustomClass(), + } + assertEquals(deepMerge({ + boolean:false, + null:undefined, + undefined:null, + number:-1, + bigint:-1n, + string:"foo", + symbol:Symbol(), + object:null, + regexp:/foo/, + date:new Date(0), + function:function () {}, + async: async function() {}, + arrow: () => {}, + class:null, + }, expected), expected) +}) + +Deno.test("deepMerge: array merge (replace)", () => { + assertEquals(deepMerge({ + foo:[1, 2, 3] + }, { + foo:[4, 5, 6] + }), { + foo:[4, 5, 6] + }) +}) + +Deno.test("deepMerge: array merge (concat)", () => { + assertEquals(deepMerge({ + foo:[1, 2, 3] + }, { + foo:[4, 5, 6] + }, {arrays:"concat"}), { + foo:[1, 2, 3, 4, 5, 6] + }) +}) + +Deno.test("deepMerge: maps merge (replace)", () => { + assertEquals(deepMerge({ + map:new Map([["foo", true]]) + }, { + map:new Map([["bar", true]]) + }), { + map:new Map([["bar", true]]) + }) +}) + +Deno.test("deepMerge: maps merge (concat)", () => { + assertEquals(deepMerge({ + map:new Map([["foo", true]]) + }, { + map:new Map([["bar", true]]) + }, {maps:"concat"}), { + map:new Map([["foo", true], ["bar", true]]) + }) +}) + +Deno.test("deepMerge: sets merge (replace)", () => { + assertEquals(deepMerge({ + set:new Set(["foo"]) + }, { + set:new Set(["bar"]) + }), { + set:new Set(["bar"]) + }) +}) + +Deno.test("deepMerge: sets merge (concat)", () => { + assertEquals(deepMerge({ + set:new Set(["foo"]) + }, { + set:new Set(["bar"]) + }, {sets:"concat"}), { + set:new Set(["foo", "bar"]) + }) +}) \ No newline at end of file From 878a93b1ea6ffaea92a144d50a1dac4d5cb033ee Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Wed, 28 Jul 2021 19:39:37 +0200 Subject: [PATCH 02/16] feat(collections): continue deepMerge --- collections/deep_merge.ts | 109 +++++---- collections/deep_merge_test.ts | 392 ++++++++++++++++++++------------- 2 files changed, 301 insertions(+), 200 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index 04569e35d939..74a5a1f3092f 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -1,89 +1,114 @@ /** Deep merge options */ interface deepMergeOptions { /** Merging strategy for arrays */ - arrays?:"replace"|"concat", + arrays?: "replace" | "concat"; /** Merging strategy for Maps */ - maps?:"replace"|"concat", + maps?: "replace" | "concat"; /** Merging strategy for Sets */ - sets?:"replace"|"concat", + sets?: "replace" | "concat"; /** Whether to include non enumerable properties */ - includeNonEnumerable?:boolean + includeNonEnumerable?: boolean; } +//TypeScript does not support 'symbol' as index type currently though it's perfectly valid +//deno-lint-ignore no-explicit-any +type propertyKey = any; + /** * Merges the two given Records, recursively merging any nested Records with the second collection overriding the first in case of conflict * * For arrays, maps and sets, a merging strategy can be specified to either "replace" values, or "concat" them instead. * Use "includeNonEnumerable" option to include non enumerable properties too. */ -export function deepMerge>(collection: Partial, other: Partial, options?:deepMergeOptions): T -export function deepMerge, U extends Record>(collection: T, other: U, options?:deepMergeOptions): T & U -export function deepMerge(collection:any, other:any, {arrays = "replace", maps = "replace", sets = "replace", includeNonEnumerable = false} = {}) { - +export function deepMerge< + T extends Record, + U extends Record, +>( + collection: T, + other: U, + { + arrays = "replace", + maps = "replace", + sets = "replace", + includeNonEnumerable = false, + }: deepMergeOptions = {}, +): T & U { //Extract property and symbols - const keys = [...Object.getOwnPropertyNames(other), ...Object.getOwnPropertySymbols(other)].filter(key => includeNonEnumerable || other.propertyIsEnumerable(key)) + const keys = [ + ...Object.getOwnPropertyNames(other), + ...Object.getOwnPropertySymbols(other), + ].filter((key) => includeNonEnumerable || other.propertyIsEnumerable(key)); //Iterate through each key of other object and use correct merging strategy - for (const key of keys) { - const a = collection[key], b = other[key] + for (const key of keys as propertyKey[]) { + const a = collection[key], b = other[key]; //Handle arrays - if ((Array.isArray(a))&&(Array.isArray(b))) { - if (arrays === "concat") - collection[key] = a.concat(...b) - else - collection[key] = b - continue + if ((Array.isArray(a)) && (Array.isArray(b))) { + if (arrays === "concat") { + collection[key] = a.concat(...b); + } else { + collection[key] = b; + } + continue; } //Handle maps - if ((a instanceof Map)&&(b instanceof Map)) { + if ((a instanceof Map) && (b instanceof Map)) { if (maps === "concat") { - for (const [k, v] of b.entries()) - a.set(k, v) + for (const [k, v] of b.entries()) { + a.set(k, v); + } + } else { + collection[key] = b; } - else - collection[key] = b - continue + continue; } //Handle sets - if ((a instanceof Set)&&(b instanceof Set)) { + if ((a instanceof Set) && (b instanceof Set)) { if (sets === "concat") { - for (const v of b.values()) - a.add(v) + for (const v of b.values()) { + a.add(v); + } + } else { + collection[key] = b; } - else - collection[key] = b - continue + continue; } //Recursively merge mergeable objects - if (isMergeable(a) && isMergeable(b)) { - collection[key] = deepMerge(collection[key] ?? {}, b) - continue + if (isMergeable(a) && isMergeable(b)) { + collection[key] = deepMerge(a ?? {}, b); + continue; } //Override value - collection[key] = b + collection[key] = b; } - return collection + return collection as T & U; } /** * Test whether a value is mergeable or not * Builtins object like, null and user classes are not considered mergeable */ -function isMergeable(value:unknown) { +function isMergeable(value: unknown): value is T { //Ignore null - if (value === null) - return false + if (value === null) { + return false; + } //Ignore builtins - if ((value instanceof RegExp)||(value instanceof Date)) - return false + if ( + (value instanceof RegExp) || (value instanceof Date) || + (value instanceof Array) + ) { + return false; + } //Ignore classes - if ((typeof value === "object")&&("constructor" in value!)) - return !/^class /.test(value.constructor.toString()) - return typeof value === "object" + if ((typeof value === "object") && ("constructor" in value!)) { + return !/^class /.test(value.constructor.toString()); + } + return typeof value === "object"; } diff --git a/collections/deep_merge_test.ts b/collections/deep_merge_test.ts index 2c6cb67760c0..5ba29516c328 100644 --- a/collections/deep_merge_test.ts +++ b/collections/deep_merge_test.ts @@ -1,189 +1,265 @@ import { assertEquals } from "../testing/asserts.ts"; +import { deepMerge } from "./deep_merge.ts"; Deno.test("deepMerge: simple merge", () => { - assertEquals(deepMerge({ - foo:true - }, { - bar:true - }), { - foo:true, - bar:true - }) -}) + assertEquals( + deepMerge({ + foo: true, + }, { + bar: true, + }), + { + foo: true, + bar: true, + }, + ); +}); Deno.test("deepMerge: symbol merge", () => { - assertEquals(deepMerge({}, { - [Symbol.for("deepmerge.test")]:true - }), { - [Symbol.for("deepmerge.test")]:true - }) -}) + assertEquals( + deepMerge({}, { + [Symbol.for("deepmerge.test")]: true, + }), + { + [Symbol.for("deepmerge.test")]: true, + }, + ); +}); Deno.test("deepMerge: ignore non enumerable", () => { - assertEquals(deepMerge({}, Object.defineProperties({}, { - foo:{enumerable:false, value:true}, - bar:{enumerable:true, value:true} - })), { - bar:true - }) -}) + assertEquals( + deepMerge( + {}, + Object.defineProperties({}, { + foo: { enumerable: false, value: true }, + bar: { enumerable: true, value: true }, + }), + ), + { + bar: true, + }, + ); +}); Deno.test("deepMerge: include non enumerable", () => { - assertEquals(deepMerge({}, Object.defineProperties({}, { - foo:{enumerable:false, value:true}, - bar:{enumerable:true, value:true} - }), {includeNonEnumerable:true}), { - foo:true, - bar:true - }) -}) + assertEquals( + deepMerge( + {}, + Object.defineProperties({}, { + foo: { enumerable: false, value: true }, + bar: { enumerable: true, value: true }, + }), + { includeNonEnumerable: true }, + ), + { + foo: true, + bar: true, + }, + ); +}); Deno.test("deepMerge: nested merge", () => { - assertEquals(deepMerge({ - foo:{ - bar:true - } - }, { - foo:{ - baz:true, - quux:{} - }, - qux:true - }), { - foo:{ - bar:true, - baz:true, - quux:{} - }, - qux:true - }) -}) + assertEquals( + deepMerge({ + foo: { + bar: true, + }, + }, { + foo: { + baz: true, + quux: {}, + }, + qux: true, + }), + { + foo: { + bar: true, + baz: true, + quux: {}, + }, + qux: true, + }, + ); +}); Deno.test("deepMerge: override target (non-mergeable source)", () => { - assertEquals(deepMerge({ - foo:{ - bar:true - } - }, { - foo:true - }), { - foo:true - }) -}) - -Deno.test("deepMerge: override target (non-mergeable destination #1)", () => { - const CustomClass = class {} - assertEquals(deepMerge({ - foo:new CustomClass() - }, { - foo:true - }), { - foo:true - }) -}) - -Deno.test("deepMerge: override target (non-mergeable destination #2)", () => { - assertEquals(deepMerge({ - foo:[] - }, { - foo:true - }), { - foo:true - }) -}) + assertEquals( + deepMerge({ + foo: { + bar: true, + }, + }, { + foo: true, + }), + { + foo: true, + }, + ); +}); + +Deno.test("deepMerge: override target (non-mergeable destination, object like)", () => { + const CustomClass = class {}; + assertEquals( + deepMerge({ + foo: new CustomClass(), + }, { + foo: true, + }), + { + foo: true, + }, + ); +}); + +Deno.test("deepMerge: override target (non-mergeable destination, array like)", () => { + assertEquals( + deepMerge({ + foo: [], + }, { + foo: true, + }), + { + foo: true, + }, + ); +}); + +Deno.test("deepMerge: override target (different object like source and destination)", () => { + assertEquals( + deepMerge({ + foo: {}, + }, { + foo: [1, 2], + }), + { + foo: [1, 2], + }, + ); + assertEquals( + deepMerge({ + foo: [], + }, { + foo: { bar: true }, + }), + { + foo: { bar: true }, + }, + ); +}); Deno.test("deepMerge: primitive types handling", () => { - const CustomClass = class {} + const CustomClass = class {}; const expected = { - boolean:true, - null:null, - undefined:undefined, - number:1, - bigint:1n, - string:"string", - symbol:Symbol.for("deepmerge.test"), - object:{foo:true}, - regexp:/regex/, - date:new Date(), + boolean: true, + null: null, + undefined: undefined, + number: 1, + bigint: 1n, + string: "string", + symbol: Symbol.for("deepmerge.test"), + object: { foo: true }, + regexp: /regex/, + date: new Date(), function() {}, async async() {}, - arrow:() => {}, - class:new CustomClass(), - } - assertEquals(deepMerge({ - boolean:false, - null:undefined, - undefined:null, - number:-1, - bigint:-1n, - string:"foo", - symbol:Symbol(), - object:null, - regexp:/foo/, - date:new Date(0), - function:function () {}, - async: async function() {}, arrow: () => {}, - class:null, - }, expected), expected) -}) + class: new CustomClass(), + }; + assertEquals( + deepMerge({ + boolean: false, + null: undefined, + undefined: null, + number: -1, + bigint: -1n, + string: "foo", + symbol: Symbol(), + object: null, + regexp: /foo/, + date: new Date(0), + function: function () {}, + async: async function () {}, + arrow: () => {}, + class: null, + }, expected), + expected, + ); +}); Deno.test("deepMerge: array merge (replace)", () => { - assertEquals(deepMerge({ - foo:[1, 2, 3] - }, { - foo:[4, 5, 6] - }), { - foo:[4, 5, 6] - }) -}) + assertEquals( + deepMerge({ + foo: [1, 2, 3], + }, { + foo: [4, 5, 6], + }), + { + foo: [4, 5, 6], + }, + ); +}); Deno.test("deepMerge: array merge (concat)", () => { - assertEquals(deepMerge({ - foo:[1, 2, 3] - }, { - foo:[4, 5, 6] - }, {arrays:"concat"}), { - foo:[1, 2, 3, 4, 5, 6] - }) -}) + assertEquals( + deepMerge({ + foo: [1, 2, 3], + }, { + foo: [4, 5, 6], + }, { arrays: "concat" }), + { + foo: [1, 2, 3, 4, 5, 6], + }, + ); +}); Deno.test("deepMerge: maps merge (replace)", () => { - assertEquals(deepMerge({ - map:new Map([["foo", true]]) - }, { - map:new Map([["bar", true]]) - }), { - map:new Map([["bar", true]]) - }) -}) + assertEquals( + deepMerge({ + map: new Map([["foo", true]]), + }, { + map: new Map([["bar", true]]), + }), + { + map: new Map([["bar", true]]), + }, + ); +}); Deno.test("deepMerge: maps merge (concat)", () => { - assertEquals(deepMerge({ - map:new Map([["foo", true]]) - }, { - map:new Map([["bar", true]]) - }, {maps:"concat"}), { - map:new Map([["foo", true], ["bar", true]]) - }) -}) + assertEquals( + deepMerge({ + map: new Map([["foo", true]]), + }, { + map: new Map([["bar", true]]), + }, { maps: "concat" }), + { + map: new Map([["foo", true], ["bar", true]]), + }, + ); +}); Deno.test("deepMerge: sets merge (replace)", () => { - assertEquals(deepMerge({ - set:new Set(["foo"]) - }, { - set:new Set(["bar"]) - }), { - set:new Set(["bar"]) - }) -}) + assertEquals( + deepMerge({ + set: new Set(["foo"]), + }, { + set: new Set(["bar"]), + }), + { + set: new Set(["bar"]), + }, + ); +}); Deno.test("deepMerge: sets merge (concat)", () => { - assertEquals(deepMerge({ - set:new Set(["foo"]) - }, { - set:new Set(["bar"]) - }, {sets:"concat"}), { - set:new Set(["foo", "bar"]) - }) -}) \ No newline at end of file + assertEquals( + deepMerge({ + set: new Set(["foo"]), + }, { + set: new Set(["bar"]), + }, { sets: "concat" }), + { + set: new Set(["foo", "bar"]), + }, + ); +}); From d8c296d52c8ebbb0b229e45f1d19f544d4085686 Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Wed, 28 Jul 2021 19:52:03 +0200 Subject: [PATCH 03/16] tests: add new tests --- collections/deep_merge_test.ts | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/collections/deep_merge_test.ts b/collections/deep_merge_test.ts index 5ba29516c328..a92c313a9e20 100644 --- a/collections/deep_merge_test.ts +++ b/collections/deep_merge_test.ts @@ -164,6 +164,9 @@ Deno.test("deepMerge: primitive types handling", () => { async async() {}, arrow: () => {}, class: new CustomClass(), + get get() { + return true; + }, }; assertEquals( deepMerge({ @@ -181,6 +184,7 @@ Deno.test("deepMerge: primitive types handling", () => { async: async function () {}, arrow: () => {}, class: null, + get: false, }, expected), expected, ); @@ -263,3 +267,48 @@ Deno.test("deepMerge: sets merge (concat)", () => { }, ); }); + +Deno.test("deepMerge: complex test", () => { + assertEquals( + deepMerge({ + foo: { + bar: { + quux: new Set(["foo"]), + grault: {}, + }, + }, + }, { + foo: { + bar: { + baz: true, + qux: [1, 2], + grault: { + garply: false, + }, + }, + corge: "deno", + [Symbol.for("deepmerge.test")]: true, + }, + }), + { + foo: { + bar: { + quux: new Set(["foo"]), + baz: true, + qux: [1, 2], + grault: { + garply: false, + }, + }, + corge: "deno", + [Symbol.for("deepmerge.test")]: true, + }, + }, + ); +}); + +Deno.test("deepMerge: handle circular references", () => { + const expected = { foo: true } as { foo: boolean; bar: unknown }; + expected.bar = expected; + assertEquals(deepMerge({}, expected), expected); +}); From 7ef82606d370a68f8b698b56476066e1beced65c Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Wed, 28 Jul 2021 20:10:42 +0200 Subject: [PATCH 04/16] chore: add missing license note --- collections/deep_merge.ts | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index 74a5a1f3092f..e984131c4d74 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -1,18 +1,4 @@ -/** Deep merge options */ -interface deepMergeOptions { - /** Merging strategy for arrays */ - arrays?: "replace" | "concat"; - /** Merging strategy for Maps */ - maps?: "replace" | "concat"; - /** Merging strategy for Sets */ - sets?: "replace" | "concat"; - /** Whether to include non enumerable properties */ - includeNonEnumerable?: boolean; -} - -//TypeScript does not support 'symbol' as index type currently though it's perfectly valid -//deno-lint-ignore no-explicit-any -type propertyKey = any; +// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. /** * Merges the two given Records, recursively merging any nested Records with the second collection overriding the first in case of conflict @@ -112,3 +98,19 @@ function isMergeable(value: unknown): value is T { } return typeof value === "object"; } + +/** Deep merge options */ +interface deepMergeOptions { + /** Merging strategy for arrays */ + arrays?: "replace" | "concat"; + /** Merging strategy for Maps */ + maps?: "replace" | "concat"; + /** Merging strategy for Sets */ + sets?: "replace" | "concat"; + /** Whether to include non enumerable properties */ + includeNonEnumerable?: boolean; +} + +//TypeScript does not support 'symbol' as index type currently though it's perfectly valid +//deno-lint-ignore no-explicit-any +type propertyKey = any; From 93dca93d31c4563f7c7a5a85db79111a23736493 Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Wed, 28 Jul 2021 20:15:03 +0200 Subject: [PATCH 05/16] fmt: format comments --- collections/deep_merge.ts | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index e984131c4d74..ef6066070721 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -1,9 +1,11 @@ // Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. /** - * Merges the two given Records, recursively merging any nested Records with the second collection overriding the first in case of conflict + * Merges the two given Records, recursively merging any nested Records with + * the second collection overriding the first in case of conflict * - * For arrays, maps and sets, a merging strategy can be specified to either "replace" values, or "concat" them instead. + * For arrays, maps and sets, a merging strategy can be specified to either + * "replace" values, or "concat" them instead. * Use "includeNonEnumerable" option to include non enumerable properties too. */ export function deepMerge< @@ -19,17 +21,17 @@ export function deepMerge< includeNonEnumerable = false, }: deepMergeOptions = {}, ): T & U { - //Extract property and symbols + // Extract property and symbols const keys = [ ...Object.getOwnPropertyNames(other), ...Object.getOwnPropertySymbols(other), ].filter((key) => includeNonEnumerable || other.propertyIsEnumerable(key)); - //Iterate through each key of other object and use correct merging strategy + // Iterate through each key of other object and use correct merging strategy for (const key of keys as propertyKey[]) { const a = collection[key], b = other[key]; - //Handle arrays + // Handle arrays if ((Array.isArray(a)) && (Array.isArray(b))) { if (arrays === "concat") { collection[key] = a.concat(...b); @@ -39,7 +41,7 @@ export function deepMerge< continue; } - //Handle maps + // Handle maps if ((a instanceof Map) && (b instanceof Map)) { if (maps === "concat") { for (const [k, v] of b.entries()) { @@ -51,7 +53,7 @@ export function deepMerge< continue; } - //Handle sets + // Handle sets if ((a instanceof Set) && (b instanceof Set)) { if (sets === "concat") { for (const v of b.values()) { @@ -63,13 +65,13 @@ export function deepMerge< continue; } - //Recursively merge mergeable objects + // Recursively merge mergeable objects if (isMergeable(a) && isMergeable(b)) { collection[key] = deepMerge(a ?? {}, b); continue; } - //Override value + // Override value collection[key] = b; } @@ -81,18 +83,18 @@ export function deepMerge< * Builtins object like, null and user classes are not considered mergeable */ function isMergeable(value: unknown): value is T { - //Ignore null + // Ignore null if (value === null) { return false; } - //Ignore builtins + // Ignore builtins if ( (value instanceof RegExp) || (value instanceof Date) || (value instanceof Array) ) { return false; } - //Ignore classes + // Ignore classes if ((typeof value === "object") && ("constructor" in value!)) { return !/^class /.test(value.constructor.toString()); } @@ -111,6 +113,7 @@ interface deepMergeOptions { includeNonEnumerable?: boolean; } -//TypeScript does not support 'symbol' as index type currently though it's perfectly valid -//deno-lint-ignore no-explicit-any +// TypeScript does not support 'symbol' as index type currently though +// it's perfectly valid +// deno-lint-ignore no-explicit-any type propertyKey = any; From c5a841619317c29db9174f26cef7094b21ab5937 Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Sat, 31 Jul 2021 00:12:38 +0200 Subject: [PATCH 06/16] feat(collections): apply recommandations --- collections/README.md | 19 +++++++ collections/deep_merge.ts | 90 +++++++++++++++++++++------------- collections/deep_merge_test.ts | 18 +++---- 3 files changed, 85 insertions(+), 42 deletions(-) diff --git a/collections/README.md b/collections/README.md index 2c107eca3892..4fae8cd74ad3 100644 --- a/collections/README.md +++ b/collections/README.md @@ -30,6 +30,25 @@ console.assert( ); ``` +## deepMerge + +Merges the two given Records, recursively merging any nested Records with the +second collection overriding the first in case of conflict + +For arrays, maps and sets, a merging strategy can be specified to either +`replace` values, or `merge` them instead. Use `includeNonEnumerable` option to +include non enumerable properties too. + +```ts +import { deepMerge } from "./deep_merge.ts"; +import { assertEquals } from "../testing/assert.ts"; + +const a = { foo: true }; +const b = { foo: { bar: true } }; + +assertEquals(deepMerge(a, b), { foo: { bar: true } }); +``` + ## distinctBy Returns all elements in the given array that produce a distinct value using the diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index ef6066070721..02185268a544 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -5,22 +5,53 @@ * the second collection overriding the first in case of conflict * * For arrays, maps and sets, a merging strategy can be specified to either - * "replace" values, or "concat" them instead. + * "replace" values, or "merge" them instead. * Use "includeNonEnumerable" option to include non enumerable properties too. + * + * Example: + * + * ```ts + * import { deepMerge } from "./deep_merge.ts"; + * import { assertEquals } from "../testing/assert.ts"; + * + * const a = {foo: true} + * const b = {foo: {bar: true}} + * + * assertEquals(deepMerge(a, b), {foo: {bar: true}}); + * ``` */ +export function deepMerge< + T extends Record, +>( + object: Partial, + other: Partial, + options?: DeepMergeOptions, +): T; + +export function deepMerge< + T extends Record, + U extends Record, +>( + object: T, + other: U, + options?: DeepMergeOptions, +): T; + export function deepMerge< T extends Record, U extends Record, >( - collection: T, + object: T, other: U, { - arrays = "replace", - maps = "replace", - sets = "replace", + arrays = "merge", + maps = "merge", + sets = "merge", includeNonEnumerable = false, - }: deepMergeOptions = {}, + }: DeepMergeOptions = {}, ): T & U { + const result = object; + // Extract property and symbols const keys = [ ...Object.getOwnPropertyNames(other), @@ -28,54 +59,54 @@ export function deepMerge< ].filter((key) => includeNonEnumerable || other.propertyIsEnumerable(key)); // Iterate through each key of other object and use correct merging strategy - for (const key of keys as propertyKey[]) { - const a = collection[key], b = other[key]; + for (const key of keys as PropertyKeys) { + const a = result[key], b = other[key]; // Handle arrays if ((Array.isArray(a)) && (Array.isArray(b))) { - if (arrays === "concat") { - collection[key] = a.concat(...b); + if (arrays === "merge") { + result[key] = a.concat(...b); } else { - collection[key] = b; + result[key] = b; } continue; } // Handle maps if ((a instanceof Map) && (b instanceof Map)) { - if (maps === "concat") { + if (maps === "merge") { for (const [k, v] of b.entries()) { a.set(k, v); } } else { - collection[key] = b; + result[key] = b; } continue; } // Handle sets if ((a instanceof Set) && (b instanceof Set)) { - if (sets === "concat") { + if (sets === "merge") { for (const v of b.values()) { a.add(v); } } else { - collection[key] = b; + result[key] = b; } continue; } // Recursively merge mergeable objects if (isMergeable(a) && isMergeable(b)) { - collection[key] = deepMerge(a ?? {}, b); + result[key] = deepMerge(a ?? {}, b); continue; } // Override value - collection[key] = b; + result[key] = b; } - return collection as T & U; + return result as T & U; } /** @@ -87,33 +118,26 @@ function isMergeable(value: unknown): value is T { if (value === null) { return false; } - // Ignore builtins - if ( - (value instanceof RegExp) || (value instanceof Date) || - (value instanceof Array) - ) { - return false; - } - // Ignore classes + // Ignore builtins and classes if ((typeof value === "object") && ("constructor" in value!)) { - return !/^class /.test(value.constructor.toString()); + return Object.getPrototypeOf(value) === Object.prototype; } return typeof value === "object"; } /** Deep merge options */ -interface deepMergeOptions { +export type DeepMergeOptions = { /** Merging strategy for arrays */ - arrays?: "replace" | "concat"; + arrays?: "replace" | "merge"; /** Merging strategy for Maps */ - maps?: "replace" | "concat"; + maps?: "replace" | "merge"; /** Merging strategy for Sets */ - sets?: "replace" | "concat"; + sets?: "replace" | "merge"; /** Whether to include non enumerable properties */ includeNonEnumerable?: boolean; -} +}; // TypeScript does not support 'symbol' as index type currently though // it's perfectly valid // deno-lint-ignore no-explicit-any -type propertyKey = any; +type PropertyKeys = any[]; diff --git a/collections/deep_merge_test.ts b/collections/deep_merge_test.ts index a92c313a9e20..7ee1989b7754 100644 --- a/collections/deep_merge_test.ts +++ b/collections/deep_merge_test.ts @@ -196,20 +196,20 @@ Deno.test("deepMerge: array merge (replace)", () => { foo: [1, 2, 3], }, { foo: [4, 5, 6], - }), + }, { arrays: "replace" }), { foo: [4, 5, 6], }, ); }); -Deno.test("deepMerge: array merge (concat)", () => { +Deno.test("deepMerge: array merge (merge)", () => { assertEquals( deepMerge({ foo: [1, 2, 3], }, { foo: [4, 5, 6], - }, { arrays: "concat" }), + }, { arrays: "merge" }), { foo: [1, 2, 3, 4, 5, 6], }, @@ -222,20 +222,20 @@ Deno.test("deepMerge: maps merge (replace)", () => { map: new Map([["foo", true]]), }, { map: new Map([["bar", true]]), - }), + }, { maps: "replace" }), { map: new Map([["bar", true]]), }, ); }); -Deno.test("deepMerge: maps merge (concat)", () => { +Deno.test("deepMerge: maps merge (merge)", () => { assertEquals( deepMerge({ map: new Map([["foo", true]]), }, { map: new Map([["bar", true]]), - }, { maps: "concat" }), + }, { maps: "merge" }), { map: new Map([["foo", true], ["bar", true]]), }, @@ -248,20 +248,20 @@ Deno.test("deepMerge: sets merge (replace)", () => { set: new Set(["foo"]), }, { set: new Set(["bar"]), - }), + }, { sets: "replace" }), { set: new Set(["bar"]), }, ); }); -Deno.test("deepMerge: sets merge (concat)", () => { +Deno.test("deepMerge: sets merge (merge)", () => { assertEquals( deepMerge({ set: new Set(["foo"]), }, { set: new Set(["bar"]), - }, { sets: "concat" }), + }, { sets: "merge" }), { set: new Set(["foo", "bar"]), }, From adb610f6e2179daa64d14ccd2cfb5cc668938e54 Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Sat, 31 Jul 2021 00:19:23 +0200 Subject: [PATCH 07/16] fix: add missing `s` in `asserts` --- collections/README.md | 2 +- collections/deep_merge.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/collections/README.md b/collections/README.md index 4fae8cd74ad3..d604f349794c 100644 --- a/collections/README.md +++ b/collections/README.md @@ -41,7 +41,7 @@ include non enumerable properties too. ```ts import { deepMerge } from "./deep_merge.ts"; -import { assertEquals } from "../testing/assert.ts"; +import { assertEquals } from "../testing/asserts.ts"; const a = { foo: true }; const b = { foo: { bar: true } }; diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index 02185268a544..8b20251710ed 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -12,7 +12,7 @@ * * ```ts * import { deepMerge } from "./deep_merge.ts"; - * import { assertEquals } from "../testing/assert.ts"; + * import { assertEquals } from "../testing/asserts.ts"; * * const a = {foo: true} * const b = {foo: {bar: true}} From 7763803ef1d1f74c23905101ea44c5e45d00748a Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Sat, 31 Jul 2021 00:41:44 +0200 Subject: [PATCH 08/16] feat(collections): clone object before merging it --- collections/deep_merge.ts | 51 ++++++++++++++++++++++++++++++---- collections/deep_merge_test.ts | 34 +++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index 8b20251710ed..0aad782ce126 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -23,7 +23,7 @@ export function deepMerge< T extends Record, >( - object: Partial, + record: Partial, other: Partial, options?: DeepMergeOptions, ): T; @@ -32,7 +32,7 @@ export function deepMerge< T extends Record, U extends Record, >( - object: T, + record: T, other: U, options?: DeepMergeOptions, ): T; @@ -41,7 +41,7 @@ export function deepMerge< T extends Record, U extends Record, >( - object: T, + record: T, other: U, { arrays = "merge", @@ -50,7 +50,7 @@ export function deepMerge< includeNonEnumerable = false, }: DeepMergeOptions = {}, ): T & U { - const result = object; + const result = clone(record, { includeNonEnumerable }); // Extract property and symbols const keys = [ @@ -65,7 +65,7 @@ export function deepMerge< // Handle arrays if ((Array.isArray(a)) && (Array.isArray(b))) { if (arrays === "merge") { - result[key] = a.concat(...b); + (result[key] as typeof a).push(...b); } else { result[key] = b; } @@ -109,6 +109,47 @@ export function deepMerge< return result as T & U; } +/** + * Clone a record + * Arrays, maps, sets and objects are cloned so references doesn't point + * anymore to those of cloned record + */ +function clone>( + record: T, + { includeNonEnumerable = false } = {}, +) { + // Extract property and symbols + const keys = [ + ...Object.getOwnPropertyNames(record), + ...Object.getOwnPropertySymbols(record), + ].filter((key) => includeNonEnumerable || record.propertyIsEnumerable(key)); + + // Build cloned record + const cloned = {} as T; + for (const key of keys as PropertyKeys) { + const v = record[key]; + if (Array.isArray(v)) { + cloned[key] = [...v]; + continue; + } + if (v instanceof Map) { + cloned[key] = new Map(v); + continue; + } + if (v instanceof Set) { + cloned[key] = new Set(v); + continue; + } + if (isMergeable>(v)) { + cloned[key] = clone(v); + continue; + } + cloned[key] = v; + } + + return cloned; +} + /** * Test whether a value is mergeable or not * Builtins object like, null and user classes are not considered mergeable diff --git a/collections/deep_merge_test.ts b/collections/deep_merge_test.ts index 7ee1989b7754..d6c8ff8c380b 100644 --- a/collections/deep_merge_test.ts +++ b/collections/deep_merge_test.ts @@ -312,3 +312,37 @@ Deno.test("deepMerge: handle circular references", () => { expected.bar = expected; assertEquals(deepMerge({}, expected), expected); }); + +Deno.test("deepMerge: target object is not modified", () => { + const record = { + foo: { + bar: true, + }, + baz: [1, 2, 3], + quux: new Set([1, 2, 3]), + }; + assertEquals( + deepMerge(record, { + foo: { + qux: false, + }, + baz: [4, 5, 6], + quux: new Set([4, 5, 6]), + }, { arrays: "merge", sets: "merge" }), + { + foo: { + bar: true, + qux: false, + }, + baz: [1, 2, 3, 4, 5, 6], + quux: new Set([1, 2, 3, 4, 5, 6]), + }, + ); + assertEquals(record, { + foo: { + bar: true, + }, + baz: [1, 2, 3], + quux: new Set([1, 2, 3]), + }); +}); From 37431e613696ea6726ad35505e0a16eadcbad1ae Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Sat, 31 Jul 2021 12:53:46 +0200 Subject: [PATCH 09/16] feat(collections): minor improvements --- collections/deep_merge.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index 0aad782ce126..8e1b126cc6e5 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -35,7 +35,7 @@ export function deepMerge< record: T, other: U, options?: DeepMergeOptions, -): T; +): T & U; export function deepMerge< T extends Record, @@ -65,7 +65,7 @@ export function deepMerge< // Handle arrays if ((Array.isArray(a)) && (Array.isArray(b))) { if (arrays === "merge") { - (result[key] as typeof a).push(...b); + (result[key] as (typeof a & typeof b)).push(...b); } else { result[key] = b; } @@ -97,8 +97,8 @@ export function deepMerge< } // Recursively merge mergeable objects - if (isMergeable(a) && isMergeable(b)) { - result[key] = deepMerge(a ?? {}, b); + if (isMergeable(a) && isMergeable(b)) { + result[key] = deepMerge(a, b); continue; } @@ -140,7 +140,7 @@ function clone>( cloned[key] = new Set(v); continue; } - if (isMergeable>(v)) { + if (isMergeable(v)) { cloned[key] = clone(v); continue; } @@ -154,7 +154,9 @@ function clone>( * Test whether a value is mergeable or not * Builtins object like, null and user classes are not considered mergeable */ -function isMergeable(value: unknown): value is T { +function isMergeable>( + value: unknown, +): value is T { // Ignore null if (value === null) { return false; From fd00db707fd823e28541da40d0a35f4e89357810 Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Sat, 31 Jul 2021 15:55:11 +0200 Subject: [PATCH 10/16] feat(collections): improve typings --- collections/deep_merge.ts | 74 ++++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index 8e1b126cc6e5..2cdb1e1d5ca1 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -35,7 +35,7 @@ export function deepMerge< record: T, other: U, options?: DeepMergeOptions, -): T & U; +): DeepMerge; export function deepMerge< T extends Record, @@ -49,23 +49,27 @@ export function deepMerge< sets = "merge", includeNonEnumerable = false, }: DeepMergeOptions = {}, -): T & U { - const result = clone(record, { includeNonEnumerable }); +): DeepMerge { + // Clone left operand to avoid performing mutations in-place + type V = DeepMerge; + const result = clone(record as V, { includeNonEnumerable }); // Extract property and symbols const keys = [ ...Object.getOwnPropertyNames(other), ...Object.getOwnPropertySymbols(other), - ].filter((key) => includeNonEnumerable || other.propertyIsEnumerable(key)); + ].filter((key) => + includeNonEnumerable || other.propertyIsEnumerable(key) + ) as Array; // Iterate through each key of other object and use correct merging strategy - for (const key of keys as PropertyKeys) { - const a = result[key], b = other[key]; + for (const key of keys) { + const a = result[key] as V[typeof key], b = other[key] as V[typeof key]; // Handle arrays if ((Array.isArray(a)) && (Array.isArray(b))) { if (arrays === "merge") { - (result[key] as (typeof a & typeof b)).push(...b); + a.push(...b); } else { result[key] = b; } @@ -98,15 +102,14 @@ export function deepMerge< // Recursively merge mergeable objects if (isMergeable(a) && isMergeable(b)) { - result[key] = deepMerge(a, b); + result[key] = deepMerge(a, b) as V[typeof key]; continue; } // Override value result[key] = b; } - - return result as T & U; + return result; } /** @@ -119,25 +122,28 @@ function clone>( { includeNonEnumerable = false } = {}, ) { // Extract property and symbols + const cloned = {} as T; const keys = [ ...Object.getOwnPropertyNames(record), ...Object.getOwnPropertySymbols(record), - ].filter((key) => includeNonEnumerable || record.propertyIsEnumerable(key)); + ].filter((key) => + includeNonEnumerable || record.propertyIsEnumerable(key) + ) as Array; // Build cloned record - const cloned = {} as T; - for (const key of keys as PropertyKeys) { + for (const key of keys) { + type SameType = T[typeof key]; const v = record[key]; if (Array.isArray(v)) { - cloned[key] = [...v]; + cloned[key] = [...v] as SameType; continue; } if (v instanceof Map) { - cloned[key] = new Map(v); + cloned[key] = new Map(v) as SameType; continue; } if (v instanceof Set) { - cloned[key] = new Set(v); + cloned[key] = new Set(v) as SameType; continue; } if (isMergeable(v)) { @@ -180,7 +186,35 @@ export type DeepMergeOptions = { includeNonEnumerable?: boolean; }; -// TypeScript does not support 'symbol' as index type currently though -// it's perfectly valid -// deno-lint-ignore no-explicit-any -type PropertyKeys = any[]; +/** + * DeepMerge typing inspired by Jakub Švehla's solution (@Svehla) + */ + +/** Object with keys in either T or U but not in both */ +type ObjectXorKeys< + T, + U, + V = Omit & Omit, + W = { [K in keyof V]: V[K] }, +> = W; + +/** Object with keys in both T and U */ +type ObjectAndKeys = Omit>; + +/** Merge two objects */ +type Merge< + T, + U, + V = + & ObjectXorKeys + & { [K in keyof ObjectAndKeys]: DeepMerge }, + W = { [K in keyof V]: V[K] }, +> = W; + +/** Merge deeply two objects */ +export type DeepMerge = + // Handle objects + [T, U] extends [{ [key: string]: unknown }, { [key: string]: unknown }] + ? Merge + : // Handle primitives + T | U; From c079a2b4a15d4a30c0b94bbdb56e964e2ef2deab Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Sat, 31 Jul 2021 18:00:48 +0200 Subject: [PATCH 11/16] =?UTF-8?q?feat(collections):=20full=20typing=20seem?= =?UTF-8?q?s=20to=20work=20=F0=9F=8E=89=20!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- collections/deep_merge.ts | 150 +++++++++++++++++++++++++++++++++----- 1 file changed, 132 insertions(+), 18 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index 2cdb1e1d5ca1..7c21dfb8b56c 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -51,8 +51,8 @@ export function deepMerge< }: DeepMergeOptions = {}, ): DeepMerge { // Clone left operand to avoid performing mutations in-place - type V = DeepMerge; - const result = clone(record as V, { includeNonEnumerable }); + type Result = DeepMerge; + const result = clone(record as Result, { includeNonEnumerable }); // Extract property and symbols const keys = [ @@ -60,11 +60,12 @@ export function deepMerge< ...Object.getOwnPropertySymbols(other), ].filter((key) => includeNonEnumerable || other.propertyIsEnumerable(key) - ) as Array; + ) as Array; // Iterate through each key of other object and use correct merging strategy for (const key of keys) { - const a = result[key] as V[typeof key], b = other[key] as V[typeof key]; + const a = result[key] as Result[typeof key], + b = other[key] as Result[typeof key]; // Handle arrays if ((Array.isArray(a)) && (Array.isArray(b))) { @@ -102,7 +103,7 @@ export function deepMerge< // Recursively merge mergeable objects if (isMergeable(a) && isMergeable(b)) { - result[key] = deepMerge(a, b) as V[typeof key]; + result[key] = deepMerge(a, b) as Result[typeof key]; continue; } @@ -187,34 +188,147 @@ export type DeepMergeOptions = { }; /** - * DeepMerge typing inspired by Jakub Švehla's solution (@Svehla) + * Recursive typings + * + * Deep merging process is handled through `DeepMerge` type. + * If both T and U are Records, we recursively merge them, + * else we treat them as primitives + * + * In merging process, handled through `Merge` type, + * We remove all maps, sets and arrays as we'll handle them differently. + * + * Merge< + * {foo: string}, + * {bar: string, baz: Set}, + * > // "foo" and "bar" will be handled with `MergeRightOmitCollections` + * // "baz" will be handled with `MergeAll*` + * + * The `MergeRightOmitCollections` will do so, while keeping T's + * exclusive keys, overriding common ones by U's typing instead and + * adding U's exclusive keys: + * + * MergeRightOmitCollections< + * {foo: string, baz: number}, + * {foo: boolean, bar: string} + * > // {baz: number, foo: boolean, bar: string} + * // "baz" was kept from T + * // "foo" was overriden by U's typing + * // "bar" was added from U + * + * Then, for Maps, Arrays and Sets, we use `MergeAll*` types. + * They will extract given collections from both T and U (providing that + * both have a collection for a specific key), retrieve each collection + * values types (and key types for maps) using `*ValueType`. + * From extracted values (and keys) types, a new collection with union + * typing is made. + * + * MergeAllSets< + * {foo: Set}, + * {foo: Set} + * > // `SetValueType` will extract "number" for T + * // `SetValueType` will extract "string" for U + * // `MergeAllSets` will infer type as Set + * // Process is similar for Maps, Arrays, and Sets + * + * This should cover most cases. */ +/** Force intellisense to expand the typing to hide merging typings */ +type ExpandRecursively = T extends Record + ? T extends infer O ? { [K in keyof O]: ExpandRecursively } : never + : T; + +/** Filter of keys matching a given type */ +type PartialByType = { + [K in keyof T as T[K] extends U ? K : never]: T[K]; +}; + +/** Get set values type */ +type SetValueType = T extends Set ? V : never; + +/** Merge all sets types definitions from keys present in both objects */ +type MergeAllSets< + T, + U, + X = PartialByType>, + Y = PartialByType>, + Z = { + [K in keyof X & keyof Y]: Set | SetValueType>; + }, +> = Z; + +/** Get array values type */ +type ArrayValueType = T extends Array ? V : never; + +/** Merge all sets types definitions from keys present in both objects */ +type MergeAllArrays< + T, + U, + X = PartialByType>, + Y = PartialByType>, + Z = { + [K in keyof X & keyof Y]: Array< + ArrayValueType | ArrayValueType + >; + }, +> = Z; + +/** Get map values types */ +type MapKeyType = T extends Map ? K : never; + +/** Get map values types */ +type MapValueType = T extends Map ? V : never; + +/** Merge all sets types definitions from keys present in both objects */ +type MergeAllMaps< + T, + U, + X = PartialByType>, + Y = PartialByType>, + Z = { + [K in keyof X & keyof Y]: Map< + MapKeyType | MapKeyType, + MapValueType | MapValueType + >; + }, +> = Z; + +/** Exclude map, sets and array from type */ +type OmitCollections = Omit< + T, + keyof PartialByType | Set | Array> +>; + /** Object with keys in either T or U but not in both */ type ObjectXorKeys< T, U, - V = Omit & Omit, - W = { [K in keyof V]: V[K] }, -> = W; + X = Omit & Omit, + Y = { [K in keyof X]: X[K] }, +> = Y; -/** Object with keys in both T and U */ -type ObjectAndKeys = Omit>; +/** Merge two objects, with left precedence */ +type MergeRightOmitCollections< + T, + U, + X = ObjectXorKeys & OmitCollections<{ [K in keyof U]: U[K] }>, +> = X; /** Merge two objects */ type Merge< T, U, - V = - & ObjectXorKeys - & { [K in keyof ObjectAndKeys]: DeepMerge }, - W = { [K in keyof V]: V[K] }, -> = W; + X = + & MergeRightOmitCollections + & MergeAllSets + & MergeAllArrays + & MergeAllMaps, +> = ExpandRecursively; -/** Merge deeply two objects */ +/** Merge deeply two objects (inspired by Jakub Švehla's solution (@Svehla)) */ export type DeepMerge = // Handle objects - [T, U] extends [{ [key: string]: unknown }, { [key: string]: unknown }] + [T, U] extends [Record, Record] ? Merge : // Handle primitives T | U; From 3e35c3efe8c80378c0c2333e7fe379460a902e12 Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Sat, 31 Jul 2021 18:23:54 +0200 Subject: [PATCH 12/16] feat(collections): add missing recursion in typing for records --- collections/deep_merge.ts | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index 7c21dfb8b56c..ff0271697c6d 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -195,19 +195,19 @@ export type DeepMergeOptions = { * else we treat them as primitives * * In merging process, handled through `Merge` type, - * We remove all maps, sets and arrays as we'll handle them differently. + * We remove all maps, sets, arrays and records as we'll handle them differently. * * Merge< * {foo: string}, * {bar: string, baz: Set}, - * > // "foo" and "bar" will be handled with `MergeRightOmitCollections` + * > // "foo" and "bar" will be handled with `MergeRightOmitComplexs` * // "baz" will be handled with `MergeAll*` * - * The `MergeRightOmitCollections` will do so, while keeping T's + * The `MergeRightOmitComplexs` will do so, while keeping T's * exclusive keys, overriding common ones by U's typing instead and * adding U's exclusive keys: * - * MergeRightOmitCollections< + * MergeRightOmitComplexs< * {foo: string, baz: number}, * {foo: boolean, bar: string} * > // {baz: number, foo: boolean, bar: string} @@ -215,7 +215,7 @@ export type DeepMergeOptions = { * // "foo" was overriden by U's typing * // "bar" was added from U * - * Then, for Maps, Arrays and Sets, we use `MergeAll*` types. + * Then, for Maps, Arrays, Sets and Records, we use `MergeAll*` types. * They will extract given collections from both T and U (providing that * both have a collection for a specific key), retrieve each collection * values types (and key types for maps) using `*ValueType`. @@ -293,10 +293,27 @@ type MergeAllMaps< }, > = Z; +/** Merge all records types definitions from keys present in both objects */ +type MergeAllRecords< + T, + U, + X = PartialByType>, + Y = PartialByType>, + Z = { + [K in keyof X & keyof Y]: DeepMerge; + }, +> = Z; + /** Exclude map, sets and array from type */ -type OmitCollections = Omit< +type OmitComplexs = Omit< T, - keyof PartialByType | Set | Array> + keyof PartialByType< + T, + | Map + | Set + | Array + | Record + > >; /** Object with keys in either T or U but not in both */ @@ -308,10 +325,10 @@ type ObjectXorKeys< > = Y; /** Merge two objects, with left precedence */ -type MergeRightOmitCollections< +type MergeRightOmitComplexs< T, U, - X = ObjectXorKeys & OmitCollections<{ [K in keyof U]: U[K] }>, + X = ObjectXorKeys & OmitComplexs<{ [K in keyof U]: U[K] }>, > = X; /** Merge two objects */ @@ -319,7 +336,8 @@ type Merge< T, U, X = - & MergeRightOmitCollections + & MergeRightOmitComplexs + & MergeAllRecords & MergeAllSets & MergeAllArrays & MergeAllMaps, From 6d59116dd2c0cb9c20be3366e7eb45278d9276e2 Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Sun, 1 Aug 2021 11:29:24 +0200 Subject: [PATCH 13/16] feat(collections): handle merging strategy in typing --- collections/deep_merge.ts | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index ff0271697c6d..7fe57e855ca4 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -175,14 +175,17 @@ function isMergeable>( return typeof value === "object"; } +/** Merging strategy */ +export type MergingStrategy = "replace" | "merge"; + /** Deep merge options */ export type DeepMergeOptions = { /** Merging strategy for arrays */ - arrays?: "replace" | "merge"; + arrays?: MergingStrategy; /** Merging strategy for Maps */ - maps?: "replace" | "merge"; + maps?: MergingStrategy; /** Merging strategy for Sets */ - sets?: "replace" | "merge"; + sets?: MergingStrategy; /** Whether to include non enumerable properties */ includeNonEnumerable?: boolean; }; @@ -297,10 +300,11 @@ type MergeAllMaps< type MergeAllRecords< T, U, + Options, X = PartialByType>, Y = PartialByType>, Z = { - [K in keyof X & keyof Y]: DeepMerge; + [K in keyof X & keyof Y]: DeepMerge; }, > = Z; @@ -335,18 +339,30 @@ type MergeRightOmitComplexs< type Merge< T, U, + Options, X = & MergeRightOmitComplexs - & MergeAllRecords - & MergeAllSets - & MergeAllArrays - & MergeAllMaps, + & MergeAllRecords + & (Options extends { sets: "replace" } ? PartialByType> + : MergeAllSets) + & (Options extends { arrays: "replace" } ? PartialByType> + : MergeAllArrays) + & (Options extends { maps: "replace" } + ? PartialByType> + : MergeAllMaps), > = ExpandRecursively; /** Merge deeply two objects (inspired by Jakub Švehla's solution (@Svehla)) */ -export type DeepMerge = +export type DeepMerge< + T, + U, + Options extends Record = Record< + string, + MergingStrategy + >, +> = // Handle objects [T, U] extends [Record, Record] - ? Merge + ? Merge : // Handle primitives T | U; From d0a7a38899871b423ae2d27d3d27e61a4c5ce0a8 Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Sun, 1 Aug 2021 11:46:01 +0200 Subject: [PATCH 14/16] feat(collections): add support for merging strategy in typing --- collections/deep_merge.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index 7fe57e855ca4..c6fd31fb127d 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -31,27 +31,32 @@ export function deepMerge< export function deepMerge< T extends Record, U extends Record, + Options extends DeepMergeOptions, >( record: T, other: U, - options?: DeepMergeOptions, -): DeepMerge; + options?: Options, +): DeepMerge; export function deepMerge< T extends Record, U extends Record, + Options extends DeepMergeOptions, >( record: T, other: U, - { + options?: Options, +): DeepMerge { + // + const { arrays = "merge", maps = "merge", sets = "merge", includeNonEnumerable = false, - }: DeepMergeOptions = {}, -): DeepMerge { + } = options ?? {}; + // Clone left operand to avoid performing mutations in-place - type Result = DeepMerge; + type Result = DeepMerge; const result = clone(record as Result, { includeNonEnumerable }); // Extract property and symbols @@ -356,10 +361,7 @@ type Merge< export type DeepMerge< T, U, - Options extends Record = Record< - string, - MergingStrategy - >, + Options = Record, > = // Handle objects [T, U] extends [Record, Record] From d195723c033f7c79b6b1b682defd8d0a75b030b2 Mon Sep 17 00:00:00 2001 From: lowlighter <22963968+lowlighter@users.noreply.github.com> Date: Sun, 1 Aug 2021 14:15:24 +0200 Subject: [PATCH 15/16] docs(collections): improve explanations --- collections/deep_merge.ts | 59 ++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index c6fd31fb127d..7bef97d10072 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -47,7 +47,7 @@ export function deepMerge< other: U, options?: Options, ): DeepMerge { - // + // Extract options const { arrays = "merge", maps = "merge", @@ -57,7 +57,7 @@ export function deepMerge< // Clone left operand to avoid performing mutations in-place type Result = DeepMerge; - const result = clone(record as Result, { includeNonEnumerable }); + const result = clone(record, { includeNonEnumerable }) as Result; // Extract property and symbols const keys = [ @@ -121,7 +121,7 @@ export function deepMerge< /** * Clone a record * Arrays, maps, sets and objects are cloned so references doesn't point - * anymore to those of cloned record + * anymore to those of target record */ function clone>( record: T, @@ -136,7 +136,7 @@ function clone>( includeNonEnumerable || record.propertyIsEnumerable(key) ) as Array; - // Build cloned record + // Build cloned record, creating new data structure when needed for (const key of keys) { type SameType = T[typeof key]; const v = record[key]; @@ -164,7 +164,8 @@ function clone>( /** * Test whether a value is mergeable or not - * Builtins object like, null and user classes are not considered mergeable + * Builtins that look like objects, null and user defined classes + * are not considered mergeable (it means that reference will be copied) */ function isMergeable>( value: unknown, @@ -196,24 +197,25 @@ export type DeepMergeOptions = { }; /** - * Recursive typings + * How does recursive typing works ? * - * Deep merging process is handled through `DeepMerge` type. + * Deep merging process is handled through `DeepMerge` type. * If both T and U are Records, we recursively merge them, - * else we treat them as primitives + * else we treat them as primitives. * - * In merging process, handled through `Merge` type, - * We remove all maps, sets, arrays and records as we'll handle them differently. + * Merging process is handled through `Merge` type, in which + * we remove all maps, sets, arrays and records so we can handle them + * separately depending on merging strategy: * * Merge< * {foo: string}, * {bar: string, baz: Set}, * > // "foo" and "bar" will be handled with `MergeRightOmitComplexs` - * // "baz" will be handled with `MergeAll*` + * // "baz" will be handled with `MergeAll*` type * - * The `MergeRightOmitComplexs` will do so, while keeping T's - * exclusive keys, overriding common ones by U's typing instead and - * adding U's exclusive keys: + * `MergeRightOmitComplexs` will do the above: all T's + * exclusive keys will be kept, though common ones with U will have their + * typing overriden instead: * * MergeRightOmitComplexs< * {foo: string, baz: number}, @@ -223,12 +225,13 @@ export type DeepMergeOptions = { * // "foo" was overriden by U's typing * // "bar" was added from U * - * Then, for Maps, Arrays, Sets and Records, we use `MergeAll*` types. - * They will extract given collections from both T and U (providing that - * both have a collection for a specific key), retrieve each collection - * values types (and key types for maps) using `*ValueType`. - * From extracted values (and keys) types, a new collection with union - * typing is made. + * For Maps, Arrays, Sets and Records, we use `MergeAll*` utilitary + * types. They will extract revelant data structure from both T and U + * (providing that both have same data data structure, except for typing). + * + * From these, `*ValueType` will extract values (and keys) types to be + * able to create a new data structure with an unioned typing from both + * data structure of T and U: * * MergeAllSets< * {foo: Set}, @@ -238,7 +241,19 @@ export type DeepMergeOptions = { * // `MergeAllSets` will infer type as Set * // Process is similar for Maps, Arrays, and Sets * - * This should cover most cases. + * `DeepMerge` is taking a third argument to be handle to + * infer final typing dependending on merging strategy: + * + * & (Options extends { sets: "replace" } ? PartialByType> + * : MergeAllSets) + * + * In the above line, if "Options" have its merging strategy for Sets set to + * "replace", instead of performing merging of Sets type, it will take the + * typing from right operand (U) instead, effectively replacing the typing. + * + * An additional note, we use `ExpandRecursively` utilitary type to expand + * the resulting typing and hide all the typing logic of deep merging so it is + * more user friendly. */ /** Force intellisense to expand the typing to hide merging typings */ @@ -357,7 +372,7 @@ type Merge< : MergeAllMaps), > = ExpandRecursively; -/** Merge deeply two objects (inspired by Jakub Švehla's solution (@Svehla)) */ +/** Merge deeply two objects */ export type DeepMerge< T, U, From 95338b3d6028df9652315eaf460a6f18d0e1a0a0 Mon Sep 17 00:00:00 2001 From: Leon Strauss Date: Wed, 4 Aug 2021 18:57:21 +0200 Subject: [PATCH 16/16] :art: Formatting and linting --- collections/deep_merge.ts | 194 ++++++++++++++++----------------- collections/deep_merge_test.ts | 17 --- 2 files changed, 92 insertions(+), 119 deletions(-) diff --git a/collections/deep_merge.ts b/collections/deep_merge.ts index 7bef97d10072..2acd2bba9278 100644 --- a/collections/deep_merge.ts +++ b/collections/deep_merge.ts @@ -1,5 +1,9 @@ // Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. +// deno-lint-ignore-file ban-types + +import { filterInPlace } from "./_utils.ts"; + /** * Merges the two given Records, recursively merging any nested Records with * the second collection overriding the first in case of conflict @@ -41,125 +45,103 @@ export function deepMerge< export function deepMerge< T extends Record, U extends Record, - Options extends DeepMergeOptions, + Options extends DeepMergeOptions = { + arrays: "merge"; + sets: "merge"; + maps: "merge"; + }, >( record: T, other: U, options?: Options, ): DeepMerge { // Extract options - const { - arrays = "merge", - maps = "merge", - sets = "merge", - includeNonEnumerable = false, - } = options ?? {}; - // Clone left operand to avoid performing mutations in-place type Result = DeepMerge; - const result = clone(record, { includeNonEnumerable }) as Result; + const result: Partial = {}; - // Extract property and symbols - const keys = [ - ...Object.getOwnPropertyNames(other), - ...Object.getOwnPropertySymbols(other), - ].filter((key) => - includeNonEnumerable || other.propertyIsEnumerable(key) - ) as Array; + const keys = new Set([ + ...getKeys(record), + ...getKeys(other), + ]) as Set; // Iterate through each key of other object and use correct merging strategy for (const key of keys) { - const a = result[key] as Result[typeof key], - b = other[key] as Result[typeof key]; + type ResultMember = Result[typeof key]; - // Handle arrays - if ((Array.isArray(a)) && (Array.isArray(b))) { - if (arrays === "merge") { - a.push(...b); - } else { - result[key] = b; - } - continue; - } + const a = record[key] as ResultMember; - // Handle maps - if ((a instanceof Map) && (b instanceof Map)) { - if (maps === "merge") { - for (const [k, v] of b.entries()) { - a.set(k, v); - } - } else { - result[key] = b; - } - continue; - } + if (!(key in other)) { + result[key] = a; - // Handle sets - if ((a instanceof Set) && (b instanceof Set)) { - if (sets === "merge") { - for (const v of b.values()) { - a.add(v); - } - } else { - result[key] = b; - } continue; } - // Recursively merge mergeable objects - if (isMergeable(a) && isMergeable(b)) { - result[key] = deepMerge(a, b) as Result[typeof key]; + const b = other[key] as ResultMember; + + if (isNonNullObject(a) && isNonNullObject(b)) { + result[key] = mergeObjects(a, b, options) as ResultMember; + continue; } // Override value result[key] = b; } - return result; + + return result as Result; } -/** - * Clone a record - * Arrays, maps, sets and objects are cloned so references doesn't point - * anymore to those of target record - */ -function clone>( - record: T, - { includeNonEnumerable = false } = {}, -) { - // Extract property and symbols - const cloned = {} as T; - const keys = [ - ...Object.getOwnPropertyNames(record), - ...Object.getOwnPropertySymbols(record), - ].filter((key) => - includeNonEnumerable || record.propertyIsEnumerable(key) - ) as Array; - - // Build cloned record, creating new data structure when needed - for (const key of keys) { - type SameType = T[typeof key]; - const v = record[key]; - if (Array.isArray(v)) { - cloned[key] = [...v] as SameType; - continue; - } - if (v instanceof Map) { - cloned[key] = new Map(v) as SameType; - continue; +function mergeObjects( + left: NonNullable, + right: NonNullable, + options: DeepMergeOptions = { + arrays: "merge", + sets: "merge", + maps: "merge", + }, +): NonNullable { + // Recursively merge mergeable objects + if (isMergeable(left) && isMergeable(right)) { + return deepMerge(left, right); + } + + if (isIterable(left) && isIterable(right)) { + // Handle arrays + if ((Array.isArray(left)) && (Array.isArray(right))) { + if (options.arrays === "merge") { + return left.concat(right); + } + + return right; } - if (v instanceof Set) { - cloned[key] = new Set(v) as SameType; - continue; + + // Handle maps + if ((left instanceof Map) && (right instanceof Map)) { + if (options.maps === "merge") { + return new Map([ + ...left, + ...right, + ]); + } + + return right; } - if (isMergeable(v)) { - cloned[key] = clone(v); - continue; + + // Handle sets + if ((left instanceof Set) && (right instanceof Set)) { + if (options.sets === "merge") { + return new Set([ + ...left, + ...right, + ]); + } + + return right; } - cloned[key] = v; } - return cloned; + return right; } /** @@ -167,18 +149,28 @@ function clone>( * Builtins that look like objects, null and user defined classes * are not considered mergeable (it means that reference will be copied) */ -function isMergeable>( - value: unknown, -): value is T { - // Ignore null - if (value === null) { - return false; - } - // Ignore builtins and classes - if ((typeof value === "object") && ("constructor" in value!)) { - return Object.getPrototypeOf(value) === Object.prototype; - } - return typeof value === "object"; +function isMergeable( + value: NonNullable, +): boolean { + return Object.getPrototypeOf(value) === Object.prototype; +} + +function isIterable( + value: NonNullable, +): value is Iterable { + return typeof (value as Iterable)[Symbol.iterator] === "function"; +} + +function isNonNullObject(value: unknown): value is NonNullable { + return value !== null && typeof value === "object"; +} + +function getKeys(record: T): Array { + const ret = Object.getOwnPropertySymbols(record) as Array; + filterInPlace(ret, (key) => record.propertyIsEnumerable(key)); + ret.push(...(Object.keys(record) as Array)); + + return ret; } /** Merging strategy */ @@ -192,8 +184,6 @@ export type DeepMergeOptions = { maps?: MergingStrategy; /** Merging strategy for Sets */ sets?: MergingStrategy; - /** Whether to include non enumerable properties */ - includeNonEnumerable?: boolean; }; /** diff --git a/collections/deep_merge_test.ts b/collections/deep_merge_test.ts index d6c8ff8c380b..a3fce6961725 100644 --- a/collections/deep_merge_test.ts +++ b/collections/deep_merge_test.ts @@ -41,23 +41,6 @@ Deno.test("deepMerge: ignore non enumerable", () => { ); }); -Deno.test("deepMerge: include non enumerable", () => { - assertEquals( - deepMerge( - {}, - Object.defineProperties({}, { - foo: { enumerable: false, value: true }, - bar: { enumerable: true, value: true }, - }), - { includeNonEnumerable: true }, - ), - { - foo: true, - bar: true, - }, - ); -}); - Deno.test("deepMerge: nested merge", () => { assertEquals( deepMerge({