Skip to content

Commit

Permalink
Efficiently track locations of incoming data to be merged.
Browse files Browse the repository at this point in the history
Instead of recursively searching for FieldValueToBeMerged wrapper objects
anywhere in the incoming data, processSelectionSet and processFieldValue
can build a sparse tree specifying just the paths of fields that need to
be merged, and then applyMerges can use that tree to traverse only the
parts of the data where merge functions need to be called.

These changes effectively revert #5880, since the idea of giving merge
functions a chance to transform their child data before calling nested
merge functions no longer makes as much sense. Instead, applyMerges will
be recursively called on the child data before parent merge functions run,
the way it used to be (before #5880).
  • Loading branch information
benjamn committed Sep 24, 2020
1 parent 45bfd4d commit 55e5511
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 174 deletions.
15 changes: 10 additions & 5 deletions src/cache/inmemory/entityStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ export abstract class EntityStore implements NormalizedCache {

public abstract getStorage(
idOrObj: string | StoreObject,
storeFieldName: string,
storeFieldName: string | number,
): StorageType;

// Maps root entity IDs to the number of times they have been retained, minus
Expand Down Expand Up @@ -353,7 +353,7 @@ export abstract class EntityStore implements NormalizedCache {
// Bound function that can be passed around to provide easy access to fields
// of Reference objects as well as ordinary objects.
public getFieldValue = <T = StoreValue>(
objectOrReference: StoreObject | Reference,
objectOrReference: StoreObject | Reference | undefined,
storeFieldName: string,
) => maybeDeepFreeze(
isReference(objectOrReference)
Expand Down Expand Up @@ -484,9 +484,14 @@ export namespace EntityStore {
public readonly storageTrie = new KeyTrie<StorageType>(canUseWeakMap);
public getStorage(
idOrObj: string | StoreObject,
storeFieldName: string,
storeFieldName: string | number,
): StorageType {
return this.storageTrie.lookup(idOrObj, storeFieldName);
return this.storageTrie.lookup(
idOrObj,
// Normalize numbers to strings, so we don't accidentally end up
// with multiple storage objects for the same field.
String(storeFieldName),
);
}
}
}
Expand Down Expand Up @@ -555,7 +560,7 @@ class Layer extends EntityStore {

public getStorage(
idOrObj: string | StoreObject,
storeFieldName: string,
storeFieldName: string | number,
): StorageType {
return this.parent.getStorage(idOrObj, storeFieldName);
}
Expand Down
57 changes: 2 additions & 55 deletions src/cache/inmemory/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FieldNode, SelectionSetNode } from 'graphql';
import { SelectionSetNode } from 'graphql';

import { NormalizedCache } from './types';
import {
Expand All @@ -8,7 +8,6 @@ import {
StoreObject,
isField,
DeepMerger,
ReconcilerFunction,
resultKeyNameFromField,
shouldInclude,
} from '../../utilities';
Expand Down Expand Up @@ -57,17 +56,6 @@ export function selectionSetMatchesResult(
return false;
}

// Invoking merge functions needs to happen after processSelectionSet has
// finished, but requires information that is more readily available
// during processSelectionSet, so processSelectionSet embeds special
// objects of the following shape within its result tree, which then must
// be removed by calling Policies#applyMerges.
export interface FieldValueToBeMerged {
__field: FieldNode;
__typename: string;
__value: StoreValue;
}

export function storeValueIsStoreObject(
value: StoreValue,
): value is StoreObject {
Expand All @@ -77,47 +65,6 @@ export function storeValueIsStoreObject(
!Array.isArray(value);
}

export function isFieldValueToBeMerged(
value: any,
): value is FieldValueToBeMerged {
const field = value && value.__field;
return field && isField(field);
}

export function makeProcessedFieldsMerger() {
// A DeepMerger that merges arrays and objects structurally, but otherwise
// prefers incoming scalar values over existing values. Provides special
// treatment for FieldValueToBeMerged objects. Used to accumulate fields
// when processing a single selection set.
return new DeepMerger(reconcileProcessedFields);
}

const reconcileProcessedFields: ReconcilerFunction<[]> = function (
existingObject,
incomingObject,
property,
) {
const existing = existingObject[property];
const incoming = incomingObject[property];

if (isFieldValueToBeMerged(existing)) {
existing.__value = this.merge(
existing.__value,
isFieldValueToBeMerged(incoming)
// TODO Check compatibility of __field and __typename properties?
? incoming.__value
: incoming,
);
return existing;
}

if (isFieldValueToBeMerged(incoming)) {
incoming.__value = this.merge(
existing,
incoming.__value,
);
return incoming;
}

return this.merge(existing, incoming);
return new DeepMerger;
}
102 changes: 17 additions & 85 deletions src/cache/inmemory/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,10 @@ import {
canUseWeakMap,
compact,
} from '../../utilities';
import { IdGetter, ReadMergeModifyContext } from "./types";
import { IdGetter, ReadMergeModifyContext, MergeInfo } from "./types";
import {
hasOwn,
fieldNameFromStoreName,
FieldValueToBeMerged,
isFieldValueToBeMerged,
storeValueIsStoreObject,
selectionSetMatchesResult,
TypeOrFieldNameRegExp,
Expand Down Expand Up @@ -715,21 +713,17 @@ export class Policies {
return !!(policy && policy.merge);
}

public applyMerges<T extends StoreValue>(
existing: T | Reference,
incoming: T | FieldValueToBeMerged,
public runMergeFunction(
existing: StoreValue,
incoming: StoreValue,
{ field, typename }: MergeInfo,
context: ReadMergeModifyContext,
storageKeys?: [string | StoreObject, string],
): T {
if (isFieldValueToBeMerged(incoming)) {
const field = incoming.__field;
const fieldName = field.name.value;
// This policy and its merge function are guaranteed to exist
// because the incoming value is a FieldValueToBeMerged object.
const { merge } = this.getFieldPolicy(
incoming.__typename, fieldName, false)!;

incoming = merge!(existing, incoming.__value, makeFieldFunctionOptions(
storage?: StorageType,
) {
const fieldName = field.name.value;
const { merge } = this.getFieldPolicy(typename, fieldName, false)!;
if (merge) {
return merge(existing, incoming, makeFieldFunctionOptions(
this,
// Unlike options.readField for read functions, we do not fall
// back to the current object if no foreignObjOrRef is provided,
Expand All @@ -743,69 +737,13 @@ export class Policies {
// However, readField(name, ref) is useful for merge functions
// that need to deduplicate child objects and references.
void 0,
{ typename: incoming.__typename,
{ typename,
fieldName,
field,
variables: context.variables },
context,
storageKeys
? context.store.getStorage(...storageKeys)
: Object.create(null),
)) as T;
}

if (Array.isArray(incoming)) {
return incoming!.map(item => this.applyMerges(
// Items in the same position in different arrays are not
// necessarily related to each other, so there is no basis for
// merging them. Passing void here means any FieldValueToBeMerged
// objects within item will be handled as if there was no existing
// data. Also, we do not pass storageKeys because the array itself
// is never an entity with a __typename, so its indices can never
// have custom read or merge functions.
void 0,
item,
context,
)) as T;
}

if (storeValueIsStoreObject(incoming)) {
const e = existing as StoreObject | Reference;
const i = incoming as StoreObject;

// If the existing object is a { __ref } object, e.__ref provides a
// stable key for looking up the storage object associated with
// e.__ref and storeFieldName. Otherwise, storage is enabled only if
// existing is actually a non-null object. It's less common for a
// merge function to use options.storage, but it's conceivable that a
// pair of read and merge functions might want to cooperate in
// managing their shared options.storage object.
const firstStorageKey = isReference(e)
? e.__ref
: typeof e === "object" && e;

let newFields: StoreObject | undefined;

Object.keys(i).forEach(storeFieldName => {
const incomingValue = i[storeFieldName];
const appliedValue = this.applyMerges(
context.store.getFieldValue(e, storeFieldName),
incomingValue,
context,
// Avoid enabling options.storage when firstStorageKey is falsy,
// which implies no options.storage object has ever been created
// for a read/merge function for this field.
firstStorageKey ? [firstStorageKey, storeFieldName] : void 0,
);
if (appliedValue !== incomingValue) {
newFields = newFields || Object.create(null);
newFields![storeFieldName] = appliedValue;
}
});

if (newFields) {
return { ...i, ...newFields } as typeof incoming;
}
storage || Object.create(null),
));
}

return incoming;
Expand Down Expand Up @@ -872,21 +810,15 @@ function makeFieldFunctionOptions(
const iType = getFieldValue(incoming, "__typename");
const typesDiffer = eType && iType && eType !== iType;

const applied = policies.applyMerges(
typesDiffer ? void 0 : existing,
incoming,
context,
);

if (
typesDiffer ||
!storeValueIsStoreObject(existing) ||
!storeValueIsStoreObject(applied)
!storeValueIsStoreObject(incoming)
) {
return applied;
return incoming;
}

return { ...existing, ...applied };
return { ...existing, ...incoming };
}

return incoming;
Expand Down
14 changes: 12 additions & 2 deletions src/cache/inmemory/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DocumentNode } from 'graphql';
import { DocumentNode, FieldNode } from 'graphql';

import { Transaction } from '../core/cache';
import {
Expand Down Expand Up @@ -65,7 +65,7 @@ export interface NormalizedCache {

getStorage(
idOrObj: string | StoreObject,
storeFieldName: string,
storeFieldName: string | number,
): StorageType;
}

Expand Down Expand Up @@ -101,6 +101,16 @@ export type ApolloReducerConfig = {
addTypename?: boolean;
};

export interface MergeInfo {
field: FieldNode;
typename: string | undefined;
};

export interface MergeTree {
info?: MergeInfo;
map: Map<string | number, MergeTree>;
};

export interface ReadMergeModifyContext {
store: NormalizedCache;
variables?: Record<string, any>;
Expand Down
Loading

0 comments on commit 55e5511

Please sign in to comment.