Skip to content

Commit

Permalink
fix: Add loc getter on fragment documents for graphql-tag inter-c…
Browse files Browse the repository at this point in the history
…ompatibility (#396)
  • Loading branch information
kitten authored Sep 19, 2024
1 parent 3c076c0 commit 971bdd0
Show file tree
Hide file tree
Showing 4 changed files with 167 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/honest-donkeys-pay.md
Original file line number Diff line number Diff line change
@@ -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.
105 changes: 105 additions & 0 deletions src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});
});
31 changes: 28 additions & 3 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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.
*
Expand Down Expand Up @@ -328,13 +329,37 @@ export function initGraphQLTada<const Setup extends AbstractSetupSchema>(): 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) {
Expand Down
29 changes: 29 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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, T, Fallback> = Constraint extends T
? Fallback
Expand Down Expand Up @@ -50,3 +52,30 @@ export interface DocumentDecoration<Result = any, Variables = any> {
*/
__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();
}
}
}

0 comments on commit 971bdd0

Please sign in to comment.