Skip to content

Commit

Permalink
Merge pull request #9764 from apollographql/named-fragment-registry
Browse files Browse the repository at this point in the history
Allow registering named fragments with `InMemoryCache` to support using
`...FragmentName` in queries without redeclaring `FragmentName` in every query
  • Loading branch information
benjamn authored Sep 21, 2022
2 parents b091220 + dc8b8ba commit 05e45c9
Show file tree
Hide file tree
Showing 17 changed files with 584 additions and 51 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
{
"name": "apollo-client",
"path": "./dist/apollo-client.min.cjs",
"maxSize": "31.46kB"
"maxSize": "31.65kB"
}
],
"engines": {
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/__snapshots__/exports.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Array [
"Policies",
"cacheSlot",
"canonicalStringify",
"createFragmentRegistry",
"defaultDataIdFromObject",
"fieldNameFromStoreName",
"isReference",
Expand Down
14 changes: 8 additions & 6 deletions src/cache/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,18 @@ export abstract class ApolloCache<TSerialized> implements DataProxy {

// Optional API

// Called once per input document, allowing the cache to make static changes
// to the query, such as adding __typename fields.
public transformDocument(document: DocumentNode): DocumentNode {
return document;
}

// Called before each ApolloLink request, allowing the cache to make dynamic
// changes to the query, such as filling in missing fragment definitions.
public transformForLink(document: DocumentNode): DocumentNode {
return document;
}

public identify(object: StoreObject | Reference): string | undefined {
return;
}
Expand All @@ -113,12 +121,6 @@ export abstract class ApolloCache<TSerialized> implements DataProxy {
return false;
}

// Experimental API

public transformForLink(document: DocumentNode): DocumentNode {
return document;
}

// DataProxy API
/**
*
Expand Down
5 changes: 5 additions & 0 deletions src/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,9 @@ export {
canonicalStringify,
} from './inmemory/object-canon';

export {
FragmentRegistryAPI,
createFragmentRegistry,
} from './inmemory/fragmentRegistry';

export * from './inmemory/types';
284 changes: 284 additions & 0 deletions src/cache/inmemory/__tests__/fragmentRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
import { ApolloClient, ApolloLink, gql, NetworkStatus } from "../../../core";
import { getFragmentDefinitions, Observable } from "../../../utilities";
import { InMemoryCache, createFragmentRegistry } from "../../index";
import { itAsync, subscribeAndCount } from "../../../testing";

describe("FragmentRegistry", () => {
it("can be passed to InMemoryCache", () => {
const cache = new InMemoryCache({
fragments: createFragmentRegistry(gql`
fragment BasicFragment on Query {
basic
}
`),
});

// TODO Allow writeFragment to just use fragmentName:"BasicFragment"?
cache.writeQuery({
query: gql`
query {
...BasicFragment
}
`,
data: {
basic: true,
},
});

const result = cache.readQuery({
query: gql`
query {
...BasicFragment
}
`,
});

expect(result).toEqual({
basic: true,
});
});

itAsync("influences ApolloClient and ApolloLink", (resolve, reject) => {
const cache = new InMemoryCache({
fragments: createFragmentRegistry(gql`
fragment SourceFragment on Query {
source
}
`),
});

const client = new ApolloClient({
cache,
link: new ApolloLink(operation => new Observable(observer => {
expect(
getFragmentDefinitions(operation.query).map(def => def.name.value).sort()
).toEqual([
// Proof that the missing SourceFragment definition was appended to
// operation.query before it was passed into the link.
"SourceFragment",
]);

observer.next({
data: {
source: "link",
},
});

observer.complete();
})),
});

const query = gql`
query SourceQuery {
...SourceFragment
}
`;

cache.writeQuery({
query,
data: {
source: "local",
},
});

subscribeAndCount(reject, client.watchQuery({
query,
fetchPolicy: "cache-and-network",
}), (count, result) => {
if (count === 1) {
expect(result).toEqual({
loading: true,
networkStatus: NetworkStatus.loading,
data: {
source: "local",
},
});

} else if (count === 2) {
expect(result).toEqual({
loading: false,
networkStatus: NetworkStatus.ready,
data: {
source: "link",
},
});

expect(cache.readQuery({ query })).toEqual({
source: "link",
});

setTimeout(resolve, 10);
} else {
reject(`Unexpectedly many results (${count})`);
}
});
});

it("throws an error when not all used fragments are defined", () => {
const cache = new InMemoryCache({
fragments: createFragmentRegistry(gql`
fragment IncompleteFragment on Person {
__typename
id
...MustBeDefinedByQuery
}
`),
});

const queryWithoutFragment = gql`
query WithoutFragment {
me {
...IncompleteFragment
}
}
`;

const queryWithFragment = gql`
query WithFragment {
me {
...IncompleteFragment
}
}
fragment MustBeDefinedByQuery on Person {
name
}
`;

expect(() => {
cache.writeQuery({
query: queryWithoutFragment,
data: {
me: {
__typename: "Person",
id: 12345,
name: "Ben",
},
},
});
}).toThrow(
/No fragment named MustBeDefinedByQuery/
);

expect(cache.extract()).toEqual({
// Nothing written because the cache.writeQuery failed above.
});

cache.writeQuery({
query: queryWithFragment,
data: {
me: {
__typename: "Person",
id: 12345,
name: "Ben Newman",
},
},
});

expect(cache.extract()).toEqual({
ROOT_QUERY: {
__typename: "Query",
me: { __ref: "Person:12345" },
},
"Person:12345": {
__typename: "Person",
id: 12345,
name: "Ben Newman",
},
});

expect(() => {
cache.diff({
query: queryWithoutFragment,
returnPartialData: true,
optimistic: true,
});
}).toThrow(
/No fragment named MustBeDefinedByQuery/
);

expect(() => {
cache.readQuery({
query: queryWithoutFragment
});
}).toThrow(
/No fragment named MustBeDefinedByQuery/
);

expect(
cache.readQuery({
query: queryWithFragment,
}),
).toEqual({
me: {
__typename: "Person",
id: 12345,
name: "Ben Newman",
},
});
});

it("can register fragments with unbound ...spreads", () => {
const cache = new InMemoryCache({
fragments: createFragmentRegistry(gql`
fragment NeedsExtra on Person {
__typename
id
# This fragment spread has a default definition below, but can be
# selectively overridden by queries.
...ExtraFields
}
fragment ExtraFields on Person {
__typename
}
`),
});

const query = gql`
query GetMe {
me {
...NeedsExtra
}
}
# This version of the ExtraFields fragment will be used instead of the one
# registered in the FragmentRegistry, because explicit definitions take
# precedence over registered fragments.
fragment ExtraFields on Person {
name
}
`;

cache.writeQuery({
query,
data: {
me: {
__typename: "Person",
id: 12345,
name: "Alice",
},
},
});

expect(cache.extract()).toEqual({
ROOT_QUERY: {
__typename: "Query",
me: { __ref: "Person:12345" },
},
"Person:12345": {
__typename: "Person",
id: 12345,
name: "Alice",
},
});

expect(cache.readQuery({ query })).toEqual({
me: {
__typename: "Person",
id: 12345,
name: "Alice",
},
});
});
});
6 changes: 2 additions & 4 deletions src/cache/inmemory/__tests__/writeToStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ import {
StoreObject,
addTypenameToDocument,
cloneDeep,
createFragmentMap,
getFragmentDefinitions,
getMainDefinition,
} from '../../../utilities';
import { itAsync } from '../../../testing/core';
Expand All @@ -27,6 +25,7 @@ import { defaultNormalizedCacheFactory, writeQueryToStore } from './helpers';
import { InMemoryCache } from '../inMemoryCache';
import { withErrorSpy, withWarningSpy } from '../../../testing';
import { TypedDocumentNode } from '../../../core'
import { extractFragmentContext } from '../helpers';

const getIdField = ({ id }: { id: string }) => id;

Expand Down Expand Up @@ -3118,15 +3117,14 @@ describe('writing to the store', () => {
},
) {
const { selectionSet } = getMainDefinition(query);
const fragmentMap = createFragmentMap(getFragmentDefinitions(query));

const flat = writer["flattenFields"](selectionSet, {
__typename: "Query",
aField: "a",
bField: "b",
rootField: "root",
}, {
fragmentMap,
...extractFragmentContext(query),
clientOnly: false,
deferred: false,
flavors: new Map,
Expand Down
Loading

0 comments on commit 05e45c9

Please sign in to comment.