Skip to content

Commit

Permalink
Set up GraphQL emitter skeleton
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
steverice committed Nov 19, 2024
1 parent c0fff8a commit 26261e3
Show file tree
Hide file tree
Showing 5 changed files with 255 additions and 7 deletions.
46 changes: 46 additions & 0 deletions packages/graphql/src/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { navigateProgram, type Program, type SemanticNodeListener } from "@typespec/compiler";
import type { GraphQLObjectType } from "graphql";

type Mutable<T> = {
-readonly [k in keyof T]: T[k];
};

// This class contains the registry of all the GraphQL types that are being used in the program
export class GraphQLTypeRegistry {
program: Program;
readonly programNavigated: boolean = false;

constructor(program: Program) {
this.program = program;
return new Proxy(this, {
get(target: GraphQLTypeRegistry, prop: string, receiver) {
if (GraphQLTypeRegistry.#publicGetters.includes(prop)) {
if (!target.programNavigated) {
const mutableThis = target as Mutable<GraphQLTypeRegistry>;
navigateProgram(target.program, target.#semanticNodeListener);
mutableThis.programNavigated = true;
}
}
return Reflect.get(target, prop, receiver);
},
});
}

static get #publicGetters() {
return Object.entries(Object.getOwnPropertyDescriptors(GraphQLTypeRegistry.prototype))
.filter(([key, descriptor]) => {
return typeof descriptor.get === "function" && key !== "constructor";
})
.map(([key]) => key);
}

get rootQueryType(): GraphQLObjectType | undefined {
return;
}

// This is the listener based on navigateProgram that will walk the TSP AST and register the types,
// deferred in some cases, and then materialize them in exitXXX functions
get #semanticNodeListener(): SemanticNodeListener {
return {};
}
}
138 changes: 131 additions & 7 deletions packages/graphql/src/schema-emitter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import { emitFile, interpolatePath, type EmitContext } from "@typespec/compiler";
import {
emitFile,
getNamespaceFullName,
interpolatePath,
type EmitContext,
type Program,
} from "@typespec/compiler";
import {
GraphQLBoolean,
GraphQLObjectType,
GraphQLSchema,
printSchema,
validateSchema,
type GraphQLSchemaConfig,
} from "graphql";
import type { ResolvedGraphQLEmitterOptions } from "./emitter.js";
import type { GraphQLEmitterOptions } from "./lib.js";
import { createDiagnostic, type GraphQLEmitterOptions } from "./lib.js";
import { listSchemas, type Schema } from "./lib/schema.js";
import { GraphQLTypeRegistry } from "./registry.js";
import type { GraphQLSchemaRecord } from "./types.js";

export const PLACEHOLDER_FIELD = {
type: GraphQLBoolean,
description:
"A placeholder field. If you are seeing this, it means no operations were defined that could be emitted.",
};

