diff options
Diffstat (limited to 'source/gql.ts')
| -rw-r--r-- | source/gql.ts | 158 |
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)!; +} |