Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
at main 14 kB view raw
1import type { FormattedNode, CombinedError } from '@urql/core'; 2import { formatDocument } from '@urql/core'; 3 4import type { 5 FieldNode, 6 DocumentNode, 7 FragmentDefinitionNode, 8} from '@0no-co/graphql.web'; 9 10import type { SelectionSet } from '../ast'; 11import { 12 getFragments, 13 getMainOperation, 14 normalizeVariables, 15 getFieldArguments, 16 isFieldAvailableOnType, 17 getSelectionSet, 18 getName, 19 getFragmentTypeName, 20 getFieldAlias, 21} from '../ast'; 22 23import { invariant, warn, pushDebugNode, popDebugNode } from '../helpers/help'; 24 25import type { 26 NullArray, 27 Variables, 28 Data, 29 Link, 30 OperationRequest, 31 Dependencies, 32 EntityField, 33 OptimisticMutationResolver, 34} from '../types'; 35 36import { joinKeys, keyOfField } from '../store/keys'; 37import type { Store } from '../store/store'; 38import * as InMemoryData from '../store/data'; 39 40import type { Context } from './shared'; 41import { 42 SelectionIterator, 43 ensureData, 44 makeContext, 45 updateContext, 46 getFieldError, 47 deferRef, 48} from './shared'; 49import { invalidateType } from './invalidate'; 50 51export interface WriteResult { 52 data: null | Data; 53 dependencies: Dependencies; 54} 55 56/** Writes a GraphQL response to the cache. 57 * @internal 58 */ 59export const __initAnd_write = ( 60 store: Store, 61 request: OperationRequest, 62 data: Data, 63 error?: CombinedError | undefined, 64 key?: number 65): WriteResult => { 66 InMemoryData.initDataState('write', store.data, key || null); 67 const result = _write(store, request, data, error); 68 InMemoryData.clearDataState(); 69 return result; 70}; 71 72export const __initAnd_writeOptimistic = ( 73 store: Store, 74 request: OperationRequest, 75 key: number 76): WriteResult => { 77 if (process.env.NODE_ENV !== 'production') { 78 invariant( 79 getMainOperation(request.query).operation === 'mutation', 80 'writeOptimistic(...) was called with an operation that is not a mutation.\n' + 81 'This case is unsupported and should never occur.', 82 10 83 ); 84 } 85 86 InMemoryData.initDataState('write', store.data, key, true); 87 const result = _write(store, request, {} as Data, undefined); 88 InMemoryData.clearDataState(); 89 return result; 90}; 91 92export const _write = ( 93 store: Store, 94 request: OperationRequest, 95 data?: Data, 96 error?: CombinedError | undefined 97) => { 98 if (process.env.NODE_ENV !== 'production') { 99 InMemoryData.getCurrentDependencies(); 100 } 101 102 const query = formatDocument(request.query); 103 const operation = getMainOperation(query); 104 const result: WriteResult = { 105 data: data || InMemoryData.makeData(), 106 dependencies: InMemoryData.currentDependencies!, 107 }; 108 const kind = store.rootFields[operation.operation]; 109 110 const ctx = makeContext( 111 store, 112 normalizeVariables(operation, request.variables), 113 getFragments(query), 114 kind, 115 kind, 116 error 117 ); 118 119 if (process.env.NODE_ENV !== 'production') { 120 pushDebugNode(kind, operation); 121 } 122 123 writeSelection(ctx, kind, getSelectionSet(operation), result.data!); 124 125 if (process.env.NODE_ENV !== 'production') { 126 popDebugNode(); 127 } 128 129 return result; 130}; 131 132export const _writeFragment = ( 133 store: Store, 134 query: FormattedNode<DocumentNode>, 135 data: Partial<Data>, 136 variables?: Variables, 137 fragmentName?: string 138) => { 139 const fragments = getFragments(query); 140 let fragment: FormattedNode<FragmentDefinitionNode>; 141 if (fragmentName) { 142 fragment = fragments[fragmentName]!; 143 if (!fragment) { 144 warn( 145 'writeFragment(...) was called with a fragment name that does not exist.\n' + 146 'You provided ' + 147 fragmentName + 148 ' but could only find ' + 149 Object.keys(fragments).join(', ') + 150 '.', 151 11, 152 store.logger 153 ); 154 155 return null; 156 } 157 } else { 158 const names = Object.keys(fragments); 159 fragment = fragments[names[0]]!; 160 if (!fragment) { 161 warn( 162 'writeFragment(...) was called with an empty fragment.\n' + 163 'You have to call it with at least one fragment in your GraphQL document.', 164 11, 165 store.logger 166 ); 167 168 return null; 169 } 170 } 171 172 const typename = getFragmentTypeName(fragment); 173 const dataToWrite = { __typename: typename, ...data } as Data; 174 const entityKey = store.keyOfEntity(dataToWrite); 175 if (!entityKey) { 176 return warn( 177 "Can't generate a key for writeFragment(...) data.\n" + 178 'You have to pass an `id` or `_id` field or create a custom `keys` config for `' + 179 typename + 180 '`.', 181 12, 182 store.logger 183 ); 184 } 185 186 if (process.env.NODE_ENV !== 'production') { 187 pushDebugNode(typename, fragment); 188 } 189 190 const ctx = makeContext( 191 store, 192 variables || {}, 193 fragments, 194 typename, 195 entityKey, 196 undefined 197 ); 198 199 writeSelection(ctx, entityKey, getSelectionSet(fragment), dataToWrite); 200 201 if (process.env.NODE_ENV !== 'production') { 202 popDebugNode(); 203 } 204}; 205 206const writeSelection = ( 207 ctx: Context, 208 entityKey: undefined | string, 209 select: FormattedNode<SelectionSet>, 210 data: Data 211) => { 212 // These fields determine how we write. The `Query` root type is written 213 // like a normal entity, hence, we use `rootField` with a default to determine 214 // this. All other root names (Subscription & Mutation) are in a different 215 // write mode 216 const rootField = ctx.store.rootNames[entityKey!] || 'query'; 217 const isRoot = !!ctx.store.rootNames[entityKey!]; 218 219 let typename = isRoot ? entityKey : data.__typename; 220 if (!typename && entityKey && ctx.optimistic) { 221 typename = InMemoryData.readRecord(entityKey, '__typename') as 222 | string 223 | undefined; 224 } 225 226 if (!typename) { 227 warn( 228 "Couldn't find __typename when writing.\n" + 229 "If you're writing to the cache manually have to pass a `__typename` property on each entity in your data.", 230 14, 231 ctx.store.logger 232 ); 233 return; 234 } else if (!isRoot && entityKey) { 235 InMemoryData.writeRecord(entityKey, '__typename', typename); 236 InMemoryData.writeType(typename, entityKey); 237 } 238 239 const updates = ctx.store.updates[typename]; 240 const selection = new SelectionIterator( 241 typename, 242 entityKey || typename, 243 false, 244 undefined, 245 select, 246 ctx 247 ); 248 249 let node: FormattedNode<FieldNode> | void; 250 while ((node = selection.next())) { 251 const fieldName = getName(node); 252 const fieldArgs = getFieldArguments(node, ctx.variables); 253 const fieldKey = keyOfField(fieldName, fieldArgs); 254 const fieldAlias = getFieldAlias(node); 255 let fieldValue = data[ctx.optimistic ? fieldName : fieldAlias]; 256 257 if ( 258 // Skip typename fields and assume they've already been written above 259 fieldName === '__typename' || 260 // Fields marked as deferred that aren't defined must be skipped 261 // Otherwise, we also ignore undefined values in optimistic updaters 262 (fieldValue === undefined && 263 (deferRef || (ctx.optimistic && rootField === 'query'))) 264 ) { 265 continue; 266 } 267 268 if (process.env.NODE_ENV !== 'production') { 269 if (ctx.store.schema && typename && fieldName !== '__typename') { 270 isFieldAvailableOnType( 271 ctx.store.schema, 272 typename, 273 fieldName, 274 ctx.store.logger 275 ); 276 } 277 } 278 279 // Add the current alias to the walked path before processing the field's value 280 ctx.__internal.path.push(fieldAlias); 281 282 // Execute optimistic mutation functions on root fields, or execute recursive functions 283 // that have been returned on optimistic objects 284 let resolver: OptimisticMutationResolver | undefined; 285 if (ctx.optimistic && rootField === 'mutation') { 286 resolver = ctx.store.optimisticMutations[fieldName]; 287 if (!resolver) continue; 288 } else if (ctx.optimistic && typeof fieldValue === 'function') { 289 resolver = fieldValue as any; 290 } 291 292 // Execute the field-level resolver to retrieve its data 293 if (resolver) { 294 // We have to update the context to reflect up-to-date ResolveInfo 295 updateContext( 296 ctx, 297 data, 298 typename, 299 entityKey || typename, 300 fieldKey, 301 fieldName 302 ); 303 fieldValue = ensureData(resolver(fieldArgs || {}, ctx.store, ctx)); 304 } 305 306 if (fieldValue === undefined) { 307 if (process.env.NODE_ENV !== 'production') { 308 if ( 309 !entityKey || 310 !InMemoryData.hasField(entityKey, fieldKey) || 311 (ctx.optimistic && !InMemoryData.readRecord(entityKey, '__typename')) 312 ) { 313 const expected = 314 node.selectionSet === undefined 315 ? 'scalar (number, boolean, etc)' 316 : 'selection set'; 317 318 warn( 319 'Invalid undefined: The field at `' + 320 fieldKey + 321 '` is `undefined`, but the GraphQL query expects a ' + 322 expected + 323 ' for this field.', 324 13, 325 ctx.store.logger 326 ); 327 } 328 } 329 330 continue; // Skip this field 331 } 332 333 if (node.selectionSet) { 334 // Process the field and write links for the child entities that have been written 335 if (entityKey && rootField === 'query') { 336 const key = joinKeys(entityKey, fieldKey); 337 const link = writeField( 338 ctx, 339 getSelectionSet(node), 340 ensureData(fieldValue), 341 key, 342 ctx.optimistic 343 ? InMemoryData.readLink(entityKey || typename, fieldKey) 344 : undefined 345 ); 346 347 InMemoryData.writeLink(entityKey || typename, fieldKey, link); 348 } else { 349 writeField(ctx, getSelectionSet(node), ensureData(fieldValue)); 350 } 351 } else if (entityKey && rootField === 'query') { 352 // This is a leaf node, so we're setting the field's value directly 353 InMemoryData.writeRecord( 354 entityKey || typename, 355 fieldKey, 356 (fieldValue !== null || !getFieldError(ctx) 357 ? fieldValue 358 : undefined) as EntityField 359 ); 360 } 361 362 // We run side-effect updates after the default, normalized updates 363 // so that the data is already available in-store if necessary 364 const updater = updates && updates[fieldName]; 365 if (updater) { 366 // We have to update the context to reflect up-to-date ResolveInfo 367 updateContext( 368 ctx, 369 data, 370 typename, 371 entityKey || typename, 372 fieldKey, 373 fieldName 374 ); 375 376 data[fieldName] = fieldValue; 377 updater(data, fieldArgs || {}, ctx.store, ctx); 378 } else if ( 379 typename === ctx.store.rootFields['mutation'] && 380 !ctx.optimistic 381 ) { 382 // If we're on a mutation that doesn't have an updater, we'll see 383 // whether we can find the entity returned by the mutation in the cache. 384 // if we don't we'll assume this is a create mutation and invalidate 385 // the found __typename. 386 if (fieldValue && Array.isArray(fieldValue)) { 387 const excludedEntities: string[] = fieldValue.map( 388 entity => ctx.store.keyOfEntity(entity) || '' 389 ); 390 for (let i = 0, l = fieldValue.length; i < l; i++) { 391 const key = excludedEntities[i]; 392 if (key && fieldValue[i].__typename) { 393 const resolved = InMemoryData.readRecord(key, '__typename'); 394 const count = InMemoryData!.getRefCount(key); 395 if (resolved && !count) { 396 invalidateType(fieldValue[i].__typename, excludedEntities); 397 } 398 } 399 } 400 } else if (fieldValue && typeof fieldValue === 'object') { 401 const key = ctx.store.keyOfEntity(fieldValue as any); 402 if (key) { 403 const resolved = InMemoryData.readRecord(key, '__typename'); 404 const count = InMemoryData.getRefCount(key); 405 if ((!resolved || !count) && fieldValue.__typename) { 406 invalidateType(fieldValue.__typename, [key]); 407 } 408 } 409 } 410 } 411 412 // After processing the field, remove the current alias from the path again 413 ctx.__internal.path.pop(); 414 } 415}; 416 417// A pattern to match typenames of types that are likely never keyable 418const KEYLESS_TYPE_RE = /^__|PageInfo|(Connection|Edge)$/; 419 420const writeField = ( 421 ctx: Context, 422 select: FormattedNode<SelectionSet>, 423 data: null | Data | NullArray<Data>, 424 parentFieldKey?: string, 425 prevLink?: Link 426): Link | undefined => { 427 if (Array.isArray(data)) { 428 const newData = new Array(data.length); 429 for (let i = 0, l = data.length; i < l; i++) { 430 // Add the current index to the walked path before processing the link 431 ctx.__internal.path.push(i); 432 // Append the current index to the parentFieldKey fallback 433 const indexKey = parentFieldKey 434 ? joinKeys(parentFieldKey, `${i}`) 435 : undefined; 436 // Recursively write array data 437 const prevIndex = prevLink != null ? prevLink[i] : undefined; 438 const links = writeField(ctx, select, data[i], indexKey, prevIndex); 439 // Link cannot be expressed as a recursive type 440 newData[i] = links as string | null; 441 // After processing the field, remove the current index from the path 442 ctx.__internal.path.pop(); 443 } 444 445 return newData; 446 } else if (data === null) { 447 return getFieldError(ctx) ? undefined : null; 448 } 449 450 const entityKey = 451 ctx.store.keyOfEntity(data) || 452 (typeof prevLink === 'string' ? prevLink : null); 453 const typename = data.__typename; 454 455 if ( 456 parentFieldKey && 457 !ctx.store.keys[data.__typename] && 458 entityKey === null && 459 typeof typename === 'string' && 460 !KEYLESS_TYPE_RE.test(typename) 461 ) { 462 warn( 463 'Invalid key: The GraphQL query at the field at `' + 464 parentFieldKey + 465 '` has a selection set, ' + 466 'but no key could be generated for the data at this field.\n' + 467 'You have to request `id` or `_id` fields for all selection sets or create ' + 468 'a custom `keys` config for `' + 469 typename + 470 '`.\n' + 471 'Entities without keys will be embedded directly on the parent entity. ' + 472 'If this is intentional, create a `keys` config for `' + 473 typename + 474 '` that always returns null.', 475 15, 476 ctx.store.logger 477 ); 478 } 479 480 const childKey = entityKey || parentFieldKey; 481 writeSelection(ctx, childKey, select, data); 482 return childKey || null; 483};