From 9c350a051c16534907da459ff466a353b90d5505 Mon Sep 17 00:00:00 2001 From: Rebecca Stevens Date: Sun, 5 Feb 2023 20:06:12 +1300 Subject: [PATCH] feat: create deepmergeInto function fix #51 --- .cspell.json | 35 +- .eslintrc.json | 2 +- .markdownlint.json | 2 +- README.md | 17 +- docs/API.md | 106 ++++- docs/deepmergeCustom.md | 75 +++- package.json | 4 +- src/actions.ts | 14 + src/deepmerge-into.ts | 394 ++++++++++++++++++ src/deepmerge.ts | 161 +------- src/defaults/into.ts | 133 ++++++ src/defaults/meta-data-updater.ts | 11 + src/defaults/vanilla.ts | 134 ++++++ src/index.ts | 7 +- src/types/options.ts | 119 +++++- src/types/utils.ts | 4 +- tests/deepmerge-custom.test.ts | 2 +- tests/deepmerge-into-custom.test.ts | 560 +++++++++++++++++++++++++ tests/deepmerge-into.test-d.ts | 83 ++++ tests/deepmerge-into.test.ts | 616 ++++++++++++++++++++++++++++ tests/deepmerge.test.ts | 20 +- yarn.lock | 20 +- 22 files changed, 2299 insertions(+), 220 deletions(-) create mode 100644 src/actions.ts create mode 100644 src/deepmerge-into.ts create mode 100644 src/defaults/into.ts create mode 100644 src/defaults/meta-data-updater.ts create mode 100644 src/defaults/vanilla.ts create mode 100644 tests/deepmerge-into-custom.test.ts create mode 100644 tests/deepmerge-into.test-d.ts create mode 100644 tests/deepmerge-into.test.ts diff --git a/.cspell.json b/.cspell.json index e8ecff86..8f889176 100644 --- a/.cspell.json +++ b/.cspell.json @@ -36,33 +36,34 @@ "/[A-Za-z0-9]{32,}/" ], "words": [ + "bar", + "baz", + "builtins", + "codesandbox", + "corge", + "customizer", "deepmerge", "deepmergecustomoptions", "deepmergets", + "denoify", + "foo", + "fred", + "garply", + "grault", "HKT", "HKTs", "kinded", - "typeguard", - "typeguards", - "sonarjs", - "denoify", "litecoin", "monero", - "codesandbox", - "builtins", - "foo", - "bar", - "baz", - "qux", - "quux", - "corge", - "grault", - "garply", - "waldo", - "fred", "plugh", - "xyzzy", + "quux", + "qux", + "sonarjs", "thud", + "typeguard", + "typeguards", + "waldo", + "xyzzy" ], "overrides": [ { diff --git a/.eslintrc.json b/.eslintrc.json index 9841d213..ecb66f1b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -31,7 +31,7 @@ "/**/*.md" ], "rules": { - "import/no-relative-parent-imports": "error", + "import/no-relative-parent-imports": "off", "node/no-extraneous-import": ["error", { "allowModules": ["deepmerge-ts"] }], diff --git a/.markdownlint.json b/.markdownlint.json index 76b1102a..bfaa9566 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -38,7 +38,7 @@ // MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content "MD024": { "siblings_only": true }, // MD025/single-title/single-h1 - Multiple top level headings in the same document - "MD025": true, + "MD025": false, // MD026/no-trailing-punctuation - Trailing punctuation in heading "MD026": true, // MD027/no-multiple-space-blockquote - Multiple spaces after blockquote symbol diff --git a/README.md b/README.md index 0235e0ef..0100a4d6 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,20 @@ console.log(merged); You can try out this example at [codesandbox.io](https://codesandbox.io/s/deepmerge-ts-example-iltxby?file=/src/example.ts). -### Using customized config +### Merging into a Target -[See deepmerge custom docs](./docs/deepmergeCustom.md). +You can use `deepmergeInto` if you want to update a target object with the merge result instead of creating a new object. + +This function is best used with objects that are all of the same type. + +Note: If the target object's type is different to the input objects, we'll assert that the target's type has changed (this is not done automatically with `deepmergeIntoCustom`). + +### Customized the Merging Process + +We provide a customizer function for each of our main deepmerge functions: `deepmergeCustom` and `deepmergeIntoCustom`. +You can use these to customize the details of how values should be merged together. + +See [deepmerge custom docs](./docs/deepmergeCustom.md) for more details. ## Performance @@ -140,4 +151,4 @@ In addition to performance improvements, this strategy merges multiple inputs at ## API -[See API docs](./docs/API.md). +See [API docs](./docs/API.md). diff --git a/docs/API.md b/docs/API.md index 66b27551..9b67fe9b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -10,9 +10,13 @@ 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`. +## deepmergeInto(target, value, ...) + +Mutate the target by merging the other inputs into it using the default configuration. + ## 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. +Generate a customized `deepmerge` function using the given options. The returned function works just like `deepmerge` except it uses the customized configuration. ### options @@ -23,7 +27,7 @@ All these options are optional. 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. +If `false`, records won't be merged. If set to a function, that function will be used to merge records. Note: Records are "vanilla" objects (e.g. `{ foo: "hello", bar: "world" }`). @@ -31,19 +35,19 @@ Note: Records are "vanilla" objects (e.g. `{ foo: "hello", bar: "world" }`). 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. +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, meta: MetaData) => unknown` -If false, maps won't be merged. If set to a function, that function will be used to merge maps. +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, meta: MetaData) => unknown` -If false, sets won't be merged. If set to a function, that function will be used to merge sets. +If `false`, sets won't be merged. If set to a function, that function will be used to merge sets. #### `mergeOthers` @@ -70,10 +74,98 @@ These will be the custom merge functions you gave, or the default merge function #### `defaultMergeFunctions` -These are all the merge functions that the default, non-customize deepmerge function uses. +These are all the merge functions that the default, non-customized `deepmerge` function uses. + +#### `metaDataUpdater` + +This function is used to update the meta data. Call it with the new meta data when/where applicable. #### `deepmerge` -This is your top level customized deepmerge function. +This is your top level customized `deepmerge` function. + +Note: Be careful when calling this as it is really easy to end up in an infinite loop. + +#### `useImplicitDefaultMerging` + +States whether or not implicit default merging is in use. + +#### `actions` + +Contains symbols that can be used to tell `deepmerge-ts` to perform a special action. + +## deepmergeIntoCustom(options[, rootMetaData]) + +Generate a customized `deepmergeInto` function using the given options. The returned function works just like `deepmergeInto` except it uses the customized configuration. + +### options + +The following options can be used to customize the deepmerge function.\ +All these options are optional. + +#### `mergeRecords` + +Type: `false | (target: DeepMergeValueReference>, values: Record[], utils: DeepMergeMergeFunctionUtils, meta: MetaData) => void | symbol` + +If `false`, records won't be merged. If set to a function, that function will be used to merge records by mutating `target.value`. + +Note: Records are "vanilla" objects (e.g. `{ foo: "hello", bar: "world" }`). + +#### `mergeArrays` + +Type: `false | (target: DeepMergeValueReference, values: unknown[][], utils: DeepMergeMergeIntoFunctionUtils, meta: MetaData) => void | symbol` + +If `false`, arrays won't be merged. If set to a function, that function will be used to merge arrays by mutating `target.value`. + +#### `mergeMaps` + +Type: `false | (target: DeepMergeValueReference>, values: Map[], utils: DeepMergeMergeIntoFunctionUtils, meta: MetaData) => void | symbol` + +If `false`, maps won't be merged. If set to a function, that function will be used to merge maps by mutating `target.value`. + +#### `mergeSets` + +Type: `false | (target: DeepMergeValueReference>, values: Set[], utils: DeepMergeMergeIntoFunctionUtils, meta: MetaData) => void | symbol` + +If `false`, sets won't be merged. If set to a function, that function will be used to merge sets by mutating `target.value`. + +#### `mergeOthers` + +Type: `(target: DeepMergeValueReference, values: unknown[], utils: DeepMergeMergeIntoFunctionUtils, meta: MetaData) => void | symbol` + +If set to a function, that function will be used to merge everything else by mutating `target.value`. + +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. + +### DeepMergeMergeIntoFunctionUtils + +This is a set of utility functions that are made available to your custom merge functions. + +#### `mergeFunctions` + +These are all the merge function being used to perform the deepmerge.\ +These will be the custom merge functions you gave, or the default merge functions for options you didn't customize. + +#### `defaultMergeFunctions` + +These are all the merge functions that the default, non-customized `deepmerge` function uses. + +#### `metaDataUpdater` + +This function is used to update the meta data. Call it with the new meta data when/where applicable. + +#### `deepmergeInto` + +This is your top level customized `deepmergeInto` function. Note: Be careful when calling this as it is really easy to end up in an infinite loop. + +#### `actions` + +Contains symbols that can be used to tell `deepmerge-ts` to perform a special action. diff --git a/docs/deepmergeCustom.md b/docs/deepmergeCustom.md index 0eb1b93d..b7337876 100644 --- a/docs/deepmergeCustom.md +++ b/docs/deepmergeCustom.md @@ -93,7 +93,7 @@ const customizedDeepmerge = deepmergeCustom({ }); ``` -## Customizing the return type +## Customizing the Return Type If you want to customize the deepmerge function, you probably also want the return type of the result to be correct too.\ Unfortunately however, due to TypeScript limitations, we can not automatically infer this. @@ -298,4 +298,75 @@ declare module "../src/types" { ## API -[See deepmerge custom API](./API.md#deepmergecustomoptions-rootmetadata). +See [deepmerge custom API](./API.md#deepmergecustomoptions-rootmetadata). + +# Deepmerge Into Custom + +`deepmergeIntoCustom` as the name suggests, works just like `deepmergeCustom`, only for `deepmergeInto` instead of `deepmerge`. +But there are some differences to be aware of. + +## Merge Functions + +The signature of merging functions for `deepmergeIntoCustom` looks like this: + +```ts +(target: DeepMergeValueReference, values: Ts, utils: U, meta: M | undefined) => void | symbol; +``` + +Instead of returning a value like with `deepmergeCustom`'s merge functions, mutations should be made to `target.value`.\ +You can however still return an action. + +Note: `values` includes all the values, including the target's value (if there is one). + +### Special Actions + +#### No Skip Action (`utils.actions.skip`) + +This action doesn't make sense with in the context of merging into a target. +Use `delete target.value[key]` instead if you don't want the property to exists on the target. + +#### No Implicit Default Merging + +It doesn't make sense to have implicit default merging here as all merge functions should return `undefined` (if not returning an action). + +## Customizing the Return Type + +The return type of a custom `deepmergeInto` should be void, so you don't need to customize it's return type like you would with a regular custom `deepmerge` function. + +However, you may want to use an assertion function if the target's type is not the same as the inputs. +This is by no means required though. +But if you want to do this then you'll simply need to explicity declare a type annotation for your customized `deepmergeInto` function that makes such an assertion. + +Here's an example: + +```ts +type CustomizedDeepmergeInto = < + Target extends object, + Ts extends ReadonlyArray +>( + target: Target, + ...objects: Ts +) => asserts target is Target & // Unioning with `Target` is essentially required to make TypeScript happy. + DeepMergeHKT< + [Target, ...Ts], // Don't forget to pass the `Target` type here too. + { + DeepMergeRecordsURI: DeepMergeMergeFunctionsDefaultURIs["DeepMergeRecordsURI"]; // Use default behavior. + DeepMergeArraysURI: DeepMergeMergeFunctionsDefaultURIs["DeepMergeArraysURI"]; // Use default behavior. + DeepMergeSetsURI: DeepMergeMergeFunctionsDefaultURIs["DeepMergeSetsURI"]; // Use default behavior. + DeepMergeMapsURI: DeepMergeMergeFunctionsDefaultURIs["DeepMergeMapsURI"]; // Use default behavior. + DeepMergeOthersURI: "CustomDeepMergeOthersURI"; // Use custom behavior (see deepmergeCustom's docs above for details). + }, + DeepMergeBuiltInMetaData // Use default meta data. + >; + +export const customizedDeepmergeInto: CustomizedDeepmergeInto = + deepmergeIntoCustom({ + mergeOthers: (source, values, utils, meta) => { + /* ... */ + }, + }); +``` + +## API + +See [deepmerge into custom API](./API.md#deepmergeintocustomoptions-rootmetadata). diff --git a/package.json b/package.json index 71bab8b6..6371f55c 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "@commitlint/cli": "17.4.2", "@commitlint/config-conventional": "17.4.2", "@cspell/dict-cryptocurrencies": "3.0.1", - "@rebeccastevens/eslint-config": "1.5.1", + "@rebeccastevens/eslint-config": "1.5.2", "@rollup/plugin-json": "6.0.0", "@rollup/plugin-node-resolve": "15.0.1", "@rollup/plugin-typescript": "11.0.0", @@ -117,7 +117,7 @@ "eslint-import-resolver-typescript": "3.5.3", "eslint-plugin-ava": "14.0.0", "eslint-plugin-eslint-comments": "3.2.0", - "eslint-plugin-functional": "5.0.3", + "eslint-plugin-functional": "5.0.4", "eslint-plugin-import": "2.27.5", "eslint-plugin-jsdoc": "39.7.5", "eslint-plugin-markdown": "3.0.0", diff --git a/src/actions.ts b/src/actions.ts new file mode 100644 index 00000000..e597db1f --- /dev/null +++ b/src/actions.ts @@ -0,0 +1,14 @@ +/** + * Special values that tell deepmerge to perform a certain action. + */ +export const actions = { + defaultMerge: Symbol("deepmerge-ts: default merge"), + skip: Symbol("deepmerge-ts: skip"), +} as const; + +/** + * Special values that tell deepmergeInto to perform a certain action. + */ +export const actionsInto = { + defaultMerge: actions.defaultMerge, +} as const; diff --git a/src/deepmerge-into.ts b/src/deepmerge-into.ts new file mode 100644 index 00000000..ed929030 --- /dev/null +++ b/src/deepmerge-into.ts @@ -0,0 +1,394 @@ +import { actionsInto as actions } from "./actions.js"; +import * as defaultMergeIntoFunctions from "./defaults/into.js"; +import { defaultMetaDataUpdater } from "./defaults/meta-data-updater.js"; +import type { + DeepMergeBuiltInMetaData, + DeepMergeIntoOptions, + DeepMergeMergeIntoFunctionUtils, + Reference, + DeepMergeHKT, + DeepMergeMergeFunctionsDefaultURIs, +} from "./types/index.js"; +import type { FlatternAlias } from "./types/utils.js"; +import { getObjectType, ObjectType } from "./utils.js"; + +/** + * Deeply merge objects into a target. + * + * @param target - This object will be mutated with the merge result. + * @param objects - The objects to merge into the target. + */ +export function deepmergeInto( + target: T, + ...objects: ReadonlyArray +): void; + +/** + * Deeply merge objects into a target. + * + * @param target - This object will be mutated with the merge result. + * @param objects - The objects to merge into the target. + */ +export function deepmergeInto< + Target extends object, + Ts extends ReadonlyArray +>( + target: Target, + ...objects: Ts +): asserts target is FlatternAlias< + Target & + DeepMergeHKT< + [Target, ...Ts], + DeepMergeMergeFunctionsDefaultURIs, + DeepMergeBuiltInMetaData + > +>; + +export function deepmergeInto< + Target extends object, + Ts extends ReadonlyArray +>( + target: Target, + ...objects: Ts +): asserts target is FlatternAlias< + Target & + DeepMergeHKT< + [Target, ...Ts], + DeepMergeMergeFunctionsDefaultURIs, + DeepMergeBuiltInMetaData + > +> { + return void deepmergeIntoCustom({})(target, ...objects); +} + +/** + * Deeply merge two or more objects using the given options. + * + * @param options - The options on how to customize the merge function. + */ +export function deepmergeIntoCustom( + options: DeepMergeIntoOptions< + DeepMergeBuiltInMetaData, + DeepMergeBuiltInMetaData + > +): >( + target: Target, + ...objects: Ts +) => void; + +/** + * 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 deepmergeIntoCustom< + MetaData, + MetaMetaData extends DeepMergeBuiltInMetaData = DeepMergeBuiltInMetaData +>( + options: DeepMergeIntoOptions, + rootMetaData?: MetaData +): >( + target: Target, + ...objects: Ts +) => void; + +export function deepmergeIntoCustom< + MetaData, + MetaMetaData extends DeepMergeBuiltInMetaData +>( + options: DeepMergeIntoOptions, + rootMetaData?: MetaData +): >( + target: Target, + ...objects: Ts +) => void { + /** + * The type of the customized deepmerge function. + */ + type CustomizedDeepmergeInto = < + Target extends object, + Ts extends ReadonlyArray + >( + target: Target, + ...objects: Ts + ) => void; + + const utils: DeepMergeMergeIntoFunctionUtils = + getIntoUtils(options, customizedDeepmergeInto as CustomizedDeepmergeInto); + + /** + * The customized deepmerge function. + */ + function customizedDeepmergeInto( + target: object, + ...objects: ReadonlyArray + ) { + mergeUnknownsInto< + ReadonlyArray, + typeof utils, + MetaData, + MetaMetaData + >({ value: target }, [target, ...objects], utils, rootMetaData); + } + + return customizedDeepmergeInto as CustomizedDeepmergeInto; +} + +/** + * The the utils that are available to the merge functions. + * + * @param options - The options the user specified + */ +function getIntoUtils( + options: DeepMergeIntoOptions, + customizedDeepmergeInto: DeepMergeMergeIntoFunctionUtils< + M, + MM + >["deepmergeInto"] +): DeepMergeMergeIntoFunctionUtils { + return { + defaultMergeFunctions: defaultMergeIntoFunctions, + mergeFunctions: { + ...defaultMergeIntoFunctions, + ...Object.fromEntries( + Object.entries(options) + .filter(([key, option]) => + Object.prototype.hasOwnProperty.call(defaultMergeIntoFunctions, key) + ) + .map(([key, option]) => + option === false + ? [key, defaultMergeIntoFunctions.mergeOthers] + : [key, option] + ) + ), + } as DeepMergeMergeIntoFunctionUtils["mergeFunctions"], + metaDataUpdater: (options.metaDataUpdater ?? + defaultMetaDataUpdater) as unknown as DeepMergeMergeIntoFunctionUtils< + M, + MM + >["metaDataUpdater"], + deepmergeInto: customizedDeepmergeInto, + actions, + }; +} + +/** + * Merge unknown things into a target. + * + * @param m_target - The target to merge into. + * @param values - The values. + */ +export function mergeUnknownsInto< + Ts extends ReadonlyArray, + U extends DeepMergeMergeIntoFunctionUtils, + M, + MM extends DeepMergeBuiltInMetaData +>( + m_target: Reference, + values: Ts, + utils: U, + meta: M | undefined + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type +): void | symbol { + if (values.length === 0) { + return; + } + if (values.length === 1) { + return void mergeOthersInto(m_target, values, utils, meta); + } + + const type = getObjectType(m_target.value); + + // eslint-disable-next-line functional/no-conditional-statements -- add an early escape for better performance. + if (type !== ObjectType.NOT && type !== ObjectType.OTHER) { + // eslint-disable-next-line functional/no-loop-statements -- using a loop here is more performant than mapping every value and then testing every value. + for (let m_index = 1; m_index < values.length; m_index++) { + if (getObjectType(values[m_index]) === type) { + continue; + } + + return void mergeOthersInto(m_target, values, utils, meta); + } + } + + switch (type) { + case ObjectType.RECORD: { + return void mergeRecordsInto( + m_target as Reference>, + values as ReadonlyArray>>, + utils, + meta + ); + } + + case ObjectType.ARRAY: { + return void mergeArraysInto( + m_target as Reference, + values as ReadonlyArray>, + utils, + meta + ); + } + + case ObjectType.SET: { + return void mergeSetsInto( + m_target as Reference>, + values as ReadonlyArray>>, + utils, + meta + ); + } + + case ObjectType.MAP: { + return void mergeMapsInto( + m_target as Reference>, + values as ReadonlyArray>>, + utils, + meta + ); + } + + default: { + return void mergeOthersInto(m_target, values, utils, meta); + } + } +} + +/** + * Merge records into a target record. + * + * @param m_target - The target to merge into. + * @param values - The records. + */ +function mergeRecordsInto< + U extends DeepMergeMergeIntoFunctionUtils, + M, + MM extends DeepMergeBuiltInMetaData +>( + m_target: Reference>, + values: ReadonlyArray>>, + utils: U, + meta: M | undefined +) { + const action = utils.mergeFunctions.mergeRecords( + m_target, + values, + utils, + meta + ); + + if (action === actions.defaultMerge) { + utils.defaultMergeFunctions.mergeRecords< + ReadonlyArray>>, + U, + M, + MM + >(m_target, values, utils, meta); + } +} + +/** + * Merge arrays into a target array. + * + * @param m_target - The target to merge into. + * @param values - The arrays. + */ +function mergeArraysInto< + U extends DeepMergeMergeIntoFunctionUtils, + M, + MM extends DeepMergeBuiltInMetaData +>( + m_target: Reference, + values: ReadonlyArray>, + utils: U, + meta: M | undefined +) { + const action = utils.mergeFunctions.mergeArrays( + m_target, + values, + utils, + meta + ); + + if (action === actions.defaultMerge) { + utils.defaultMergeFunctions.mergeArrays(m_target, values); + } +} + +/** + * Merge sets into a target set. + * + * @param m_target - The target to merge into. + * @param values - The sets. + */ +function mergeSetsInto< + U extends DeepMergeMergeIntoFunctionUtils, + M, + MM extends DeepMergeBuiltInMetaData +>( + m_target: Reference>, + values: ReadonlyArray>>, + utils: U, + meta: M | undefined +) { + const action = utils.mergeFunctions.mergeSets(m_target, values, utils, meta); + + if (action === actions.defaultMerge) { + utils.defaultMergeFunctions.mergeSets(m_target, values); + } +} + +/** + * Merge maps into a target map. + * + * @param m_target - The target to merge into. + * @param values - The maps. + */ +function mergeMapsInto< + U extends DeepMergeMergeIntoFunctionUtils, + M, + MM extends DeepMergeBuiltInMetaData +>( + m_target: Reference>, + values: ReadonlyArray>>, + utils: U, + meta: M | undefined +) { + const action = utils.mergeFunctions.mergeMaps(m_target, values, utils, meta); + + if (action === actions.defaultMerge) { + utils.defaultMergeFunctions.mergeMaps(m_target, values); + } +} + +/** + * Merge other things into a target. + * + * @param m_target - The target to merge into. + * @param values - The other things. + */ +function mergeOthersInto< + U extends DeepMergeMergeIntoFunctionUtils, + M, + MM extends DeepMergeBuiltInMetaData +>( + m_target: Reference, + values: ReadonlyArray, + utils: U, + meta: M | undefined +) { + const action = utils.mergeFunctions.mergeOthers( + m_target, + values, + utils, + meta + ); + + if ( + action === actions.defaultMerge || + m_target.value === actions.defaultMerge + ) { + utils.defaultMergeFunctions.mergeOthers(m_target, values); + } +} diff --git a/src/deepmerge.ts b/src/deepmerge.ts index 1d7708d6..76cf58ab 100644 --- a/src/deepmerge.ts +++ b/src/deepmerge.ts @@ -1,54 +1,16 @@ +import { actions } from "./actions.js"; +import { defaultMetaDataUpdater } from "./defaults/meta-data-updater.js"; +import * as defaultMergeFunctions from "./defaults/vanilla.js"; import type { DeepMergeBuiltInMetaData, DeepMergeHKT, - DeepMergeArraysDefaultHKT, DeepMergeMergeFunctionsDefaultURIs, - DeepMergeMapsDefaultHKT, DeepMergeMergeFunctionsURIs, DeepMergeOptions, - DeepMergeRecordsDefaultHKT, - DeepMergeSetsDefaultHKT, DeepMergeMergeFunctionUtils, GetDeepMergeMergeFunctionsURIs, } from "./types/index.js"; -import { - getIterableOfIterables, - getKeys, - getObjectType, - ObjectType, - objectHasProperty, -} from "./utils.js"; - -const defaultMergeFunctions = { - mergeMaps: defaultMergeMaps, - mergeSets: defaultMergeSets, - mergeArrays: defaultMergeArrays, - mergeRecords: defaultMergeRecords, - mergeOthers: leaf, -} as const; - -/** - * Special values that tell deepmerge-ts to perform a certain action. - */ -const actions = { - defaultMerge: Symbol("deepmerge-ts: default merge"), - skip: Symbol("deepmerge-ts: skip"), -} as const; - -/** - * The default function to update meta data. - */ -function defaultMetaDataUpdater( - previousMeta: M, - metaMeta: DeepMergeBuiltInMetaData -): DeepMergeBuiltInMetaData { - return metaMeta; -} - -/** - * The default merge functions. - */ -export type DeepMergeMergeFunctionsDefaults = typeof defaultMergeFunctions; +import { getObjectType, ObjectType } from "./utils.js"; /** * Deeply merge objects. @@ -142,7 +104,7 @@ export function deepmergeCustom< } /** - * The the full options with defaults apply. + * The the utils that are available to the merge functions. * * @param options - The options the user specified */ @@ -160,7 +122,9 @@ function getUtils( Object.prototype.hasOwnProperty.call(defaultMergeFunctions, key) ) .map(([key, option]) => - option === false ? [key, leaf] : [key, option] + option === false + ? [key, defaultMergeFunctions.mergeOthers] + : [key, option] ) ), } as DeepMergeMergeFunctionUtils["mergeFunctions"], @@ -180,7 +144,7 @@ function getUtils( * * @param values - The values. */ -function mergeUnknowns< +export function mergeUnknowns< Ts extends ReadonlyArray, U extends DeepMergeMergeFunctionUtils, MF extends DeepMergeMergeFunctionsURIs, @@ -400,110 +364,3 @@ function mergeOthers< } return result; } - -/** - * The default strategy to merge records. - * - * @param values - The records. - */ -function defaultMergeRecords< - Ts extends ReadonlyArray>, - U extends DeepMergeMergeFunctionUtils, - MF extends DeepMergeMergeFunctionsURIs, - M, - MM extends DeepMergeBuiltInMetaData ->( - values: Ts, - utils: U, - meta: M | undefined -): DeepMergeRecordsDefaultHKT { - const result: Record = {}; - - /* eslint-disable functional/no-loop-statements, functional/no-conditional-statements -- using a loop here is more performant. */ - - for (const key of getKeys(values)) { - const propValues = []; - - for (const value of values) { - if (objectHasProperty(value, key)) { - propValues.push(value[key]); - } - } - - if (propValues.length === 0) { - continue; - } - - const updatedMeta = utils.metaDataUpdater(meta, { - key, - parents: values, - } as unknown as MM); - - const propertyResult = mergeUnknowns, U, MF, M, MM>( - propValues, - utils, - updatedMeta - ); - - if (propertyResult === actions.skip) { - continue; - } - - if (key === "__proto__") { - Object.defineProperty(result, key, { - value: propertyResult, - configurable: true, - enumerable: true, - writable: true, - }); - } else { - result[key] = propertyResult; - } - } - - /* eslint-enable functional/no-loop-statements, functional/no-conditional-statements */ - - return result as DeepMergeRecordsDefaultHKT; -} - -/** - * The default strategy to merge arrays. - * - * @param values - The arrays. - */ -function defaultMergeArrays< - Ts extends ReadonlyArray>, - MF extends DeepMergeMergeFunctionsURIs, - M ->(values: Ts): DeepMergeArraysDefaultHKT { - return values.flat() as DeepMergeArraysDefaultHKT; -} - -/** - * The default strategy to merge sets. - * - * @param values - The sets. - */ -function defaultMergeSets< - Ts extends ReadonlyArray>> ->(values: Ts): DeepMergeSetsDefaultHKT { - return new Set(getIterableOfIterables(values)) as DeepMergeSetsDefaultHKT; -} - -/** - * The default strategy to merge maps. - * - * @param values - The maps. - */ -function defaultMergeMaps< - Ts extends ReadonlyArray>> ->(values: Ts): DeepMergeMapsDefaultHKT { - return new Map(getIterableOfIterables(values)) as DeepMergeMapsDefaultHKT; -} - -/** - * Get the last value in the given array. - */ -function leaf>(values: Ts) { - return values[values.length - 1]; -} diff --git a/src/defaults/into.ts b/src/defaults/into.ts new file mode 100644 index 00000000..9a699436 --- /dev/null +++ b/src/defaults/into.ts @@ -0,0 +1,133 @@ +import { mergeUnknownsInto } from "../deepmerge-into.js"; +import type { + DeepMergeBuiltInMetaData, + DeepMergeMergeIntoFunctionUtils, + Reference, +} from "../types"; +import { + getIterableOfIterables, + getKeys, + objectHasProperty, +} from "../utils.js"; + +/** + * The default merge functions. + */ +export type MergeFunctions = { + mergeRecords: typeof mergeRecords; + mergeArrays: typeof mergeArrays; + mergeSets: typeof mergeSets; + mergeMaps: typeof mergeMaps; + mergeOthers: typeof mergeOthers; +}; + +/** + * The default strategy to merge records into a target record. + * + * @param m_target - The result will be mutated into this record + * @param values - The records (including the target's value if there is one). + */ +export function mergeRecords< + Ts extends ReadonlyArray>, + U extends DeepMergeMergeIntoFunctionUtils, + M, + MM extends DeepMergeBuiltInMetaData +>( + m_target: Reference>, + values: Ts, + utils: U, + meta: M | undefined +): void { + /* eslint-disable functional/no-loop-statements, functional/no-conditional-statements -- using a loop here is more performant. */ + + for (const key of getKeys(values)) { + const propValues = []; + + for (const value of values) { + if (objectHasProperty(value, key)) { + propValues.push(value[key]); + } + } + + if (propValues.length === 0) { + continue; + } + + const updatedMeta = utils.metaDataUpdater(meta, { + key, + parents: values, + } as unknown as MM); + + const propertyTarget: Reference = { value: propValues[0] }; + mergeUnknownsInto, U, M, MM>( + propertyTarget, + propValues, + utils, + updatedMeta + ); + + if (key === "__proto__") { + Object.defineProperty(m_target, key, { + value: propertyTarget.value, + configurable: true, + enumerable: true, + writable: true, + }); + } else { + m_target.value[key] = propertyTarget.value; + } + } + + /* eslint-enable functional/no-loop-statements, functional/no-conditional-statements */ +} + +/** + * The default strategy to merge arrays into a target array. + * + * @param m_target - The result will be mutated into this array + * @param values - The arrays (including the target's value if there is one). + */ +export function mergeArrays>>( + m_target: Reference, + values: Ts +): void { + m_target.value.push(...values.slice(1).flat()); +} + +/** + * The default strategy to merge sets into a target set. + * + * @param m_target - The result will be mutated into this set + * @param values - The sets (including the target's value if there is one). + */ +export function mergeSets< + Ts extends ReadonlyArray>> +>(m_target: Reference>, values: Ts): void { + for (const value of getIterableOfIterables(values.slice(1))) { + m_target.value.add(value); + } +} + +/** + * The default strategy to merge maps into a target map. + * + * @param m_target - The result will be mutated into this map + * @param values - The maps (including the target's value if there is one). + */ +export function mergeMaps< + Ts extends ReadonlyArray>> +>(m_target: Reference>, values: Ts): void { + for (const [key, value] of getIterableOfIterables(values.slice(1))) { + m_target.value.set(key, value); + } +} + +/** + * Set the target to the last value. + */ +export function mergeOthers>( + m_target: Reference, + values: Ts +) { + m_target.value = values[values.length - 1]; +} diff --git a/src/defaults/meta-data-updater.ts b/src/defaults/meta-data-updater.ts new file mode 100644 index 00000000..9c9a065c --- /dev/null +++ b/src/defaults/meta-data-updater.ts @@ -0,0 +1,11 @@ +import type { DeepMergeBuiltInMetaData } from "../types"; + +/** + * The default function to update meta data. + */ +export function defaultMetaDataUpdater( + previousMeta: M, + metaMeta: DeepMergeBuiltInMetaData +): DeepMergeBuiltInMetaData { + return metaMeta; +} diff --git a/src/defaults/vanilla.ts b/src/defaults/vanilla.ts new file mode 100644 index 00000000..34b68555 --- /dev/null +++ b/src/defaults/vanilla.ts @@ -0,0 +1,134 @@ +import { actions } from "../actions.js"; +import { mergeUnknowns } from "../deepmerge.js"; +import type { + DeepMergeArraysDefaultHKT, + DeepMergeBuiltInMetaData, + DeepMergeMapsDefaultHKT, + DeepMergeMergeFunctionsURIs, + DeepMergeMergeFunctionUtils, + DeepMergeRecordsDefaultHKT, + DeepMergeSetsDefaultHKT, +} from "../types"; +import { + getKeys, + objectHasProperty, + getIterableOfIterables, +} from "../utils.js"; + +/** + * The default merge functions. + */ +export type MergeFunctions = { + mergeRecords: typeof mergeRecords; + mergeArrays: typeof mergeArrays; + mergeSets: typeof mergeSets; + mergeMaps: typeof mergeMaps; + mergeOthers: typeof mergeOthers; +}; + +/** + * The default strategy to merge records. + * + * @param values - The records. + */ +export function mergeRecords< + Ts extends ReadonlyArray>, + U extends DeepMergeMergeFunctionUtils, + MF extends DeepMergeMergeFunctionsURIs, + M, + MM extends DeepMergeBuiltInMetaData +>( + values: Ts, + utils: U, + meta: M | undefined +): DeepMergeRecordsDefaultHKT { + const result: Record = {}; + + /* eslint-disable functional/no-loop-statements, functional/no-conditional-statements -- using a loop here is more performant. */ + + for (const key of getKeys(values)) { + const propValues = []; + + for (const value of values) { + if (objectHasProperty(value, key)) { + propValues.push(value[key]); + } + } + + if (propValues.length === 0) { + continue; + } + + const updatedMeta = utils.metaDataUpdater(meta, { + key, + parents: values, + } as unknown as MM); + + const propertyResult = mergeUnknowns, U, MF, M, MM>( + propValues, + utils, + updatedMeta + ); + + if (propertyResult === actions.skip) { + continue; + } + + if (key === "__proto__") { + Object.defineProperty(result, key, { + value: propertyResult, + configurable: true, + enumerable: true, + writable: true, + }); + } else { + result[key] = propertyResult; + } + } + + /* eslint-enable functional/no-loop-statements, functional/no-conditional-statements */ + + return result as DeepMergeRecordsDefaultHKT; +} + +/** + * The default strategy to merge arrays. + * + * @param values - The arrays. + */ +export function mergeArrays< + Ts extends ReadonlyArray>, + MF extends DeepMergeMergeFunctionsURIs, + M +>(values: Ts): DeepMergeArraysDefaultHKT { + return values.flat() as DeepMergeArraysDefaultHKT; +} + +/** + * The default strategy to merge sets. + * + * @param values - The sets. + */ +export function mergeSets< + Ts extends ReadonlyArray>> +>(values: Ts): DeepMergeSetsDefaultHKT { + return new Set(getIterableOfIterables(values)) as DeepMergeSetsDefaultHKT; +} + +/** + * The default strategy to merge maps. + * + * @param values - The maps. + */ +export function mergeMaps< + Ts extends ReadonlyArray>> +>(values: Ts): DeepMergeMapsDefaultHKT { + return new Map(getIterableOfIterables(values)) as DeepMergeMapsDefaultHKT; +} + +/** + * Get the last value in the given array. + */ +export function mergeOthers>(values: Ts) { + return values[values.length - 1]; +} diff --git a/src/index.ts b/src/index.ts index b73635fc..b9077b98 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ export { deepmerge, deepmergeCustom } from "./deepmerge.js"; +export { deepmergeInto, deepmergeIntoCustom } from "./deepmerge-into.js"; -export type { DeepMergeMergeFunctionsDefaults } from "./deepmerge.js"; +export type { MergeFunctions as DeepMergeMergeIntoFunctionsDefaults } from "./defaults/into.js"; +export type { MergeFunctions as DeepMergeMergeFunctionsDefaults } from "./defaults/vanilla.js"; export type { DeepMergeArraysDefaultHKT, DeepMergeBuiltInMetaData, @@ -13,7 +15,10 @@ export type { DeepMergeMergeFunctionsURIs, DeepMergeMergeFunctionURItoKind, DeepMergeMergeFunctionUtils, + DeepMergeMergeIntoFunctionUtils, DeepMergeOptions, + DeepMergeIntoOptions, DeepMergeRecordsDefaultHKT, DeepMergeSetsDefaultHKT, + Reference as DeepMergeValueReference, } from "./types/index.js"; diff --git a/src/types/options.ts b/src/types/options.ts index 8342ac57..5147afe9 100644 --- a/src/types/options.ts +++ b/src/types/options.ts @@ -1,6 +1,7 @@ -// eslint-disable-next-line import/no-relative-parent-imports -- use "deepmerge-ts" once denoify can support it. -import type { DeepMergeMergeFunctionsDefaults } from "../index.js"; +import type { MergeFunctions as MergeIntoFunctions } from "../defaults/into.js"; +import type { MergeFunctions } from "../defaults/vanilla.js"; +// eslint-disable-next-line import/no-relative-parent-imports -- use "deepmerge-ts" once denoify can support it. import type { DeepMergeBuiltInMetaData } from "./merging.js"; /** @@ -11,6 +12,14 @@ export type DeepMergeOptions< MM extends Readonly> = DeepMergeBuiltInMetaData > = Partial>; +/** + * The options the user can pass to customize deepmergeInto. + */ +export type DeepMergeIntoOptions< + in out M, + MM extends Readonly> = DeepMergeBuiltInMetaData +> = Partial>; + type MetaDataUpdater = ( previousMeta: M | undefined, metaMeta: Readonly> @@ -32,6 +41,28 @@ type DeepMergeOptionsFull< enableImplicitDefaultMerging: boolean; }>; +/** + * All the options the user can pass to customize deepmergeInto. + */ +type DeepMergeIntoOptionsFull< + in out M, + MM extends DeepMergeBuiltInMetaData +> = Readonly<{ + mergeRecords: DeepMergeMergeIntoFunctions["mergeRecords"] | false; + mergeArrays: DeepMergeMergeIntoFunctions["mergeArrays"] | false; + mergeMaps: DeepMergeMergeIntoFunctions["mergeMaps"] | false; + mergeSets: DeepMergeMergeIntoFunctions["mergeSets"] | false; + mergeOthers: DeepMergeMergeIntoFunctions["mergeOthers"]; + metaDataUpdater: MetaDataUpdater; +}>; + +/** + * An object that has a reference to a value. + */ +export type Reference = { + value: T; +}; + /** * All the merge functions that deepmerge uses. */ @@ -85,15 +116,77 @@ type DeepMergeMergeFunctions< ) => unknown; }>; +// eslint-disable-next-line @typescript-eslint/no-invalid-void-type +type DeepMergeMergeIntoFunctionsReturnType = void | symbol; + +/** + * All the merge functions that deepmerge uses. + */ +type DeepMergeMergeIntoFunctions< + in M, + MM extends DeepMergeBuiltInMetaData +> = Readonly<{ + mergeRecords: < + Ts extends ReadonlyArray>>, + U extends DeepMergeMergeIntoFunctionUtils + >( + m_target: Reference>, + values: Ts, + utils: U, + meta: M | undefined + ) => DeepMergeMergeIntoFunctionsReturnType; + + mergeArrays: < + Ts extends ReadonlyArray>, + U extends DeepMergeMergeIntoFunctionUtils + >( + m_target: Reference, + values: Ts, + utils: U, + meta: M | undefined + ) => DeepMergeMergeIntoFunctionsReturnType; + + mergeMaps: < + Ts extends ReadonlyArray>>, + U extends DeepMergeMergeIntoFunctionUtils + >( + m_target: Reference>, + values: Ts, + utils: U, + meta: M | undefined + ) => DeepMergeMergeIntoFunctionsReturnType; + + mergeSets: < + Ts extends ReadonlyArray>>, + U extends DeepMergeMergeIntoFunctionUtils + >( + m_target: Reference>, + values: Ts, + utils: U, + meta: M | undefined + ) => DeepMergeMergeIntoFunctionsReturnType; + + mergeOthers: < + Ts extends ReadonlyArray, + U extends DeepMergeMergeIntoFunctionUtils + >( + m_target: Reference, + values: Ts, + utils: U, + meta: M | undefined + ) => DeepMergeMergeIntoFunctionsReturnType; +}>; + /** * The utils provided to the merge functions. */ +// eslint-disable-next-line functional/no-mixed-types export type DeepMergeMergeFunctionUtils< in out M, MM extends DeepMergeBuiltInMetaData > = Readonly<{ mergeFunctions: DeepMergeMergeFunctions; - defaultMergeFunctions: DeepMergeMergeFunctionsDefaults; + defaultMergeFunctions: MergeFunctions; metaDataUpdater: MetaDataUpdater; deepmerge: >(...values: Ts) => unknown; useImplicitDefaultMerging: boolean; @@ -102,3 +195,23 @@ export type DeepMergeMergeFunctionUtils< skip: symbol; }>; }>; + +/** + * The utils provided to the merge functions. + */ +// eslint-disable-next-line functional/no-mixed-types +export type DeepMergeMergeIntoFunctionUtils< + in out M, + MM extends DeepMergeBuiltInMetaData +> = Readonly<{ + mergeFunctions: DeepMergeMergeIntoFunctions; + defaultMergeFunctions: MergeIntoFunctions; + metaDataUpdater: MetaDataUpdater; + deepmergeInto: >( + target: Target, + ...values: Ts + ) => void; + actions: Readonly<{ + defaultMerge: symbol; + }>; +}>; diff --git a/src/types/utils.ts b/src/types/utils.ts index fdc378fa..61fcf2ca 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -2,7 +2,9 @@ * Flatten a complex type such as a union or intersection of objects into a * single object. */ -export type FlatternAlias = { [P in keyof T]: T[P] } & {}; +export type FlatternAlias = Is extends true + ? T + : { [P in keyof T]: T[P] } & {}; /** * Get the value of the given key in the given object. diff --git a/tests/deepmerge-custom.test.ts b/tests/deepmerge-custom.test.ts index 685ffd71..d46feb07 100644 --- a/tests/deepmerge-custom.test.ts +++ b/tests/deepmerge-custom.test.ts @@ -630,7 +630,7 @@ test("implicit default merging", (t) => { t.deepEqual(merged, expected); }); -test("default merging using shortcut", (t) => { +test("default merging using actions", (t) => { const x = { foo: 1, bar: { baz: [2], qux: new Set([1]), quux: new Map([[1, 2]]) }, diff --git a/tests/deepmerge-into-custom.test.ts b/tests/deepmerge-into-custom.test.ts new file mode 100644 index 00000000..48e569bc --- /dev/null +++ b/tests/deepmerge-into-custom.test.ts @@ -0,0 +1,560 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-unused-vars */ + +import test from "ava"; +import _ from "lodash"; + +import { deepmergeIntoCustom } from "../src/index.js"; +import type { + DeepMergeValueReference, + DeepMergeIntoOptions, +} from "../src/index.js"; +import { getKeys } from "../src/utils.js"; + +import { areAllNumbers, hasProp } from "./utils.js"; + +declare module "ava" { + interface DeepEqualAssertion { + /** + * Assert that `actual` is [deeply equal](https://github.com/concordancejs/concordance#comparison-details) to + * `expected`, returning a boolean indicating whether the assertion passed. + */ + ( + actual: Actual, + expected: Expected, + message?: string + ): expected is Actual; + } +} + +test("works just like non-customized version when no options passed", (t) => { + const v = { first: true }; + const x = { second: false }; + const y = { third: 123 }; + const z = { fourth: "abc" }; + + const expected = { + first: true, + second: false, + third: 123, + fourth: "abc", + }; + + deepmergeIntoCustom({})(v, x, y, z); + + t.deepEqual(v, expected); +}); + +test("custom merge strings", (t) => { + const v = { foo: { bar: { baz: { qux: "a" } } } }; + const x = { foo: { bar: { baz: { qux: "b" } } } }; + const y = { foo: { bar: { baz: { qux: "c" } } } }; + const z = { foo: { bar: { baz: { qux: "d" } } } }; + + const expected = { + foo: { bar: { baz: { qux: "a b c d" } } }, + }; + + const customizedDeepmerge = deepmergeIntoCustom({ + mergeOthers: (target, values, utils) => { + if (values.every((value) => typeof value === "string")) { + target.value = values.join(" "); + return; + } + utils.defaultMergeFunctions.mergeOthers(target, values); + }, + }); + + customizedDeepmerge(v, x, y, z); + + t.deepEqual(v, expected); +}); + +test("custom merge arrays", (t) => { + const x = { foo: { bar: { baz: { qux: [1, 2, 3] } } } }; + const y = { foo: { bar: { baz: { qux: ["a", "b", "c"] } } } }; + + const expected = { + foo: { bar: { baz: { qux: ["1a", "2b", "3c"] } } }, + }; + + const customizedDeepmerge = deepmergeIntoCustom({ + mergeArrays: (target, arrays) => { + const maxLength = Math.max(...arrays.map((array) => array.length)); + + const result = []; + for (let m_i = 0; m_i < maxLength; m_i++) { + result[m_i] = ""; + + for (const array of arrays) { + if (m_i >= array.length) { + break; + } + result[m_i] += `${array[m_i]}`; + } + } + + target.value = result; + }, + }); + + customizedDeepmerge(x, y); + + t.deepEqual(x, expected); +}); + +test("custom merge arrays of records", (t) => { + const x = { + foo: [ + { bar: { baz: [{ qux: 35 }] } }, + { bar: { baz: [{ qux: 36 }] } }, + { bar: { baz: [{ qux: 37 }] } }, + ], + }; + const y = { + foo: [ + { bar: { baz: [{ qux: 38 }] } }, + { bar: { baz: [{ qux: 39 }] } }, + { bar: { baz: [{ qux: 40 }] } }, + ], + }; + + const expected = { + foo: [ + { bar: { baz: [{ qux: "I" }] } }, + { bar: { baz: [{ qux: "K" }] } }, + { bar: { baz: [{ qux: "M" }] } }, + ], + }; + + const customizedDeepmerge = deepmergeIntoCustom({ + mergeArrays: (target, arrays, utils) => { + const maxLength = Math.max(...arrays.map((array) => array.length)); + const result: unknown[] = []; + + for (let m_i = 0; m_i < maxLength; m_i++) { + const never = {}; + const s = {}; + utils.deepmergeInto( + s, + ...arrays + .map((array) => (m_i < array.length ? array[m_i] : never)) + .filter((value) => value !== never) + ); + result.push(s); + } + + target.value = result; + }, + mergeOthers: (target, values) => { + if (values.every((value) => typeof value === "number")) { + target.value = String.fromCodePoint( + values.reduce((carry, value) => carry + (value as number), 0) + ); + return; + } + target.value = ""; + }, + }); + + customizedDeepmerge(x, y); + + t.deepEqual(x, expected); +}); + +test("custom merge records", (t) => { + const x = { + foo: { + bar: 1, + }, + }; + const y = { + foo: { + qux: 4, + }, + }; + + const expected = { + foo: "foo", + }; + + const customizedDeepmerge = deepmergeIntoCustom({ + mergeRecords: (target, records, utils, meta) => { + for (const key of getKeys(records)) { + target.value[key] = key; + } + }, + }); + + customizedDeepmerge(x, y); + + t.deepEqual(x, expected); +}); + +test("custom don't merge arrays", (t) => { + const v = { foo: [1, 2] } as const; + const x = { foo: [3, 4] } as const; + const y = { foo: [5, 6] } as const; + const z = { foo: [7, 8] } as const; + + const expected = { foo: [7, 8] } as const; + + const customizedDeepmerge = deepmergeIntoCustom({ + mergeArrays: false, + }); + + customizedDeepmerge(v, x, y, z); + + t.deepEqual(v, expected); +}); + +type EveryIsDate> = Ts extends Readonly< + readonly [infer Head, ...infer Rest] +> + ? Head extends Date + ? EveryIsDate + : false + : true; + +test("custom merge dates", (t) => { + const x = { foo: new Date("2020-01-01") }; + const y = { foo: new Date("2021-02-02") }; + const z = { foo: new Date("2022-03-03") }; + + const expected = { foo: [x.foo, y.foo, z.foo] } as const; + + const customizedDeepmerge = deepmergeIntoCustom({ + mergeOthers: (target, values, utils) => { + if (values.every((value) => value instanceof Date)) { + target.value = values; + return; + } + utils.defaultMergeFunctions.mergeOthers(target, values); + }, + }); + + customizedDeepmerge(x, y, z); + + t.deepEqual(x, 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 = deepmergeIntoCustom({ + mergeOthers: (target, values, utils, meta) => { + if (meta !== undefined && areAllNumbers(values)) { + const { key } = meta; + const numbers: ReadonlyArray = values; + + if (key === "sum") { + target.value = numbers.reduce((sum, value) => sum + value); + return; + } + if (key === "product") { + target.value = numbers.reduce((prod, value) => prod * value); + return; + } + if (key === "mean") { + target.value = + numbers.reduce((sum, value) => sum + value) / numbers.length; + return; + } + } + + utils.defaultMergeFunctions.mergeOthers(target, values); + }, + }); + + customizedDeepmerge(v, x, y, z); + + t.deepEqual(v, expected); +}); + +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 = deepmergeIntoCustom>({ + metaDataUpdater: (previousMeta, metaMeta) => { + if (metaMeta.key === undefined) { + return previousMeta ?? []; + } + return [...(previousMeta ?? []), metaMeta.key]; + }, + mergeOthers: (target, values, utils, meta): void => { + if ( + meta !== undefined && + meta.length >= 2 && + meta[meta.length - 2] === "bar" && + meta[meta.length - 1] === "baz" + ) { + target.value = "special merge"; + return; + } + + utils.defaultMergeFunctions.mergeOthers(target, values); + }, + }); + + customizedDeepmerge(x, y); + + t.deepEqual(x, 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: DeepMergeIntoOptions< + ReadonlyArray, + Readonly<{ id: unknown }> + > = { + metaDataUpdater: (previousMeta, metaMeta) => { + return [...(previousMeta ?? []), metaMeta.key ?? metaMeta.id]; + }, + mergeArrays: (target, 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) { + utils.defaultMergeFunctions.mergeArrays(target, values); + return; + } + + const id = idPath[idPath.length - 1]; + const valuesById = values.reduce< + Map>> + >((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>>()); + + target.value = [...valuesById.entries()].reduce( + (carry, [id, values]) => { + const childMeta = utils.metaDataUpdater(meta, { id }); + const s = {}; + deepmergeIntoCustom(mergeSettings, childMeta)(s, ...values); + return [...carry, s]; + }, + [] + ); + }, + }; + + return deepmergeIntoCustom(mergeSettings, []); + }; + + customizedDeepmergeEntry(["foo", "id"], ["baz", "qux", "id"])(x, y); + + t.deepEqual(x, 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 = deepmergeIntoCustom({ + mergeOthers: (target, values, utils, meta): void => { + if (meta !== undefined) { + const { key, parents } = meta; + if (key === "isBadObject") { + target.value = false; + return; + } + + const goodValues = values.filter( + (value, index): value is number => + parents[index].isBadObject !== true && typeof value === "number" + ); + + if (key === "sum") { + target.value = goodValues.reduce((sum, value) => sum + value, 0); + return; + } + } + utils.defaultMergeFunctions.mergeOthers(target, values); + }, + }); + + customizedDeepmerge(v, x, y, z); + + t.deepEqual(v, expected); +}); + +test("default merging using actions", (t) => { + const x = { + foo: 1, + bar: { baz: [2], qux: new Set([1]), quux: new Map([[1, 2]]) }, + }; + + const y = { + foo: 3, + bar: { baz: [4], qux: new Set([2]), quux: new Map([[2, 3]]) }, + }; + + const expected = { + foo: 3, + bar: { + baz: [2, 4], + qux: new Set([1, 2]), + quux: new Map([ + [1, 2], + [2, 3], + ]), + }, + }; + + const customizedDeepmerge = deepmergeIntoCustom({ + mergeRecords: (target, values, utils) => utils.actions.defaultMerge, + mergeArrays: (target, values, utils) => utils.actions.defaultMerge, + mergeSets: (target, values, utils) => utils.actions.defaultMerge, + mergeMaps: (target, values, utils) => utils.actions.defaultMerge, + mergeOthers: (target, values, utils) => utils.actions.defaultMerge, + }); + + customizedDeepmerge(x, y); + + t.deepEqual(x, expected); +}); + +test("merging class object as record", (t) => { + class Klass { + public readonly prop1 = 1 as const; + public readonly prop2 = 2 as const; + } + + const x = new Klass(); + const y = { foo: false }; + + const expected = { + prop1: 1, + prop2: 2, + foo: false, + }; + + const customizedDeepmerge = deepmergeIntoCustom({ + mergeOthers: (target, values, utils, meta) => { + let m_allRecords = true; + const records = values.map((v) => { + if (typeof v === "object" && v !== null) { + return { ...v }; + } + m_allRecords = false; + return false; + }); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (m_allRecords) { + utils.mergeFunctions.mergeRecords( + target as DeepMergeValueReference>, + records, + utils, + meta + ); + return; + } + target.value = utils.actions.defaultMerge; + }, + }); + + customizedDeepmerge(x, y); + + t.deepEqual({ ...x }, expected); +}); diff --git a/tests/deepmerge-into.test-d.ts b/tests/deepmerge-into.test-d.ts new file mode 100644 index 00000000..03d018c1 --- /dev/null +++ b/tests/deepmerge-into.test-d.ts @@ -0,0 +1,83 @@ +import { expectType, expectAssignable } from "tsd"; + +import { deepmergeInto } from "../src"; + +const a = { + foo: "abc", + baz: { + quux: ["def", "ghi"], + }, + garply: 42, +}; + +const b = { + foo: "cba", + baz: { + corge: 96, + }, + grault: 42, +}; + +const test1 = { ...a }; +deepmergeInto(test1, b); +expectAssignable<{ + foo: string; + baz: { quux: string[]; corge: number }; + garply: number; + grault: number; +}>(test1); + +type T = { + readonly foo: string; + bar?: string; +}; + +const test2 = { ...a } as T; +deepmergeInto(test2, b as T); +expectType(test2); + +type U = { + grault: number; +}; + +const test3 = { ...a } as T; +deepmergeInto(test3, b as U); +expectAssignable<{ foo: string; grault: number }>(test3); + +const c = { + bar: "123", + quux: "456", + garply: 42, +} as const; + +const test4 = { ...a }; +deepmergeInto(test4, c); +expectAssignable<{ + foo: string; + baz: { quux: string[] }; + garply: 42; + bar: "123"; + quux: "456"; +}>(test4); + +const test5 = { ...b }; +deepmergeInto(test5, c); +expectAssignable<{ + foo: string; + baz: { corge: number }; + garply: 42; + grault: number; + bar: "123"; + quux: "456"; +}>(test5); + +const test6 = { ...a }; +deepmergeInto(test6, b, c); +expectAssignable<{ + foo: string; + baz: { quux: string[]; corge: number }; + garply: 42; + grault: number; + bar: "123"; + quux: "456"; +}>(test6); diff --git a/tests/deepmerge-into.test.ts b/tests/deepmerge-into.test.ts new file mode 100644 index 00000000..54c26124 --- /dev/null +++ b/tests/deepmerge-into.test.ts @@ -0,0 +1,616 @@ +import { createRequire } from "node:module"; + +import test from "ava"; + +import { deepmergeInto } from "../src/index.js"; + +test("does not modify the target when nothing to merge", (t) => { + const target = { prop: 1 }; + deepmergeInto(target); + t.deepEqual(target, { prop: 1 }); +}); + +test("can merge 1 object into another with different props", (t) => { + const x = { first: true }; + const y = { second: false }; + + const expectedX = { + first: true, + second: false, + }; + const expectedY = { second: false }; + + deepmergeInto(x, y); + + t.deepEqual(x, expectedX); + t.deepEqual(y, expectedY); +}); + +test("can merge many objects with different props", (t) => { + const v = { first: true }; + const x = { second: false }; + const y = { third: 123 }; + const z = { fourth: "abc" }; + + const expected = { + first: true, + second: false, + third: 123, + fourth: "abc", + }; + + deepmergeInto(v, x, y, z); + + t.deepEqual(v, expected); +}); + +test("can merge many objects with same props", (t) => { + const x = { key1: "value1", key2: "value2" }; + const y = { key1: "changed", key3: "value3" }; + const z = { key3: "changed", key4: "value4" }; + + const expected = { + key1: "changed", + key2: "value2", + key3: "changed", + key4: "value4", + }; + + deepmergeInto(x, y, z); + + t.deepEqual(x, expected); +}); + +test("does not clone any elements", (t) => { + const x = { a: { d: 123 } }; + const y = { b: { e: true } }; + const z = { c: { f: "string" } }; + + deepmergeInto(x, y, z); + + t.is(x.a, x.a); + t.is(x.b, y.b); + t.is(x.c, z.c); +}); + +test("does not mutate non-target inputs", (t) => { + const x = { a: { d: 123 } }; + const y = { b: { e: true } }; + const z = { c: { f: "string" } }; + + deepmergeInto(x, y, z); + + t.deepEqual(y, { b: { e: true } }); + t.deepEqual(z, { c: { f: "string" } }); +}); + +test("merging with empty object shallow clones the object", (t) => { + const value = { a: { d: 123 } }; + + const target = {}; + deepmergeInto<{}, [typeof value]>(target, value); + + t.deepEqual(target, value); + t.not(target, value, "Value should be shallow cloned."); + t.is(target.a, value.a, "Value should not be deep cloned."); +}); + +test(`can merge nested objects`, (t) => { + const x = { + key1: { + subkey1: `value1`, + subkey2: `value2`, + }, + }; + const y = { + key1: { + subkey1: `changed`, + subkey3: `added`, + }, + }; + + const expected = { + key1: { + subkey1: `changed`, + subkey2: `value2`, + subkey3: `added`, + }, + }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); +}); + +test(`replaces simple prop with nested object`, (t) => { + const x = { + key1: `value1`, + key2: `value2`, + }; + const y = { + key1: { + subkey1: `subvalue1`, + subkey2: `subvalue2`, + }, + }; + + const expected = { + key1: { + subkey1: `subvalue1`, + subkey2: `subvalue2`, + }, + key2: `value2`, + }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); +}); + +test(`should add nested object in target`, (t) => { + const x = { + a: {}, + }; + const y = { + b: { + c: {}, + }, + }; + + const expected = { + a: {}, + b: { + c: {}, + }, + }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); + t.is(x.b, y.b, "Value should not be deep cloned."); +}); + +test(`replaces nested object with simple prop`, (t) => { + const x = { + key1: { + subkey1: `subvalue1`, + subkey2: `subvalue2`, + }, + key2: `value2`, + }; + const y = { key1: `value1` }; + + const expected = { key1: `value1`, key2: `value2` }; + + deepmergeInto(x, y); + t.deepEqual(x, expected); +}); + +test(`replaces records with arrays`, (t) => { + const x = { key1: { subkey: `one` } }; + const y = { key1: [`subkey`] }; + + const expected = { key1: [`subkey`] }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); +}); + +test(`replaces arrays with records`, (t) => { + const x = { key1: [`subkey`] }; + const y = { key1: { subkey: `one` } }; + + const expected = { key1: { subkey: `one` } }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); +}); + +test(`replaces dates with records`, (t) => { + const x = { key1: new Date() }; + const y = { key1: { subkey: `one` } }; + + const expected = { key1: { subkey: `one` } }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); +}); + +test(`replaces records with dates`, (t) => { + const date = new Date(); + const x = { key1: { subkey: `one` } }; + const y = { key1: date }; + + const expected = { key1: date }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); +}); + +test(`replaces null with records`, (t) => { + const x = { key1: null }; + const y = { key1: { subkey: `one` } }; + + const expected = { key1: { subkey: `one` } }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); +}); + +test(`replaces records with null`, (t) => { + const x = { key1: { subkey: `one` } }; + const y = { key1: null }; + + const expected = { key1: null }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); +}); + +test(`replaces undefined with records`, (t) => { + const x = { key1: undefined }; + const y = { key1: { subkey: `one` } }; + + const expected = { key1: { subkey: `one` } }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); +}); + +test(`replaces records with undefined`, (t) => { + const x = { key1: { subkey: `one` } }; + const y = { key1: undefined }; + + const expected = { key1: undefined }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); +}); + +test(`can merge arrays`, (t) => { + const x = [`one`, `two`]; + const y = [`one`, `three`]; + + const expected = [`one`, `two`, `one`, `three`]; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); + t.true(Array.isArray(x)); +}); + +test(`can merge sets`, (t) => { + const x = new Set([`one`, `two`]); + const y = new Set([`one`, `three`]); + + const expected = new Set([`one`, `two`, `three`]); + + deepmergeInto(x, y); + + t.deepEqual(x, expected); + t.true(x instanceof Set); +}); + +test(`can merge maps`, (t) => { + const x = new Map([ + ["key1", "value1"], + ["key2", "value2"], + ]); + const y = new Map([ + ["key1", "changed"], + ["key3", "value3"], + ]); + + const expected = new Map([ + ["key1", "changed"], + ["key2", "value2"], + ["key3", "value3"], + ]); + + deepmergeInto(x, y); + + t.deepEqual(x, expected); + t.true(x instanceof Map); +}); + +test(`can merge array props`, (t) => { + const x = { a: [`one`, `two`] }; + const y = { a: [`one`, `three`], b: [null] }; + + const expected = { a: [`one`, `two`, `one`, `three`], b: [null] }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); + t.true(Array.isArray(x.a)); + t.true(Array.isArray(x.b)); +}); + +test(`can merge set props`, (t) => { + const x = { a: new Set([`one`, `two`]) }; + const y = { a: new Set([`one`, `three`]) }; + + const expected = { a: new Set([`one`, `two`, `three`]) }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); + t.true(x.a instanceof Set); +}); + +test(`can merge map props`, (t) => { + const x = { + a: new Map([ + ["key1", "value1"], + ["key2", "value2"], + ]), + }; + const y = { + a: new Map([ + ["key1", "changed"], + ["key3", "value3"], + ]), + }; + + const expected = { + a: new Map([ + ["key1", "changed"], + ["key2", "value2"], + ["key3", "value3"], + ]), + }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); + t.true(x.a instanceof Map); +}); + +test(`works with regular expressions`, (t) => { + const x = { key1: /abc/u }; + const y = { key1: /efg/u }; + + const expected = { key1: /efg/u }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); + t.true(x.key1 instanceof RegExp); + // eslint-disable-next-line @typescript-eslint/prefer-includes + t.true(x.key1.test(`efg`)); +}); + +test(`works with dates`, (t) => { + const x = { key1: new Date() }; + const y = { key1: new Date() }; + + const expected = { key1: y.key1 }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); + t.true(x.key1 instanceof Date); +}); + +test(`supports symbols`, (t) => { + const testSymbol1 = Symbol("test symbol 1"); + const testSymbol2 = Symbol("test symbol 2"); + const testSymbol3 = Symbol("test symbol 3"); + + const x = { [testSymbol1]: `value1`, [testSymbol2]: `value2` }; + const y = { [testSymbol1]: `changed`, [testSymbol3]: `value3` }; + + const expected = { + [testSymbol1]: `changed`, + [testSymbol2]: `value2`, + [testSymbol3]: `value3`, + }; + + deepmergeInto(x, y); + + t.deepEqual( + Object.getOwnPropertySymbols(x), + Object.getOwnPropertySymbols(expected) + ); + + t.deepEqual(x[testSymbol1], expected[testSymbol1]); + t.deepEqual(x[testSymbol2], expected[testSymbol2]); + t.deepEqual(x[testSymbol3], expected[testSymbol3]); +}); + +test("enumerable keys", (t) => { + const x = {}; + const y = {}; + + Object.defineProperties(x, { + a: { + value: 1, + enumerable: false, + }, + b: { + value: 2, + enumerable: true, + }, + }); + + Object.defineProperties(y, { + a: { + value: 3, + enumerable: false, + }, + b: { + value: 4, + enumerable: false, + }, + }); + + const expected = { b: 2 }; + + const target = {}; + deepmergeInto(target, x, y); + t.deepEqual(x, expected); + + t.throws(() => { + deepmergeInto(x, y); + }); +}); + +test(`merging objects with plain and non-plain properties`, (t) => { + const plainSymbolKey = Symbol(`plainSymbolKey`); + const parent = { + parentKey: `should be undefined`, + }; + + const x = Object.create(parent); + x.plainKey = `should be replaced`; + x[plainSymbolKey] = `should also be replaced`; + + const y = { + plainKey: `bar`, + newKey: `baz`, + [plainSymbolKey]: `qux`, + }; + + deepmergeInto(x, y); + + t.false( + Object.prototype.hasOwnProperty.call(x, "parentKey"), + `inherited properties of target should be removed, not target or ignored` + ); + t.is( + x.plainKey, + `bar`, + `enumerable own properties of target should be target` + ); + t.is(x.newKey, `baz`, `property should be target`); + t.is( + x[plainSymbolKey], + `qux`, + `enumerable own symbol properties should be target` + ); +}); + +test(`merging objects with null prototype`, (t) => { + const x = Object.create(null); + x.a = 1; + x.b = { c: [2] }; + + const y = Object.create(null); + y.b = { c: [3] }; + y.d = 4; + + const expected = { + a: 1, + b: { + c: [2, 3], + }, + d: 4, + }; + + deepmergeInto(x, y); + + t.deepEqual(x, expected); +}); + +test("dectecting valid records", (t) => { + const a = { a: 1 }; + // eslint-disable-next-line no-proto, @typescript-eslint/no-explicit-any + (a as any).__proto__.aProto = 1; + + const b = Object.create({ bProto: 2 }); + b.b = 2; + + const c = Object.create(Object.prototype); + c.c = 3; + + const d = Object.create(null); + d.d = 4; + + const expected = { + a: 1, + b: 2, + c: 3, + d: 4, + }; + + deepmergeInto(a, b, c, d); + + t.deepEqual(a, expected); +}); + +test("dectecting invalid records", (t) => { + const a = {}; + + class AClass {} + const b = new AClass(); + (b as any).a = 1; + + const c = {}; + + const expected = {}; + + deepmergeInto(a, b, c); + t.deepEqual(a, expected); +}); + +test("merging cjs modules", (t) => { + const require = createRequire(import.meta.url); + + /* eslint-disable @typescript-eslint/no-var-requires, unicorn/prefer-module */ + const a = require("./modules/a.cjs"); + const b = require("./modules/b.cjs"); + /* eslint-enable @typescript-eslint/no-var-requires, unicorn/prefer-module */ + + const expected = { + age: 30, + name: "alice", + }; + + deepmergeInto(a, b); + + t.deepEqual(a, expected); +}); + +test("merging esm modules", async (t) => { + const a = await import("./modules/a.mjs"); + const b = await import("./modules/b.mjs"); + + const expected = { + age: 30, + name: "alice", + }; + + const target = {}; + deepmergeInto(target, a, b); + t.deepEqual(target, expected); + + t.throws(() => { + deepmergeInto(a, b); + }); +}); + +test("prototype pollution", (t) => { + /* eslint-disable @typescript-eslint/no-explicit-any */ + const payload = '{"__proto__":{"a0":true}}'; + + const x: any = JSON.parse(payload); + const y: any = {}; + + deepmergeInto(x, y); + + t.deepEqual(JSON.stringify(x), payload); + + t.not(({} as any).a0, true, "Safe POJO"); + t.not(x.a0, true, "Safe x input"); + t.not(y.a0, true, "Safe y input"); + t.not(x.a0, true, "Safe output"); + /* eslint-enable @typescript-eslint/no-explicit-any */ +}); diff --git a/tests/deepmerge.test.ts b/tests/deepmerge.test.ts index 637557ea..5b1c1f6f 100644 --- a/tests/deepmerge.test.ts +++ b/tests/deepmerge.test.ts @@ -72,7 +72,7 @@ test("can merge many objects with different props", (t) => { t.deepEqual(merged, expected); }); -test("can merge with same props", (t) => { +test("can merge many objects with same props", (t) => { const x = { key1: "value1", key2: "value2" }; const y = { key1: "changed", key3: "value3" }; const z = { key3: "changed", key4: "value4" }; @@ -500,24 +500,6 @@ test("enumerable keys", (t) => { t.deepEqual(merged, expected); }); -/* eslint-disable no-proto, @typescript-eslint/naming-convention */ -test(`merging objects with own __proto__`, (t) => { - const a = { key1: "value1" }; - const malicious = { __proto__: { key2: "value2" } }; - - const merged = deepmerge(a, malicious); - - t.false( - Object.prototype.hasOwnProperty.call(merged, "__proto__"), - `non-plain properties should not be merged` - ); - t.false( - Object.prototype.hasOwnProperty.call(merged, "key2"), - `the destination should have an unmodified prototype` - ); -}); -/* eslint-enable no-proto, @typescript-eslint/naming-convention */ - test(`merging objects with plain and non-plain properties`, (t) => { const plainSymbolKey = Symbol(`plainSymbolKey`); const parent = { diff --git a/yarn.lock b/yarn.lock index 496fde8d..c232125a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1258,9 +1258,9 @@ __metadata: languageName: node linkType: hard -"@rebeccastevens/eslint-config@npm:1.5.1": - version: 1.5.1 - resolution: "@rebeccastevens/eslint-config@npm:1.5.1" +"@rebeccastevens/eslint-config@npm:1.5.2": + version: 1.5.2 + resolution: "@rebeccastevens/eslint-config@npm:1.5.2" dependencies: deepmerge-ts: ^4.2.2 peerDependencies: @@ -1278,7 +1278,7 @@ __metadata: eslint-plugin-promise: "*" eslint-plugin-sonarjs: "*" eslint-plugin-unicorn: "*" - checksum: 3ebc32a5bc60588f1e4f3388c2e40c59b26cafe182c02e8092f6d517cf6dad91698d0a3089d21b0b99788460bc47bba9605188f788372daed864a4ea3f5ba0d3 + checksum: 9205881cd9f9ac2878c74c4cbb77652d492a6b60584f972ba463c8798ebd5fd8a2a2764cb1eedd9f9edde2da01c8fe8e0017131857f4065e431ae17b460cbca1 languageName: node linkType: hard @@ -3405,7 +3405,7 @@ __metadata: "@commitlint/cli": 17.4.2 "@commitlint/config-conventional": 17.4.2 "@cspell/dict-cryptocurrencies": 3.0.1 - "@rebeccastevens/eslint-config": 1.5.1 + "@rebeccastevens/eslint-config": 1.5.2 "@rollup/plugin-json": 6.0.0 "@rollup/plugin-node-resolve": 15.0.1 "@rollup/plugin-typescript": 11.0.0 @@ -3431,7 +3431,7 @@ __metadata: eslint-import-resolver-typescript: 3.5.3 eslint-plugin-ava: 14.0.0 eslint-plugin-eslint-comments: 3.2.0 - eslint-plugin-functional: 5.0.3 + eslint-plugin-functional: 5.0.4 eslint-plugin-import: 2.27.5 eslint-plugin-jsdoc: 39.7.5 eslint-plugin-markdown: 3.0.0 @@ -3978,9 +3978,9 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-functional@npm:5.0.3": - version: 5.0.3 - resolution: "eslint-plugin-functional@npm:5.0.3" +"eslint-plugin-functional@npm:5.0.4": + version: 5.0.4 + resolution: "eslint-plugin-functional@npm:5.0.4" dependencies: "@typescript-eslint/type-utils": ^5.50.0 "@typescript-eslint/utils": ^5.50.0 @@ -3994,7 +3994,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 1f4e942adb9d2ff377fa3e56316327fce6e0aa1f7d092bbcb95fc4126ee689e5da70f7b39908b7c39e471623cb3f0acc16eae8f62f8ad9b9083e3f212190237b + checksum: f4deaadbc7958d096fd846817a256a2991fe99e3d1904da67f8fa2ca9747c488149e157954c3145bec44a2cbc7392a04cecbf463a48e64ef20cd0d6693ba69b3 languageName: node linkType: hard