Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(core): add support for multiple schemas #303

Merged
merged 12 commits into from
Apr 25, 2024
Merged
17 changes: 6 additions & 11 deletions packages/example-tada/introspection.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,24 @@
* instead save to a .ts instead of a .d.ts file.
*/
export type introspection = {
name: 'pokemons';
query: 'Query';
mutation: never;
subscription: never;
types: {
'Attack': { kind: 'OBJECT'; name: 'Attack'; fields: { 'damage': { name: 'damage'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'type': { name: 'type'; type: { kind: 'ENUM'; name: 'PokemonType'; ofType: null; } }; }; };
'Int': unknown;
'String': unknown;
'AttacksConnection': { kind: 'OBJECT'; name: 'AttacksConnection'; fields: { 'fast': { name: 'fast'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Attack'; ofType: null; }; } }; 'special': { name: 'special'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Attack'; ofType: null; }; } }; }; };
'Boolean': unknown;
'EvolutionRequirement': { kind: 'OBJECT'; name: 'EvolutionRequirement'; fields: { 'amount': { name: 'amount'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; }; };
'Pokemon': { kind: 'OBJECT'; name: 'Pokemon'; fields: { 'attacks': { name: 'attacks'; type: { kind: 'OBJECT'; name: 'AttacksConnection'; ofType: null; } }; 'classification': { name: 'classification'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'evolutionRequirements': { name: 'evolutionRequirements'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'EvolutionRequirement'; ofType: null; }; } }; 'evolutions': { name: 'evolutions'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Pokemon'; ofType: null; }; } }; 'fleeRate': { name: 'fleeRate'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; } }; 'height': { name: 'height'; type: { kind: 'OBJECT'; name: 'PokemonDimension'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'maxCP': { name: 'maxCP'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'maxHP': { name: 'maxHP'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'resistant': { name: 'resistant'; type: { kind: 'LIST'; name: never; ofType: { kind: 'ENUM'; name: 'PokemonType'; ofType: null; }; } }; 'types': { name: 'types'; type: { kind: 'LIST'; name: never; ofType: { kind: 'ENUM'; name: 'PokemonType'; ofType: null; }; } }; 'weaknesses': { name: 'weaknesses'; type: { kind: 'LIST'; name: never; ofType: { kind: 'ENUM'; name: 'PokemonType'; ofType: null; }; } }; 'weight': { name: 'weight'; type: { kind: 'OBJECT'; name: 'PokemonDimension'; ofType: null; } }; }; };
'Float': unknown;
'ID': unknown;
'Int': unknown;
'Pokemon': { kind: 'OBJECT'; name: 'Pokemon'; fields: { 'attacks': { name: 'attacks'; type: { kind: 'OBJECT'; name: 'AttacksConnection'; ofType: null; } }; 'classification': { name: 'classification'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'evolutionRequirements': { name: 'evolutionRequirements'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'EvolutionRequirement'; ofType: null; }; } }; 'evolutions': { name: 'evolutions'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Pokemon'; ofType: null; }; } }; 'fleeRate': { name: 'fleeRate'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; } }; 'height': { name: 'height'; type: { kind: 'OBJECT'; name: 'PokemonDimension'; ofType: null; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'ID'; ofType: null; }; } }; 'maxCP': { name: 'maxCP'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'maxHP': { name: 'maxHP'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; 'name': { name: 'name'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'resistant': { name: 'resistant'; type: { kind: 'LIST'; name: never; ofType: { kind: 'ENUM'; name: 'PokemonType'; ofType: null; }; } }; 'types': { name: 'types'; type: { kind: 'LIST'; name: never; ofType: { kind: 'ENUM'; name: 'PokemonType'; ofType: null; }; } }; 'weaknesses': { name: 'weaknesses'; type: { kind: 'LIST'; name: never; ofType: { kind: 'ENUM'; name: 'PokemonType'; ofType: null; }; } }; 'weight': { name: 'weight'; type: { kind: 'OBJECT'; name: 'PokemonDimension'; ofType: null; } }; }; };
'PokemonDimension': { kind: 'OBJECT'; name: 'PokemonDimension'; fields: { 'maximum': { name: 'maximum'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'minimum': { name: 'minimum'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; }; };
'PokemonType': { kind: 'ENUM'; name: 'PokemonType'; type: 'Bug' | 'Dark' | 'Dragon' | 'Electric' | 'Fairy' | 'Fighting' | 'Fire' | 'Flying' | 'Ghost' | 'Grass' | 'Ground' | 'Ice' | 'Normal' | 'Poison' | 'Psychic' | 'Rock' | 'Steel' | 'Water'; };
'PokemonType': { name: 'PokemonType'; enumValues: 'Bug' | 'Dark' | 'Dragon' | 'Electric' | 'Fairy' | 'Fighting' | 'Fire' | 'Flying' | 'Ghost' | 'Grass' | 'Ground' | 'Ice' | 'Normal' | 'Poison' | 'Psychic' | 'Rock' | 'Steel' | 'Water'; };
'Query': { kind: 'OBJECT'; name: 'Query'; fields: { 'pokemon': { name: 'pokemon'; type: { kind: 'OBJECT'; name: 'Pokemon'; ofType: null; } }; 'pokemons': { name: 'pokemons'; type: { kind: 'LIST'; name: never; ofType: { kind: 'OBJECT'; name: 'Pokemon'; ofType: null; }; } }; }; };
'Boolean': unknown;
'String': unknown;
};
};

import * as gqlTada from 'gql.tada';

declare module 'gql.tada' {
interface setupSchema {
introspection: introspection;
}
}
2 changes: 1 addition & 1 deletion packages/example-tada/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"license": "ISC",
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
"gql.tada": "^1.4.0",
"gql.tada": "1.5.9-canary-92d094cc29ae2fc73a9f8ff9c0ef6b24fdf52552",
"@urql/core": "^3.0.0",
"graphql": "^16.8.1",
"urql": "^4.0.6"
Expand Down
9 changes: 7 additions & 2 deletions packages/example-tada/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
"plugins": [
{
"name": "@0no-co/graphqlsp",
"schema": "./schema.graphql",
"tadaOutputLocation": "./introspection.d.ts"
"schemas": [
{
"name": "pokemons",
"schema": "./schema.graphql",
"tadaOutputLocation": "./introspection.d.ts"
}
]
}
],
"jsx": "react-jsx",
Expand Down
2 changes: 1 addition & 1 deletion packages/graphqlsp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"typescript": "^5.3.3"
},
"dependencies": {
"@gql.tada/internal": "^0.1.2",
"@gql.tada/internal": "0.3.0-canary-92d094cc29ae2fc73a9f8ff9c0ef6b24fdf52552",
"graphql": "^16.8.1",
"node-fetch": "^2.0.0"
},
Expand Down
50 changes: 43 additions & 7 deletions packages/graphqlsp/src/ast/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,19 +124,51 @@ export function unrollTadaFragments(
return wip;
}

export const getSchemaName = (
node: ts.CallExpression,
typeChecker?: ts.TypeChecker
): string => {
if (!typeChecker) return 'default';

const expression = ts.isPropertyAccessExpression(node.expression)
? node.expression.expression
: node.expression;
const type = typeChecker.getTypeAtLocation(expression);
if (type) {
const brandTypeSymbol = type.getProperty('__name');
if (brandTypeSymbol) {
const brand = typeChecker.getTypeOfSymbol(brandTypeSymbol);
if (brand.isUnionOrIntersection()) {
const found = brand.types.find(x => x.isStringLiteral());
return found && found.isStringLiteral() ? found.value : 'default';
} else if (brand.isStringLiteral()) {
return brand.value;
}
}
}

return 'default';
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved
};

export function findAllCallExpressions(
sourceFile: ts.SourceFile,
info: ts.server.PluginCreateInfo,
shouldSearchFragments: boolean = true
): {
nodes: Array<ts.NoSubstitutionTemplateLiteral>;
nodes: Array<{ node: ts.NoSubstitutionTemplateLiteral; schema: string }>;
fragments: Array<FragmentDefinitionNode>;
} {
const result: Array<ts.NoSubstitutionTemplateLiteral> = [];
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
const result: Array<{
node: ts.NoSubstitutionTemplateLiteral;
schema: string;
}> = [];
let fragments: Array<FragmentDefinitionNode> = [];
let hasTriedToFindFragments = shouldSearchFragments ? false : true;
function find(node: ts.Node) {
if (ts.isCallExpression(node) && templates.has(node.expression.getText())) {
const name = getSchemaName(node, typeChecker);

const [arg, arg2] = node.arguments;

if (!hasTriedToFindFragments && !arg2) {
Expand All @@ -160,7 +192,7 @@ export function findAllCallExpressions(
}

if (arg && ts.isNoSubstitutionTemplateLiteral(arg)) {
result.push(arg);
result.push({ node: arg, schema: name });
}
return;
} else {
Expand All @@ -172,9 +204,11 @@ export function findAllCallExpressions(
}

export function findAllPersistedCallExpressions(
sourceFile: ts.SourceFile
): Array<ts.CallExpression> {
const result: Array<ts.CallExpression> = [];
sourceFile: ts.SourceFile,
info: ts.server.PluginCreateInfo
): Array<{ node: ts.CallExpression; schema: string }> {
const result: Array<{ node: ts.CallExpression; schema: string }> = [];
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
function find(node: ts.Node) {
if (ts.isCallExpression(node)) {
// This expression ideally for us looks like <template>.persisted
Expand All @@ -185,7 +219,9 @@ export function findAllPersistedCallExpressions(
const [template, method] = parts;
if (!templates.has(template) || method !== 'persisted') return;

result.push(node);
const name = getSchemaName(node, typeChecker);

result.push({ node, schema: name });
} else {
ts.forEachChild(node, find);
}
Expand Down
18 changes: 14 additions & 4 deletions packages/graphqlsp/src/autoComplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,20 @@ import {
bubbleUpTemplate,
findNode,
getAllFragments,
getSchemaName,
getSource,
} from './ast';
import { Cursor } from './ast/cursor';
import { resolveTemplate } from './ast/resolve';
import { getToken } from './ast/token';
import { getSuggestionsForFragmentSpread } from './graphql/getFragmentSpreadSuggestions';
import { templates } from './ast/templates';
import { SchemaRef } from './graphql/getSchema';

export function getGraphQLCompletions(
filename: string,
cursorPosition: number,
schema: { current: GraphQLSchema | null },
schema: SchemaRef,
info: ts.server.PluginCreateInfo
): ts.WithMetadata<ts.CompletionInfo> | undefined {
const isCallExpression = info.config.templateIsCallExpression ?? true;
Expand All @@ -46,22 +48,29 @@ export function getGraphQLCompletions(
? bubbleUpCallExpression(node)
: bubbleUpTemplate(node);

let text, cursor;
let text, cursor, schemaToUse: GraphQLSchema;
if (
ts.isCallExpression(node) &&
isCallExpression &&
templates.has(node.expression.getText()) &&
node.arguments.length > 0 &&
ts.isNoSubstitutionTemplateLiteral(node.arguments[0])
) {
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
const schemaName = getSchemaName(node, typeChecker);

const foundToken = getToken(node.arguments[0], cursorPosition);
if (!schema.current || !foundToken) return undefined;
if ((!schema.current && !schema.multi[schemaName]) || !foundToken)
return undefined;

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

text = `${queryText}\n${fragments.map(x => print(x)).join('\n')}`;
cursor = new Cursor(foundToken.line, foundToken.start - 1);
schemaToUse = schema.multi[schemaName]
? schema.multi[schemaName]!.schema
: schema.current!.schema;
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved
} else if (ts.isTaggedTemplateExpression(node)) {
const { template, tag } = node;

Expand All @@ -88,12 +97,13 @@ export function getGraphQLCompletions(

text = combinedText;
cursor = new Cursor(foundToken.line, foundToken.start - 1);
schemaToUse = schema.current.schema;
} else {
return undefined;
}

const [suggestions, spreadSuggestions] = getSuggestionsInternal(
schema.current,
schemaToUse,
text,
cursor
);
Expand Down
4 changes: 2 additions & 2 deletions packages/graphqlsp/src/checkImports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ function getFragmentsInSource(
const exports = typeChecker.getExportsOfModule(symbol);
const exportedNames = exports.map(symb => symb.name);
const nodes = callExpressions.nodes.filter(x => {
let parent = x.parent;
let parent = x.node.parent;
while (
parent &&
!ts.isSourceFile(parent) &&
Expand All @@ -191,7 +191,7 @@ function getFragmentsInSource(
});

nodes.forEach(node => {
const text = resolveTemplate(node, src.fileName, info).combinedText;
const text = resolveTemplate(node.node, src.fileName, info).combinedText;
try {
const parsed = parse(text, { noLocation: true });
if (parsed.definitions.every(x => x.kind === Kind.FRAGMENT_DEFINITION)) {
Expand Down
50 changes: 31 additions & 19 deletions packages/graphqlsp/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getDocumentReferenceFromDocumentNode,
getDocumentReferenceFromTypeQuery,
} from './persisted';
import { SchemaRef } from './graphql/getSchema';

const clientDirectives = new Set([
'populate',
Expand Down Expand Up @@ -76,7 +77,7 @@ const cache = new LRUCache<number, ts.Diagnostic[]>({

export function getGraphQLDiagnostics(
filename: string,
schema: { current: GraphQLSchema | null; version: number },
schema: SchemaRef,
info: ts.server.PluginCreateInfo
): ts.Diagnostic[] | undefined {
const isCallExpression = info.config.templateIsCallExpression ?? true;
Expand All @@ -85,16 +86,22 @@ export function getGraphQLDiagnostics(
if (!source) return undefined;

let fragments: Array<FragmentDefinitionNode> = [],
nodes: (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral)[];
nodes: {
node: ts.NoSubstitutionTemplateLiteral | ts.TaggedTemplateExpression;
schema: string;
}[];
if (isCallExpression) {
const result = findAllCallExpressions(source, info);
fragments = result.fragments;
nodes = result.nodes;
} else {
nodes = findAllTaggedTemplateNodes(source);
nodes = findAllTaggedTemplateNodes(source).map(x => ({
node: x,
schema: 'default',
JoviDeCroock marked this conversation as resolved.
Show resolved Hide resolved
}));
}

const texts = nodes.map(node => {
const texts = nodes.map(({ node }) => {
if (
(ts.isNoSubstitutionTemplateLiteral(node) ||
ts.isTemplateExpression(node)) &&
Expand Down Expand Up @@ -131,15 +138,13 @@ export function getGraphQLDiagnostics(
let fragmentDiagnostics: ts.Diagnostic[] = [];

if (isCallExpression) {
const persistedCalls = findAllPersistedCallExpressions(source);
const persistedCalls = findAllPersistedCallExpressions(source, info);
// We need to check whether the user has correctly inserted a hash,
// by means of providing an argument to the function and that they
// are establishing a reference to the document by means of the generic.
//
// OPTIONAL: we could also check whether the hash is out of date with the
// document but this removes support for self-generating identifiers
const persistedDiagnostics = persistedCalls
.map<ts.Diagnostic | null>(callExpression => {
.map<ts.Diagnostic | null>(found => {
const { node: callExpression } = found;
if (!callExpression.typeArguments && !callExpression.arguments[1]) {
return {
category: ts.DiagnosticCategory.Warning,
Expand Down Expand Up @@ -252,13 +257,14 @@ export function getGraphQLDiagnostics(

const hash = callExpression.arguments[0].getText().slice(1, -1);
if (hash.startsWith('sha256:')) {
const hash = generateHashForDocument(
const generatedHash = generateHashForDocument(
info,
initializer.arguments[0],
foundFilename
);
if (!hash) return null;
const upToDateHash = `sha256:${hash}`;
if (!generatedHash) return null;

const upToDateHash = `sha256:${generatedHash}`;
if (upToDateHash !== hash) {
return {
category: ts.DiagnosticCategory.Warning,
Expand All @@ -283,7 +289,7 @@ export function getGraphQLDiagnostics(
const moduleSpecifierToFragments = getColocatedFragmentNames(source, info);

const usedFragments = new Set();
nodes.forEach(node => {
nodes.forEach(({ node }) => {
try {
const parsed = parse(node.getText().slice(1, -1), {
noLocation: true,
Expand Down Expand Up @@ -331,18 +337,21 @@ const runDiagnostics = (
nodes,
fragments,
}: {
nodes: (ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral)[];
nodes: {
node: ts.TaggedTemplateExpression | ts.NoSubstitutionTemplateLiteral;
schema: string;
}[];
fragments: FragmentDefinitionNode[];
},
schema: { current: GraphQLSchema | null; version: number },
schema: SchemaRef,
info: ts.server.PluginCreateInfo
) => {
const filename = source.fileName;
const isCallExpression = info.config.templateIsCallExpression ?? true;

const diagnostics = nodes
.map(originalNode => {
let node = originalNode;
let node = originalNode.node;
if (
!isCallExpression &&
(ts.isNoSubstitutionTemplateLiteral(node) ||
Expand Down Expand Up @@ -397,9 +406,12 @@ const runDiagnostics = (
} catch (e) {}
}

const schemaToUse = schema.multi[originalNode.schema]
? schema.multi[originalNode.schema]!.schema
: schema.current!.schema;
const graphQLDiagnostics = getDiagnostics(
text,
schema.current,
schemaToUse,
undefined,
undefined,
docFragments
Expand Down Expand Up @@ -482,7 +494,7 @@ const runDiagnostics = (
message: 'Operation should contain a name.',
start: node.getStart(),
code: MISSING_OPERATION_NAME_CODE,
length: originalNode.getText().length,
length: originalNode.node.getText().length,
range: {} as any,
severity: 2,
} as any);
Expand Down Expand Up @@ -516,7 +528,7 @@ const runDiagnostics = (
const usageDiagnostics =
checkFieldUsageInFile(
source,
nodes as ts.NoSubstitutionTemplateLiteral[],
nodes.map(x => x.node) as ts.NoSubstitutionTemplateLiteral[],
info
) || [];

Expand Down
Loading
Loading