From 421d0f199764bcfc4b90353cc84eb6ab7be1c98c Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 13 Dec 2024 13:06:42 -0800 Subject: [PATCH 1/4] [WIP] Sketch of derived context Summary: A sketch of derived contexts as described in https://github.com/captbaritone/grats/issues/159 Not sure this is how the implemenetaiton should work. Was just focusing on getting things working end to end. If we go this way, I'd want to focus a bit more on internal architecture as well as error handling. Test Plan: [ghstack-poisoned] --- src/Extractor.ts | 42 ++++++++++++++- src/TypeContext.ts | 52 ++++++++++++------- src/codegen/resolverCodegen.ts | 10 ++++ src/lib.ts | 5 ++ src/metadata.ts | 10 ++++ src/resolverSignature.ts | 9 ++++ .../derived_context/simpleDerivedContext.ts | 21 ++++++++ .../simpleDerivedContext.ts.expected | 0 src/transforms/makeResolverSignature.ts | 48 ++++++++++------- src/transforms/resolveResolverParams.ts | 8 +++ 10 files changed, 164 insertions(+), 41 deletions(-) create mode 100644 src/tests/fixtures/derived_context/simpleDerivedContext.ts create mode 100644 src/tests/fixtures/derived_context/simpleDerivedContext.ts.expected diff --git a/src/Extractor.ts b/src/Extractor.ts index 3b443cfd..8b2d7c4e 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -82,6 +82,7 @@ export type ExtractionSnapshot = { readonly definitions: DefinitionNode[]; readonly unresolvedNames: Map; readonly nameDefinitions: Map; + readonly implicitNameDefinitions: Map; readonly typesWithTypename: Set; readonly interfaceDeclarations: Array; }; @@ -113,6 +114,8 @@ class Extractor { // Snapshot data unresolvedNames: Map = new Map(); nameDefinitions: Map = new Map(); + implicitNameDefinitions: Map = + new Map(); typesWithTypename: Set = new Set(); interfaceDeclarations: Array = []; @@ -132,6 +135,7 @@ class Extractor { name: NameNode, kind: NameDefinition["kind"], ): void { + // @ts-ignore FIXME this.nameDefinitions.set(node, { name, kind }); } @@ -218,8 +222,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; } @@ -293,11 +301,41 @@ class Extractor { definitions: this.definitions, unresolvedNames: this.unresolvedNames, nameDefinitions: this.nameDefinitions, + implicitNameDefinitions: this.implicitNameDefinitions, typesWithTypename: this.typesWithTypename, interfaceDeclarations: this.interfaceDeclarations, }); } + 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 name = this.gql.name(tag, "CONTEXT_DUMMY_NAME"); + this.implicitNameDefinitions.set( + { + kind: "DERIVED_CONTEXT", + name, + path: tsModulePath, + exportName: funcName?.text ?? null, + }, + returnType, + ); + } + extractType(node: ts.Node, tag: ts.JSDocTag) { if (ts.isClassDeclaration(node)) { this.typeClassDeclaration(node, tag); diff --git a/src/TypeContext.ts b/src/TypeContext.ts index 548d8473..f80da869 100644 --- a/src/TypeContext.ts +++ b/src/TypeContext.ts @@ -18,18 +18,25 @@ import { ExtractionSnapshot } from "./Extractor"; export const UNRESOLVED_REFERENCE_NAME = `__UNRESOLVED_REFERENCE__`; -export type NameDefinition = { - name: NameNode; - kind: - | "TYPE" - | "INTERFACE" - | "UNION" - | "SCALAR" - | "INPUT_OBJECT" - | "ENUM" - | "CONTEXT" - | "INFO"; -}; +export type NameDefinition = + | { + name: NameNode; + kind: + | "TYPE" + | "INTERFACE" + | "UNION" + | "SCALAR" + | "INPUT_OBJECT" + | "ENUM" + | "CONTEXT" + | "INFO"; + } + | { + name: NameNode; + path: string; + exportName: string | null; + kind: "DERIVED_CONTEXT"; + }; type TsIdentifier = number; @@ -61,7 +68,16 @@ export class TypeContext { 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) { + throw new Error( + "Expected to find declaration for implicit name definition.", + ); + } + self._recordTypeName(declaration, definition); } return self; } @@ -72,13 +88,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/resolverCodegen.ts b/src/codegen/resolverCodegen.ts index 2ab8aec7..b24c62dd 100644 --- a/src/codegen/resolverCodegen.ts +++ b/src/codegen/resolverCodegen.ts @@ -178,6 +178,16 @@ export default class ResolverCodegen { F.createIdentifier("args"), F.createIdentifier(arg.name), ); + case "derivedContext": { + const localName = "derivedContext"; + this.ts.importUserConstruct(arg.path, arg.exportName, localName); + return F.createCallExpression( + F.createIdentifier(localName), + undefined, + [this.resolverParam(arg.input)], + ); + } + default: // @ts-expect-error throw new Error(`Unexpected resolver kind ${arg.kind}`); diff --git a/src/lib.ts b/src/lib.ts index 11a3f60c..85ed5c1a 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -173,6 +173,7 @@ function combineSnapshots(snapshots: ExtractionSnapshot[]): ExtractionSnapshot { const result: ExtractionSnapshot = { definitions: [], nameDefinitions: new Map(), + implicitNameDefinitions: new Map(), unresolvedNames: new Map(), typesWithTypename: new Set(), interfaceDeclarations: [], @@ -191,6 +192,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..b47b9406 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -87,6 +87,7 @@ export type ResolverArgument = | SourceArgument | ArgumentsObjectArgument | ContextArgument + | DerivedContextArgument | InformationArgument | NamedArgument; @@ -105,6 +106,15 @@ 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 + input: ContextArgument | DerivedContextArgument; + // TODO: Add a parent which could be ContextArgument or another DerivedContextArgument +}; + /** The GraphQL info object */ export type InformationArgument = { kind: "information"; diff --git a/src/resolverSignature.ts b/src/resolverSignature.ts index 34c52a13..0dac39f7 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; + // TODO: Support custom inputs + 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/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..e69de29b diff --git a/src/transforms/makeResolverSignature.ts b/src/transforms/makeResolverSignature.ts index a97e7507..dea602e5 100644 --- a/src/transforms/makeResolverSignature.ts +++ b/src/transforms/makeResolverSignature.ts @@ -78,23 +78,33 @@ 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, + // TODO: Support custom inputs + input: { kind: "context" }, + }; + 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..22face9f 100644 --- a/src/transforms/resolveResolverParams.ts +++ b/src/transforms/resolveResolverParams.ts @@ -108,6 +108,7 @@ class ResolverParamsResolver { case "argumentsObject": case "information": case "context": + case "derivedContext": case "source": return param; case "unresolved": { @@ -124,6 +125,13 @@ class ResolverParamsResolver { return param; } switch (resolved.value.kind) { + case "DERIVED_CONTEXT": + return { + kind: "derivedContext", + node: param.node, + path: resolved.value.path, + exportName: resolved.value.exportName, + }; case "CONTEXT": return { kind: "context", node: param.node }; case "INFO": From 4375fc8f3b7b87c46d777e5e2b604eae018ef6e6 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 13 Dec 2024 13:08:17 -0800 Subject: [PATCH 2/4] Update on "[WIP] Sketch of derived context" Summary: A sketch of derived contexts as described in https://github.com/captbaritone/grats/issues/159 Not sure this is how the implemenetaiton should work. Was just focusing on getting things working end to end. If we go this way, I'd want to focus a bit more on internal architecture as well as error handling. Test Plan: [ghstack-poisoned] --- .../simpleDerivedContext.ts.expected | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/tests/fixtures/derived_context/simpleDerivedContext.ts.expected b/src/tests/fixtures/derived_context/simpleDerivedContext.ts.expected index e69de29b..41633b50 100644 --- a/src/tests/fixtures/derived_context/simpleDerivedContext.ts.expected +++ 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 derivedContext } 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, derivedContext(context)); + } + } + }; + } + }); + return new GraphQLSchema({ + query: QueryType, + types: [QueryType] + }); +} From fa222eac427ebd1f30c88e73beae9ae53c51c62e Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 13 Dec 2024 15:31:48 -0800 Subject: [PATCH 3/4] Update on "[WIP] Sketch of derived context" Summary: A sketch of derived contexts as described in https://github.com/captbaritone/grats/issues/159 Not sure this is how the implemenetaiton should work. Was just focusing on getting things working end to end. If we go this way, I'd want to focus a bit more on internal architecture as well as error handling. Test Plan: [ghstack-poisoned] --- src/TypeContext.ts | 27 ++++++-- src/codegen/TSAstBuilder.ts | 15 ++++ src/codegen/resolverCodegen.ts | 18 ++++- src/lib.ts | 6 +- .../derivedContextUsedMultipleTimes.ts | 26 +++++++ ...erivedContextUsedMultipleTimes.ts.expected | 68 +++++++++++++++++++ ...multipleDerivedContextsSameType.invalid.ts | 26 +++++++ ...erivedContextsSameType.invalid.ts.expected | 50 ++++++++++++++ .../simpleDerivedContext.ts.expected | 4 +- ...simpleDerivedContextGenericType.invalid.ts | 21 ++++++ ...ivedContextGenericType.invalid.ts.expected | 32 +++++++++ ...mpleDerivedContextUndefinedType.invalid.ts | 21 ++++++ ...edContextUndefinedType.invalid.ts.expected | 32 +++++++++ 13 files changed, 338 insertions(+), 8 deletions(-) create mode 100644 src/tests/fixtures/derived_context/derivedContextUsedMultipleTimes.ts create mode 100644 src/tests/fixtures/derived_context/derivedContextUsedMultipleTimes.ts.expected create mode 100644 src/tests/fixtures/derived_context/multipleDerivedContextsSameType.invalid.ts create mode 100644 src/tests/fixtures/derived_context/multipleDerivedContextsSameType.invalid.ts.expected create mode 100644 src/tests/fixtures/derived_context/simpleDerivedContextGenericType.invalid.ts create mode 100644 src/tests/fixtures/derived_context/simpleDerivedContextGenericType.invalid.ts.expected create mode 100644 src/tests/fixtures/derived_context/simpleDerivedContextUndefinedType.invalid.ts create mode 100644 src/tests/fixtures/derived_context/simpleDerivedContextUndefinedType.invalid.ts.expected diff --git a/src/TypeContext.ts b/src/TypeContext.ts index f80da869..bc54328c 100644 --- a/src/TypeContext.ts +++ b/src/TypeContext.ts @@ -11,6 +11,9 @@ import { DiagnosticResult, tsErr, gqlRelated, + DiagnosticsResult, + FixableDiagnosticWithLocation, + tsRelated, } from "./utils/DiagnosticError"; import { err, ok } from "./utils/Result"; import * as E from "./Errors"; @@ -62,7 +65,8 @@ 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); @@ -73,13 +77,28 @@ export class TypeContext { for (const [definition, reference] of snapshot.implicitNameDefinitions) { const declaration = self.maybeTsDeclarationForTsName(reference.typeName); if (declaration == null) { - throw new Error( - "Expected to find declaration for implicit name definition.", + 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); } - return self; + + if (errors.length > 0) { + return err(errors); + } + return ok(self); } constructor(checker: ts.TypeChecker) { 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 b24c62dd..cd109e0f 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, @@ -179,7 +180,7 @@ export default class ResolverCodegen { F.createIdentifier(arg.name), ); case "derivedContext": { - const localName = "derivedContext"; + const localName = this.getDerivedContextName(arg.path, arg.exportName); this.ts.importUserConstruct(arg.path, arg.exportName, localName); return F.createCallExpression( F.createIdentifier(localName), @@ -193,6 +194,21 @@ export default class ResolverCodegen { 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 85ed5c1a..8620ccc9 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -89,7 +89,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( 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.expected b/src/tests/fixtures/derived_context/simpleDerivedContext.ts.expected index 41633b50..a16c9f9f 100644 --- a/src/tests/fixtures/derived_context/simpleDerivedContext.ts.expected +++ b/src/tests/fixtures/derived_context/simpleDerivedContext.ts.expected @@ -31,7 +31,7 @@ type Query { greeting: String } -- TypeScript -- -import { greeting as queryGreetingResolver, createDerivedContext as derivedContext } from "./simpleDerivedContext"; +import { greeting as queryGreetingResolver, createDerivedContext as createDerivedContext } from "./simpleDerivedContext"; import { GraphQLSchema, GraphQLObjectType, GraphQLString } from "graphql"; export function getSchema(): GraphQLSchema { const QueryType: GraphQLObjectType = new GraphQLObjectType({ @@ -42,7 +42,7 @@ export function getSchema(): GraphQLSchema { name: "greeting", type: GraphQLString, resolve(source) { - return queryGreetingResolver(source, derivedContext(context)); + return queryGreetingResolver(source, createDerivedContext(context)); } } }; diff --git a/src/tests/fixtures/derived_context/simpleDerivedContextGenericType.invalid.ts b/src/tests/fixtures/derived_context/simpleDerivedContextGenericType.invalid.ts new file mode 100644 index 00000000..d0caa258 --- /dev/null +++ b/src/tests/fixtures/derived_context/simpleDerivedContextGenericType.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/simpleDerivedContextGenericType.invalid.ts.expected b/src/tests/fixtures/derived_context/simpleDerivedContextGenericType.invalid.ts.expected new file mode 100644 index 00000000..f60d1eb3 --- /dev/null +++ b/src/tests/fixtures/derived_context/simpleDerivedContextGenericType.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/simpleDerivedContextGenericType.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/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 { + ~~~~~~~~~~~~~~ From c46e8663671672d959801eb219050d72bb0564e3 Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 13 Dec 2024 16:20:23 -0800 Subject: [PATCH 4/4] Update on "[WIP] Sketch of derived context" Summary: A sketch of derived contexts as described in https://github.com/captbaritone/grats/issues/159 Not sure this is how the implemenetaiton should work. Was just focusing on getting things working end to end. If we go this way, I'd want to focus a bit more on internal architecture as well as error handling. Test Plan: [ghstack-poisoned] --- src/Extractor.ts | 4 + src/TypeContext.ts | 16 ++-- src/codegen/resolverCodegen.ts | 2 +- src/metadata.ts | 5 +- src/resolverSignature.ts | 2 +- .../derived_context/derivedContextChain.ts | 46 ++++++++++ .../derivedContextChain.ts.expected | 88 +++++++++++++++++++ ...simpleDerivedContextGenericType.invalid.ts | 21 ----- ...ivedContextGenericType.invalid.ts.expected | 32 ------- src/transforms/makeResolverSignature.ts | 13 ++- src/transforms/resolveResolverParams.ts | 43 +++++++-- 11 files changed, 199 insertions(+), 73 deletions(-) create mode 100644 src/tests/fixtures/derived_context/derivedContextChain.ts create mode 100644 src/tests/fixtures/derived_context/derivedContextChain.ts.expected delete mode 100644 src/tests/fixtures/derived_context/simpleDerivedContextGenericType.invalid.ts delete mode 100644 src/tests/fixtures/derived_context/simpleDerivedContextGenericType.invalid.ts.expected diff --git a/src/Extractor.ts b/src/Extractor.ts index 8b2d7c4e..69ba05ad 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -324,6 +324,9 @@ class Extractor { 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( { @@ -331,6 +334,7 @@ class Extractor { name, path: tsModulePath, exportName: funcName?.text ?? null, + args: paramResults.resolverParams, }, returnType, ); diff --git a/src/TypeContext.ts b/src/TypeContext.ts index bc54328c..9647f56d 100644 --- a/src/TypeContext.ts +++ b/src/TypeContext.ts @@ -18,9 +18,18 @@ import { 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 DerivedResolverDefinition = { + name: NameNode; + path: string; + exportName: string | null; + args: ResolverArgument[]; + kind: "DERIVED_CONTEXT"; +}; + export type NameDefinition = | { name: NameNode; @@ -34,12 +43,7 @@ export type NameDefinition = | "CONTEXT" | "INFO"; } - | { - name: NameNode; - path: string; - exportName: string | null; - kind: "DERIVED_CONTEXT"; - }; + | DerivedResolverDefinition; type TsIdentifier = number; diff --git a/src/codegen/resolverCodegen.ts b/src/codegen/resolverCodegen.ts index cd109e0f..0da40b9e 100644 --- a/src/codegen/resolverCodegen.ts +++ b/src/codegen/resolverCodegen.ts @@ -185,7 +185,7 @@ export default class ResolverCodegen { return F.createCallExpression( F.createIdentifier(localName), undefined, - [this.resolverParam(arg.input)], + arg.args.map((arg) => this.resolverParam(arg)), ); } diff --git a/src/metadata.ts b/src/metadata.ts index b47b9406..0faae6ef 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -82,6 +82,8 @@ export type StaticMethodResolver = { arguments: ResolverArgument[] | null; }; +export type ContextArgs = ContextArgument | DerivedContextArgument; + /** An argument expected by a resolver function or method */ export type ResolverArgument = | SourceArgument @@ -111,8 +113,7 @@ export type DerivedContextArgument = { kind: "derivedContext"; path: string; // Path to the module exportName: string | null; // Export name. If omitted, the class is the default export - input: ContextArgument | DerivedContextArgument; - // TODO: Add a parent which could be ContextArgument or another DerivedContextArgument + args: Array; }; /** The GraphQL info object */ diff --git a/src/resolverSignature.ts b/src/resolverSignature.ts index 0dac39f7..390e7508 100644 --- a/src/resolverSignature.ts +++ b/src/resolverSignature.ts @@ -64,7 +64,7 @@ export type DerivedContextResolverArgument = { kind: "derivedContext"; path: string; exportName: string | null; - // TODO: Support custom inputs + args: Array; node: ts.Node; }; 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/simpleDerivedContextGenericType.invalid.ts b/src/tests/fixtures/derived_context/simpleDerivedContextGenericType.invalid.ts deleted file mode 100644 index d0caa258..00000000 --- a/src/tests/fixtures/derived_context/simpleDerivedContextGenericType.invalid.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** @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/simpleDerivedContextGenericType.invalid.ts.expected b/src/tests/fixtures/derived_context/simpleDerivedContextGenericType.invalid.ts.expected deleted file mode 100644 index f60d1eb3..00000000 --- a/src/tests/fixtures/derived_context/simpleDerivedContextGenericType.invalid.ts.expected +++ /dev/null @@ -1,32 +0,0 @@ ------------------ -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/simpleDerivedContextGenericType.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 dea602e5..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 { @@ -98,8 +99,14 @@ function transformArg(arg: DirectiveResolverArgument): ResolverArgument { kind: "derivedContext", path: arg.path, exportName: arg.exportName, - // TODO: Support custom inputs - input: { kind: "context" }, + 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"); diff --git a/src/transforms/resolveResolverParams.ts b/src/transforms/resolveResolverParams.ts index 22face9f..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, @@ -126,12 +133,7 @@ class ResolverParamsResolver { } switch (resolved.value.kind) { case "DERIVED_CONTEXT": - return { - kind: "derivedContext", - node: param.node, - path: resolved.value.path, - exportName: resolved.value.exportName, - }; + return this.resolveDerivedContext(param.node, resolved.value); case "CONTEXT": return { kind: "context", node: param.node }; case "INFO": @@ -152,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 {