1import type {
2 FragmentDefinitionNode,
3 IntrospectionQuery,
4 SelectionNode,
5 GraphQLInterfaceType,
6 FieldNode,
7 InlineFragmentNode,
8 FragmentSpreadNode,
9 ArgumentNode,
10} from 'graphql';
11import {
12 buildClientSchema,
13 isAbstractType,
14 Kind,
15 GraphQLObjectType,
16 valueFromASTUntyped,
17 GraphQLScalarType,
18} from 'graphql';
19import { pipe, tap, map } from 'wonka';
20import type { Exchange, Operation } from '@urql/core';
21import { stringifyVariables } from '@urql/core';
22
23import type { GraphQLFlatType } from './helpers/node';
24import { getName, unwrapType } from './helpers/node';
25import { traverse } from './helpers/traverse';
26
27/** Configuration options for the {@link populateExchange}'s behaviour */
28export interface Options {
29 /** Prevents populating fields for matching types.
30 *
31 * @remarks
32 * `skipType` may be set to a regular expression that, when matching,
33 * prevents fields to be added automatically for the given type by the
34 * `populateExchange`.
35 *
36 * @defaultValue `/^PageInfo|(Connection|Edge)$/` - Omit Relay pagination fields
37 */
38 skipType?: RegExp;
39 /** Specifies a maximum depth for populated fields.
40 *
41 * @remarks
42 * `maxDepth` may be set to a maximum depth at which fields are populated.
43 * This may prevent the `populateExchange` from adding infinitely deep
44 * recursive fields or simply too many fields.
45 *
46 * @defaultValue `2` - Omit fields past a depth of 2.
47 */
48 maxDepth?: number;
49}
50
51/** Input parameters for the {@link populateExchange}. */
52export interface PopulateExchangeOpts {
53 /** Introspection data for an API’s schema.
54 *
55 * @remarks
56 * `schema` must be passed Schema Introspection data for the GraphQL API
57 * this exchange is applied for.
58 * You may use the `@urql/introspection` package to generate this data.
59 *
60 * @see {@link https://spec.graphql.org/October2021/#sec-Schema-Introspection} for the Schema Introspection spec.
61 */
62 schema: IntrospectionQuery;
63 /** Configuration options for the {@link populateExchange}'s behaviour */
64 options?: Options;
65}
66
67const makeDict = (): any => Object.create(null);
68
69/** stores information per each type it finds */
70type TypeKey = GraphQLObjectType | GraphQLInterfaceType;
71/** stores all known fields per each type key */
72type FieldValue = Record<string, FieldUsage>;
73type TypeFields = Map<String, FieldValue>;
74/** Describes information about a given field, i.e. type (owner), arguments, how many operations use this field */
75interface FieldUsage {
76 type: TypeKey;
77 args: null | { [key: string]: { value: any; kind: any } };
78 fieldName: string;
79}
80
81type FragmentMap<T extends string = string> = Record<T, FragmentDefinitionNode>;
82const SKIP_COUNT_TYPE = /^PageInfo|(Connection|Edge)$/;
83
84/** Creates an `Exchange` handing automatic mutation selection-set population based on the
85 * query selection-sets seen.
86 *
87 * @param options - A {@link PopulateExchangeOpts} configuration object.
88 * @returns the created populate {@link Exchange}.
89 *
90 * @remarks
91 * The `populateExchange` will create an exchange that monitors queries and
92 * extracts fields and types so it knows what is currently observed by your
93 * application.
94 * When a mutation comes in with the `@populate` directive it will fill the
95 * selection-set based on these prior queries.
96 *
97 * This Exchange can ease up the transition from documentCache to graphCache
98 *
99 * @example
100 * ```ts
101 * populateExchange({
102 * schema,
103 * options: {
104 * maxDepth: 3,
105 * skipType: /Todo/
106 * },
107 * });
108 *
109 * const query = gql`
110 * mutation { addTodo @popualte }
111 * `;
112 * ```
113 */
114export const populateExchange =
115 ({ schema: ogSchema, options }: PopulateExchangeOpts): Exchange =>
116 ({ forward }) => {
117 const maxDepth = (options && options.maxDepth) || 2;
118 const skipType = (options && options.skipType) || SKIP_COUNT_TYPE;
119
120 const schema = buildClientSchema(ogSchema);
121 /** List of operation keys that have already been parsed. */
122 const parsedOperations = new Set<number>();
123 /** List of operation keys that have not been torn down. */
124 const activeOperations = new Set<number>();
125 /** Collection of fragments used by the user. */
126 const userFragments: FragmentMap = makeDict();
127
128 // State of the global types & their fields
129 const typeFields: TypeFields = new Map();
130 let currentVariables: object = {};
131
132 /** Handle mutation and inject selections + fragments. */
133 const handleIncomingMutation = (op: Operation) => {
134 if (op.kind !== 'mutation') {
135 return op;
136 }
137
138 const document = traverse(op.query, node => {
139 if (node.kind === Kind.FIELD) {
140 if (!node.directives) return;
141
142 const directives = node.directives.filter(
143 d => getName(d) !== 'populate'
144 );
145
146 if (directives.length === node.directives.length) return;
147
148 const field = schema.getMutationType()!.getFields()[node.name.value];
149
150 if (!field) return;
151
152 const type = unwrapType(field.type);
153
154 if (!type) {
155 return {
156 ...node,
157 selectionSet: {
158 kind: Kind.SELECTION_SET,
159 selections: [
160 {
161 kind: Kind.FIELD,
162 name: {
163 kind: Kind.NAME,
164 value: '__typename',
165 },
166 },
167 ],
168 },
169 directives,
170 };
171 }
172
173 const visited = new Set();
174 const populateSelections = (
175 type: GraphQLFlatType,
176 selections: Array<
177 FieldNode | InlineFragmentNode | FragmentSpreadNode
178 >,
179 depth: number
180 ) => {
181 let possibleTypes: readonly string[] = [];
182 let isAbstract = false;
183 if (isAbstractType(type)) {
184 isAbstract = true;
185 possibleTypes = schema.getPossibleTypes(type).map(x => x.name);
186 } else {
187 possibleTypes = [type.name];
188 }
189
190 possibleTypes.forEach(typeName => {
191 const fieldsForType = typeFields.get(typeName);
192 if (!fieldsForType) {
193 if (possibleTypes.length === 1) {
194 selections.push({
195 kind: Kind.FIELD,
196 name: {
197 kind: Kind.NAME,
198 value: '__typename',
199 },
200 });
201 }
202 return;
203 }
204
205 let typeSelections: Array<
206 FieldNode | InlineFragmentNode | FragmentSpreadNode
207 > = selections;
208
209 if (isAbstract) {
210 typeSelections = [
211 {
212 kind: Kind.FIELD,
213 name: {
214 kind: Kind.NAME,
215 value: '__typename',
216 },
217 },
218 ];
219 selections.push({
220 kind: Kind.INLINE_FRAGMENT,
221 typeCondition: {
222 kind: Kind.NAMED_TYPE,
223 name: {
224 kind: Kind.NAME,
225 value: typeName,
226 },
227 },
228 selectionSet: {
229 kind: Kind.SELECTION_SET,
230 selections: typeSelections,
231 },
232 });
233 } else {
234 typeSelections.push({
235 kind: Kind.FIELD,
236 name: {
237 kind: Kind.NAME,
238 value: '__typename',
239 },
240 });
241 }
242
243 Object.keys(fieldsForType).forEach(key => {
244 const value = fieldsForType[key];
245 if (value.type instanceof GraphQLScalarType) {
246 const args = value.args
247 ? Object.keys(value.args).map(k => {
248 const v = value.args![k];
249 return {
250 kind: Kind.ARGUMENT,
251 value: {
252 kind: v.kind,
253 value: v.value,
254 },
255 name: {
256 kind: Kind.NAME,
257 value: k,
258 },
259 } as ArgumentNode;
260 })
261 : [];
262 const field: FieldNode = {
263 kind: Kind.FIELD,
264 arguments: args,
265 name: {
266 kind: Kind.NAME,
267 value: value.fieldName,
268 },
269 };
270
271 typeSelections.push(field);
272 } else if (
273 value.type instanceof GraphQLObjectType &&
274 !visited.has(value.type.name) &&
275 depth < maxDepth
276 ) {
277 visited.add(value.type.name);
278 const fieldSelections: Array<FieldNode> = [];
279
280 populateSelections(
281 value.type,
282 fieldSelections,
283 skipType.test(value.type.name) ? depth : depth + 1
284 );
285
286 const args = value.args
287 ? Object.keys(value.args).map(k => {
288 const v = value.args![k];
289 return {
290 kind: Kind.ARGUMENT,
291 value: {
292 kind: v.kind,
293 value: v.value,
294 },
295 name: {
296 kind: Kind.NAME,
297 value: k,
298 },
299 } as ArgumentNode;
300 })
301 : [];
302
303 const field: FieldNode = {
304 kind: Kind.FIELD,
305 selectionSet: {
306 kind: Kind.SELECTION_SET,
307 selections: fieldSelections,
308 },
309 arguments: args,
310 name: {
311 kind: Kind.NAME,
312 value: value.fieldName,
313 },
314 };
315
316 typeSelections.push(field);
317 }
318 });
319 });
320 };
321
322 visited.add(type.name);
323 const selections: Array<
324 FieldNode | InlineFragmentNode | FragmentSpreadNode
325 > = node.selectionSet ? [...node.selectionSet.selections] : [];
326 populateSelections(type, selections, 0);
327
328 return {
329 ...node,
330 selectionSet: {
331 kind: Kind.SELECTION_SET,
332 selections,
333 },
334 directives,
335 };
336 }
337 });
338
339 return {
340 ...op,
341 query: document,
342 };
343 };
344
345 const readFromSelectionSet = (
346 type: GraphQLObjectType | GraphQLInterfaceType,
347 selections: readonly SelectionNode[],
348 seenFields: Record<string, TypeKey> = {}
349 ) => {
350 if (isAbstractType(type)) {
351 // TODO: should we add this to typeParents/typeFields as well?
352 schema.getPossibleTypes(type).forEach(t => {
353 readFromSelectionSet(t, selections);
354 });
355 } else {
356 const fieldMap = type.getFields();
357
358 let args: null | Record<string, any> = null;
359 for (let i = 0; i < selections.length; i++) {
360 const selection = selections[i];
361
362 if (selection.kind === Kind.FRAGMENT_SPREAD) {
363 const fragmentName = getName(selection);
364
365 const fragment = userFragments[fragmentName];
366
367 if (fragment) {
368 readFromSelectionSet(type, fragment.selectionSet.selections);
369 }
370
371 continue;
372 }
373
374 if (selection.kind === Kind.INLINE_FRAGMENT) {
375 readFromSelectionSet(type, selection.selectionSet.selections);
376
377 continue;
378 }
379
380 if (selection.kind !== Kind.FIELD) continue;
381
382 const fieldName = selection.name.value;
383 if (!fieldMap[fieldName]) continue;
384
385 const ownerType =
386 seenFields[fieldName] || (seenFields[fieldName] = type);
387
388 let fields = typeFields.get(ownerType.name);
389 if (!fields) typeFields.set(type.name, (fields = {}));
390
391 const childType = unwrapType(
392 fieldMap[fieldName].type
393 ) as GraphQLObjectType;
394
395 if (selection.arguments && selection.arguments.length) {
396 args = {};
397 for (let j = 0; j < selection.arguments.length; j++) {
398 const argNode = selection.arguments[j];
399 args[argNode.name.value] = {
400 value: valueFromASTUntyped(
401 argNode.value,
402 currentVariables as any
403 ),
404 kind: argNode.value.kind,
405 };
406 }
407 }
408
409 const fieldKey = args
410 ? `${fieldName}:${stringifyVariables(args)}`
411 : fieldName;
412
413 if (!fields[fieldKey]) {
414 fields[fieldKey] = {
415 type: childType,
416 args,
417 fieldName,
418 };
419 }
420
421 if (selection.selectionSet) {
422 readFromSelectionSet(childType, selection.selectionSet.selections);
423 }
424 }
425 }
426 };
427
428 /** Handle query and extract fragments. */
429 const handleIncomingQuery = ({
430 key,
431 kind,
432 query,
433 variables,
434 }: Operation) => {
435 if (kind !== 'query') {
436 return;
437 }
438
439 activeOperations.add(key);
440 if (parsedOperations.has(key)) {
441 return;
442 }
443
444 parsedOperations.add(key);
445 currentVariables = variables || {};
446
447 for (let i = query.definitions.length; i--; ) {
448 const definition = query.definitions[i];
449
450 if (definition.kind === Kind.FRAGMENT_DEFINITION) {
451 userFragments[getName(definition)] = definition;
452 } else if (definition.kind === Kind.OPERATION_DEFINITION) {
453 const type = schema.getQueryType()!;
454 readFromSelectionSet(
455 unwrapType(type) as GraphQLObjectType,
456 definition.selectionSet.selections!
457 );
458 }
459 }
460 };
461
462 const handleIncomingTeardown = ({ key, kind }: Operation) => {
463 // TODO: we might want to remove fields here, the risk becomes
464 // that data in the cache would become stale potentially
465 if (kind === 'teardown') {
466 activeOperations.delete(key);
467 }
468 };
469
470 return ops$ => {
471 return pipe(
472 ops$,
473 tap(handleIncomingQuery),
474 tap(handleIncomingTeardown),
475 map(handleIncomingMutation),
476 forward
477 );
478 };
479 };