aboutsummaryrefslogtreecommitdiff
path: root/source/gql.ts
diff options
context:
space:
mode:
Diffstat (limited to 'source/gql.ts')
-rw-r--r--source/gql.ts158
1 files changed, 158 insertions, 0 deletions
diff --git a/source/gql.ts b/source/gql.ts
new file mode 100644
index 0000000..f88d8ff
--- /dev/null
+++ b/source/gql.ts
@@ -0,0 +1,158 @@
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import {
+ parse,
+ type DefinitionNode,
+ type DocumentNode,
+ type Location
+} from "graphql";
+
+/*** UTILITY ------------------------------------------ ***/
+
+const docCache = new Map<string, DocumentNode>();
+const fragmentSourceMap = new Map<string, Set<string>>();
+let allowLegacyFragmentVariables = false;
+let printFragmentWarnings = true;
+
+/*** EXPORT ------------------------------------------- ***/
+
+/** Turns off support for legacy/experimental fragment variables (the default). */
+export function disableExperimentalFragmentVariables() {
+ allowLegacyFragmentVariables = false;
+}
+
+/** Silences `console.warn` calls when duplicate fragment names are detected. */
+export function disableFragmentWarnings() {
+ printFragmentWarnings = false;
+}
+
+/** Opts into parsing experimental fragment variables via `graphql-js`. */
+export function enableExperimentalFragmentVariables() {
+ allowLegacyFragmentVariables = true;
+}
+
+/**
+ * Tagged-template literal that parses a GraphQL string into a `DocumentNode`.
+ *
+ * Parsed documents are cached by normalized source, so repeated calls with the
+ * same query return the same AST. Embedded `DocumentNode` interpolations are
+ * inlined from their original source.
+ *
+ * @example
+ * ```ts
+ * const Hello = gql`type Query { hello: String }`;
+ * ```
+ */
+export function gql(literals: string | readonly string[], ...args: unknown[]): DocumentNode {
+ if (typeof literals === "string")
+ literals = [literals];
+
+ let result = literals[0];
+
+ args.forEach((arg, i) => {
+ if (arg && typeof arg === "object" && "kind" in arg && arg.kind === "Document")
+ result += (arg as DocumentNode).loc?.source.body ?? "";
+ else
+ result += arg;
+
+ result += literals[i + 1];
+ });
+
+ return parseDocument(result);
+}
+
+/** Clears the internal document and fragment caches used by {@link gql}. */
+export function resetCaches() {
+ docCache.clear();
+ fragmentSourceMap.clear();
+}
+
+/*** HELPER ------------------------------------------- ***/
+
+function normalize(string: string) {
+ return string.replace(/[\s,]+/g, " ").trim();
+}
+
+function cacheKeyFromLoc(loc: Location) {
+ return normalize(loc.source.body.substring(loc.start, loc.end));
+}
+
+function processFragments(ast: DocumentNode) {
+ const definitions: DefinitionNode[] = [];
+ const seenKeys = new Set<string>();
+
+ ast.definitions.forEach((fragmentDefinition) => {
+ if (fragmentDefinition.kind === "FragmentDefinition") {
+ const fragmentName = fragmentDefinition.name.value;
+ const sourceKey = cacheKeyFromLoc(fragmentDefinition.loc!);
+ let sourceKeySet = fragmentSourceMap.get(fragmentName)!;
+
+ if (sourceKeySet && !sourceKeySet.has(sourceKey)) {
+ if (printFragmentWarnings)
+ console.warn(`Warning: fragment with name ${fragmentName} already exists.`);
+ } else if (!sourceKeySet) {
+ fragmentSourceMap.set(fragmentName, (sourceKeySet = new Set()));
+ }
+
+ sourceKeySet.add(sourceKey);
+
+ if (!seenKeys.has(sourceKey)) {
+ seenKeys.add(sourceKey);
+ definitions.push(fragmentDefinition);
+ }
+ } else {
+ definitions.push(fragmentDefinition);
+ }
+ })
+
+ return {
+ ...ast,
+ definitions
+ };
+}
+
+function stripLoc(doc: DocumentNode) {
+ const workSet = new Set<Record<string, unknown>>(doc.definitions as unknown as Record<string, unknown>[]);
+
+ workSet.forEach((node) => {
+ if (node.loc)
+ delete node.loc;
+
+ Object.keys(node).forEach((key) => {
+ const value = node[key];
+
+ if (value && typeof value === "object")
+ workSet.add(value as Record<string, unknown>);
+ });
+ });
+
+ const loc = doc.loc as { endToken: unknown; startToken: unknown; };
+
+ if (loc) {
+ delete loc.startToken;
+ delete loc.endToken;
+ }
+
+ return doc;
+}
+
+function parseDocument(source: string) {
+ const cacheKey = normalize(source);
+
+ if (!docCache.has(cacheKey)) {
+ const parsed = parse(source, { allowLegacyFragmentVariables });
+
+ if (!parsed || parsed.kind !== "Document")
+ throw new Error("Not a valid GraphQL document.");
+
+ docCache.set(
+ cacheKey,
+ stripLoc(processFragments(parsed))
+ );
+ }
+
+ return docCache.get(cacheKey)!;
+}