1import { Kind, parse, print } from '@0no-co/graphql.web';
2import type { DocumentNode, DefinitionNode } from './graphql';
3import type { HashValue } from './hash';
4import { phash } from './hash';
5import { stringifyVariables } from './variables';
6
7import type {
8 DocumentInput,
9 TypedDocumentNode,
10 AnyVariables,
11 GraphQLRequest,
12 RequestExtensions,
13} from '../types';
14
15type PersistedDocumentNode = TypedDocumentNode & {
16 documentId?: string;
17};
18
19/** A `DocumentNode` annotated with its hashed key.
20 * @internal
21 */
22export type KeyedDocumentNode = TypedDocumentNode & {
23 __key: HashValue;
24};
25
26const SOURCE_NAME = 'gql';
27const GRAPHQL_STRING_RE = /("{3}[\s\S]*"{3}|"(?:\\.|[^"])*")/g;
28const REPLACE_CHAR_RE = /(?:#[^\n\r]+)?(?:[\r\n]+|$)/g;
29
30const replaceOutsideStrings = (str: string, idx: number): string =>
31 idx % 2 === 0 ? str.replace(REPLACE_CHAR_RE, '\n') : str;
32
33/** Sanitizes a GraphQL document string by replacing comments and redundant newlines in it. */
34const sanitizeDocument = (node: string): string =>
35 node.split(GRAPHQL_STRING_RE).map(replaceOutsideStrings).join('').trim();
36
37const prints: Map<DocumentNode | DefinitionNode, string> = new Map<
38 DocumentNode | DefinitionNode,
39 string
40>();
41const docs: Map<HashValue, KeyedDocumentNode> = new Map<
42 HashValue,
43 KeyedDocumentNode
44>();
45
46/** A cached printing function for GraphQL documents.
47 *
48 * @param node - A string of a document or a {@link DocumentNode}
49 * @returns A normalized printed string of the passed GraphQL document.
50 *
51 * @remarks
52 * This function accepts a GraphQL query string or {@link DocumentNode},
53 * then prints and sanitizes it. The sanitizer takes care of removing
54 * comments, which otherwise alter the key of the document although the
55 * document is otherwise equivalent to another.
56 *
57 * When a {@link DocumentNode} is passed to this function, it caches its
58 * output by modifying the `loc.source.body` property on the GraphQL node.
59 */
60export const stringifyDocument = (
61 node: string | DefinitionNode | DocumentNode
62): string => {
63 let printed: string;
64 if (typeof node === 'string') {
65 printed = sanitizeDocument(node);
66 } else if (node.loc && docs.get((node as KeyedDocumentNode).__key) === node) {
67 printed = node.loc.source.body;
68 } else {
69 printed = prints.get(node) || sanitizeDocument(print(node));
70 prints.set(node, printed);
71 }
72
73 if (typeof node !== 'string' && !node.loc) {
74 (node as any).loc = {
75 start: 0,
76 end: printed.length,
77 source: {
78 body: printed,
79 name: SOURCE_NAME,
80 locationOffset: { line: 1, column: 1 },
81 },
82 };
83 }
84
85 return printed;
86};
87
88/** Computes the hash for a document's string using {@link stringifyDocument}'s output.
89 *
90 * @param node - A string of a document or a {@link DocumentNode}
91 * @returns A {@link HashValue}
92 *
93 * @privateRemarks
94 * This function adds the operation name of the document to the hash, since sometimes
95 * a merged document with multiple operations may be used. Although `urql` requires a
96 * `DocumentNode` to only contain a single operation, when the cached `loc.source.body`
97 * of a `DocumentNode` is used, this string may still contain multiple operations and
98 * the resulting hash should account for only one at a time.
99 */
100const hashDocument = (
101 node: string | DefinitionNode | DocumentNode
102): HashValue => {
103 let key: HashValue;
104 if ((node as PersistedDocumentNode).documentId) {
105 key = phash((node as PersistedDocumentNode).documentId!);
106 } else {
107 key = phash(stringifyDocument(node));
108 // Add the operation name to the produced hash
109 if ((node as DocumentNode).definitions) {
110 const operationName = getOperationName(node as DocumentNode);
111 if (operationName) key = phash(`\n# ${operationName}`, key);
112 }
113 }
114 return key;
115};
116
117/** Returns a canonical version of the passed `DocumentNode` with an added hash key.
118 *
119 * @param node - A string of a document or a {@link DocumentNode}
120 * @returns A {@link KeyedDocumentNode}
121 *
122 * @remarks
123 * `urql` will always avoid unnecessary work, no matter whether a user passes `DocumentNode`s
124 * or strings of GraphQL documents to its APIs.
125 *
126 * This function will return a canonical version of a {@link KeyedDocumentNode} no matter
127 * which kind of input is passed, avoiding parsing or hashing of passed data as needed.
128 */
129export const keyDocument = (node: string | DocumentNode): KeyedDocumentNode => {
130 let key: HashValue;
131 let query: DocumentNode;
132 if (typeof node === 'string') {
133 key = hashDocument(node);
134 query = docs.get(key) || parse(node, { noLocation: true });
135 } else {
136 key = (node as KeyedDocumentNode).__key || hashDocument(node);
137 query = docs.get(key) || node;
138 }
139
140 // Add location information if it's missing
141 if (!query.loc) stringifyDocument(query);
142
143 (query as KeyedDocumentNode).__key = key;
144 docs.set(key, query as KeyedDocumentNode);
145 return query as KeyedDocumentNode;
146};
147
148/** Creates a `GraphQLRequest` from the passed parameters.
149 *
150 * @param q - A string of a document or a {@link DocumentNode}
151 * @param variables - A variables object for the defined GraphQL operation.
152 * @returns A {@link GraphQLRequest}
153 *
154 * @remarks
155 * `createRequest` creates a {@link GraphQLRequest} from the passed parameters,
156 * while replacing the document as needed with a canonical version of itself,
157 * to avoid parsing, printing, or hashing the same input multiple times.
158 *
159 * If no variables are passed, canonically it'll default to an empty object,
160 * which is removed from the resulting hash key.
161 */
162export const createRequest = <
163 Data = any,
164 Variables extends AnyVariables = AnyVariables,
165>(
166 _query: DocumentInput<Data, Variables>,
167 _variables: Variables,
168 extensions?: RequestExtensions | undefined
169): GraphQLRequest<Data, Variables> => {
170 const variables = _variables || ({} as Variables);
171 const query = keyDocument(_query);
172 const printedVars = stringifyVariables(variables, true);
173 let key = query.__key;
174 if (printedVars !== '{}') key = phash(printedVars, key);
175 return { key, query, variables, extensions };
176};
177
178/** Returns the name of the `DocumentNode`'s operation, if any.
179 * @param query - A {@link DocumentNode}
180 * @returns the operation's name contained within the document, or `undefined`
181 */
182export const getOperationName = (query: DocumentNode): string | undefined => {
183 for (let i = 0, l = query.definitions.length; i < l; i++) {
184 const node = query.definitions[i];
185 if (node.kind === Kind.OPERATION_DEFINITION) {
186 return node.name ? node.name.value : undefined;
187 }
188 }
189};
190
191/** Returns the type of the `DocumentNode`'s operation, if any.
192 * @param query - A {@link DocumentNode}
193 * @returns the operation's type contained within the document, or `undefined`
194 */
195export const getOperationType = (query: DocumentNode): string | undefined => {
196 for (let i = 0, l = query.definitions.length; i < l; i++) {
197 const node = query.definitions[i];
198 if (node.kind === Kind.OPERATION_DEFINITION) {
199 return node.operation;
200 }
201 }
202};