From 7850d2be2a37d44c5285e98f71a986c7f5dd44a9 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Tue, 2 Jan 2024 19:55:53 -0800 Subject: [PATCH 1/3] Explore persisting query --- examples/yoga/persisted/MyQuery.ts | 108 +++++++++++++++ examples/yoga/query.graphql | 12 ++ src/cli.ts | 47 ++++++- src/queryCodegen.ts | 207 +++++++++++++++++++++++++++++ 4 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 examples/yoga/persisted/MyQuery.ts create mode 100644 examples/yoga/query.graphql create mode 100644 src/queryCodegen.ts diff --git a/examples/yoga/persisted/MyQuery.ts b/examples/yoga/persisted/MyQuery.ts new file mode 100644 index 00000000..6499fe32 --- /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() { + return execute({ schema: schema, document: doc }); +} 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..626e4105 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(process.stdin.fd, "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/queryCodegen.ts b/src/queryCodegen.ts new file mode 100644 index 00000000..7b28c6dd --- /dev/null +++ b/src/queryCodegen.ts @@ -0,0 +1,207 @@ +import { DocumentNode } from "graphql"; +import * as ts from "typescript"; +import * as path from "path"; +import { ConfigOptions } from "./gratsConfig"; + +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( + gratsOptions: ConfigOptions, + configPath: string, + dest: string, + doc: DocumentNode, +): string { + const schemaLocation = path.resolve( + path.dirname(configPath), + gratsOptions.tsSchema, + ); + const codegen = new QueryCodegen(schemaLocation, dest); + + codegen.gen(doc); + + return codegen.print(); +} + +class QueryCodegen { + _schemaLocation: string; + _imports: ts.Statement[] = []; + _typeDefinitions: Set = new Set(); + _graphQLImports: Set = new Set(); + _statements: ts.Statement[] = []; + + constructor(schemaLocation: string, destination: string) { + this._schemaLocation = path.relative( + path.dirname(destination), + schemaLocation, + ); + } + + gen(doc: DocumentNode) { + this.import(makeImport(this._schemaLocation), [{ name: "getSchema" }]); + + // 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, + [], + 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"), + ), + ]), + ]), + ), + ], + 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._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}`); + } +} + +// add ./ and trim extension +function makeImport(path: string): string { + return `./${path.replace(/\.[^/.]+$/, "")}`; +} From 0b668947ee1bbacb7ae6b01481493883be5329e2 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Tue, 2 Jan 2024 20:48:33 -0800 Subject: [PATCH 2/3] Pass variables --- examples/yoga/persisted/MyQuery.ts | 4 ++-- src/queryCodegen.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/examples/yoga/persisted/MyQuery.ts b/examples/yoga/persisted/MyQuery.ts index 6499fe32..7bed8722 100644 --- a/examples/yoga/persisted/MyQuery.ts +++ b/examples/yoga/persisted/MyQuery.ts @@ -103,6 +103,6 @@ const doc: DocumentNode = { } ] } as DocumentNode; -export function executeOperation() { - return execute({ schema: schema, document: doc }); +export function executeOperation(variables: any) { + return execute({ schema: schema, document: doc, variableValues: variables }); } diff --git a/src/queryCodegen.ts b/src/queryCodegen.ts index 7b28c6dd..7f5ebc91 100644 --- a/src/queryCodegen.ts +++ b/src/queryCodegen.ts @@ -87,7 +87,16 @@ class QueryCodegen { undefined, "executeOperation", undefined, - [], + [ + F.createParameterDeclaration( + undefined, + undefined, + "variables", + undefined, + F.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), + undefined, + ), + ], undefined, F.createBlock( [ @@ -102,6 +111,10 @@ class QueryCodegen { "document", F.createIdentifier("doc"), ), + F.createPropertyAssignment( + "variableValues", + F.createIdentifier("variables"), + ), ]), ]), ), From b9fad47cb5ff08f1c4c480f40cbf547874b1195b Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Mon, 5 Feb 2024 15:08:58 -0800 Subject: [PATCH 3/3] Start exploring "used schema" --- src/cli.ts | 2 +- src/codegen.ts | 2 +- src/queryCodegen.ts | 56 +++++++++---------- src/tests/persistFixtures/simpleQuery.ts | 13 +++++ .../persistFixtures/simpleQuery.ts.expected | 0 src/tests/test.ts | 55 ++++++++++++++++++ src/usedSchema.ts | 32 +++++++++++ 7 files changed, 129 insertions(+), 31 deletions(-) create mode 100644 src/tests/persistFixtures/simpleQuery.ts create mode 100644 src/tests/persistFixtures/simpleQuery.ts.expected create mode 100644 src/usedSchema.ts diff --git a/src/cli.ts b/src/cli.ts index 626e4105..f9e370d0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -84,7 +84,7 @@ program } if (operationText === "-") { - operationText = fs.readFileSync(process.stdin.fd, "utf-8"); + operationText = fs.readFileSync(0, "utf-8"); } const doc = parse(operationText, { noLocation: true }); 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 index 7f5ebc91..0df7c683 100644 --- a/src/queryCodegen.ts +++ b/src/queryCodegen.ts @@ -1,46 +1,45 @@ -import { DocumentNode } from "graphql"; +import { DocumentNode, GraphQLSchema } from "graphql"; import * as ts from "typescript"; -import * as path from "path"; -import { ConfigOptions } from "./gratsConfig"; +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( - gratsOptions: ConfigOptions, - configPath: string, - dest: string, + schema: GraphQLSchema, doc: DocumentNode, + destination: string, ): string { - const schemaLocation = path.resolve( - path.dirname(configPath), - gratsOptions.tsSchema, - ); - const codegen = new QueryCodegen(schemaLocation, dest); + const usedSchema = extractUsedSchema(schema, doc); + const codegen = new Codegen(usedSchema, destination); - codegen.gen(doc); + codegen.schemaDeclarationExport(); - return codegen.print(); + // 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 { - _schemaLocation: string; _imports: ts.Statement[] = []; _typeDefinitions: Set = new Set(); _graphQLImports: Set = new Set(); _statements: ts.Statement[] = []; - - constructor(schemaLocation: string, destination: string) { - this._schemaLocation = path.relative( - path.dirname(destination), - schemaLocation, - ); - } + _helpers: Map = new Map(); gen(doc: DocumentNode) { - this.import(makeImport(this._schemaLocation), [{ name: "getSchema" }]); - // TODO: Should every query create its own schema instance? this._statements.push( F.createVariableStatement( @@ -177,7 +176,11 @@ class QueryCodegen { return printer.printList( ts.ListFormat.MultiLine, - F.createNodeArray([...this._imports, ...this._statements]), + F.createNodeArray([ + ...this._imports, + ...this._helpers.values(), + ...this._statements, + ]), sourceFile, ); } @@ -213,8 +216,3 @@ function jsonAbleToAst(value: any): ts.Expression { throw new Error(`Unexpected value: ${value}`); } } - -// add ./ and trim extension -function makeImport(path: string): string { - return `./${path.replace(/\.[^/.]+$/, "")}`; -} 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, + }); +}