diff --git a/src/Extractor.ts b/src/Extractor.ts index 8e5ada8b..27ec539a 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -86,6 +86,7 @@ export type ExtractionSnapshot = { readonly definitions: DefinitionNode[]; readonly unresolvedNames: Map; readonly nameDefinitions: Map; + readonly implicitNameDefinitions: Map; readonly typesWithTypename: Set; readonly interfaceDeclarations: Array; }; @@ -117,6 +118,8 @@ class Extractor { // Snapshot data unresolvedNames: Map = new Map(); nameDefinitions: Map = new Map(); + implicitNameDefinitions: Map = + new Map(); typesWithTypename: Set = new Set(); interfaceDeclarations: Array = []; @@ -136,6 +139,7 @@ class Extractor { name: NameNode, kind: NameDefinition["kind"], ): void { + // @ts-ignore FIXME this.nameDefinitions.set(node, { name, kind }); } @@ -188,8 +192,12 @@ class Extractor { if (!ts.isDeclarationStatement(node)) { this.report(tag, E.contextTagOnNonDeclaration()); } else { - const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME"); - this.recordTypeName(node, name, "CONTEXT"); + if (ts.isFunctionDeclaration(node)) { + this.recordDerivedContext(node, tag); + } else { + const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME"); + this.recordTypeName(node, name, "CONTEXT"); + } } break; } @@ -270,6 +278,7 @@ class Extractor { definitions: this.definitions, unresolvedNames: this.unresolvedNames, nameDefinitions: this.nameDefinitions, + implicitNameDefinitions: this.implicitNameDefinitions, typesWithTypename: this.typesWithTypename, interfaceDeclarations: this.interfaceDeclarations, }); @@ -329,6 +338,38 @@ class Extractor { } } } + recordDerivedContext(node: ts.FunctionDeclaration, tag: ts.JSDocTag) { + const returnType = node.type; + if (returnType == null) { + throw new Error("Function declaration must have a return type"); + } + if (!ts.isTypeReferenceNode(returnType)) { + throw new Error("Function declaration must return an explicit type"); + } + + const funcName = this.namedFunctionExportName(node); + + if (!ts.isSourceFile(node.parent)) { + return this.report(node, E.functionFieldNotTopLevel()); + } + + const tsModulePath = relativePath(node.getSourceFile().fileName); + + const paramResults = this.resolverParams(node.parameters); + if (paramResults == null) return null; + + const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME"); + this.implicitNameDefinitions.set( + { + kind: "DERIVED_CONTEXT", + name, + path: tsModulePath, + exportName: funcName?.text ?? null, + args: paramResults.resolverParams, + }, + returnType, + ); + } extractType(node: ts.Node, tag: ts.JSDocTag) { if (ts.isClassDeclaration(node)) { diff --git a/src/TypeContext.ts b/src/TypeContext.ts index 548d8473..9647f56d 100644 --- a/src/TypeContext.ts +++ b/src/TypeContext.ts @@ -11,26 +11,40 @@ import { DiagnosticResult, tsErr, gqlRelated, + DiagnosticsResult, + FixableDiagnosticWithLocation, + tsRelated, } from "./utils/DiagnosticError"; import { err, ok } from "./utils/Result"; import * as E from "./Errors"; import { ExtractionSnapshot } from "./Extractor"; +import { ResolverArgument } from "./resolverSignature"; export const UNRESOLVED_REFERENCE_NAME = `__UNRESOLVED_REFERENCE__`; -export type NameDefinition = { +export type DerivedResolverDefinition = { name: NameNode; - kind: - | "TYPE" - | "INTERFACE" - | "UNION" - | "SCALAR" - | "INPUT_OBJECT" - | "ENUM" - | "CONTEXT" - | "INFO"; + path: string; + exportName: string | null; + args: ResolverArgument[]; + kind: "DERIVED_CONTEXT"; }; +export type NameDefinition = + | { + name: NameNode; + kind: + | "TYPE" + | "INTERFACE" + | "UNION" + | "SCALAR" + | "INPUT_OBJECT" + | "ENUM" + | "CONTEXT" + | "INFO"; + } + | DerivedResolverDefinition; + type TsIdentifier = number; /** @@ -55,15 +69,40 @@ export class TypeContext { static fromSnapshot( checker: ts.TypeChecker, snapshot: ExtractionSnapshot, - ): TypeContext { + ): DiagnosticsResult { + const errors: FixableDiagnosticWithLocation[] = []; const self = new TypeContext(checker); for (const [node, typeName] of snapshot.unresolvedNames) { self._markUnresolvedType(node, typeName); } for (const [node, definition] of snapshot.nameDefinitions) { - self._recordTypeName(node, definition.name, definition.kind); + self._recordTypeName(node, definition); + } + for (const [definition, reference] of snapshot.implicitNameDefinitions) { + const declaration = self.maybeTsDeclarationForTsName(reference.typeName); + if (declaration == null) { + errors.push(tsErr(reference.typeName, E.unresolvedTypeReference())); + continue; + } + const existing = self._declarationToName.get(declaration); + if (existing != null) { + errors.push( + // TODO: Better error messages here + tsErr(declaration, "Duplicate derived contexts for given type", [ + tsRelated(reference, "One was defined here"), + gqlRelated(existing.name, "Other here"), + ]), + ); + continue; + } + + self._recordTypeName(declaration, definition); + } + + if (errors.length > 0) { + return err(errors); } - return self; + return ok(self); } constructor(checker: ts.TypeChecker) { @@ -72,13 +111,9 @@ export class TypeContext { // Record that a GraphQL construct of type `kind` with the name `name` is // declared at `node`. - private _recordTypeName( - node: ts.Declaration, - name: NameNode, - kind: NameDefinition["kind"], - ) { - this._idToDeclaration.set(name.tsIdentifier, node); - this._declarationToName.set(node, { name, kind }); + private _recordTypeName(node: ts.Declaration, definition: NameDefinition) { + this._idToDeclaration.set(definition.name.tsIdentifier, node); + this._declarationToName.set(node, definition); } // Record that a type references `node` diff --git a/src/codegen/TSAstBuilder.ts b/src/codegen/TSAstBuilder.ts index c82561b3..df4dde64 100644 --- a/src/codegen/TSAstBuilder.ts +++ b/src/codegen/TSAstBuilder.ts @@ -9,6 +9,7 @@ const F = ts.factory; * A helper class to build up a TypeScript document AST. */ export default class TSAstBuilder { + _globalNames: Map = new Map(); _imports: ts.Statement[] = []; imports: Map = new Map(); _helpers: ts.Statement[] = []; @@ -209,7 +210,21 @@ export default class TSAstBuilder { sourceFile, ); } + + // Given a desired name in the module scope, return a name that is unique. If + // the name is already taken, a suffix will be added to the name to make it + // unique. + // + // NOTE: This is not truly unique, as it only checks the names that have been + // generated through this method. In the future we could add more robust + // scope/name tracking. + getUniqueName(name: string): string { + const count = this._globalNames.get(name) ?? 0; + this._globalNames.set(name, count + 1); + return count === 0 ? name : `${name}_${count}`; + } } + function replaceExt(filePath: string, newSuffix: string): string { const ext = path.extname(filePath); return filePath.slice(0, -ext.length) + newSuffix; diff --git a/src/codegen/resolverCodegen.ts b/src/codegen/resolverCodegen.ts index 7b3ea745..499f8434 100644 --- a/src/codegen/resolverCodegen.ts +++ b/src/codegen/resolverCodegen.ts @@ -20,6 +20,7 @@ const F = ts.factory; */ export default class ResolverCodegen { _helpers: Set = new Set(); + _derivedContextNames: Map = new Map(); constructor(public ts: TSAstBuilder, public _resolvers: Metadata) {} resolveMethod( fieldName: string, @@ -178,11 +179,36 @@ export default class ResolverCodegen { F.createIdentifier("args"), F.createIdentifier(arg.name), ); + case "derivedContext": { + const localName = this.getDerivedContextName(arg.path, arg.exportName); + this.ts.importUserConstruct(arg.path, arg.exportName, localName); + return F.createCallExpression( + F.createIdentifier(localName), + undefined, + arg.args.map((arg) => this.resolverParam(arg)), + ); + } + default: // @ts-expect-error throw new Error(`Unexpected resolver kind ${arg.kind}`); } } + + // Derived contexts are not anchored to anything that we know to be + // globally unique, like GraphQL type names, so must ensure this name is + // unique within our module. However, we want to avoid generating a new + // name for the same derived context more than once. + getDerivedContextName(path: string, exportName: string | null): string { + const key = `${path}:${exportName ?? ""}`; + let name = this._derivedContextNames.get(key); + if (name == null) { + name = this.ts.getUniqueName(exportName ?? "deriveContext"); + this._derivedContextNames.set(key, name); + } + return name; + } + // If a field is smantically non-null, we need to wrap the resolver in a // runtime check to ensure that the resolver does not return null. maybeApplySemanticNullRuntimeCheck( diff --git a/src/lib.ts b/src/lib.ts index cc89a4aa..9a3d4d27 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -90,7 +90,11 @@ export function extractSchemaAndDoc( const { typesWithTypename } = snapshot; const config = options.raw.grats; const checker = program.getTypeChecker(); - const ctx = TypeContext.fromSnapshot(checker, snapshot); + const ctxResult = TypeContext.fromSnapshot(checker, snapshot); + if (ctxResult.kind === "ERROR") { + return ctxResult; + } + const ctx = ctxResult.value; // Collect validation errors const validationResult = concatResults( @@ -177,6 +181,7 @@ function combineSnapshots(snapshots: ExtractionSnapshot[]): ExtractionSnapshot { const result: ExtractionSnapshot = { definitions: [], nameDefinitions: new Map(), + implicitNameDefinitions: new Map(), unresolvedNames: new Map(), typesWithTypename: new Set(), interfaceDeclarations: [], @@ -195,6 +200,10 @@ function combineSnapshots(snapshots: ExtractionSnapshot[]): ExtractionSnapshot { result.unresolvedNames.set(node, typeName); } + for (const [node, definition] of snapshot.implicitNameDefinitions) { + result.implicitNameDefinitions.set(node, definition); + } + for (const typeName of snapshot.typesWithTypename) { result.typesWithTypename.add(typeName); } diff --git a/src/metadata.ts b/src/metadata.ts index 3969a610..0faae6ef 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -82,11 +82,14 @@ export type StaticMethodResolver = { arguments: ResolverArgument[] | null; }; +export type ContextArgs = ContextArgument | DerivedContextArgument; + /** An argument expected by a resolver function or method */ export type ResolverArgument = | SourceArgument | ArgumentsObjectArgument | ContextArgument + | DerivedContextArgument | InformationArgument | NamedArgument; @@ -105,6 +108,14 @@ export type ContextArgument = { kind: "context"; }; +/** A context value which is expressed as a function of the global context */ +export type DerivedContextArgument = { + kind: "derivedContext"; + path: string; // Path to the module + exportName: string | null; // Export name. If omitted, the class is the default export + args: Array; +}; + /** The GraphQL info object */ export type InformationArgument = { kind: "information"; diff --git a/src/resolverSignature.ts b/src/resolverSignature.ts index 34c52a13..390e7508 100644 --- a/src/resolverSignature.ts +++ b/src/resolverSignature.ts @@ -60,6 +60,14 @@ export type ContextResolverArgument = { node: ts.Node; }; +export type DerivedContextResolverArgument = { + kind: "derivedContext"; + path: string; + exportName: string | null; + args: Array; + node: ts.Node; +}; + export type InformationResolverArgument = { kind: "information"; node: ts.Node; @@ -82,6 +90,7 @@ export type ResolverArgument = | SourceResolverArgument | ArgumentsObjectResolverArgument | ContextResolverArgument + | DerivedContextResolverArgument | InformationResolverArgument | NamedResolverArgument | UnresolvedResolverArgument; diff --git a/src/tests/fixtures/derived_context/derivedContextChain.ts b/src/tests/fixtures/derived_context/derivedContextChain.ts new file mode 100644 index 00000000..72d0b274 --- /dev/null +++ b/src/tests/fixtures/derived_context/derivedContextChain.ts @@ -0,0 +1,46 @@ +/** @gqlContext */ +type RootContext = { userName: string }; + +type DerivedContextA = { greeting: string }; + +/** @gqlContext */ +export function createDerivedContextA(ctx: RootContext): DerivedContextA { + return { greeting: `Hello, ${ctx.userName}!` }; +} + +type DerivedContextB = { greeting: string }; + +/** @gqlContext */ +export function createDerivedContextB(ctx: DerivedContextA): DerivedContextB { + return { greeting: ctx.greeting.toUpperCase() }; +} + +type EverythingContext = { greeting: string }; + +/** @gqlContext */ +export function allTheContexts( + root: RootContext, + a: DerivedContextA, + b: DerivedContextB, +): EverythingContext { + return { greeting: `${root.userName} ${a.greeting} ${b.greeting}` }; +} + +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query, ctx: EverythingContext): string { + return ctx.greeting; +} + +/** @gqlField */ +export function consumingMultipleContexts( + _: Query, + root: RootContext, + a: DerivedContextA, + b: DerivedContextB, + everything: EverythingContext, +): string { + return `${root.userName} ${a.greeting} ${b.greeting} ${everything.greeting}`; +} diff --git a/src/tests/fixtures/derived_context/derivedContextChain.ts.expected b/src/tests/fixtures/derived_context/derivedContextChain.ts.expected new file mode 100644 index 00000000..339e9691 --- /dev/null +++ b/src/tests/fixtures/derived_context/derivedContextChain.ts.expected @@ -0,0 +1,88 @@ +----------------- +INPUT +----------------- +/** @gqlContext */ +type RootContext = { userName: string }; + +type DerivedContextA = { greeting: string }; + +/** @gqlContext */ +export function createDerivedContextA(ctx: RootContext): DerivedContextA { + return { greeting: `Hello, ${ctx.userName}!` }; +} + +type DerivedContextB = { greeting: string }; + +/** @gqlContext */ +export function createDerivedContextB(ctx: DerivedContextA): DerivedContextB { + return { greeting: ctx.greeting.toUpperCase() }; +} + +type EverythingContext = { greeting: string }; + +/** @gqlContext */ +export function allTheContexts( + root: RootContext, + a: DerivedContextA, + b: DerivedContextB, +): EverythingContext { + return { greeting: `${root.userName} ${a.greeting} ${b.greeting}` }; +} + +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query, ctx: EverythingContext): string { + return ctx.greeting; +} + +/** @gqlField */ +export function consumingMultipleContexts( + _: Query, + root: RootContext, + a: DerivedContextA, + b: DerivedContextB, + everything: EverythingContext, +): string { + return `${root.userName} ${a.greeting} ${b.greeting} ${everything.greeting}`; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type Query { + consumingMultipleContexts: String + greeting: String +} +-- TypeScript -- +import { consumingMultipleContexts as queryConsumingMultipleContextsResolver, createDerivedContextA as createDerivedContextA, createDerivedContextB as createDerivedContextB, allTheContexts as allTheContexts, greeting as queryGreetingResolver } from "./derivedContextChain"; +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from "graphql"; +export function getSchema(): GraphQLSchema { + const QueryType: GraphQLObjectType = new GraphQLObjectType({ + name: "Query", + fields() { + return { + consumingMultipleContexts: { + name: "consumingMultipleContexts", + type: GraphQLString, + resolve(source, _args, context) { + return queryConsumingMultipleContextsResolver(source, context, createDerivedContextA(context), createDerivedContextB(createDerivedContextA(context)), allTheContexts(context, createDerivedContextA(context), createDerivedContextB(createDerivedContextA(context)))); + } + }, + greeting: { + name: "greeting", + type: GraphQLString, + resolve(source) { + return queryGreetingResolver(source, allTheContexts(context, createDerivedContextA(context), createDerivedContextB(createDerivedContextA(context)))); + } + } + }; + } + }); + return new GraphQLSchema({ + query: QueryType, + types: [QueryType] + }); +} diff --git a/src/tests/fixtures/derived_context/derivedContextUsedMultipleTimes.ts b/src/tests/fixtures/derived_context/derivedContextUsedMultipleTimes.ts new file mode 100644 index 00000000..4d936d67 --- /dev/null +++ b/src/tests/fixtures/derived_context/derivedContextUsedMultipleTimes.ts @@ -0,0 +1,26 @@ +/** @gqlContext */ +type RootContext = { + userName: string; +}; + +type DerivedContext = { + greeting: string; +}; + +/** @gqlContext */ +export function greetingContext(ctx: RootContext): DerivedContext { + return { greeting: `Hello, ${ctx.userName}!` }; +} + +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query, ctx: DerivedContext): string { + return ctx.greeting; +} + +/** @gqlField */ +export function farewell(_: Query, ctx: DerivedContext): string { + return `${ctx.greeting}... NOT!`; +} diff --git a/src/tests/fixtures/derived_context/derivedContextUsedMultipleTimes.ts.expected b/src/tests/fixtures/derived_context/derivedContextUsedMultipleTimes.ts.expected new file mode 100644 index 00000000..ce806c75 --- /dev/null +++ b/src/tests/fixtures/derived_context/derivedContextUsedMultipleTimes.ts.expected @@ -0,0 +1,68 @@ +----------------- +INPUT +----------------- +/** @gqlContext */ +type RootContext = { + userName: string; +}; + +type DerivedContext = { + greeting: string; +}; + +/** @gqlContext */ +export function greetingContext(ctx: RootContext): DerivedContext { + return { greeting: `Hello, ${ctx.userName}!` }; +} + +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query, ctx: DerivedContext): string { + return ctx.greeting; +} + +/** @gqlField */ +export function farewell(_: Query, ctx: DerivedContext): string { + return `${ctx.greeting}... NOT!`; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type Query { + farewell: String + greeting: String +} +-- TypeScript -- +import { farewell as queryFarewellResolver, greetingContext as greetingContext, greeting as queryGreetingResolver } from "./derivedContextUsedMultipleTimes"; +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from "graphql"; +export function getSchema(): GraphQLSchema { + const QueryType: GraphQLObjectType = new GraphQLObjectType({ + name: "Query", + fields() { + return { + farewell: { + name: "farewell", + type: GraphQLString, + resolve(source) { + return queryFarewellResolver(source, greetingContext(context)); + } + }, + greeting: { + name: "greeting", + type: GraphQLString, + resolve(source) { + return queryGreetingResolver(source, greetingContext(context)); + } + } + }; + } + }); + return new GraphQLSchema({ + query: QueryType, + types: [QueryType] + }); +} diff --git a/src/tests/fixtures/derived_context/multipleDerivedContextsSameType.invalid.ts b/src/tests/fixtures/derived_context/multipleDerivedContextsSameType.invalid.ts new file mode 100644 index 00000000..dbadcd50 --- /dev/null +++ b/src/tests/fixtures/derived_context/multipleDerivedContextsSameType.invalid.ts @@ -0,0 +1,26 @@ +/** @gqlContext */ +type RootContext = { + userName: string; +}; + +type DerivedContext = { + greeting: string; +}; + +/** @gqlContext */ +export function createDerivedContext(ctx: RootContext): DerivedContext { + return { greeting: `Hello, ${ctx.userName}!` }; +} + +/** @gqlContext */ +export function createAnotherDerivedContext(ctx: RootContext): DerivedContext { + return { greeting: `Goodbye!, ${ctx.userName}!` }; +} + +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query, ctx: DerivedContext): string { + return ctx.greeting; +} diff --git a/src/tests/fixtures/derived_context/multipleDerivedContextsSameType.invalid.ts.expected b/src/tests/fixtures/derived_context/multipleDerivedContextsSameType.invalid.ts.expected new file mode 100644 index 00000000..9af48458 --- /dev/null +++ b/src/tests/fixtures/derived_context/multipleDerivedContextsSameType.invalid.ts.expected @@ -0,0 +1,50 @@ +----------------- +INPUT +----------------- +/** @gqlContext */ +type RootContext = { + userName: string; +}; + +type DerivedContext = { + greeting: string; +}; + +/** @gqlContext */ +export function createDerivedContext(ctx: RootContext): DerivedContext { + return { greeting: `Hello, ${ctx.userName}!` }; +} + +/** @gqlContext */ +export function createAnotherDerivedContext(ctx: RootContext): DerivedContext { + return { greeting: `Goodbye!, ${ctx.userName}!` }; +} + +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query, ctx: DerivedContext): string { + return ctx.greeting; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/derived_context/multipleDerivedContextsSameType.invalid.ts:6:1 - error: Duplicate derived contexts for given type + +6 type DerivedContext = { + ~~~~~~~~~~~~~~~~~~~~~~~ +7 greeting: string; + ~~~~~~~~~~~~~~~~~~~ +8 }; + ~~ + + src/tests/fixtures/derived_context/multipleDerivedContextsSameType.invalid.ts:16:64 + 16 export function createAnotherDerivedContext(ctx: RootContext): DerivedContext { + ~~~~~~~~~~~~~~ + One was defined here + src/tests/fixtures/derived_context/multipleDerivedContextsSameType.invalid.ts:10:5 + 10 /** @gqlContext */ + ~~~~~~~~~~~~ + Other here diff --git a/src/tests/fixtures/derived_context/simpleDerivedContext.ts b/src/tests/fixtures/derived_context/simpleDerivedContext.ts new file mode 100644 index 00000000..7312aa05 --- /dev/null +++ b/src/tests/fixtures/derived_context/simpleDerivedContext.ts @@ -0,0 +1,21 @@ +/** @gqlContext */ +type RootContext = { + userName: string; +}; + +type DerivedContext = { + greeting: string; +}; + +/** @gqlContext */ +export function createDerivedContext(ctx: RootContext): DerivedContext { + return { greeting: `Hello, ${ctx.userName}!` }; +} + +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query, ctx: DerivedContext): string { + return ctx.greeting; +} diff --git a/src/tests/fixtures/derived_context/simpleDerivedContext.ts.expected b/src/tests/fixtures/derived_context/simpleDerivedContext.ts.expected new file mode 100644 index 00000000..a16c9f9f --- /dev/null +++ b/src/tests/fixtures/derived_context/simpleDerivedContext.ts.expected @@ -0,0 +1,55 @@ +----------------- +INPUT +----------------- +/** @gqlContext */ +type RootContext = { + userName: string; +}; + +type DerivedContext = { + greeting: string; +}; + +/** @gqlContext */ +export function createDerivedContext(ctx: RootContext): DerivedContext { + return { greeting: `Hello, ${ctx.userName}!` }; +} + +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query, ctx: DerivedContext): string { + return ctx.greeting; +} + +----------------- +OUTPUT +----------------- +-- SDL -- +type Query { + greeting: String +} +-- TypeScript -- +import { greeting as queryGreetingResolver, createDerivedContext as createDerivedContext } from "./simpleDerivedContext"; +import { GraphQLSchema, GraphQLObjectType, GraphQLString } from "graphql"; +export function getSchema(): GraphQLSchema { + const QueryType: GraphQLObjectType = new GraphQLObjectType({ + name: "Query", + fields() { + return { + greeting: { + name: "greeting", + type: GraphQLString, + resolve(source) { + return queryGreetingResolver(source, createDerivedContext(context)); + } + } + }; + } + }); + return new GraphQLSchema({ + query: QueryType, + types: [QueryType] + }); +} diff --git a/src/tests/fixtures/derived_context/simpleDerivedContextUndefinedType.invalid.ts b/src/tests/fixtures/derived_context/simpleDerivedContextUndefinedType.invalid.ts new file mode 100644 index 00000000..d0caa258 --- /dev/null +++ b/src/tests/fixtures/derived_context/simpleDerivedContextUndefinedType.invalid.ts @@ -0,0 +1,21 @@ +/** @gqlContext */ +type RootContext = { + userName: string; +}; + +// type DerivedContext = { +// greeting: string; +// }; + +/** @gqlContext */ +export function createDerivedContext(ctx: RootContext): DerivedContext { + return { greeting: `Hello, ${ctx.userName}!` }; +} + +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query, ctx: DerivedContext): string { + return ctx.greeting; +} diff --git a/src/tests/fixtures/derived_context/simpleDerivedContextUndefinedType.invalid.ts.expected b/src/tests/fixtures/derived_context/simpleDerivedContextUndefinedType.invalid.ts.expected new file mode 100644 index 00000000..62c18bbe --- /dev/null +++ b/src/tests/fixtures/derived_context/simpleDerivedContextUndefinedType.invalid.ts.expected @@ -0,0 +1,32 @@ +----------------- +INPUT +----------------- +/** @gqlContext */ +type RootContext = { + userName: string; +}; + +// type DerivedContext = { +// greeting: string; +// }; + +/** @gqlContext */ +export function createDerivedContext(ctx: RootContext): DerivedContext { + return { greeting: `Hello, ${ctx.userName}!` }; +} + +/** @gqlType */ +type Query = unknown; + +/** @gqlField */ +export function greeting(_: Query, ctx: DerivedContext): string { + return ctx.greeting; +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/derived_context/simpleDerivedContextUndefinedType.invalid.ts:11:57 - error: Unable to resolve type reference. In order to generate a GraphQL schema, Grats needs to determine which GraphQL type is being referenced. This requires being able to resolve type references to their `@gql` annotated declaration. However this reference could not be resolved. Is it possible that this type is not defined in this file? + +11 export function createDerivedContext(ctx: RootContext): DerivedContext { + ~~~~~~~~~~~~~~ diff --git a/src/transforms/makeResolverSignature.ts b/src/transforms/makeResolverSignature.ts index a97e7507..1770cbaa 100644 --- a/src/transforms/makeResolverSignature.ts +++ b/src/transforms/makeResolverSignature.ts @@ -4,8 +4,9 @@ import { ResolverDefinition, Metadata, FieldDefinition, + ContextArgs, } from "../metadata"; -import { nullThrows } from "../utils/helpers"; +import { invariant, nullThrows } from "../utils/helpers"; import { ResolverArgument as DirectiveResolverArgument } from "../resolverSignature"; export function makeResolverSignature(documentAst: DocumentNode): Metadata { @@ -78,23 +79,39 @@ function transformArgs( if (args == null) { return null; } - return args.map((arg): ResolverArgument => { - switch (arg.kind) { - case "argumentsObject": - return { kind: "argumentsObject" }; - case "named": - return { kind: "named", name: arg.name }; - case "source": - return { kind: "source" }; - case "information": - return { kind: "information" }; - case "context": - return { kind: "context" }; - case "unresolved": - throw new Error("Unresolved argument in resolver"); - default: - // @ts-expect-error - throw new Error(`Unknown argument kind: ${arg.kind}`); - } - }); + return args.map(transformArg); +} + +function transformArg(arg: DirectiveResolverArgument): ResolverArgument { + switch (arg.kind) { + case "argumentsObject": + return { kind: "argumentsObject" }; + case "named": + return { kind: "named", name: arg.name }; + case "source": + return { kind: "source" }; + case "information": + return { kind: "information" }; + case "context": + return { kind: "context" }; + case "derivedContext": + return { + kind: "derivedContext", + path: arg.path, + exportName: arg.exportName, + args: arg.args.map((arg): ContextArgs => { + const newArg = transformArg(arg); + invariant( + newArg.kind === "derivedContext" || newArg.kind === "context", + "Previous validation passes ensure we only have valid derived context args here", + ); + return newArg; + }), + }; + case "unresolved": + throw new Error("Unresolved argument in resolver"); + default: + // @ts-expect-error + throw new Error(`Unknown argument kind: ${arg.kind}`); + } } diff --git a/src/transforms/resolveResolverParams.ts b/src/transforms/resolveResolverParams.ts index 525522f9..f9e63337 100644 --- a/src/transforms/resolveResolverParams.ts +++ b/src/transforms/resolveResolverParams.ts @@ -1,3 +1,4 @@ +import * as ts from "typescript"; import { DefinitionNode, FieldDefinitionNode, @@ -5,7 +6,11 @@ import { Kind, visit, } from "graphql"; -import { TypeContext, UNRESOLVED_REFERENCE_NAME } from "../TypeContext"; +import { + DerivedResolverDefinition, + TypeContext, + UNRESOLVED_REFERENCE_NAME, +} from "../TypeContext"; import { err, ok } from "../utils/Result"; import { DiagnosticsResult, @@ -15,6 +20,8 @@ import { } from "../utils/DiagnosticError"; import { nullThrows } from "../utils/helpers"; import { + ContextResolverArgument, + DerivedContextResolverArgument, NamedResolverArgument, ResolverArgument, UnresolvedResolverArgument, @@ -108,6 +115,7 @@ class ResolverParamsResolver { case "argumentsObject": case "information": case "context": + case "derivedContext": case "source": return param; case "unresolved": { @@ -124,6 +132,8 @@ class ResolverParamsResolver { return param; } switch (resolved.value.kind) { + case "DERIVED_CONTEXT": + return this.resolveDerivedContext(param.node, resolved.value); case "CONTEXT": return { kind: "context", node: param.node }; case "INFO": @@ -144,6 +154,33 @@ class ResolverParamsResolver { } } } + private resolveDerivedContext( + node: ts.Node, + { path, exportName, args }: DerivedResolverDefinition, + ): ResolverArgument { + const newArgs: Array< + DerivedContextResolverArgument | ContextResolverArgument + > = []; + for (const arg of args) { + const resolvedArg = this.transformParam(arg); + switch (resolvedArg.kind) { + case "context": + case "derivedContext": + newArgs.push(resolvedArg); + break; + default: + // FIXME: Improve this error message + this.errors.push( + tsErr( + resolvedArg.node, + "Invalid argument passed to derived context function", + ), + ); + } + } + return { kind: "derivedContext", node, path, exportName, args: newArgs }; + } + resolveToPositionalArg( unresolved: UnresolvedResolverArgument, ): ResolverArgument | null {