Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(graphcache): Allow arbitrary types in updates config (#2979)

authored by

Phil Pluckthun and committed by
GitHub
1aa3cb5c 9ecfba41

+160 -98
+10
.changeset/thirty-pears-lay.md
··· 1 + --- 2 + '@urql/exchange-graphcache': minor 3 + --- 4 + 5 + Allow `updates` config to react to arbitrary type updates other than just `Mutation` and `Subscription` fields. 6 + You’ll now be able to write updaters that react to any entity field being written to the cache, 7 + which allows for more granular invalidations. **Note:** If you’ve previously used `updates.Mutation` 8 + and `updated.Subscription` with a custom schema with custom root names, you‘ll get a warning since 9 + you’ll have to update your `updates` config to reflect this. This was a prior implementation 10 + mistake!
+12 -10
docs/graphcache/errors.md
··· 308 308 Check whether your schema is up-to-date, or whether you're using an invalid 309 309 typename in `opts.keys`, maybe due to a typo. 310 310 311 - ## (21) Invalid mutation 311 + ## (21) Invalid updates type 312 312 313 - > Invalid mutation field `???` is not in the defined schema, but the `updates` option is referencing it. 313 + > Invalid updates field: The type `???` is not an object in the defined schema, 314 + > but the `updates` config is referencing it. 314 315 315 316 When you're passing an introspected schema to the cache exchange, it is 316 - able to check whether your `opts.updates.Mutation` is valid. 317 - This error occurs when an unknown mutation field is found in `opts.updates.Mutation`. 317 + able to check whether your `opts.updates` config is valid. 318 + This error occurs when an unknown type is found in the `opts.updates` config. 318 319 319 - Check whether your schema is up-to-date, or whether you've got a typo in `opts.updates.Mutation`. 320 + Check whether your schema is up-to-date, or whether you've got a typo in `opts.updates`. 320 321 321 - ## (22) Invalid subscription 322 + ## (22) Invalid updates field 322 323 323 - > Invalid subscription field: `???` is not in the defined schema, but the `updates` option is referencing it. 324 + > Invalid updates field: `???` on `???` is not in the defined schema, 325 + > but the `updates` config is referencing it. 324 326 325 327 When you're passing an introspected schema to the cache exchange, it is 326 - able to check whether your `opts.updates.Subscription` is valid. 327 - This error occurs when an unknown subscription field is found in `opts.updates.Subscription`. 328 + able to check whether your `opts.updates` config is valid. 329 + This error occurs when an unknown field is found in `opts.updates[typename]`. 328 330 329 331 Check whether your schema is up-to-date, or whether you're using an invalid 330 - subscription name in `opts.updates.Subscription`, maybe due to a typo. 332 + field name in `opts.updates`, maybe due to a typo. 331 333 332 334 ## (23) Invalid resolver 333 335
+39 -27
exchanges/graphcache/src/ast/schemaPredicates.ts
··· 6 6 7 7 import { 8 8 KeyingConfig, 9 - UpdateResolver, 9 + UpdatesConfig, 10 10 ResolverConfig, 11 11 OptimisticMutationConfig, 12 12 } from '../types'; ··· 139 139 140 140 export function expectValidUpdatesConfig( 141 141 schema: SchemaIntrospector, 142 - updates: Record<string, Record<string, UpdateResolver | undefined>> 142 + updates: UpdatesConfig 143 143 ): void { 144 144 if (process.env.NODE_ENV === 'production') { 145 145 return; 146 146 } 147 147 148 - if (schema.mutation) { 149 - const mutationFields = (schema.types!.get( 150 - schema.mutation 151 - ) as SchemaObject).fields(); 152 - const givenMutations = updates[schema.mutation] || {}; 153 - for (const fieldName in givenMutations) { 154 - if (mutationFields[fieldName] === undefined) { 155 - warn( 156 - 'Invalid mutation field: `' + 157 - fieldName + 158 - '` is not in the defined schema, but the `updates.Mutation` option is referencing it.', 159 - 21 160 - ); 148 + for (const typename in updates) { 149 + if (!updates[typename]) { 150 + continue; 151 + } else if (!schema.types!.has(typename)) { 152 + let addition = ''; 153 + 154 + if ( 155 + typename === 'Mutation' && 156 + schema.mutation && 157 + schema.mutation !== 'Mutation' 158 + ) { 159 + addition += 160 + '\nMaybe your config should reference `' + schema.mutation + '`?'; 161 + } else if ( 162 + typename === 'Subscription' && 163 + schema.subscription && 164 + schema.subscription !== 'Subscription' 165 + ) { 166 + addition += 167 + '\nMaybe your config should reference `' + schema.subscription + '`?'; 161 168 } 169 + 170 + return warn( 171 + 'Invalid updates type: The type `' + 172 + typename + 173 + '` is not an object in the defined schema, but the `updates` config is referencing it.' + 174 + addition, 175 + 21 176 + ); 162 177 } 163 - } 164 178 165 - if (schema.subscription) { 166 - const subscriptionFields = (schema.types!.get( 167 - schema.subscription 168 - ) as SchemaObject).fields(); 169 - const givenSubscription = updates[schema.subscription] || {}; 170 - for (const fieldName in givenSubscription) { 171 - if (subscriptionFields[fieldName] === undefined) { 179 + const fields = (schema.types!.get(typename)! as SchemaObject).fields(); 180 + for (const fieldName in updates[typename]!) { 181 + if (!fields[fieldName]) { 172 182 warn( 173 - 'Invalid subscription field: `' + 183 + 'Invalid updates field: `' + 174 184 fieldName + 175 - '` is not in the defined schema, but the `updates.Subscription` option is referencing it.', 185 + '` on `' + 186 + typename + 187 + '` is not in the defined schema, but the `updates` config is referencing it.', 176 188 22 177 189 ); 178 190 } ··· 213 225 const validQueries = (schema.types!.get( 214 226 schema.query 215 227 ) as SchemaObject).fields(); 216 - for (const resolverQuery in resolvers.Query) { 228 + for (const resolverQuery in resolvers.Query || {}) { 217 229 if (!validQueries[resolverQuery]) { 218 230 warnAboutResolver('Query.' + resolverQuery); 219 231 } ··· 236 248 const validTypeProperties = (schema.types!.get( 237 249 key 238 250 ) as SchemaObject).fields(); 239 - for (const resolverProperty in resolvers[key]) { 251 + for (const resolverProperty in resolvers[key] || {}) { 240 252 if (!validTypeProperties[resolverProperty]) { 241 253 warnAboutResolver(key + '.' + resolverProperty); 242 254 }
+4 -4
exchanges/graphcache/src/operations/query.ts
··· 284 284 result?: Data 285 285 ): Data | undefined => { 286 286 const { store } = ctx; 287 - const isQuery = key === store.rootFields['query']; 287 + const isQuery = key === store.rootFields.query; 288 288 289 289 const entityKey = (result && store.keyOfEntity(result)) || key; 290 290 if (!isQuery && !!ctx.store.rootNames[entityKey]) { ··· 321 321 return; 322 322 } 323 323 324 + const resolvers = store.resolvers[typename]; 324 325 const iterate = makeSelectionIterator(typename, entityKey, select, ctx); 325 326 326 327 let hasFields = false; ··· 337 338 const key = joinKeys(entityKey, fieldKey); 338 339 const fieldValue = InMemoryData.readRecord(entityKey, fieldKey); 339 340 const resultValue = result ? result[fieldName] : undefined; 340 - const resolvers = store.resolvers[typename]; 341 341 342 342 if (process.env.NODE_ENV !== 'production' && store.schema && typename) { 343 343 isFieldAvailableOnType(store.schema, typename, fieldName); ··· 358 358 } else if ( 359 359 getCurrentOperation() === 'read' && 360 360 resolvers && 361 - typeof resolvers[fieldName] === 'function' 361 + resolvers[fieldName] 362 362 ) { 363 363 // We have to update the information in context to reflect the info 364 364 // that the resolver will receive ··· 370 370 output[fieldAlias] = fieldValue; 371 371 } 372 372 373 - dataFieldValue = resolvers[fieldName]( 373 + dataFieldValue = resolvers[fieldName]!( 374 374 output, 375 375 fieldArgs || ({} as Variables), 376 376 store,
+30 -26
exchanges/graphcache/src/operations/write.ts
··· 198 198 select: SelectionSet, 199 199 data: Data 200 200 ) => { 201 - const isQuery = entityKey === ctx.store.rootFields['query']; 202 - const isRoot = !isQuery && !!ctx.store.rootNames[entityKey!]; 203 - const typename = isRoot || isQuery ? entityKey : data.__typename; 201 + // These fields determine how we write. The `Query` root type is written 202 + // like a normal entity, hence, we use `rootField` with a default to determine 203 + // this. All other root names (Subscription & Mutation) are in a different 204 + // write mode 205 + const rootField = ctx.store.rootNames[entityKey!] || 'query'; 206 + const isRoot = !!ctx.store.rootNames[entityKey!]; 207 + 208 + const typename = isRoot ? entityKey : data.__typename; 204 209 if (!typename) { 205 210 warn( 206 211 "Couldn't find __typename when writing.\n" + ··· 208 213 14 209 214 ); 210 215 return; 211 - } else if (!isRoot && !isQuery && entityKey) { 216 + } else if (!isRoot && entityKey) { 212 217 InMemoryData.writeRecord(entityKey, '__typename', typename); 213 218 } 214 219 220 + const updates = ctx.store.updates[typename]; 215 221 const iterate = makeSelectionIterator( 216 222 typename, 217 223 entityKey || typename, ··· 230 236 // Development check of undefined fields 231 237 if (process.env.NODE_ENV !== 'production') { 232 238 if ( 233 - !isRoot && 239 + rootField === 'query' && 234 240 fieldValue === undefined && 235 241 !deferRef.current && 236 242 !ctx.optimistic ··· 261 267 // Fields marked as deferred that aren't defined must be skipped 262 268 // Otherwise, we also ignore undefined values in optimistic updaters 263 269 (fieldValue === undefined && 264 - (deferRef.current || (ctx.optimistic && !isRoot))) 270 + (deferRef.current || (ctx.optimistic && rootField === 'query'))) 265 271 ) { 266 272 continue; 267 273 } ··· 272 278 // Execute optimistic mutation functions on root fields, or execute recursive functions 273 279 // that have been returned on optimistic objects 274 280 let resolver: OptimisticMutationResolver | void; 275 - if (ctx.optimistic && isRoot) { 281 + if (ctx.optimistic && rootField === 'mutation') { 276 282 resolver = ctx.store.optimisticMutations[fieldName]; 277 283 if (!resolver) continue; 278 284 } else if (ctx.optimistic && typeof fieldValue === 'function') { ··· 288 294 289 295 if (node.selectionSet) { 290 296 // Process the field and write links for the child entities that have been written 291 - if (entityKey && !isRoot) { 297 + if (entityKey && rootField === 'query') { 292 298 const key = joinKeys(entityKey, fieldKey); 293 299 const link = writeField( 294 300 ctx, ··· 300 306 } else { 301 307 writeField(ctx, getSelectionSet(node), ensureData(fieldValue)); 302 308 } 303 - } else if (entityKey && !isRoot) { 309 + } else if (entityKey && rootField === 'query') { 304 310 // This is a leaf node, so we're setting the field's value directly 305 311 InMemoryData.writeRecord( 306 312 entityKey || typename, ··· 311 317 ); 312 318 } 313 319 314 - if (isRoot) { 315 - // We run side-effect updates after the default, normalized updates 316 - // so that the data is already available in-store if necessary 317 - const updater = ctx.store.updates[typename][fieldName]; 318 - if (updater) { 319 - // We have to update the context to reflect up-to-date ResolveInfo 320 - updateContext( 321 - ctx, 322 - data, 323 - typename, 324 - typename, 325 - joinKeys(typename, fieldKey), 326 - fieldName 327 - ); 320 + // We run side-effect updates after the default, normalized updates 321 + // so that the data is already available in-store if necessary 322 + const updater = updates && updates[fieldName]; 323 + if (updater) { 324 + // We have to update the context to reflect up-to-date ResolveInfo 325 + updateContext( 326 + ctx, 327 + data, 328 + typename, 329 + typename, 330 + joinKeys(typename, fieldKey), 331 + fieldName 332 + ); 328 333 329 - data[fieldName] = fieldValue; 330 - updater(data, fieldArgs || {}, ctx.store, ctx); 331 - } 334 + data[fieldName] = fieldValue; 335 + updater(data, fieldArgs || {}, ctx.store, ctx); 332 336 } 333 337 334 338 // After processing the field, remove the current alias from the path again
+6 -14
exchanges/graphcache/src/store/store.test.ts
··· 123 123 expect(store.updates.Subscription).toBe(updatesOption.Subscription); 124 124 }); 125 125 126 - it("sets the store's updates field to an empty default if not provided", () => { 127 - const store = new Store({}); 128 - 129 - expect(store.updates.Mutation).toEqual({}); 130 - expect(store.updates.Subscription).toEqual({}); 131 - }); 132 - 133 126 it('should not warn if Mutation/Subscription operations do exist in the schema', function () { 134 127 new Store({ 135 128 schema: minifyIntrospectionQuery( ··· 163 156 expect(console.warn).toBeCalledTimes(1); 164 157 const warnMessage = mocked(console.warn).mock.calls[0][0]; 165 158 expect(warnMessage).toContain( 166 - 'Invalid mutation field: `doTheChaChaSlide` is not in the defined schema, but the `updates.Mutation` option is referencing it.' 159 + 'Invalid updates field: `doTheChaChaSlide` on `Mutation` is not in the defined schema' 167 160 ); 168 - expect(warnMessage).toContain('https://bit.ly/2XbVrpR#21'); 161 + expect(warnMessage).toContain('https://bit.ly/2XbVrpR#22'); 169 162 }); 170 163 171 164 it("should warn if Subscription operations don't exist in the schema", function () { ··· 183 176 expect(console.warn).toBeCalledTimes(1); 184 177 const warnMessage = mocked(console.warn).mock.calls[0][0]; 185 178 expect(warnMessage).toContain( 186 - 'Invalid subscription field: `someoneDidTheChaChaSlide` is not in the defined schema, but the `updates.Subscription` option is referencing it.' 179 + 'Invalid updates field: `someoneDidTheChaChaSlide` on `Subscription` is not in the defined schema' 187 180 ); 188 181 expect(warnMessage).toContain('https://bit.ly/2XbVrpR#22'); 189 182 }); ··· 1006 999 expect(warnMessage).toContain('https://bit.ly/2XbVrpR#24'); 1007 1000 }); 1008 1001 1009 - it('should use different rootConfigs', function () { 1002 + it('should use different rootConfigs', () => { 1010 1003 const fakeUpdater = vi.fn(); 1011 1004 1012 1005 const store = new Store({ ··· 1024 1017 }, 1025 1018 }, 1026 1019 updates: { 1027 - Mutation: { 1020 + mutation_root: { 1028 1021 toggleTodo: fakeUpdater, 1029 1022 }, 1030 1023 }, 1031 1024 }); 1032 1025 1033 1026 const mutationData = { 1034 - __typename: 'mutation_root', 1035 1027 toggleTodo: { 1036 1028 __typename: 'Todo', 1037 1029 id: 1, ··· 1049 1041 } 1050 1042 `, 1051 1043 }, 1052 - mutationData 1044 + mutationData as any 1053 1045 ); 1054 1046 1055 1047 expect(fakeUpdater).toBeCalledTimes(1);
+4 -7
exchanges/graphcache/src/store/store.ts
··· 11 11 Link, 12 12 Data, 13 13 QueryInput, 14 - UpdateResolver, 14 + UpdatesConfig, 15 15 OptimisticMutationConfig, 16 16 KeyingConfig, 17 17 Entity, ··· 43 43 data: InMemoryData.InMemoryData; 44 44 45 45 resolvers: ResolverConfig; 46 - updates: Record<string, Record<string, UpdateResolver | undefined>>; 46 + updates: UpdatesConfig; 47 47 optimisticMutations: OptimisticMutationConfig; 48 48 keys: KeyingConfig; 49 49 schema?: SchemaIntrospector; 50 50 51 51 rootFields: { query: string; mutation: string; subscription: string }; 52 - rootNames: { [name: string]: RootField }; 52 + rootNames: { [name: string]: RootField | void }; 53 53 54 54 constructor(opts?: C) { 55 55 if (!opts) opts = {} as C; ··· 70 70 if (schema.types) this.schema = schema; 71 71 } 72 72 73 - this.updates = { 74 - [mutationName]: (opts.updates && opts.updates.Mutation) || {}, 75 - [subscriptionName]: (opts.updates && opts.updates.Subscription) || {}, 76 - }; 73 + this.updates = opts.updates || {}; 77 74 78 75 this.rootFields = { 79 76 query: queryName,
+49 -1
exchanges/graphcache/src/test-utils/examples-1.test.ts
··· 11 11 todos { 12 12 __typename 13 13 id 14 - text 15 14 complete 15 + text 16 16 } 17 17 } 18 18 `; ··· 401 401 __typename: 'Todo', 402 402 }, 403 403 { id: '2', text: 'Install urql', complete: true, __typename: 'Todo' }, 404 + ], 405 + }); 406 + }); 407 + 408 + it('respects arbitrary type update functions', () => { 409 + const store = new Store({ 410 + updates: { 411 + Todo: { 412 + text(result, _, cache) { 413 + const fragment = gql` 414 + fragment _ on Todo { 415 + id 416 + complete 417 + } 418 + `; 419 + 420 + cache.writeFragment(fragment, { 421 + id: result.id, 422 + complete: true, 423 + }); 424 + }, 425 + }, 426 + }, 427 + }); 428 + 429 + const todosData = { 430 + __typename: 'Query', 431 + todos: [ 432 + { id: '1', text: 'First', complete: false, __typename: 'Todo' }, 433 + { id: '2', text: 'Second', complete: false, __typename: 'Todo' }, 434 + ], 435 + }; 436 + 437 + write(store, { query: Todos }, todosData); 438 + const queryRes = query(store, { query: Todos }); 439 + 440 + expect(queryRes.partial).toBe(false); 441 + expect(queryRes.data).toEqual({ 442 + ...todosData, 443 + todos: [ 444 + { 445 + ...todosData.todos[0], 446 + complete: true, 447 + }, 448 + { 449 + ...todosData.todos[1], 450 + complete: true, 451 + }, 404 452 ], 405 453 }); 406 454 });
+6 -9
exchanges/graphcache/src/types.ts
··· 138 138 | undefined; 139 139 140 140 export type CacheExchangeOpts = { 141 - updates?: Partial<UpdatesConfig>; 141 + updates?: UpdatesConfig; 142 142 resolvers?: ResolverConfig; 143 143 optimistic?: OptimisticMutationConfig; 144 144 keys?: KeyingConfig; ··· 162 162 163 163 export type ResolverConfig = { 164 164 [typeName: string]: { 165 - [fieldName: string]: Resolver; 166 - }; 165 + [fieldName: string]: Resolver | void; 166 + } | void; 167 167 }; 168 168 169 169 export type UpdateResolver<ParentData = DataFields, Args = Variables> = { ··· 180 180 }['bivarianceHack']; 181 181 182 182 export type UpdatesConfig = { 183 - Mutation: { 184 - [fieldName: string]: UpdateResolver; 185 - }; 186 - Subscription: { 187 - [fieldName: string]: UpdateResolver; 188 - }; 183 + [typeName: string | 'Query' | 'Mutation' | 'Subscription']: { 184 + [fieldName: string]: UpdateResolver | void; 185 + } | void; 189 186 }; 190 187 191 188 export type MakeFunctional<T> = T extends { __typename: string }