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

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

Changed files
+157 -8
.changeset
.github
workflows
packages
example-tada
src
graphqlsp
test
e2e
fixture-project-tada
+5
.changeset/swift-ghosts-end.md
···
+
---
+
'@0no-co/graphqlsp': patch
+
---
+
+
Correctly identify missing fragments for gql.tada graphql call-expressions
+1 -1
.github/workflows/release.yaml
···
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
-
version: 9
+
version: 8.6.1
run_install: false
- name: Get pnpm store directory
+10 -1
packages/example-tada/src/index.tsx
···
}
`, [PokemonFields, Fields.Pokemon])
-
const persisted = graphql.persisted<typeof PokemonQuery>("sha256:78c769ed6cfef67e17e579a2abfe4da27bd51e09ed832a88393148bcce4c5a7d")
+
const Test = graphql(`
+
query Po($id: ID!) {
+
pokemon(id: $id) {
+
id
+
fleeRate
+
...Pok
+
...pokemonFields
+
}
+
}
+
`, [])
const Pokemons = () => {
const [result] = useQuery({
+19 -5
packages/graphqlsp/src/ast/index.ts
···
return checks.isGraphQLCall(value, checker) ? value : null;
}
-
function unrollFragment(
+
export function unrollFragment(
element: ts.Identifier,
info: ts.server.PluginCreateInfo,
checker: ts.TypeChecker | undefined
···
nodes: Array<{
node: ts.StringLiteralLike;
schema: string | null;
+
// For gql.tada call-expressions, this contains the identifiers of explicitly declared fragments
+
tadaFragmentRefs?: readonly ts.Identifier[] | null;
}>;
fragments: Array<FragmentDefinitionNode>;
} {
···
const result: Array<{
node: ts.StringLiteralLike;
schema: string | null;
+
tadaFragmentRefs?: readonly ts.Identifier[];
}> = [];
let fragments: Array<FragmentDefinitionNode> = [];
let hasTriedToFindFragments = shouldSearchFragments ? false : true;
···
const name = checks.getSchemaName(node, typeChecker);
const text = node.arguments[0];
const fragmentRefs = resolveTadaFragmentArray(node.arguments[1]);
+
const isTadaCall = checks.isTadaGraphQLCall(node, typeChecker);
if (!hasTriedToFindFragments && !fragmentRefs) {
-
hasTriedToFindFragments = true;
-
fragments.push(...getAllFragments(sourceFile.fileName, node, info));
+
// Only collect global fragments if this is NOT a gql.tada call
+
if (!isTadaCall) {
+
hasTriedToFindFragments = true;
+
fragments.push(...getAllFragments(node, info));
+
}
} else if (fragmentRefs) {
for (const identifier of fragmentRefs) {
fragments.push(...unrollFragment(identifier, info, typeChecker));
···
}
if (text && ts.isStringLiteralLike(text)) {
-
result.push({ node: text, schema: name });
+
result.push({
+
node: text,
+
schema: name,
+
tadaFragmentRefs: isTadaCall
+
? fragmentRefs === undefined
+
? []
+
: fragmentRefs
+
: undefined,
+
});
}
}
find(sourceFile);
···
}
export function getAllFragments(
-
fileName: string,
node: ts.Node,
info: ts.server.PluginCreateInfo
) {
+1 -1
packages/graphqlsp/src/autoComplete.ts
···
return undefined;
const queryText = node.arguments[0].getText().slice(1, -1);
-
const fragments = getAllFragments(filename, node, info);
+
const fragments = getAllFragments(node, info);
text = `${queryText}\n${fragments.map(x => print(x)).join('\n')}`;
cursor = new Cursor(foundToken.line, foundToken.start - 1);
+17
packages/graphqlsp/src/diagnostics.ts
···
findAllPersistedCallExpressions,
findAllTaggedTemplateNodes,
getSource,
+
unrollFragment,
} from './ast';
import { resolveTemplate } from './ast/resolve';
import { UNUSED_FIELD_CODE, checkFieldUsageInFile } from './fieldUsage';
···
nodes: {
node: ts.TaggedTemplateExpression | ts.StringLiteralLike;
schema: string | null;
+
tadaFragmentRefs?: readonly ts.Identifier[];
}[];
fragments: FragmentDefinitionNode[];
},
···
): ts.Diagnostic[] => {
const filename = source.fileName;
const isCallExpression = info.config.templateIsCallExpression ?? true;
+
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
const diagnostics = nodes
.map(originalNode => {
···
(isExpression ? 2 : 0));
const endPosition = startingPosition + node.getText().length;
let docFragments = [...fragments];
+
+
if (originalNode.tadaFragmentRefs !== undefined) {
+
const fragmentNames = new Set<string>();
+
for (const identifier of originalNode.tadaFragmentRefs) {
+
const unrolled = unrollFragment(identifier, info, typeChecker);
+
unrolled.forEach((frag: FragmentDefinitionNode) =>
+
fragmentNames.add(frag.name.value)
+
);
+
}
+
docFragments = docFragments.filter(frag =>
+
fragmentNames.has(frag.name.value)
+
);
+
}
+
if (isCallExpression) {
try {
const documentFragments = parse(text, {
+33
test/e2e/fixture-project-tada/fixtures/missing-fragment-dep.ts
···
+
import { graphql } from './graphql';
+
+
const pokemonFragment = graphql(`
+
fragment PokemonBasicInfo on Pokemon {
+
id
+
name
+
}
+
`);
+
+
// This query correctly includes the fragment as a dep
+
const FirstQuery = graphql(
+
`
+
query FirstQuery {
+
pokemons(limit: 1) {
+
...PokemonBasicInfo
+
}
+
}
+
`,
+
[pokemonFragment]
+
);
+
+
// This query uses the fragment but DOES NOT include it as a dep
+
// It should show an error, but currently doesn't because the fragment
+
// was already added as a dep in FirstQuery above
+
const SecondQuery = graphql(`
+
query SecondQuery {
+
pokemons(limit: 2) {
+
...PokemonBasicInfo
+
}
+
}
+
`);
+
+
export { FirstQuery, SecondQuery };
+71
test/e2e/tada.test.ts
···
`);
}, 30000);
});
+
+
describe('Fragment dependencies - Issue #494', () => {
+
const projectPath = path.resolve(__dirname, 'fixture-project-tada');
+
const outfileMissingFragmentDep = path.join(
+
projectPath,
+
'missing-fragment-dep.ts'
+
);
+
+
let server: TSServer;
+
beforeAll(async () => {
+
server = new TSServer(projectPath, { debugLog: false });
+
+
server.sendCommand('open', {
+
file: outfileMissingFragmentDep,
+
fileContent: '// empty',
+
scriptKindName: 'TS',
+
} satisfies ts.server.protocol.OpenRequestArgs);
+
+
server.sendCommand('updateOpen', {
+
openFiles: [
+
{
+
file: outfileMissingFragmentDep,
+
fileContent: fs.readFileSync(
+
path.join(projectPath, 'fixtures/missing-fragment-dep.ts'),
+
'utf-8'
+
),
+
},
+
],
+
} satisfies ts.server.protocol.UpdateOpenRequestArgs);
+
+
server.sendCommand('saveto', {
+
file: outfileMissingFragmentDep,
+
tmpfile: outfileMissingFragmentDep,
+
} satisfies ts.server.protocol.SavetoRequestArgs);
+
});
+
+
afterAll(() => {
+
try {
+
fs.unlinkSync(outfileMissingFragmentDep);
+
} catch {}
+
});
+
+
it('warns about missing fragment dep even when fragment is used in another query in same file', async () => {
+
await server.waitForResponse(
+
e =>
+
e.type === 'event' &&
+
e.event === 'semanticDiag' &&
+
e.body?.file === outfileMissingFragmentDep
+
);
+
+
const res = server.responses.filter(
+
resp =>
+
resp.type === 'event' &&
+
resp.event === 'semanticDiag' &&
+
resp.body?.file === outfileMissingFragmentDep
+
);
+
+
// Should have a diagnostic about the unknown fragment in SecondQuery
+
expect(res.length).toBeGreaterThan(0);
+
expect(res[0].body.diagnostics.length).toBeGreaterThan(0);
+
+
const fragmentError = res[0].body.diagnostics.find((diag: any) =>
+
diag.text.includes('PokemonBasicInfo')
+
);
+
+
expect(fragmentError).toBeDefined();
+
expect(fragmentError.text).toBe('Unknown fragment "PokemonBasicInfo".');
+
expect(fragmentError.code).toBe(52001);
+
expect(fragmentError.category).toBe('error');
+
}, 30000);
+
});