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

feat(core/graphcache): Add support for client-side-only directive processing (#3317)

authored by kitten.sh and committed by

GitHub d3eb256d 7ddccc13

+660 -513
+5
.changeset/brave-lamps-punch.md
··· 1 + --- 2 + '@urql/exchange-graphcache': patch 3 + --- 4 + 5 + Use new `FormattedNode` / `formatDocument` functionality added to `@urql/core` to slightly speed up directive processing by using the client-side `_directives` dictionary that `formatDocument` adds.
+5
.changeset/rude-waves-check.md
··· 1 + --- 2 + '@urql/core': minor 3 + --- 4 + 5 + Update `formatDocument` to output `FormattedNode` type mapping. The formatter will now annotate added `__typename` fields with `_generated: true`, place selection nodes' directives onto a `_directives` dictionary, and will filter directives to not include `"_"` underscore prefixed directives in the final query. This prepares us for a feature that allows enhanced client-side directives in Graphcache.
+16 -13
exchanges/graphcache/src/ast/node.ts
··· 1 1 import { 2 2 NamedTypeNode, 3 3 NameNode, 4 + DirectiveNode, 4 5 SelectionNode, 5 6 SelectionSetNode, 6 - InlineFragmentNode, 7 7 FieldNode, 8 8 FragmentDefinitionNode, 9 - Kind, 10 9 } from '@0no-co/graphql.web'; 11 10 12 - export type SelectionSet = ReadonlyArray<SelectionNode>; 11 + import { FormattedNode } from '@urql/core'; 12 + 13 + export type SelectionSet = readonly FormattedNode<SelectionNode>[]; 14 + 15 + const EMPTY_DIRECTIVES: Record<string, DirectiveNode | undefined> = {}; 16 + 17 + /** Returns the directives dictionary of a given node */ 18 + export const getDirectives = (node: { 19 + _directives?: Record<string, DirectiveNode | undefined>; 20 + }) => node._directives || EMPTY_DIRECTIVES; 13 21 14 22 /** Returns the name of a given node */ 15 23 export const getName = (node: { name: NameNode }): string => node.name.value; ··· 25 33 26 34 /** Returns the SelectionSet for a given inline or defined fragment node */ 27 35 export const getSelectionSet = (node: { 28 - selectionSet?: SelectionSetNode; 29 - }): SelectionSet => 30 - node.selectionSet ? node.selectionSet.selections : emptySelectionSet; 36 + selectionSet?: FormattedNode<SelectionSetNode>; 37 + }): FormattedNode<SelectionSet> => 38 + (node.selectionSet 39 + ? node.selectionSet.selections 40 + : emptySelectionSet) as FormattedNode<SelectionSet>; 31 41 32 42 export const getTypeCondition = (node: { 33 43 typeCondition?: NamedTypeNode; 34 44 }): string | null => 35 45 node.typeCondition ? node.typeCondition.name.value : null; 36 - 37 - export const isFieldNode = (node: SelectionNode): node is FieldNode => 38 - node.kind === Kind.FIELD; 39 - 40 - export const isInlineFragment = ( 41 - node: SelectionNode 42 - ): node is InlineFragmentNode => node.kind === Kind.INLINE_FRAGMENT;
+22 -15
exchanges/graphcache/src/ast/traversal.test.ts
··· 1 - import { gql } from '@urql/core'; 1 + import { formatDocument, gql } from '@urql/core'; 2 2 import { describe, it, expect } from 'vitest'; 3 3 4 4 import { getSelectionSet } from './node'; ··· 6 6 7 7 describe('getMainOperation', () => { 8 8 it('retrieves the first operation', () => { 9 - const doc = gql` 9 + const doc = formatDocument(gql` 10 10 query Query { 11 11 field 12 12 } 13 - `; 13 + `); 14 + 14 15 const operation = getMainOperation(doc); 15 16 expect(operation).toBe(doc.definitions[0]); 16 17 }); 17 18 18 19 it('throws when no operation is found', () => { 19 - const doc = gql` 20 + const doc = formatDocument(gql` 20 21 fragment _ on Query { 21 22 field 22 23 } 23 - `; 24 + `); 25 + 24 26 expect(() => getMainOperation(doc)).toThrow(); 25 27 }); 26 28 }); 27 29 28 30 describe('shouldInclude', () => { 29 31 it('should include fields with truthy @include or falsy @skip directives', () => { 30 - const doc = gql` 32 + const doc = formatDocument(gql` 31 33 { 32 34 fieldA @include(if: true) 33 35 fieldB @skip(if: false) 34 36 } 35 - `; 37 + `); 38 + 36 39 const fieldA = getSelectionSet(getMainOperation(doc))[0]; 37 40 const fieldB = getSelectionSet(getMainOperation(doc))[1]; 38 41 expect(shouldInclude(fieldA, {})).toBe(true); ··· 40 43 }); 41 44 42 45 it('should exclude fields with falsy @include or truthy @skip directives', () => { 43 - const doc = gql` 46 + const doc = formatDocument(gql` 44 47 { 45 48 fieldA @include(if: false) 46 49 fieldB @skip(if: true) 47 50 } 48 - `; 51 + `); 52 + 49 53 const fieldA = getSelectionSet(getMainOperation(doc))[0]; 50 54 const fieldB = getSelectionSet(getMainOperation(doc))[1]; 51 55 expect(shouldInclude(fieldA, {})).toBe(false); ··· 53 57 }); 54 58 55 59 it('ignore other directives', () => { 56 - const doc = gql` 60 + const doc = formatDocument(gql` 57 61 { 58 62 field @test(if: false) 59 63 } 60 - `; 64 + `); 65 + 61 66 const field = getSelectionSet(getMainOperation(doc))[0]; 62 67 expect(shouldInclude(field, {})).toBe(true); 63 68 }); 64 69 65 70 it('ignore unknown arguments on directives', () => { 66 - const doc = gql` 71 + const doc = formatDocument(gql` 67 72 { 68 73 field @skip(if: true, other: false) 69 74 } 70 - `; 75 + `); 76 + 71 77 const field = getSelectionSet(getMainOperation(doc))[0]; 72 78 expect(shouldInclude(field, {})).toBe(false); 73 79 }); 74 80 75 81 it('ignore directives with invalid first arguments', () => { 76 - const doc = gql` 82 + const doc = formatDocument(gql` 77 83 { 78 84 field @skip(other: true) 79 85 } 80 - `; 86 + `); 87 + 81 88 const field = getSelectionSet(getMainOperation(doc))[0]; 82 89 expect(shouldInclude(field, {})).toBe(true); 83 90 });
+39 -41
exchanges/graphcache/src/ast/traversal.ts
··· 8 8 Kind, 9 9 } from '@0no-co/graphql.web'; 10 10 11 - import { getName } from './node'; 12 - 11 + import { FormattedNode } from '@urql/core'; 12 + import { getName, getDirectives } from './node'; 13 13 import { invariant } from '../helpers/help'; 14 14 import { Fragments, Variables } from '../types'; 15 15 16 + function getMainOperation( 17 + doc: FormattedNode<DocumentNode> 18 + ): FormattedNode<OperationDefinitionNode>; 19 + function getMainOperation(doc: DocumentNode): OperationDefinitionNode; 20 + 16 21 /** Returns the main operation's definition */ 17 - export const getMainOperation = ( 18 - doc: DocumentNode 19 - ): OperationDefinitionNode => { 22 + function getMainOperation(doc: DocumentNode): OperationDefinitionNode { 20 23 for (let i = 0; i < doc.definitions.length; i++) { 21 24 if (doc.definitions[i].kind === Kind.OPERATION_DEFINITION) { 22 - return doc.definitions[i] as OperationDefinitionNode; 25 + return doc.definitions[i] as FormattedNode<OperationDefinitionNode>; 23 26 } 24 27 } 25 28 ··· 29 32 'node for a query, subscription, or mutation.', 30 33 1 31 34 ); 32 - }; 35 + } 36 + 37 + export { getMainOperation }; 33 38 34 39 /** Returns a mapping from fragment names to their selections */ 35 - export const getFragments = (doc: DocumentNode): Fragments => { 40 + export const getFragments = (doc: FormattedNode<DocumentNode>): Fragments => { 36 41 const fragments: Fragments = {}; 37 42 for (let i = 0; i < doc.definitions.length; i++) { 38 43 const node = doc.definitions[i]; ··· 46 51 47 52 /** Resolves @include and @skip directives to determine whether field is included. */ 48 53 export const shouldInclude = ( 49 - node: SelectionNode, 54 + node: FormattedNode<SelectionNode>, 50 55 vars: Variables 51 56 ): boolean => { 52 - // Finds any @include or @skip directive that forces the node to be skipped 53 - for (let i = 0; node.directives && i < node.directives.length; i++) { 54 - const directive = node.directives[i]; 55 - const name = getName(directive); 56 - if ( 57 - (name === 'include' || name === 'skip') && 58 - directive.arguments && 59 - directive.arguments[0] && 60 - getName(directive.arguments[0]) === 'if' 61 - ) { 62 - // Return whether this directive forces us to skip 63 - // `@include(if: false)` or `@skip(if: true)` 64 - const value = valueFromASTUntyped(directive.arguments[0].value, vars); 65 - return name === 'include' ? !!value : !value; 57 + const directives = getDirectives(node); 58 + if (directives.include || directives.skip) { 59 + // Finds any @include or @skip directive that forces the node to be skipped 60 + for (const name in directives) { 61 + const directive = directives[name]; 62 + if ( 63 + directive && 64 + (name === 'include' || name === 'skip') && 65 + directive.arguments && 66 + directive.arguments[0] && 67 + getName(directive.arguments[0]) === 'if' 68 + ) { 69 + // Return whether this directive forces us to skip 70 + // `@include(if: false)` or `@skip(if: true)` 71 + const value = valueFromASTUntyped(directive.arguments[0].value, vars); 72 + return name === 'include' ? !!value : !value; 73 + } 66 74 } 67 75 } 68 - 69 76 return true; 70 77 }; 71 78 72 79 /** Resolves @defer directive to determine whether a fragment is potentially skipped. */ 73 80 export const isDeferred = ( 74 - node: FragmentSpreadNode | InlineFragmentNode, 81 + node: FormattedNode<FragmentSpreadNode | InlineFragmentNode>, 75 82 vars: Variables 76 83 ): boolean => { 77 - for (let i = 0; node.directives && i < node.directives.length; i++) { 78 - const directive = node.directives[i]; 79 - const name = getName(directive); 80 - if (name === 'defer') { 81 - for ( 82 - let j = 0; 83 - directive.arguments && j < directive.arguments.length; 84 - j++ 85 - ) { 86 - const argument = directive.arguments[i]; 87 - if (getName(argument) === 'if') { 88 - // Return whether `@defer(if: )` is enabled 89 - return !!valueFromASTUntyped(argument.value, vars); 90 - } 84 + const { defer } = getDirectives(node); 85 + if (defer) { 86 + for (const argument of defer.arguments || []) { 87 + if (getName(argument) === 'if') { 88 + // Return whether `@defer(if: )` is enabled 89 + return !!valueFromASTUntyped(argument.value, vars); 91 90 } 92 - 93 - return true; 94 91 } 92 + return true; 95 93 } 96 94 97 95 return false;
+57 -41
exchanges/graphcache/src/ast/variables.test.ts
··· 1 - import { gql } from '@urql/core'; 1 + import { formatDocument, gql } from '@urql/core'; 2 2 import { describe, it, expect } from 'vitest'; 3 3 import { getMainOperation } from './traversal'; 4 4 import { normalizeVariables, filterVariables } from './variables'; ··· 7 7 it('normalizes variables', () => { 8 8 const input = { x: 42 }; 9 9 const operation = getMainOperation( 10 - gql` 11 - query ($x: Int!) { 12 - field 13 - } 14 - ` 10 + formatDocument( 11 + gql` 12 + query ($x: Int!) { 13 + field 14 + } 15 + ` 16 + ) 15 17 ); 16 18 const normalized = normalizeVariables(operation, input); 17 19 expect(normalized).toEqual({ x: 42 }); ··· 20 22 it('normalizes variables with defaults', () => { 21 23 const input = { x: undefined }; 22 24 const operation = getMainOperation( 23 - gql` 24 - query ($x: Int! = 42) { 25 - field 26 - } 27 - ` 25 + formatDocument( 26 + gql` 27 + query ($x: Int! = 42) { 28 + field 29 + } 30 + ` 31 + ) 28 32 ); 29 33 const normalized = normalizeVariables(operation, input); 30 34 expect(normalized).toEqual({ x: 42 }); ··· 33 37 it('normalizes variables even with missing fields', () => { 34 38 const input = { x: undefined }; 35 39 const operation = getMainOperation( 36 - gql` 37 - query ($x: Int!) { 38 - field 39 - } 40 - ` 40 + formatDocument( 41 + gql` 42 + query ($x: Int!) { 43 + field 44 + } 45 + ` 46 + ) 41 47 ); 42 48 const normalized = normalizeVariables(operation, input); 43 49 expect(normalized).toEqual({}); ··· 45 51 46 52 it('skips normalizing for queries without variables', () => { 47 53 const operation = getMainOperation( 48 - gql` 49 - query { 50 - field 51 - } 52 - ` 54 + formatDocument( 55 + gql` 56 + query { 57 + field 58 + } 59 + ` 60 + ) 53 61 ); 54 62 (operation as any).variableDefinitions = undefined; 55 63 const normalized = normalizeVariables(operation, {}); ··· 58 66 59 67 it('preserves missing variables', () => { 60 68 const operation = getMainOperation( 61 - gql` 62 - query { 63 - field 64 - } 65 - ` 69 + formatDocument( 70 + gql` 71 + query { 72 + field 73 + } 74 + ` 75 + ) 66 76 ); 67 77 (operation as any).variableDefinitions = undefined; 68 78 const normalized = normalizeVariables(operation, { test: true }); ··· 73 83 describe('filterVariables', () => { 74 84 it('returns undefined when no variables are defined', () => { 75 85 const operation = getMainOperation( 76 - gql` 77 - query { 78 - field 79 - } 80 - ` 86 + formatDocument( 87 + gql` 88 + query { 89 + field 90 + } 91 + ` 92 + ) 81 93 ); 82 94 const vars = filterVariables(operation, { test: true }); 83 95 expect(vars).toBe(undefined); ··· 86 98 it('filters out missing vars', () => { 87 99 const input = { x: true, y: false }; 88 100 const operation = getMainOperation( 89 - gql` 90 - query ($x: Int!) { 91 - field 92 - } 93 - ` 101 + formatDocument( 102 + gql` 103 + query ($x: Int!) { 104 + field 105 + } 106 + ` 107 + ) 94 108 ); 95 109 const vars = filterVariables(operation, input); 96 110 expect(vars).toEqual({ x: true }); ··· 99 113 it('ignores defaults', () => { 100 114 const input = { x: undefined }; 101 115 const operation = getMainOperation( 102 - gql` 103 - query ($x: Int! = 42) { 104 - field 105 - } 106 - ` 116 + formatDocument( 117 + gql` 118 + query ($x: Int! = 42) { 119 + field 120 + } 121 + ` 122 + ) 107 123 ); 108 124 const vars = filterVariables(operation, input); 109 125 expect(vars).toEqual({ x: undefined });
+25 -22
exchanges/graphcache/src/cacheExchange.ts
··· 4 4 makeOperation, 5 5 Operation, 6 6 OperationResult, 7 - OperationContext, 8 7 RequestPolicy, 9 8 CacheOutcome, 10 9 } from '@urql/core'; ··· 145 144 146 145 // This registers queries with the data layer to ensure commutativity 147 146 const prepareForwardedOperation = (operation: Operation) => { 148 - let context: Partial<OperationContext> | undefined; 149 147 if (operation.kind === 'query') { 150 148 // Pre-reserve the position of the result layer 151 149 reserveLayer(store.data, operation.key); ··· 158 156 // Mark operation layer as done 159 157 noopDataState(store.data, operation.key); 160 158 return operation; 161 - } else if ( 159 + } 160 + 161 + const query = formatDocument(operation.query); 162 + operation = makeOperation( 163 + operation.kind, 164 + { 165 + key: operation.key, 166 + query, 167 + variables: operation.variables 168 + ? filterVariables(getMainOperation(query), operation.variables) 169 + : operation.variables, 170 + }, 171 + { ...operation.context } 172 + ); 173 + 174 + if ( 162 175 operation.kind === 'mutation' && 163 176 operation.context.requestPolicy !== 'network-only' 164 177 ) { 165 178 operations.set(operation.key, operation); 166 179 // This executes an optimistic update for mutations and registers it if necessary 167 180 initDataState('write', store.data, operation.key, true, false); 168 - const { dependencies } = _write(store, operation, undefined, undefined); 181 + const { dependencies } = _write( 182 + store, 183 + operation as any, 184 + undefined, 185 + undefined 186 + ); 169 187 clearDataState(); 170 188 if (dependencies.size) { 171 189 // Update blocked optimistic dependencies ··· 180 198 executePendingOperations(operation, pendingOperations, true); 181 199 182 200 // Mark operation as optimistic 183 - context = { optimistic: true }; 201 + operation.context.optimistic = true; 184 202 } 185 203 } 186 204 187 - return makeOperation( 188 - operation.kind, 189 - { 190 - key: operation.key, 191 - query: formatDocument(operation.query), 192 - variables: operation.variables 193 - ? filterVariables( 194 - getMainOperation(operation.query), 195 - operation.variables 196 - ) 197 - : operation.variables, 198 - }, 199 - { ...operation.context, ...context } 200 - ); 205 + return operation; 201 206 }; 202 207 203 208 // This updates the known dependencies for the passed operation ··· 246 251 result: OperationResult, 247 252 pendingOperations: Operations 248 253 ): OperationResult => { 249 - // Retrieve the original operation to remove changes made by formatDocument 250 - const operation = 251 - operations.get(result.operation.key) || result.operation; 254 + const { operation } = result; 252 255 if (operation.kind === 'mutation') { 253 256 // Collect previous dependencies that have been written for optimistic updates 254 257 const dependencies = optimisticKeysToDependencies.get(operation.key);
-1
exchanges/graphcache/src/offlineExchange.ts
··· 13 13 } from '@urql/core'; 14 14 15 15 import { SerializedRequest, CacheExchangeOpts, StorageAdapter } from './types'; 16 - 17 16 import { cacheExchange } from './cacheExchange'; 18 17 import { toRequestPolicy } from './helpers/operation'; 19 18
+15 -14
exchanges/graphcache/src/operations/query.ts
··· 1 - import { CombinedError } from '@urql/core'; 1 + import { formatDocument, FormattedNode, CombinedError } from '@urql/core'; 2 2 3 3 import { 4 4 FieldNode, ··· 80 80 input?: Data | null | undefined, 81 81 error?: CombinedError | undefined 82 82 ): QueryResult => { 83 - const operation = getMainOperation(request.query); 83 + const query = formatDocument(request.query); 84 + const operation = getMainOperation(query); 84 85 const rootKey = store.rootFields[operation.operation]; 85 86 const rootSelect = getSelectionSet(operation); 86 87 87 88 const ctx = makeContext( 88 89 store, 89 90 normalizeVariables(operation, request.variables), 90 - getFragments(request.query), 91 + getFragments(query), 91 92 rootKey, 92 93 rootKey, 93 94 error ··· 127 128 const readRoot = ( 128 129 ctx: Context, 129 130 entityKey: string, 130 - select: SelectionSet, 131 + select: FormattedNode<SelectionSet>, 131 132 input: Data 132 133 ): Data => { 133 134 const typename = ctx.store.rootNames[entityKey] ··· 145 146 ctx 146 147 ); 147 148 148 - let node: FieldNode | void; 149 + let node: FormattedNode<FieldNode> | void; 149 150 let hasChanged = InMemoryData.currentForeignData; 150 151 const output = InMemoryData.makeData(input); 151 152 while ((node = iterate())) { ··· 179 180 180 181 const readRootField = ( 181 182 ctx: Context, 182 - select: SelectionSet, 183 + select: FormattedNode<SelectionSet>, 183 184 originalData: Link<Data> 184 185 ): Link<Data> => { 185 186 if (Array.isArray(originalData)) { ··· 213 214 214 215 export const _queryFragment = ( 215 216 store: Store, 216 - query: DocumentNode, 217 + query: FormattedNode<DocumentNode>, 217 218 entity: Partial<Data> | string, 218 219 variables?: Variables, 219 220 fragmentName?: string 220 221 ): Data | null => { 221 222 const fragments = getFragments(query); 222 223 223 - let fragment: FragmentDefinitionNode; 224 + let fragment: FormattedNode<FragmentDefinitionNode>; 224 225 if (fragmentName) { 225 - fragment = fragments[fragmentName] as FragmentDefinitionNode; 226 + fragment = fragments[fragmentName]!; 226 227 if (!fragment) { 227 228 warn( 228 229 'readFragment(...) was called with a fragment name that does not exist.\n' + ··· 238 239 } 239 240 } else { 240 241 const names = Object.keys(fragments); 241 - fragment = fragments[names[0]] as FragmentDefinitionNode; 242 + fragment = fragments[names[0]]!; 242 243 if (!fragment) { 243 244 warn( 244 245 'readFragment(...) was called with an empty fragment.\n' + ··· 297 298 const readSelection = ( 298 299 ctx: Context, 299 300 key: string, 300 - select: SelectionSet, 301 + select: FormattedNode<SelectionSet>, 301 302 input: Data, 302 303 result?: Data 303 304 ): Data | undefined => { ··· 352 353 let hasPartials = false; 353 354 let hasNext = false; 354 355 let hasChanged = InMemoryData.currentForeignData; 355 - let node: FieldNode | void; 356 + let node: FormattedNode<FieldNode> | void; 356 357 const output = InMemoryData.makeData(input); 357 358 while ((node = iterate()) !== undefined) { 358 359 // Derive the needed data from our node. ··· 511 512 typename: string, 512 513 fieldName: string, 513 514 key: string, 514 - select: SelectionSet, 515 + select: FormattedNode<SelectionSet>, 515 516 prevData: void | null | Data | Data[], 516 517 result: void | DataField, 517 518 isOwnedData: boolean ··· 583 584 link: Link | Link[], 584 585 typename: string, 585 586 fieldName: string, 586 - select: SelectionSet, 587 + select: FormattedNode<SelectionSet>, 587 588 prevData: void | null | Data | Data[], 588 589 isOwnedData: boolean 589 590 ): DataField | undefined => {
+11 -4
exchanges/graphcache/src/operations/shared.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 - import { TypedDocumentNode, gql } from '@urql/core'; 2 + import { 3 + TypedDocumentNode, 4 + FormattedNode, 5 + formatDocument, 6 + gql, 7 + } from '@urql/core'; 3 8 import { FieldNode } from '@0no-co/graphql.web'; 4 9 5 10 import { makeSelectionIterator, deferRef } from './shared'; 6 11 import { SelectionSet } from '../ast'; 7 12 8 - const selectionOfDocument = (doc: TypedDocumentNode): SelectionSet => { 9 - for (const definition of doc.definitions) 13 + const selectionOfDocument = ( 14 + doc: TypedDocumentNode 15 + ): FormattedNode<SelectionSet> => { 16 + for (const definition of formatDocument(doc).definitions) 10 17 if (definition.kind === 'OperationDefinition') 11 - return definition.selectionSet.selections; 18 + return definition.selectionSet.selections as FormattedNode<SelectionSet>; 12 19 return []; 13 20 }; 14 21
+13 -13
exchanges/graphcache/src/operations/shared.ts
··· 1 - import { CombinedError, ErrorLike } from '@urql/core'; 1 + import { CombinedError, ErrorLike, FormattedNode } from '@urql/core'; 2 2 3 3 import { 4 + Kind, 4 5 FieldNode, 5 6 InlineFragmentNode, 6 7 FragmentDefinitionNode, ··· 8 9 9 10 import { 10 11 isDeferred, 11 - isInlineFragment, 12 12 getTypeCondition, 13 13 getSelectionSet, 14 14 getName, 15 15 SelectionSet, 16 - isFieldNode, 17 16 } from '../ast'; 18 17 19 18 import { warn, pushDebugNode, popDebugNode } from '../helpers/help'; ··· 120 119 }; 121 120 122 121 const isFragmentHeuristicallyMatching = ( 123 - node: InlineFragmentNode | FragmentDefinitionNode, 122 + node: FormattedNode<InlineFragmentNode | FragmentDefinitionNode>, 124 123 typename: void | string, 125 124 entityKey: string, 126 125 vars: Variables ··· 146 145 return ( 147 146 currentOperation === 'write' || 148 147 !getSelectionSet(node).some(node => { 149 - if (!isFieldNode(node)) return false; 148 + if (node.kind !== Kind.FIELD) return false; 150 149 const fieldKey = keyOfField(getName(node), getFieldArguments(node, vars)); 151 150 return !hasField(entityKey, fieldKey); 152 151 }) ··· 154 153 }; 155 154 156 155 interface SelectionIterator { 157 - (): FieldNode | undefined; 156 + (): FormattedNode<FieldNode> | undefined; 158 157 } 159 158 160 159 export const makeSelectionIterator = ( 161 160 typename: void | string, 162 161 entityKey: string, 163 162 defer: boolean, 164 - selectionSet: SelectionSet, 163 + selectionSet: FormattedNode<SelectionSet>, 165 164 ctx: Context 166 165 ): SelectionIterator => { 167 166 let child: SelectionIterator | void; 168 167 let index = 0; 169 168 170 169 return function next() { 171 - let node: FieldNode | undefined; 170 + let node: FormattedNode<FieldNode> | undefined; 172 171 while (child || index < selectionSet.length) { 173 172 node = undefined; 174 173 deferRef = defer; ··· 183 182 const select = selectionSet[index++]; 184 183 if (!shouldInclude(select, ctx.variables)) { 185 184 /*noop*/ 186 - } else if (!isFieldNode(select)) { 185 + } else if (select.kind !== Kind.FIELD) { 187 186 // A fragment is either referred to by FragmentSpread or inline 188 - const fragment = !isInlineFragment(select) 189 - ? ctx.fragments[getName(select)] 190 - : select; 187 + const fragment = 188 + select.kind !== Kind.INLINE_FRAGMENT 189 + ? ctx.fragments[getName(select)] 190 + : select; 191 191 if (fragment) { 192 192 const isMatching = 193 193 !fragment.typeCondition || ··· 211 211 ); 212 212 } 213 213 } 214 - } else { 214 + } else if (currentOperation === 'write' || !select._generated) { 215 215 return select; 216 216 } 217 217 }
+11 -10
exchanges/graphcache/src/operations/write.ts
··· 1 - import { CombinedError } from '@urql/core'; 1 + import { formatDocument, FormattedNode, CombinedError } from '@urql/core'; 2 2 3 3 import { 4 4 FieldNode, ··· 97 97 InMemoryData.getCurrentDependencies(); 98 98 } 99 99 100 - const operation = getMainOperation(request.query); 100 + const query = formatDocument(request.query); 101 + const operation = getMainOperation(query); 101 102 const result: WriteResult = { 102 103 data: data || InMemoryData.makeData(), 103 104 dependencies: InMemoryData.currentDependencies!, ··· 107 108 const ctx = makeContext( 108 109 store, 109 110 normalizeVariables(operation, request.variables), 110 - getFragments(request.query), 111 + getFragments(query), 111 112 kind, 112 113 kind, 113 114 error ··· 128 129 129 130 export const _writeFragment = ( 130 131 store: Store, 131 - query: DocumentNode, 132 + query: FormattedNode<DocumentNode>, 132 133 data: Partial<Data>, 133 134 variables?: Variables, 134 135 fragmentName?: string 135 136 ) => { 136 137 const fragments = getFragments(query); 137 - let fragment: FragmentDefinitionNode; 138 + let fragment: FormattedNode<FragmentDefinitionNode>; 138 139 if (fragmentName) { 139 - fragment = fragments[fragmentName] as FragmentDefinitionNode; 140 + fragment = fragments[fragmentName]!; 140 141 if (!fragment) { 141 142 warn( 142 143 'writeFragment(...) was called with a fragment name that does not exist.\n' + ··· 152 153 } 153 154 } else { 154 155 const names = Object.keys(fragments); 155 - fragment = fragments[names[0]] as FragmentDefinitionNode; 156 + fragment = fragments[names[0]]!; 156 157 if (!fragment) { 157 158 warn( 158 159 'writeFragment(...) was called with an empty fragment.\n' + ··· 200 201 const writeSelection = ( 201 202 ctx: Context, 202 203 entityKey: undefined | string, 203 - select: SelectionSet, 204 + select: FormattedNode<SelectionSet>, 204 205 data: Data 205 206 ) => { 206 207 // These fields determine how we write. The `Query` root type is written ··· 237 238 ctx 238 239 ); 239 240 240 - let node: FieldNode | void; 241 + let node: FormattedNode<FieldNode> | void; 241 242 while ((node = iterate())) { 242 243 const fieldName = getName(node); 243 244 const fieldArgs = getFieldArguments(node, ctx.variables); ··· 371 372 372 373 const writeField = ( 373 374 ctx: Context, 374 - select: SelectionSet, 375 + select: FormattedNode<SelectionSet>, 375 376 data: null | Data | NullArray<Data>, 376 377 parentFieldKey?: string, 377 378 prevLink?: Link
-2
exchanges/graphcache/src/store/store.ts
··· 180 180 updater: (data: T | null) => T | null 181 181 ): void { 182 182 const request = createRequest(input.query, input.variables!); 183 - request.query = formatDocument(request.query); 184 183 const output = updater(this.readQuery(request)); 185 184 if (output !== null) { 186 185 _write(this, request, output as any, undefined); ··· 189 188 190 189 readQuery<T = Data, V = Variables>(input: QueryInput<T, V>): T | null { 191 190 const request = createRequest(input.query, input.variables!); 192 - request.query = formatDocument(request.query); 193 191 return _query(this, request, undefined, undefined).data as T | null; 194 192 } 195 193
+4 -3
exchanges/graphcache/src/types.ts
··· 3 3 DocumentInput, 4 4 RequestExtensions, 5 5 TypedDocumentNode, 6 + FormattedNode, 6 7 ErrorLike, 7 8 } from '@urql/core'; 8 9 9 - import { FragmentDefinitionNode } from '@0no-co/graphql.web'; 10 + import { DocumentNode, FragmentDefinitionNode } from '@0no-co/graphql.web'; 10 11 import { IntrospectionData } from './ast'; 11 12 12 13 /** Nullable GraphQL list types of `T`. ··· 26 27 * executing. 27 28 */ 28 29 export interface Fragments { 29 - [fragmentName: string]: void | FragmentDefinitionNode; 30 + [fragmentName: string]: void | FormattedNode<FragmentDefinitionNode>; 30 31 } 31 32 32 33 /** Non-object JSON values as serialized by a GraphQL API ··· 184 185 * GraphQL operation: its query document and variables. 185 186 */ 186 187 export interface OperationRequest { 187 - query: Exclude<DocumentInput<any, any>, string>; 188 + query: FormattedNode<DocumentNode> | DocumentNode; 188 189 variables?: any; 189 190 } 190 191
+2 -2
packages/core/src/exchanges/cache.ts
··· 7 7 import { 8 8 makeOperation, 9 9 addMetadata, 10 - collectTypesFromResponse, 10 + collectTypenames, 11 11 formatDocument, 12 12 } from '../utils'; 13 13 ··· 126 126 // than using subscriptions as “signals” to reexecute queries. However, if they’re 127 127 // just used as signals, it’s intuitive to hook them up using `additionalTypenames` 128 128 if (response.operation.kind !== 'subscription') { 129 - typenames = collectTypesFromResponse(response.data).concat(typenames); 129 + typenames = collectTypenames(response.data).concat(typenames); 130 130 } 131 131 132 132 // Invalidates the cache given a mutation's response
+36
packages/core/src/types.ts
··· 1 1 import type { GraphQLError, DocumentNode } from './utils/graphql'; 2 + import type { 3 + Kind, 4 + DirectiveNode, 5 + ValueNode, 6 + TypeNode, 7 + } from '@0no-co/graphql.web'; 2 8 import { Subscription, Source } from 'wonka'; 3 9 import { Client } from './client'; 4 10 import { CombinedError } from './utils/error'; ··· 35 41 */ 36 42 __ensureTypesOfVariablesAndResultMatching?: (variables: Variables) => Result; 37 43 }; 44 + 45 + /** GraphQL nodes with added `_directives` dictionary on nodes with directives. 46 + * 47 + * @remarks 48 + * The {@link formatDocument} utility processes documents to add `__typename` 49 + * fields to them. It additionally provides additional directives processing 50 + * and outputs this type. 51 + * 52 + * When applied, every node with non-const directives, will have an additional 53 + * `_directives` dictionary added to it, and filter directives starting with 54 + * a leading `_` underscore from the directives array. 55 + */ 56 + export type FormattedNode<Node> = Node extends readonly (infer Child)[] 57 + ? readonly FormattedNode<Child>[] 58 + : Node extends ValueNode | TypeNode 59 + ? Node 60 + : Node extends { kind: Kind } 61 + ? { 62 + [K in Exclude<keyof Node, 'directives' | 'loc'>]: FormattedNode<Node[K]>; 63 + } extends infer Node 64 + ? Node extends { 65 + kind: Kind.FIELD | Kind.INLINE_FRAGMENT | Kind.FRAGMENT_SPREAD; 66 + } 67 + ? Node & { 68 + _generated?: boolean; 69 + _directives?: Record<string, DirectiveNode> | undefined; 70 + } 71 + : Node 72 + : Node 73 + : Node; 38 74 39 75 /** Any GraphQL `DocumentNode` or query string input. 40 76 *
+88
packages/core/src/utils/collectTypenames.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { collectTypenames } from './collectTypenames'; 3 + 4 + describe('collectTypenames', () => { 5 + it('returns all typenames included in a response as an array', () => { 6 + const typeNames = collectTypenames({ 7 + todos: [ 8 + { 9 + id: 1, 10 + __typename: 'Todo', 11 + }, 12 + ], 13 + }); 14 + expect(typeNames).toEqual(['Todo']); 15 + }); 16 + 17 + it('does not duplicate typenames', () => { 18 + const typeNames = collectTypenames({ 19 + todos: [ 20 + { 21 + id: 1, 22 + __typename: 'Todo', 23 + }, 24 + { 25 + id: 3, 26 + __typename: 'Todo', 27 + }, 28 + ], 29 + }); 30 + expect(typeNames).toEqual(['Todo']); 31 + }); 32 + 33 + it('returns multiple different typenames', () => { 34 + const typeNames = collectTypenames({ 35 + todos: [ 36 + { 37 + id: 1, 38 + __typename: 'Todo', 39 + }, 40 + { 41 + id: 3, 42 + __typename: 'Avocado', 43 + }, 44 + ], 45 + }); 46 + expect(typeNames).toEqual(['Todo', 'Avocado']); 47 + }); 48 + 49 + it('works on nested objects', () => { 50 + const typeNames = collectTypenames({ 51 + todos: [ 52 + { 53 + id: 1, 54 + __typename: 'Todo', 55 + }, 56 + { 57 + id: 2, 58 + subTask: { 59 + id: 3, 60 + __typename: 'SubTask', 61 + }, 62 + }, 63 + ], 64 + }); 65 + expect(typeNames).toEqual(['Todo', 'SubTask']); 66 + }); 67 + 68 + it('traverses nested arrays of objects', () => { 69 + const typenames = collectTypenames({ 70 + todos: [ 71 + { 72 + id: 1, 73 + authors: [ 74 + [ 75 + { 76 + name: 'Phil', 77 + __typename: 'Author', 78 + }, 79 + ], 80 + ], 81 + __typename: 'Todo', 82 + }, 83 + ], 84 + }); 85 + 86 + expect(typenames).toEqual(['Author', 'Todo']); 87 + }); 88 + });
+30
packages/core/src/utils/collectTypenames.ts
··· 1 + interface EntityLike { 2 + [key: string]: EntityLike | EntityLike[] | any; 3 + __typename: string | null | void; 4 + } 5 + 6 + const collectTypes = (obj: EntityLike | EntityLike[], types: Set<string>) => { 7 + if (Array.isArray(obj)) { 8 + for (const item of obj) collectTypes(item, types); 9 + } else if (typeof obj === 'object' && obj !== null) { 10 + for (const key in obj) { 11 + if (key === '__typename' && typeof obj[key] === 'string') { 12 + types.add(obj[key] as string); 13 + } else { 14 + collectTypes(obj[key], types); 15 + } 16 + } 17 + } 18 + 19 + return types; 20 + }; 21 + 22 + /** Finds and returns a list of `__typename` fields found in response data. 23 + * 24 + * @privateRemarks 25 + * This is used by `@urql/core`’s document `cacheExchange` to find typenames 26 + * in a given GraphQL response’s data. 27 + */ 28 + export const collectTypenames = (response: object): string[] => [ 29 + ...collectTypes(response as EntityLike, new Set()), 30 + ];
+150
packages/core/src/utils/formatDocument.test.ts
··· 1 + import { Kind, parse, print } from '@0no-co/graphql.web'; 2 + import { describe, it, expect } from 'vitest'; 3 + import { createRequest } from './request'; 4 + import { formatDocument } from './formatDocument'; 5 + 6 + const formatTypeNames = (query: string) => { 7 + const typedNode = formatDocument(parse(query)); 8 + return print(typedNode); 9 + }; 10 + 11 + describe('formatDocument', () => { 12 + it('creates a new instance when adding typenames', () => { 13 + const doc = parse(`{ id todos { id } }`) as any; 14 + const newDoc = formatDocument(doc) as any; 15 + expect(doc).not.toBe(newDoc); 16 + expect(doc.definitions).not.toBe(newDoc.definitions); 17 + expect(doc.definitions[0]).not.toBe(newDoc.definitions[0]); 18 + expect(doc.definitions[0].selectionSet).not.toBe( 19 + newDoc.definitions[0].selectionSet 20 + ); 21 + expect(doc.definitions[0].selectionSet.selections).not.toBe( 22 + newDoc.definitions[0].selectionSet.selections 23 + ); 24 + // Here we're equal again: 25 + expect(doc.definitions[0].selectionSet.selections[0]).toBe( 26 + newDoc.definitions[0].selectionSet.selections[0] 27 + ); 28 + // Not equal again: 29 + expect(doc.definitions[0].selectionSet.selections[1]).not.toBe( 30 + newDoc.definitions[0].selectionSet.selections[1] 31 + ); 32 + expect(doc.definitions[0].selectionSet.selections[1].selectionSet).not.toBe( 33 + newDoc.definitions[0].selectionSet.selections[1].selectionSet 34 + ); 35 + // Equal again: 36 + expect( 37 + doc.definitions[0].selectionSet.selections[1].selectionSet.selections[0] 38 + ).toBe( 39 + newDoc.definitions[0].selectionSet.selections[1].selectionSet 40 + .selections[0] 41 + ); 42 + }); 43 + 44 + it('preserves the hashed key of the resulting query', () => { 45 + const doc = parse(`{ id todos { id } }`) as any; 46 + const expectedKey = createRequest(doc, undefined).key; 47 + const formattedDoc = formatDocument(doc); 48 + expect(formattedDoc).not.toBe(doc); 49 + const actualKey = createRequest(formattedDoc, undefined).key; 50 + expect(expectedKey).toBe(actualKey); 51 + }); 52 + 53 + it('does not preserve the referential integrity with a cloned object', () => { 54 + const doc = parse(`{ id todos { id } }`); 55 + const formattedDoc = formatDocument(doc); 56 + expect(formattedDoc).not.toBe(doc); 57 + const query = { ...formattedDoc }; 58 + const reformattedDoc = formatDocument(query); 59 + expect(reformattedDoc).not.toBe(doc); 60 + }); 61 + 62 + it('preserves custom properties', () => { 63 + const doc = parse(`{ todos { id } }`) as any; 64 + doc.documentId = '123'; 65 + expect((formatDocument(doc) as any).documentId).toBe(doc.documentId); 66 + }); 67 + 68 + it('adds typenames to a query string', () => { 69 + expect(formatTypeNames(`{ todos { id } }`)).toMatchInlineSnapshot(` 70 + "{ 71 + todos { 72 + id 73 + __typename 74 + } 75 + }" 76 + `); 77 + }); 78 + 79 + it('does not duplicate typenames', () => { 80 + expect( 81 + formatTypeNames(`{ 82 + todos { 83 + id 84 + __typename 85 + } 86 + }`) 87 + ).toMatchInlineSnapshot(` 88 + "{ 89 + todos { 90 + id 91 + __typename 92 + } 93 + }" 94 + `); 95 + }); 96 + 97 + it('does add typenames when it is aliased', () => { 98 + expect( 99 + formatTypeNames(`{ 100 + todos { 101 + id 102 + typename: __typename 103 + } 104 + }`) 105 + ).toMatchInlineSnapshot(` 106 + "{ 107 + todos { 108 + id 109 + typename: __typename 110 + __typename 111 + } 112 + }" 113 + `); 114 + }); 115 + 116 + it('processes directives', () => { 117 + const document = ` 118 + { 119 + todos @skip { 120 + id @_test 121 + } 122 + } 123 + `; 124 + 125 + const node = formatDocument(parse(document)); 126 + 127 + expect(node).toHaveProperty( 128 + 'definitions.0.selectionSet.selections.0.selectionSet.selections.0._directives', 129 + { 130 + test: { 131 + kind: Kind.DIRECTIVE, 132 + arguments: [], 133 + name: { 134 + kind: Kind.NAME, 135 + value: '_test', 136 + }, 137 + }, 138 + } 139 + ); 140 + 141 + expect(formatTypeNames(document)).toMatchInlineSnapshot(` 142 + "{ 143 + todos @skip { 144 + id 145 + __typename 146 + } 147 + }" 148 + `); 149 + }); 150 + });
+122
packages/core/src/utils/formatDocument.ts
··· 1 + import { 2 + Kind, 3 + FieldNode, 4 + SelectionNode, 5 + DefinitionNode, 6 + DirectiveNode, 7 + } from '@0no-co/graphql.web'; 8 + import { KeyedDocumentNode, keyDocument } from './request'; 9 + import { FormattedNode, TypedDocumentNode } from '../types'; 10 + 11 + const formatNode = < 12 + T extends SelectionNode | DefinitionNode | TypedDocumentNode<any, any> 13 + >( 14 + node: T 15 + ): FormattedNode<T> => { 16 + if ('definitions' in node) { 17 + const definitions: FormattedNode<DefinitionNode>[] = []; 18 + for (const definition of node.definitions) { 19 + const newDefinition = formatNode(definition); 20 + definitions.push(newDefinition); 21 + } 22 + 23 + return { ...node, definitions } as FormattedNode<T>; 24 + } 25 + 26 + if ('directives' in node && node.directives && node.directives.length) { 27 + const directives: DirectiveNode[] = []; 28 + const _directives = {}; 29 + for (const directive of node.directives) { 30 + let name = directive.name.value; 31 + if (name[0] !== '_') { 32 + directives.push(directive); 33 + } else { 34 + name = name.slice(1); 35 + } 36 + _directives[name] = directive; 37 + } 38 + node = { ...node, directives, _directives }; 39 + } 40 + 41 + if ('selectionSet' in node) { 42 + const selections: FormattedNode<SelectionNode>[] = []; 43 + let hasTypename = node.kind === Kind.OPERATION_DEFINITION; 44 + if (node.selectionSet) { 45 + for (const selection of node.selectionSet.selections || []) { 46 + hasTypename = 47 + hasTypename || 48 + (selection.kind === Kind.FIELD && 49 + selection.name.value === '__typename' && 50 + !selection.alias); 51 + const newSelection = formatNode(selection); 52 + selections.push(newSelection); 53 + } 54 + 55 + if (!hasTypename) { 56 + selections.push({ 57 + kind: Kind.FIELD, 58 + name: { 59 + kind: Kind.NAME, 60 + value: '__typename', 61 + }, 62 + _generated: true, 63 + } as FormattedNode<FieldNode>); 64 + } 65 + 66 + return { 67 + ...node, 68 + selectionSet: { ...node.selectionSet, selections }, 69 + } as FormattedNode<T>; 70 + } 71 + } 72 + 73 + return node as FormattedNode<T>; 74 + }; 75 + 76 + const formattedDocs = new Map<number, KeyedDocumentNode>(); 77 + 78 + /** Formats a GraphQL document to add `__typename` fields and process client-side directives. 79 + * 80 + * @param node - a {@link DocumentNode}. 81 + * @returns a {@link FormattedDocument} 82 + * 83 + * @remarks 84 + * Cache {@link Exchange | Exchanges} will require typename introspection to 85 + * recognize types in a GraphQL response. To retrieve these typenames, 86 + * this function is used to add the `__typename` fields to non-root 87 + * selection sets of a GraphQL document. 88 + * 89 + * Additionally, this utility will process directives, filter out client-side 90 + * directives starting with an `_` underscore, and place a `_directives` dictionary 91 + * on selection nodes. 92 + * 93 + * This utility also preserves the internally computed key of the 94 + * document as created by {@link createRequest} to avoid any 95 + * formatting from being duplicated. 96 + * 97 + * @see {@link https://spec.graphql.org/October2021/#sec-Type-Name-Introspection} for more information 98 + * on typename introspection via the `__typename` field. 99 + */ 100 + export const formatDocument = <T extends TypedDocumentNode<any, any>>( 101 + node: T 102 + ): FormattedNode<T> => { 103 + const query = keyDocument(node); 104 + 105 + let result = formattedDocs.get(query.__key); 106 + if (!result) { 107 + formattedDocs.set( 108 + query.__key, 109 + (result = formatNode(query) as KeyedDocumentNode) 110 + ); 111 + // Ensure that the hash of the resulting document won't suddenly change 112 + // we are marking __key as non-enumerable so when external exchanges use visit 113 + // to manipulate a document we won't restore the previous query due to the __key 114 + // property. 115 + Object.defineProperty(result, '__key', { 116 + value: query.__key, 117 + enumerable: false, 118 + }); 119 + } 120 + 121 + return result as FormattedNode<T>; 122 + };
+2 -1
packages/core/src/utils/index.ts
··· 1 1 export * from './error'; 2 2 export * from './request'; 3 3 export * from './result'; 4 - export * from './typenames'; 5 4 export * from './variables'; 5 + export * from './collectTypenames'; 6 + export * from './formatDocument'; 6 7 export * from './maskTypename'; 7 8 export * from './streamUtils'; 8 9 export * from './operation';
+1 -1
packages/core/src/utils/request.test.ts
··· 3 3 import { parse, print } from '@0no-co/graphql.web'; 4 4 import { gql } from '../gql'; 5 5 import { createRequest, stringifyDocument } from './request'; 6 - import { formatDocument } from './typenames'; 6 + import { formatDocument } from './formatDocument'; 7 7 8 8 describe('createRequest', () => { 9 9 it('should hash identical queries identically', () => {
-201
packages/core/src/utils/typenames.test.ts
··· 1 - import { parse, print } from '@0no-co/graphql.web'; 2 - import { describe, it, expect } from 'vitest'; 3 - import { collectTypesFromResponse, formatDocument } from './typenames'; 4 - import { createRequest } from './request'; 5 - 6 - const formatTypeNames = (query: string) => { 7 - const typedNode = formatDocument(parse(query)); 8 - return print(typedNode); 9 - }; 10 - 11 - describe('formatTypeNames', () => { 12 - it('creates a new instance when adding typenames', () => { 13 - const doc = parse(`{ id todos { id } }`) as any; 14 - const newDoc = formatDocument(doc) as any; 15 - expect(doc).not.toBe(newDoc); 16 - expect(doc.definitions).not.toBe(newDoc.definitions); 17 - expect(doc.definitions[0]).not.toBe(newDoc.definitions[0]); 18 - expect(doc.definitions[0].selectionSet).not.toBe( 19 - newDoc.definitions[0].selectionSet 20 - ); 21 - expect(doc.definitions[0].selectionSet.selections).not.toBe( 22 - newDoc.definitions[0].selectionSet.selections 23 - ); 24 - // Here we're equal again: 25 - expect(doc.definitions[0].selectionSet.selections[0]).toBe( 26 - newDoc.definitions[0].selectionSet.selections[0] 27 - ); 28 - // Not equal again: 29 - expect(doc.definitions[0].selectionSet.selections[1]).not.toBe( 30 - newDoc.definitions[0].selectionSet.selections[1] 31 - ); 32 - expect(doc.definitions[0].selectionSet.selections[1].selectionSet).not.toBe( 33 - newDoc.definitions[0].selectionSet.selections[1].selectionSet 34 - ); 35 - // Equal again: 36 - expect( 37 - doc.definitions[0].selectionSet.selections[1].selectionSet.selections[0] 38 - ).toBe( 39 - newDoc.definitions[0].selectionSet.selections[1].selectionSet 40 - .selections[0] 41 - ); 42 - }); 43 - 44 - it('preserves the hashed key of the resulting query', () => { 45 - const doc = parse(`{ id todos { id } }`) as any; 46 - const expectedKey = createRequest(doc, undefined).key; 47 - const formattedDoc = formatDocument(doc); 48 - expect(formattedDoc).not.toBe(doc); 49 - const actualKey = createRequest(formattedDoc, undefined).key; 50 - expect(expectedKey).toBe(actualKey); 51 - }); 52 - 53 - it('does not preserve the referential integrity with a cloned object', () => { 54 - const doc = parse(`{ id todos { id } }`); 55 - const formattedDoc = formatDocument(doc); 56 - expect(formattedDoc).not.toBe(doc); 57 - const query = { ...formattedDoc }; 58 - const reformattedDoc = formatDocument(query); 59 - expect(reformattedDoc).not.toBe(doc); 60 - }); 61 - 62 - it('preserves custom properties', () => { 63 - const doc = parse(`{ todos { id } }`) as any; 64 - doc.documentId = '123'; 65 - expect((formatDocument(doc) as any).documentId).toBe(doc.documentId); 66 - }); 67 - 68 - it('adds typenames to a query string', () => { 69 - expect(formatTypeNames(`{ todos { id } }`)).toMatchInlineSnapshot(` 70 - "{ 71 - todos { 72 - id 73 - __typename 74 - } 75 - }" 76 - `); 77 - }); 78 - 79 - it('does not duplicate typenames', () => { 80 - expect( 81 - formatTypeNames(`{ 82 - todos { 83 - id 84 - __typename 85 - } 86 - }`) 87 - ).toMatchInlineSnapshot(` 88 - "{ 89 - todos { 90 - id 91 - __typename 92 - } 93 - }" 94 - `); 95 - }); 96 - 97 - it('does add typenames when it is aliased', () => { 98 - expect( 99 - formatTypeNames(`{ 100 - todos { 101 - id 102 - typename: __typename 103 - } 104 - }`) 105 - ).toMatchInlineSnapshot(` 106 - "{ 107 - todos { 108 - id 109 - typename: __typename 110 - __typename 111 - } 112 - }" 113 - `); 114 - }); 115 - }); 116 - 117 - describe('collectTypesFromResponse', () => { 118 - it('returns all typenames included in a response as an array', () => { 119 - const typeNames = collectTypesFromResponse({ 120 - todos: [ 121 - { 122 - id: 1, 123 - __typename: 'Todo', 124 - }, 125 - ], 126 - }); 127 - expect(typeNames).toEqual(['Todo']); 128 - }); 129 - 130 - it('does not duplicate typenames', () => { 131 - const typeNames = collectTypesFromResponse({ 132 - todos: [ 133 - { 134 - id: 1, 135 - __typename: 'Todo', 136 - }, 137 - { 138 - id: 3, 139 - __typename: 'Todo', 140 - }, 141 - ], 142 - }); 143 - expect(typeNames).toEqual(['Todo']); 144 - }); 145 - 146 - it('returns multiple different typenames', () => { 147 - const typeNames = collectTypesFromResponse({ 148 - todos: [ 149 - { 150 - id: 1, 151 - __typename: 'Todo', 152 - }, 153 - { 154 - id: 3, 155 - __typename: 'Avocado', 156 - }, 157 - ], 158 - }); 159 - expect(typeNames).toEqual(['Todo', 'Avocado']); 160 - }); 161 - 162 - it('works on nested objects', () => { 163 - const typeNames = collectTypesFromResponse({ 164 - todos: [ 165 - { 166 - id: 1, 167 - __typename: 'Todo', 168 - }, 169 - { 170 - id: 2, 171 - subTask: { 172 - id: 3, 173 - __typename: 'SubTask', 174 - }, 175 - }, 176 - ], 177 - }); 178 - expect(typeNames).toEqual(['Todo', 'SubTask']); 179 - }); 180 - 181 - it('traverses nested arrays of objects', () => { 182 - const typenames = collectTypesFromResponse({ 183 - todos: [ 184 - { 185 - id: 1, 186 - authors: [ 187 - [ 188 - { 189 - name: 'Phil', 190 - __typename: 'Author', 191 - }, 192 - ], 193 - ], 194 - __typename: 'Todo', 195 - }, 196 - ], 197 - }); 198 - 199 - expect(typenames).toEqual(['Author', 'Todo']); 200 - }); 201 - });
-125
packages/core/src/utils/typenames.ts
··· 1 - import { Kind, SelectionNode, DefinitionNode } from '@0no-co/graphql.web'; 2 - import { KeyedDocumentNode, keyDocument } from './request'; 3 - import { TypedDocumentNode } from '../types'; 4 - 5 - interface EntityLike { 6 - [key: string]: EntityLike | EntityLike[] | any; 7 - __typename: string | null | void; 8 - } 9 - 10 - const collectTypes = (obj: EntityLike | EntityLike[], types: Set<string>) => { 11 - if (Array.isArray(obj)) { 12 - for (const item of obj) collectTypes(item, types); 13 - } else if (typeof obj === 'object' && obj !== null) { 14 - for (const key in obj) { 15 - if (key === '__typename' && typeof obj[key] === 'string') { 16 - types.add(obj[key] as string); 17 - } else { 18 - collectTypes(obj[key], types); 19 - } 20 - } 21 - } 22 - 23 - return types; 24 - }; 25 - 26 - /** Finds and returns a list of `__typename` fields found in response data. 27 - * 28 - * @privateRemarks 29 - * This is used by `@urql/core`’s document `cacheExchange` to find typenames 30 - * in a given GraphQL response’s data. 31 - */ 32 - export const collectTypesFromResponse = (response: object): string[] => [ 33 - ...collectTypes(response as EntityLike, new Set()), 34 - ]; 35 - 36 - const formatNode = < 37 - T extends SelectionNode | DefinitionNode | TypedDocumentNode<any, any> 38 - >( 39 - node: T 40 - ): T => { 41 - let hasChanged = false; 42 - 43 - if ('definitions' in node) { 44 - const definitions: DefinitionNode[] = []; 45 - for (const definition of node.definitions) { 46 - const newDefinition = formatNode(definition); 47 - hasChanged = hasChanged || newDefinition !== definition; 48 - definitions.push(newDefinition); 49 - } 50 - if (hasChanged) return { ...node, definitions }; 51 - } else if ('selectionSet' in node) { 52 - const selections: SelectionNode[] = []; 53 - let hasTypename = node.kind === Kind.OPERATION_DEFINITION; 54 - if (node.selectionSet) { 55 - for (const selection of node.selectionSet.selections || []) { 56 - hasTypename = 57 - hasTypename || 58 - (selection.kind === Kind.FIELD && 59 - selection.name.value === '__typename' && 60 - !selection.alias); 61 - const newSelection = formatNode(selection); 62 - hasChanged = hasChanged || newSelection !== selection; 63 - selections.push(newSelection); 64 - } 65 - if (!hasTypename) { 66 - hasChanged = true; 67 - selections.push({ 68 - kind: Kind.FIELD, 69 - name: { 70 - kind: Kind.NAME, 71 - value: '__typename', 72 - }, 73 - }); 74 - } 75 - if (hasChanged) 76 - return { ...node, selectionSet: { ...node.selectionSet, selections } }; 77 - } 78 - } 79 - 80 - return node; 81 - }; 82 - 83 - const formattedDocs = new Map<number, KeyedDocumentNode>(); 84 - 85 - /** Adds `__typename` fields to a GraphQL `DocumentNode`. 86 - * 87 - * @param node - a {@link DocumentNode}. 88 - * @returns a copy of the passed {@link DocumentNode} with added `__typename` introspection fields. 89 - * 90 - * @remarks 91 - * Cache {@link Exchange | Exchanges} will require typename introspection to 92 - * recognize types in a GraphQL response. To retrieve these typenames, 93 - * this function is used to add the `__typename` fields to non-root 94 - * selection sets of a GraphQL document. 95 - * 96 - * This utility also preserves the internally computed key of the 97 - * document as created by {@link createRequest} to avoid any 98 - * formatting from being duplicated. 99 - * 100 - * @see {@link https://spec.graphql.org/October2021/#sec-Type-Name-Introspection} for more information 101 - * on typename introspection via the `__typename` field. 102 - */ 103 - export const formatDocument = <T extends TypedDocumentNode<any, any>>( 104 - node: T 105 - ): T => { 106 - const query = keyDocument(node); 107 - 108 - let result = formattedDocs.get(query.__key); 109 - if (!result) { 110 - formattedDocs.set( 111 - query.__key, 112 - (result = formatNode(query) as KeyedDocumentNode) 113 - ); 114 - // Ensure that the hash of the resulting document won't suddenly change 115 - // we are marking __key as non-enumerable so when external exchanges use visit 116 - // to manipulate a document we won't restore the previous query due to the __key 117 - // property. 118 - Object.defineProperty(result, '__key', { 119 - value: query.__key, 120 - enumerable: false, 121 - }); 122 - } 123 - 124 - return result as unknown as T; 125 - };
+6 -4
scripts/rollup/config.mjs
··· 29 29 const rel = relative(source.dir, process.cwd()); 30 30 plugins.push({ 31 31 async writeBundle() { 32 - await fs.mkdir(source.dir, { recursive: true }); 33 - await fs.writeFile(join(source.dir, 'package.json'), JSON.stringify({ 32 + const packageJson = JSON.stringify({ 34 33 name: source.name, 35 34 private: true, 36 35 version: '0.0.0', ··· 40 39 source: join(rel, source.source), 41 40 exports: { 42 41 '.': { 42 + types: join(rel, source.types), 43 43 import: join(rel, source.module), 44 44 require: join(rel, source.main), 45 - types: join(rel, source.types), 46 45 source: join(rel, source.source), 47 46 }, 48 47 './package.json': './package.json' 49 48 }, 50 - }, null, 2)); 49 + }, null, 2).trim(); 50 + 51 + await fs.mkdir(source.dir, { recursive: true }); 52 + await fs.writeFile(join(source.dir, 'package.json'), packageJson + '\n'); 51 53 }, 52 54 }); 53 55 }