diff --git a/README.md b/README.md index 1b8c0ba3..19d7143c 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ The following JSDoc tags are supported: * [`@GQLScalar`](#GQLscalar) * [`@GQLEnum`](#GQLenum) * [`@GQLInput`](#GQLinput) +* [`@GQLExtendType`](#GQLExtendType) ### @GQLType @@ -284,7 +285,7 @@ enum MyEnum { } ``` -We also support defining enums using a union of string literals, howerver there +We also support defining enums using a union of string literals, however there are some limitations to this approach: * You cannot add descriptions to enum values @@ -318,6 +319,49 @@ type MyInput = { }; ``` +### @GQLExtendType + +Sometimes you want to add a computed field to a non-class type, or extend base +type like `Query` or `Mutation` from another file. Both of these usecases are +enabled by placing a `@GQLExtendType` before a: + +* Exported function declaration + +In this case, the function should expect an instance of the base type as the +first argument, and an object representing the GraphQL field arguments as the +second argument. The function should return the value of the field. + +Extending Query: + +```ts +/** + * Description of my field + * @GQLExtendType + */ +export function userById(_: Query, args: {id: string}): User { + return DB.getUserById(args.id); +} +``` + +Extending Mutation: + +```ts +/** + * Delete a user. GOODBYE! + * @GQLExtendType + */ +export function deleteUser(_: Mutation, args: {id: string}): boolean { + return DB.deleteUser(args.id); +} +``` + +Note that Grats will use the type of the first argument to determine which type +is being extended. So, as seen in the previous examples, even if you don't need +access to the instance you should still define a typed first argument. + +You can think of `@GQLExtendType` as equivalent to the `extend type` syntax in +GraphQL's schema definition language. + ## Example See `example-server/` in the repo root for a working example. Here we run the static diff --git a/TODO.md b/TODO.md index a8a7841b..4eac8d5b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,6 @@ # TODO ## Alpha -- [ ] Rewrite pitch/description -- [ ] Decouple from file system - [ ] Ensure `__typename`? - [ ] More literals - [ ] Int @@ -13,32 +11,43 @@ - [ ] List - [ ] Object - [ ] Null -- [ ] Build a playground - - Code on left, GraphQL on right - - Could we actually evaluate the resolvers? Maybe in a worker? - - Could we hook up GraphiQL 2? - - Could we surface TS errors? -- [ ] Table of contents for docs +- [ ] Mutations and Query fields as functions +- [ ] Define types from type literals +- [ ] Extend interfaces ## Beta -- [ ] Classes as interfaces. Classes which extend this interface will implement it in GraphQL. - [ ] Support generic directives - [ ] How do we handle arguments? - [ ] Same as defaults? - [ ] Try converting the Star Wars example server - [ ] Better error message for non GraphQL types. -- [ ] A name - [ ] Split out docs into multipe files - [ ] Extract error messages to a separate file -- [ ] Mutations and Query fields as functions - [ ] Validate that we don't shadow builtins - [ ] Parse directives from all docblocks and attach them to the schema - [ ] This will help catch places where people are using directives like @deprecated in unsupported positions - [ ] Add header to generated schema file indicating it was generated. - [ ] Add option to print sorted schema. -- [ ] Better capture ranges form GraphQL errors + +## Examples, Guides and Documentation +- [ ] Add a guide for using with Apollo Server +- [ ] Add a guide for using with Express-graphql +- [ ] Add a guide for OOP style +- [ ] Add a guide for functional style +- [ ] Comparison to Nexus +- [ ] Comparison to Pothos +- [ ] Comparison to TypeGraphQL +- [ ] Migration guide from Nexus +- [ ] Migration guide from Pothos +- [ ] Migration guide from TypeGraphQL +- [ ] Post about what it means to be "True" code-first ## Future +- [ ] Improve playground + - Could we actually evaluate the resolvers? Maybe in a worker? + - Could we hook up GraphiQL 2? + - Could we surface TS errors? +- [ ] Can we ensure the context and ast arguments of resolvers are correct? - [ ] Can we use TypeScript's inference to infer types? - [ ] For example, a method which returns a string, or a property that has a default value. - [ ] Define resolvers? diff --git a/docs/comparisons.md b/docs/comparisons.md new file mode 100644 index 00000000..d2966576 --- /dev/null +++ b/docs/comparisons.md @@ -0,0 +1 @@ +# How Grats Compares to Other Tools \ No newline at end of file diff --git a/docs/vs_nexus/after.ts b/docs/vs_nexus/after.ts new file mode 100644 index 00000000..53769aa8 --- /dev/null +++ b/docs/vs_nexus/after.ts @@ -0,0 +1,35 @@ +import { PrismaClient } from "@prisma/client"; +import express from "express"; +import { graphqlHTTP } from "express-graphql"; + +const prisma = new PrismaClient(); + +// FIXME: Not supported yet +/** @GQLType */ +type User = { + /** @GQLField */ + email: string; + /** @GQLField */ + name?: string; +}; + +/** @GQLType */ +class Query { + allUsers(): Promise { + return prisma.user.findMany(); + } +} + +const schema = makeSchema({ + types: [User, Query], +}); + +const app = express(); +app.use( + "/graphql", + graphqlHTTP({ + schema, + }), +); + +app.listen(4000); diff --git a/docs/vs_nexus/before.ts b/docs/vs_nexus/before.ts new file mode 100644 index 00000000..50cf2916 --- /dev/null +++ b/docs/vs_nexus/before.ts @@ -0,0 +1,37 @@ +import { PrismaClient } from "@prisma/client"; +import { queryType, objectType, makeSchema } from "@nexus/schema"; +import express from "express"; +import { graphqlHTTP } from "express-graphql"; + +const prisma = new PrismaClient(); + +const User = objectType({ + name: "User", + definition(t) { + t.string("email"); + t.string("name", { nullable: true }); + }, +}); + +const Query = queryType({ + definition(t) { + t.list.field("allUsers", { + type: "User", + resolve: () => prisma.user.findMany(), + }); + }, +}); + +const schema = makeSchema({ + types: [User, Query], +}); + +const app = express(); +app.use( + "/graphql", + graphqlHTTP({ + schema, + }), +); + +app.listen(4000); diff --git a/docs/vs_nexus/vs_nexus.md b/docs/vs_nexus/vs_nexus.md new file mode 100644 index 00000000..e69de29b diff --git a/docs/vs_typegraphql/after.ts b/docs/vs_typegraphql/after.ts new file mode 100644 index 00000000..da3ed460 --- /dev/null +++ b/docs/vs_typegraphql/after.ts @@ -0,0 +1,35 @@ +import { PrismaClient } from "@prisma/client"; +import express from "express"; +import { graphqlHTTP } from "express-graphql"; + +const prisma = new PrismaClient(); + +/** @GQLType */ +export class User { + /** @GQLField */ + email: string; + + /** @GQLField */ + name?: string | null; +} + +/** @GQLType */ +export class Query { + /** @GQLField */ + async allUsers() { + return prisma.user.findMany(); + } +} + +const schema = buildSchemaSync({ + resolvers: [PostResolver, UserResolver], +}); + +const app = express(); +app.use( + "/graphql", + graphqlHTTP({ + schema, + }), +); +app.listen(4000); diff --git a/docs/vs_typegraphql/before.ts b/docs/vs_typegraphql/before.ts new file mode 100644 index 00000000..a88ec736 --- /dev/null +++ b/docs/vs_typegraphql/before.ts @@ -0,0 +1,36 @@ +import { PrismaClient } from "@prisma/client"; +import { ObjectType, Field, ID, buildSchemaSync } from "type-graphql"; +import express from "express"; +import { graphqlHTTP } from "express-graphql"; + +const prisma = new PrismaClient(); + +@ObjectType() +export class User { + @Field() + email: string; + + @Field((type) => String, { nullable: true }) + name?: string | null; +} + +@Resolver(User) +export class UserResolver { + @Query((returns) => [User], { nullable: true }) + async allUsers() { + return prisma.user.findMany(); + } +} + +const schema = buildSchemaSync({ + resolvers: [PostResolver, UserResolver], +}); + +const app = express(); +app.use( + "/graphql", + graphqlHTTP({ + schema, + }), +); +app.listen(4000); diff --git a/docs/vs_typegraphql/vs_typegraphql.md b/docs/vs_typegraphql/vs_typegraphql.md new file mode 100644 index 00000000..ef55268e --- /dev/null +++ b/docs/vs_typegraphql/vs_typegraphql.md @@ -0,0 +1,26 @@ +## TypeGraphQL + Prisma + +```diff +2d1 +< import { ObjectType, Field, ID, buildSchemaSync } from "type-graphql"; +8c7 +< @ObjectType() +--- +> /** @GQLType */ +10c9 +< @Field() +--- +> /** @GQLField */ +13c12 +< @Field((type) => String, { nullable: true }) +--- +> /** @GQLField */ +17,19c16,18 +< @Resolver(User) +< export class UserResolver { +< @Query((returns) => [User], { nullable: true }) +--- +> /** @GQLType */ +> export class Query { +> /** @GQLField */ +``` diff --git a/example-server/models/User.ts b/example-server/models/User.ts index 5f5fb594..6f1ea85e 100644 --- a/example-server/models/User.ts +++ b/example-server/models/User.ts @@ -1,3 +1,4 @@ +import Query from "../Query"; import Group from "./Group"; /** @GQLType User */ @@ -11,3 +12,8 @@ export default class UserResolver { return [new Group()]; } } + +/** @GQLExtendType */ +export function allUsers(_: Query): UserResolver[] { + return [new UserResolver(), new UserResolver()]; +} diff --git a/example-server/schema.graphql b/example-server/schema.graphql index 878714e9..cd366c9e 100644 --- a/example-server/schema.graphql +++ b/example-server/schema.graphql @@ -2,19 +2,22 @@ schema { query: Query } -directive @renameField(name: String!) on FIELD_DEFINITION +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +directive @methodName(name: String!) on FIELD_DEFINITION type Group { - description: String - members: [User!] - name: String + description: String! + members: [User!]! + name: String! } type Query { - me: User + allUsers: [User!]! @exported(filename: "/Users/captbaritone/projects/grats/example-server/models/User.ts", functionName: "allUsers") + me: User! } type User { - groups: [Group!] - name: String + groups: [Group!]! + name: String! } \ No newline at end of file diff --git a/src/Extractor.ts b/src/Extractor.ts index 049b747c..a35c4e0e 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -27,7 +27,13 @@ import { import * as ts from "typescript"; import { TypeContext, UNRESOLVED_REFERENCE_NAME } from "./TypeContext"; import { BuildOptions } from "./lib"; -import { METHOD_NAME_ARG, METHOD_NAME_DIRECTIVE } from "./serverDirectives"; +import { + EXPORTED_DIRECTIVE, + EXPORTED_FILENAME_ARG, + EXPORTED_FUNCTION_NAME_ARG, + METHOD_NAME_ARG, + METHOD_NAME_DIRECTIVE, +} from "./serverDirectives"; const LIBRARY_IMPORT_NAME = "grats"; const LIBRARY_NAME = "Grats"; @@ -40,6 +46,7 @@ const INTERFACE_TAG = "GQLInterface"; const ENUM_TAG = "GQLEnum"; const UNION_TAG = "GQLUnion"; const INPUT_TAG = "GQLInput"; +const EXTEND_TYPE = "GQLExtendType"; const DEPRECATED_TAG = "deprecated"; @@ -104,6 +111,9 @@ export class Extractor { case UNION_TAG: this.extractUnion(node, tag); break; + case EXTEND_TYPE: + this.extractExtendType(node, tag); + break; } } }); @@ -183,9 +193,21 @@ export class Extractor { } } + extractExtendType(node: ts.Node, tag: ts.JSDocTag) { + if (ts.isFunctionDeclaration(node)) { + // FIXME: Validate that the function is a named export + this.functionDeclarationExtendType(node, tag); + } else { + this.report( + tag, + `\`@${EXTEND_TYPE}\` can only be used on function declarations.`, + ); + } + } + /** Error handling and location juggling */ - report(node: ts.Node, message: string) { + report(node: ts.Node, message: string): null { const start = node.getStart(); const length = node.getEnd() - start; this.errors.push({ @@ -196,14 +218,16 @@ export class Extractor { start, length, }); + return null; } // Report an error that we don't know how to infer a type, but it's possible that we should. // Gives the user a path forward if they think we should be able to infer this type. - reportUnhandled(node: ts.Node, message: string) { + reportUnhandled(node: ts.Node, message: string): null { const suggestion = `If you think ${LIBRARY_NAME} should be able to infer this type, please report an issue at ${ISSUE_URL}.`; const completedMessage = `${message}\n\n${suggestion}`; this.report(node, completedMessage); + return null; } diagnosticAnnotatedLocation(node: ts.Node): { @@ -239,28 +263,26 @@ export class Extractor { if (name == null) return null; if (!ts.isUnionTypeNode(node.type)) { - this.report( + return this.report( node, `Expected a TypeScript union. \`@${UNION_TAG}\` can only be used on TypeScript unions.`, ); - return null; } const description = this.collectDescription(node.name); const types: NamedTypeNode[] = []; for (const member of node.type.types) { if (!ts.isTypeReferenceNode(member)) { - this.reportUnhandled( + return this.reportUnhandled( member, `Expected \`@${UNION_TAG}\` union members to be type references.`, ); - return null; } const namedType = this.gqlNamedType( member.typeName, UNRESOLVED_REFERENCE_NAME, ); - this.ctx.markUnresolvedType(member.typeName, namedType); + this.ctx.markUnresolvedType(member.typeName, namedType.name); types.push(namedType); } @@ -275,6 +297,134 @@ export class Extractor { }); } + functionDeclarationExtendType( + node: ts.FunctionDeclaration, + tag: ts.JSDocTag, + ) { + const funcName = this.namedFunctionExportName(node); + if (funcName == null) return null; + + const typeParam = node.parameters[0]; + if (typeParam == null) { + return this.report( + funcName, + `Expected type extension function to have a first argument representing the type to extend.`, + ); + } + + const typeName = this.typeReferenceFromParam(typeParam); + if (typeName == null) return null; + + const name = this.entityName(node, tag); + if (name == null) return null; + + if (node.type == null) { + return this.report( + funcName, + "Expected GraphQL field to have an explicit return type.", + ); + } + + const type = this.collectType(node.type); + if (type == null) return null; + + let args: readonly InputValueDefinitionNode[] | null = null; + const argsParam = node.parameters[1]; + if (argsParam != null) { + args = this.collectArgs(argsParam); + } + + const description = this.collectDescription(funcName); + + if (!ts.isSourceFile(node.parent)) { + return this.report( + node, + "Expected type extension function to be a top-level declaration.", + ); + } + + let directives = [ + this.exportDirective(funcName, node.parent.fileName, funcName.text), + ]; + + if (funcName.text !== name.value) { + directives = [this.methodNameDirective(funcName, funcName.text)]; + } + + const deprecated = this.collectDeprecated(node); + if (deprecated != null) { + directives.push(deprecated); + } + + this.definitions.push({ + kind: Kind.OBJECT_TYPE_EXTENSION, + loc: this.loc(node), + name: typeName, + fields: [ + { + kind: Kind.FIELD_DEFINITION, + loc: this.loc(node), + description: description || undefined, + name, + arguments: args || undefined, + type: this.handleErrorBubbling(type), + directives: directives.length === 0 ? undefined : directives, + }, + ], + }); + } + + typeReferenceFromParam(typeParam: ts.ParameterDeclaration): NameNode | null { + if (typeParam.type == null) { + return this.report( + typeParam, + `Expected first argument of a type extension function to have an explicit type annotation.`, + ); + } + if (!ts.isTypeReferenceNode(typeParam.type)) { + return this.report( + typeParam.type, + `Expected first argument of a type extension function to be typed as a \`@GQLType\` type.`, + ); + } + + const nameNode = typeParam.type.typeName; + const typeName = this.gqlName(nameNode, UNRESOLVED_REFERENCE_NAME); + this.ctx.markUnresolvedType(nameNode, typeName); + return typeName; + } + + namedFunctionExportName(node: ts.FunctionDeclaration): ts.Identifier | null { + if (node.name == null) { + return this.report( + node, + `Expected type extension function to be a named function.`, + ); + } + const exportKeyword = node.modifiers?.some((modifier) => { + return modifier.kind === ts.SyntaxKind.ExportKeyword; + }); + const defaultKeyword = node.modifiers?.find((modifier) => { + return modifier.kind === ts.SyntaxKind.DefaultKeyword; + }); + + if (defaultKeyword != null) { + // TODO: We could support this + return this.report( + defaultKeyword, + `Expected type extension function to be a named export, not a default export.`, + ); + } + + if (exportKeyword == null) { + return this.report( + node.name, + `Expected type extension function to be a named export.`, + ); + } + return node.name; + } + scalarTypeAliasDeclaration(node: ts.TypeAliasDeclaration, tag: ts.JSDocTag) { const name = this.entityName(node, tag); if (name == null) return null; @@ -314,11 +464,10 @@ export class Extractor { const fields: Array = []; if (!ts.isTypeLiteralNode(node.type)) { - this.reportUnhandled( + return this.reportUnhandled( node, `\`@${INPUT_TAG}\` can only be used on type literals.`, ); - return null; } for (const member of node.type.members) { @@ -343,8 +492,7 @@ export class Extractor { if (id == null) return null; if (node.type == null) { - this.report(node, "Input field must have a type annotation."); - return null; + return this.report(node, "Input field must have a type annotation."); } const inner = this.collectType(node.type); @@ -368,11 +516,10 @@ export class Extractor { typeClassDeclaration(node: ts.ClassDeclaration, tag: ts.JSDocTag) { if (node.name == null) { - this.report( + return this.report( node, `Unexpected \`@${TYPE_TAG}\` annotation on unnamed class declaration.`, ); - return null; } const name = this.entityName(node, tag); @@ -431,7 +578,7 @@ export class Extractor { type.expression, UNRESOLVED_REFERENCE_NAME, ); - this.ctx.markUnresolvedType(type.expression, namedType); + this.ctx.markUnresolvedType(type.expression, namedType.name); return namedType; }); }); @@ -500,30 +647,25 @@ export class Extractor { } collectArgs( - node: ts.MethodDeclaration, + argsParam: ts.ParameterDeclaration, ): ReadonlyArray | null { const args: InputValueDefinitionNode[] = []; - const argsParam = node.parameters[0]; - if (argsParam == null) { - return null; - } + const argsType = argsParam.type; if (argsType == null) { - this.report( + return this.report( argsParam, "Expected GraphQL field arguments to have a TypeScript type. If there are no arguments, you can use `args: never`.", ); - return null; } if (argsType.kind === ts.SyntaxKind.NeverKeyword) { return []; } if (!ts.isTypeLiteralNode(argsType)) { - this.report( + return this.report( argsType, "Expected GraphQL field arguments to be typed using a literal object: `{someField: string}`.", ); - return null; } let defaults: ArgDefaults | null = null; @@ -577,24 +719,24 @@ export class Extractor { ): InputValueDefinitionNode | null { if (!ts.isPropertySignature(node)) { // TODO: How can I create this error? - this.report( + return this.report( node, "Expected GraphQL field argument type to be a property signature.", ); - return null; } if (!ts.isIdentifier(node.name)) { // TODO: How can I create this error? - this.report( + return this.report( node.name, "Expected GraphQL field argument names to be a literal.", ); - return null; } if (node.type == null) { - this.report(node.name, "Expected GraphQL field argument to have a type."); - return null; + return this.report( + node.name, + "Expected GraphQL field argument to have a type.", + ); } const type = this.collectType(node.type); if (type == null) return null; @@ -729,7 +871,8 @@ export class Extractor { | ts.InterfaceDeclaration | ts.PropertySignature | ts.EnumDeclaration - | ts.TypeAliasDeclaration, + | ts.TypeAliasDeclaration + | ts.FunctionDeclaration, tag: ts.JSDocTag, ) { if (tag.comment != null) { @@ -741,8 +884,7 @@ export class Extractor { } if (node.name == null) { - this.report(node, "Expected GraphQL entity to have a name."); - return null; + return this.report(node, "Expected GraphQL entity to have a name."); } const id = this.expectIdentifier(node.name); if (id == null) return null; @@ -757,8 +899,7 @@ export class Extractor { if (name == null) return null; if (node.type == null) { - this.report(node.name, "Expected GraphQL field to have a type."); - return null; + return this.report(node.name, "Expected GraphQL field to have a type."); } const type = this.collectType(node.type); @@ -766,7 +907,11 @@ export class Extractor { // We already reported an error if (type == null) return null; - const args = this.collectArgs(node); + let args: readonly InputValueDefinitionNode[] | null = null; + const argsParam = node.parameters[0]; + if (argsParam != null) { + args = this.collectArgs(argsParam); + } const description = this.collectDescription(node.name); @@ -796,11 +941,10 @@ export class Extractor { collectDescription(node: ts.Node): StringValueNode | null { const symbol = this.ctx.checker.getSymbolAtLocation(node); if (symbol == null) { - this.report( + return this.report( node, "Expected TypeScript to be able to resolve this GraphQL entity to a symbol.", ); - return null; } const doc = symbol.getDocumentationComment(this.ctx.checker); const description = ts.displayPartsToString(doc); @@ -906,17 +1050,15 @@ export class Extractor { } else if (node.kind === ts.SyntaxKind.BooleanKeyword) { return this.gqlNonNullType(node, this.gqlNamedType(node, "Boolean")); } else if (node.kind === ts.SyntaxKind.NumberKeyword) { - this.report( + return this.report( node, `Unexpected number type. GraphQL supports both Int and Float, making \`number\` ambiguous. Instead, import the \`Int\` or \`Float\` type from \`${LIBRARY_IMPORT_NAME}\` and use that.`, ); - return null; } else if (ts.isTypeLiteralNode(node)) { - this.report( + return this.report( node, `Unexpected type literal. You may want to define a named GraphQL type elsewhere and reference it here.`, ); - return null; } // TODO: Better error message. This is okay if it's a type reference, but everything else is not. this.reportUnhandled(node, `Unknown GraphQL type.`); @@ -931,8 +1073,10 @@ export class Extractor { switch (typeName) { case "Promise": { if (node.typeArguments == null) { - this.report(node, `Expected type reference to have type arguments.`); - return null; + return this.report( + node, + `Expected type reference to have type arguments.`, + ); } const type = this.collectType(node.typeArguments[0]); if (type == null) return null; @@ -942,8 +1086,10 @@ export class Extractor { case "Iterator": case "ReadonlyArray": { if (node.typeArguments == null) { - this.report(node, `Expected type reference to have type arguments.`); - return null; + return this.report( + node, + `Expected type reference to have type arguments.`, + ); } const element = this.collectType(node.typeArguments[0]); if (element == null) return null; @@ -955,7 +1101,7 @@ export class Extractor { // // A later pass will resolve the type. const namedType = this.gqlNamedType(node, UNRESOLVED_REFERENCE_NAME); - this.ctx.markUnresolvedType(node.typeName, namedType); + this.ctx.markUnresolvedType(node.typeName, namedType.name); return this.gqlNonNullType(node, namedType); } } @@ -982,8 +1128,7 @@ export class Extractor { if (ts.isIdentifier(node)) { return node; } - this.report(node, "Expected an identifier."); - return null; + return this.report(node, "Expected an identifier."); } findTag(node: ts.Node, tagName: string): ts.JSDocTag | null { @@ -1019,6 +1164,29 @@ export class Extractor { ); } + exportDirective( + nameNode: ts.Node, + filename: string, + functionName, + ): ConstDirectiveNode { + return this.gqlConstDirective( + nameNode, + this.gqlName(nameNode, EXPORTED_DIRECTIVE), + [ + this.gqlConstArgument( + nameNode, + this.gqlName(nameNode, EXPORTED_FILENAME_ARG), + this.gqlString(nameNode, filename), + ), + this.gqlConstArgument( + nameNode, + this.gqlName(nameNode, EXPORTED_FUNCTION_NAME_ARG), + this.gqlString(nameNode, functionName), + ), + ], + ); + } + /** GraphQL AST node helper methods */ gqlName(node: ts.Node, value: string): NameNode { diff --git a/src/TypeContext.ts b/src/TypeContext.ts index 1a08f471..502ec864 100644 --- a/src/TypeContext.ts +++ b/src/TypeContext.ts @@ -1,4 +1,4 @@ -import { DocumentNode, NamedTypeNode, visit } from "graphql"; +import { DocumentNode, NameNode, visit } from "graphql"; import * as ts from "typescript"; import { DiagnosticResult, @@ -27,7 +27,7 @@ export class TypeContext { host: ts.CompilerHost; _symbolToName: Map = new Map(); - _unresolvedTypes: Map = new Map(); + _unresolvedTypes: Map = new Map(); constructor(checker: ts.TypeChecker, host: ts.CompilerHost) { this.checker = checker; @@ -49,9 +49,10 @@ export class TypeContext { this._symbolToName.set(symbol, name); } - markUnresolvedType(node: ts.Node, namedType: NamedTypeNode) { + markUnresolvedType(node: ts.Node, name: NameNode) { let symbol = this.checker.getSymbolAtLocation(node); if (symbol == null) { + // throw new Error( "Could not resolve type reference. You probably have a TypeScript error.", ); @@ -62,13 +63,13 @@ export class TypeContext { symbol = this.checker.getAliasedSymbol(symbol); } - this._unresolvedTypes.set(namedType, symbol); + this._unresolvedTypes.set(name, symbol); } resolveTypes(doc: DocumentNode): DiagnosticsResult { const errors: ts.Diagnostic[] = []; const newDoc = visit(doc, { - NamedType: (t) => { + Name: (t) => { const namedTypeResult = this.resolveNamedType(t); if (namedTypeResult.kind === "ERROR") { errors.push(namedTypeResult.err); @@ -83,34 +84,34 @@ export class TypeContext { return ok(newDoc); } - resolveNamedType(namedType: NamedTypeNode): DiagnosticResult { - const symbol = this._unresolvedTypes.get(namedType); + resolveNamedType(unresolved: NameNode): DiagnosticResult { + const symbol = this._unresolvedTypes.get(unresolved); if (symbol == null) { - if (namedType.name.value === UNRESOLVED_REFERENCE_NAME) { + if (unresolved.value === UNRESOLVED_REFERENCE_NAME) { // This is a logic error on our side. throw new Error("Unexpected unresolved reference name."); } - return ok(namedType); + return ok(unresolved); } const name = this._symbolToName.get(symbol); if (name == null) { - if (namedType.loc == null) { + if (unresolved.loc == null) { throw new Error("Expected namedType to have a location."); } return err({ messageText: "This type is not a valid GraphQL type. Did you mean to annotate it's definition with `/** @GQLType */` or `/** @GQLScalar */`?", - start: namedType.loc.start, - length: namedType.loc.end - namedType.loc.start, + start: unresolved.loc.start, + length: unresolved.loc.end - unresolved.loc.start, category: ts.DiagnosticCategory.Error, code: FAKE_ERROR_CODE, file: ts.createSourceFile( - namedType.loc.source.name, - namedType.loc.source.body, + unresolved.loc.source.name, + unresolved.loc.source.body, ts.ScriptTarget.Latest, ), }); } - return ok({ ...namedType, name: { ...namedType.name, value: name } }); + return ok({ ...unresolved, value: name }); } } diff --git a/src/serverDirectives.ts b/src/serverDirectives.ts index 5ee8cbde..0421d928 100644 --- a/src/serverDirectives.ts +++ b/src/serverDirectives.ts @@ -1,44 +1,120 @@ import { mapSchema, getDirective, MapperKind } from "@graphql-tools/utils"; -import { defaultFieldResolver, GraphQLSchema, parse } from "graphql"; +import { + defaultFieldResolver, + GraphQLFieldConfig, + GraphQLSchema, + parse, +} from "graphql"; export const METHOD_NAME_DIRECTIVE = "methodName"; export const METHOD_NAME_ARG = "name"; +export const EXPORTED_DIRECTIVE = "exported"; +export const EXPORTED_FILENAME_ARG = "filename"; +export const EXPORTED_FUNCTION_NAME_ARG = "functionName"; export const DIRECTIVES_AST = parse(` directive @${METHOD_NAME_DIRECTIVE}(${METHOD_NAME_ARG}: String!) on FIELD_DEFINITION + directive @${EXPORTED_DIRECTIVE}( + ${EXPORTED_FILENAME_ARG}: String!, + ${EXPORTED_FUNCTION_NAME_ARG}: String! + ) on FIELD_DEFINITION `); -/** - * Field renaming directive: - * - * By default, when resolving a field, the server will take the schema field - * name, and look for a resolver/property by that name on the parent object. - * Since we support exposing a method/property under a different name, we need - * to modify that field's resolver to look for the implementation name rather - * than the schema name. - */ export function applyServerDirectives(schema: GraphQLSchema): GraphQLSchema { // TODO: Do we really need all of mapSchema here or can we create our own // thing that's simpler. return mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig) => { + let newFieldConfig = fieldConfig; + const methodNameDirective = getDirective( schema, fieldConfig, METHOD_NAME_DIRECTIVE, )?.[0]; - if (!methodNameDirective) return; - const { resolve = defaultFieldResolver } = fieldConfig; - return { - ...fieldConfig, - resolve(source, args, context, info) { - const newInfo = { - ...info, - fieldName: methodNameDirective[METHOD_NAME_ARG], - }; - return resolve(source, args, context, newInfo); - }, - }; + if (methodNameDirective != null) { + newFieldConfig = applyMethodNameDirective( + newFieldConfig, + methodNameDirective, + ); + } + + const exportedDirective = getDirective( + schema, + fieldConfig, + EXPORTED_DIRECTIVE, + )?.[0]; + + if (exportedDirective != null) { + newFieldConfig = applyExportDirective( + newFieldConfig, + exportedDirective, + ); + } + + return newFieldConfig; }, }); } + +/** + * Field renaming directive: + * + * By default, when resolving a field, the server will take the schema field + * name, and look for a resolver/property by that name on the parent object. + * Since we support exposing a method/property under a different name, we need + * to modify that field's resolver to look for the implementation name rather + * than the schema name. + */ +function applyMethodNameDirective( + fieldConfig: GraphQLFieldConfig, + methodNameDirective: Record, +): GraphQLFieldConfig { + const { resolve = defaultFieldResolver } = fieldConfig; + return { + ...fieldConfig, + resolve(source, args, context, info) { + const newInfo = { + ...info, + fieldName: methodNameDirective[METHOD_NAME_ARG], + }; + return resolve(source, args, context, newInfo); + }, + }; +} + +/** + * Export directive: + * + * By default, when resolving a field, the server will look for a resolver + * function on the parent object. This directive allows you to specify a + * module and function name to import and use as the resolver. + */ +function applyExportDirective( + fieldConfig: GraphQLFieldConfig, + methodNameDirective: Record, +): GraphQLFieldConfig { + const filename = methodNameDirective[EXPORTED_FILENAME_ARG]; + const functionName = methodNameDirective[EXPORTED_FUNCTION_NAME_ARG]; + return { + ...fieldConfig, + async resolve(source, args, context, info) { + let mod: any = {}; + try { + mod = await import(filename); + } catch (e) { + console.error( + `Grats Error: Failed to import module \`${filename}\`. You may need to rerun Grats.`, + ); + throw e; + } + const resolve = mod[functionName]; + if (typeof resolve !== "function") { + throw new Error( + `Grats Error: Expected \`${filename}\` to have a named export \`${functionName}\` that is a function, but it was \`${typeof resolve}\`. You may need to rerun Grats.`, + ); + } + return resolve(source, args, context, info); + }, + }; +} diff --git a/src/tests/fixtures/extend_type/addDeprecatedField.ts b/src/tests/fixtures/extend_type/addDeprecatedField.ts new file mode 100644 index 00000000..2c275b28 --- /dev/null +++ b/src/tests/fixtures/extend_type/addDeprecatedField.ts @@ -0,0 +1,12 @@ +/** @GQLType */ +class Query { + // No fields +} + +/** + * @GQLExtendType + * @deprecated Because reasons + */ +export function greeting(query: Query): string { + return "Hello world!"; +} diff --git a/src/tests/fixtures/extend_type/addDeprecatedField.ts.expected b/src/tests/fixtures/extend_type/addDeprecatedField.ts.expected new file mode 100644 index 00000000..fec8bf86 --- /dev/null +++ b/src/tests/fixtures/extend_type/addDeprecatedField.ts.expected @@ -0,0 +1,30 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + // No fields +} + +/** + * @GQLExtendType + * @deprecated Because reasons + */ +export function greeting(query: Query): string { + return "Hello world!"; +} + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + greeting: String @deprecated(reason: "Because reasons") @exported(filename: "/Users/captbaritone/projects/grats/src/tests/fixtures/extend_type/addDeprecatedField.ts", functionName: "greeting") +} \ No newline at end of file diff --git a/src/tests/fixtures/extend_type/addFieldWithArguments.ts b/src/tests/fixtures/extend_type/addFieldWithArguments.ts new file mode 100644 index 00000000..015c1d5e --- /dev/null +++ b/src/tests/fixtures/extend_type/addFieldWithArguments.ts @@ -0,0 +1,9 @@ +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting(_: Query, args: { name: string }): string { + return `Hello ${args.name}!`; +} diff --git a/src/tests/fixtures/extend_type/addFieldWithArguments.ts.expected b/src/tests/fixtures/extend_type/addFieldWithArguments.ts.expected new file mode 100644 index 00000000..74bbbd6e --- /dev/null +++ b/src/tests/fixtures/extend_type/addFieldWithArguments.ts.expected @@ -0,0 +1,27 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting(_: Query, args: { name: string }): string { + return `Hello ${args.name}!`; +} + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + greeting(name: String!): String @exported(filename: "/Users/captbaritone/projects/grats/src/tests/fixtures/extend_type/addFieldWithArguments.ts", functionName: "greeting") +} \ No newline at end of file diff --git a/src/tests/fixtures/extend_type/addFieldWithDescription.ts b/src/tests/fixtures/extend_type/addFieldWithDescription.ts new file mode 100644 index 00000000..3a42a276 --- /dev/null +++ b/src/tests/fixtures/extend_type/addFieldWithDescription.ts @@ -0,0 +1,12 @@ +/** @GQLType */ +class Query { + // No fields +} + +/** + * Best field ever! + * @GQLExtendType + */ +export function greeting(_: Query): string { + return "Hello world!"; +} diff --git a/src/tests/fixtures/extend_type/addFieldWithDescription.ts.expected b/src/tests/fixtures/extend_type/addFieldWithDescription.ts.expected new file mode 100644 index 00000000..c9fa7f73 --- /dev/null +++ b/src/tests/fixtures/extend_type/addFieldWithDescription.ts.expected @@ -0,0 +1,31 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + // No fields +} + +/** + * Best field ever! + * @GQLExtendType + */ +export function greeting(_: Query): string { + return "Hello world!"; +} + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + "Best field ever!" + greeting: String @exported(filename: "/Users/captbaritone/projects/grats/src/tests/fixtures/extend_type/addFieldWithDescription.ts", functionName: "greeting") +} \ No newline at end of file diff --git a/src/tests/fixtures/extend_type/addStringFieldToQuery.ts b/src/tests/fixtures/extend_type/addStringFieldToQuery.ts new file mode 100644 index 00000000..5badf246 --- /dev/null +++ b/src/tests/fixtures/extend_type/addStringFieldToQuery.ts @@ -0,0 +1,9 @@ +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting(_: Query): string { + return "Hello world!"; +} diff --git a/src/tests/fixtures/extend_type/addStringFieldToQuery.ts.expected b/src/tests/fixtures/extend_type/addStringFieldToQuery.ts.expected new file mode 100644 index 00000000..70f9ad4f --- /dev/null +++ b/src/tests/fixtures/extend_type/addStringFieldToQuery.ts.expected @@ -0,0 +1,27 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting(_: Query): string { + return "Hello world!"; +} + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + greeting: String @exported(filename: "/Users/captbaritone/projects/grats/src/tests/fixtures/extend_type/addStringFieldToQuery.ts", functionName: "greeting") +} \ No newline at end of file diff --git a/src/tests/fixtures/extend_type/defaultExport.invalid.ts b/src/tests/fixtures/extend_type/defaultExport.invalid.ts new file mode 100644 index 00000000..b1a362e5 --- /dev/null +++ b/src/tests/fixtures/extend_type/defaultExport.invalid.ts @@ -0,0 +1,9 @@ +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export default function greeting(_: Query): string { + return `Hello World`; +} diff --git a/src/tests/fixtures/extend_type/defaultExport.invalid.ts.expected b/src/tests/fixtures/extend_type/defaultExport.invalid.ts.expected new file mode 100644 index 00000000..67c57d24 --- /dev/null +++ b/src/tests/fixtures/extend_type/defaultExport.invalid.ts.expected @@ -0,0 +1,20 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export default function greeting(_: Query): string { + return `Hello World`; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/extend_type/defaultExport.invalid.ts:7:8 - error: Expected type extension function to be a named export, not a default export. + +7 export default function greeting(_: Query): string { + ~~~~~~~ diff --git a/src/tests/fixtures/extend_type/interfaceFirstArgumentType.invalid.ts b/src/tests/fixtures/extend_type/interfaceFirstArgumentType.invalid.ts new file mode 100644 index 00000000..fa806d27 --- /dev/null +++ b/src/tests/fixtures/extend_type/interfaceFirstArgumentType.invalid.ts @@ -0,0 +1,16 @@ +/** @GQLType */ +class Query { + /** @GQLField */ + foo: string; +} + +/** @GQLInterface */ +interface IFoo { + /** @GQLField */ + bar: string; +} + +/** @GQLExtendType */ +export function greeting(iFoo: IFoo): string { + return "Hello world!"; +} diff --git a/src/tests/fixtures/extend_type/interfaceFirstArgumentType.invalid.ts.expected b/src/tests/fixtures/extend_type/interfaceFirstArgumentType.invalid.ts.expected new file mode 100644 index 00000000..acf47dfd --- /dev/null +++ b/src/tests/fixtures/extend_type/interfaceFirstArgumentType.invalid.ts.expected @@ -0,0 +1,27 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + /** @GQLField */ + foo: string; +} + +/** @GQLInterface */ +interface IFoo { + /** @GQLField */ + bar: string; +} + +/** @GQLExtendType */ +export function greeting(iFoo: IFoo): string { + return "Hello world!"; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/extend_type/interfaceFirstArgumentType.invalid.ts:8:1 - error: Cannot extend non-object type "IFoo". + +8 interface IFoo { + ~ diff --git a/src/tests/fixtures/extend_type/missingFirstArgument.invalid.ts b/src/tests/fixtures/extend_type/missingFirstArgument.invalid.ts new file mode 100644 index 00000000..35cc389c --- /dev/null +++ b/src/tests/fixtures/extend_type/missingFirstArgument.invalid.ts @@ -0,0 +1,9 @@ +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting(/* Without an arg we can't infer the type! */): string { + return "Hello world!"; +} diff --git a/src/tests/fixtures/extend_type/missingFirstArgument.invalid.ts.expected b/src/tests/fixtures/extend_type/missingFirstArgument.invalid.ts.expected new file mode 100644 index 00000000..b551be99 --- /dev/null +++ b/src/tests/fixtures/extend_type/missingFirstArgument.invalid.ts.expected @@ -0,0 +1,20 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting(/* Without an arg we can't infer the type! */): string { + return "Hello world!"; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/extend_type/missingFirstArgument.invalid.ts:7:17 - error: Expected type extension function to have a first argument representing the type to extend. + +7 export function greeting(/* Without an arg we can't infer the type! */): string { + ~~~~~~~~ diff --git a/src/tests/fixtures/extend_type/missingFirstArgumentType.invalid.ts b/src/tests/fixtures/extend_type/missingFirstArgumentType.invalid.ts new file mode 100644 index 00000000..8800e10d --- /dev/null +++ b/src/tests/fixtures/extend_type/missingFirstArgumentType.invalid.ts @@ -0,0 +1,11 @@ +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting( + query /* Without an arg type we can't infer the GraphQL type to extend! */, +): string { + return "Hello world!"; +} diff --git a/src/tests/fixtures/extend_type/missingFirstArgumentType.invalid.ts.expected b/src/tests/fixtures/extend_type/missingFirstArgumentType.invalid.ts.expected new file mode 100644 index 00000000..bb76c2bc --- /dev/null +++ b/src/tests/fixtures/extend_type/missingFirstArgumentType.invalid.ts.expected @@ -0,0 +1,22 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting( + query /* Without an arg type we can't infer the GraphQL type to extend! */, +): string { + return "Hello world!"; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/extend_type/missingFirstArgumentType.invalid.ts:8:3 - error: Expected first argument of a type extension function to have an explicit type annotation. + +8 query /* Without an arg type we can't infer the GraphQL type to extend! */, + ~~~~~ diff --git a/src/tests/fixtures/extend_type/nonAliasFirstArgumentType.invalid.ts b/src/tests/fixtures/extend_type/nonAliasFirstArgumentType.invalid.ts new file mode 100644 index 00000000..0a5a1a80 --- /dev/null +++ b/src/tests/fixtures/extend_type/nonAliasFirstArgumentType.invalid.ts @@ -0,0 +1,9 @@ +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting(query: { name: string }): string { + return "Hello world!"; +} diff --git a/src/tests/fixtures/extend_type/nonAliasFirstArgumentType.invalid.ts.expected b/src/tests/fixtures/extend_type/nonAliasFirstArgumentType.invalid.ts.expected new file mode 100644 index 00000000..264cb05a --- /dev/null +++ b/src/tests/fixtures/extend_type/nonAliasFirstArgumentType.invalid.ts.expected @@ -0,0 +1,20 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting(query: { name: string }): string { + return "Hello world!"; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/extend_type/nonAliasFirstArgumentType.invalid.ts:7:33 - error: Expected first argument of a type extension function to be typed as a `@GQLType` type. + +7 export function greeting(query: { name: string }): string { + ~~~~~~~~~~~~~~~~ diff --git a/src/tests/fixtures/extend_type/nonGQLFirstArgumentType.invalid.ts b/src/tests/fixtures/extend_type/nonGQLFirstArgumentType.invalid.ts new file mode 100644 index 00000000..2e9bd064 --- /dev/null +++ b/src/tests/fixtures/extend_type/nonGQLFirstArgumentType.invalid.ts @@ -0,0 +1,11 @@ +/** @GQLType */ +class Query { + // No fields +} + +class Foo {} + +/** @GQLExtendType */ +export function greeting(query: Foo): string { + return "Hello world!"; +} diff --git a/src/tests/fixtures/extend_type/nonGQLFirstArgumentType.invalid.ts.expected b/src/tests/fixtures/extend_type/nonGQLFirstArgumentType.invalid.ts.expected new file mode 100644 index 00000000..6388d064 --- /dev/null +++ b/src/tests/fixtures/extend_type/nonGQLFirstArgumentType.invalid.ts.expected @@ -0,0 +1,22 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + // No fields +} + +class Foo {} + +/** @GQLExtendType */ +export function greeting(query: Foo): string { + return "Hello world!"; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/extend_type/nonGQLFirstArgumentType.invalid.ts:9:33 - error: This type is not a valid GraphQL type. Did you mean to annotate it's definition with `/** @GQLType */` or `/** @GQLScalar */`? + +9 export function greeting(query: Foo): string { + ~~~ diff --git a/src/tests/fixtures/extend_type/notExported.invalid.ts b/src/tests/fixtures/extend_type/notExported.invalid.ts new file mode 100644 index 00000000..ab21deef --- /dev/null +++ b/src/tests/fixtures/extend_type/notExported.invalid.ts @@ -0,0 +1,9 @@ +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +function greeting(_: Query): string { + return `Hello World`; +} diff --git a/src/tests/fixtures/extend_type/notExported.invalid.ts.expected b/src/tests/fixtures/extend_type/notExported.invalid.ts.expected new file mode 100644 index 00000000..06e00023 --- /dev/null +++ b/src/tests/fixtures/extend_type/notExported.invalid.ts.expected @@ -0,0 +1,20 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +function greeting(_: Query): string { + return `Hello World`; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/extend_type/notExported.invalid.ts:7:10 - error: Expected type extension function to be a named export. + +7 function greeting(_: Query): string { + ~~~~~~~~ diff --git a/src/tests/fixtures/extend_type/optionalModelType.ts b/src/tests/fixtures/extend_type/optionalModelType.ts new file mode 100644 index 00000000..cdc97a5f --- /dev/null +++ b/src/tests/fixtures/extend_type/optionalModelType.ts @@ -0,0 +1,16 @@ +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting( + // A bit odd that this is optional, but it's fine, since we will always call + // it with a non-null value + q?: Query, +): string { + if (q == null) { + return "Out!"; + } + return "Hello world!"; +} diff --git a/src/tests/fixtures/extend_type/optionalModelType.ts.expected b/src/tests/fixtures/extend_type/optionalModelType.ts.expected new file mode 100644 index 00000000..b567ace3 --- /dev/null +++ b/src/tests/fixtures/extend_type/optionalModelType.ts.expected @@ -0,0 +1,34 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + // No fields +} + +/** @GQLExtendType */ +export function greeting( + // A bit odd that this is optional, but it's fine, since we will always call + // it with a non-null value + q?: Query, +): string { + if (q == null) { + return "Out!"; + } + return "Hello world!"; +} + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + greeting: String @exported(filename: "/Users/captbaritone/projects/grats/src/tests/fixtures/extend_type/optionalModelType.ts", functionName: "greeting") +} \ No newline at end of file diff --git a/src/tests/fixtures/extend_type/userExample.ts b/src/tests/fixtures/extend_type/userExample.ts new file mode 100644 index 00000000..7df1bd8d --- /dev/null +++ b/src/tests/fixtures/extend_type/userExample.ts @@ -0,0 +1,20 @@ +/** @GQLType */ +type Query = {}; + +/** @GQLExtendType */ +export function me(_: Query): User { + return { firstName: "John", lastName: "Doe" }; +} + +/** @GQLType */ +type User = { + /** @GQLField */ + firstName: string; + /** @GQLField */ + lastName: string; +}; + +/** @GQLExtendType */ +export function fullName(user: User): string { + return `${user.firstName} ${user.lastName}`; +} diff --git a/src/tests/fixtures/extend_type/userExample.ts.expected b/src/tests/fixtures/extend_type/userExample.ts.expected new file mode 100644 index 00000000..920b46c0 --- /dev/null +++ b/src/tests/fixtures/extend_type/userExample.ts.expected @@ -0,0 +1,42 @@ +----------------- +INPUT +----------------- +/** @GQLType */ +class Query { + /** @GQLField */ + me: User; +} + +/** @GQLType */ +class User { + /** @GQLField */ + firstName: string; + /** @GQLField */ + lastName: string; +} + +/** @GQLExtendType */ +export function fullName(user: User): string { + return `${user.firstName} ${user.lastName}`; +} + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + me: User +} + +type User { + firstName: String + lastName: String + fullName: String @exported(filename: "/Users/captbaritone/projects/grats/src/tests/fixtures/extend_type/userExample.ts", functionName: "fullName") +} \ No newline at end of file