export function createGraphQLEmitter(
context: EmitContext<GraphQLEmitterOptions>,
Expand All @@ -12,15 +35,116 @@ export function createGraphQLEmitter(
emitGraphQL,
};

function resolveOutputFile(schema: Schema, multipleSchema: boolean): string {
return interpolatePath(options.outputFile, {
"schema-name": multipleSchema ? schema.name || getNamespaceFullName(schema.type) : "schema",
});
}

async function emitGraphQL() {
// replace this with the real emitter code
if (!program.compilerOptions.noEmit) {
const filePath = interpolatePath(options.outputFile, { "schema-name": "schema" });
const emitter = new GraphQLEmitter(program, options);

for (const schemaRecord of emitter.schemaRecords) {
program.reportDiagnostics(schemaRecord.diagnostics);
}

if (program.compilerOptions.noEmit || program.hasError()) {
return;
}

const multipleSchema = emitter.schemaRecords.length > 1;

for (const schemaRecord of emitter.schemaRecords) {
await emitFile(program, {
path: filePath,
content: "",
path: resolveOutputFile(schemaRecord.schema, multipleSchema),
content: serializeDocument(schemaRecord.graphQLSchema),
newLine: options.newLine,
});
}
}
}

function serializeDocument(schema: GraphQLSchema): string {
return printSchema(schema);
}

export class GraphQLEmitter {
#options: ResolvedGraphQLEmitterOptions;
program: Program;

constructor(program: Program, options: ResolvedGraphQLEmitterOptions) {
this.#options = options;
this.program = program;
}

#schemaDefinitions?: Schema[];
get schemaDefinitions(): Schema[] {
if (!this.#schemaDefinitions) {
const schemas = listSchemas(this.program);
if (schemas.length === 0) {
schemas.push({ type: this.program.getGlobalNamespaceType() });
}
this.#schemaDefinitions = schemas;
}
return this.#schemaDefinitions;
}

#registry?: GraphQLTypeRegistry;
get registry() {
if (!this.#registry) {
this.#registry = new GraphQLTypeRegistry(this.program);
}
return this.#registry;
}

#schemaRecords?: GraphQLSchemaRecord[];
get schemaRecords(): GraphQLSchemaRecord[] {
if (!this.#schemaRecords) {
this.#schemaRecords = this.#buildGraphQLSchemas();
}
return this.#schemaRecords;
}

static get placeholderQuery(): GraphQLObjectType {
return new GraphQLObjectType({
name: "Query",
fields: {
// An Object type must define one or more fields.
// https://spec.graphql.org/October2021/#sec-Objects.Type-Validation
_: PLACEHOLDER_FIELD,
},
});
}

#buildGraphQLSchemas(): GraphQLSchemaRecord[] {
const schemaRecords: GraphQLSchemaRecord[] = [];

for (const schema of this.schemaDefinitions) {
const schemaConfig: GraphQLSchemaConfig = {};
if (!("query" in schemaConfig)) {
// The query root operation type must be provided and must be an Object type.
// https://spec.graphql.org/draft/#sec-Root-Operation-Types
schemaConfig.query = GraphQLEmitter.placeholderQuery;
}
// Build schema
const graphQLSchema = new GraphQLSchema(schemaConfig);
// Validate schema
const validationErrors = validateSchema(graphQLSchema);
const diagnostics = validationErrors.map((error) => {
const locations = error.locations?.map((loc) => `line ${loc.line}, column ${loc.column}`);
return createDiagnostic({
code: "graphql-validation-error",
format: {
message: error.message,
locations: locations ? locations.join(", ") : "none",
},
target: schema.type,
});
});

schemaRecords.push({ schema, graphQLSchema, diagnostics });
}

return schemaRecords;
}
}
37 changes: 37 additions & 0 deletions packages/graphql/test/assertions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { isType, type GraphQLType } from "graphql";
import { expect } from "vitest";

interface GraphQLAssertions<R = unknown> {
toEqualType: (expected: GraphQLType) => R;
}

declare module "vitest" {
interface Assertion<T = any> extends GraphQLAssertions<T> {}
interface AsymmetricMatchersContaining extends GraphQLAssertions {}
}

expect.extend({
toEqualType(received: GraphQLType, expected: GraphQLType) {
if (!isType(expected)) {
return {
pass: false,
message: () => `Expected value ${expected} is not a GraphQLType.`,
};
}

if (!isType(received)) {
return {
pass: false,
message: () => `Received value ${received} is not a GraphQLType.`,
};
}

const { isNot } = this;
return {
pass: received.toJSON() === expected.toJSON(),
message: () => `${received} is${isNot ? " not" : ""} the same as ${expected}`,
};
},
});

export { expect };
21 changes: 21 additions & 0 deletions packages/graphql/test/emitter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { expectDiagnosticEmpty } from "@typespec/compiler/testing";
import { GraphQLSchema, printSchema } from "graphql";
import { describe, it } from "vitest";
import { GraphQLEmitter } from "../src/schema-emitter.js";
import { expect } from "./assertions.js";
import { emitSingleSchemaWithDiagnostics } from "./test-host.js";

describe("GraphQL emitter", () => {
it("Can produce a placeholder GraphQL schema", async () => {
const result = await emitSingleSchemaWithDiagnostics("");
expectDiagnosticEmpty(result.diagnostics);
expect(result.graphQLSchema).toBeInstanceOf(GraphQLSchema);
expect(result.graphQLSchema?.getQueryType()).toEqualType(GraphQLEmitter.placeholderQuery);
});

it("Can produce an SDL output", async () => {
const result = await emitSingleSchemaWithDiagnostics("");
expectDiagnosticEmpty(result.diagnostics);
expect(result.graphQLOutput).toEqual(printSchema(result.graphQLSchema!));
});
});
20 changes: 20 additions & 0 deletions packages/graphql/test/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { beforeEach, describe, it } from "vitest";
import { GraphQLTypeRegistry } from "../src/registry.js";
import { expect } from "./assertions.js";
import { createGraphqlTestRunner } from "./test-host.js";

describe("GraphQL Type Registry", () => {
let registry: GraphQLTypeRegistry;

beforeEach(async () => {
const runner = await createGraphqlTestRunner();
await runner.diagnose("");
registry = new GraphQLTypeRegistry(runner.program);
});

it("Will navigate program when state is accessed", () => {
expect(registry.programNavigated).toBe(false);
expect(registry.rootQueryType).toBeUndefined();
expect(registry.programNavigated).toBe(true);
});
});

0 comments on commit 26261e3

Please sign in to comment.