diff --git a/src/serviceFile2.ts b/src/serviceFile2.ts new file mode 100644 index 0000000..355abaa --- /dev/null +++ b/src/serviceFile2.ts @@ -0,0 +1,311 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ + +import t, { tsTypeAnnotation } from "@babel/types" +import * as graphql from "graphql" + +import { AppContext } from "./context.js" +import { getCodeFactsForJSTSFileAtPath } from "./serviceFile.codefacts.js" +import { builder } from "./tsBuilder.js" +import { CodeFacts, ModelResolverFacts, ResolverFuncFact } from "./typeFacts.js" +import { TypeMapper, typeMapper } from "./typeMap.js" +import { capitalizeFirstLetter, createAndReferOrInlineArgsForField, inlineArgsForField } from "./utils.js" + +export const lookAtServiceFile = async (file: string, context: AppContext) => { + const { gql, prisma, pathSettings: settings, codeFacts: serviceFacts, fieldFacts } = context + + if (!gql) throw new Error(`No schema when wanting to look at service file: ${file}`) + if (!prisma) throw new Error(`No prisma schema when wanting to look at service file: ${file}`) + + // This isn't good enough, needs to be relative to api/src/services + const fileKey = file.replace(settings.apiServicesPath, "") + + const thisFact: CodeFacts = {} + + const filename = context.basename(file) + const dts = builder("", {}) + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const queryType = gql.getQueryType()! + if (!queryType) throw new Error("No query type") + + // 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 }) + const returnTypeMapper = typeMapper(context, {}) + + // The description of the source file + const fileFacts = getCodeFactsForJSTSFileAtPath(file, context) + if (Object.keys(fileFacts).length === 0) return + + // Tracks prospective prisma models which are used in the file + const extraPrismaReferences = new Set() + const extraSharedFileImportReferences = new Set<{ import: string; name?: string }>() + + // Basically if a top level resolver reference Query or Mutation + const knownSpecialCasesForGraphQL = new Set() + + // Add the root function declarations + const rootResolvers = fileFacts.maybe_query_mutation?.resolvers + if (rootResolvers) + rootResolvers.forEach((v) => { + const isQuery = v.name in queryType.getFields() + const isMutation = v.name in mutationType.getFields() + const parentName = isQuery ? queryType.name : isMutation ? mutationType.name : undefined + if (parentName) { + addDefinitionsForTopLevelResolvers(parentName, v) + } else { + // Add warning about unused resolver + dts.rootScope.addInterface(v.name, [], { exported: true, docs: "This resolver does not exist on Query or Mutation" }) + } + }) + + // Add the root function declarations + Object.values(fileFacts).forEach((model) => { + if (!model) return + const skip = ["maybe_query_mutation", queryType.name, mutationType.name] + if (skip.includes(model.typeName)) return + + addCustomTypeModel(model) + }) + + // Set up the module imports at the top + const sharedGraphQLObjectsReferenced = externalMapper.getReferencedGraphQLThingsInMapping() + const sharedGraphQLObjectsReferencedTypes = [...sharedGraphQLObjectsReferenced.types, ...knownSpecialCasesForGraphQL] + const sharedInternalGraphQLObjectsReferenced = returnTypeMapper.getReferencedGraphQLThingsInMapping() + + const aliases = [...new Set([...sharedGraphQLObjectsReferenced.scalars, ...sharedInternalGraphQLObjectsReferenced.scalars])] + + for (const alias of aliases) { + dts.rootScope.addTypeAlias(alias, "any") + } + + const prismases = [ + ...new Set([ + ...sharedGraphQLObjectsReferenced.prisma, + ...sharedInternalGraphQLObjectsReferenced.prisma, + ...extraPrismaReferences.values(), + ]), + ] + + const validPrismaObjs = prismases.filter((p) => prisma.has(p)) + if (validPrismaObjs.length) { + dts.setImport("@prisma/client", { subImports: validPrismaObjs.map((p) => `${p} as P${p}`) }) + } + + const initialResult = dts.getResult() + if (initialResult.includes("GraphQLResolveInfo")) { + dts.setImport("graphql", { subImports: ["GraphQLResolveInfo"] }) + } + + if (initialResult.includes("RedwoodGraphQLContext")) { + dts.setImport("@redwoodjs/graphql-server/dist/types", { subImports: ["RedwoodGraphQLContext"] }) + } + + if (sharedInternalGraphQLObjectsReferenced.types.length || extraSharedFileImportReferences.size) { + const source = `./${settings.sharedInternalFilename.replace(".d.ts", "")}` + dts.setImport(source, { + subImports: [ + ...sharedInternalGraphQLObjectsReferenced.types.map((t) => `${t} as RT${t}`), + ...[...extraSharedFileImportReferences.values()].map((t) => ("name" in t && t.name ? `${t.import} as ${t.name}` : t.import)), + ], + }) + } + + if (sharedGraphQLObjectsReferencedTypes.length) { + const source = `./${settings.sharedFilename.replace(".d.ts", "")}` + dts.setImport(source, { subImports: sharedGraphQLObjectsReferencedTypes }) + } + + serviceFacts.set(fileKey, thisFact) + + const dtsFilename = filename.endsWith(".ts") ? filename.replace(".ts", ".d.ts") : filename.replace(".js", ".d.ts") + const dtsFilepath = context.join(context.pathSettings.typesFolderRoot, dtsFilename) + + // Some manual formatting tweaks so we align with Redwood's setup more + const final = dts.getResult() + + const shouldWriteDTS = !!final.trim().length + if (!shouldWriteDTS) return + + const formatted = final // await formatDTS(dtsFilepath, dts) + + // Don't make a file write if the content is the same + const priorContent = context.sys.readFile(dtsFilename) + if (priorContent === formatted) return + + context.sys.writeFile(dtsFilepath, formatted) + return dtsFilepath + + function addDefinitionsForTopLevelResolvers(parentName: string, config: ResolverFuncFact) { + const { name } = config + let field = queryType.getFields()[name] + if (!field) { + field = mutationType.getFields()[name] + } + + const nodeDocs = field.astNode + ? ["SDL: " + graphql.print(field.astNode)] + : ["@deprecated: Could not find this field in the schema for Mutation or Query"] + const interfaceName = `${capitalizeFirstLetter(config.name)}Resolver` + + const args = createAndReferOrInlineArgsForField(field, { + name: interfaceName, + dts, + mapper: externalMapper.map, + }) + + if (parentName === queryType.name) knownSpecialCasesForGraphQL.add(queryType.name) + if (parentName === mutationType.name) knownSpecialCasesForGraphQL.add(mutationType.name) + + const argsParam = args ?? "object" + const qForInfos = config.infoParamType === "just_root_destructured" ? "?" : "" + const returnType = returnTypeForResolver(returnTypeMapper, field, config) + + dts.rootScope.addInterface( + interfaceName, + [ + { + type: "call-signature", + optional: config.funcArgCount < 1, + returnType, + params: [ + { name: "args", type: argsParam, optional: config.funcArgCount < 1 }, + { + name: "obj", + type: `{ root: ${parentName}, context${qForInfos}: RedwoodGraphQLContext, info${qForInfos}: GraphQLResolveInfo }`, + optional: config.funcArgCount < 2, + }, + ], + }, + ], + { + exported: true, + docs: nodeDocs.join(" "), + } + ) + } + + /** Ideally, we want to be able to write the type for just the object */ + function addCustomTypeModel(modelFacts: ModelResolverFacts) { + const modelName = modelFacts.typeName + extraPrismaReferences.add(modelName) + + // Make an interface, this is the version we are replacing from graphql-codegen: + // Account: MergePrismaWithSdlTypes, AllMappedModels>; + const gqlType = gql.getType(modelName) + if (!gqlType) { + // throw new Error(`Could not find a GraphQL type named ${d.getName()}`); + // fileDTS.addStatements(`\n// ${modelName} does not exist in the schema`) + return + } + + if (!graphql.isObjectType(gqlType)) { + throw new Error(`In your schema ${modelName} is not an object, which we can only make resolver types for`) + } + + const fields = gqlType.getFields() + + // See: https://github.com/redwoodjs/redwood/pull/6228#issue-1342966511 + // For more ideas + + const hasGenerics = modelFacts.hasGenericArg + + const resolverInterface = dts.rootScope.addInterface(`${modelName}TypeResolvers`, [], { + exported: true, + generics: hasGenerics ? [{ name: "Extended" }] : [], + }) + + // Handle extending classes in the runtime which only exist in SDL + const parentIsPrisma = prisma.has(modelName) + if (!parentIsPrisma) extraSharedFileImportReferences.add({ name: `S${modelName}`, import: modelName }) + const suffix = parentIsPrisma ? "P" : "S" + + const parentTypeString = `${suffix}${modelName} ${createParentAdditionallyDefinedFunctions()} ${hasGenerics ? " & Extended" : ""}` + + /** + type CurrentUserAccountAsParent = SCurrentUserAccount & { + users: () => PUser[] | Promise | (() => Promise); + registeredPublishingPartner: () => Promise; + subIsViaGift: () => boolean | Promise | (() => Promise); + } + */ + + dts.rootScope.addTypeAlias(`${modelName}AsParent`, t.tsTypeReference(t.identifier(parentTypeString)), { + generics: hasGenerics ? [{ name: "Extended" }] : [], + }) + const modelFieldFacts = fieldFacts.get(modelName) ?? {} + + // Loop through the resolvers, adding the fields which have resolvers implemented in the source file + modelFacts.resolvers.forEach((resolver) => { + const field = fields[resolver.name] + if (field) { + const fieldName = resolver.name + if (modelFieldFacts[fieldName]) modelFieldFacts[fieldName].hasResolverImplementation = true + else modelFieldFacts[fieldName] = { hasResolverImplementation: true } + + const argsType = inlineArgsForField(field, { mapper: externalMapper.map }) ?? "undefined" + const param = hasGenerics ? "" : "" + + const firstQ = resolver.funcArgCount < 1 ? "?" : "" + const secondQ = resolver.funcArgCount < 2 ? "?" : "" + const qForInfos = resolver.infoParamType === "just_root_destructured" ? "?" : "" + + const innerArgs = `args${firstQ}: ${argsType}, obj${secondQ}: { root: ${modelName}AsParent${param}, context${qForInfos}: RedwoodGraphQLContext, info${qForInfos}: GraphQLResolveInfo }` + const returnType = returnTypeForResolver(returnTypeMapper, field, resolver) + const args = resolver.isFunc || resolver.isUnknown ? `(${innerArgs}) => ${returnType ?? "any"}` : returnType + + const docs = field.astNode ? `SDL: ${graphql.print(field.astNode)}` : "" + const property = t.tsPropertySignature(t.identifier(fieldName), t.tsTypeAnnotation(t.tsTypeReference(t.identifier(args)))) + t.addComment(property, "leading", docs) + + resolverInterface.body.body.push(property) + } else { + resolverInterface.body.body.push( + t.tsPropertySignature(t.identifier(resolver.name), t.tsTypeAnnotation(t.tsTypeReference(t.identifier("void")))) + ) + } + }) + + function createParentAdditionallyDefinedFunctions() { + const fns: string[] = [] + modelFacts.resolvers.forEach((resolver) => { + const existsInGraphQLSchema = fields[resolver.name] + if (!existsInGraphQLSchema) { + console.warn( + `The service file ${filename} has a field ${resolver.name} on ${modelName} that does not exist in the generated schema.graphql` + ) + } + + const prefix = !existsInGraphQLSchema ? "\n// This field does not exist in the generated schema.graphql\n" : "" + const returnType = returnTypeForResolver(externalMapper, existsInGraphQLSchema, resolver) + // fns.push(`${prefix}${resolver.name}: () => Promise<${externalMapper.map(type, {})}>`) + fns.push(`${prefix}${resolver.name}: () => ${returnType}`) + }) + + if (fns.length < 1) return "" + return "& {" + fns.join(", \n") + "}" + } + + fieldFacts.set(modelName, modelFieldFacts) + } +} + +function returnTypeForResolver(mapper: TypeMapper, field: graphql.GraphQLField | undefined, resolver: ResolverFuncFact) { + if (!field) return "void" + + const tType = mapper.map(field.type, { preferNullOverUndefined: true, typenamePrefix: "RT" }) ?? "void" + + let returnType = tType + const all = `${tType} | Promise<${tType}> | (() => Promise<${tType}>)` + + if (resolver.isFunc && resolver.isAsync) returnType = `Promise<${tType}>` + else if (resolver.isFunc && resolver.isObjLiteral) returnType = tType + else if (resolver.isFunc) returnType = all + else if (resolver.isObjLiteral) returnType = tType + else if (resolver.isUnknown) returnType = all + + return returnType +} +/* eslint-enable @typescript-eslint/no-unnecessary-condition */ diff --git a/src/sharedSchema.ts b/src/sharedSchema.ts index 333bf6d..28c2d59 100644 --- a/src/sharedSchema.ts +++ b/src/sharedSchema.ts @@ -23,7 +23,6 @@ export const createSharedSchemaFiles = async (context: AppContext, verbose: bool } function createSharedExternalSchemaFile(context: AppContext) { - console.time("createSharedExternalSchemaFile") const gql = context.gql const types = gql.getTypeMap() const knownPrimitives = ["String", "Boolean", "Int"] @@ -34,10 +33,6 @@ function createSharedExternalSchemaFile(context: AppContext) { const priorFile = "" const dts = builder(priorFile, {}) - // const externalTSFile = context.tsProject.createSourceFile(`/source/${context.pathSettings.sharedFilename}`, "") - const statements = [] as BlockStatement[] - - console.time("createSharedExternalSchemaFile:types-loop") Object.keys(types).forEach((name) => { if (name.startsWith("__")) { return @@ -61,8 +56,6 @@ function createSharedExternalSchemaFile(context: AppContext) { docs.push(type.description) } - // dts.rootScope.addTypeAlias(type.name, null, true) - dts.rootScope.addInterface( type.name, [ @@ -72,14 +65,9 @@ function createSharedExternalSchemaFile(context: AppContext) { optional: true, }, ...Object.entries(type.getFields()).map(([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) @@ -88,13 +76,13 @@ function createSharedExternalSchemaFile(context: AppContext) { const field = { name: fieldName, type: mapper.map(type, { preferNullOverUndefined: true })!, - // docs, + docs: prismaField?.leadingComments.trim(), optional: hasResolverImplementation ?? (isOptionalInSDL || doesNotExistInPrisma), } return field }), ], - true + { exported: true, docs: docs.join(" ") } ) } @@ -106,7 +94,7 @@ function createSharedExternalSchemaFile(context: AppContext) { .map((m) => (m as { value: string }).value) .join('" | "') + '"' - dts.rootScope.addTypeAlias(type.name, t.tsTypeReference(t.identifier(union)), true) + dts.rootScope.addTypeAlias(type.name, t.tsTypeReference(t.identifier(union)), { exported: true }) } if (graphql.isUnionType(type)) { @@ -114,29 +102,16 @@ function createSharedExternalSchemaFile(context: AppContext) { .getTypes() .map((m) => m.name) .join(" | ") - dts.rootScope.addTypeAlias(type.name, t.tsTypeReference(t.identifier(union)), true) + dts.rootScope.addTypeAlias(type.name, t.tsTypeReference(t.identifier(union)), { exported: true }) } }) - console.timeEnd("createSharedExternalSchemaFile:types-loop") - console.time("createSharedExternalSchemaFile:scalars") const { scalars } = mapper.getReferencedGraphQLThingsInMapping() for (const s of scalars) { dts.rootScope.addTypeAlias(s, t.tsAnyKeyword()) } - console.timeEnd("createSharedExternalSchemaFile:scalars") - - console.time("createSharedExternalSchemaFile:write") - // externalTSFile.set({ statements }) - - console.timeEnd("createSharedExternalSchemaFile:write") - console.time("createSharedExternalSchemaFile:read") const text = dts.getResult() - console.timeEnd("createSharedExternalSchemaFile:read") - - console.timeEnd("createSharedExternalSchemaFile") - const fullPath = context.join(context.pathSettings.typesFolderRoot, context.pathSettings.sharedFilename) const prior = context.sys.readFile(fullPath) if (prior !== text) context.sys.writeFile(fullPath, text) @@ -150,10 +125,9 @@ async function createSharedReturnPositionSchemaFile(context: AppContext) { const typesToImport = [] as string[] const knownPrimitives = ["String", "Boolean", "Int"] - const externalTSFile = context.tsProject.createSourceFile( - `/source/${context.pathSettings.sharedInternalFilename}`, - ` -// You may very reasonably ask yourself, 'what is this file?' and why do I need it. + const dts = builder("", {}) + + dts.rootScope.addLeadingComment(`// 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 @@ -162,11 +136,7 @@ async function createSharedReturnPositionSchemaFile(context: AppContext) { // 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("__")) { @@ -188,91 +158,67 @@ async function createSharedReturnPositionSchemaFile(context: AppContext) { return } - statements.push({ - name: type.name, - kind: tsMorph.StructureKind.Interface, - isExported: true, - docs: [], - properties: [ + dts.rootScope.addInterface( + type.name, + [ { name: "__typename", type: `"${type.name}"`, - hasQuestionToken: true, + optional: 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 = { + const field = { name: fieldName, - type: mapper.map(obj.type, { preferNullOverUndefined: true }), - hasQuestionToken: hasResolverImplementation ?? (isOptionalInSDL || doesNotExistInPrisma), + type: mapper.map(obj.type, { preferNullOverUndefined: true })!, + optional: hasResolverImplementation ?? (isOptionalInSDL || doesNotExistInPrisma), } return field }), ], - }) + { exported: true } + ) } 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('" | "') + - '"', - }) + const union = + '"' + + type + .getValues() + .map((m) => (m as { value: string }).value) + .join('" | "') + + '"' + dts.rootScope.addTypeAlias(type.name, t.tsTypeReference(t.identifier(union)), { exported: true }) } if (graphql.isUnionType(type)) { - statements.push({ - name: type.name, - kind: tsMorph.StructureKind.TypeAlias, - isExported: true, - type: type - .getTypes() - .map((m) => m.name) - .join(" | "), - }) + const union = type + .getTypes() + .map((m) => m.name) + .join(" | ") + dts.rootScope.addTypeAlias(type.name, t.tsTypeReference(t.identifier(union)), { exported: true }) } }) const { scalars, prisma: prismaModels } = mapper.getReferencedGraphQLThingsInMapping() for (const s of scalars) { - statements.push({ - kind: tsMorph.StructureKind.TypeAlias, - name: s, - type: "any", - }) + dts.rootScope.addTypeAlias(s, t.tsAnyKeyword()) } 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}`), - }) + dts.setImport("@prisma/client", { subImports: allPrismaModels.map((p) => `${p} as P${p}`) }) for (const p of allPrismaModels) { - statements.push({ - kind: tsMorph.StructureKind.TypeAlias, - name: p, - type: `P${p}`, - }) + dts.rootScope.addTypeAlias(p, t.tsTypeReference(t.identifier(`P${p}`))) } } - externalTSFile.set({ statements }) + const text = dts.getResult() const fullPath = context.join(context.pathSettings.typesFolderRoot, context.pathSettings.sharedInternalFilename) - const formatted = await formatDTS(fullPath, externalTSFile.getText()) - const prior = context.sys.readFile(fullPath) - if (prior !== formatted) context.sys.writeFile(fullPath, formatted) + if (prior !== text) context.sys.writeFile(fullPath, text) } diff --git a/src/tests/bugs/parentCanBeGraphQLObject.test.ts b/src/tests/bugs/parentCanBeGraphQLObject.test.ts index 111d3fd..2323445 100644 --- a/src/tests/bugs/parentCanBeGraphQLObject.test.ts +++ b/src/tests/bugs/parentCanBeGraphQLObject.test.ts @@ -30,13 +30,11 @@ export const Puzzle = { const { vfsMap } = await getDTSFilesForRun({ sdl, gamesService, prismaSchema }) const dts = vfsMap.get("/types/games.d.ts")! expect(dts.trim()).toMatchInlineSnapshot(` - "import type { Puzzle as SPuzzle } from \\"./shared-return-types\\"; - - export interface PuzzleTypeResolvers { - /** SDL: id: Int! */ + "interface PuzzleTypeResolvers { + /*SDL: id: Int!*/ id: number; } - - type PuzzleAsParent = SPuzzle & { id: () => number };" + type PuzzleAsParent = SPuzzle & {id: () => number} ; + import { Puzzle as SPuzzle } from \\"./shared-return-types\\";" `) }) diff --git a/src/tests/bugs/returnObjectCanBeGraphQLInterfaces.test.ts b/src/tests/bugs/returnObjectCanBeGraphQLInterfaces.test.ts index f706905..42fc270 100644 --- a/src/tests/bugs/returnObjectCanBeGraphQLInterfaces.test.ts +++ b/src/tests/bugs/returnObjectCanBeGraphQLInterfaces.test.ts @@ -31,28 +31,15 @@ export const Game = { const { vfsMap } = await getDTSFilesForRun({ sdl, gamesService, prismaSchema }) const dts = vfsMap.get("/types/games.d.ts")! expect(dts.trim()).toMatchInlineSnapshot(` - "import type { Game as PGame } from \\"@prisma/client\\"; - import type { GraphQLResolveInfo } from \\"graphql\\"; - - import type { RedwoodGraphQLContext } from \\"@redwoodjs/graphql-server/dist/types\\"; - - import type { Node as RTNode } from \\"./shared-return-types\\"; - import type { Node } from \\"./shared-schema-types\\"; - - export interface GameTypeResolvers { - /** SDL: puzzle: Node! */ - puzzle: ( - args?: undefined, - obj?: { - root: GameAsParent; - context: RedwoodGraphQLContext; - info: GraphQLResolveInfo; - } - ) => RTNode | Promise | (() => Promise); + "interface GameTypeResolvers { + /*SDL: puzzle: Node!*/ + puzzle: (args?: undefined, obj?: { root: GameAsParent, context: RedwoodGraphQLContext, info: GraphQLResolveInfo }) => RTNode | Promise | (() => Promise); } - - type GameAsParent = PGame & { - puzzle: () => RTNode | Promise | (() => Promise); - };" + type GameAsParent = PGame & {puzzle: () => RTNode | Promise | (() => Promise)} ; + import { Game as PGame } from \\"@prisma/client\\"; + import { GraphQLResolveInfo } from \\"graphql\\"; + import { RedwoodGraphQLContext } from \\"@redwoodjs/graphql-server/dist/types\\"; + import { Node as RTNode } from \\"./shared-return-types\\"; + import { Node } from \\"./shared-schema-types\\";" `) }) diff --git a/src/tests/features/generatesTypesForUnions.test.ts b/src/tests/features/generatesTypesForUnions.test.ts index 508e3da..7762f7d 100644 --- a/src/tests/features/generatesTypesForUnions.test.ts +++ b/src/tests/features/generatesTypesForUnions.test.ts @@ -38,21 +38,21 @@ export const Game = { const { vfsMap } = await getDTSFilesForRun({ sdl, gamesService, prismaSchema, generateShared: true }) const dts = vfsMap.get("/types/shared-schema-types.d.ts")! expect(dts.trim()).toMatchInlineSnapshot(` - "export interface Game { + "interface Game { __typename?: \\"Game\\"; id?: number; } - export interface Puzzle { + interface Puzzle { __typename?: \\"Puzzle\\"; id: number; } - export type Gameish = Game | Puzzle; - export interface Query { + type Gameish = Game | Puzzle; + interface Query { __typename?: \\"Query\\"; gameObj?: Game| null | Puzzle| null| null; gameArr: (Game | Puzzle)[]; } - export interface Mutation { + interface Mutation { __typename?: \\"Mutation\\"; __?: string| null; }" diff --git a/src/tests/features/preferPromiseFnWhenKnown.test.ts b/src/tests/features/preferPromiseFnWhenKnown.test.ts index 3484f6f..18e57c2 100644 --- a/src/tests/features/preferPromiseFnWhenKnown.test.ts +++ b/src/tests/features/preferPromiseFnWhenKnown.test.ts @@ -54,114 +54,39 @@ export const Game = { const { vfsMap } = await getDTSFilesForRun({ sdl, gamesService, prismaSchema }) const dts = vfsMap.get("/types/games.d.ts")! expect(dts.trim()).toMatchInlineSnapshot(` - "import type { Game as PGame } from \\"@prisma/client\\"; - import type { GraphQLResolveInfo } from \\"graphql\\"; - - import type { RedwoodGraphQLContext } from \\"@redwoodjs/graphql-server/dist/types\\"; - - import type { Game as RTGame } from \\"./shared-return-types\\"; - import type { Query } from \\"./shared-schema-types\\"; - - /** SDL: gameSync: Game */ - export interface GameSyncResolver { - ( - args?: object, - obj?: { - root: Query; - context: RedwoodGraphQLContext; - info: GraphQLResolveInfo; - } - ): RTGame | null | Promise | (() => Promise); + "interface GameSyncResolver { + (args?: RTGame| null | Promise | (() => Promise), obj?: RTGame| null | Promise | (() => Promise)): RTGame| null | Promise | (() => Promise); } - - /** SDL: gameAsync: Game */ - export interface GameAsyncResolver { - ( - args?: object, - obj?: { - root: Query; - context: RedwoodGraphQLContext; - info: GraphQLResolveInfo; - } - ): Promise; + interface GameAsyncResolver { + (args?: Promise, obj?: Promise): Promise; } - - /** SDL: gameAsync1Arg: Game */ - export interface GameAsync1ArgResolver { - ( - args: object, - obj?: { - root: Query; - context: RedwoodGraphQLContext; - info: GraphQLResolveInfo; - } - ): RTGame | null | Promise | (() => Promise); + interface GameAsync1ArgResolver { + (args: RTGame| null | Promise | (() => Promise), obj?: RTGame| null | Promise | (() => Promise)): RTGame| null | Promise | (() => Promise); } - - /** SDL: gameAsync2Arg: Game */ - export interface GameAsync2ArgResolver { - ( - args: object, - obj: { - root: Query; - context: RedwoodGraphQLContext; - info: GraphQLResolveInfo; - } - ): RTGame | null | Promise | (() => Promise); + interface GameAsync2ArgResolver { + (args: RTGame| null | Promise | (() => Promise), obj: RTGame| null | Promise | (() => Promise)): RTGame| null | Promise | (() => Promise); } - - /** SDL: gameObj: Game */ - export interface GameObjResolver { - ( - args?: object, - obj?: { - root: Query; - context: RedwoodGraphQLContext; - info: GraphQLResolveInfo; - } - ): RTGame | null; + interface GameObjResolver { + (args?: RTGame| null, obj?: RTGame| null): RTGame| null; } - - export interface GameTypeResolvers { - /** SDL: summary: String! */ + interface GameTypeResolvers { + /*SDL: summary: String!*/ summary: string; - - /** SDL: summarySync: String! */ - summarySync: ( - args?: undefined, - obj?: { - root: GameAsParent; - context: RedwoodGraphQLContext; - info: GraphQLResolveInfo; - } - ) => string; - - /** SDL: summarySyncBlock: String! */ - summarySyncBlock: ( - args?: undefined, - obj?: { - root: GameAsParent; - context: RedwoodGraphQLContext; - info: GraphQLResolveInfo; - } - ) => string | Promise | (() => Promise); - - /** SDL: summaryAsync: String! */ - summaryAsync: ( - args?: undefined, - obj?: { - root: GameAsParent; - context: RedwoodGraphQLContext; - info: GraphQLResolveInfo; - } - ) => Promise; + /*SDL: summarySync: String!*/ + summarySync: (args?: undefined, obj?: { root: GameAsParent, context: RedwoodGraphQLContext, info: GraphQLResolveInfo }) => string; + /*SDL: summarySyncBlock: String!*/ + summarySyncBlock: (args?: undefined, obj?: { root: GameAsParent, context: RedwoodGraphQLContext, info: GraphQLResolveInfo }) => string | Promise | (() => Promise); + /*SDL: summaryAsync: String!*/ + summaryAsync: (args?: undefined, obj?: { root: GameAsParent, context: RedwoodGraphQLContext, info: GraphQLResolveInfo }) => Promise; } - - type GameAsParent = PGame & { - summary: () => string; - summarySync: () => string; - summarySyncBlock: () => string | Promise | (() => Promise); - summaryAsync: () => Promise; - } & Extended;" + type GameAsParent = PGame & {summary: () => string, + summarySync: () => string, + summarySyncBlock: () => string | Promise | (() => Promise), + summaryAsync: () => Promise} & Extended; + import { Game as PGame } from \\"@prisma/client\\"; + import { GraphQLResolveInfo } from \\"graphql\\"; + import { RedwoodGraphQLContext } from \\"@redwoodjs/graphql-server/dist/types\\"; + import { Game as RTGame } from \\"./shared-return-types\\"; + import { Query } from \\"./shared-schema-types\\";" `) }) diff --git a/src/tests/features/returnTypePositionsWhichPreferPrisma.test.ts b/src/tests/features/returnTypePositionsWhichPreferPrisma.test.ts index 08039ab..0ffa49a 100644 --- a/src/tests/features/returnTypePositionsWhichPreferPrisma.test.ts +++ b/src/tests/features/returnTypePositionsWhichPreferPrisma.test.ts @@ -41,40 +41,19 @@ export const Game = { expect(dts.trimStart()).toMatchInlineSnapshot( ` - "import type { Game as PGame } from \\"@prisma/client\\"; - import type { GraphQLResolveInfo } from \\"graphql\\"; - - import type { RedwoodGraphQLContext } from \\"@redwoodjs/graphql-server/dist/types\\"; - - import type { Game as RTGame } from \\"./shared-return-types\\"; - import type { Query } from \\"./shared-schema-types\\"; - - /** SDL: game: Game */ - export interface GameResolver { - ( - args?: object, - obj?: { - root: Query; - context: RedwoodGraphQLContext; - info: GraphQLResolveInfo; - } - ): RTGame | null | Promise | (() => Promise); + "interface GameResolver { + (args?: RTGame| null | Promise | (() => Promise), obj?: RTGame| null | Promise | (() => Promise)): RTGame| null | Promise | (() => Promise); } - - export interface GameTypeResolvers { - /** SDL: summary: String! */ - summary: ( - args: undefined, - obj: { - root: GameAsParent; - context?: RedwoodGraphQLContext; - info?: GraphQLResolveInfo; - } - ) => string; + interface GameTypeResolvers { + /*SDL: summary: String!*/ + summary: (args: undefined, obj: { root: GameAsParent, context?: RedwoodGraphQLContext, info?: GraphQLResolveInfo }) => string; } - - type GameAsParent = PGame & { summary: () => string }; - " + type GameAsParent = PGame & {summary: () => string} ; + import { Game as PGame } from \\"@prisma/client\\"; + import { GraphQLResolveInfo } from \\"graphql\\"; + import { RedwoodGraphQLContext } from \\"@redwoodjs/graphql-server/dist/types\\"; + import { Game as RTGame } from \\"./shared-return-types\\"; + import { Query } from \\"./shared-schema-types\\";" ` ) }) diff --git a/src/tests/features/supportReferringToEnumsOnlyInSDL.test.ts b/src/tests/features/supportReferringToEnumsOnlyInSDL.test.ts index 644fc15..aa8132a 100644 --- a/src/tests/features/supportReferringToEnumsOnlyInSDL.test.ts +++ b/src/tests/features/supportReferringToEnumsOnlyInSDL.test.ts @@ -38,44 +38,28 @@ export const Game: GameResolvers = {}; // We are expecting to see import type GameType from "./shared-schema-types" expect(vfsMap.get("/types/games.d.ts")).toMatchInlineSnapshot(` - "import type { Game as PGame } from \\"@prisma/client\\"; - import type { GraphQLResolveInfo } from \\"graphql\\"; - - import type { RedwoodGraphQLContext } from \\"@redwoodjs/graphql-server/dist/types\\"; - - import type { Game as RTGame } from \\"./shared-return-types\\"; - import type { GameType, Query } from \\"./shared-schema-types\\"; - - /** SDL: allGames(type: GameType!): [Game!]! */ - export interface AllGamesResolver { - ( - args?: { type: GameType }, - obj?: { - root: Query; - context: RedwoodGraphQLContext; - info: GraphQLResolveInfo; - } - ): RTGame[] | Promise | (() => Promise); + "interface AllGamesResolver { + (args?: RTGame[] | Promise | (() => Promise), obj?: RTGame[] | Promise | (() => Promise)): RTGame[] | Promise | (() => Promise); } - - export interface GameTypeResolvers {} - - type GameAsParent = PGame; - " + interface GameTypeResolvers {} + type GameAsParent = PGame ; + import { Game as PGame } from \\"@prisma/client\\"; + import { Game as RTGame } from \\"./shared-return-types\\"; + import { GameType, Query } from \\"./shared-schema-types\\";" `) expect(vfsMap.get("/types/shared-schema-types.d.ts"))!.toMatchInlineSnapshot(` - "export interface Game { + "interface Game { __typename?: \\"Game\\"; id: number; games: Game[]; } - export interface Query { + interface Query { __typename?: \\"Query\\"; allGames: Game[]; } - export type GameType = \\"FOOTBALL\\" | \\"BASKETBALL\\"; - export interface Mutation { + type GameType = \\"FOOTBALL\\" | \\"BASKETBALL\\"; + interface Mutation { __typename?: \\"Mutation\\"; __?: string| null; }" diff --git a/src/tests/testRunner.ts b/src/tests/testRunner.ts index c969c35..0c519ac 100644 --- a/src/tests/testRunner.ts +++ b/src/tests/testRunner.ts @@ -6,7 +6,7 @@ import { Project } from "ts-morph" import { AppContext } from "../context.js" import { prismaModeller } from "../prismaModeller.js" -import { lookAtServiceFile } from "../serviceFile.js" +import { lookAtServiceFile } from "../serviceFile2.js" import { createSharedSchemaFiles } from "../sharedSchema.js" import type { CodeFacts, FieldFacts } from "../typeFacts.js" diff --git a/src/tsBuilder.ts b/src/tsBuilder.ts index 49afca6..9fb3934 100644 --- a/src/tsBuilder.ts +++ b/src/tsBuilder.ts @@ -3,11 +3,40 @@ import generator from "@babel/generator" import parser from "@babel/parser" import traverse from "@babel/traverse" -import t, { BlockStatement, ExpressionStatement, Node, Statement, TSType, TSTypeAliasDeclaration, TypeAlias } from "@babel/types" +import t, { + addComment, + BlockStatement, + Declaration, + ExpressionStatement, + Statement, + TSType, + TSTypeParameterDeclaration, +} from "@babel/types" + +interface InterfaceProperty { + docs?: string + name: string + optional: boolean + type: string +} + +interface InterfaceCallSignature { + docs?: string + params: { name: string; optional?: boolean; type: string }[] + returnType: string + type: "call-signature" +} + +interface NodeConfig { + docs?: string + exported?: boolean + generics?: { name: string }[] +} export const builder = (priorSource: string, opts: {}) => { const sourceFile = parser.parse(priorSource, { sourceType: "module", plugins: ["jsx", "typescript"] }) + /** Declares an import which should exist in the source document */ const setImport = (source: string, opts: { mainImport?: string; subImports?: string[] }) => { const imports = sourceFile.program.body.filter((s) => s.type === "ImportDeclaration") @@ -41,6 +70,7 @@ export const builder = (priorSource: string, opts: {}) => { } } + /** Allows creating a type alias via an AST parsed string */ const setTypeViaTemplate = (template: string) => { const type = parser.parse(template, { sourceType: "module", plugins: ["jsx", "typescript"] }) @@ -86,7 +116,8 @@ export const builder = (priorSource: string, opts: {}) => { throw new Error(`Unsupported type annotation: ${newAnnotion.type} - ${generator(newAnnotion).code}`) } - const createScope = (name: string, statements: Statement[]) => { + /** An internal API for describing a new area for inputting template info */ + const createScope = (scopeName: string, scopeNode: t.Node, statements: Statement[]) => { const addFunction = (name: string) => { let functionNode = statements.find( (s) => t.isVariableDeclaration(s) && t.isIdentifier(s.declarations[0].id) && s.declarations[0].id.name === name @@ -115,7 +146,7 @@ export const builder = (priorSource: string, opts: {}) => { else exists.typeAnnotation = param.typeAnnotation }, - scope: createScope(name, (arrowFn.body as BlockStatement).body), + scope: createScope(name, arrowFn, (arrowFn.body as BlockStatement).body), } } @@ -133,7 +164,7 @@ export const builder = (priorSource: string, opts: {}) => { statements.push(declaration) } - const addTypeAlias = (name: string, type: TSType, exported?: boolean) => { + const addTypeAlias = (name: string, type: "any" | "string" | TSType, nodeConfig?: NodeConfig) => { const prior = statements.find( (s) => (t.isTSTypeAliasDeclaration(s) && s.id.name === name) || @@ -141,29 +172,73 @@ export const builder = (priorSource: string, opts: {}) => { ) if (prior) return - const alias = t.tsTypeAliasDeclaration(t.identifier(name), null, type) - statements.push(exported ? t.exportNamedDeclaration(alias) : alias) + // Allow having some easy literals + let typeNode = null + if (typeof type === "string") { + if (type === "any") typeNode = t.tsAnyKeyword() + if (type === "string") typeNode = t.tsStringKeyword() + } else { + typeNode = type + } + + const alias = t.tsTypeAliasDeclaration(t.identifier(name), null, typeNode!) + const statement = nodeFromNodeConfig(alias, nodeConfig) + statements.push(statement) + + return alias } - const addInterface = (name: string, fields: { docs?: string; name: string; optional: boolean; type: string }[], exported?: boolean) => { + const addInterface = (name: string, fields: (InterfaceCallSignature | InterfaceProperty)[], nodeConfig?: NodeConfig) => { const prior = statements.find( (s) => (t.isTSInterfaceDeclaration(s) && s.id.name === name) || (t.isExportNamedDeclaration(s) && t.isTSInterfaceDeclaration(s.declaration) && s.declaration.id.name === name) ) - if (prior) return + if (prior) { + if (t.isTSInterfaceDeclaration(prior)) return prior + if (t.isExportNamedDeclaration(prior) && t.isTSInterfaceDeclaration(prior.declaration)) return prior.declaration + throw new Error("Unknown state") + } const body = t.tsInterfaceBody( fields.map((f) => { - const prop = t.tsPropertySignature(t.identifier(f.name), t.tsTypeAnnotation(t.tsTypeReference(t.identifier(f.type)))) - prop.optional = f.optional - return prop + // Allow call signatures + if (!("name" in f) && f.type === "call-signature") { + const sig = t.tsCallSignatureDeclaration( + null, // generics + f.params.map((p) => { + const i = t.identifier(p.name) + i.typeAnnotation = t.tsTypeAnnotation(t.tsTypeReference(t.identifier(f.returnType))) + if (p.optional) i.optional = true + return i + }), + t.tsTypeAnnotation(t.tsTypeReference(t.identifier(f.returnType))) + ) + return sig + } else { + const prop = t.tsPropertySignature(t.identifier(f.name), t.tsTypeAnnotation(t.tsTypeReference(t.identifier(f.type)))) + prop.optional = f.optional + if (f.docs?.length) t.addComment(prop, "leading", f.docs) + return prop + } }) ) - const alias = t.tsInterfaceDeclaration(t.identifier(name), null, null, body) - statements.push(exported ? t.exportNamedDeclaration(alias) : alias) + const interfaceDec = t.tsInterfaceDeclaration(t.identifier(name), null, null, body) + const statement = nodeFromNodeConfig(interfaceDec, nodeConfig) + statements.push(statement) + return interfaceDec + } + + const addLeadingComment = (comment: string) => { + const firstStatement = statements[0] || scopeNode + if (firstStatement) { + if (firstStatement.leadingComments?.find((c) => c.value === comment)) return + t.addComment(firstStatement, "leading", comment) + } else { + t.addComment(scopeNode, "leading", comment) + } } return { @@ -171,9 +246,11 @@ export const builder = (priorSource: string, opts: {}) => { addVariableDeclaration, addTypeAlias, addInterface, + addLeadingComment, } } + /** Experimental function for parsing out a graphql template tag, and ensuring certain fields have been called */ const updateGraphQLTemplateTag = (expression: t.Expression, path: string, modelFields: string[]) => { if (path !== ".") throw new Error("Only support updating the root of the graphql tag ATM") traverse( @@ -200,11 +277,9 @@ export const builder = (priorSource: string, opts: {}) => { const parseStatement = (code: string) => parser.parse(code, { sourceType: "module", plugins: ["jsx", "typescript"] }).program.body[0] as ExpressionStatement - const getResult = () => { - return generator(sourceFile.program, {}).code - } + const getResult = () => generator(sourceFile.program, {}).code - const rootScope = createScope("root", sourceFile.program.body) + const rootScope = createScope("root", sourceFile, sourceFile.program.body) return { setImport, getResult, setTypeViaTemplate, parseStatement, updateGraphQLTemplateTag, rootScope } } @@ -215,3 +290,19 @@ const getTypeLevelAST = (type: string) => { if (!typeDeclaration) throw new Error("No type declaration found in template: " + type) return typeDeclaration.typeAnnotation } + +export type TSBuilder = ReturnType + +/** A little helper to handle all the extras for */ +const nodeFromNodeConfig = ( + node: T, + nodeConfig?: NodeConfig +) => { + const statement = nodeConfig?.exported ? t.exportNamedDeclaration(node) : node + if (nodeConfig?.docs) addComment(statement, "leading", nodeConfig.docs) + if (nodeConfig?.generics && nodeConfig.generics.length > 0) { + node.typeParameters = t.tsTypeParameterDeclaration(nodeConfig.generics.map((g) => t.tsTypeParameter(null, null, g.name))) + } + + return node +} diff --git a/src/utils.ts b/src/utils.ts index 695d0d1..080d380 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ import * as graphql from "graphql" import * as tsMorph from "ts-morph" +import { TSBuilder } from "./tsBuilder.js" import { TypeMapper } from "./typeMap.js" export const varStartsWithUppercase = (v: tsMorph.VariableDeclaration) => v.getName()[0].startsWith(v.getName()[0].toUpperCase()) @@ -32,7 +33,7 @@ export const inlineArgsForField = (field: graphql.GraphQLField export const createAndReferOrInlineArgsForField = ( field: graphql.GraphQLField, config: { - file: tsMorph.SourceFile + dts: TSBuilder mapper: TypeMapper["map"] name: string noSeparateType?: true @@ -42,17 +43,15 @@ export const createAndReferOrInlineArgsForField = ( if (!inlineArgs) return undefined if (inlineArgs.length < 120) return inlineArgs - const argsInterface = config.file.addInterface({ - name: `${config.name}Args`, - isExported: true, - }) - - field.args.forEach((a) => { - argsInterface.addProperty({ + const dts = config.dts + dts.rootScope.addInterface( + `${config.name}Args`, + field.args.map((a) => ({ name: a.name, - type: config.mapper(a.type, {}), - }) - }) + type: config.mapper(a.type, {})!, + optional: false, + })) + ) return `${config.name}Args` }