diff --git a/src/formatDTS.ts b/src/formatDTS.ts index 3adf0ca..a8863d1 100644 --- a/src/formatDTS.ts +++ b/src/formatDTS.ts @@ -1,15 +1,16 @@ // https://prettier.io/docs/en/api.html let hasPrettierInstalled = false +let prettier = null try { hasPrettierInstalled = !!require.resolve("prettier") + prettier = require("prettier") } catch (error) {} export const formatDTS = async (path: string, content: string): Promise => { if (!hasPrettierInstalled) return content try { - const prettier = await import("prettier") if (!prettier) return content return prettier.format(content, { filepath: path }) } catch (error) { diff --git a/src/index.ts b/src/index.ts index 4d618f4..ae08066 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,8 @@ export * from "./types.js" import { basename, join } from "node:path" +import { makeStep } from "./utils.js" + export interface SDLCodeGenReturn { // Optional way to start up a watcher mode for the codegen createWatcher: () => { fileChanged: (path: string) => Promise } @@ -22,7 +24,10 @@ export interface SDLCodeGenReturn { } /** The API specifically for the Redwood preset */ -export async function runFullCodegen(preset: "redwood", config: { paths: RedwoodPaths; verbose?: true }): Promise +export async function runFullCodegen( + preset: "redwood", + config: { paths: RedwoodPaths; sys?: typescript.System; verbose?: true } +): Promise export async function runFullCodegen(preset: string, config: unknown): Promise @@ -84,15 +89,15 @@ export async function runFullCodegen(preset: string, config: unknown): Promise { - const sharedDTSes = await createSharedSchemaFiles(appContext) + const sharedDTSes = await createSharedSchemaFiles(appContext, verbose) filepaths.push(...sharedDTSes) }) let knownServiceFiles: string[] = [] const createDTSFilesForAllServices = async () => { - // TODO: Maybe Redwood has an API for this? Its grabbing all the services const serviceFiles = appContext.sys.readDirectory(appContext.pathSettings.apiServicesPath) knownServiceFiles = serviceFiles.filter(isRedwoodServiceFile) + for (const path of knownServiceFiles) { const dts = await lookAtServiceFile(path, appContext) if (dts) filepaths.push(dts) @@ -118,7 +123,7 @@ export async function runFullCodegen(preset: string, config: unknown): Promise getGraphQLSDLFromFile(appContext.pathSettings)) - await step("Create all shared schema files", () => createSharedSchemaFiles(appContext)) + await step("Create all shared schema files", () => createSharedSchemaFiles(appContext, verbose)) await step("Create all service files", createDTSFilesForAllServices) } else if (path === appContext.pathSettings.prismaDSLPath) { await step("Prisma schema changed", () => getPrismaSchemaFromFile(appContext.pathSettings)) @@ -149,11 +154,3 @@ const isRedwoodServiceFile = (file: string) => { if (file.endsWith("scenarios.ts") || file.endsWith("scenarios.js")) return false return file.endsWith(".ts") || file.endsWith(".tsx") || file.endsWith(".js") } - -const makeStep = (verbose: boolean) => async (msg: string, fn: () => Promise | Promise | void) => { - if (!verbose) return fn() - console.log("[sdl-codegen] " + msg) - console.time("[sdl-codegen] " + msg) - await fn() - console.timeEnd("[sdl-codegen] " + msg) -} diff --git a/src/serviceFile.ts b/src/serviceFile.ts index f3718ae..44958c2 100644 --- a/src/serviceFile.ts +++ b/src/serviceFile.ts @@ -28,7 +28,6 @@ export const lookAtServiceFile = async (file: string, context: AppContext) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const mutationType = gql.getMutationType()! - if (!mutationType) throw new Error("No mutation type") const externalMapper = typeMapper(context, { preferPrismaModels: true }) @@ -201,6 +200,8 @@ export const lookAtServiceFile = async (file: string, context: AppContext) => { ], returnType, }) + + interfaceDeclaration.forget() } /** Ideally, we want to be able to write the type for just the object */ diff --git a/src/sharedSchema.ts b/src/sharedSchema.ts index 1821a5a..da4e649 100644 --- a/src/sharedSchema.ts +++ b/src/sharedSchema.ts @@ -5,11 +5,19 @@ import * as tsMorph from "ts-morph" import { AppContext } from "./context.js" import { formatDTS } from "./formatDTS.js" +import { createSharedExternalSchemaFileViaStructure } from "./sharedSchemaStructures.js" +import { createSharedExternalSchemaFileViaTSC } from "./sharedSchemaTSC.js" import { typeMapper } from "./typeMap.js" +import { makeStep } from "./utils.js" -export const createSharedSchemaFiles = async (context: AppContext) => { - await createSharedExternalSchemaFile(context) - await createSharedReturnPositionSchemaFile(context) +export const createSharedSchemaFiles = async (context: AppContext, verbose: boolean) => { + const step = makeStep(verbose) + + await step("Creating shared schema files", () => createSharedExternalSchemaFile(context)) + await step("Creating shared schema files via tsc", () => createSharedExternalSchemaFileViaTSC(context)) + await step("Creating shared schema files via structure", () => createSharedExternalSchemaFileViaStructure(context)) + + await step("Creating shared return position schema files", () => createSharedReturnPositionSchemaFile(context)) return [ context.join(context.pathSettings.typesFolderRoot, context.pathSettings.sharedFilename), @@ -17,7 +25,7 @@ export const createSharedSchemaFiles = async (context: AppContext) => { ] } -async function createSharedExternalSchemaFile(context: AppContext) { +function createSharedExternalSchemaFile(context: AppContext) { const gql = context.gql const types = gql.getTypeMap() const knownPrimitives = ["String", "Boolean", "Int"] @@ -27,6 +35,9 @@ async function createSharedExternalSchemaFile(context: AppContext) { const externalTSFile = context.tsProject.createSourceFile(`/source/${context.pathSettings.sharedFilename}`, "") + const interfaces = [] as tsMorph.InterfaceDeclarationStructure[] + const typeAliases = [] as tsMorph.TypeAliasDeclarationStructure[] + Object.keys(types).forEach((name) => { if (name.startsWith("__")) { return @@ -50,7 +61,8 @@ async function createSharedExternalSchemaFile(context: AppContext) { docs.push(type.description) } - externalTSFile.addInterface({ + interfaces.push({ + kind: tsMorph.StructureKind.Interface, name: type.name, isExported: true, docs: [], @@ -87,9 +99,10 @@ async function createSharedExternalSchemaFile(context: AppContext) { } if (graphql.isEnumType(type)) { - externalTSFile.addTypeAlias({ + typeAliases.push({ name: type.name, isExported: true, + kind: tsMorph.StructureKind.TypeAlias, type: '"' + type @@ -101,9 +114,10 @@ async function createSharedExternalSchemaFile(context: AppContext) { } if (graphql.isUnionType(type)) { - externalTSFile.addTypeAlias({ + typeAliases.push({ name: type.name, isExported: true, + kind: tsMorph.StructureKind.TypeAlias, type: type .getTypes() .map((m) => m.name) @@ -112,21 +126,22 @@ async function createSharedExternalSchemaFile(context: AppContext) { } }) + context.tsProject.forgetNodesCreatedInBlock(() => { + externalTSFile.addInterfaces(interfaces) + }) + + context.tsProject.forgetNodesCreatedInBlock(() => { + externalTSFile.addTypeAliases(typeAliases) + }) + const { scalars } = mapper.getReferencedGraphQLThingsInMapping() - if (scalars.length) { - externalTSFile.addTypeAliases( - scalars.map((s) => ({ - name: s, - type: "any", - })) - ) - } + if (scalars.length) externalTSFile.addTypeAliases(scalars.map((s) => ({ name: s, type: "any" }))) const fullPath = context.join(context.pathSettings.typesFolderRoot, context.pathSettings.sharedFilename) - const formatted = await formatDTS(fullPath, externalTSFile.getText()) + const text = externalTSFile.getText() const prior = context.sys.readFile(fullPath) - if (prior !== formatted) context.sys.writeFile(fullPath, formatted) + if (prior !== text) context.sys.writeFile(fullPath, text) } async function createSharedReturnPositionSchemaFile(context: AppContext) { @@ -153,6 +168,9 @@ async function createSharedReturnPositionSchemaFile(context: AppContext) { ` ) + const interfaces = [] as tsMorph.InterfaceDeclarationStructure[] + const typeAliases = [] as tsMorph.TypeAliasDeclarationStructure[] + Object.keys(types).forEach((name) => { if (name.startsWith("__")) { return @@ -173,8 +191,9 @@ async function createSharedReturnPositionSchemaFile(context: AppContext) { return } - externalTSFile.addInterface({ + interfaces.push({ name: type.name, + kind: tsMorph.StructureKind.Interface, isExported: true, docs: [], properties: [ @@ -200,9 +219,10 @@ async function createSharedReturnPositionSchemaFile(context: AppContext) { } if (graphql.isEnumType(type)) { - externalTSFile.addTypeAlias({ + typeAliases.push({ name: type.name, isExported: true, + kind: tsMorph.StructureKind.TypeAlias, type: '"' + type @@ -214,8 +234,9 @@ async function createSharedReturnPositionSchemaFile(context: AppContext) { } if (graphql.isUnionType(type)) { - externalTSFile.addTypeAlias({ + typeAliases.push({ name: type.name, + kind: tsMorph.StructureKind.TypeAlias, isExported: true, type: type .getTypes() @@ -243,15 +264,18 @@ async function createSharedReturnPositionSchemaFile(context: AppContext) { namedImports: allPrismaModels.map((p) => `${p} as P${p}`), }) - allPrismaModels.forEach((p) => { - externalTSFile.addTypeAlias({ + externalTSFile.addTypeAliases( + allPrismaModels.map((p) => ({ isExported: true, name: p, type: `P${p}`, - }) - }) + })) + ) } + externalTSFile.addInterfaces(interfaces) + externalTSFile.addTypeAliases(typeAliases) + const fullPath = context.join(context.pathSettings.typesFolderRoot, context.pathSettings.sharedInternalFilename) const formatted = await formatDTS(fullPath, externalTSFile.getText()) diff --git a/src/sharedSchemaStructures.ts b/src/sharedSchemaStructures.ts new file mode 100644 index 0000000..74fcf67 --- /dev/null +++ b/src/sharedSchemaStructures.ts @@ -0,0 +1,154 @@ +/// The main schema for objects and inputs + +import * as graphql from "graphql" +import * as tsMorph from "ts-morph" + +import { AppContext } from "./context.js" +import { typeMapper } from "./typeMap.js" + +export function createSharedExternalSchemaFileViaStructure(context: AppContext) { + const { gql, prisma, fieldFacts } = context + const types = gql.getTypeMap() + const mapper = typeMapper(context, { preferPrismaModels: true }) + + const typesToImport = [] as string[] + const knownPrimitives = ["String", "Boolean", "Int"] + + const externalTSFile = context.tsProject.createSourceFile( + `/source/a/${context.pathSettings.sharedInternalFilename}`, + ` +// You may very reasonably ask yourself, 'what is this file?' and why do I need it. + +// Roughly, this file ensures that when a resolver wants to return a type - that +// type will match a prisma model. This is useful because you can trivially extend +// the type in the SDL and not have to worry about type mis-matches because the thing +// you returned does not include those functions. + +// This gets particularly valuable when you want to return a union type, an interface, +// or a model where the prisma model is nested pretty deeply (GraphQL connections, for example.) + +` + ) + + const statements = [] as tsMorph.StatementStructures[] + + Object.keys(types).forEach((name) => { + if (name.startsWith("__")) { + return + } + + if (knownPrimitives.includes(name)) { + return + } + + const type = types[name] + const pType = prisma.get(name) + + if (graphql.isObjectType(type) || graphql.isInterfaceType(type) || graphql.isInputObjectType(type)) { + // Return straight away if we have a matching type in the prisma schema + // as we dont need it + if (pType) { + typesToImport.push(name) + return + } + + statements.push({ + name: type.name, + kind: tsMorph.StructureKind.Interface, + isExported: true, + docs: [], + properties: [ + { + name: "__typename", + type: `"${type.name}"`, + hasQuestionToken: true, + }, + ...Object.entries(type.getFields()).map(([fieldName, obj]: [string, graphql.GraphQLField]) => { + const hasResolverImplementation = fieldFacts.get(name)?.[fieldName]?.hasResolverImplementation + const isOptionalInSDL = !graphql.isNonNullType(obj.type) + const doesNotExistInPrisma = false // !prismaField; + + const field: tsMorph.OptionalKind = { + name: fieldName, + type: mapper.map(obj.type, { preferNullOverUndefined: true }), + hasQuestionToken: hasResolverImplementation ?? (isOptionalInSDL || doesNotExistInPrisma), + } + return field + }), + ], + }) + } + + if (graphql.isEnumType(type)) { + statements.push({ + name: type.name, + isExported: true, + kind: tsMorph.StructureKind.TypeAlias, + type: + '"' + + type + .getValues() + .map((m) => (m as { value: string }).value) + .join('" | "') + + '"', + }) + } + + if (graphql.isUnionType(type)) { + statements.push({ + name: type.name, + kind: tsMorph.StructureKind.TypeAlias, + isExported: true, + type: type + .getTypes() + .map((m) => m.name) + .join(" | "), + }) + } + }) + + const { scalars, prisma: prismaModels } = mapper.getReferencedGraphQLThingsInMapping() + if (scalars.length) { + statements.push( + ...scalars.map( + (s) => + ({ + kind: tsMorph.StructureKind.TypeAlias, + name: s, + type: "any", + } as const) + ) + ) + } + + const allPrismaModels = [...new Set([...prismaModels, ...typesToImport])].sort() + if (allPrismaModels.length) { + statements.push({ + kind: tsMorph.StructureKind.ImportDeclaration, + moduleSpecifier: `@prisma/client`, + namedImports: allPrismaModels.map((p) => `${p} as P${p}`), + }) + + statements.push( + ...allPrismaModels.map( + (p) => + ({ + kind: tsMorph.StructureKind.TypeAlias, + name: p, + type: `P${p}`, + } as const) + ) + ) + } + + const fullPath = context.join(context.pathSettings.typesFolderRoot, context.pathSettings.sharedInternalFilename) + externalTSFile.set({ statements }) + const text = externalTSFile.getText() + + // console.log(sourceFileStructure.statements) + // console.log(text) + // const formatted = await formatDTS(fullPath, externalTSFile) + + const prior = context.sys.readFile(fullPath) + if (prior !== text) context.sys.writeFile(fullPath, text) +} diff --git a/src/sharedSchemaTSC.ts b/src/sharedSchemaTSC.ts new file mode 100644 index 0000000..e175b40 --- /dev/null +++ b/src/sharedSchemaTSC.ts @@ -0,0 +1,134 @@ +/// The main schema for objects and inputs + +import * as graphql from "graphql" +import ts from "typescript" + +import { AppContext } from "./context.js" +import { formatDTS } from "./formatDTS.js" +import { typeMapper } from "./typeMap.js" + +export function createSharedExternalSchemaFileViaTSC(context: AppContext) { + const gql = context.gql + const types = gql.getTypeMap() + const knownPrimitives = ["String", "Boolean", "Int"] + + const { prisma, fieldFacts } = context + const mapper = typeMapper(context, {}) + + const statements = [] as ts.Statement[] + + console.time("") + + Object.keys(types).forEach((name) => { + if (name.startsWith("__")) { + return + } + + if (knownPrimitives.includes(name)) { + return + } + + const type = types[name] + const pType = prisma.get(name) + + if (graphql.isObjectType(type) || graphql.isInterfaceType(type) || graphql.isInputObjectType(type)) { + // This is slower than it could be, use the add many at once api + const docs = [] + if (pType?.leadingComments) { + docs.push(pType.leadingComments) + } + + if (type.description) { + docs.push(type.description) + } + + const properties = [ + ts.factory.createPropertySignature( + undefined, + "__typename", + ts.factory.createToken(ts.SyntaxKind.QuestionToken), + ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(type.name)) + ), + ] + + Object.entries(type.getFields()).forEach(([fieldName, obj]: [string, graphql.GraphQLField]) => { + const docs = [] + const prismaField = pType?.properties.get(fieldName) + const type = obj.type as graphql.GraphQLType + + if (prismaField?.leadingComments.length) { + docs.push(prismaField.leadingComments.trim()) + } + + // if (obj.description) docs.push(obj.description); + const hasResolverImplementation = fieldFacts.get(name)?.[fieldName]?.hasResolverImplementation + const isOptionalInSDL = !graphql.isNonNullType(type) + const doesNotExistInPrisma = false // !prismaField; + + const hasQuestionToken = hasResolverImplementation ?? (isOptionalInSDL || doesNotExistInPrisma) + const mappedType = mapper.map(type, { preferNullOverUndefined: true }) + if (mappedType) { + properties.push( + ts.factory.createPropertySignature( + undefined, + fieldName, + hasQuestionToken ? ts.factory.createToken(ts.SyntaxKind.QuestionToken) : undefined, + ts.factory.createTypeReferenceNode(mappedType) + ) + ) + } + }) + + const interfaceD = ts.factory.createInterfaceDeclaration( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + ts.factory.createIdentifier(name), + undefined, + undefined, + properties + ) + + statements.push(interfaceD) + } + + if (graphql.isEnumType(type)) { + const values = type.getValues().map((m) => (m as { value: string }).value) + const typeKind = `"${values.join('" | "')}"` + + statements.push(ts.factory.createTypeAliasDeclaration(undefined, type.name, [], ts.factory.createTypeReferenceNode(typeKind))) + } + + if (graphql.isUnionType(type)) { + const types = type.getTypes().map((t) => t.name) + const typeKind = types.join(" | ") + statements.push( + ts.factory.createTypeAliasDeclaration( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + type.name, + [], + ts.factory.createTypeReferenceNode(typeKind) + ) + ) + } + }) + + const { scalars } = mapper.getReferencedGraphQLThingsInMapping() + if (scalars.length) { + statements.push( + ts.factory.createTypeAliasDeclaration( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + "Scalars", + [], + ts.factory.createTypeReferenceNode(`{ ${scalars.join(", ")} }`) + ) + ) + } + + const sourceFile = ts.factory.createSourceFile(statements, ts.factory.createToken(ts.SyntaxKind.EndOfFileToken), ts.NodeFlags.None) + const printer = ts.createPrinter({}) + const result = printer.printNode(ts.EmitHint.Unspecified, sourceFile, sourceFile) + + const fullPath = context.join(context.pathSettings.typesFolderRoot, context.pathSettings.sharedFilename) + + const prior = context.sys.readFile(fullPath) + if (prior !== result) context.sys.writeFile(fullPath, result) +} diff --git a/src/tests/features/generatesTypesForUnions.test.ts b/src/tests/features/generatesTypesForUnions.test.ts index 6125ea5..a6845be 100644 --- a/src/tests/features/generatesTypesForUnions.test.ts +++ b/src/tests/features/generatesTypesForUnions.test.ts @@ -39,26 +39,22 @@ export const Game = { const dts = vfsMap.get("/types/shared-schema-types.d.ts")! expect(dts.trim()).toMatchInlineSnapshot(` "export interface Game { - __typename?: \\"Game\\"; - id?: number; + __typename?: \\"Game\\"; + id?: number; } - export interface Puzzle { - __typename?: \\"Puzzle\\"; - id: number; + __typename?: \\"Puzzle\\"; + id: number; } - export type Gameish = Game | Puzzle; - export interface Query { - __typename?: \\"Query\\"; - gameObj?: Game | null | Puzzle | null | null; - gameArr: (Game | Puzzle)[]; + __typename?: \\"Query\\"; + gameObj?: Game| null | Puzzle| null| null; + gameArr: (Game | Puzzle)[]; } - export interface Mutation { - __typename?: \\"Mutation\\"; - __?: string | null; + __typename?: \\"Mutation\\"; + __?: string| null; }" `) }) diff --git a/src/tests/features/supportRefferingToEnumsOnlyInSDL.test.ts b/src/tests/features/supportRefferingToEnumsOnlyInSDL.test.ts index 54dcdb9..2796bb9 100644 --- a/src/tests/features/supportRefferingToEnumsOnlyInSDL.test.ts +++ b/src/tests/features/supportRefferingToEnumsOnlyInSDL.test.ts @@ -66,21 +66,18 @@ export const Game: GameResolvers = {}; expect(vfsMap.get("/types/shared-schema-types.d.ts"))!.toMatchInlineSnapshot(` "export interface Game { - __typename?: \\"Game\\"; - id: number; - games: Game[]; + __typename?: \\"Game\\"; + id: number; + games: Game[]; } - export interface Query { - __typename?: \\"Query\\"; - allGames: Game[]; + __typename?: \\"Query\\"; + allGames: Game[]; } - - export type GameType = \\"FOOTBALL\\" | \\"BASKETBALL\\"; - + type GameType = \\"FOOTBALL\\" | \\"BASKETBALL\\"; export interface Mutation { - __typename?: \\"Mutation\\"; - __?: string | null; + __typename?: \\"Mutation\\"; + __?: string| null; } " `) diff --git a/src/tests/integration.puzzmo.test.ts b/src/tests/integration.puzzmo.test.ts new file mode 100644 index 0000000..5a4a71a --- /dev/null +++ b/src/tests/integration.puzzmo.test.ts @@ -0,0 +1,44 @@ +import { existsSync } from "node:fs" +import { join, resolve } from "node:path" + +import { createSystem } from "@typescript/vfs" +import { describe, expect, it } from "vitest" + +import { runFullCodegen } from "../index" + +it("Passes", () => expect(true).toBe(true)) + +const hasAccessToPuzzmo = existsSync("../app/package.json") +const desc = hasAccessToPuzzmo ? describe : describe.skip + +desc("Puzzmo", () => { + it("Runs the entire puzzmo codebase fast", async () => { + const puzzmoAPIWD = resolve(process.cwd() + "/..../../../app/apps/api.puzzmo.com") + const vfsMap = new Map() + const vfs = createSystem(vfsMap) + + // Replicates a Redwood project config object + const paths = { + base: puzzmoAPIWD, + api: { + base: puzzmoAPIWD, + config: "-", + dbSchema: join(puzzmoAPIWD, "prisma", "schema.prisma"), + directives: join(puzzmoAPIWD, "src", "directives"), + graphql: join(puzzmoAPIWD, "src", "functions", "graphql.ts"), + lib: join(puzzmoAPIWD, "src", "lib"), + models: "-", + services: join(puzzmoAPIWD, "src", "services"), + src: join(puzzmoAPIWD, "src"), + types: join(puzzmoAPIWD, "types"), + }, + generated: { + schema: join(puzzmoAPIWD, "..", "..", "api-schema.graphql"), + }, + web: {}, + scripts: "-", + } + + await runFullCodegen("redwood", { paths, verbose: true, sys: vfs }) + }) +}) diff --git a/src/utils.ts b/src/utils.ts index cff11aa..695d0d1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -56,3 +56,11 @@ export const createAndReferOrInlineArgsForField = ( return `${config.name}Args` } + +export const makeStep = (verbose: boolean) => async (msg: string, fn: () => Promise | Promise | void) => { + if (!verbose) return fn() + console.log("[sdl-codegen] " + msg) + console.time("[sdl-codegen] " + msg) + await fn() + console.timeEnd("[sdl-codegen] " + msg) +}