Skip to content

Commit

Permalink
feat: use weak-map instead of json-stringify for circular reference h…
Browse files Browse the repository at this point in the history
…andling
  • Loading branch information
tada5hi committed May 28, 2023
1 parent fad6beb commit 33b4ea4
Show file tree
Hide file tree
Showing 7 changed files with 81 additions and 43 deletions.
63 changes: 39 additions & 24 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@

import { PriorityName } from './constants';
import type {
Merger,
Merger, MergerContext,
MergerResult,
MergerSource,
MergerSourceUnwrap,
Options,
OptionsInput,
} from './type';

Expand All @@ -21,17 +20,16 @@ import {
distinctArray,
hasOwnProperty,
isObject,
isSafeInput,
isSafeKey,
} from './utils';

function baseMerger<B extends MergerSource[]>(
options: Options,
context: MergerContext,
...sources: B
) : MergerResult<B> {
let target : MergerSourceUnwrap<B>;
let source : MergerSourceUnwrap<B> | undefined;
if (options.priority === PriorityName.RIGHT) {
if (context.options.priority === PriorityName.RIGHT) {
target = sources.pop() as MergerSourceUnwrap<B>;
source = sources.pop() as MergerSourceUnwrap<B>;
} else {
Expand All @@ -42,7 +40,7 @@ function baseMerger<B extends MergerSource[]>(
if (!source) {
if (
Array.isArray(target) &&
options.arrayDistinct
context.options.arrayDistinct
) {
return distinctArray(target) as MergerResult<B>;
}
Expand All @@ -56,21 +54,23 @@ function baseMerger<B extends MergerSource[]>(
) {
target.push(...source as MergerSource[]);

if (options.priority === PriorityName.RIGHT) {
if (context.options.priority === PriorityName.RIGHT) {
return baseMerger(
options,
context,
...sources,
target,
) as MergerResult<B>;
}

return baseMerger(
options,
context,
target,
...sources,
) as MergerResult<B>;
}

context.map.set(source, true);

if (
isObject(target) &&
isObject(source)
Expand All @@ -84,8 +84,8 @@ function baseMerger<B extends MergerSource[]>(
continue;
}

if (options.strategy) {
const applied = options.strategy(target, key as string, source[key]);
if (context.options.strategy) {
const applied = context.options.strategy(target, key as string, source[key]);
if (typeof applied !== 'undefined') {
continue;
}
Expand All @@ -95,19 +95,29 @@ function baseMerger<B extends MergerSource[]>(
isObject(target[key]) &&
isObject(source[key])
) {
if (!isSafeInput(source[key])) {
if (context.map.has(source[key])) {
const sourceKeys = Object.keys(source[key] as Record<string, any>);
for (let j = 0; j < sourceKeys.length; j++) {
if (
isSafeKey(sourceKeys[j]) &&
!hasOwnProperty(target[key] as Record<string, any>, sourceKeys[j])
) {
(target[key] as Record<string, any>)[sourceKeys[j]] = (source[key] as Record<string, any>)[sourceKeys[j]];
}
}

continue;
}

if (options.priority === PriorityName.RIGHT) {
if (context.options.priority === PriorityName.RIGHT) {
target[key] = baseMerger(
options,
context,
source[key] as MergerSource,
target[key] as MergerSource,
) as MergerSourceUnwrap<B>[keyof MergerSourceUnwrap<B>];
} else {
target[key] = baseMerger(
options,
context,
target[key] as MergerSource,
source[key] as MergerSource,
) as MergerSourceUnwrap<B>[keyof MergerSourceUnwrap<B>];
Expand All @@ -117,19 +127,19 @@ function baseMerger<B extends MergerSource[]>(
}

if (
options.array &&
context.options.array &&
Array.isArray(target[key]) &&
Array.isArray(source[key])
) {
switch (options.priority) {
switch (context.options.priority) {
case PriorityName.LEFT:
Object.assign(target, {
[key]: baseMerger(options, target[key] as MergerSource, source[key] as MergerSource),
[key]: baseMerger(context, target[key] as MergerSource, source[key] as MergerSource),
});
break;
case PriorityName.RIGHT:
Object.assign(target, {
[key]: baseMerger(options, source[key] as MergerSource, target[key] as MergerSource),
[key]: baseMerger(context, source[key] as MergerSource, target[key] as MergerSource),
});
break;
}
Expand All @@ -142,11 +152,11 @@ function baseMerger<B extends MergerSource[]>(
}
}

if (options.priority === PriorityName.RIGHT) {
return baseMerger(options, ...sources, target) as MergerResult<B>;
if (context.options.priority === PriorityName.RIGHT) {
return baseMerger(context, ...sources, target) as MergerResult<B>;
}

return baseMerger(options, target, ...sources) as MergerResult<B>;
return baseMerger(context, target, ...sources) as MergerResult<B>;
}

export function createMerger(input?: OptionsInput) : Merger {
Expand All @@ -159,8 +169,13 @@ export function createMerger(input?: OptionsInput) : Merger {
throw new SyntaxError('At least one input element is required.');
}

const ctx : MergerContext = {
options,
map: new WeakMap<any, any>(),
};

if (options.clone) {
return baseMerger(options, ...clone(sources));
return baseMerger(ctx, ...clone(sources));
}

if (!options.inPlace) {
Expand All @@ -177,7 +192,7 @@ export function createMerger(input?: OptionsInput) : Merger {
}
}

return baseMerger(options, ...sources);
return baseMerger(ctx, ...sources);
};
}

Expand Down
21 changes: 13 additions & 8 deletions src/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,6 @@ import type { PriorityName } from './constants';
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never) extends ((k: infer I)=>void) ? I : never;

export type MergerSource = any[] | Record<string, any>;

export type MergerSourceUnwrap<T extends MergerSource> = T extends Array<infer Return> ? Return : T;

export type MergerResult<B extends MergerSource> = UnionToIntersection<MergerSourceUnwrap<B>>;

export type Merger = <B extends MergerSource[]>(...sources: B) => MergerResult<B>;

export type Options = {
/**
* Merge object array properties.
Expand Down Expand Up @@ -60,3 +52,16 @@ export type Options = {
};

export type OptionsInput = Partial<Options>;

export type MergerSource = any[] | Record<string, any>;

export type MergerSourceUnwrap<T extends MergerSource> = T extends Array<infer Return> ? Return : T;

export type MergerResult<B extends MergerSource> = UnionToIntersection<MergerSourceUnwrap<B>>;

export type MergerContext = {
options: Options,
map: WeakMap<any, any>
};

export type Merger = <B extends MergerSource[]>(...sources: B) => MergerResult<B>;
9 changes: 0 additions & 9 deletions src/utils/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,6 @@ export function isObject(item: unknown) : item is Record<string, any> {
);
}

export function isSafeInput(object: any) : boolean {
try {
JSON.stringify(object);
return true;
} catch (e) {
return false;
}
}

export function isSafeKey(key: string) : boolean {
return key !== '__proto__' &&
key !== 'prototype' &&
Expand Down
2 changes: 1 addition & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ export * from './array';
export * from './check';
export * from './clone';
export * from './cut';
export * from './has-own-property';
export * from './object';
export * from './options';
File renamed without changes.
27 changes: 27 additions & 0 deletions test/unit/module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,33 @@ describe('src/module/*.ts', () => {
expect(merged).toEqual(one);
});

it('should merge circular objects', () => {
const foo : Record<string, any> = {bar: 'baz'};
foo.boz = foo;

const boo : Record<string, any> = {bar: 'baz', extra: foo};
boo.boz = boo;

const merged = merge(foo, boo);
expect(merged.extra).toBeDefined();
expect(merged.bar).toEqual('baz');
expect(merged.boz.bar).toEqual('baz');
expect(merged.boz.boz).toBeDefined();
expect(merged.boz.boz.extra).toBeDefined();
expect(merged.boz.extra).toEqual(boo.extra);
})

it('should merge circular arrays', () => {
const foo : any[] = ['bar'];
foo.push(foo);

const boo : any[] = ['bar'];
boo.push(boo);

const merged = merge(foo, boo);
expect(merged.length).toEqual(4);
})

it('should not merge unsafe key', () => {
let merger = createMerger({priority: 'right'});
const merged = merger( {prototype: null}, {prototype: 1});
Expand Down
2 changes: 1 addition & 1 deletion test/unit/utils/clone.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* view the LICENSE file that was distributed with this source code.
*/

import {polyfillClone} from "../../../src";
import { polyfillClone } from "../../../src";

describe('src/utils/clone', function () {
it('should polyfill clone objects with circular reference', () => {
Expand Down

0 comments on commit 33b4ea4

Please sign in to comment.