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))); } }