Skip to content

Commit b37ef6a

Browse files
authored
Derived context values (#164)
* [WIP] Sketch of derived context Summary: A sketch of derived contexts as described in #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-source-id: 0513dda Pull Request resolved: #161 * Document derived context * Cleanup
1 parent 89047e2 commit b37ef6a

34 files changed

+1238
-51
lines changed

src/Errors.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,3 +607,19 @@ export function noTypesDefined() {
607607
export function tsConfigNotFound(cwd: string) {
608608
return `Grats: Could not find \`tsconfig.json\` searching in ${cwd}.\n\nSee https://www.typescriptlang.org/download/ for instructors on how to add TypeScript to your project. Then run \`npx tsc --init\` to create a \`tsconfig.json\` file.`;
609609
}
610+
611+
export function cyclicDerivedContext() {
612+
return `Cyclic dependency detected in derived context. This derived context value depends upon itself.`;
613+
}
614+
615+
export function invalidDerivedContextArgType() {
616+
return "Invalid type for derived context function argument. Derived context functions may only accept other `@gqlContext` types as arguments.";
617+
}
618+
619+
export function missingReturnTypeForDerivedResolver() {
620+
return 'Expected derived resolver to have an explicit return type. This is needed to allow Grats to "see" which type to treat as a derived context type.';
621+
}
622+
623+
export function derivedResolverInvalidReturnType() {
624+
return "Expected derived resolver function's return type to be a type reference. Grats uses this type reference to determine which type to treat as a derived context type.";
625+
}

src/Extractor.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ import {
2727
} from "./utils/DiagnosticError";
2828
import { err, ok } from "./utils/Result";
2929
import * as ts from "typescript";
30-
import { NameDefinition, UNRESOLVED_REFERENCE_NAME } from "./TypeContext";
30+
import {
31+
DeclarationDefinition,
32+
NameDefinition,
33+
UNRESOLVED_REFERENCE_NAME,
34+
} from "./TypeContext";
3135
import * as E from "./Errors";
3236
import { traverseJSDocTags } from "./utils/JSDoc";
3337
import { GraphQLConstructor } from "./GraphQLConstructor";
@@ -86,6 +90,10 @@ export type ExtractionSnapshot = {
8690
readonly definitions: DefinitionNode[];
8791
readonly unresolvedNames: Map<ts.EntityName, NameNode>;
8892
readonly nameDefinitions: Map<ts.DeclarationStatement, NameDefinition>;
93+
readonly implicitNameDefinitions: Map<
94+
DeclarationDefinition,
95+
ts.TypeReferenceNode
96+
>;
8997
readonly typesWithTypename: Set<string>;
9098
readonly interfaceDeclarations: Array<ts.InterfaceDeclaration>;
9199
};
@@ -117,6 +125,8 @@ class Extractor {
117125
// Snapshot data
118126
unresolvedNames: Map<ts.EntityName, NameNode> = new Map();
119127
nameDefinitions: Map<ts.DeclarationStatement, NameDefinition> = new Map();
128+
implicitNameDefinitions: Map<DeclarationDefinition, ts.TypeReferenceNode> =
129+
new Map();
120130
typesWithTypename: Set<string> = new Set();
121131
interfaceDeclarations: Array<ts.InterfaceDeclaration> = [];
122132

@@ -188,8 +198,12 @@ class Extractor {
188198
if (!ts.isDeclarationStatement(node)) {
189199
this.report(tag, E.contextTagOnNonDeclaration());
190200
} else {
191-
const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME");
192-
this.recordTypeName(node, name, "CONTEXT");
201+
if (ts.isFunctionDeclaration(node)) {
202+
this.recordDerivedContext(node, tag);
203+
} else {
204+
const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME");
205+
this.recordTypeName(node, name, "CONTEXT");
206+
}
193207
}
194208
break;
195209
}
@@ -270,6 +284,7 @@ class Extractor {
270284
definitions: this.definitions,
271285
unresolvedNames: this.unresolvedNames,
272286
nameDefinitions: this.nameDefinitions,
287+
implicitNameDefinitions: this.implicitNameDefinitions,
273288
typesWithTypename: this.typesWithTypename,
274289
interfaceDeclarations: this.interfaceDeclarations,
275290
});
@@ -329,6 +344,38 @@ class Extractor {
329344
}
330345
}
331346
}
347+
recordDerivedContext(node: ts.FunctionDeclaration, tag: ts.JSDocTag) {
348+
const returnType = node.type;
349+
if (returnType == null) {
350+
return this.report(node, E.missingReturnTypeForDerivedResolver());
351+
}
352+
if (!ts.isTypeReferenceNode(returnType)) {
353+
return this.report(returnType, E.missingReturnTypeForDerivedResolver());
354+
}
355+
356+
const funcName = this.namedFunctionExportName(node);
357+
358+
if (!ts.isSourceFile(node.parent)) {
359+
return this.report(node, E.functionFieldNotTopLevel());
360+
}
361+
362+
const tsModulePath = relativePath(node.getSourceFile().fileName);
363+
364+
const paramResults = this.resolverParams(node.parameters);
365+
if (paramResults == null) return null;
366+
367+
const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME");
368+
this.implicitNameDefinitions.set(
369+
{
370+
kind: "DERIVED_CONTEXT",
371+
name,
372+
path: tsModulePath,
373+
exportName: funcName?.text ?? null,
374+
args: paramResults.resolverParams,
375+
},
376+
returnType,
377+
);
378+
}
332379

