diff --git a/examples/yoga/persisted/MyQuery.ts b/examples/yoga/persisted/MyQuery.ts new file mode 100644 index 00000000..7bed8722 --- /dev/null +++ b/examples/yoga/persisted/MyQuery.ts @@ -0,0 +1,108 @@ +import { getSchema } from "./../schema"; +import { DocumentNode, execute } from "graphql"; +const schema = getSchema(); +const doc: DocumentNode = { + kind: "Document", + definitions: [ + { + kind: "OperationDefinition", + operation: "query", + name: { + kind: "Name", + value: "MyQuery" + }, + variableDefinitions: [], + directives: [], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + alias: undefined, + name: { + kind: "Name", + value: "me" + }, + arguments: [], + directives: [], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + alias: undefined, + name: { + kind: "Name", + value: "groups" + }, + arguments: [], + directives: [], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + alias: undefined, + name: { + kind: "Name", + value: "name" + }, + arguments: [], + directives: [], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + alias: undefined, + name: { + kind: "Name", + value: "description" + }, + arguments: [], + directives: [], + selectionSet: undefined + }, + { + kind: "Field", + alias: undefined, + name: { + kind: "Name", + value: "members" + }, + arguments: [], + directives: [], + selectionSet: { + kind: "SelectionSet", + selections: [ + { + kind: "Field", + alias: undefined, + name: { + kind: "Name", + value: "name" + }, + arguments: [], + directives: [], + selectionSet: undefined + } + ] + } + } + ] + } + } + ] + } + } + ] + } + } + ] + } + } + ] +} as DocumentNode; +export function executeOperation(variables: any) { + return execute({ schema: schema, document: doc, variableValues: variables }); +} diff --git a/examples/yoga/query.graphql b/examples/yoga/query.graphql new file mode 100644 index 00000000..755ca16d --- /dev/null +++ b/examples/yoga/query.graphql @@ -0,0 +1,12 @@ +query MyQuery { + me { + groups { + name { + description + members { + name + } + } + } + } +} diff --git a/src/cli.ts b/src/cli.ts index fd18df7b..f9e370d0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { Location } from "graphql"; +import { Location, parse } from "graphql"; import { getParsedTsConfig } from "./"; import { SchemaAndDoc, @@ -9,13 +9,15 @@ import { } from "./lib"; import { Command } from "commander"; import { writeFileSync } from "fs"; -import { resolve, dirname } from "path"; +import { resolve, dirname, join } from "path"; import { version } from "../package.json"; import { locate } from "./Locate"; import { printGratsSDL, printExecutableSchema } from "./printSchema"; import * as ts from "typescript"; import { ReportableDiagnostics } from "./utils/DiagnosticError"; import { ConfigOptions, ParsedCommandLineGrats } from "./gratsConfig"; +import * as fs from "fs"; +import { queryCodegen } from "./queryCodegen"; const program = new Command(); @@ -63,6 +65,47 @@ program console.log(formatLoc(loc.value)); }); +program + .command("persist") + .argument("", "Text of the GraphQL operation to persist") + .option( + "--tsconfig ", + "Path to tsconfig.json. Defaults to auto-detecting based on the current working directory", + ) + .action((operationText, { tsconfig }) => { + const { config, configPath } = getTsConfigOrReportAndExit(tsconfig); + + const schemaAndDocResult = buildSchemaAndDocResult(config); + if (schemaAndDocResult.kind === "ERROR") { + console.error( + schemaAndDocResult.err.formatDiagnosticsWithColorAndContext(), + ); + process.exit(1); + } + + if (operationText === "-") { + operationText = fs.readFileSync(0, "utf-8"); + } + + const doc = parse(operationText, { noLocation: true }); + + if (doc.definitions.length !== 1) { + throw new Error("Expected exactly one definition in the document"); + } + if (doc.definitions[0].kind !== "OperationDefinition") { + throw new Error("Expected the definition to be an operation"); + } + const name = doc.definitions[0].name?.value; + + const destDir = resolve(dirname(configPath), `./persisted`); + const dest = join(destDir, `${name}.ts`); + const result = queryCodegen(config.raw.grats, configPath, dest, doc); + + fs.mkdirSync(destDir, { recursive: true }); + writeFileSync(dest, result); + console.error(`Grats: Wrote TypeScript operation to \`${dest}\`.`); + }); + program.parse(); /** diff --git a/src/codegen.ts b/src/codegen.ts index 14382da7..10ca50fd 100644 --- a/src/codegen.ts +++ b/src/codegen.ts @@ -52,7 +52,7 @@ export function codegen(schema: GraphQLSchema, destination: string): string { return codegen.print(); } -class Codegen { +export class Codegen { _schema: GraphQLSchema; _destination: string; _imports: ts.Statement[] = []; diff --git a/src/queryCodegen.ts b/src/queryCodegen.ts new file mode 100644 index 00000000..0df7c683 --- /dev/null +++ b/src/queryCodegen.ts @@ -0,0 +1,218 @@ +import { DocumentNode, GraphQLSchema } from "graphql"; +import * as ts from "typescript"; +import { Codegen } from "./codegen"; +import { extractUsedSchema } from "./usedSchema"; + +const F = ts.factory; + +// Given a GraphQL SDL, returns the a string of TypeScript code that generates a +// GraphQLSchema implementing that schema. +export function queryCodegen( + schema: GraphQLSchema, + doc: DocumentNode, + destination: string, +): string { + const usedSchema = extractUsedSchema(schema, doc); + const codegen = new Codegen(usedSchema, destination); + + codegen.schemaDeclarationExport(); + + // TODO: Rather than leak these implementation details, + // we could create an IR class for a TypeScript module which + // can be shared by both the schema and query codegen. + const queryCodegen = new QueryCodegen(); + queryCodegen._graphQLImports = codegen._graphQLImports; + queryCodegen._imports = codegen._imports; + queryCodegen._typeDefinitions = codegen._typeDefinitions; + queryCodegen._statements = codegen._statements; + queryCodegen._helpers = codegen._helpers; + + queryCodegen.gen(doc); + + return queryCodegen.print(); +} + +class QueryCodegen { + _imports: ts.Statement[] = []; + _typeDefinitions: Set = new Set(); + _graphQLImports: Set = new Set(); + _statements: ts.Statement[] = []; + _helpers: Map = new Map(); + + gen(doc: DocumentNode) { + // TODO: Should every query create its own schema instance? + this._statements.push( + F.createVariableStatement( + undefined, + F.createVariableDeclarationList( + [ + F.createVariableDeclaration( + "schema", + undefined, + undefined, + F.createCallExpression( + F.createIdentifier("getSchema"), + undefined, + [], + ), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + this._statements.push( + F.createVariableStatement( + undefined, + F.createVariableDeclarationList( + [ + F.createVariableDeclaration( + "doc", + undefined, + F.createTypeReferenceNode(this.graphQLImport("DocumentNode")), + F.createAsExpression( + jsonAbleToAst(doc), + F.createTypeReferenceNode(this.graphQLImport("DocumentNode")), + ), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + this._statements.push( + F.createFunctionDeclaration( + [F.createModifier(ts.SyntaxKind.ExportKeyword)], + undefined, + "executeOperation", + undefined, + [ + F.createParameterDeclaration( + undefined, + undefined, + "variables", + undefined, + F.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), + undefined, + ), + ], + undefined, + F.createBlock( + [ + F.createReturnStatement( + F.createCallExpression(this.graphQLImport("execute"), undefined, [ + F.createObjectLiteralExpression([ + F.createPropertyAssignment( + "schema", + F.createIdentifier("schema"), + ), + F.createPropertyAssignment( + "document", + F.createIdentifier("doc"), + ), + F.createPropertyAssignment( + "variableValues", + F.createIdentifier("variables"), + ), + ]), + ]), + ), + ], + true, + ), + ), + ); + // + } + + graphQLImport(name: string): ts.Identifier { + this._graphQLImports.add(name); + return F.createIdentifier(name); + } + + import(from: string, names: { name: string; as?: string }[]) { + const namedImports = names.map((name) => { + if (name.as) { + return F.createImportSpecifier( + false, + F.createIdentifier(name.name), + F.createIdentifier(name.as), + ); + } else { + return F.createImportSpecifier( + false, + undefined, + F.createIdentifier(name.name), + ); + } + }); + this._imports.push( + F.createImportDeclaration( + undefined, + F.createImportClause( + false, + undefined, + F.createNamedImports(namedImports), + ), + F.createStringLiteral(from), + ), + ); + } + + print(): string { + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + const sourceFile = ts.createSourceFile( + "tempFile.ts", + "", + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS, + ); + + this.import( + "graphql", + [...this._graphQLImports].map((name) => ({ name })), + ); + + return printer.printList( + ts.ListFormat.MultiLine, + F.createNodeArray([ + ...this._imports, + ...this._helpers.values(), + ...this._statements, + ]), + sourceFile, + ); + } +} + +function jsonAbleToAst(value: any): ts.Expression { + if (value === null) { + return F.createNull(); + } else if (value === undefined) { + return F.createIdentifier("undefined"); + } else if (typeof value === "string") { + return F.createStringLiteral(value); + } else if (typeof value === "number") { + return F.createNumericLiteral(value.toString()); + } else if (typeof value === "boolean") { + return value ? F.createTrue() : F.createFalse(); + } else if (Array.isArray(value)) { + return F.createArrayLiteralExpression( + value.map((v) => jsonAbleToAst(v)), + true, + ); + } else if (typeof value === "object") { + return F.createObjectLiteralExpression( + Object.entries(value).map(([key, value]) => + F.createPropertyAssignment( + F.createIdentifier(key), + jsonAbleToAst(value), + ), + ), + true, + ); + } else { + throw new Error(`Unexpected value: ${value}`); + } +} diff --git a/src/tests/persistFixtures/simpleQuery.ts b/src/tests/persistFixtures/simpleQuery.ts new file mode 100644 index 00000000..2b1feb5e --- /dev/null +++ b/src/tests/persistFixtures/simpleQuery.ts @@ -0,0 +1,13 @@ +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query): string { + return "Hello World!"; +} + +export const operation = /* GraphQL */ ` + query { + greeting + } +`; diff --git a/src/tests/persistFixtures/simpleQuery.ts.expected b/src/tests/persistFixtures/simpleQuery.ts.expected new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/test.ts b/src/tests/test.ts index fa2288c9..d3bc0dc3 100644 --- a/src/tests/test.ts +++ b/src/tests/test.ts @@ -9,6 +9,7 @@ import { buildASTSchema, graphql, GraphQLSchema, + parse, print, printSchema, specifiedScalarTypes, @@ -18,6 +19,7 @@ import { locate } from "../Locate"; import { gqlErr, ReportableDiagnostics } from "../utils/DiagnosticError"; import { writeFileSync } from "fs"; import { codegen } from "../codegen"; +import { queryCodegen } from "../queryCodegen"; import { diff } from "jest-diff"; import { METADATA_DIRECTIVE_NAMES } from "../metadataDirectives"; import * as semver from "semver"; @@ -70,6 +72,7 @@ program const gratsDir = path.join(__dirname, "../.."); const fixturesDir = path.join(__dirname, "fixtures"); const integrationFixturesDir = path.join(__dirname, "integrationFixtures"); +const persistFixturesDir = path.join(__dirname, "persistFixtures"); const testDirs = [ { @@ -236,6 +239,58 @@ const testDirs = [ return JSON.stringify(data, null, 2); }, }, + { + fixturesDir: persistFixturesDir, + testFilePattern: /\.ts$/, + transformer: async ( + code: string, + fileName: string, + ): Promise => { + const firstLine = code.split("\n")[0]; + let options: Partial = { + nullableByDefault: true, + }; + if (firstLine.startsWith("// {")) { + const json = firstLine.slice(3); + const testOptions = JSON.parse(json); + options = { ...options, ...testOptions }; + } + const filePath = `${persistFixturesDir}/${fileName}`; + + const files = [filePath, path.join(__dirname, `../Types.ts`)]; + const parsedOptions: ParsedCommandLineGrats = validateGratsOptions({ + options: { + // Required to enable ts-node to locate function exports + rootDir: gratsDir, + outDir: "dist", + configFilePath: "tsconfig.json", + }, + raw: { + grats: options, + }, + errors: [], + fileNames: files, + }); + const schemaResult = buildSchemaAndDocResult(parsedOptions); + if (schemaResult.kind === "ERROR") { + throw new Error(schemaResult.err.formatDiagnosticsWithContext()); + } + const mod = await import(filePath); + if (mod.operation == null) { + throw new Error( + `Expected \`${filePath}\` to export a operation text as \`operation\``, + ); + } + + const operationDocument = parse(mod.operation, { noLocation: true }); + + const { schema } = schemaResult.value; + + const tsQuery = queryCodegen(schema, operationDocument, "./"); + + return tsQuery; + }, + }, ]; // Returns null if the schemas are equal, otherwise returns a string diff. diff --git a/src/usedSchema.ts b/src/usedSchema.ts new file mode 100644 index 00000000..0cf6184d --- /dev/null +++ b/src/usedSchema.ts @@ -0,0 +1,32 @@ +import { + DocumentNode, + GraphQLSchema, + visitWithTypeInfo, + TypeInfo, + visit, + GraphQLNamedType, + Kind, + getNamedType, +} from "graphql"; + +export function extractUsedSchema( + schema: GraphQLSchema, + operation: DocumentNode, +): GraphQLSchema { + const types: GraphQLNamedType[] = []; + const typeInfo = new TypeInfo(schema); + + const visitor = { + [Kind.OPERATION_DEFINITION](t) { + const type = typeInfo.getType(); + if (type != null) { + types.push(getNamedType(type)); + } + }, + }; + + visit(operation, visitWithTypeInfo(typeInfo, visitor)); + return new GraphQLSchema({ + types, + }); +}