Skip to content

Commit

Permalink
Move mergeDeep helper from apollo-cache-inmemory to apollo-utilities.
Browse files Browse the repository at this point in the history
  • Loading branch information
benjamn committed Jan 24, 2019
1 parent c3cb3a9 commit 8af8875
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 85 deletions.
75 changes: 3 additions & 72 deletions packages/apollo-cache-inmemory/src/readFromStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
resultKeyNameFromField,
shouldInclude,
toIdValue,
mergeDeepArray,
} from 'apollo-utilities';

import { Cache } from 'apollo-cache';
Expand Down Expand Up @@ -296,9 +297,7 @@ export class StoreReader {
execContext,
}: ExecSelectionSetOptions): ExecResult {
const { fragmentMap, contextValue, variableValues: variables } = execContext;
const finalResult: ExecResult = {
result: {},
};
const finalResult: ExecResult = { result: null };

const objectsToMerge: { [key: string]: any }[] = [];

Expand Down Expand Up @@ -374,7 +373,7 @@ export class StoreReader {

// Perform a single merge at the end so that we can avoid making more
// defensive shallow copies than necessary.
merge(finalResult.result, objectsToMerge);
finalResult.result = mergeDeepArray(objectsToMerge);

return finalResult;
}
Expand Down Expand Up @@ -589,71 +588,3 @@ function readStoreResolver(
result: fieldValue,
};
}

const hasOwn = Object.prototype.hasOwnProperty;

function merge(
target: { [key: string]: any },
sources: { [key: string]: any }[]
) {
const pastCopies: any[] = [];
sources.forEach(source => {
mergeHelper(target, source, pastCopies);
});
return target;
}

function mergeHelper(
target: { [key: string]: any },
source: { [key: string]: any },
pastCopies: any[],
) {
if (source !== null && typeof source === 'object') {
// In case the target has been frozen, make an extensible copy so that
// we can merge properties into the copy.
if (Object.isExtensible && !Object.isExtensible(target)) {
target = shallowCopyForMerge(target, pastCopies);
}

Object.keys(source).forEach(sourceKey => {
const sourceValue = source[sourceKey];
if (hasOwn.call(target, sourceKey)) {
const targetValue = target[sourceKey];
if (sourceValue !== targetValue) {
// When there is a key collision, we need to make a shallow copy of
// target[sourceKey] so the merge does not modify any source objects.
// To avoid making unnecessary copies, we use a simple array to track
// past copies, instead of a Map, since the number of copies should
// be relatively small, and some Map polyfills modify their keys.
target[sourceKey] = mergeHelper(
shallowCopyForMerge(targetValue, pastCopies),
sourceValue,
pastCopies,
);
}
} else {
// If there is no collision, the target can safely share memory with
// the source, and the recursion can terminate here.
target[sourceKey] = sourceValue;
}
});
}

return target;
}

function shallowCopyForMerge<T>(value: T, pastCopies: any[]): T {
if (
value !== null &&
typeof value === 'object' &&
pastCopies.indexOf(value) < 0
) {
if (Array.isArray(value)) {
value = (value as any).slice(0);
} else {
value = { ...(value as any) };
}
pastCopies.push(value);
}
return value;
}
1 change: 1 addition & 0 deletions packages/apollo-utilities/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from './util/environment';
export * from './util/errorHandling';
export * from './util/isEqual';
export * from './util/maybeDeepFreeze';
export * from './util/mergeDeep';
export * from './util/warnOnce';
export * from './util/stripSymbols';
export * from './util/mergeDeep';
88 changes: 88 additions & 0 deletions packages/apollo-utilities/src/util/__tests__/mergeDeep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { mergeDeep } from '../mergeDeep';

