Skip to content

Commit

Permalink
feat(core): add support for multiple schemas (#303)
Browse files Browse the repository at this point in the history
  • Loading branch information
JoviDeCroock committed Apr 25, 2024
1 parent c4630f6 commit b9b3401
Show file tree
Hide file tree
Showing 14 changed files with 473 additions and 135 deletions.
26 changes: 26 additions & 0 deletions .changeset/light-penguins-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@0no-co/graphqlsp': minor
---

Add support for defining multiple indepenent schemas through a new config property called `schemas`, you can
pass a config like the following:

```json
{
"name": "@0no-co/graphqlsp",
"schemas": [
{
"name": "pokemons",
"schema": "./pokemons.graphql",
"tadaOutputLocation": "./pokemons-introspection.d.ts"
},
{
"name": "weather",
"schema": "./weather.graphql",
"tadaOutputLocation": "./weather-introspection.d.ts"
}
]
}
```

The LSP will depending on what `graphql()` template you use figure out what API you are reaching out to.
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-8711af177005f46fa3e06d990b6ba28e353e7f9b",
"@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-8711af177005f46fa3e06d990b6ba28e353e7f9b",
"graphql": "^16.8.1",
"node-fetch": "^2.0.0"
},
Expand Down
55 changes: 47 additions & 8 deletions packages/graphqlsp/src/ast/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,19 +124,54 @@ export function unrollTadaFragments(
return wip;
}

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

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 : null;
} else if (brand.isStringLiteral()) {
return brand.value;
}
}
}

return null;
};

export function findAllCallExpressions(
sourceFile: ts.SourceFile,
info: ts.server.PluginCreateInfo,
shouldSearchFragments: boolean = true
): {
nodes: Array<ts.NoSubstitutionTemplateLiteral>;
nodes: Array<{
node: ts.NoSubstitutionTemplateLiteral;
schema: string | null;
}>;
fragments: Array<FragmentDefinitionNode>;
} {
const result: Array<ts.NoSubstitutionTemplateLiteral> = [];
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
const result: Array<{
node: ts.NoSubstitutionTemplateLiteral;
schema: string | null;
}> = [];
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 +195,7 @@ export function findAllCallExpressions(
}

if (arg && ts.isNoSubstitutionTemplateLiteral(arg)) {
result.push(arg);
result.push({ node: arg, schema: name });
}
return;
} else {
Expand All @@ -172,11 +207,13 @@ 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 | null }> {
const result: Array<{ node: ts.CallExpression; schema: string | null }> = [];
const typeChecker = info.languageService.getProgram()?.getTypeChecker();
function find(node: ts.Node) {
if (ts.isCallExpression(node)) {
if (node && ts.isCallExpression(node)) {
// This expression ideally for us looks like <template>.persisted
const expression = node.expression.getText();
const parts = expression.split('.');
Expand All @@ -185,7 +222,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
19 changes: 15 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,16 +48,24 @@ export function getGraphQLCompletions(
? bubbleUpCallExpression(node)
: bubbleUpTemplate(node);

let text, cursor;
let text, cursor, schemaToUse: GraphQLSchema | undefined;
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);

schemaToUse =
schemaName && schema.multi[schemaName]
? schema.multi[schemaName]?.schema
: schema.current?.schema;

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

const queryText = node.arguments[0].getText().slice(1, -1);
const fragments = getAllFragments(filename, node, info);
Expand Down Expand Up @@ -88,12 +98,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
Loading

0 comments on commit b9b3401

Please sign in to comment.