diff --git a/src/cache/inmemory/__tests__/policies.ts b/src/cache/inmemory/__tests__/policies.ts index af01ba43ee3..2ca08332e5f 100644 --- a/src/cache/inmemory/__tests__/policies.ts +++ b/src/cache/inmemory/__tests__/policies.ts @@ -3145,6 +3145,80 @@ describe("type policies", function () { ); }); + it("readField can read fields with arguments", function () { + const enum Style { UPPER, LOWER, TITLE }; + + const cache = new InMemoryCache({ + typePolicies: { + Word: { + keyFields: ["text"], + + fields: { + style(_, { args, readField }) { + const text = readField("text"); + switch (args?.style) { + case Style.UPPER: return text?.toUpperCase(); + case Style.LOWER: return text?.toLowerCase(); + case Style.TITLE: + return text && ( + text.charAt(0).toUpperCase() + + text.slice(1).toLowerCase() + ); + } + }, + upperCase(_, { readField }) { + return readField({ + fieldName: "style", + args: { style: Style.UPPER }, + }); + }, + lowerCase(_, { readField }) { + return readField({ + fieldName: "style", + args: { style: Style.LOWER }, + }); + }, + titleCase(_, { readField }) { + return readField({ + fieldName: "style", + args: { style: Style.TITLE }, + }); + }, + }, + }, + }, + }); + + cache.writeQuery({ + query: gql`query { wordOfTheDay { text } }`, + data: { + wordOfTheDay: { + __typename: "Word", + text: "inveigle", + }, + }, + }); + + expect(cache.readQuery({ + query: gql` + query { + wordOfTheDay { + upperCase + lowerCase + titleCase + } + } + `, + })).toEqual({ + wordOfTheDay: { + __typename: "Word", + upperCase: "INVEIGLE", + lowerCase: "inveigle", + titleCase: "Inveigle", + }, + }); + }); + it("can return existing object from merge function (issue #6245)", function () { const cache = new InMemoryCache({ typePolicies: { diff --git a/src/cache/inmemory/entityStore.ts b/src/cache/inmemory/entityStore.ts index da8b7959742..2cb7ba74f2f 100644 --- a/src/cache/inmemory/entityStore.ts +++ b/src/cache/inmemory/entityStore.ts @@ -184,12 +184,17 @@ export abstract class EntityStore implements NormalizedCache { fieldName?: string, args?: Record, ) { - const storeFieldName = fieldName && args - ? this.policies.getStoreFieldName(dataId, fieldName, args) - : fieldName; - return this.modify(dataId, storeFieldName ? { - [storeFieldName]: delModifier, - } : delModifier); + const storeObject = this.lookup(dataId); + if (storeObject) { + const typename = this.getFieldValue(storeObject, "__typename"); + const storeFieldName = fieldName && args + ? this.policies.getStoreFieldName({ typename, fieldName, args }) + : fieldName; + return this.modify(dataId, storeFieldName ? { + [storeFieldName]: delModifier, + } : delModifier); + } + return false; } public evict( diff --git a/src/cache/inmemory/policies.ts b/src/cache/inmemory/policies.ts index 0cf39b988a0..2cf61abaeb9 100644 --- a/src/cache/inmemory/policies.ts +++ b/src/cache/inmemory/policies.ts @@ -98,6 +98,23 @@ export type FieldPolicy< type StorageType = Record; +interface FieldSpecifier { + typename?: string; + fieldName: string; + field?: FieldNode; + args?: Record; + variables?: Record; +} + +function argsFromFieldSpecifier(spec: FieldSpecifier) { + return spec.args !== void 0 ? spec.args : + spec.field ? argumentsObjectFromField(spec.field, spec.variables) : null; +} + +export interface ReadFieldOptions extends FieldSpecifier { + from?: StoreObject | Reference; +} + export interface FieldFunctionOptions< TArgs = Record, TVars = Record, @@ -133,9 +150,10 @@ export interface FieldFunctionOptions< // to compute the argument values. Note that this function will invoke // custom read functions for other fields, if defined. Always returns // immutable data (enforced with Object.freeze in development). + readField(options: ReadFieldOptions): SafeReadonly | undefined; readField( - nameOrField: string | FieldNode, - foreignObjOrRef?: StoreObject | Reference, + fieldName: string, + from?: StoreObject | Reference, ): SafeReadonly | undefined; // A handy place to put field-specific data that you want to survive @@ -436,30 +454,19 @@ export class Policies { return false; } - public getStoreFieldName( - typename: string | undefined, - nameOrField: string | FieldNode, - // If nameOrField is a string, argsOrVars should be an object of - // arguments. If nameOrField is a FieldNode, argsOrVars should be the - // variables to use when computing the arguments of the field. - argsOrVars: Record, - ): string { - let field: FieldNode | null; - let fieldName: string; - if (typeof nameOrField === "string") { - field = null; - fieldName = nameOrField; - } else { - field = nameOrField; - fieldName = field.name.value; - } + public getStoreFieldName(fieldSpec: FieldSpecifier): string { + const { typename, fieldName } = fieldSpec; const policy = this.getFieldPolicy(typename, fieldName, false); let storeFieldName: string | undefined; let keyFn = policy && policy.keyFn; if (keyFn && typename) { - const args = field ? argumentsObjectFromField(field, argsOrVars) : argsOrVars; - const context: Parameters[1] = { typename, fieldName, field }; + const context: Parameters[1] = { + typename, + fieldName, + field: fieldSpec.field || null, + }; + const args = argsFromFieldSpecifier(fieldSpec); while (keyFn) { const specifierOrString = keyFn(args, context); if (Array.isArray(specifierOrString)) { @@ -474,9 +481,9 @@ export class Policies { } if (storeFieldName === void 0) { - storeFieldName = field - ? storeKeyNameFromField(field, argsOrVars) - : getStoreKeyName(fieldName, argsOrVars); + storeFieldName = fieldSpec.field + ? storeKeyNameFromField(fieldSpec.field, fieldSpec.variables) + : getStoreKeyName(fieldName, argsFromFieldSpecifier(fieldSpec)); } // Make sure custom field names start with the actual field.name.value @@ -490,26 +497,29 @@ export class Policies { private storageTrie = new KeyTrie(true); public readField( - objectOrReference: StoreObject | Reference, - nameOrField: string | FieldNode, + options: ReadFieldOptions, context: ReadMergeContext, - typename = context.getFieldValue(objectOrReference, "__typename"), - ): SafeReadonly { - invariant( - objectOrReference, - "Must provide an object or Reference when calling Policies#readField", - ); - - const policies = this; - const storeFieldName = typeof nameOrField === "string" ? nameOrField - : policies.getStoreFieldName(typename, nameOrField, context.variables); + ): SafeReadonly | undefined { + const objectOrReference = options.from; + if (!objectOrReference) return; + + const nameOrField = options.field || options.fieldName; + if (!nameOrField) return; + + if (options.typename === void 0) { + const typename = context.getFieldValue( + objectOrReference, "__typename"); + if (typename) options.typename = typename; + } + + const storeFieldName = this.getStoreFieldName(options); const fieldName = fieldNameFromStoreName(storeFieldName); const existing = context.getFieldValue(objectOrReference, storeFieldName); - const policy = policies.getFieldPolicy(typename, fieldName, false); + const policy = this.getFieldPolicy(options.typename, fieldName, false); const read = policy && policy.read; if (read) { - const storage = policies.storageTrie.lookup( + const storage = this.storageTrie.lookup( isReference(objectOrReference) ? objectOrReference.__ref : objectOrReference, @@ -517,10 +527,9 @@ export class Policies { ); return read(existing, makeFieldFunctionOptions( - policies, - typename, + this, objectOrReference, - nameOrField, + options, storage, context, )) as SafeReadonly; @@ -543,14 +552,12 @@ export class Policies { context: ReadMergeContext, storageKeys?: [string | StoreObject, string], ): T { - const policies = this; - 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 } = policies.getFieldPolicy( + const { merge } = this.getFieldPolicy( incoming.__typename, fieldName, false)!; // If storage ends up null, that just means no options.storage object @@ -561,12 +568,11 @@ export class Policies { // this comment then you probably have good reasons for wanting to know // esoteric details like these, you wizard, you. const storage = storageKeys - ? policies.storageTrie.lookupArray(storageKeys) + ? this.storageTrie.lookupArray(storageKeys) : null; incoming = merge!(existing, incoming.__value, makeFieldFunctionOptions( - policies, - incoming.__typename, + this, // Unlike options.readField for read functions, we do not fall // back to the current object if no foreignObjOrRef is provided, // because it's not clear what the current object should be for @@ -578,15 +584,18 @@ export class Policies { // to the order in which fields are written into the cache. // However, readField(name, ref) is useful for merge functions // that need to deduplicate child objects and references. - null, - field, + void 0, + { typename: incoming.__typename, + fieldName, + field, + variables: context.variables }, storage, context, )) as T; } if (Array.isArray(incoming)) { - return incoming!.map(item => policies.applyMerges( + 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 @@ -619,7 +628,7 @@ export class Policies { Object.keys(i).forEach(storeFieldName => { const incomingValue = i[storeFieldName]; - const appliedValue = policies.applyMerges( + const appliedValue = this.applyMerges( context.getFieldValue(e, storeFieldName), incomingValue, context, @@ -653,20 +662,17 @@ export interface ReadMergeContext { function makeFieldFunctionOptions( policies: Policies, - typename: string, - objectOrReference: StoreObject | Reference | null, - nameOrField: string | FieldNode, + objectOrReference: StoreObject | Reference | undefined, + fieldSpec: FieldSpecifier, storage: StorageType | null, context: ReadMergeContext, ): FieldFunctionOptions { const { toReference, getFieldValue, variables } = context; - const storeFieldName = typeof nameOrField === "string" ? nameOrField : - policies.getStoreFieldName(typename, nameOrField, variables); + const storeFieldName = policies.getStoreFieldName(fieldSpec); const fieldName = fieldNameFromStoreName(storeFieldName); return { - args: typeof nameOrField === "string" ? null : - argumentsObjectFromField(nameOrField, variables), - field: typeof nameOrField === "string" ? null : nameOrField, + args: argsFromFieldSpecifier(fieldSpec), + field: fieldSpec.field || null, fieldName, storeFieldName, variables, @@ -675,14 +681,19 @@ function makeFieldFunctionOptions( storage, readField( - nameOrField: string | FieldNode, - foreignObjOrRef: StoreObject | Reference, + fieldNameOrOptions: string | ReadFieldOptions, + from?: StoreObject | Reference, ) { - return policies.readField( - foreignObjOrRef || objectOrReference, - nameOrField, - context, - ); + const options: ReadFieldOptions = + typeof fieldNameOrOptions === "string" ? { + fieldName: fieldNameOrOptions, + from, + } : fieldNameOrOptions; + + return policies.readField(options.from ? options : { + ...options, + from: objectOrReference, + }, context); }, mergeObjects(existing, incoming) { diff --git a/src/cache/inmemory/readFromStore.ts b/src/cache/inmemory/readFromStore.ts index ef930187159..33cf095f4dc 100644 --- a/src/cache/inmemory/readFromStore.ts +++ b/src/cache/inmemory/readFromStore.ts @@ -256,13 +256,12 @@ export class StoreReader { if (!shouldInclude(selection, variables)) return; if (isField(selection)) { - let fieldValue = policies.readField( - objectOrReference, - selection, - // Since ExecContext extends ReadMergeContext, we can pass it - // here without any modifications. - context, - ); + let fieldValue = policies.readField({ + fieldName: selection.name.value, + field: selection, + variables: context.variables, + from: objectOrReference, + }, context); const resultName = resultKeyNameFromField(selection); context.path.push(resultName); diff --git a/src/cache/inmemory/writeToStore.ts b/src/cache/inmemory/writeToStore.ts index 80c3072ba96..a58dfaccfba 100644 --- a/src/cache/inmemory/writeToStore.ts +++ b/src/cache/inmemory/writeToStore.ts @@ -211,11 +211,12 @@ export class StoreWriter { const value = result[resultFieldKey]; if (typeof value !== 'undefined') { - const storeFieldName = policies.getStoreFieldName( + const storeFieldName = policies.getStoreFieldName({ typename, - selection, - context.variables, - ); + fieldName: selection.name.value, + field: selection, + variables: context.variables, + }); let incomingValue = this.processFieldValue(value, selection, context, out); diff --git a/src/utilities/graphql/storeUtils.ts b/src/utilities/graphql/storeUtils.ts index 516ff28dde4..7076017e08d 100644 --- a/src/utilities/graphql/storeUtils.ts +++ b/src/utilities/graphql/storeUtils.ts @@ -179,10 +179,11 @@ const KNOWN_DIRECTIVES: string[] = [ export function getStoreKeyName( fieldName: string, - args?: Object, + args?: Record | null, directives?: Directives, ): string { if ( + args && directives && directives['connection'] && directives['connection']['key'] @@ -196,10 +197,9 @@ export function getStoreKeyName( : []; filterKeys.sort(); - const queryArgs = args as { [key: string]: any }; const filteredArgs = {} as { [key: string]: any }; filterKeys.forEach(key => { - filteredArgs[key] = queryArgs[key]; + filteredArgs[key] = args[key]; }); return `${directives['connection']['key']}(${JSON.stringify( @@ -236,7 +236,7 @@ export function getStoreKeyName( export function argumentsObjectFromField( field: FieldNode | DirectiveNode, - variables: Object, + variables?: Record, ): Object | null { if (field.arguments && field.arguments.length) { const argObj: Object = {}; @@ -245,7 +245,6 @@ export function argumentsObjectFromField( ); return argObj; } - return null; }