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