From ba774ba8fa9b0144c0a3eb797136feee8708ceb4 Mon Sep 17 00:00:00 2001 From: Alin Eugen Deac Date: Tue, 20 Feb 2024 21:18:11 +0100 Subject: [PATCH] Add "useCloneable" option Refactored to allow developers to disable the cloneable behavior. --- .../src/support/objects/MergeOptions.ts | 32 ++++++++++ packages/support/src/objects/merge.ts | 58 +++++++++++------- .../packages/support/objects/merge.test.js | 60 ++++++++++++++++++- 3 files changed, 128 insertions(+), 22 deletions(-) diff --git a/packages/contracts/src/support/objects/MergeOptions.ts b/packages/contracts/src/support/objects/MergeOptions.ts index 617d0c9c..e155b69a 100644 --- a/packages/contracts/src/support/objects/MergeOptions.ts +++ b/packages/contracts/src/support/objects/MergeOptions.ts @@ -65,6 +65,38 @@ export default interface MergeOptions */ overwriteWithUndefined?: boolean; + /** + * Flag, if source object is [`Cloneable`]{@link import('@aedart/contracts/support/objects').Cloneable}, then the + * resulting object from the `clone()` method is used. + * + * **When `true` (_default behaviour_)**: _If source object is cloneable then the resulting object from `clone()` + * method is used. Its properties are then iterated by the merge function._ + * + * **When `false`**: _Cloneable objects are treated like any other objects, the `clone()` method is ignored._ + * + * **Example:** + * ```js + * const a = { 'foo': { 'name': 'John Doe' } }; + * const b = { 'foo': { + * 'name': 'Jane Doe', + * clone() { + * return { + * 'name': 'Rick Doe', + * 'age': 26 + * } + * } + * } }; + * + * merge([ a, b ]); // { 'foo': { 'name': 'Rick Doe', 'age': 26 } } + * merge([ a, b ], { useCloneable: false }); // { 'foo': { 'name': 'Jane Doe', clone() {...} } } + * ``` + * + * @see [`Cloneable`]{@link import('@aedart/contracts/support/objects').Cloneable} + * + * @type {boolean} + */ + useCloneable?: boolean; + /** * Flag, whether to merge array, array-like, and [concat spreadable]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/isConcatSpreadable} * properties or not. diff --git a/packages/support/src/objects/merge.ts b/packages/support/src/objects/merge.ts index ca8b79d3..e7cfd74e 100644 --- a/packages/support/src/objects/merge.ts +++ b/packages/support/src/objects/merge.ts @@ -30,6 +30,7 @@ export const DEFAULT_MERGE_OPTIONS: MergeOptions = { depth: DEFAULT_MAX_MERGE_DEPTH, skip: DEFAULT_MERGE_SKIP_KEYS, overwriteWithUndefined: true, + useCloneable: true, mergeArrays: false, }; Object.freeze(DEFAULT_MERGE_OPTIONS); @@ -172,7 +173,7 @@ export const defaultMergeCallback: MergeCallback = function( // Objects (of "native" kind) - - - - - - - - - - - - - - - - - - - - - - - - // Clone the object of a "native" kind value, if supported. - if (canCloneObjectValue(value)) { + if (canCloneUsingStructuredClone(value)) { return structuredClone(value); } @@ -227,7 +228,7 @@ export const defaultMergeCallback: MergeCallback = function( function performMerge(sources: object[], options: Readonly, depth: number = 0): object { // Abort if maximum depth has been reached - if (depth > options.depth) { + if (depth > (options.depth as number)) { throw new MergeError(`Maximum merge depth (${options.depth}) has been exceeded`, { cause: { source: sources, @@ -248,22 +249,11 @@ function performMerge(sources: object[], options: Readonly, depth: }); } - // Favour "clone()" method return object instead of the source object, if the source implements - // the Cloneable interface. - const cloneable: boolean = isCloneable(source); - let resolvedSource: object = cloneable - ? (source as Cloneable).clone() - : source; - - // Abort if clone() returned invalid type... - if (cloneable && (!resolvedSource || typeof resolvedSource != 'object' || Array.isArray(resolvedSource))) { - throw new MergeError(`Expected clone() method to return object for source, (source index: ${index})`, { - cause: { - source: source, - index: index, - depth: depth - } - }); + let resolvedSource: object = source; + + // If allowed and source implements "Cloneable" interface, favour "clone()" method's resulting object. + if (options.useCloneable && isCloneable(source)) { + resolvedSource = cloneSource(source as Cloneable); } // Iterate through all properties, including symbols @@ -275,7 +265,7 @@ function performMerge(sources: object[], options: Readonly, depth: } // Resolve the value via callback and set it in resulting object. - result[key] = options.callback( + result[key] = (options.callback as MergeCallback)( result, key, resolvedSource[key], @@ -290,6 +280,32 @@ function performMerge(sources: object[], options: Readonly, depth: }, Object.create(null)); } +/** + * Returns source object's clone, from it's + * + * @internal + * + * @param {Cloneable} source + * + * @returns {object} + */ +function cloneSource(source: Cloneable): object +{ + const clone: object = source.clone(); + + // Abort if resulting value from "clone()" is not a valid value... + if (!clone || typeof clone != 'object' || Array.isArray(clone)) { + throw new MergeError(`Expected clone() method to return object for source, ${descTag(clone)} was returned`, { + cause: { + source: source, + clone: clone, + } + }); + } + + return clone; +} + /** * Determine if an object value can be cloned via `structuredClone()` * @@ -301,7 +317,7 @@ function performMerge(sources: object[], options: Readonly, depth: * * @return {boolean} */ -function canCloneObjectValue(value: object): boolean +function canCloneUsingStructuredClone(value: object): boolean { const supported: Constructor[] = [ // Array, // Handled by array, with evt. array value merges @@ -317,7 +333,7 @@ function canCloneObjectValue(value: object): boolean RegExp, Set, String, - TYPED_ARRAY_PROTOTYPE + TYPED_ARRAY_PROTOTYPE as Constructor ]; for (const constructor of supported) { diff --git a/tests/browser/packages/support/objects/merge.test.js b/tests/browser/packages/support/objects/merge.test.js index 8ea1317f..1ebbe1b4 100644 --- a/tests/browser/packages/support/objects/merge.test.js +++ b/tests/browser/packages/support/objects/merge.test.js @@ -457,7 +457,7 @@ describe('@aedart/support/objects', () => { const result = merge([a, b], { mergeArrays: true }); // Debug - console.log('result', result); + // console.log('result', result); expect(JSON.stringify(result.a)) .withContext('a) should have merged existing array with array-like object') @@ -807,5 +807,63 @@ describe('@aedart/support/objects', () => { .withContext('Other properties are not merged in correctly') .toBe(42) }); + + it('can disable cloneable behaviour', () => { + + const a = { + a: { + name: 'John', + } + }; + + const b = { + a: { + name: 'Jim', + clone: () => { + return { + name: 'Rick' + } + } + } + }; + + // --------------------------------------------------------------------- // + + const result = merge([ a, b ], { useCloneable: false }); + + // Debug + // console.log('result', result); + + expect(result.a.name) + .withContext('Clone was not disabled') + .toBe('Jim'); + }); + + it('fails when cloneable source returns invalid value', () => { + + const a = { + a: { + name: 'John', + } + }; + + const b = { + a: { + name: 'Jim', + clone: () => { + return null; // Should cause error + } + } + }; + + // --------------------------------------------------------------------- // + + const callback = () => { + return merge([ a, b ]); + } + + expect(callback) + .toThrowError(MergeError); + }); }); }); \ No newline at end of file