Mirror: TypeScript LSP plugin that finds GraphQL documents in your code and provides diagnostics, auto-complete and hover-information.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add the associated fragments to the found graphql-nodes (#376)

authored by

Jovi De Croock and committed by
GitHub
034628ec 69f75e00

+157 -8
+5
.changeset/swift-ghosts-end.md
··· 1 + --- 2 + '@0no-co/graphqlsp': patch 3 + --- 4 + 5 + Correctly identify missing fragments for gql.tada graphql call-expressions
+1 -1
.github/workflows/release.yaml
··· 30 30 - name: Setup pnpm 31 31 uses: pnpm/action-setup@v3 32 32 with: 33 - version: 9 33 + version: 8.6.1 34 34 run_install: false 35 35 36 36 - name: Get pnpm store directory
+10 -1
packages/example-tada/src/index.tsx
··· 32 32 } 33 33 `, [PokemonFields, Fields.Pokemon]) 34 34 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 + `, []) 36 45 37 46 const Pokemons = () => { 38 47 const [result] = useQuery({
+19 -5
packages/graphqlsp/src/ast/index.ts
··· 68 68 return checks.isGraphQLCall(value, checker) ? value : null; 69 69 } 70 70 71 - function unrollFragment( 71 + export function unrollFragment( 72 72 element: ts.Identifier, 73 73 info: ts.server.PluginCreateInfo, 74 74 checker: ts.TypeChecker | undefined ··· 135 135 nodes: Array<{ 136 136 node: ts.StringLiteralLike; 137 137 schema: string | null; 138 + // For gql.tada call-expressions, this contains the identifiers of explicitly declared fragments 139 + tadaFragmentRefs?: readonly ts.Identifier[] | null; 138 140 }>; 139 141 fragments: Array<FragmentDefinitionNode>; 140 142 } { ··· 142 144 const result: Array<{ 143 145 node: ts.StringLiteralLike; 144 146 schema: string | null; 147 + tadaFragmentRefs?: readonly ts.Identifier[]; 145 148 }> = []; 146 149 let fragments: Array<FragmentDefinitionNode> = []; 147 150 let hasTriedToFindFragments = shouldSearchFragments ? false : true; ··· 160 163 const name = checks.getSchemaName(node, typeChecker); 161 164 const text = node.arguments[0]; 162 165 const fragmentRefs = resolveTadaFragmentArray(node.arguments[1]); 166 + const isTadaCall = checks.isTadaGraphQLCall(node, typeChecker); 163 167 164 168 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 + } 167 174 } else if (fragmentRefs) { 168 175 for (const identifier of fragmentRefs) { 169 176 fragments.push(...unrollFragment(identifier, info, typeChecker)); ··· 171 178 } 172 179 173 180 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 + }); 175 190 } 176 191 } 177 192 find(sourceFile); ··· 213 228 } 214 229 215 230 export function getAllFragments( 216 - fileName: string, 217 231 node: ts.Node, 218 232 info: ts.server.PluginCreateInfo 219 233 ) {
+1 -1
packages/graphqlsp/src/autoComplete.ts
··· 66 66 return undefined; 67 67 68 68 const queryText = node.arguments[0].getText().slice(1, -1); 69 - const fragments = getAllFragments(filename, node, info); 69 + const fragments = getAllFragments(node, info); 70 70 71 71 text = `${queryText}\n${fragments.map(x => print(x)).join('\n')}`; 72 72 cursor = new Cursor(foundToken.line, foundToken.start - 1);
+17
packages/graphqlsp/src/diagnostics.ts
··· 16 16 findAllPersistedCallExpressions, 17 17 findAllTaggedTemplateNodes, 18 18 getSource, 19 + unrollFragment, 19 20 } from './ast'; 20 21 import { resolveTemplate } from './ast/resolve'; 21 22 import { UNUSED_FIELD_CODE, checkFieldUsageInFile } from './fieldUsage'; ··· 344 345 nodes: { 345 346 node: ts.TaggedTemplateExpression | ts.StringLiteralLike; 346 347 schema: string | null; 348 + tadaFragmentRefs?: readonly ts.Identifier[]; 347 349 }[]; 348 350 fragments: FragmentDefinitionNode[]; 349 351 }, ··· 352 354 ): ts.Diagnostic[] => { 353 355 const filename = source.fileName; 354 356 const isCallExpression = info.config.templateIsCallExpression ?? true; 357 + const typeChecker = info.languageService.getProgram()?.getTypeChecker(); 355 358 356 359 const diagnostics = nodes 357 360 .map(originalNode => { ··· 394 397 (isExpression ? 2 : 0)); 395 398 const endPosition = startingPosition + node.getText().length; 396 399 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 + 397 414 if (isCallExpression) { 398 415 try { 399 416 const documentFragments = parse(text, {
+33
test/e2e/fixture-project-tada/fixtures/missing-fragment-dep.ts
··· 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 };
+71
test/e2e/tada.test.ts
··· 619 619 `); 620 620 }, 30000); 621 621 }); 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 + });