Skip to content

Commit 6bd70d0

Browse files
committed
Set up GraphQL emitter skeleton
This commit sets up the basic skeleton for the GraphQL emitter, capable of handling multiple defined GraphQL schemas. The approach here is slightly different from before. Here, we are limiting the functionality of `GraphQLEmitter` to handling the schema definitions, and not participating in any direct parsing of the TSP program. Instead, the `GraphQLTypeRegistry` is responsible for handling its own interpretation of the TSP program in order to provide the types in its registry. This primarily allows for two things: 1. The `GraphQLTypeRegistry` can encapsulate its own functionality, instead of being a "bucket of state" that must be modified and managed externally. 2. The "bucket of state" responsibility can be primarily handled by the StateMap library, with the `GraphQLTypeRegistry` being the orchestrator of that state The `GraphQLTypeRegistry` uses a `Proxy` object to ensure that the program navigation has taken place before any of its public properties are accessed.
1 parent cdceeeb commit 6bd70d0

File tree

5 files changed

+255
-7
lines changed

5 files changed

+255
-7
lines changed

packages/graphql/src/registry.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { navigateProgram, type Program, type SemanticNodeListener } from "@typespec/compiler";
2+
import type { GraphQLObjectType } from "graphql";
3+
4+
type Mutable<T> = {
5+
-readonly [k in keyof T]: T[k];
6+
};
7+
8+
// This class contains the registry of all the GraphQL types that are being used in the program
9+
export class GraphQLTypeRegistry {
10+
program: Program;
11+
readonly programNavigated: boolean = false;
12+
13+
constructor(program: Program) {
14+
this.program = program;
15+
return new Proxy(this, {
16+
get(target: GraphQLTypeRegistry, prop: string, receiver) {
17+
if (GraphQLTypeRegistry.#publicGetters.includes(prop)) {
18+
if (!target.programNavigated) {
19+
const mutableThis = target as Mutable<GraphQLTypeRegistry>;
20+
navigateProgram(target.program, target.#semanticNodeListener);
21+
mutableThis.programNavigated = true;
22+
}
23+
}
24+
return Reflect.get(target, prop, receiver);
25+
},
26+
});
27+
}
28+
29+
static get #publicGetters() {
30+
return Object.entries(Object.getOwnPropertyDescriptors(GraphQLTypeRegistry.prototype))
31+
.filter(([key, descriptor]) => {
32+
return typeof descriptor.get === "function" && key !== "constructor";
33+
})
34+
.map(([key]) => key);
35+
}
36+
37+
get rootQueryType(): GraphQLObjectType | undefined {
38+
return;
39+
}
40+
41+
// This is the listener based on navigateProgram that will walk the TSP AST and register the types,
42+
// deferred in some cases, and then materialize them in exitXXX functions
43+
get #semanticNodeListener(): SemanticNodeListener {
44+
return {};
45+
}
46+
}
Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
1-
import { emitFile, interpolatePath, type EmitContext } from "@typespec/compiler";
1+
import {
2+
emitFile,
3+
getNamespaceFullName,
4+
interpolatePath,
5+
type EmitContext,
6+
type Program,
7+
} from "@typespec/compiler";
8+
import {
9+
GraphQLBoolean,
10+
GraphQLObjectType,
11+
GraphQLSchema,
12+
printSchema,
13+
validateSchema,
14+
type GraphQLSchemaConfig,
15+
} from "graphql";
216
import type { ResolvedGraphQLEmitterOptions } from "./emitter.js";
3-
import type { GraphQLEmitterOptions } from "./lib.js";
17+
import { createDiagnostic, type GraphQLEmitterOptions } from "./lib.js";
18+
import { listSchemas, type Schema } from "./lib/schema.js";
19+
import { GraphQLTypeRegistry } from "./registry.js";
20+
import type { GraphQLSchemaRecord } from "./types.js";
21+
22+
export const PLACEHOLDER_FIELD = {
23+
type: GraphQLBoolean,
24+
description:
25+
"A placeholder field. If you are seeing this, it means no operations were defined that could be emitted.",
26+
};
427