describe('mergeDeep', function() {
it('should return an object if first argument falsy', function() {
expect(mergeDeep()).toEqual({});
expect(mergeDeep(null)).toEqual({});
expect(mergeDeep(null, { foo: 42 })).toEqual({ foo: 42 });
});

it('should preserve identity for single arguments', function() {
const arg = Object.create(null);
expect(mergeDeep(arg)).toBe(arg);
});

it('should preserve identity when merging non-conflicting objects', function() {
const a = { a: { name: 'ay' } };
const b = { b: { name: 'bee' } };
const c = mergeDeep(a, b);
expect(c.a).toBe(a.a);
expect(c.b).toBe(b.b);
expect(c).toEqual({
a: { name: 'ay' },
b: { name: 'bee' },
});
});

it('should shallow-copy conflicting fields', function() {
const a = { conflict: { fromA: [1, 2, 3] } };
const b = { conflict: { fromB: [4, 5] } };
const c = mergeDeep(a, b);
expect(c.conflict).not.toBe(a.conflict);
expect(c.conflict).not.toBe(b.conflict);
expect(c.conflict.fromA).toBe(a.conflict.fromA);
expect(c.conflict.fromB).toBe(b.conflict.fromB);
expect(c).toEqual({
conflict: {
fromA: [1, 2, 3],
fromB: [4, 5],
},
});
});

it('should resolve conflicts among more than two objects', function() {
const sources = [];

for (let i = 0; i < 100; ++i) {
sources.push({
['unique' + i]: { value: i },
conflict: {
['from' + i]: { value: i },
nested: {
['nested' + i]: { value: i },
},
},
});
}

const merged = mergeDeep(...sources);

sources.forEach((source, i) => {
expect(merged['unique' + i].value).toBe(i);
expect(source['unique' + i]).toBe(merged['unique' + i]);

expect(merged.conflict).not.toBe(source.conflict);
expect(merged.conflict['from' + i].value).toBe(i);
expect(merged.conflict['from' + i]).toBe(source.conflict['from' + i]);

expect(merged.conflict.nested).not.toBe(source.conflict.nested);
expect(merged.conflict.nested['nested' + i].value).toBe(i);
expect(merged.conflict.nested['nested' + i]).toBe(
source.conflict.nested['nested' + i],
);
});
});

it('can merge array elements', function() {
const a = [{ a: 1 }, { a: 'ay' }, 'a'];
const b = [{ b: 2 }, { b: 'bee' }, 'b'];
const c = [{ c: 3 }, { c: 'cee' }, 'c'];
const d = { 1: { d: 'dee' } };

expect(mergeDeep(a, b, c, d)).toEqual([
{ a: 1, b: 2, c: 3 },
{ a: 'ay', b: 'bee', c: 'cee', d: 'dee' },
'a',
]);
});
});
84 changes: 71 additions & 13 deletions packages/apollo-utilities/src/util/mergeDeep.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,79 @@
function isObject(item: any): boolean {
return item && typeof item === 'object' && !Array.isArray(item);
const { hasOwnProperty } = Object.prototype;

// These mergeDeep and mergeDeepArray utilities merge any number of objects
// together, sharing as much memory as possible with the source objects, while
// remaining careful to avoid modifying any source objects.

export function mergeDeep(...sources: any[]) {
return mergeDeepArray(sources);
}

export function mergeDeepArray(sources: any[]) {
let first = sources[0] || {};
const count = sources.length;
if (count > 1) {
const pastCopies: any[] = [];
first = shallowCopyForMerge(first, pastCopies);
for (let i = 1; i < count; ++i) {
mergeHelper(first, sources[i], pastCopies);
}
}
return first;
}

export function mergeDeep(target: any, source: any): any {
let output = Object.assign({}, target);
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach(key => {
if (isObject(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] });
} else {
output[key] = mergeDeep(target[key], source[key]);
function mergeHelper(
target: Record<string, any>,
source: Record<string, any>,
pastCopies: any[],
) {
if (source !== null && typeof source === 'object') {
// In case the target has been frozen, make an extensible copy so that
// we can merge properties into the copy.
if (Object.isExtensible && !Object.isExtensible(target)) {
target = shallowCopyForMerge(target, pastCopies);
}

Object.keys(source).forEach(sourceKey => {
const sourceValue = source[sourceKey];
if (hasOwnProperty.call(target, sourceKey)) {
const targetValue = target[sourceKey];
if (sourceValue !== targetValue) {
// When there is a key collision, we need to make a shallow copy of
// target[sourceKey] so the merge does not modify any source objects.
// To avoid making unnecessary copies, we use a simple array to track
// past copies, since it's safe to modify copies created earlier in
// the merge. We use an array for pastCopies instead of a Map or Set,
// since the number of copies should be relatively small, and some
// Map/Set polyfills modify their keys.
target[sourceKey] = mergeHelper(
shallowCopyForMerge(targetValue, pastCopies),
sourceValue,
pastCopies,
);
}
} else {
Object.assign(output, { [key]: source[key] });
// If there is no collision, the target can safely share memory with
// the source, and the recursion can terminate here.
target[sourceKey] = sourceValue;
}
});
}
return output;

return target;
}

function shallowCopyForMerge<T>(value: T, pastCopies: any[]): T {
if (
value !== null &&
typeof value === 'object' &&
pastCopies.indexOf(value) < 0
) {
if (Array.isArray(value)) {
value = (value as any).slice(0);
} else {
value = { ...(value as any) };
}
pastCopies.push(value);
}
return value;
}

0 comments on commit 8af8875

Please sign in to comment.