Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

fix: Add loc getter on fragment documents for graphql-tag inter-compatibility #396

Merged
merged 6 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
}
}
}
Loading