333380
extractType(node: ts.Node, tag: ts.JSDocTag) {
334381
if (ts.isClassDeclaration(node)) {

src/TypeContext.ts

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,24 @@ import {
1111
DiagnosticResult,
1212
tsErr,
1313
gqlRelated,
14+
DiagnosticsResult,
15+
FixableDiagnosticWithLocation,
1416
} from "./utils/DiagnosticError";
1517
import { err, ok } from "./utils/Result";
1618
import * as E from "./Errors";
1719
import { ExtractionSnapshot } from "./Extractor";
20+
import { ResolverArgument } from "./resolverSignature";
1821

1922
export const UNRESOLVED_REFERENCE_NAME = `__UNRESOLVED_REFERENCE__`;
2023

24+
export type DerivedResolverDefinition = {
25+
name: NameNode;
26+
path: string;
27+
exportName: string | null;
28+
args: ResolverArgument[];
29+
kind: "DERIVED_CONTEXT";
30+
};
31+
2132
export type NameDefinition = {
2233
name: NameNode;
2334
kind:
@@ -31,6 +42,8 @@ export type NameDefinition = {
3142
| "INFO";
3243
};
3344

45+
export type DeclarationDefinition = NameDefinition | DerivedResolverDefinition;
46+
3447
type TsIdentifier = number;
3548

3649
/**
@@ -48,22 +61,50 @@ type TsIdentifier = number;
4861
export class TypeContext {
4962
checker: ts.TypeChecker;
5063

51-
_declarationToName: Map<ts.Declaration, NameDefinition> = new Map();
64+
_declarationToDefinition: Map<ts.Declaration, DeclarationDefinition> =
65+
new Map();
5266
_unresolvedNodes: Map<TsIdentifier, ts.EntityName> = new Map();
5367
_idToDeclaration: Map<TsIdentifier, ts.Declaration> = new Map();
5468

5569
static fromSnapshot(
5670
checker: ts.TypeChecker,
5771
snapshot: ExtractionSnapshot,
58-
): TypeContext {
72+
): DiagnosticsResult<TypeContext> {
73+
const errors: FixableDiagnosticWithLocation[] = [];
5974
const self = new TypeContext(checker);
6075
for (const [node, typeName] of snapshot.unresolvedNames) {
6176
self._markUnresolvedType(node, typeName);
6277
}
6378
for (const [node, definition] of snapshot.nameDefinitions) {
64-
self._recordTypeName(node, definition.name, definition.kind);
79+
self._recordDeclaration(node, definition);
80+
}
81+
for (const [definition, reference] of snapshot.implicitNameDefinitions) {
82+
const declaration = self.maybeTsDeclarationForTsName(reference.typeName);
83+
if (declaration == null) {
84+
errors.push(tsErr(reference.typeName, E.unresolvedTypeReference()));
85+
continue;
86+
}
87+
const existing = self._declarationToDefinition.get(declaration);
88+
if (existing != null) {
89+
errors.push(
90+
tsErr(
91+
declaration,
92+
"Multiple derived contexts defined for given type",
93+
[
94+
gqlRelated(definition.name, "One was defined here"),
95+
gqlRelated(existing.name, "Another here"),
96+
],
97+
),
98+
);
99+
continue;
100+
}
101+
self._recordDeclaration(declaration, definition);
102+
}
103+
104+
if (errors.length > 0) {
105+
return err(errors);
65106
}
66-
return self;
107+
return ok(self);
67108
}
68109

69110
constructor(checker: ts.TypeChecker) {
@@ -72,22 +113,21 @@ export class TypeContext {
72113

73114
// Record that a GraphQL construct of type `kind` with the name `name` is
74115
// declared at `node`.
75-
private _recordTypeName(
116+
private _recordDeclaration(
76117
node: ts.Declaration,
77-
name: NameNode,
78-
kind: NameDefinition["kind"],
118+
definition: DeclarationDefinition,
79119
) {
80-
this._idToDeclaration.set(name.tsIdentifier, node);
81-
this._declarationToName.set(node, { name, kind });
120+
this._idToDeclaration.set(definition.name.tsIdentifier, node);
121+
this._declarationToDefinition.set(node, definition);
82122
}
83123

84124
// Record that a type references `node`
85125
private _markUnresolvedType(node: ts.EntityName, name: NameNode) {
86126
this._unresolvedNodes.set(name.tsIdentifier, node);
87127
}
88128

89-
allNameDefinitions(): Iterable<NameDefinition> {
90-
return this._declarationToName.values();
129+
allDefinitions(): Iterable<DeclarationDefinition> {
130+
return this._declarationToDefinition.values();
91131
}
92132

93133
findSymbolDeclaration(startSymbol: ts.Symbol): ts.Declaration | null {
@@ -135,7 +175,9 @@ export class TypeContext {
135175
);
136176
}
137177

138-
const nameDefinition = this._declarationToName.get(declarationResult.value);
178+
const nameDefinition = this._declarationToDefinition.get(
179+
declarationResult.value,
180+
);
139181
if (nameDefinition == null) {
140182
return err(gqlErr(unresolved, E.unresolvedTypeReference()));
141183
}
@@ -156,12 +198,12 @@ export class TypeContext {
156198
if (referenceNode == null) return false;
157199
const declaration = this.maybeTsDeclarationForTsName(referenceNode);
158200
if (declaration == null) return false;
159-
return this._declarationToName.has(declaration);
201+
return this._declarationToDefinition.has(declaration);
160202
}
161203

162204
gqlNameDefinitionForGqlName(
163205
nameNode: NameNode,
164-
): DiagnosticResult<NameDefinition> {
206+
): DiagnosticResult<DeclarationDefinition> {
165207
const referenceNode = this.getEntityName(nameNode);
166208
if (referenceNode == null) {
167209
throw new Error("Expected to find reference node for name node.");
@@ -171,7 +213,7 @@ export class TypeContext {
171213
if (declaration == null) {
172214
return err(gqlErr(nameNode, E.unresolvedTypeReference()));
173215
}
174-
const definition = this._declarationToName.get(declaration);
216+
const definition = this._declarationToDefinition.get(declaration);
175217
if (definition == null) {
176218
return err(gqlErr(nameNode, E.unresolvedTypeReference()));
177219
}
@@ -192,7 +234,9 @@ export class TypeContext {
192234
);
193235
}
194236

195-
const nameDefinition = this._declarationToName.get(declarationResult.value);
237+
const nameDefinition = this._declarationToDefinition.get(
238+
declarationResult.value,
239+
);
196240
if (nameDefinition == null) {
197241
return err(tsErr(node, E.unresolvedTypeReference()));
198242
}

src/codegen/TSAstBuilder.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const F = ts.factory;
99
* A helper class to build up a TypeScript document AST.
1010
*/
1111
export default class TSAstBuilder {
12+
_globalNames: Map<string, number> = new Map();
1213
_imports: ts.Statement[] = [];
1314
imports: Map<string, { name: string; as?: string }[]> = new Map();
1415
_helpers: ts.Statement[] = [];
@@ -209,7 +210,21 @@ export default class TSAstBuilder {
209210
sourceFile,
210211
);
211212
}
213+
214+
// Given a desired name in the module scope, return a name that is unique. If
215+
// the name is already taken, a suffix will be added to the name to make it
216+
// unique.
217+
//
218+
// NOTE: This is not truly unique, as it only checks the names that have been
219+
// generated through this method. In the future we could add more robust
220+
// scope/name tracking.
221+
getUniqueName(name: string): string {
222+
const count = this._globalNames.get(name) ?? 0;
223+
this._globalNames.set(name, count + 1);
224+
return count === 0 ? name : `${name}_${count}`;
225+
}
212226
}
227+
213228
function replaceExt(filePath: string, newSuffix: string): string {
214229
const ext = path.extname(filePath);
215230
return filePath.slice(0, -ext.length) + newSuffix;

src/codegen/resolverCodegen.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const F = ts.factory;
2020
*/
2121
export default class ResolverCodegen {
2222
_helpers: Set<string> = new Set();
23+
_derivedContextNames: Map<string, string> = new Map();
2324
constructor(public ts: TSAstBuilder, public _resolvers: Metadata) {}
2425
resolveMethod(
2526
fieldName: string,
@@ -178,11 +179,36 @@ export default class ResolverCodegen {
178179
F.createIdentifier("args"),
179180
F.createIdentifier(arg.name),
180181
);
182+
case "derivedContext": {
183+
const localName = this.getDerivedContextName(arg.path, arg.exportName);
184+
this.ts.importUserConstruct(arg.path, arg.exportName, localName);
185+
return F.createCallExpression(
186+
F.createIdentifier(localName),
187+
undefined,
188+
arg.args.map((arg) => this.resolverParam(arg)),
189+
);
190+
}
191+
181192
default:
182193
// @ts-expect-error
183194
throw new Error(`Unexpected resolver kind ${arg.kind}`);
184195
}
185196
}
197+
198+
// Derived contexts are not anchored to anything that we know to be
199+
// globally unique, like GraphQL type names, so must ensure this name is
200+
// unique within our module. However, we want to avoid generating a new
201+
// name for the same derived context more than once.
202+
getDerivedContextName(path: string, exportName: string | null): string {
203+
const key = `${path}:${exportName ?? ""}`;
204+
let name = this._derivedContextNames.get(key);
205+
if (name == null) {
206+
name = this.ts.getUniqueName(exportName ?? "deriveContext");
207+
this._derivedContextNames.set(key, name);
208+
}
209+
return name;
210+
}
211+
186212
// If a field is smantically non-null, we need to wrap the resolver in a
187213
// runtime check to ensure that the resolver does not return null.
188214
maybeApplySemanticNullRuntimeCheck(

src/lib.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,11 @@ export function extractSchemaAndDoc(
9090
const { typesWithTypename } = snapshot;
9191
const config = options.raw.grats;
9292
const checker = program.getTypeChecker();
93-
const ctx = TypeContext.fromSnapshot(checker, snapshot);
93+
const ctxResult = TypeContext.fromSnapshot(checker, snapshot);
94+
if (ctxResult.kind === "ERROR") {
95+
return ctxResult;
96+
}
97+
const ctx = ctxResult.value;
9498

9599
// Collect validation errors
96100
const validationResult = concatResults(
@@ -177,6 +181,7 @@ function combineSnapshots(snapshots: ExtractionSnapshot[]): ExtractionSnapshot {
177181
const result: ExtractionSnapshot = {
178182
definitions: [],
179183
nameDefinitions: new Map(),
184+
implicitNameDefinitions: new Map(),
180185
unresolvedNames: new Map(),
181186
typesWithTypename: new Set(),
182187
interfaceDeclarations: [],
@@ -195,6 +200,10 @@ function combineSnapshots(snapshots: ExtractionSnapshot[]): ExtractionSnapshot {
195200
result.unresolvedNames.set(node, typeName);
196201
}
197202

203+
for (const [node, definition] of snapshot.implicitNameDefinitions) {
204+
result.implicitNameDefinitions.set(node, definition);
205+
}
206+
198207
for (const typeName of snapshot.typesWithTypename) {
199208
result.typesWithTypename.add(typeName);
200209
}

0 commit comments

Comments
 (0)