diff --git a/.changeset/twelve-spies-learn.md b/.changeset/twelve-spies-learn.md new file mode 100644 index 00000000..2001b285 --- /dev/null +++ b/.changeset/twelve-spies-learn.md @@ -0,0 +1,6 @@ +--- +"@0no-co/graphqlsp": minor +--- + +only run the `typescript` plugin once to generate a set of types that we'll reference from our +`typescript-operations`, this to reduce lengthy generated files. diff --git a/packages/example/__generated__/baseGraphQLSP.ts b/packages/example/__generated__/baseGraphQLSP.ts new file mode 100644 index 00000000..848f3e80 --- /dev/null +++ b/packages/example/__generated__/baseGraphQLSP.ts @@ -0,0 +1,105 @@ +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { + [K in keyof T]: T[K]; +}; +export type MakeOptional = Omit & { + [SubKey in K]?: Maybe; +}; +export type MakeMaybe = Omit & { + [SubKey in K]: Maybe; +}; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; +}; + +/** Elemental property associated with either a Pokémon or one of their moves. */ +export type PokemonType = + | 'Grass' + | 'Poison' + | 'Fire' + | 'Flying' + | 'Water' + | 'Bug' + | 'Normal' + | 'Electric' + | 'Ground' + | 'Fairy' + | 'Fighting' + | 'Psychic' + | 'Rock' + | 'Steel' + | 'Ice' + | 'Ghost' + | 'Dragon' + | 'Dark'; + +/** Move a Pokémon can perform with the associated damage and type. */ +export type Attack = { + __typename?: 'Attack'; + name?: Maybe; + type?: Maybe; + damage?: Maybe; +}; + +/** Requirement that prevents an evolution through regular means of levelling up. */ +export type EvolutionRequirement = { + __typename?: 'EvolutionRequirement'; + amount?: Maybe; + name?: Maybe; +}; + +export type PokemonDimension = { + __typename?: 'PokemonDimension'; + minimum?: Maybe; + maximum?: Maybe; +}; + +export type AttacksConnection = { + __typename?: 'AttacksConnection'; + fast?: Maybe>>; + special?: Maybe>>; +}; + +export type Pokemon = { + __typename?: 'Pokemon'; + id: Scalars['ID']; + name: Scalars['String']; + classification?: Maybe; + types?: Maybe>>; + resistant?: Maybe>>; + weaknesses?: Maybe>>; + evolutionRequirements?: Maybe>>; + weight?: Maybe; + height?: Maybe; + attacks?: Maybe; + /** Likelihood of an attempt to catch a Pokémon to fail. */ + fleeRate?: Maybe; + /** Maximum combat power a Pokémon may achieve at max level. */ + maxCP?: Maybe; + /** Maximum health points a Pokémon may achieve at max level. */ + maxHP?: Maybe; + evolutions?: Maybe>>; +}; + +export type Query = { + __typename?: 'Query'; + /** List out all Pokémon, optionally in pages */ + pokemons?: Maybe>>; + /** Get a single Pokémon by its ID, a three character long identifier padded with zeroes */ + pokemon?: Maybe; +}; + +export type QueryPokemonsArgs = { + limit?: InputMaybe; + skip?: InputMaybe; +}; + +export type QueryPokemonArgs = { + id: Scalars['ID']; +}; diff --git a/packages/example/src/Pokemon.generated.ts b/packages/example/src/Pokemon.generated.ts index 611dba21..754b1d09 100644 --- a/packages/example/src/Pokemon.generated.ts +++ b/packages/example/src/Pokemon.generated.ts @@ -1,110 +1,5 @@ +import * as Types from '../__generated__/baseGraphQLSP'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; -export type Maybe = T | null; -export type InputMaybe = Maybe; -export type Exact = { - [K in keyof T]: T[K]; -}; -export type MakeOptional = Omit & { - [SubKey in K]?: Maybe; -}; -export type MakeMaybe = Omit & { - [SubKey in K]: Maybe; -}; -/** All built-in and custom scalars, mapped to their actual values */ -export type Scalars = { - ID: string; - String: string; - Boolean: boolean; - Int: number; - Float: number; -}; - -/** Elemental property associated with either a Pokémon or one of their moves. */ -export type PokemonType = - | 'Grass' - | 'Poison' - | 'Fire' - | 'Flying' - | 'Water' - | 'Bug' - | 'Normal' - | 'Electric' - | 'Ground' - | 'Fairy' - | 'Fighting' - | 'Psychic' - | 'Rock' - | 'Steel' - | 'Ice' - | 'Ghost' - | 'Dragon' - | 'Dark'; - -/** Move a Pokémon can perform with the associated damage and type. */ -export type Attack = { - __typename?: 'Attack'; - name?: Maybe; - type?: Maybe; - damage?: Maybe; -}; - -/** Requirement that prevents an evolution through regular means of levelling up. */ -export type EvolutionRequirement = { - __typename?: 'EvolutionRequirement'; - amount?: Maybe; - name?: Maybe; -}; - -export type PokemonDimension = { - __typename?: 'PokemonDimension'; - minimum?: Maybe; - maximum?: Maybe; -}; - -export type AttacksConnection = { - __typename?: 'AttacksConnection'; - fast?: Maybe>>; - special?: Maybe>>; -}; - -export type Pokemon = { - __typename?: 'Pokemon'; - id: Scalars['ID']; - name: Scalars['String']; - classification?: Maybe; - types?: Maybe>>; - resistant?: Maybe>>; - weaknesses?: Maybe>>; - evolutionRequirements?: Maybe>>; - weight?: Maybe; - height?: Maybe; - attacks?: Maybe; - /** Likelihood of an attempt to catch a Pokémon to fail. */ - fleeRate?: Maybe; - /** Maximum combat power a Pokémon may achieve at max level. */ - maxCP?: Maybe; - /** Maximum health points a Pokémon may achieve at max level. */ - maxHP?: Maybe; - evolutions?: Maybe>>; -}; - -export type Query = { - __typename?: 'Query'; - /** List out all Pokémon, optionally in pages */ - pokemons?: Maybe>>; - /** Get a single Pokémon by its ID, a three character long identifier padded with zeroes */ - pokemon?: Maybe; -}; - -export type QueryPokemonsArgs = { - limit?: InputMaybe; - skip?: InputMaybe; -}; - -export type QueryPokemonArgs = { - id: Scalars['ID']; -}; - export type FieldsFragment = { __typename?: 'Pokemon'; classification?: string | null; diff --git a/packages/example/src/index.generated.ts b/packages/example/src/index.generated.ts index bc7b538a..eb02b41a 100644 --- a/packages/example/src/index.generated.ts +++ b/packages/example/src/index.generated.ts @@ -1,111 +1,6 @@ +import * as Types from '../__generated__/baseGraphQLSP'; import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; -export type Maybe = T | null; -export type InputMaybe = Maybe; -export type Exact = { - [K in keyof T]: T[K]; -}; -export type MakeOptional = Omit & { - [SubKey in K]?: Maybe; -}; -export type MakeMaybe = Omit & { - [SubKey in K]: Maybe; -}; -/** All built-in and custom scalars, mapped to their actual values */ -export type Scalars = { - ID: string; - String: string; - Boolean: boolean; - Int: number; - Float: number; -}; - -/** Elemental property associated with either a Pokémon or one of their moves. */ -export type PokemonType = - | 'Grass' - | 'Poison' - | 'Fire' - | 'Flying' - | 'Water' - | 'Bug' - | 'Normal' - | 'Electric' - | 'Ground' - | 'Fairy' - | 'Fighting' - | 'Psychic' - | 'Rock' - | 'Steel' - | 'Ice' - | 'Ghost' - | 'Dragon' - | 'Dark'; - -/** Move a Pokémon can perform with the associated damage and type. */ -export type Attack = { - __typename?: 'Attack'; - name?: Maybe; - type?: Maybe; - damage?: Maybe; -}; - -/** Requirement that prevents an evolution through regular means of levelling up. */ -export type EvolutionRequirement = { - __typename?: 'EvolutionRequirement'; - amount?: Maybe; - name?: Maybe; -}; - -export type PokemonDimension = { - __typename?: 'PokemonDimension'; - minimum?: Maybe; - maximum?: Maybe; -}; - -export type AttacksConnection = { - __typename?: 'AttacksConnection'; - fast?: Maybe>>; - special?: Maybe>>; -}; - -export type Pokemon = { - __typename?: 'Pokemon'; - id: Scalars['ID']; - name: Scalars['String']; - classification?: Maybe; - types?: Maybe>>; - resistant?: Maybe>>; - weaknesses?: Maybe>>; - evolutionRequirements?: Maybe>>; - weight?: Maybe; - height?: Maybe; - attacks?: Maybe; - /** Likelihood of an attempt to catch a Pokémon to fail. */ - fleeRate?: Maybe; - /** Maximum combat power a Pokémon may achieve at max level. */ - maxCP?: Maybe; - /** Maximum health points a Pokémon may achieve at max level. */ - maxHP?: Maybe; - evolutions?: Maybe>>; -}; - -export type Query = { - __typename?: 'Query'; - /** List out all Pokémon, optionally in pages */ - pokemons?: Maybe>>; - /** Get a single Pokémon by its ID, a three character long identifier padded with zeroes */ - pokemon?: Maybe; -}; - -export type QueryPokemonsArgs = { - limit?: InputMaybe; - skip?: InputMaybe; -}; - -export type QueryPokemonArgs = { - id: Scalars['ID']; -}; - -export type PokemonsQueryVariables = Exact<{ [key: string]: never }>; +export type PokemonsQueryVariables = Types.Exact<{ [key: string]: never }>; export type PokemonsQuery = { __typename?: 'Query'; @@ -123,8 +18,8 @@ export type PokemonFieldsFragment = { name: string; }; -export type PokemonQueryVariables = Exact<{ - id: Scalars['ID']; +export type PokemonQueryVariables = Types.Exact<{ + id: Types.Scalars['ID']; }>; export type PokemonQuery = { diff --git a/packages/graphqlsp/package.json b/packages/graphqlsp/package.json index b6aacbe1..32c8e070 100644 --- a/packages/graphqlsp/package.json +++ b/packages/graphqlsp/package.json @@ -40,6 +40,7 @@ "typescript": "^5.0.0" }, "dependencies": { + "@graphql-codegen/add": "^4.0.1", "@graphql-codegen/core": "^3.1.0", "@graphql-codegen/typed-document-node": "^3.0.2", "@graphql-codegen/typescript": "^3.0.3", diff --git a/packages/graphqlsp/src/getSchema.ts b/packages/graphqlsp/src/getSchema.ts index b7571a4d..52b45787 100644 --- a/packages/graphqlsp/src/getSchema.ts +++ b/packages/graphqlsp/src/getSchema.ts @@ -10,11 +10,14 @@ import path from 'path'; import fs from 'fs'; import { Logger } from './index'; +import { generateBaseTypes } from './types/generate'; export const loadSchema = ( root: string, schema: string, - logger: Logger + logger: Logger, + baseTypesPath: string, + scalars: Record ): { current: GraphQLSchema | null } => { const ref: { current: GraphQLSchema | null } = { current: null }; let url: URL | undefined; @@ -54,6 +57,7 @@ export const loadSchema = ( (result as { data: IntrospectionQuery }).data ); logger(`Got schema for ${url!.toString()}`); + generateBaseTypes(ref.current, baseTypesPath, scalars); } catch (e: any) { logger(`Got schema error for ${e.message}`); } @@ -70,11 +74,13 @@ export const loadSchema = ( ref.current = isJson ? buildClientSchema(JSON.parse(contents)) : buildSchema(contents); + generateBaseTypes(ref.current, baseTypesPath, scalars); }); ref.current = isJson ? buildClientSchema(JSON.parse(contents)) : buildSchema(contents); + generateBaseTypes(ref.current, baseTypesPath, scalars); logger(`Got schema and initialized watcher for ${schema}`); } diff --git a/packages/graphqlsp/src/index.ts b/packages/graphqlsp/src/index.ts index 10fa43ef..c4f0239c 100644 --- a/packages/graphqlsp/src/index.ts +++ b/packages/graphqlsp/src/index.ts @@ -68,10 +68,14 @@ function create(info: ts.server.PluginCreateInfo) { const proxy = createBasicDecorator(info); + const baseTypesPath = + info.project.getCurrentDirectory() + '/__generated__/baseGraphQLSP.ts'; const schema = loadSchema( info.project.getProjectName(), info.config.schema, - logger + logger, + baseTypesPath, + scalars ); proxy.getSemanticDiagnostics = (filename: string): ts.Diagnostic[] => { @@ -191,7 +195,8 @@ function create(info: ts.server.PluginCreateInfo) { schema.current, parts.join('/'), texts.join('\n'), - scalars + scalars, + baseTypesPath ).then(() => { if (isFileDirty(filename, source)) { return; diff --git a/packages/graphqlsp/src/types/generate.ts b/packages/graphqlsp/src/types/generate.ts index efc79e0d..4b31f541 100644 --- a/packages/graphqlsp/src/types/generate.ts +++ b/packages/graphqlsp/src/types/generate.ts @@ -1,19 +1,69 @@ import fs from 'fs'; -import path from 'path'; +import { posix as path } from 'path'; import { printSchema, parse, GraphQLSchema } from 'graphql'; import { codegen } from '@graphql-codegen/core'; import * as typescriptPlugin from '@graphql-codegen/typescript'; import * as typescriptOperationsPlugin from '@graphql-codegen/typescript-operations'; import * as typedDocumentNodePlugin from '@graphql-codegen/typed-document-node'; +import * as addPlugin from '@graphql-codegen/add'; +import { Logger } from '..'; + +export const generateBaseTypes = async ( + schema: GraphQLSchema | null, + outputFile: string, + scalars: Record +) => { + if (!schema) return; + + const config = { + documents: [], + config: { + scalars, + // nonOptionalTypename: true, + // avoidOptionals, worth looking into + enumsAsTypes: true, + globalNamespace: true, + }, + filename: outputFile, + schema: parse(printSchema(schema)), + plugins: [{ typescript: {} }], + pluginMap: { + typescript: typescriptPlugin, + }, + }; + + // @ts-ignore + const output = await codegen(config); + let folderParts = outputFile.split('/'); + folderParts.pop(); + const folder = path.join(folderParts.join('/')); + if (!fs.existsSync(folder)) { + fs.mkdirSync(folder); + } + fs.writeFile(path.join(outputFile), output, 'utf8', err => { + console.error(err); + }); +}; export const generateTypedDocumentNodes = async ( schema: GraphQLSchema | null, outputFile: string, doc: string, - scalars: Record + scalars: Record, + baseTypesPath: string ) => { if (!schema) return; + const parts = outputFile.split('/'); + parts.pop(); + let basePath = path + .relative(parts.join('/'), baseTypesPath) + .replace('.ts', ''); + // case where files are declared globally, we need to prefix with ./ + if (basePath === '__generated__/baseGraphQLSP') { + basePath = './' + basePath; + } + const config = { documents: [ { @@ -22,6 +72,7 @@ export const generateTypedDocumentNodes = async ( }, ], config: { + namespacedImportName: 'Types', scalars, // nonOptionalTypename: true, // avoidOptionals, worth looking into @@ -29,21 +80,17 @@ export const generateTypedDocumentNodes = async ( dedupeOperationSuffix: true, dedupeFragments: true, }, - // used by a plugin internally, although the 'typescript' plugin currently - // returns the string output, rather than writing to a file filename: outputFile, schema: parse(printSchema(schema)), plugins: [ - // TODO: there's optimisations to be had here where we move the typescript and typescript-operations - // to a global __generated__ folder and import from it. - { typescript: {} }, { 'typescript-operations': {} }, { 'typed-document-node': {} }, + { add: { content: `import * as Types from "${basePath}"` } }, ], pluginMap: { - typescript: typescriptPlugin, 'typescript-operations': typescriptOperationsPlugin, 'typed-document-node': typedDocumentNodePlugin, + add: addPlugin, }, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36c97fbd..ea9375ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: packages/graphqlsp: dependencies: + '@graphql-codegen/add': + specifier: ^4.0.1 + version: 4.0.1(graphql@16.6.0) '@graphql-codegen/core': specifier: ^3.1.0 version: 3.1.0(graphql@16.6.0) @@ -1043,6 +1046,16 @@ packages: dev: true optional: true + /@graphql-codegen/add@4.0.1(graphql@16.6.0): + resolution: {integrity: sha512-A7k+9eRfrKyyNfhWEN/0eKz09R5cp4XXxUuNLQAVm/aohmVI2xdMV4lM02rTlM6Pyou3cU/v0iZnhgo6IRpqeg==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + dependencies: + '@graphql-codegen/plugin-helpers': 4.2.0(graphql@16.6.0) + graphql: 16.6.0 + tslib: 2.5.0 + dev: false + /@graphql-codegen/core@3.1.0(graphql@16.6.0): resolution: {integrity: sha512-DH1/yaR7oJE6/B+c6ZF2Tbdh7LixF1K8L+8BoSubjNyQ8pNwR4a70mvc1sv6H7qgp6y1bPQ9tKE+aazRRshysw==} peerDependencies: diff --git a/test/e2e/graphqlsp.test.ts b/test/e2e/graphqlsp.test.ts index 68115dc7..bc563cb0 100644 --- a/test/e2e/graphqlsp.test.ts +++ b/test/e2e/graphqlsp.test.ts @@ -14,6 +14,10 @@ let server: TSServer; describe('simple', () => { const testFile = path.join(projectPath, 'simple.ts'); const generatedFile = path.join(projectPath, 'simple.generated.ts'); + const baseGeneratedFile = path.join( + projectPath, + '__generated__/baseGraphQLSP.ts' + ); beforeAll(async () => { server = new TSServer(projectPath, { debugLog: false }); @@ -46,6 +50,7 @@ describe('simple', () => { try { fs.unlinkSync(testFile); fs.unlinkSync(generatedFile); + fs.unlinkSync(baseGeneratedFile); } catch {} server.close(); }); @@ -54,13 +59,18 @@ describe('simple', () => { expect(() => { fs.lstatSync(testFile); fs.lstatSync(generatedFile); + fs.lstatSync(baseGeneratedFile); }).not.toThrow(); - expect(fs.readFileSync(testFile, 'utf-8')).toContain( + const testFileContents = fs.readFileSync(testFile, 'utf-8'); + const generatedFileContents = fs.readFileSync(generatedFile, 'utf-8'); + + expect(testFileContents).toContain( `as typeof import('./simple.generated').AllPostsDocument` ); - expect(fs.readFileSync(generatedFile, 'utf-8')).toContain( - 'export const AllPostsDocument = ' + expect(generatedFileContents).toContain('export const AllPostsDocument = '); + expect(generatedFileContents).toContain( + 'import * as Types from "./__generated__/baseGraphQLSP"' ); }, 7500);