Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
at main 15 kB view raw
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 };