From bbcf680857d9052938169925ce733d6957081653 Mon Sep 17 00:00:00 2001 From: Rebecca Stevens Date: Tue, 15 Feb 2022 02:51:01 +1300 Subject: [PATCH] feat: provide customizable meta data to custom merge functions fix #33 --- docs/API.md | 18 ++- docs/deepmergeCustom.md | 121 +++++++++++++- src/deepmerge.ts | 174 +++++++++++++++----- src/index.ts | 1 + src/types/defaults.ts | 50 +++--- src/types/merging.ts | 61 ++++--- src/types/options.ts | 66 +++++--- tests/.eslintrc.json | 4 +- tests/deepmerge-custom.test.ts | 279 ++++++++++++++++++++++++++++++++- tests/utils.ts | 12 ++ types-legacy/v4_0.d.ts | 31 ++-- 11 files changed, 682 insertions(+), 135 deletions(-) create mode 100644 tests/utils.ts diff --git a/docs/API.md b/docs/API.md index 97a845cba..50c112ee0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -10,7 +10,7 @@ Merges the array of inputs together using the default configuration. Note: If `inputs` isn't typed as a tuple then we cannot determine the output type. The output type will simply be `unknown`. -## deepmergeCustom(options) +## deepmergeCustom(options[, rootMetaData]) Generate a customized deepmerge function using the given options. The returned function works just like `deepmerge` except it uses the customized configuration. @@ -21,7 +21,7 @@ All these options are optional. #### `mergeRecords` -Type: `false | (values: Record[], utils: DeepMergeMergeFunctionUtils) => unknown` +Type: `false | (values: Record[], utils: DeepMergeMergeFunctionUtils, meta: MetaData) => unknown` If false, records won't be merged. If set to a function, that function will be used to merge records. @@ -29,30 +29,36 @@ Note: Records are "vanilla" objects (e.g. `{ foo: "hello", bar: "world" }`). #### `mergeArrays` -Type: `false | (values: unknown[][], utils: DeepMergeMergeFunctionUtils) => unknown` +Type: `false | (values: unknown[][], utils: DeepMergeMergeFunctionUtils, meta: MetaData) => unknown` If false, arrays won't be merged. If set to a function, that function will be used to merge arrays. #### `mergeMaps` -Type: `false | (values: Map[], utils: DeepMergeMergeFunctionUtils) => unknown` +Type: `false | (values: Map[], utils: DeepMergeMergeFunctionUtils, meta: MetaData) => unknown` If false, maps won't be merged. If set to a function, that function will be used to merge maps. #### `mergeSets` -Type: `false | (values: Set[], utils: DeepMergeMergeFunctionUtils) => unknown` +Type: `false | (values: Set[], utils: DeepMergeMergeFunctionUtils, meta: MetaData) => unknown` If false, sets won't be merged. If set to a function, that function will be used to merge sets. #### `mergeOthers` -Type: `(values: Set[], utils: DeepMergeMergeFunctionUtils) => unknown` +Type: `(values: Set[], utils: DeepMergeMergeFunctionUtils, meta: MetaData) => unknown` If set to a function, that function will be used to merge everything else. Note: This includes merging mixed types, such as merging a map with an array. +### `rootMetaData` + +Type: `MetaData` + +The given meta data value will be passed to root level merges. + ### DeepMergeMergeFunctionUtils This is a set of utility functions that are made available to your custom merge functions. diff --git a/docs/deepmergeCustom.md b/docs/deepmergeCustom.md index 5d25ff75a..77b42e459 100644 --- a/docs/deepmergeCustom.md +++ b/docs/deepmergeCustom.md @@ -37,7 +37,7 @@ This can be done using [Declaration Merging](https://www.typescriptlang.org/docs ```ts declare module "deepmerge-ts" { - interface DeepMergeMergeFunctionURItoKind, MF extends DeepMergeMergeFunctionsURIs> { + interface DeepMergeMergeFunctionURItoKind, MF extends DeepMergeMergeFunctionsURIs, M> { readonly MyCustomMergeURI: MyValue; } } @@ -52,13 +52,13 @@ import { deepmergeCustom } from "deepmerge-ts"; const customizedDeepmerge = deepmergeCustom<{ DeepMergeOthersURI: "MyDeepMergeDatesURI"; // <-- Needed for correct output type. }>({ - mergeOthers: (values, utils) => { + mergeOthers: (values, utils, meta) => { // If every value is a date, the return the amalgamated array. if (values.every((value) => value instanceof Date)) { return values; } // Otherwise, use the default merging strategy. - return utils.defaultMergeFunctions.mergeOthers(values, utils); + return utils.defaultMergeFunctions.mergeOthers(values); }, }); @@ -71,7 +71,8 @@ customDeepmerge(x, y, z); // => { foo: [Date, Date, Date] } declare module "deepmerge-ts" { interface DeepMergeMergeFunctionURItoKind< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs + MF extends DeepMergeMergeFunctionsURIs, + M > { readonly MyDeepMergeDatesURI: EveryIsDate extends true ? Ts : DeepMergeLeaf; } @@ -86,6 +87,118 @@ type EveryIsDate> = Ts extends readonly [infer Note: If you want to use HKTs in your own project, not related to deepmerge-ts, we recommend checking out [fp-ts](https://gcanti.github.io/fp-ts/modules/HKT.ts.html). +## Meta Data + +We provide a simple object of meta data that states the key that the values being merged were under. + +Here's an example that creates a custom deepmerge function that merges numbers differently based on the key they were under. + +```ts +import type { DeepMergeLeaf, DeepMergeMergeFunctionURItoKind, DeepMergeMergeFunctionsURIs } from "deepmerge-ts"; +import { deepmergeCustom } from "deepmerge-ts"; + +const customizedDeepmerge = deepmergeCustom({ + mergeOthers: (values, utils, meta) => { + if (meta !== undefined && areAllNumbers(values)) { + const { key } = meta; + const numbers: ReadonlyArray = values; + + if (key === "sum") { + return numbers.reduce((sum, value) => sum + value); + } + if (key === "product") { + return numbers.reduce((prod, value) => prod * value); + } + if (key === "mean") { + return numbers.reduce((sum, value) => sum + value) / numbers.length; + } + } + + return utils.defaultMergeFunctions.mergeOthers(values); + }, +}); + +function areAllNumbers(values: ReadonlyArray): values is ReadonlyArray { + return values.every((value) => typeof value === "number"); +} + +const v = { sum: 1, product: 2, mean: 3 }; +const x = { sum: 4, product: 5, mean: 6 }; +const y = { sum: 7, product: 8, mean: 9 }; +const z = { sum: 10, product: 11, mean: 12 }; + +customizedDeepmerge(v, x, y, z); // => { sum: 22, product: 880, mean: 7.5 } +``` + +### Customizing the Meta Data + +You can customize the meta data that is passed to the merge functions by providing a `metaDataUpdater` function. + +Here's an example that uses custom metadata that accumulates the full key path. + +```ts +import type { DeepMergeLeaf, DeepMergeMergeFunctionURItoKind, DeepMergeMergeFunctionsURIs } from "deepmerge-ts"; +import { deepmergeCustom } from "deepmerge-ts"; + +const customizedDeepmerge = deepmergeCustom< + // Change the return type of `mergeOthers`. + { + DeepMergeOthersURI: "KeyPathBasedMerge"; + }, + // Change the meta data type. + { + keyPath: ReadonlyArray; + } +>({ + // Customize what the actual meta data. + metaDataUpdater: (previousMeta, metaMeta) => { + if (previousMeta === undefined) { + return { keyPath: [] }; + } + return { + ...metaMeta, + keyPath: [...previousMeta.keyPath, metaMeta.key], + }; + }, + // Use the meta data when merging others. + mergeOthers: (values, utils, meta) => { + if ( + meta !== undefined && + meta.keyPath.length >= 2 && + meta.keyPath[meta.keyPath.length - 2] === "bar" && + meta.keyPath[meta.keyPath.length - 1] === "baz" + ) { + return "special merge"; + } + + return utils.defaultMergeFunctions.mergeOthers(values); + }, +}); + +const x = { + foo: { bar: { baz: 1, qux: 2 } }, + bar: { baz: 3, qux: 4 }, +}; +const y = { + foo: { bar: { baz: 5, bar: { baz: 6, qux: 7 } } }, + bar: { baz: 8, qux: 9 }, +}; + +customizedDeepmerge(x, y); // => { foo: { bar: { baz: "special merge", bar: { baz: 6, qux: 7 }, qux: 2 } }, bar: { baz: "special merge", qux: 9 }, } + +declare module "../src/types" { + interface DeepMergeMergeFunctionURItoKind< + Ts extends Readonly>, + MF extends DeepMergeMergeFunctionsURIs, + M // This is the meta data type + > { + readonly KeyPathBasedMerge: Ts[number] extends number + ? Ts[number] | string + : DeepMergeLeaf; + } +} +``` + ## API [See deepmerge custom API](./API.md#deepmergecustomoptions). diff --git a/src/deepmerge.ts b/src/deepmerge.ts index b6eee50ac..f2638b58f 100644 --- a/src/deepmerge.ts +++ b/src/deepmerge.ts @@ -1,4 +1,5 @@ import type { + DeepMergeBuiltInMetaData, DeepMergeHKT, DeepMergeArraysDefaultHKT, DeepMergeMergeFunctionsDefaultURIs, @@ -26,6 +27,16 @@ const defaultMergeFunctions = { mergeOthers: leaf, } as const; +/** + * The default function to update meta data. + */ +function defaultMetaDataUpdater( + previousMeta: M, + metaMeta: DeepMergeBuiltInMetaData +): DeepMergeBuiltInMetaData { + return metaMeta; +} + /** * The default merge functions. */ @@ -36,12 +47,17 @@ export type DeepMergeMergeFunctionsDefaults = typeof defaultMergeFunctions; * * @param objects - The objects to merge. */ -export function deepmerge>( +export function deepmerge>>( ...objects: readonly [...Ts] -): DeepMergeHKT { +): DeepMergeHKT< + Ts, + DeepMergeMergeFunctionsDefaultURIs, + DeepMergeBuiltInMetaData +> { return deepmergeCustom({})(...objects) as DeepMergeHKT< Ts, - DeepMergeMergeFunctionsDefaultURIs + DeepMergeMergeFunctionsDefaultURIs, + DeepMergeBuiltInMetaData >; } @@ -53,18 +69,55 @@ export function deepmerge>( export function deepmergeCustom< PMF extends Partial >( - options: DeepMergeOptions + options: DeepMergeOptions +): >( + ...objects: Ts +) => DeepMergeHKT< + Ts, + GetDeepMergeMergeFunctionsURIs, + DeepMergeBuiltInMetaData +>; + +/** + * Deeply merge two or more objects using the given options and meta data. + * + * @param options - The options on how to customize the merge function. + * @param rootMetaData - The meta data passed to the root items' being merged. + */ +export function deepmergeCustom< + PMF extends Partial, + MetaData, + MetaMetaData extends Readonly< + Record + > = DeepMergeBuiltInMetaData +>( + options: DeepMergeOptions, + rootMetaData?: MetaData +): >( + ...objects: Ts +) => DeepMergeHKT, MetaData>; + +export function deepmergeCustom< + PMF extends Partial, + MetaData, + MetaMetaData extends Readonly> +>( + options: DeepMergeOptions, + rootMetaData?: MetaData ): >( ...objects: Ts -) => DeepMergeHKT> { +) => DeepMergeHKT, MetaData> { /** * The type of the customized deepmerge function. */ type CustomizedDeepmerge = >( ...objects: Ts - ) => DeepMergeHKT>; + ) => DeepMergeHKT, MetaData>; - const utils = getUtils(options, customizedDeepmerge as CustomizedDeepmerge); + const utils: DeepMergeMergeFunctionUtils = getUtils( + options, + customizedDeepmerge as CustomizedDeepmerge + ); /** * The customized deepmerge function. @@ -77,7 +130,13 @@ export function deepmergeCustom< return objects[0]; } - return mergeUnknowns(objects, utils); + return mergeUnknowns< + ReadonlyArray, + typeof utils, + GetDeepMergeMergeFunctionsURIs, + MetaData, + MetaMetaData + >(objects, utils, rootMetaData); } return customizedDeepmerge as CustomizedDeepmerge; @@ -88,20 +147,29 @@ export function deepmergeCustom< * * @param options - The options the user specified */ -function getUtils( - options: DeepMergeOptions, - customizedDeepmerge: DeepMergeMergeFunctionUtils["deepmerge"] -): DeepMergeMergeFunctionUtils { +function getUtils>>( + options: DeepMergeOptions, + customizedDeepmerge: DeepMergeMergeFunctionUtils["deepmerge"] +): DeepMergeMergeFunctionUtils { return { defaultMergeFunctions, mergeFunctions: { ...defaultMergeFunctions, ...Object.fromEntries( - Object.entries(options).map(([key, option]) => - option === false ? [key, leaf] : [key, option] - ) + Object.entries(options) + .filter(([key, option]) => + Object.prototype.hasOwnProperty.call(defaultMergeFunctions, key) + ) + .map(([key, option]) => + option === false ? [key, leaf] : [key, option] + ) ), - } as DeepMergeMergeFunctionUtils["mergeFunctions"], + } as DeepMergeMergeFunctionUtils["mergeFunctions"], + metaDataUpdater: (options.metaDataUpdater ?? + defaultMetaDataUpdater) as unknown as DeepMergeMergeFunctionUtils< + M, + MM + >["metaDataUpdater"], deepmerge: customizedDeepmerge, }; } @@ -113,9 +181,11 @@ function getUtils( */ function mergeUnknowns< Ts extends ReadonlyArray, - U extends DeepMergeMergeFunctionUtils, - MF extends DeepMergeMergeFunctionsURIs ->(values: Ts, utils: U): DeepMergeHKT { + U extends DeepMergeMergeFunctionUtils, + MF extends DeepMergeMergeFunctionsURIs, + M, + MM extends Readonly> +>(values: Ts, utils: U, meta: M | undefined): DeepMergeHKT { const type = getObjectType(values[0]); // eslint-disable-next-line functional/no-conditional-statement -- add an early escape for better performance. @@ -126,10 +196,11 @@ function mergeUnknowns< continue; } - return utils.mergeFunctions.mergeOthers(values, utils) as DeepMergeHKT< - Ts, - MF - >; + return utils.mergeFunctions.mergeOthers( + values, + utils, + meta + ) as DeepMergeHKT; } } @@ -137,32 +208,37 @@ function mergeUnknowns< case ObjectType.RECORD: return utils.mergeFunctions.mergeRecords( values as ReadonlyArray>>, - utils - ) as DeepMergeHKT; + utils, + meta + ) as DeepMergeHKT; case ObjectType.ARRAY: return utils.mergeFunctions.mergeArrays( - values as ReadonlyArray>, - utils - ) as DeepMergeHKT; + values as ReadonlyArray>>, + utils, + meta + ) as DeepMergeHKT; case ObjectType.SET: return utils.mergeFunctions.mergeSets( values as ReadonlyArray>>, - utils - ) as DeepMergeHKT; + utils, + meta + ) as DeepMergeHKT; case ObjectType.MAP: return utils.mergeFunctions.mergeMaps( values as ReadonlyArray>>, - utils - ) as DeepMergeHKT; + utils, + meta + ) as DeepMergeHKT; default: - return utils.mergeFunctions.mergeOthers(values, utils) as DeepMergeHKT< - Ts, - MF - >; + return utils.mergeFunctions.mergeOthers( + values, + utils, + meta + ) as DeepMergeHKT; } } @@ -173,9 +249,11 @@ function mergeUnknowns< */ function mergeRecords< Ts extends ReadonlyArray>, - U extends DeepMergeMergeFunctionUtils, - MF extends DeepMergeMergeFunctionsURIs ->(values: Ts, utils: U) { + U extends DeepMergeMergeFunctionUtils, + MF extends DeepMergeMergeFunctionsURIs, + M, + MM extends DeepMergeBuiltInMetaData +>(values: Ts, utils: U, meta: M | undefined) { const result: Record = {}; /* eslint-disable functional/no-loop-statement, functional/no-conditional-statement -- using a loop here is more performant. */ @@ -191,15 +269,24 @@ function mergeRecords< // assert(propValues.length > 0); + const updatedMeta = utils.metaDataUpdater(meta, { + key, + parents: values, + } as unknown as MM); + result[key] = propValues.length === 1 ? propValues[0] - : mergeUnknowns(propValues, utils); + : mergeUnknowns, U, MF, M, MM>( + propValues, + utils, + updatedMeta + ); } /* eslint-enable functional/no-loop-statement, functional/no-conditional-statement */ - return result as DeepMergeRecordsDefaultHKT; + return result as DeepMergeRecordsDefaultHKT; } /** @@ -209,9 +296,10 @@ function mergeRecords< */ function mergeArrays< Ts extends ReadonlyArray>, - MF extends DeepMergeMergeFunctionsURIs + MF extends DeepMergeMergeFunctionsURIs, + M >(values: Ts) { - return values.flat() as DeepMergeArraysDefaultHKT; + return values.flat() as DeepMergeArraysDefaultHKT; } /** diff --git a/src/index.ts b/src/index.ts index ffde02d4d..0f4746edd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export { deepmerge, deepmergeCustom } from "./deepmerge"; export type { DeepMergeMergeFunctionsDefaults } from "./deepmerge"; export type { DeepMergeArraysDefaultHKT, + DeepMergeBuiltInMetaData, DeepMergeHKT, DeepMergeLeaf, DeepMergeLeafHKT, diff --git a/src/types/defaults.ts b/src/types/defaults.ts index 9872142a1..2a610857a 100644 --- a/src/types/defaults.ts +++ b/src/types/defaults.ts @@ -57,11 +57,12 @@ type BlacklistedRecordProps = "__proto__"; */ export type DeepMergeRecordsDefaultHKT< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs -> = Ts extends readonly [unknown, ...ReadonlyArray] + MF extends DeepMergeMergeFunctionsURIs, + M +> = Ts extends Readonly>]> ? FlatternAlias< Omit< - DeepMergeRecordsDefaultHKTInternalProps, + DeepMergeRecordsDefaultHKTInternalProps, BlacklistedRecordProps > > @@ -72,16 +73,19 @@ export type DeepMergeRecordsDefaultHKT< */ type DeepMergeRecordsDefaultHKTInternalProps< Ts extends readonly [unknown, ...ReadonlyArray], - MF extends DeepMergeMergeFunctionsURIs + MF extends DeepMergeMergeFunctionsURIs, + M > = { [K in OptionalKeysOf]?: DeepMergeHKT< - DeepMergeRecordsDefaultHKTInternalPropValue, - MF + DeepMergeRecordsDefaultHKTInternalPropValue, + MF, + M >; } & { [K in RequiredKeysOf]: DeepMergeHKT< - DeepMergeRecordsDefaultHKTInternalPropValue, - MF + DeepMergeRecordsDefaultHKTInternalPropValue, + MF, + M >; }; @@ -90,9 +94,15 @@ type DeepMergeRecordsDefaultHKTInternalProps< */ type DeepMergeRecordsDefaultHKTInternalPropValue< Ts extends readonly [unknown, ...ReadonlyArray], - K extends PropertyKey + K extends PropertyKey, + M > = FilterOutNever< - DeepMergeRecordsDefaultHKTInternalPropValueHelper + DeepMergeRecordsDefaultHKTInternalPropValueHelper< + Ts, + K, + M, + Readonly + > >; /** @@ -101,6 +111,7 @@ type DeepMergeRecordsDefaultHKTInternalPropValue< type DeepMergeRecordsDefaultHKTInternalPropValueHelper< Ts extends readonly [unknown, ...ReadonlyArray], K extends PropertyKey, + M, Acc extends ReadonlyArray > = Ts extends readonly [infer Head, ...infer Rest] ? Head extends Record @@ -108,6 +119,7 @@ type DeepMergeRecordsDefaultHKTInternalPropValueHelper< ? DeepMergeRecordsDefaultHKTInternalPropValueHelper< Rest, K, + M, [...Acc, ValueOfKey] > : [...Acc, ValueOfKey] @@ -119,8 +131,9 @@ type DeepMergeRecordsDefaultHKTInternalPropValueHelper< */ export type DeepMergeArraysDefaultHKT< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs -> = DeepMergeArraysDefaultHKTHelper; + MF extends DeepMergeMergeFunctionsURIs, + M +> = DeepMergeArraysDefaultHKTHelper; /** * Tail-recursive helper type for DeepMergeArraysDefaultHKT. @@ -128,6 +141,7 @@ export type DeepMergeArraysDefaultHKT< type DeepMergeArraysDefaultHKTHelper< Ts extends ReadonlyArray, MF extends DeepMergeMergeFunctionsURIs, + M, Acc extends ReadonlyArray > = Ts extends readonly [infer Head, ...infer Rest] ? Head extends ReadonlyArray @@ -135,7 +149,7 @@ type DeepMergeArraysDefaultHKTHelper< ReadonlyArray, ...ReadonlyArray> ] - ? DeepMergeArraysDefaultHKTHelper + ? DeepMergeArraysDefaultHKTHelper : [...Acc, ...Head] : never : never; @@ -164,35 +178,35 @@ export type GetDeepMergeMergeFunctionsURIs< // prettier-ignore DeepMergeRecordsURI: // eslint-disable-next-line @typescript-eslint/no-explicit-any - PMF["DeepMergeRecordsURI"] extends keyof DeepMergeMergeFunctionURItoKind + PMF["DeepMergeRecordsURI"] extends keyof DeepMergeMergeFunctionURItoKind ? PMF["DeepMergeRecordsURI"] : DeepMergeRecordsDefaultURI; // prettier-ignore DeepMergeArraysURI: // eslint-disable-next-line @typescript-eslint/no-explicit-any - PMF["DeepMergeArraysURI"] extends keyof DeepMergeMergeFunctionURItoKind + PMF["DeepMergeArraysURI"] extends keyof DeepMergeMergeFunctionURItoKind ? PMF["DeepMergeArraysURI"] : DeepMergeArraysDefaultURI; // prettier-ignore DeepMergeSetsURI: // eslint-disable-next-line @typescript-eslint/no-explicit-any - PMF["DeepMergeSetsURI"] extends keyof DeepMergeMergeFunctionURItoKind + PMF["DeepMergeSetsURI"] extends keyof DeepMergeMergeFunctionURItoKind ? PMF["DeepMergeSetsURI"] : DeepMergeSetsDefaultURI; // prettier-ignore DeepMergeMapsURI: // eslint-disable-next-line @typescript-eslint/no-explicit-any - PMF["DeepMergeMapsURI"] extends keyof DeepMergeMergeFunctionURItoKind + PMF["DeepMergeMapsURI"] extends keyof DeepMergeMergeFunctionURItoKind ? PMF["DeepMergeMapsURI"] : DeepMergeMapsDefaultURI; // prettier-ignore DeepMergeOthersURI: // eslint-disable-next-line @typescript-eslint/no-explicit-any - PMF["DeepMergeOthersURI"] extends keyof DeepMergeMergeFunctionURItoKind + PMF["DeepMergeOthersURI"] extends keyof DeepMergeMergeFunctionURItoKind ? PMF["DeepMergeOthersURI"] : DeepMergeLeafURI; }>; diff --git a/src/types/merging.ts b/src/types/merging.ts index f60dc08e8..64b4b440b 100644 --- a/src/types/merging.ts +++ b/src/types/merging.ts @@ -19,11 +19,12 @@ import type { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export interface DeepMergeMergeFunctionURItoKind< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs + MF extends DeepMergeMergeFunctionsURIs, + M > { readonly DeepMergeLeafURI: DeepMergeLeaf; - readonly DeepMergeRecordsDefaultURI: DeepMergeRecordsDefaultHKT; - readonly DeepMergeArraysDefaultURI: DeepMergeArraysDefaultHKT; + readonly DeepMergeRecordsDefaultURI: DeepMergeRecordsDefaultHKT; + readonly DeepMergeArraysDefaultURI: DeepMergeArraysDefaultHKT; readonly DeepMergeSetsDefaultURI: DeepMergeSetsDefaultHKT; readonly DeepMergeMapsDefaultURI: DeepMergeMapsDefaultHKT; } @@ -34,15 +35,17 @@ export interface DeepMergeMergeFunctionURItoKind< type DeepMergeMergeFunctionKind< URI extends DeepMergeMergeFunctionURIs, Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs -> = DeepMergeMergeFunctionURItoKind[URI]; + MF extends DeepMergeMergeFunctionsURIs, + M +> = DeepMergeMergeFunctionURItoKind[URI]; /** * A union of all valid merge function URIs. */ type DeepMergeMergeFunctionURIs = keyof DeepMergeMergeFunctionURItoKind< ReadonlyArray, - DeepMergeMergeFunctionsURIs + DeepMergeMergeFunctionsURIs, + unknown >; /** @@ -80,21 +83,22 @@ export type DeepMergeMergeFunctionsURIs = Readonly<{ */ export type DeepMergeHKT< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs + MF extends DeepMergeMergeFunctionsURIs, + M > = IsTuple extends true ? Ts extends readonly [] ? undefined : Ts extends readonly [infer T1] ? T1 : EveryIsArray extends true - ? DeepMergeArraysHKT + ? DeepMergeArraysHKT : EveryIsMap extends true - ? DeepMergeMapsHKT + ? DeepMergeMapsHKT : EveryIsSet extends true - ? DeepMergeSetsHKT + ? DeepMergeSetsHKT : EveryIsRecord extends true - ? DeepMergeRecordsHKT - : DeepMergeOthersHKT + ? DeepMergeRecordsHKT + : DeepMergeOthersHKT : unknown; /** @@ -102,40 +106,45 @@ export type DeepMergeHKT< */ type DeepMergeRecordsHKT< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs -> = DeepMergeMergeFunctionKind; + MF extends DeepMergeMergeFunctionsURIs, + M +> = DeepMergeMergeFunctionKind; /** * Deep merge arrays. */ type DeepMergeArraysHKT< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs -> = DeepMergeMergeFunctionKind; + MF extends DeepMergeMergeFunctionsURIs, + M +> = DeepMergeMergeFunctionKind; /** * Deep merge sets. */ type DeepMergeSetsHKT< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs -> = DeepMergeMergeFunctionKind; + MF extends DeepMergeMergeFunctionsURIs, + M +> = DeepMergeMergeFunctionKind; /** * Deep merge maps. */ type DeepMergeMapsHKT< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs -> = DeepMergeMergeFunctionKind; + MF extends DeepMergeMergeFunctionsURIs, + M +> = DeepMergeMergeFunctionKind; /** * Deep merge other things. */ type DeepMergeOthersHKT< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs -> = DeepMergeMergeFunctionKind; + MF extends DeepMergeMergeFunctionsURIs, + M +> = DeepMergeMergeFunctionKind; /** * The merge function that returns a leaf. @@ -165,3 +174,11 @@ export type DeepMergeLeaf> = : never : Tail : never; + +/** + * The meta data deepmerge is able to provide. + */ +export type DeepMergeBuiltInMetaData = Readonly<{ + key: PropertyKey; + parents: ReadonlyArray>>; +}>; diff --git a/src/types/options.ts b/src/types/options.ts index 4180b1ef5..14490a7cb 100644 --- a/src/types/options.ts +++ b/src/types/options.ts @@ -1,71 +1,97 @@ import type { DeepMergeMergeFunctionsDefaults } from "@/deepmerge"; +import type { DeepMergeBuiltInMetaData } from "./merging"; + /** * The options the user can pass to customize deepmerge. */ -export type DeepMergeOptions = Partial; +export type DeepMergeOptions< + M, + MM extends Record = DeepMergeBuiltInMetaData +> = Partial>>; + +type MetaDataUpdater> = ( + previousMeta: M | undefined, + metaMeta: MM +) => M; /** * All the options the user can pass to customize deepmerge. */ -type DeepMergeOptionsFull = Readonly<{ - mergeRecords: DeepMergeMergeFunctions["mergeRecords"] | false; - mergeArrays: DeepMergeMergeFunctions["mergeArrays"] | false; - mergeMaps: DeepMergeMergeFunctions["mergeMaps"] | false; - mergeSets: DeepMergeMergeFunctions["mergeSets"] | false; - mergeOthers: DeepMergeMergeFunctions["mergeOthers"]; +type DeepMergeOptionsFull< + M, + MM extends Record +> = Readonly<{ + mergeRecords: DeepMergeMergeFunctions["mergeRecords"] | false; + mergeArrays: DeepMergeMergeFunctions["mergeArrays"] | false; + mergeMaps: DeepMergeMergeFunctions["mergeMaps"] | false; + mergeSets: DeepMergeMergeFunctions["mergeSets"] | false; + mergeOthers: DeepMergeMergeFunctions["mergeOthers"]; + metaDataUpdater: MetaDataUpdater; }>; /** * All the merge functions that deepmerge uses. */ -type DeepMergeMergeFunctions = Readonly<{ +type DeepMergeMergeFunctions< + M, + MM extends Record +> = Readonly<{ mergeRecords: < Ts extends ReadonlyArray>>, - U extends DeepMergeMergeFunctionUtils + U extends DeepMergeMergeFunctionUtils >( records: Ts, - utils: U + utils: U, + meta: M | undefined ) => unknown; mergeArrays: < Ts extends ReadonlyArray>, - U extends DeepMergeMergeFunctionUtils + U extends DeepMergeMergeFunctionUtils >( records: Ts, - utils: U + utils: U, + meta: M | undefined ) => unknown; mergeMaps: < Ts extends ReadonlyArray>>, - U extends DeepMergeMergeFunctionUtils + U extends DeepMergeMergeFunctionUtils >( records: Ts, - utils: U + utils: U, + meta: M | undefined ) => unknown; mergeSets: < Ts extends ReadonlyArray>>, - U extends DeepMergeMergeFunctionUtils + U extends DeepMergeMergeFunctionUtils >( records: Ts, - utils: U + utils: U, + meta: M | undefined ) => unknown; mergeOthers: < Ts extends ReadonlyArray, - U extends DeepMergeMergeFunctionUtils + U extends DeepMergeMergeFunctionUtils >( records: Ts, - utils: U + utils: U, + meta: M | undefined ) => unknown; }>; /** * The utils provided to the merge functions. */ -export type DeepMergeMergeFunctionUtils = Readonly<{ - mergeFunctions: DeepMergeMergeFunctions; +export type DeepMergeMergeFunctionUtils< + M, + MM extends Record +> = Readonly<{ + mergeFunctions: DeepMergeMergeFunctions; defaultMergeFunctions: DeepMergeMergeFunctionsDefaults; + metaDataUpdater: MetaDataUpdater; deepmerge: >(...values: Ts) => unknown; }>; diff --git a/tests/.eslintrc.json b/tests/.eslintrc.json index e6045184f..c3c46e72e 100644 --- a/tests/.eslintrc.json +++ b/tests/.eslintrc.json @@ -24,6 +24,8 @@ "functional/prefer-readonly-type": "off", "import/no-relative-parent-imports": "off", "jsdoc/require-jsdoc": "off", - "sonarjs/no-duplicate-string": "off" + "sonarjs/no-duplicate-string": "off", + "sonarjs/no-identical-functions": "off", + "unicorn/consistent-function-scoping": "off" } } diff --git a/tests/deepmerge-custom.test.ts b/tests/deepmerge-custom.test.ts index f65018d78..f3bbd5341 100644 --- a/tests/deepmerge-custom.test.ts +++ b/tests/deepmerge-custom.test.ts @@ -8,8 +8,11 @@ import type { DeepMergeMergeFunctionsURIs, DeepMergeRecordsDefaultHKT, DeepMergeLeaf, + DeepMergeOptions, } from "@/deepmerge"; +import { areAllNumbers, hasProp } from "./utils"; + declare module "ava" { interface DeepEqualAssertion { /** @@ -69,7 +72,8 @@ test("custom merge strings", (t) => { declare module "../src/types" { interface DeepMergeMergeFunctionURItoKind< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs + MF extends DeepMergeMergeFunctionsURIs, + M > { readonly CustomArrays1: string[]; } @@ -113,7 +117,8 @@ test("custom merge arrays", (t) => { declare module "../src/types" { interface DeepMergeMergeFunctionURItoKind< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs + MF extends DeepMergeMergeFunctionsURIs, + M > { readonly CustomArrays2: Ts extends Readonly ? Es extends ReadonlyArray @@ -187,9 +192,10 @@ test("custom merge arrays of records", (t) => { declare module "../src/types" { interface DeepMergeMergeFunctionURItoKind< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs + MF extends DeepMergeMergeFunctionsURIs, + M > { - readonly CustomRecords3: Entries>; + readonly CustomRecords3: Entries>; } } @@ -224,8 +230,10 @@ test("custom merge records", (t) => { const customizedDeepmerge = deepmergeCustom<{ DeepMergeRecordsURI: "CustomRecords3"; }>({ - mergeRecords: (records, utils) => - Object.entries(utils.defaultMergeFunctions.mergeRecords(records, utils)), + mergeRecords: (records, utils, meta) => + Object.entries( + utils.defaultMergeFunctions.mergeRecords(records, utils, meta) + ), }); const merged = customizedDeepmerge(x, y); @@ -236,7 +244,8 @@ test("custom merge records", (t) => { declare module "../src/types" { interface DeepMergeMergeFunctionURItoKind< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs + MF extends DeepMergeMergeFunctionsURIs, + M > { readonly NoArrayMerge1: DeepMergeLeaf; } @@ -264,7 +273,8 @@ test("custom don't merge arrays", (t) => { declare module "../src/types" { interface DeepMergeMergeFunctionURItoKind< Ts extends ReadonlyArray, - MF extends DeepMergeMergeFunctionsURIs + MF extends DeepMergeMergeFunctionsURIs, + M > { readonly MergeDates1: EveryIsDate extends true ? Ts : DeepMergeLeaf; } @@ -300,3 +310,256 @@ test("custom merge dates", (t) => { t.deepEqual(merged, expected); }); + +test("key based merging", (t) => { + const v = { sum: 1, product: 2, mean: 3 }; + const x = { sum: 4, product: 5, mean: 6 }; + const y = { sum: 7, product: 8, mean: 9 }; + const z = { sum: 10, product: 11, mean: 12 }; + + const expected = { + sum: 22, + product: 880, + mean: 7.5, + }; + + const customizedDeepmerge = deepmergeCustom({ + mergeOthers: (values, utils, meta) => { + if (meta !== undefined && areAllNumbers(values)) { + const { key } = meta; + const numbers: ReadonlyArray = values; + + if (key === "sum") { + return numbers.reduce((sum, value) => sum + value); + } + if (key === "product") { + return numbers.reduce((prod, value) => prod * value); + } + if (key === "mean") { + return numbers.reduce((sum, value) => sum + value) / numbers.length; + } + } + + return utils.defaultMergeFunctions.mergeOthers(values); + }, + }); + + const merged = customizedDeepmerge(v, x, y, z); + + t.deepEqual(merged, expected); +}); + +declare module "../src/types" { + interface DeepMergeMergeFunctionURItoKind< + Ts extends Readonly>, + MF extends DeepMergeMergeFunctionsURIs, + M + > { + readonly KeyPathBasedMerge: Ts[number] extends number + ? Ts[number] | string + : DeepMergeLeaf; + } +} + +test("key path based merging", (t) => { + const x = { + foo: { bar: { baz: 1, qux: 2 } }, + bar: { baz: 3, qux: 4 }, + }; + const y = { + foo: { bar: { baz: 5, bar: { baz: 6, qux: 7 } } }, + bar: { baz: 8, qux: 9 }, + }; + + const expected = { + foo: { bar: { baz: "special merge", bar: { baz: 6, qux: 7 }, qux: 2 } }, + bar: { baz: "special merge", qux: 9 }, + }; + + const customizedDeepmerge = deepmergeCustom< + { + DeepMergeOthersURI: "KeyPathBasedMerge"; + }, + ReadonlyArray + >({ + metaDataUpdater: (previousMeta = [], metaMeta) => { + return [...previousMeta, metaMeta.key]; + }, + mergeOthers: (values, utils, meta) => { + if ( + meta !== undefined && + meta.length >= 2 && + meta[meta.length - 2] === "bar" && + meta[meta.length - 1] === "baz" + ) { + return "special merge"; + } + + return utils.defaultMergeFunctions.mergeOthers(values); + }, + }); + + const merged = customizedDeepmerge(x, y); + + t.deepEqual(merged, expected); +}); + +test("key path based array merging", (t) => { + const x = { + foo: [ + { id: 1, value: ["a"] }, + { id: 2, value: ["b"] }, + ], + bar: [1, 2, 3], + baz: { + qux: [ + { id: 1, value: ["c"] }, + { id: 2, value: ["d"] }, + ], + }, + qux: [ + { id: 1, value: ["e"] }, + { id: 2, value: ["f"] }, + ], + }; + const y = { + foo: [ + { id: 2, value: ["g"] }, + { id: 1, value: ["h"] }, + ], + bar: [4, 5, 6], + baz: { + qux: [ + { id: 2, value: ["i"] }, + { id: 1, value: ["j"] }, + ], + }, + qux: [ + { id: 2, value: ["k"] }, + { id: 1, value: ["l"] }, + ], + }; + + const expected = { + foo: [ + { id: 1, value: ["a", "h"] }, + { id: 2, value: ["b", "g"] }, + ], + bar: [1, 2, 3, 4, 5, 6], + baz: { + qux: [ + { id: 1, value: ["c", "j"] }, + { id: 2, value: ["d", "i"] }, + ], + }, + qux: [ + { id: 1, value: ["e"] }, + { id: 2, value: ["f"] }, + { id: 2, value: ["k"] }, + { id: 1, value: ["l"] }, + ], + }; + + const customizedDeepmergeEntry = ( + ...idsPaths: ReadonlyArray> + ) => { + const mergeSettings: DeepMergeOptions< + ReadonlyArray, + Readonly> + > = { + metaDataUpdater: (previousMeta = [], metaMeta) => { + return [...previousMeta, metaMeta.key ?? metaMeta.id]; + }, + mergeArrays: (values, utils, meta = []) => { + const idPath = idsPaths.find((idPath) => { + const parentPath = idPath.slice(0, -1); + return ( + parentPath.length === meta.length && + parentPath.every((part, i) => part === meta[i]) + ); + }); + if (idPath === undefined) { + return utils.defaultMergeFunctions.mergeArrays(values); + } + + const id = idPath[idPath.length - 1]; + const valuesById = values.reduce>( + (carry, current) => { + const currentElementsById = new Map(); + for (const element of current) { + if (!hasProp(element, id)) { + throw new Error("Invalid element type"); + } + if (currentElementsById.has(element[id])) { + throw new Error("multiple elements with the same id"); + } + currentElementsById.set(element[id], element); + + const currentList = carry.get(element[id]) ?? []; + carry.set(element[id], [...currentList, element]); + } + return carry; + }, + new Map() + ); + + return [...valuesById.entries()].reduce( + (carry, [id, values]) => { + const childMeta = utils.metaDataUpdater(meta, { id }); + return [ + ...carry, + deepmergeCustom(mergeSettings, childMeta)(...values), + ]; + }, + [] + ); + }, + }; + + return deepmergeCustom(mergeSettings, []); + }; + + const merged = customizedDeepmergeEntry(["foo", "id"], ["baz", "qux", "id"])( + x, + y + ); + + t.deepEqual(merged, expected); +}); + +test("custom merge with parents", (t) => { + const v = { sum: 1, isBadObject: true }; + const x = { sum: 2, isBadObject: false }; + const y = { sum: 3, isBadObject: true }; + const z = { sum: 4, isBadObject: false }; + + const expected = { + sum: 6, + isBadObject: false, + }; + + const customizedDeepmerge = deepmergeCustom({ + mergeOthers: (values, utils, meta) => { + if (meta !== undefined) { + const { key, parents } = meta; + if (key === "isBadObject") { + return false; + } + + const goodValues = values.filter( + (value, index): value is number => + parents[index].isBadObject !== true && typeof value === "number" + ); + + if (key === "sum") { + return goodValues.reduce((sum, value) => sum + value, 0); + } + } + return utils.defaultMergeFunctions.mergeOthers(values); + }, + }); + + const merged = customizedDeepmerge(v, x, y, z); + + t.deepEqual(merged, expected); +}); diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 000000000..4d588f5ac --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,12 @@ +export function areAllNumbers( + values: ReadonlyArray +): values is ReadonlyArray { + return values.every((value) => typeof value === "number"); +} + +export function hasProp( + value: T, + prop: K +): value is T & Record { + return typeof value === "object" && value !== null && prop in value; +} diff --git a/types-legacy/v4_0.d.ts b/types-legacy/v4_0.d.ts index 40b032be0..7a30a0a16 100644 --- a/types-legacy/v4_0.d.ts +++ b/types-legacy/v4_0.d.ts @@ -5,6 +5,8 @@ * designed to work with versions of TypeScript < 4.1 */ +declare type MetaDataUpdater = (previousMeta: any, metaMeta: any) => any; + /** * All the options the user can pass to customize deepmerge. */ @@ -14,17 +16,18 @@ declare type DeepMergeOptionsFull = Readonly<{ mergeMaps: DeepMergeMergeFunctions["mergeMaps"] | false; mergeSets: DeepMergeMergeFunctions["mergeSets"] | false; mergeOthers: DeepMergeMergeFunctions["mergeOthers"]; + metaDataUpdater: MetaDataUpdater; }>; /** * All the merge functions that deepmerge uses. */ declare type DeepMergeMergeFunctions = Readonly<{ - mergeRecords: >>>, U extends DeepMergeMergeFunctionUtils>(records: Ts, utils: U) => any; - mergeArrays: >>>, U extends DeepMergeMergeFunctionUtils>(records: Ts, utils: U) => any; - mergeMaps: >>>, U extends DeepMergeMergeFunctionUtils>(records: Ts, utils: U) => any; - mergeSets: >>>, U extends DeepMergeMergeFunctionUtils>(records: Ts, utils: U) => any; - mergeOthers: >, U extends DeepMergeMergeFunctionUtils>(records: Ts, utils: U) => any; + mergeRecords: >>, U extends DeepMergeMergeFunctionUtils>(records: Ts, utils: U, meta: any) => any; + mergeArrays: >, U extends DeepMergeMergeFunctionUtils>(records: Ts, utils: U, meta: any) => any; + mergeMaps: >>, U extends DeepMergeMergeFunctionUtils>(records: Ts, utils: U, meta: any) => any; + mergeSets: >>, U extends DeepMergeMergeFunctionUtils>(records: Ts, utils: U, meta: any) => any; + mergeOthers: , U extends DeepMergeMergeFunctionUtils>(records: Ts, utils: U, meta: any) => any; }>; /** @@ -33,18 +36,19 @@ declare type DeepMergeMergeFunctions = Readonly<{ declare type DeepMergeMergeFunctionUtils = Readonly<{ mergeFunctions: DeepMergeMergeFunctions; defaultMergeFunctions: DeepMergeMergeFunctionsDefaults; - deepmerge: >>(...values: Ts) => any; + metaDataUpdater: MetaDataUpdater; + deepmerge: >(...values: Ts) => any; }>; /** * The default merge functions. */ declare type DeepMergeMergeFunctionsDefaults = Readonly<{ - mergeMaps: (values: Record[], utils: DeepMergeMergeFunctionUtils) => any; - mergeSets: (values: any[][], utils: DeepMergeMergeFunctionUtils) => any; - mergeArrays: (values: Set[], utils: DeepMergeMergeFunctionUtils) => any; - mergeRecords: (values: Map[], utils: DeepMergeMergeFunctionUtils) => any; - mergeOthers: (values: any[], utils: DeepMergeMergeFunctionUtils) => any; + mergeMaps: (values: Record[]) => any; + mergeSets: (values: any[][]) => any; + mergeArrays: (values: Set[]) => any; + mergeRecords: (values: Map[], utils: DeepMergeMergeFunctionUtils, meta: any) => any; + mergeOthers: (values: any[]) => any; }>; /** @@ -66,10 +70,11 @@ declare function deepmerge(arg0: T0, arg declare function deepmerge(...args: any[]): any; /** - * Deeply merge two or more objects using the given options. + * Deeply merge two or more objects using the given options and meta data. * * @param options - The options on how to customize the merge function. + * @param rootMetaData - The meta data passed to the root items' being merged. */ -declare function deepmergeCustom(options: Partial): (...objects: any[]) => any; +declare function deepmergeCustom(options: Partial, rootMetaData?: any): (...objects: any[]) => any; export { deepmerge, deepmergeCustom };