From 971bdd0808e58e7eb983c0e54bd7a0d59b8cc3fb Mon Sep 17 00:00:00 2001 From: Phil Pluckthun Date: Thu, 19 Sep 2024 14:41:04 +0100 Subject: [PATCH] fix: Add `loc` getter on fragment documents for `graphql-tag` inter-compatibility (#396) --- .changeset/honest-donkeys-pay.md | 5 ++ src/__tests__/utils.test.ts | 105 +++++++++++++++++++++++++++++++ src/api.ts | 31 ++++++++- src/utils.ts | 29 +++++++++ 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 .changeset/honest-donkeys-pay.md create mode 100644 src/__tests__/utils.test.ts diff --git a/.changeset/honest-donkeys-pay.md b/.changeset/honest-donkeys-pay.md new file mode 100644 index 00000000..9310ea4d --- /dev/null +++ b/.changeset/honest-donkeys-pay.md @@ -0,0 +1,5 @@ +--- +'gql.tada': patch +--- + +Add `loc` getter to parsed `DocumentNode` fragment outputs to ensure that using fragments created by `gql.tada`'s `graphql()` function with `graphql-tag` doesn't crash. `graphql-tag` does not treat the `DocumentNode.loc` property as optional on interpolations, which leads to intercompatibility issues. diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts new file mode 100644 index 00000000..096b704c --- /dev/null +++ b/src/__tests__/utils.test.ts @@ -0,0 +1,105 @@ +import type { Location } from '@0no-co/graphql.web'; +import { describe, it, expect } from 'vitest'; +import { concatLocSources } from '../utils'; + +const makeLocation = (input: string): Location => ({ + start: 0, + end: input.length, + source: { + body: input, + name: 'GraphQLTada', + locationOffset: { line: 1, column: 1 }, + }, +}); + +describe('concatLocSources', () => { + it('outputs the fragments concatenated to one another', () => { + const actual = concatLocSources([{ loc: makeLocation('a') }, { loc: makeLocation('b') }]); + expect(actual).toBe('ab'); + }); + + it('works when called recursively', () => { + // NOTE: Should work repeatedly + for (let i = 0; i < 2; i++) { + const actual = concatLocSources([ + { + get loc() { + return makeLocation( + concatLocSources([{ loc: makeLocation('a') }, { loc: makeLocation('b') }]) + ); + }, + }, + { + get loc() { + return makeLocation( + concatLocSources([{ loc: makeLocation('c') }, { loc: makeLocation('d') }]) + ); + }, + }, + ]); + expect(actual).toBe('abcd'); + } + }); + + it('deduplicates recursively', () => { + // NOTE: Should work repeatedly + for (let i = 0; i < 2; i++) { + const a = { loc: makeLocation('a') }; + const b = { loc: makeLocation('b') }; + const c = { loc: makeLocation('c') }; + const d = { loc: makeLocation('d') }; + + let actual = concatLocSources([ + { + get loc() { + return makeLocation(concatLocSources([a, b, c, d])); + }, + }, + { + get loc() { + return makeLocation( + concatLocSources([ + a, + b, + c, + { + get loc() { + return makeLocation(concatLocSources([a, b, c, d])); + }, + }, + ]) + ); + }, + }, + ]); + + expect(actual).toBe('abcd'); + + actual = concatLocSources([ + { + get loc() { + return makeLocation( + concatLocSources([ + a, + b, + c, + { + get loc() { + return makeLocation(concatLocSources([a, b, c, d])); + }, + }, + ]) + ); + }, + }, + { + get loc() { + return makeLocation(concatLocSources([a, b, c, d])); + }, + }, + ]); + + expect(actual).toBe('abcd'); + } + }); +}); diff --git a/src/api.ts b/src/api.ts index 8b80c372..a159b2fe 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,4 @@ -import type { DocumentNode, DefinitionNode } from '@0no-co/graphql.web'; +import type { DocumentNode, DefinitionNode, Location } from '@0no-co/graphql.web'; import { Kind, parse as _parse } from '@0no-co/graphql.web'; import type { @@ -22,6 +22,7 @@ import type { getDocumentType } from './selection'; import type { parseDocument, DocumentNodeLike } from './parser'; import type { getVariablesType, getScalarType } from './variables'; import type { obj, matchOr, writable, DocumentDecoration } from './utils'; +import { concatLocSources } from './utils'; /** Abstract configuration type input for your schema and scalars. * @@ -328,13 +329,37 @@ export function initGraphQLTada(): init } } - if (definitions[0].kind === Kind.FRAGMENT_DEFINITION && definitions[0].directives) { + let isFragment: boolean; + if ( + (isFragment = definitions[0].kind === Kind.FRAGMENT_DEFINITION) && + definitions[0].directives + ) { definitions[0].directives = definitions[0].directives.filter( (directive) => directive.name.value !== '_unmask' ); } - return { kind: Kind.DOCUMENT, definitions }; + return { + kind: Kind.DOCUMENT, + definitions, + get loc(): Location { + // NOTE: This is only meant for graphql-tag compatibility. When fragment documents + // are interpolated into other documents, graphql-tag blindly reads `document.loc` + // without checking whether it's `undefined`. + if (isFragment) { + const body = input + concatLocSources(fragments || []); + return { + start: 0, + end: body.length, + source: { + body: body, + name: 'GraphQLTada', + locationOffset: { line: 1, column: 1 }, + }, + }; + } + }, + } satisfies DocumentNode; } graphql.scalar = function scalar(_schema: Schema, value: any) { diff --git a/src/utils.ts b/src/utils.ts index 72d24b86..b39d25a7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import type { DocumentNode, Location } from '@0no-co/graphql.web'; + /** Returns `T` if it matches `Constraint` without being equal to it. Failing this evaluates to `Fallback` otherwise. */ export type matchOr = Constraint extends T ? Fallback @@ -50,3 +52,30 @@ export interface DocumentDecoration { */ __ensureTypesOfVariablesAndResultMatching?: (variables: Variables) => Result; } + +let CONCAT_LOC_DEPTH = 0; +const CONCAT_LOC_SEEN = new Set(); + +interface LocationNode { + loc?: Location; +} + +/** Concatenates all fragments' `loc.source.body`s */ +export function concatLocSources(fragments: readonly LocationNode[]): string { + try { + CONCAT_LOC_DEPTH++; + let result = ''; + for (const fragment of fragments) { + if (!CONCAT_LOC_SEEN.has(fragment)) { + CONCAT_LOC_SEEN.add(fragment); + const { loc } = fragment; + if (loc) result += loc.source.body; + } + } + return result; + } finally { + if (--CONCAT_LOC_DEPTH === 0) { + CONCAT_LOC_SEEN.clear(); + } + } +}