Skip to content

Commit

Permalink
Add "useCloneable" option
Browse files Browse the repository at this point in the history
Refactored to allow developers to disable the cloneable behavior.
  • Loading branch information
aedart committed Feb 20, 2024
1 parent 62c3b63 commit ba774ba
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 22 deletions.
32 changes: 32 additions & 0 deletions packages/contracts/src/support/objects/MergeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 37 additions & 21 deletions packages/support/src/objects/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -227,7 +228,7 @@ export const defaultMergeCallback: MergeCallback = function(
function performMerge(sources: object[], options: Readonly<MergeOptions>, 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,
Expand All @@ -248,22 +249,11 @@ function performMerge(sources: object[], options: Readonly<MergeOptions>, 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
Expand All @@ -275,7 +265,7 @@ function performMerge(sources: object[], options: Readonly<MergeOptions>, 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],
Expand All @@ -290,6 +280,32 @@ function performMerge(sources: object[], options: Readonly<MergeOptions>, 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()`
*
Expand All @@ -301,7 +317,7 @@ function performMerge(sources: object[], options: Readonly<MergeOptions>, 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
Expand All @@ -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) {
Expand Down
60 changes: 59 additions & 1 deletion tests/browser/packages/support/objects/merge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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);
});
});
});

0 comments on commit ba774ba

Please sign in to comment.