From e192118725169bcb514bbe5b2d6f0992cdba2416 Mon Sep 17 00:00:00 2001 From: Rebecca Stevens Date: Thu, 10 Feb 2022 23:23:27 +1300 Subject: [PATCH] feat: provide meta data to custom merge functions and allow it to be customized fix #33 --- docs/API.md | 18 ++- docs/deepmergeCustom.md | 126 ++++++++++++++++- src/deepmerge.ts | 169 +++++++++++++++++------ src/types/defaults.ts | 50 ++++--- src/types/merging.ts | 60 +++++--- src/types/options.ts | 66 ++++++--- tests/.eslintrc.json | 4 +- tests/deepmerge-custom.test.ts | 244 +++++++++++++++++++++++++++++++-- tests/utils.ts | 12 ++ 9 files changed, 627 insertions(+), 122 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..88f9ce935 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,123 @@ 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 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 (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 accumulate 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. + { + key?: PropertyKey; + keyPath: ReadonlyArray; + } +>( + { + // Customize what the actual meta data. + metaDataUpdater: (previousMeta, metaMeta) => { + return { + ...metaMeta, + keyPath: [...previousMeta.keyPath, metaMeta.key], + }; + }, + // Use the meta data when merging others. + mergeOthers: (values, utils, meta) => { + if ( + 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); + }, + }, + // Provide initial meta data. + { + keyPath: [], + } +); + +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: M extends Readonly<{ + keyPath: ReadonlyArray; + }> + ? Ts[number] | string + : DeepMergeLeaf; + } +} +``` + ## API [See deepmerge custom API](./API.md#deepmergecustomoptions). diff --git a/src/deepmerge.ts b/src/deepmerge.ts index b6eee50ac..9c11bfd2e 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,53 @@ 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 Partial = DeepMergeBuiltInMetaData +>( + options: DeepMergeOptions, + rootMetaData: MetaData +): >( + ...objects: Ts +) => DeepMergeHKT, MetaData>; + +export function deepmergeCustom< + PMF extends Partial, + MetaData, + MetaMetaData extends Partial +>( + 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 +128,13 @@ export function deepmergeCustom< return objects[0]; } - return mergeUnknowns(objects, utils); + return mergeUnknowns< + ReadonlyArray, + typeof utils, + GetDeepMergeMergeFunctionsURIs, + MetaData, + MetaMetaData + >(objects, utils, rootMetaData as MetaData); } return customizedDeepmerge as CustomizedDeepmerge; @@ -88,20 +145,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 DeepMergeMergeFunctionUtils< + M, + MM + >["metaDataUpdater"], deepmerge: customizedDeepmerge, }; } @@ -113,9 +179,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 Partial +>(values: Ts, utils: U, meta: M): DeepMergeHKT { const type = getObjectType(values[0]); // eslint-disable-next-line functional/no-conditional-statement -- add an early escape for better performance. @@ -126,10 +194,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 +206,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 +247,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) { const result: Record = {}; /* eslint-disable functional/no-loop-statement, functional/no-conditional-statement -- using a loop here is more performant. */ @@ -191,15 +267,21 @@ function mergeRecords< // assert(propValues.length > 0); + const updatedMeta = utils.metaDataUpdater(meta, { key } 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 +291,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/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 a79eae0c4..6f991eb6d 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: DeepMergeLeafHKT; - 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,10 @@ export type DeepMergeLeaf> = : never : Tail : never; + +/** + * The meta data deepmerge is able to provide. + */ +export type DeepMergeBuiltInMetaData = Readonly<{ + key: PropertyKey; +}>; diff --git a/src/types/options.ts b/src/types/options.ts index 4180b1ef5..b9b442cc1 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 Partial = DeepMergeBuiltInMetaData +> = Partial>; + +type MetaDataUpdater> = ( + previousMeta: M, + 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 Partial +> = 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 Partial +> = Readonly<{ mergeRecords: < Ts extends ReadonlyArray>>, - U extends DeepMergeMergeFunctionUtils + U extends DeepMergeMergeFunctionUtils >( records: Ts, - utils: U + utils: U, + meta: M ) => unknown; mergeArrays: < Ts extends ReadonlyArray>, - U extends DeepMergeMergeFunctionUtils + U extends DeepMergeMergeFunctionUtils >( records: Ts, - utils: U + utils: U, + meta: M ) => unknown; mergeMaps: < Ts extends ReadonlyArray>>, - U extends DeepMergeMergeFunctionUtils + U extends DeepMergeMergeFunctionUtils >( records: Ts, - utils: U + utils: U, + meta: M ) => unknown; mergeSets: < Ts extends ReadonlyArray>>, - U extends DeepMergeMergeFunctionUtils + U extends DeepMergeMergeFunctionUtils >( records: Ts, - utils: U + utils: U, + meta: M ) => unknown; mergeOthers: < Ts extends ReadonlyArray, - U extends DeepMergeMergeFunctionUtils + U extends DeepMergeMergeFunctionUtils >( records: Ts, - utils: U + utils: U, + meta: M ) => unknown; }>; /** * The utils provided to the merge functions. */ -export type DeepMergeMergeFunctionUtils = Readonly<{ - mergeFunctions: DeepMergeMergeFunctions; +export type DeepMergeMergeFunctionUtils< + M, + MM extends Partial +> = 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..65b977f66 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,221 @@ 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 (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: M extends ReadonlyArray + ? 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.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); +}); 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; +}