528
export function createGraphQLEmitter(
629
context: EmitContext<GraphQLEmitterOptions>,
@@ -12,15 +35,116 @@ export function createGraphQLEmitter(
1235
emitGraphQL,
1336
};
1437

38+
function resolveOutputFile(schema: Schema, multipleSchema: boolean): string {
39+
return interpolatePath(options.outputFile, {
40+
"schema-name": multipleSchema ? schema.name || getNamespaceFullName(schema.type) : "schema",
41+
});
42+
}
43+
1544
async function emitGraphQL() {
16-
// replace this with the real emitter code
17-
if (!program.compilerOptions.noEmit) {
18-
const filePath = interpolatePath(options.outputFile, { "schema-name": "schema" });
45+
const emitter = new GraphQLEmitter(program, options);
46+
47+
for (const schemaRecord of emitter.schemaRecords) {
48+
program.reportDiagnostics(schemaRecord.diagnostics);
49+
}
50+
51+
if (program.compilerOptions.noEmit || program.hasError()) {
52+
return;
53+
}
54+
55+
const multipleSchema = emitter.schemaRecords.length > 1;
56+
57+
for (const schemaRecord of emitter.schemaRecords) {
1958
await emitFile(program, {
20-
path: filePath,
21-
content: "",
59+
path: resolveOutputFile(schemaRecord.schema, multipleSchema),
60+
content: serializeDocument(schemaRecord.graphQLSchema),
2261
newLine: options.newLine,
2362
});
2463
}
2564
}
2665
}
66+
67+
function serializeDocument(schema: GraphQLSchema): string {
68+
return printSchema(schema);
69+
}
70+
71+
export class GraphQLEmitter {
72+
#options: ResolvedGraphQLEmitterOptions;
73+
program: Program;
74+
75+
constructor(program: Program, options: ResolvedGraphQLEmitterOptions) {
76+
this.#options = options;
77+
this.program = program;
78+
}
79+
80+
#schemaDefinitions?: Schema[];
81+
get schemaDefinitions(): Schema[] {
82+
if (!this.#schemaDefinitions) {
83+
const schemas = listSchemas(this.program);
84+
if (schemas.length === 0) {
85+
schemas.push({ type: this.program.getGlobalNamespaceType() });
86+
}
87+
this.#schemaDefinitions = schemas;
88+
}
89+
return this.#schemaDefinitions;
90+
}
91+
92+
#registry?: GraphQLTypeRegistry;
93+
get registry() {
94+
if (!this.#registry) {
95+
this.#registry = new GraphQLTypeRegistry(this.program);
96+
}
97+
return this.#registry;
98+
}
99+
100+
#schemaRecords?: GraphQLSchemaRecord[];
101+
get schemaRecords(): GraphQLSchemaRecord[] {
102+
if (!this.#schemaRecords) {
103+
this.#schemaRecords = this.#buildGraphQLSchemas();
104+
}
105+
return this.#schemaRecords;
106+
}
107+
108+
static get placeholderQuery(): GraphQLObjectType {
109+
return new GraphQLObjectType({
110+
name: "Query",
111+
fields: {
112+
// An Object type must define one or more fields.
113+
// https://spec.graphql.org/October2021/#sec-Objects.Type-Validation
114+
_: PLACEHOLDER_FIELD,
115+
},
116+
});
117+
}
118+
119+
#buildGraphQLSchemas(): GraphQLSchemaRecord[] {
120+
const schemaRecords: GraphQLSchemaRecord[] = [];
121+
122+
for (const schema of this.schemaDefinitions) {
123+
const schemaConfig: GraphQLSchemaConfig = {};
124+
if (!("query" in schemaConfig)) {
125+
// The query root operation type must be provided and must be an Object type.
126+
// https://spec.graphql.org/draft/#sec-Root-Operation-Types
127+
schemaConfig.query = GraphQLEmitter.placeholderQuery;
128+
}
129+
// Build schema
130+
const graphQLSchema = new GraphQLSchema(schemaConfig);
131+
// Validate schema
132+
const validationErrors = validateSchema(graphQLSchema);
133+
const diagnostics = validationErrors.map((error) => {
134+
const locations = error.locations?.map((loc) => `line ${loc.line}, column ${loc.column}`);
135+
return createDiagnostic({
136+
code: "graphql-validation-error",
137+
format: {
138+
message: error.message,
139+
locations: locations ? locations.join(", ") : "none",
140+
},
141+
target: schema.type,
142+
});
143+
});
144+
145+
schemaRecords.push({ schema, graphQLSchema, diagnostics });
146+
}
147+
148+
return schemaRecords;
149+
}
150+
}

