Skip to content

Commit f9b80ae

Browse files
committed
[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
1 parent 89047e2 commit f9b80ae

19 files changed

+677
-44
lines changed

src/Extractor.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export type ExtractionSnapshot = {
8686
readonly definitions: DefinitionNode[];
8787
readonly unresolvedNames: Map<ts.EntityName, NameNode>;
8888
readonly nameDefinitions: Map<ts.DeclarationStatement, NameDefinition>;
89+
readonly implicitNameDefinitions: Map<NameDefinition, ts.TypeReferenceNode>;
8990
readonly typesWithTypename: Set<string>;
9091
readonly interfaceDeclarations: Array<ts.InterfaceDeclaration>;
9192
};
@@ -117,6 +118,8 @@ class Extractor {
117118
// Snapshot data
118119
unresolvedNames: Map<ts.EntityName, NameNode> = new Map();
119120
nameDefinitions: Map<ts.DeclarationStatement, NameDefinition> = new Map();
121+
implicitNameDefinitions: Map<NameDefinition, ts.TypeReferenceNode> =
122+
new Map();
120123
typesWithTypename: Set<string> = new Set();
121124
interfaceDeclarations: Array<ts.InterfaceDeclaration> = [];
122125

@@ -136,6 +139,7 @@ class Extractor {
136139
name: NameNode,
137140
kind: NameDefinition["kind"],
138141
): void {
142+
// @ts-ignore FIXME
139143
this.nameDefinitions.set(node, { name, kind });
140144
}
141145

@@ -188,8 +192,12 @@ class Extractor {
188192
if (!ts.isDeclarationStatement(node)) {
189193
this.report(tag, E.contextTagOnNonDeclaration());
190194
} else {
191-
const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME");
192-
this.recordTypeName(node, name, "CONTEXT");
195+
if (ts.isFunctionDeclaration(node)) {
196+
this.recordDerivedContext(node, tag);
197+
} else {
198+
const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME");
199+
this.recordTypeName(node, name, "CONTEXT");
200+
}
193201
}
194202
break;
195203
}
@@ -270,6 +278,7 @@ class Extractor {
270278
definitions: this.definitions,
271279
unresolvedNames: this.unresolvedNames,
272280
nameDefinitions: this.nameDefinitions,
281+
implicitNameDefinitions: this.implicitNameDefinitions,
273282
typesWithTypename: this.typesWithTypename,
274283
interfaceDeclarations: this.interfaceDeclarations,
275284
});
@@ -329,6 +338,38 @@ class Extractor {
329338
}
330339
}
331340
}
341+
recordDerivedContext(node: ts.FunctionDeclaration, tag: ts.JSDocTag) {
342+
const returnType = node.type;
343+
if (returnType == null) {
344+
throw new Error("Function declaration must have a return type");
345+
}
346+
if (!ts.isTypeReferenceNode(returnType)) {
347+
throw new Error("Function declaration must return an explicit type");
348+
}
349+
350+
const funcName = this.namedFunctionExportName(node);
351+
352+
if (!ts.isSourceFile(node.parent)) {
353+
return this.report(node, E.functionFieldNotTopLevel());
354+
}
355+
356+
const tsModulePath = relativePath(node.getSourceFile().fileName);
357+
358+
const paramResults = this.resolverParams(node.parameters);
359+
if (paramResults == null) return null;
360+
361+
const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME");
362+
this.implicitNameDefinitions.set(
363+
{
364+
kind: "DERIVED_CONTEXT",
365+
name,
366+
path: tsModulePath,
367+
exportName: funcName?.text ?? null,
368+
args: paramResults.resolverParams,
369+
},
370+
returnType,
371+
);
372+
}
332373

