Skip to content

Commit 034628e

Browse files
authored
Add the associated fragments to the found graphql-nodes (#376)
1 parent 69f75e0 commit 034628e

File tree

8 files changed

+157
-8
lines changed

8 files changed

+157
-8
lines changed

.changeset/swift-ghosts-end.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@0no-co/graphqlsp': patch
3+
---
4+
5+
Correctly identify missing fragments for gql.tada graphql call-expressions

.github/workflows/release.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
- name: Setup pnpm
3131
uses: pnpm/action-setup@v3
3232
with:
33-
version: 9
33+
version: 8.6.1
3434
run_install: false
3535

3636
- name: Get pnpm store directory

packages/example-tada/src/index.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,16 @@ const PokemonQuery = graphql(`
3232
}
3333
`, [PokemonFields, Fields.Pokemon])
3434

35-
const persisted = graphql.persisted<typeof PokemonQuery>("sha256:78c769ed6cfef67e17e579a2abfe4da27bd51e09ed832a88393148bcce4c5a7d")
35+
const Test = graphql(`
36+
query Po($id: ID!) {
37+
pokemon(id: $id) {
38+
id
39+
fleeRate
40+
...Pok
41+
...pokemonFields
42+
}
43+
}
44+
`, [])
3645

3746
const Pokemons = () => {
3847
const [result] = useQuery({

packages/graphqlsp/src/ast/index.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ function resolveIdentifierToGraphQLCall(
6868
return checks.isGraphQLCall(value, checker) ? value : null;
6969
}
7070

71-
function unrollFragment(
71+
export function unrollFragment(
7272
element: ts.Identifier,
7373
info: ts.server.PluginCreateInfo,
7474
checker: ts.TypeChecker | undefined
@@ -135,13 +135,16 @@ export function findAllCallExpressions(
135135
nodes: Array<{
136136
node: ts.StringLiteralLike;
137137
schema: string | null;
138+
// For gql.tada call-expressions, this contains the identifiers of explicitly declared fragments
139+
tadaFragmentRefs?: readonly ts.Identifier[] | null;
138140
}>;
139141
fragments: Array<FragmentDefinitionNode>;
140142
} {
141143
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
142144
const result: Array<{
143145
node: ts.StringLiteralLike;
144146
schema: string | null;
147+
tadaFragmentRefs?: readonly ts.Identifier[];
145148
}> = [];
146149
let fragments: Array<FragmentDefinitionNode> = [];
147150
let hasTriedToFindFragments = shouldSearchFragments ? false : true;
@@ -160,18 +163,30 @@ export function findAllCallExpressions(
160163
const name = checks.getSchemaName(node, typeChecker);
161164
const text = node.arguments[0];
162165
const fragmentRefs = resolveTadaFragmentArray(node.arguments[1]);
166+
const isTadaCall = checks.isTadaGraphQLCall(node, typeChecker);
163167

164168
if (!hasTriedToFindFragments && !fragmentRefs) {
165-
hasTriedToFindFragments = true;
166-
fragments.push(...getAllFragments(sourceFile.fileName, node, info));
169+
// Only collect global fragments if this is NOT a gql.tada call
170+
if (!isTadaCall) {
171+
hasTriedToFindFragments = true;
172+
fragments.push(...getAllFragments(node, info));
173+
}
167174
} else if (fragmentRefs) {
168175
for (const identifier of fragmentRefs) {
169176
fragments.push(...unrollFragment(identifier, info, typeChecker));
170177
}
171178
}
172179

173180
if (text && ts.isStringLiteralLike(text)) {
174-
result.push({ node: text, schema: name });
181+
result.push({
182+
node: text,
183+
schema: name,
184+
tadaFragmentRefs: isTadaCall
185+
? fragmentRefs === undefined
186+
? []
187+
: fragmentRefs
188+
: undefined,
189+
});
175190
}
176191
}
177192
find(sourceFile);
@@ -213,7 +228,6 @@ export function findAllPersistedCallExpressions(
213228
}
214229

215230
export function getAllFragments(
216-
fileName: string,
217231
node: ts.Node,
218232
info: ts.server.PluginCreateInfo
219233
) {

packages/graphqlsp/src/autoComplete.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export function getGraphQLCompletions(
6666
return undefined;
6767

6868
const queryText = node.arguments[0].getText().slice(1, -1);
69-
const fragments = getAllFragments(filename, node, info);
69+
const fragments = getAllFragments(node, info);
7070

7171
text = `${queryText}\n${fragments.map(x => print(x)).join('\n')}`;
7272
cursor = new Cursor(foundToken.line, foundToken.start - 1);

packages/graphqlsp/src/diagnostics.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
findAllPersistedCallExpressions,
1717
findAllTaggedTemplateNodes,
1818
getSource,
19+
unrollFragment,
1920
} from './ast';
2021
import { resolveTemplate } from './ast/resolve';
2122
import { UNUSED_FIELD_CODE, checkFieldUsageInFile } from './fieldUsage';
@@ -344,6 +345,7 @@ const runDiagnostics = (
344345
nodes: {
345346
node: ts.TaggedTemplateExpression | ts.StringLiteralLike;
346347
schema: string | null;
348+
tadaFragmentRefs?: readonly ts.Identifier[];
347349
}[];
348350
fragments: FragmentDefinitionNode[];
349351
},
@@ -352,6 +354,7 @@ const runDiagnostics = (
352354
): ts.Diagnostic[] => {
353355
const filename = source.fileName;
354356
const isCallExpression = info.config.templateIsCallExpression ?? true;
357+
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
355358

356359
const diagnostics = nodes
357360
.map(originalNode => {
@@ -394,6 +397,20 @@ const runDiagnostics = (
394397
(isExpression ? 2 : 0));
395398
const endPosition = startingPosition + node.getText().length;
396399
let docFragments = [...fragments];
400+
401+
if (originalNode.tadaFragmentRefs !== undefined) {
402+
const fragmentNames = new Set<string>();
403+
for (const identifier of originalNode.tadaFragmentRefs) {
404+
const unrolled = unrollFragment(identifier, info, typeChecker);
405+
unrolled.forEach((frag: FragmentDefinitionNode) =>
406+
fragmentNames.add(frag.name.value)
407+
);
408+
}
409+
docFragments = docFragments.filter(frag =>
410+
fragmentNames.has(frag.name.value)
411+
);
412+
}
413+
397414
if (isCallExpression) {
398415
try {
399416
const documentFragments = parse(text, {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { graphql } from './graphql';
2+
3+
const pokemonFragment = graphql(`
4+
fragment PokemonBasicInfo on Pokemon {
5+
id
6+
name
7+
}
8+
`);
9+
10+
// This query correctly includes the fragment as a dep
11+
const FirstQuery = graphql(
12+
`
13+
query FirstQuery {
14+
pokemons(limit: 1) {
15+
...PokemonBasicInfo
16+
}
17+
}
18+
`,
19+
[pokemonFragment]
20+
);
21+
22+
// This query uses the fragment but DOES NOT include it as a dep
23+
// It should show an error, but currently doesn't because the fragment
24+
// was already added as a dep in FirstQuery above
25+
const SecondQuery = graphql(`
26+
query SecondQuery {
27+
pokemons(limit: 2) {
28+
...PokemonBasicInfo
29+
}
30+
}
31+
`);
32+
33+
export { FirstQuery, SecondQuery };

test/e2e/tada.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,3 +619,74 @@ List out all Pokémon, optionally in pages`
619619
`);
620620
}, 30000);
621621
});
622+
623+
describe('Fragment dependencies - Issue #494', () => {
624+
const projectPath = path.resolve(__dirname, 'fixture-project-tada');
625+
const outfileMissingFragmentDep = path.join(
626+
projectPath,
627+
'missing-fragment-dep.ts'
628+
);
629+
630+
let server: TSServer;
631+
beforeAll(async () => {
632+
server = new TSServer(projectPath, { debugLog: false });
633+
634+
server.sendCommand('open', {
635+
file: outfileMissingFragmentDep,
636+
fileContent: '// empty',
637+
scriptKindName: 'TS',
638+
} satisfies ts.server.protocol.OpenRequestArgs);
639+
640+
server.sendCommand('updateOpen', {
641+
openFiles: [
642+
{
643+
file: outfileMissingFragmentDep,
644+
fileContent: fs.readFileSync(
645+
path.join(projectPath, 'fixtures/missing-fragment-dep.ts'),
646+
'utf-8'
647+
),
648+
},
649+
],
650+
} satisfies ts.server.protocol.UpdateOpenRequestArgs);
651+
652+
server.sendCommand('saveto', {
653+
file: outfileMissingFragmentDep,
654+
tmpfile: outfileMissingFragmentDep,
655+
} satisfies ts.server.protocol.SavetoRequestArgs);
656+
});
657+
658+
afterAll(() => {
659+
try {
660+
fs.unlinkSync(outfileMissingFragmentDep);
661+
} catch {}
662+
});
663+
664+
it('warns about missing fragment dep even when fragment is used in another query in same file', async () => {
665+
await server.waitForResponse(
666+
e =>
667+
e.type === 'event' &&
668+
e.event === 'semanticDiag' &&
669+
e.body?.file === outfileMissingFragmentDep
670+
);
671+
672+
const res = server.responses.filter(
673+
resp =>
674+
resp.type === 'event' &&
675+
resp.event === 'semanticDiag' &&
676+
resp.body?.file === outfileMissingFragmentDep
677+
);
678+
679+
// Should have a diagnostic about the unknown fragment in SecondQuery
680+
expect(res.length).toBeGreaterThan(0);
681+
expect(res[0].body.diagnostics.length).toBeGreaterThan(0);
682+
683+
const fragmentError = res[0].body.diagnostics.find((diag: any) =>
684+
diag.text.includes('PokemonBasicInfo')
685+
);
686+
687+
expect(fragmentError).toBeDefined();
688+
expect(fragmentError.text).toBe('Unknown fragment "PokemonBasicInfo".');
689+
expect(fragmentError.code).toBe(52001);
690+
expect(fragmentError.category).toBe('error');
691+
}, 30000);
692+
});

0 commit comments

Comments
 (0)