Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set up GraphQL emitter skeleton #2

Open
wants to merge 1 commit into
base: feature/graphql
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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];
};
Comment on lines +4 to +6
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@steverice there is a version of this in @typespec/compiler/src/utils/misc.ts that we can probably use instead.


// 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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would there be a situation where the program has been navigated? navigateProgram is deterministic in it's walk, so there is no reason to check for this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the longer discussion below. Basically, the intent here is that navigateProgram is never called explicitly — it's an implementation detail.

const mutableThis = target as Mutable<GraphQLTypeRegistry>;
navigateProgram(target.program, target.#semanticNodeListener);
mutableThis.programNavigated = true;
}
}
return Reflect.get(target, prop, receiver);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't seen the use of reflection in the TSP repo. I am not sure that this is good typescript practice. I am not an expert on this, but in compiled languages at least using reflection is usually a code smell. Besides, I am not quite convinced that this is needed as per my comment below.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflect in JavaScript isn't really the same concept as reflection in other languages:

The major use case of Reflect is to provide default forwarding behavior in Proxy handler traps.
JS docs

The way we're using it here is how it's expected to be used — when you are creating a Proxy object, it's used to call through to the original proxied object.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this actually is used in the TypeSpec repo. It's in the "experimental" TypeKit stuff, which is the new code that Timothee seems to have been working on as the replacement for the existing emitter framework. Since it's some of the newest code, I imagine it's up to the standards that the Microsoft team intends to use.

},
});
}

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 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to make this dynamic/lazy? We can use stateMaps to handle state if we want (although we should check that with Microsoft and I'm not sure if that'll work for all our cases, but that is a different discussion). The navigateProgram already takes care of navigating the program, so I don't think we need a dynamically accessible get function.

I think a more readable and deterministic navigate flow looks like

navigateProgram --> enterXXX extract information based on type and add to some map --> exitXXX extract information from map and create a GraphQL AST object.

None of the existing emitters need lazy initialization, so I am not sure that the lazy init is doing much for us here.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getters in the prototype are only used in the exit method, so these shouldn't really be public. Maybe registry is not the right name, but this is the thing that should return a schema from a public method.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! I was hoping to have this discussion 🙂

The only thing that's intended to be lazy in GraphQLTypeRegistry is the execution of navigateProgram. Basically, we have this object/interface that we want to be able to provide various information about the schema, but in order to have that information we need to have walked through the TSP program via navigateProgram. The intent of this Proxy trap is to ensure that, whenever some of that data is accessed, we have already navigated the program and therefore that data is available.

The emitter flow then looks something like this:

  1. GraphQLEmitter knows how to construct a GraphQL schema — i.e. it knows that you start with 1-3 root object types, each of those object types have fields, each of those fields are of various types, etc.
  2. As GraphQLEmitter works on constructing the schema, it asks GraphQLTypeRegistry various GraphQL-y questions like: "is there a root Query type?" "What fields are on this object type?" "What does this decorator look like?"
  3. GraphQLTypeRegistry interprets the TypeSpec schema in order to provide answers to those questions. It does so by walking through the TSP program and storing the answers to the questions that it is capable of asking.

This differs from the flow you outlined in that there is no explicit "now we must navigate the program" step. Instead of it being a TypeSpec-centric approach, it is a GraphQL-centric approach. This matters when we are looking to emit multiple GraphQL schemas — we want to start with each schema that we are going to emit and figuring out what it looks like, rather than starting with the whole TypeSpec program and figuring out what schemas we need to create, then categorizing all of the data into those different schemas (which requires a lot of global state). We already know what schemas we need to emit from the @schema decorator's stateMap.

From my understanding of the TypeSpec code, this is more in line with how both the existing TypeEmitter structure and the experimental TypeKit approaches work. They focus on creating definitions in the emitted language, and provide a series of questions that can be asked about the TypeSpec program in order to do so.

Also — I fully intend/expect that we will use stateMap for handling state where it can do so.

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);
});
});