Skip to content

Commit

Permalink
feat(core): support cycles in deep cloning (#3322)
Browse files Browse the repository at this point in the history
Before this change, deepClone function supported
directed acyclic trees. With this change the
function will handle directed cyclic graphs.

Refs #3290
  • Loading branch information
char0n authored Oct 25, 2023
1 parent e750e72 commit a9ba0a5
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 28 deletions.
76 changes: 64 additions & 12 deletions packages/apidom-core/src/clone/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,78 @@ import ShallowCloneError from './errors/ShallowCloneError';

type FinalCloneTypes = KeyValuePair | ArraySlice | ObjectSlice;

const invokeClone = <T extends Element | FinalCloneTypes>(value: T): T => {
if (typeof value?.clone === 'function') {
return value.clone() as T;
}
return value;
type DeepCloneOptions<T extends Element | FinalCloneTypes> = {
visited?: WeakMap<T, T>;
};

export const cloneDeep = <T extends Element | FinalCloneTypes>(value: T): T => {
export const cloneDeep = <T extends Element | FinalCloneTypes>(
value: T,
options: DeepCloneOptions<T> = {},
): T => {
const { visited = new WeakMap<T, T>() } = options;
const passThroughOptions = { ...options, visited };

// detect cycle and return memoized value
if (visited.has(value)) {
return visited.get(value) as T;
}

if (value instanceof KeyValuePair) {
const { key, value: val } = value;
const keyCopy = isElement(key)
? cloneDeep(key, passThroughOptions as DeepCloneOptions<Element>)
: key;
const valueCopy = isElement(val)
? cloneDeep(val, passThroughOptions as DeepCloneOptions<Element>)
: val;
const copy = new KeyValuePair(keyCopy, valueCopy) as T;
visited.set(value, copy);
return copy;
}

if (value instanceof ObjectSlice) {
const items = [...(value as ObjectSlice)].map(invokeClone) as T[];
return new ObjectSlice(items) as T;
const mapper = (element: T) => cloneDeep(element, passThroughOptions);
const items = [...(value as ObjectSlice)].map(mapper) as T[];
const copy = new ObjectSlice(items) as T;
visited.set(value, copy);
return copy;
}

if (value instanceof ArraySlice) {
const items = [...(value as ArraySlice)].map(invokeClone) as T[];
return new ArraySlice(items) as T;
const mapper = (element: T) => cloneDeep(element, passThroughOptions);
const items = [...(value as ArraySlice)].map(mapper) as T[];
const copy = new ArraySlice(items) as T;
visited.set(value, copy);
return copy;
}

if (typeof value?.clone === 'function') {
return value.clone() as T;
if (isElement(value)) {
const copy = cloneShallow(value); // eslint-disable-line @typescript-eslint/no-use-before-define

visited.set(value, copy);

if (value.content) {
if (isElement(value.content)) {
copy.content = cloneDeep(
value.content,
passThroughOptions as DeepCloneOptions<Element>,
) as any;
} else if ((value.content as unknown) instanceof KeyValuePair) {
copy.content = cloneDeep(
value.content as unknown as KeyValuePair,
passThroughOptions as DeepCloneOptions<KeyValuePair>,
) as any;
} else if (Array.isArray(value.content)) {
const mapper = (element: unknown) => cloneDeep(element as T, passThroughOptions);
copy.content = value.content.map(mapper);
} else {
copy.content = value.content;
}
} else {
copy.content = value.content;
}

return copy;
}

throw new DeepCloneError("Value provided to cloneDeep function couldn't be cloned", {
Expand Down
52 changes: 36 additions & 16 deletions packages/apidom-core/test/clone/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,28 +119,48 @@ describe('clone', function () {
});

context('cloneDeep', function () {
specify('should deep clone ObjectElement', function () {
const valueElement = new ArrayElement([1]);
const objectElement = new ObjectElement({ a: valueElement });
const clone = cloneDeep(objectElement);
context('given ObjectElement', function () {
specify('should deep clone', function () {
const valueElement = new ArrayElement([1]);
const objectElement = new ObjectElement({ a: valueElement });
const clone = cloneDeep(objectElement);

objectElement.set('c', 'd');
valueElement.push(2);
objectElement.set('c', 'd');
valueElement.push(2);

assert.notStrictEqual(clone, objectElement);
assert.deepEqual(toValue(clone), { a: [1] });
assert.notStrictEqual(clone, objectElement);
assert.deepEqual(toValue(clone), { a: [1] });
});

specify('should deep clone with cycles', function () {
const objectElement = new ObjectElement({ a: 'b' });
objectElement.set('c', objectElement);
const clone = cloneDeep(objectElement);

assert.strictEqual(clone, clone.get('c'));
});
});

specify('should deep clone ArrayElement', function () {
const firstItemElement = new ObjectElement({ a: 'b' });
const arrayElement = new ArrayElement([firstItemElement, 2, 3]);
const clone = cloneDeep(arrayElement);
context('given ArrayElement', function () {
specify('should deep clone', function () {
const firstItemElement = new ObjectElement({ a: 'b' });
const arrayElement = new ArrayElement([firstItemElement, 2, 3]);
const clone = cloneDeep(arrayElement);

arrayElement.push(4);
firstItemElement.set('a', 'c');
arrayElement.push(4);
firstItemElement.set('a', 'c');

assert.notStrictEqual(clone, arrayElement);
assert.deepEqual(toValue(clone), [{ a: 'b' }, 2, 3]);
assert.notStrictEqual(clone, arrayElement);
assert.deepEqual(toValue(clone), [{ a: 'b' }, 2, 3]);
});

specify('should deep clone with cycles', function () {
const arrayElement = new ArrayElement([1]);
arrayElement.push(arrayElement);
const clone = cloneDeep(arrayElement);

assert.strictEqual(clone, clone.get(1));
});
});

specify('should deep clone NumberElement', function () {
Expand Down

0 comments on commit a9ba0a5

Please sign in to comment.