diff --git a/src/cli.ts b/src/cli.ts index 1cbfd849..88575057 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import * as E from "./Errors"; -import { GraphQLNamedType, GraphQLObjectType, Location } from "graphql"; +import { GraphQLNamedType, GraphQLObjectType, Location, parse } from "graphql"; import { getParsedTsConfig } from "./"; import { SchemaAndDoc, @@ -10,7 +10,7 @@ import { } from "./lib"; import { Command } from "commander"; import { writeFileSync } from "fs"; -import { resolve, dirname } from "path"; +import { resolve, dirname, join } from "node:path"; import { version } from "../package.json"; import { locate } from "./Locate"; import { printGratsSDL, printExecutableSchema } from "./printSchema"; @@ -21,6 +21,9 @@ import { } from "./utils/DiagnosticError"; import { GratsConfig, ParsedCommandLineGrats } from "./gratsConfig"; import { err, ok, Result } from "./utils/Result"; +import * as fs from "fs"; +import { resolverMapCodegen } from "./codegen/resolverMapCodegen"; +import { filterMetadata, extractUsedFields } from "./constructUsedMetadata"; const program = new Command(); @@ -61,6 +64,59 @@ 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 } = handleDiagnostics(getTsConfig(tsconfig)); + + const schemaAndDocResult = buildSchemaAndDocResult(config); + if (schemaAndDocResult.kind === "ERROR") { + console.error( + schemaAndDocResult.err.formatDiagnosticsWithColorAndContext(), + ); + process.exit(1); + } + + const { schema, resolvers } = schemaAndDocResult.value; + + if (operationText === "-") { + operationText = fs.readFileSync(0, "utf-8"); + } + + // TODO: Turn parse errors into diagnostics. + const doc = parse(operationText, { noLocation: true }); + + if (doc.definitions.some((def) => def.kind !== "OperationDefinition")) { + // TODO: Diagnostics? + throw new Error("Expected all definitions to be operations."); + } + + const name = "placeholder_name"; + + const destDir = resolve(dirname(configPath), `./persisted`); + const dest = join(destDir, `${name}.ts`); + + const usedResolverMap = extractUsedFields(schema, doc); + + const newResolverMap = filterMetadata(usedResolverMap, resolvers); + + const result = resolverMapCodegen( + schema, + newResolverMap, + config.raw.grats, + dest, + ); + + fs.mkdirSync(destDir, { recursive: true }); + writeFileSync(dest, result); + console.error(`Grats: Wrote TypeScript operation to \`${dest}\`.`); + }); + program.parse(); /** diff --git a/src/constructUsedMetadata.ts b/src/constructUsedMetadata.ts new file mode 100644 index 00000000..1c262353 --- /dev/null +++ b/src/constructUsedMetadata.ts @@ -0,0 +1,89 @@ +import { Metadata } from "./metadata"; + +import { + DocumentNode, + GraphQLSchema, + visitWithTypeInfo, + TypeInfo, + visit, + Kind, + getNamedType, + isAbstractType, + isObjectType, + FieldNode, +} from "graphql"; + +// Map of used concrete typenames to a set of used fields on that type. +export type UsedFields = Map>; + +// Given a GraphQL schema and a document (which may contain multiple +// operations), produce a `UsedFields` map representing the concrete fields used +// in the document. Useful for building sub-schema resolver maps scoped to a set +// of operations, such as all the queries included in a JS bundle. +export function extractUsedFields( + schema: GraphQLSchema, + operation: DocumentNode, +): UsedFields { + const usedSchemaMap: UsedFields = new Map(); + const typeInfo = new TypeInfo(schema); + + function addField(typeName: string, fieldName: string) { + let fieldSet = usedSchemaMap.get(typeName); + if (fieldSet == null) { + fieldSet = new Set(); + usedSchemaMap.set(typeName, fieldSet); + } + fieldSet.add(fieldName); + } + + const visitor = { + [Kind.FIELD](field: FieldNode) { + const type = getNamedType(typeInfo.getParentType()); + if (isObjectType(type)) { + addField(type.name, field.name.value); + } else if (isAbstractType(type)) { + const possibleTypes = schema.getPossibleTypes(type); + for (const possibleType of possibleTypes) { + addField(possibleType.name, field.name.value); + } + } + }, + }; + + visit(operation, visitWithTypeInfo(typeInfo, visitor)); + return usedSchemaMap; +} + +/** + * Given a set of used fields, filter a metadata object to only include + * information about the used fields. + * + * Useful for constructing a Metadata object representing a subset of the graph, + * such as: + * + * - A single query or mutation operation + * - All the queries/mutations used in a given JS bundle + */ +export function filterMetadata( + usedFields: UsedFields, + metadata: Metadata, +): Metadata { + const newMetadata: Metadata = { types: {} }; + + for (const [typeName, fields] of Object.entries(metadata.types)) { + const usedFieldsForType = usedFields.get(typeName); + if (!usedFieldsForType) { + continue; + } + const newFields = {}; + for (const [fieldName, field] of Object.entries(fields)) { + if (usedFieldsForType.has(fieldName)) { + newFields[fieldName] = field; + } + } + if (Object.keys(newFields).length > 0) { + newMetadata.types[typeName] = newFields; + } + } + return newMetadata; +} diff --git a/src/tests/presentence/interface.ts b/src/tests/presentence/interface.ts new file mode 100644 index 00000000..7cd80606 --- /dev/null +++ b/src/tests/presentence/interface.ts @@ -0,0 +1,39 @@ +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function viewer(_: Query): Person { + return new User(); +} + +/** @gqlInterface */ +interface Person { + /** @gqlField */ + name(foo: string | null): string; +} + +/** @gqlType */ +export class User implements Person { + __typename = "User" as const; + /** @gqlField */ + name(foo: string | null): string { + return "Alice"; + } +} + +/** @gqlType */ +export class Admin implements Person { + __typename = "Admin" as const; + /** @gqlField */ + name(foo: string | null): string { + return "Alice"; + } +} + +export const operation = ` +query { + greeting + viewer { + name + } +}`; diff --git a/src/tests/presentence/interface.ts.expected b/src/tests/presentence/interface.ts.expected new file mode 100644 index 00000000..bcf1577b --- /dev/null +++ b/src/tests/presentence/interface.ts.expected @@ -0,0 +1,67 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function viewer(_: Query): Person { + return new User(); +} + +/** @gqlInterface */ +interface Person { + /** @gqlField */ + name(foo: string | null): string; +} + +/** @gqlType */ +export class User implements Person { + __typename = "User" as const; + /** @gqlField */ + name(foo: string | null): string { + return "Alice"; + } +} + +/** @gqlType */ +export class Admin implements Person { + __typename = "Admin" as const; + /** @gqlField */ + name(foo: string | null): string { + return "Alice"; + } +} + +export const operation = ` +query { + greeting + viewer { + name + } +}`; + +----------------- +OUTPUT +----------------- +import { IResolvers } from "@graphql-tools/utils"; +import { viewer as queryViewerResolver } from "./src/tests/presentence/interface"; +export function getResolverMap(): IResolvers { + return { + Admin: { + name(source, args) { + return source.name(args.foo); + } + }, + Query: { + viewer(source) { + return queryViewerResolver(source); + } + }, + User: { + name(source, args) { + return source.name(args.foo); + } + } + }; +} diff --git a/src/tests/presentence/simple.ts b/src/tests/presentence/simple.ts new file mode 100644 index 00000000..c1506ab2 --- /dev/null +++ b/src/tests/presentence/simple.ts @@ -0,0 +1,12 @@ +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query): string { + return "Hello, world!"; +} + +export const operation = ` +query { + greeting +}`; diff --git a/src/tests/presentence/simple.ts.expected b/src/tests/presentence/simple.ts.expected new file mode 100644 index 00000000..26302a1b --- /dev/null +++ b/src/tests/presentence/simple.ts.expected @@ -0,0 +1,30 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query): string { + return "Hello, world!"; +} + +export const operation = ` +query { + greeting +}`; + +----------------- +OUTPUT +----------------- +import { IResolvers } from "@graphql-tools/utils"; +import { greeting as queryGreetingResolver } from "./src/tests/presentence/simple"; +export function getResolverMap(): IResolvers { + return { + Query: { + greeting(source) { + return queryGreetingResolver(source); + } + } + }; +} diff --git a/src/tests/test.ts b/src/tests/test.ts index cb762eed..da5a66ea 100644 --- a/src/tests/test.ts +++ b/src/tests/test.ts @@ -9,6 +9,7 @@ import { buildASTSchema, graphql, GraphQLSchema, + parse, print, printSchema, specifiedScalarTypes, @@ -28,6 +29,8 @@ import { import { SEMANTIC_NON_NULL_DIRECTIVE } from "../publicDirectives"; import { applySDLHeader, applyTypeScriptHeader } from "../printSchema"; import { extend } from "../utils/helpers"; +import { filterMetadata, extractUsedFields } from "../constructUsedMetadata"; +import { resolverMapCodegen } from "../codegen/resolverMapCodegen"; const TS_VERSION = ts.version; @@ -59,7 +62,7 @@ program !!write, filterRegex, testFilePattern, - ignoreFilePattern, + ignoreFilePattern ?? null, transformer, ); failures = !(await runner.run({ interactive })) || failures; @@ -72,6 +75,7 @@ program const gratsDir = path.join(__dirname, "../.."); const fixturesDir = path.join(__dirname, "fixtures"); const integrationFixturesDir = path.join(__dirname, "integrationFixtures"); +const persistFixturesDir = path.join(__dirname, "presentence"); const testDirs = [ { @@ -257,6 +261,63 @@ 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 config: Partial = { + nullableByDefault: true, + }; + if (firstLine.startsWith("// {")) { + const json = firstLine.slice(3); + const testOptions = JSON.parse(json); + config = { ...config, ...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: config }, + errors: [], + fileNames: files, + }); + const schemaResult = buildSchemaAndDocResult(parsedOptions); + if (schemaResult.kind === "ERROR") { + throw new Error(schemaResult.err.formatDiagnosticsWithContext()); + } + const { schema, resolvers } = schemaResult.value; + 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 usedFields = extractUsedFields(schema, operationDocument); + + const newResolverMap = filterMetadata(usedFields, resolvers); + + return resolverMapCodegen( + schema, + newResolverMap, + parsedOptions.raw.grats, + "./foo.ts", + ); + }, + }, ]; // Returns null if the schemas are equal, otherwise returns a string diff.