diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0908effc475..26f04d6f330 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@
## Improvements
+- Support inheritance of type and field policies, according to `possibleTypes`.
+ [@benjamn](https://github.com/benjamn) in [#7065](https://github.com/apollographql/apollo-client/pull/7065)
+
- Shallow-merge `options.variables` when combining existing or default options with newly-provided options, so new variables do not completely overwrite existing variables.
[@amannn](https://github.com/amannn) in [#6927](https://github.com/apollographql/apollo-client/pull/6927)
diff --git a/package.json b/package.json
index 409c8b11bdb..d28b44963c2 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
{
"name": "apollo-client",
"path": "./dist/apollo-client.cjs.min.js",
- "maxSize": "24.75 kB"
+ "maxSize": "24.9 kB"
}
],
"peerDependencies": {
diff --git a/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap b/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap
index f75ee96681b..821ba05f635 100644
--- a/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap
+++ b/src/cache/inmemory/__tests__/__snapshots__/policies.ts.snap
@@ -977,3 +977,48 @@ Object {
},
}
`;
+
+exports[`type policies support inheritance 1`] = `
+Object {
+ "Cobra:{\\"tagId\\":\\"Egypt30BC\\"}": Object {
+ "__typename": "Cobra",
+ "scientificName": "naja haje",
+ "tagId": "Egypt30BC",
+ "venomous": true,
+ },
+ "Cottonmouth:{\\"tagId\\":\\"CM420\\"}": Object {
+ "__typename": "Cottonmouth",
+ "scientificName": "agkistrodon piscivorus",
+ "tagId": "CM420",
+ "venomous": true,
+ },
+ "Python:{\\"tagId\\":\\"BigHug4U\\"}": Object {
+ "__typename": "Python",
+ "scientificName": "malayopython reticulatus",
+ "tagId": "BigHug4U",
+ "venomous": false,
+ },
+ "ROOT_QUERY": Object {
+ "__typename": "Query",
+ "reptiles": Array [
+ Object {
+ "__ref": "Turtle:{\\"tagId\\":\\"RedEaredSlider42\\"}",
+ },
+ Object {
+ "__ref": "Python:{\\"tagId\\":\\"BigHug4U\\"}",
+ },
+ Object {
+ "__ref": "Cobra:{\\"tagId\\":\\"Egypt30BC\\"}",
+ },
+ Object {
+ "__ref": "Cottonmouth:{\\"tagId\\":\\"CM420\\"}",
+ },
+ ],
+ },
+ "Turtle:{\\"tagId\\":\\"RedEaredSlider42\\"}": Object {
+ "__typename": "Turtle",
+ "scientificName": "trachemys scripta elegans",
+ "tagId": "RedEaredSlider42",
+ },
+}
+`;
diff --git a/src/cache/inmemory/__tests__/policies.ts b/src/cache/inmemory/__tests__/policies.ts
index f0430074a9a..3e0d62dd670 100644
--- a/src/cache/inmemory/__tests__/policies.ts
+++ b/src/cache/inmemory/__tests__/policies.ts
@@ -318,6 +318,134 @@ describe("type policies", function () {
})).toBe("MotionPicture::3993d4118143");
});
+ it("support inheritance", function () {
+ const cache = new InMemoryCache({
+ possibleTypes: {
+ Reptile: ["Snake", "Turtle"],
+ Snake: ["Python", "Viper", "Cobra"],
+ Viper: ["Cottonmouth"],
+ },
+
+ typePolicies: {
+ Reptile: {
+ keyFields: ["tagId"],
+
+ fields: {
+ scientificName: {
+ merge(_, incoming) {
+ // Normalize all scientific names to lower case.
+ return incoming.toLowerCase();
+ },
+ },
+ },
+ },
+
+ Snake: {
+ fields: {
+ // Default to a truthy non-boolean value if we don't know
+ // whether this snake is venomous.
+ venomous(status = "unknown") {
+ return status;
+ },
+ },
+ },
+ },
+ });
+
+ const query: TypedDocumentNode<{
+ reptiles: Record[];
+ }> = gql`
+ query {
+ reptiles {
+ tagId
+ scientificName
+ ... on Snake {
+ venomous
+ }
+ }
+ }
+ `;
+
+ const reptiles = [
+ {
+ __typename: "Turtle",
+ tagId: "RedEaredSlider42",
+ scientificName: "Trachemys scripta elegans",
+ },
+ {
+ __typename: "Python",
+ tagId: "BigHug4U",
+ scientificName: "Malayopython reticulatus",
+ venomous: false,
+ },
+ {
+ __typename: "Cobra",
+ tagId: "Egypt30BC",
+ scientificName: "Naja haje",
+ venomous: true,
+ },
+ {
+ __typename: "Cottonmouth",
+ tagId: "CM420",
+ scientificName: "Agkistrodon piscivorus",
+ venomous: true,
+ },
+ ];
+
+ cache.writeQuery({
+ query,
+ data: { reptiles },
+ });
+
+ expect(cache.extract()).toMatchSnapshot();
+
+ const result1 = cache.readQuery({ query })!;
+ expect(result1).toEqual({
+ reptiles: reptiles.map(reptile => ({
+ ...reptile,
+ scientificName: reptile.scientificName.toLowerCase(),
+ })),
+ });
+
+ const cmId = cache.identify({
+ __typename: "Cottonmouth",
+ tagId: "CM420",
+ });
+
+ expect(cache.evict({
+ id: cmId,
+ fieldName: "venomous",
+ })).toBe(true);
+
+ const result2 = cache.readQuery({ query })!;
+
+ result2.reptiles.forEach((reptile, i) => {
+ if (reptile.__typename === "Cottonmouth") {
+ expect(reptile).not.toBe(result1.reptiles[i]);
+ expect(reptile).not.toEqual(result1.reptiles[i]);
+ expect(reptile).toEqual({
+ __typename: "Cottonmouth",
+ tagId: "CM420",
+ // This name has been normalized to lower case.
+ scientificName: "agkistrodon piscivorus",
+ // Venomosity status has been set to a default value.
+ venomous: "unknown",
+ });
+ } else {
+ expect(reptile).toBe(result1.reptiles[i]);
+ }
+ });
+
+ cache.policies.addPossibleTypes({
+ Viper: ["DeathAdder"],
+ });
+
+ expect(cache.identify({
+ __typename: "DeathAdder",
+ tagId: "LethalAbacus666",
+ })).toBe('DeathAdder:{"tagId":"LethalAbacus666"}');
+ });
+
describe("field policies", function () {
it("can filter key arguments", function () {
const cache = new InMemoryCache({
diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts
index 276a1289a1a..8266e9971ae 100644
--- a/src/cache/inmemory/policies.ts
+++ b/src/cache/inmemory/policies.ts
@@ -21,6 +21,7 @@ import {
isReference,
getStoreKeyName,
canUseWeakMap,
+ compact,
} from '../../utilities';
import { IdGetter, ReadMergeModifyContext } from "./types";
import {
@@ -238,7 +239,7 @@ export class Policies {
private typePolicies: {
[__typename: string]: {
keyFn?: KeyFieldsFunction;
- fields?: {
+ fields: {
[fieldName: string]: {
keyFn?: KeyArgsFunction;
read?: FieldReadFunction;
@@ -248,6 +249,10 @@ export class Policies {
};
} = Object.create(null);
+ private toBeAdded: {
+ [__typename: string]: TypePolicy[];
+ } = Object.create(null);
+
// Map from subtype names to sets of supertype names. Note that this
// representation inverts the structure of possibleTypes (whose keys are
// supertypes and whose values are arrays of subtypes) because it tends
@@ -298,7 +303,6 @@ export class Policies {
selectionSet?: SelectionSetNode,
fragmentMap?: FragmentMap,
): [string?, StoreObject?] {
- // TODO Consider subtypes?
// TODO Use an AliasMap here?
const typename = selectionSet && fragmentMap
? getTypenameFromResult(object, selectionSet, fragmentMap)
@@ -322,7 +326,7 @@ export class Policies {
let id: string | undefined;
- const policy = this.getTypePolicy(typename, false);
+ const policy = typename && this.getTypePolicy(typename);
let keyFn = policy && policy.keyFn || this.config.dataIdFromObject;
while (keyFn) {
const specifierOrId = keyFn(object, context);
@@ -341,73 +345,81 @@ export class Policies {
public addTypePolicies(typePolicies: TypePolicies) {
Object.keys(typePolicies).forEach(typename => {
- const existing = this.getTypePolicy(typename, true)!;
const incoming = typePolicies[typename];
- const { keyFields, fields } = incoming;
-
- if (incoming.queryType) this.setRootTypename("Query", typename);
- if (incoming.mutationType) this.setRootTypename("Mutation", typename);
- if (incoming.subscriptionType) this.setRootTypename("Subscription", typename);
-
- existing.keyFn =
- // Pass false to disable normalization for this typename.
- keyFields === false ? nullKeyFieldsFn :
- // Pass an array of strings to use those fields to compute a
- // composite ID for objects of this typename.
- Array.isArray(keyFields) ? keyFieldsFnFromSpecifier(keyFields) :
- // Pass a function to take full control over identification.
- typeof keyFields === "function" ? keyFields :
- // Leave existing.keyFn unchanged if above cases fail.
- existing.keyFn;
-
- if (fields) {
- Object.keys(fields).forEach(fieldName => {
- const existing = this.getFieldPolicy(typename, fieldName, true)!;
- const incoming = fields[fieldName];
-
- if (typeof incoming === "function") {
- existing.read = incoming;
- } else {
- const { keyArgs, read, merge } = incoming;
-
- existing.keyFn =
- // Pass false to disable argument-based differentiation of
- // field identities.
- keyArgs === false ? simpleKeyArgsFn :
- // Pass an array of strings to use named arguments to
- // compute a composite identity for the field.
- Array.isArray(keyArgs) ? keyArgsFnFromSpecifier(keyArgs) :
- // Pass a function to take full control over field identity.
- typeof keyArgs === "function" ? keyArgs :
- // Leave existing.keyFn unchanged if above cases fail.
- existing.keyFn;
-
- if (typeof read === "function") existing.read = read;
-
- existing.merge =
- typeof merge === "function" ? merge :
- // Pass merge:true as a shorthand for a merge implementation
- // that returns options.mergeObjects(existing, incoming).
- merge === true ? mergeTrueFn :
- // Pass merge:false to make incoming always replace existing
- // without any warnings about data clobbering.
- merge === false ? mergeFalseFn :
- existing.merge;
- }
-
- if (existing.read && existing.merge) {
- // If we have both a read and a merge function, assume
- // keyArgs:false, because read and merge together can take
- // responsibility for interpreting arguments in and out. This
- // default assumption can always be overridden by specifying
- // keyArgs explicitly in the FieldPolicy.
- existing.keyFn = existing.keyFn || simpleKeyArgsFn;
- }
- });
+ if (hasOwn.call(this.toBeAdded, typename)) {
+ this.toBeAdded[typename].push(incoming);
+ } else {
+ this.toBeAdded[typename] = [incoming];
}
});
}
+ private updateTypePolicy(typename: string, incoming: TypePolicy) {
+ const existing = this.getTypePolicy(typename);
+ const { keyFields, fields } = incoming;
+
+ if (incoming.queryType) this.setRootTypename("Query", typename);
+ if (incoming.mutationType) this.setRootTypename("Mutation", typename);
+ if (incoming.subscriptionType) this.setRootTypename("Subscription", typename);
+
+ existing.keyFn =
+ // Pass false to disable normalization for this typename.
+ keyFields === false ? nullKeyFieldsFn :
+ // Pass an array of strings to use those fields to compute a
+ // composite ID for objects of this typename.
+ Array.isArray(keyFields) ? keyFieldsFnFromSpecifier(keyFields) :
+ // Pass a function to take full control over identification.
+ typeof keyFields === "function" ? keyFields :
+ // Leave existing.keyFn unchanged if above cases fail.
+ existing.keyFn;
+
+ if (fields) {
+ Object.keys(fields).forEach(fieldName => {
+ const existing = this.getFieldPolicy(typename, fieldName, true)!;
+ const incoming = fields[fieldName];
+
+ if (typeof incoming === "function") {
+ existing.read = incoming;
+ } else {
+ const { keyArgs, read, merge } = incoming;
+
+ existing.keyFn =
+ // Pass false to disable argument-based differentiation of
+ // field identities.
+ keyArgs === false ? simpleKeyArgsFn :
+ // Pass an array of strings to use named arguments to
+ // compute a composite identity for the field.
+ Array.isArray(keyArgs) ? keyArgsFnFromSpecifier(keyArgs) :
+ // Pass a function to take full control over field identity.
+ typeof keyArgs === "function" ? keyArgs :
+ // Leave existing.keyFn unchanged if above cases fail.
+ existing.keyFn;
+
+ if (typeof read === "function") existing.read = read;
+
+ existing.merge =
+ typeof merge === "function" ? merge :
+ // Pass merge:true as a shorthand for a merge implementation
+ // that returns options.mergeObjects(existing, incoming).
+ merge === true ? mergeTrueFn :
+ // Pass merge:false to make incoming always replace existing
+ // without any warnings about data clobbering.
+ merge === false ? mergeFalseFn :
+ existing.merge;
+ }
+
+ if (existing.read && existing.merge) {
+ // If we have both a read and a merge function, assume
+ // keyArgs:false, because read and merge together can take
+ // responsibility for interpreting arguments in and out. This
+ // default assumption can always be overridden by specifying
+ // keyArgs explicitly in the FieldPolicy.
+ existing.keyFn = existing.keyFn || simpleKeyArgsFn;
+ }
+ });
+ }
+ }
+
private setRootTypename(
which: "Query" | "Mutation" | "Subscription",
typename: string = which,
@@ -445,14 +457,49 @@ export class Policies {
});
}
- private getTypePolicy(
- typename: string | undefined,
- createIfMissing: boolean,
- ): Policies["typePolicies"][string] | undefined {
- if (typename) {
- return this.typePolicies[typename] || (
- createIfMissing && (this.typePolicies[typename] = Object.create(null)));
+ private getTypePolicy(typename: string): Policies["typePolicies"][string] {
+ if (!hasOwn.call(this.typePolicies, typename)) {
+ const policy: Policies["typePolicies"][string] =
+ this.typePolicies[typename] = Object.create(null);
+ policy.fields = Object.create(null);
+
+ // When the TypePolicy for typename is first accessed, instead of
+ // starting with an empty policy object, inherit any properties or
+ // fields from the type policies of the supertypes of typename.
+ //
+ // Any properties or fields defined explicitly within the TypePolicy
+ // for typename will take precedence, and if there are multiple
+ // supertypes, the properties of policies whose types were added
+ // later via addPossibleTypes will take precedence over those of
+ // earlier supertypes. TODO Perhaps we should warn about these
+ // conflicts in development, and recommend defining the property
+ // explicitly in the subtype policy?
+ //
+ // Field policy inheritance is atomic/shallow: you can't inherit a
+ // field policy and then override just its read function, since read
+ // and merge functions often need to cooperate, so changing only one
+ // of them would be a recipe for inconsistency.
+ //
+ // Once the TypePolicy for typename has been accessed, its
+ // properties can still be updated directly using addTypePolicies,
+ // but future changes to supertype policies will not be reflected in
+ // this policy, because this code runs at most once per typename.
+ const supertypes = this.supertypeMap.get(typename);
+ if (supertypes && supertypes.size) {
+ supertypes.forEach(supertype => {
+ const { fields, ...rest } = this.getTypePolicy(supertype);
+ Object.assign(policy, rest);
+ Object.assign(policy.fields, fields);
+ });
+ }
+ }
+
+ const inbox = this.toBeAdded[typename];
+ if (inbox && inbox.length) {
+ this.updateTypePolicy(typename, compact(...inbox.splice(0)));
}
+
+ return this.typePolicies[typename];
}
private getFieldPolicy(
@@ -464,14 +511,10 @@ export class Policies {
read?: FieldReadFunction;
merge?: FieldMergeFunction;
} | undefined {
- const typePolicy = this.getTypePolicy(typename, createIfMissing);
- if (typePolicy) {
- const fieldPolicies = typePolicy.fields || (
- createIfMissing && (typePolicy.fields = Object.create(null)));
- if (fieldPolicies) {
- return fieldPolicies[fieldName] || (
- createIfMissing && (fieldPolicies[fieldName] = Object.create(null)));
- }
+ if (typename) {
+ const fieldPolicies = this.getTypePolicy(typename).fields;
+ return fieldPolicies[fieldName] || (
+ createIfMissing && (fieldPolicies[fieldName] = Object.create(null)));
}
}