Skip to content

Commit 1e9384d

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 831703d commit 1e9384d

19 files changed

+678
-44
lines changed

src/Extractor.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export type ExtractionSnapshot = {
8282
readonly definitions: DefinitionNode[];
8383
readonly unresolvedNames: Map<ts.EntityName, NameNode>;
8484
readonly nameDefinitions: Map<ts.DeclarationStatement, NameDefinition>;
85+
readonly implicitNameDefinitions: Map<NameDefinition, ts.TypeReferenceNode>;
8586
readonly typesWithTypename: Set<string>;
8687
readonly interfaceDeclarations: Array<ts.InterfaceDeclaration>;
8788
};
@@ -113,6 +114,8 @@ class Extractor {
113114
// Snapshot data
114115
unresolvedNames: Map<ts.EntityName, NameNode> = new Map();
115116
nameDefinitions: Map<ts.DeclarationStatement, NameDefinition> = new Map();
117+
implicitNameDefinitions: Map<NameDefinition, ts.TypeReferenceNode> =
118+
new Map();
116119
typesWithTypename: Set<string> = new Set();
117120
interfaceDeclarations: Array<ts.InterfaceDeclaration> = [];
118121

@@ -132,6 +135,7 @@ class Extractor {
132135
name: NameNode,
133136
kind: NameDefinition["kind"],
134137
): void {
138+
// @ts-ignore FIXME
135139
this.nameDefinitions.set(node, { name, kind });
136140
}
137141

@@ -218,8 +222,12 @@ class Extractor {
218222
if (!ts.isDeclarationStatement(node)) {
219223
this.report(tag, E.contextTagOnNonDeclaration());
220224
} else {
221-
const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME");
222-
this.recordTypeName(node, name, "CONTEXT");
225+
if (ts.isFunctionDeclaration(node)) {
226+
this.recordDerivedContext(node, tag);
227+
} else {
228+
const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME");
229+
this.recordTypeName(node, name, "CONTEXT");
230+
}
223231
}
224232
break;
225233
}
@@ -293,11 +301,45 @@ class Extractor {
293301
definitions: this.definitions,
294302
unresolvedNames: this.unresolvedNames,
295303
nameDefinitions: this.nameDefinitions,
304+
implicitNameDefinitions: this.implicitNameDefinitions,
296305
typesWithTypename: this.typesWithTypename,
297306
interfaceDeclarations: this.interfaceDeclarations,
298307
});
299308
}
300309

310+
recordDerivedContext(node: ts.FunctionDeclaration, tag: ts.JSDocTag) {
311+
const returnType = node.type;
312+
if (returnType == null) {
313+
throw new Error("Function declaration must have a return type");
314+
}
315+
if (!ts.isTypeReferenceNode(returnType)) {
316+
throw new Error("Function declaration must return an explicit type");
317+
}
318+
319+
const funcName = this.namedFunctionExportName(node);
320+
321+
if (!ts.isSourceFile(node.parent)) {
322+
return this.report(node, E.functionFieldNotTopLevel());
323+
}
324+
325+
const tsModulePath = relativePath(node.getSourceFile().fileName);
326+
327+
const paramResults = this.resolverParams(node.parameters);
328+
if (paramResults == null) return null;
329+
330+
const name = this.gql.name(tag, "CONTEXT_DUMMY_NAME");
331+
this.implicitNameDefinitions.set(
332+
{
333+
kind: "DERIVED_CONTEXT",
334+
name,
335+
path: tsModulePath,
336+
exportName: funcName?.text ?? null,
337+
args: paramResults.resolverParams,
338+
},
339+
returnType,
340+
);
341+
}
342+
301343
extractType(node: ts.Node, tag: ts.JSDocTag) {
302344
if (ts.isClassDeclaration(node)) {
303345
this.typeClassDeclaration(node, tag);

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
@@ -89,7 +89,11 @@ export function extractSchemaAndDoc(
8989
const { typesWithTypename } = snapshot;
9090
const config = options.raw.grats;
9191
const checker = program.getTypeChecker();
92-
const ctx = TypeContext.fromSnapshot(checker, snapshot);
92+
const ctxResult = TypeContext.fromSnapshot(checker, snapshot);
93+
if (ctxResult.kind === "ERROR") {
94+
return ctxResult;
95+
}
96+
const ctx = ctxResult.value;
9397

9498
// Collect validation errors
9599
const validationResult = concatResults(
@@ -173,6 +177,7 @@ function combineSnapshots(snapshots: ExtractionSnapshot[]): ExtractionSnapshot {
173177
const result: ExtractionSnapshot = {
174178
definitions: [],
175179
nameDefinitions: new Map(),
180+
implicitNameDefinitions: new Map(),
176181
unresolvedNames: new Map(),
177182
typesWithTypename: new Set(),
178183
interfaceDeclarations: [],
@@ -191,6 +196,10 @@ function combineSnapshots(snapshots: ExtractionSnapshot[]): ExtractionSnapshot {
191196
result.unresolvedNames.set(node, typeName);
192197
}
193198

199+
for (const [node, definition] of snapshot.implicitNameDefinitions) {
200+
result.implicitNameDefinitions.set(node, definition);
201+
}
202+
194203
for (const typeName of snapshot.typesWithTypename) {
195204
result.typesWithTypename.add(typeName);
196205
}

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)