Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow merge:true to merge references with non-normalized objects, and vice-versa. #7778

Merged
merged 5 commits into from
Mar 3, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ TBD
- The `FetchMoreQueryOptions` type now takes two instead of three type parameters (`<TVariables, TData>`), thanks to using `Partial<TVariables>` instead of `K extends typeof TVariables` and `Pick<TVariables, K>`. <br/>
[@ArnaudBarre](https://github.com/ArnaudBarre) in [#7476](https://github.com/apollographql/apollo-client/pull/7476)

- Allow `merge: true` field policy to merge `Reference` objects with non-normalized objects, and vice-versa. <br/>
[@benjamn](https://github.com/benjamn) in [#7778](https://github.com/apollographql/apollo-client/pull/7778)

### Documentation
TBD

Expand Down
182 changes: 182 additions & 0 deletions src/cache/inmemory/__tests__/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4294,6 +4294,188 @@ describe("type policies", function () {
expect(personMergeCount).toBe(3);
});

it("can force merging references with non-normalized objects", function () {
const nameQuery = gql`
query GetName {
viewer {
name
}
}
`;

const emailQuery = gql`
query GetEmail {
viewer {
id
email
}
}
`;

check(new InMemoryCache({
typePolicies: {
Query: {
fields: {
viewer: {
merge: true,
},
},
},
},
}));

check(new InMemoryCache({
typePolicies: {
User: {
merge: true,
},
},
}));

function check(cache: InMemoryCache) {
// Write nameQuery first, so the existing data will be a
// non-normalized object when we write emailQuery next.
cache.writeQuery({
query: nameQuery,
data: {
viewer: {
__typename: "User",
name: "Alice",
},
},
});

expect(cache.extract()).toEqual({
ROOT_QUERY: {
__typename: "Query",
viewer: {
__typename: "User",
name: "Alice",
},
},
});

cache.writeQuery({
query: emailQuery,
data: {
viewer: {
__typename: "User",
id: 12345,
email: "alice@example.com",
},
},
});

expect(cache.extract()).toEqual({
ROOT_QUERY: {
__typename: "Query",
viewer: {
__ref: "User:12345",
},
},
"User:12345": {
__typename: "User",
name: "Alice",
id: 12345,
email: "alice@example.com",
},
});

expect(cache.readQuery({
query: nameQuery,
})).toEqual({
viewer: {
__typename: "User",
name: "Alice",
},
});

expect(cache.readQuery({
query: emailQuery,
})).toEqual({
viewer: {
__typename: "User",
id: 12345,
email: "alice@example.com",
},
});

cache.reset();
expect(cache.extract()).toEqual({});

// Write emailQuery first, so the existing data will be a
// normalized reference when we write nameQuery next.
cache.writeQuery({
query: emailQuery,
data: {
viewer: {
__typename: "User",
id: 12345,
email: "alice@example.com",
},
},
});

expect(cache.extract()).toEqual({
"User:12345": {
id: 12345,
__typename: "User",
email: "alice@example.com"
},
ROOT_QUERY: {
__typename: "Query",
viewer: {
__ref: "User:12345",
},
},
});

cache.writeQuery({
query: nameQuery,
data: {
viewer: {
__typename: "User",
name: "Alice",
},
},
});

expect(cache.extract()).toEqual({
"User:12345": {
id: 12345,
__typename: "User",
email: "alice@example.com",
name: "Alice",
},
ROOT_QUERY: {
__typename: "Query",
viewer: {
__ref: "User:12345",
},
},
});

expect(cache.readQuery({
query: nameQuery,
})).toEqual({
viewer: {
__typename: "User",
name: "Alice",
},
});

expect(cache.readQuery({
query: emailQuery,
})).toEqual({
viewer: {
__typename: "User",
id: 12345,
email: "alice@example.com",
},
});
}
});

it("can force merging with inherited field merge function", function () {
let authorMergeCount = 0;

Expand Down
32 changes: 29 additions & 3 deletions src/cache/inmemory/entityStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { dep, OptimisticDependencyFunction } from 'optimism';
import invariant from 'ts-invariant';
import { equal } from '@wry/equality';
import { Trie } from '@wry/trie';

Expand Down Expand Up @@ -94,13 +95,38 @@ export abstract class EntityStore implements NormalizedCache {
}
}

public merge(dataId: string, incoming: StoreObject): void {
const existing = this.lookup(dataId);
public merge(
older: string | StoreObject,
newer: StoreObject | string,
): void {
let dataId: string | undefined;

const existing: StoreObject | undefined =
typeof older === "string"
? this.lookup(dataId = older)
: older;

const incoming: StoreObject | undefined =
typeof newer === "string"
? this.lookup(dataId = newer)
: newer;

// If newer was a string ID, but that ID was not defined in this store,
// then there are no fields to be merged, so we're done.
if (!incoming) return;

invariant(
typeof dataId === "string",
"store.merge expects a string ID",
);

const merged: StoreObject =
new DeepMerger(storeObjectReconciler).merge(existing, incoming);

// Even if merged === existing, existing may have come from a lower
// layer, so we always need to set this.data[dataId] on this level.
this.data[dataId] = merged;

if (merged !== existing) {
delete this.refs[dataId];
if (this.group.caching) {
Expand Down Expand Up @@ -142,7 +168,7 @@ export abstract class EntityStore implements NormalizedCache {
});

Object.keys(fieldsToDirty).forEach(
fieldName => this.group.dirty(dataId, fieldName));
fieldName => this.group.dirty(dataId as string, fieldName));
}
}
}
Expand Down
41 changes: 29 additions & 12 deletions src/cache/inmemory/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ import {
canUseWeakMap,
compact,
} from '../../utilities';
import { IdGetter, ReadMergeModifyContext, MergeInfo } from "./types";
import {
IdGetter,
MergeInfo,
NormalizedCache,
ReadMergeModifyContext,
} from "./types";
import {
hasOwn,
fieldNameFromStoreName,
Expand All @@ -41,7 +46,6 @@ import {
ReadFieldOptions,
CanReadFunction,
} from '../core/types/common';
import { FieldValueGetter } from './entityStore';

export type TypePolicies = {
[__typename: string]: TypePolicy;
Expand Down Expand Up @@ -788,7 +792,7 @@ export class Policies {
// FieldFunctionOptions object and calling mergeTrueFn, we can
// simply call mergeObjects, as mergeTrueFn would.
return makeMergeObjectsFunction(
context.store.getFieldValue
context.store,
)(existing as StoreObject,
incoming as StoreObject);
}
Expand Down Expand Up @@ -832,7 +836,7 @@ function makeFieldFunctionOptions(
const storeFieldName = policies.getStoreFieldName(fieldSpec);
const fieldName = fieldNameFromStoreName(storeFieldName);
const variables = fieldSpec.variables || context.variables;
const { getFieldValue, toReference, canRead } = context.store;
const { toReference, canRead } = context.store;

return {
args: argsFromFieldSpecifier(fieldSpec),
Expand Down Expand Up @@ -867,12 +871,12 @@ function makeFieldFunctionOptions(
return policies.readField<T>(options, context);
},

mergeObjects: makeMergeObjectsFunction(getFieldValue),
mergeObjects: makeMergeObjectsFunction(context.store),
};
}

function makeMergeObjectsFunction(
getFieldValue: FieldValueGetter,
store: NormalizedCache,
): MergeObjectsFunction {
return function mergeObjects(existing, incoming) {
if (Array.isArray(existing) || Array.isArray(incoming)) {
Expand All @@ -885,17 +889,30 @@ function makeMergeObjectsFunction(
// types of options.mergeObjects.
if (existing && typeof existing === "object" &&
incoming && typeof incoming === "object") {
const eType = getFieldValue(existing, "__typename");
const iType = getFieldValue(incoming, "__typename");
const eType = store.getFieldValue(existing, "__typename");
const iType = store.getFieldValue(incoming, "__typename");
const typesDiffer = eType && iType && eType !== iType;

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

return { ...existing, ...incoming };
if (isReference(existing) &&
storeValueIsStoreObject(incoming)) {
store.merge(existing.__ref, incoming);
return existing;
}

if (storeValueIsStoreObject(existing) &&
isReference(incoming)) {
store.merge(existing, incoming.__ref);
return incoming;
}

if (storeValueIsStoreObject(existing) &&
storeValueIsStoreObject(incoming)) {
return { ...existing, ...incoming };
}
}

return incoming;
Expand Down
8 changes: 7 additions & 1 deletion src/cache/inmemory/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ export declare type IdGetter = (
export interface NormalizedCache {
has(dataId: string): boolean;
get(dataId: string, fieldName: string): StoreValue;
merge(dataId: string, incoming: StoreObject): void;

// The store.merge method allows either argument to be a string ID, but
// the other argument has to be a StoreObject. Either way, newer fields
// always take precedence over older fields.
merge(olderId: string, newerObject: StoreObject): void;
merge(olderObject: StoreObject, newerId: string): void;

modify(dataId: string, fields: Modifiers | Modifier<any>): boolean;
delete(dataId: string, fieldName?: string): boolean;
clear(): void;
Expand Down