333374
extractType(node: ts.Node, tag: ts.JSDocTag) {
334375
if (ts.isClassDeclaration(node)) {

src/TypeContext.ts

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,40 @@ import {
1111
DiagnosticResult,
1212
tsErr,
1313
gqlRelated,
14+
DiagnosticsResult,
15+
FixableDiagnosticWithLocation,
16+
tsRelated,
1417
} from "./utils/DiagnosticError";
1518
import { err, ok } from "./utils/Result";
1619
import * as E from "./Errors";
1720
import { ExtractionSnapshot } from "./Extractor";
21+
import { ResolverArgument } from "./resolverSignature";
1822

1923
export const UNRESOLVED_REFERENCE_NAME = `__UNRESOLVED_REFERENCE__`;
2024

21-
export type NameDefinition = {
25+
export type DerivedResolverDefinition = {
2226
name: NameNode;
23-
kind:
24-
| "TYPE"
25-
| "INTERFACE"
26-
| "UNION"
27-
| "SCALAR"
28-
| "INPUT_OBJECT"
29-
| "ENUM"
30-
| "CONTEXT"
31-
| "INFO";
27+
path: string;
28+
exportName: string | null;
29+
args: ResolverArgument[];
30+
kind: "DERIVED_CONTEXT";
3231
};
3332

33+
export type NameDefinition =
34+
| {
35+
name: NameNode;
36+
kind:
37+
| "TYPE"
38+
| "INTERFACE"
39+
| "UNION"
40+
| "SCALAR"
41+
| "INPUT_OBJECT"
42+
| "ENUM"
43+
| "CONTEXT"
44+
| "INFO";
45+
}
46+
| DerivedResolverDefinition;
47+
3448
type TsIdentifier = number;
3549

3650
/**
@@ -55,15 +69,40 @@ export class TypeContext {
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._recordTypeName(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._declarationToName.get(declaration);
88+
if (existing != null) {
89+
errors.push(
90+
// TODO: Better error messages here
91+
tsErr(declaration, "Duplicate derived contexts for given type", [
92+
tsRelated(reference, "One was defined here"),
93+
gqlRelated(existing.name, "Other here"),
94+
]),
95+
);
96+
continue;
97+
}
98+
99+
self._recordTypeName(declaration, definition);
100+
}
101+
102+
if (errors.length > 0) {
103+
return err(errors);
65104
}
66-
return self;
105+
return ok(self);
67106
}
68107

69108
constructor(checker: ts.TypeChecker) {
@@ -72,13 +111,9 @@ export class TypeContext {
72111

73112
// Record that a GraphQL construct of type `kind` with the name `name` is
74113
// declared at `node`.
75-
private _recordTypeName(
76-
node: ts.Declaration,
77-
name: NameNode,
78-
kind: NameDefinition["kind"],
79-
) {
80-
this._idToDeclaration.set(name.tsIdentifier, node);
81-
this._declarationToName.set(node, { name, kind });
114+
private _recordTypeName(node: ts.Declaration, definition: NameDefinition) {
115+
this._idToDeclaration.set(definition.name.tsIdentifier, node);
116+
this._declarationToName.set(node, definition);
82117
}
83118

84119
// Record that a type references `node`

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
}

src/metadata.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,14 @@ export type StaticMethodResolver = {
8282
arguments: ResolverArgument[] | null;
8383
};
8484

85+
export type ContextArgs = ContextArgument | DerivedContextArgument;
86+
8587
/** An argument expected by a resolver function or method */
8688
export type ResolverArgument =
8789
| SourceArgument
8890
| ArgumentsObjectArgument
8991
| ContextArgument
92+
| DerivedContextArgument
9093
| InformationArgument
9194
| NamedArgument;
9295

@@ -105,6 +108,14 @@ export type ContextArgument = {
105108
kind: "context";
106109
};
107110

111+
/** A context value which is expressed as a function of the global context */
112+
export type DerivedContextArgument = {
113+
kind: "derivedContext";
114+
path: string; // Path to the module
115+
exportName: string | null; // Export name. If omitted, the class is the default export
116+
args: Array<ContextArgs>;
117+
};
118+
108119
/** The GraphQL info object */
109120
export type InformationArgument = {
110121
kind: "information";

src/resolverSignature.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ export type ContextResolverArgument = {
6060
node: ts.Node;
6161
};
6262

63+
export type DerivedContextResolverArgument = {
64+
kind: "derivedContext";
65+
path: string;
66+
exportName: string | null;
67+
args: Array<DerivedContextResolverArgument | ContextResolverArgument>;
68+
node: ts.Node;
69+
};
70+
6371
export type InformationResolverArgument = {
6472
kind: "information";
6573
node: ts.Node;
@@ -82,6 +90,7 @@ export type ResolverArgument =
8290
| SourceResolverArgument
8391
| ArgumentsObjectResolverArgument
8492
| ContextResolverArgument
93+
| DerivedContextResolverArgument
8594
| InformationResolverArgument
8695
| NamedResolverArgument
8796
| UnresolvedResolverArgument;

0 commit comments

Comments
 (0)