packages/graphql/test/assertions.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { isType, type GraphQLType } from "graphql";
2+
import { expect } from "vitest";
3+
4+
interface GraphQLAssertions<R = unknown> {
5+
toEqualType: (expected: GraphQLType) => R;
6+
}
7+
8+
declare module "vitest" {
9+
interface Assertion<T = any> extends GraphQLAssertions<T> {}
10+
interface AsymmetricMatchersContaining extends GraphQLAssertions {}
11+
}
12+
13+
expect.extend({
14+
toEqualType(received: GraphQLType, expected: GraphQLType) {
15+
if (!isType(expected)) {
16+
return {
17+
pass: false,
18+
message: () => `Expected value ${expected} is not a GraphQLType.`,
19+
};
20+
}
21+
22+
if (!isType(received)) {
23+
return {
24+
pass: false,
25+
message: () => `Received value ${received} is not a GraphQLType.`,
26+
};
27+
}
28+
29+
const { isNot } = this;
30+
return {
31+
pass: received.toJSON() === expected.toJSON(),
32+
message: () => `${received} is${isNot ? " not" : ""} the same as ${expected}`,
33+
};
34+
},
35+
});
36+
37+
export { expect };

packages/graphql/test/emitter.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { expectDiagnosticEmpty } from "@typespec/compiler/testing";
2+
import { GraphQLSchema, printSchema } from "graphql";
3+
import { describe, it } from "vitest";
4+
import { GraphQLEmitter } from "../src/schema-emitter.js";
5+
import { expect } from "./assertions.js";
6+
import { emitSingleSchemaWithDiagnostics } from "./test-host.js";
7+
8+
describe("GraphQL emitter", () => {
9+
it("Can produce a placeholder GraphQL schema", async () => {
10+
const result = await emitSingleSchemaWithDiagnostics("");
11+
expectDiagnosticEmpty(result.diagnostics);
12+
expect(result.graphQLSchema).toBeInstanceOf(GraphQLSchema);
13+
expect(result.graphQLSchema?.getQueryType()).toEqualType(GraphQLEmitter.placeholderQuery);
14+
});
15+
16+
it("Can produce an SDL output", async () => {
17+
const result = await emitSingleSchemaWithDiagnostics("");
18+
expectDiagnosticEmpty(result.diagnostics);
19+
expect(result.graphQLOutput).toEqual(printSchema(result.graphQLSchema!));
20+
});
21+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { beforeEach, describe, it } from "vitest";
2+
import { GraphQLTypeRegistry } from "../src/registry.js";
3+
import { expect } from "./assertions.js";
4+
import { createGraphqlTestRunner } from "./test-host.js";
5+
6+
describe("GraphQL Type Registry", () => {
7+
let registry: GraphQLTypeRegistry;
8+
9+
beforeEach(async () => {
10+
const runner = await createGraphqlTestRunner();
11+
await runner.diagnose("");
12+
registry = new GraphQLTypeRegistry(runner.program);
13+
});
14+
15+
it("Will navigate program when state is accessed", () => {
16+
expect(registry.programNavigated).toBe(false);
17+
expect(registry.rootQueryType).toBeUndefined();
18+
expect(registry.programNavigated).toBe(true);
19+
});
20+
});

0 commit comments

Comments
 (0)