From fe4f8bc9278f43714c21b2e8a0ab172d49d7e512 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 20 Apr 2026 10:58:05 -0400 Subject: [PATCH 1/5] Add operation components, orchestrator, and emitter rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the component-based GraphQL emitter online by wiring the data pipeline from the foundation skeleton into Alloy JSX rendering. New components: - components/operations/query-type.tsx, mutation-type.tsx, subscription-type.tsx: render the three GraphQL root operation types - components/operations/index.ts: barrel export - components/type-collections.tsx: orchestrator that dispatches each classified-type bucket (scalars, enums, unions, interfaces, objects, inputs) into the appropriate leaf-type component Emitter wiring: - Rename src/emitter.ts → src/emitter.tsx - Phase 5 now renders via Alloy's renderSchema, converts to SDL with graphql-js printSchema, and writes the output via emitFile - Adds output-file option handling with interpolatePath Testing: - test/e2e.test.ts: single happy-path smoke test proving the full TypeSpec → mutation → classification → rendering → SDL pipeline works - Update the existing test/emitter.test.ts data-pipeline test to now assert that SDL output is produced (Phase 5 is wired up) Broader integration coverage (nullability, input/output splitting, unions, enums, arrays, circular refs, deprecation, doc comments) lands in a follow-up PR focused on tests. --- .../src/components/operations/index.ts | 3 + .../components/operations/mutation-type.tsx | 27 +++ .../src/components/operations/query-type.tsx | 27 +++ .../operations/subscription-type.tsx | 27 +++ .../src/components/type-collections.tsx | 105 ++++++++++++ packages/graphql/src/emitter.ts | 83 --------- packages/graphql/src/emitter.tsx | 161 ++++++++++++++++++ packages/graphql/test/e2e.test.ts | 31 ++++ packages/graphql/test/emitter.test.ts | 24 ++- 9 files changed, 401 insertions(+), 87 deletions(-) create mode 100644 packages/graphql/src/components/operations/index.ts create mode 100644 packages/graphql/src/components/operations/mutation-type.tsx create mode 100644 packages/graphql/src/components/operations/query-type.tsx create mode 100644 packages/graphql/src/components/operations/subscription-type.tsx create mode 100644 packages/graphql/src/components/type-collections.tsx delete mode 100644 packages/graphql/src/emitter.ts create mode 100644 packages/graphql/src/emitter.tsx create mode 100644 packages/graphql/test/e2e.test.ts diff --git a/packages/graphql/src/components/operations/index.ts b/packages/graphql/src/components/operations/index.ts new file mode 100644 index 00000000000..bfb1fcd9df5 --- /dev/null +++ b/packages/graphql/src/components/operations/index.ts @@ -0,0 +1,3 @@ +export { QueryType, type QueryTypeProps } from "./query-type.js"; +export { MutationType, type MutationTypeProps } from "./mutation-type.js"; +export { SubscriptionType, type SubscriptionTypeProps } from "./subscription-type.js"; diff --git a/packages/graphql/src/components/operations/mutation-type.tsx b/packages/graphql/src/components/operations/mutation-type.tsx new file mode 100644 index 00000000000..0025e3b95d3 --- /dev/null +++ b/packages/graphql/src/components/operations/mutation-type.tsx @@ -0,0 +1,27 @@ +import { type Operation } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { OperationField } from "../fields/index.js"; + +export interface MutationTypeProps { + /** Mutation operations to render as fields */ + operations: Operation[]; +} + +/** + * Renders the GraphQL Mutation root type using Alloy's Mutation component + * + * Only renders if operations exist (Mutation is optional in GraphQL) + */ +export function MutationType(props: MutationTypeProps) { + if (props.operations.length === 0) { + return null; + } + + return ( + + {props.operations.map((op) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/operations/query-type.tsx b/packages/graphql/src/components/operations/query-type.tsx new file mode 100644 index 00000000000..fa3eb340e1b --- /dev/null +++ b/packages/graphql/src/components/operations/query-type.tsx @@ -0,0 +1,27 @@ +import { type Operation } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { OperationField } from "../fields/index.js"; + +export interface QueryTypeProps { + /** Query operations to render as fields */ + operations: Operation[]; +} + +/** + * Renders the GraphQL Query root type using Alloy's Query component. + * Returns null if no query operations exist (the emitter will emit an + * empty-schema diagnostic in that case). + */ +export function QueryType(props: QueryTypeProps) { + if (props.operations.length === 0) { + return null; + } + + return ( + + {props.operations.map((op) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/operations/subscription-type.tsx b/packages/graphql/src/components/operations/subscription-type.tsx new file mode 100644 index 00000000000..64031601b3c --- /dev/null +++ b/packages/graphql/src/components/operations/subscription-type.tsx @@ -0,0 +1,27 @@ +import { type Operation } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { OperationField } from "../fields/index.js"; + +export interface SubscriptionTypeProps { + /** Subscription operations to render as fields */ + operations: Operation[]; +} + +/** + * Renders the GraphQL Subscription root type using Alloy's Subscription component + * + * Only renders if operations exist (Subscription is optional in GraphQL) + */ +export function SubscriptionType(props: SubscriptionTypeProps) { + if (props.operations.length === 0) { + return null; + } + + return ( + + {props.operations.map((op) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/type-collections.tsx b/packages/graphql/src/components/type-collections.tsx new file mode 100644 index 00000000000..e5e0519649e --- /dev/null +++ b/packages/graphql/src/components/type-collections.tsx @@ -0,0 +1,105 @@ +import { For } from "@alloy-js/core"; +import * as gql from "@alloy-js/graphql"; +import { useGraphQLSchema } from "../context/index.js"; +import { + ScalarType, + EnumType, + UnionType, + InterfaceType, + ObjectType, + InputType, +} from "./types/index.js"; + +/** + * Renders scalar variant types for encoded scalars (e.g., bytes + base64 -> Bytes) + * AND custom user-defined scalars + */ +export function ScalarVariantTypes() { + const { classifiedTypes } = useGraphQLSchema(); + + // Get set of variant names to avoid duplicates + const variantNames = new Set( + classifiedTypes.scalarVariants.map((v) => v.graphqlName), + ); + + // Filter custom scalars to only include ones not already in variants + const customScalars = classifiedTypes.scalars.filter( + (s) => !variantNames.has(s.name), + ); + + return ( + <> + + {(variant) => ( + + )} + + + {(scalar) => } + + + ); +} + +/** + * Renders all enum types in the schema + */ +export function EnumTypes() { + const { classifiedTypes } = useGraphQLSchema(); + return ( + + {(enumType) => } + + ); +} + +/** + * Renders all union types in the schema + */ +export function UnionTypes() { + const { classifiedTypes } = useGraphQLSchema(); + return ( + + {(union) => } + + ); +} + +/** + * Renders all interface types in the schema + */ +export function InterfaceTypes() { + const { classifiedTypes } = useGraphQLSchema(); + return ( + + {(iface) => } + + ); +} + +/** + * Renders all object types in the schema + */ +export function ObjectTypes() { + const { classifiedTypes } = useGraphQLSchema(); + return ( + + {(model) => } + + ); +} + +/** + * Renders all input types in the schema + */ +export function InputTypes() { + const { classifiedTypes } = useGraphQLSchema(); + return ( + + {(model) => } + + ); +} diff --git a/packages/graphql/src/emitter.ts b/packages/graphql/src/emitter.ts deleted file mode 100644 index 560f6343b73..00000000000 --- a/packages/graphql/src/emitter.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { type EmitContext, type Namespace } from "@typespec/compiler"; -import { type GraphQLEmitterOptions, reportDiagnostic } from "./lib.js"; -import { listSchemas } from "./lib/schema.js"; -import { - createGraphQLMutationEngine, - type MutatedSchema, -} from "./mutation-engine/index.js"; -import { resolveTypeUsage } from "./type-usage.js"; - -/** - * Main emitter entry point for GraphQL SDL generation. - * - * Runs the data pipeline (type usage → mutation → variant lookups) and - * passes the result to `renderSchema`. Component-based rendering is a stub - * in this PR and will be implemented in a follow-up. - */ -export async function $onEmit(context: EmitContext) { - const schemas = listSchemas(context.program); - if (schemas.length === 0) { - schemas.push({ type: context.program.getGlobalNamespaceType() }); - } - - for (const schema of schemas) { - const mutated = await emitSchema(context, schema); - if (mutated) { - renderSchema(mutated); - } - } -} - -/** - * Run the data pipeline for a single GraphQL schema. - * - * Returns the `MutatedSchema` on success, or `undefined` if the schema - * cannot be built (e.g., no query root) — in which case a diagnostic has - * already been emitted. - */ -async function emitSchema( - context: EmitContext, - schema: { type: Namespace; name?: string }, -): Promise { - // Phase 1: Type usage tracking — determine which types are reachable from operations. - // Must run before mutation so the engine can filter on original (pre-clone) type objects. - const omitUnreachable = context.options["omit-unreachable-types"] ?? false; - const typeUsage = resolveTypeUsage(schema.type, omitUnreachable); - - // Phase 2: Mutation — transform TypeSpec types with GraphQL naming conventions - // and classify the results. The engine consumes `typeUsage` internally to - // filter unreachable types and route models into input/output/interface buckets. - const engine = createGraphQLMutationEngine(context.program); - const mutated = engine.mutateSchema(schema.type, typeUsage); - - // Report void-returning operations — GraphQL fields must return a type, - // so these are excluded from the schema. Mutation collected them; the - // emitter decides to warn. - for (const op of mutated.voidOperations) { - reportDiagnostic(context.program, { - code: "void-operation-return", - format: { name: op.name }, - target: op, - }); - } - - // GraphQL requires at least a Query root type. If there are no query operations, - // the schema cannot be built. Emit a diagnostic and skip rendering. - if (mutated.queries.length === 0) { - reportDiagnostic(context.program, { - code: "empty-schema", - target: schema.type, - }); - return undefined; - } - - return mutated; -} - -/** - * Phase 3: Render the mutated schema to GraphQL SDL. - * - * Stub in this PR — does nothing. The component-based Alloy renderer that - * consumes `mutated` is implemented in a follow-up PR. - */ -function renderSchema(_mutated: MutatedSchema): void {} diff --git a/packages/graphql/src/emitter.tsx b/packages/graphql/src/emitter.tsx new file mode 100644 index 00000000000..317cc3a77cd --- /dev/null +++ b/packages/graphql/src/emitter.tsx @@ -0,0 +1,161 @@ +import { + emitFile, + interpolatePath, + resolvePath, + type EmitContext, + type Namespace, +} from "@typespec/compiler"; +import { renderSchema as renderAlloySchema, printSchema } from "@alloy-js/graphql"; +import { type GraphQLEmitterOptions, reportDiagnostic } from "./lib.js"; +import { listSchemas } from "./lib/schema.js"; +import { + createGraphQLMutationEngine, + type MutatedSchema, +} from "./mutation-engine/index.js"; +import { resolveTypeUsage } from "./type-usage.js"; +import { GraphQLSchema } from "./components/graphql-schema.js"; +import { + ScalarVariantTypes, + EnumTypes, + UnionTypes, + InterfaceTypes, + ObjectTypes, + InputTypes, +} from "./components/type-collections.js"; +import { QueryType, MutationType, SubscriptionType } from "./components/operations/index.js"; +import type { ClassifiedTypes } from "./context/index.js"; + +/** + * Main emitter entry point for GraphQL SDL generation. + * + * Runs the data pipeline (type usage → mutation) and passes the result to + * `renderSchema`, which renders via Alloy components and writes the SDL file. + */ +export async function $onEmit(context: EmitContext) { + const schemas = listSchemas(context.program); + if (schemas.length === 0) { + schemas.push({ type: context.program.getGlobalNamespaceType() }); + } + + for (const schema of schemas) { + const mutated = await emitSchema(context, schema); + if (mutated) { + await renderSchema(context, schema, mutated); + } + } +} + +/** + * Run the data pipeline for a single GraphQL schema. + * + * Returns the `MutatedSchema` on success, or `undefined` if the schema + * cannot be built (e.g., no query root) — in which case a diagnostic has + * already been emitted. + */ +async function emitSchema( + context: EmitContext, + schema: { type: Namespace; name?: string }, +): Promise { + // Phase 1: Type usage tracking — determine which types are reachable from operations. + // Must run before mutation so the engine can filter on original (pre-clone) type objects. + const omitUnreachable = context.options["omit-unreachable-types"] ?? false; + const typeUsage = resolveTypeUsage(schema.type, omitUnreachable); + + // Phase 2: Mutation — transform TypeSpec types with GraphQL naming conventions + // and classify the results. The engine consumes `typeUsage` internally to + // filter unreachable types and route models into input/output/interface buckets. + const engine = createGraphQLMutationEngine(context.program); + const mutated = engine.mutateSchema(schema.type, typeUsage); + + // Report void-returning operations — GraphQL fields must return a type, + // so these are excluded from the schema. + for (const op of mutated.voidOperations) { + reportDiagnostic(context.program, { + code: "void-operation-return", + format: { name: op.name }, + target: op, + }); + } + + // GraphQL requires at least a Query root type. If there are no query operations, + // the schema cannot be built. Emit a diagnostic and skip rendering. + if (mutated.queries.length === 0) { + reportDiagnostic(context.program, { + code: "empty-schema", + target: schema.type, + }); + return undefined; + } + + return mutated; +} + +/** + * Phase 3: Render the mutated schema to GraphQL SDL via Alloy components, + * then write the SDL to the emitter output directory. + */ +async function renderSchema( + context: EmitContext, + schema: { type: Namespace; name?: string }, + mutated: MutatedSchema, +): Promise { + // Wrapper models from union mutations are always output — fold them into + // the output bucket for the renderer. + const classifiedTypes: ClassifiedTypes = { + interfaces: mutated.interfaces, + outputModels: [...mutated.outputModels, ...mutated.wrapperModels], + inputModels: mutated.inputModels, + enums: mutated.enums, + scalars: mutated.scalars, + scalarVariants: mutated.scalarVariants, + unions: mutated.unions, + queries: mutated.queries, + mutations: mutated.mutations, + subscriptions: mutated.subscriptions, + }; + + const contextValue = { + classifiedTypes, + unionMembers: mutated.unionMembers, + scalarSpecifications: mutated.scalarSpecifications, + }; + + // Determine output file name + const outputFilePattern = context.options["output-file"] ?? "{schema-name}.graphql"; + const schemaName = schema.name || "schema"; + const outputFileName = interpolatePath(outputFilePattern, { + "schema-name": schemaName, + }); + + // Render the schema using Alloy's renderSchema to get a GraphQLSchema object. + // We disable name policy validation because TypeSpec has already validated names and applied mutations. + const graphqlSchema = renderAlloySchema( + + + + + + + + + + + , + { namePolicy: null }, + ); + + // Convert the GraphQLSchema to SDL string using graphql-js printSchema + const rawSdl = printSchema(graphqlSchema); + + // Ensure file ends with blank line (two newlines) + const sdl = rawSdl.trimEnd() + "\n\n"; + + // Write to file + const outputPath = resolvePath(context.emitterOutputDir, outputFileName); + + await emitFile(context.program, { + path: outputPath, + content: sdl, + newLine: context.options["new-line"] ?? "lf", + }); +} diff --git a/packages/graphql/test/e2e.test.ts b/packages/graphql/test/e2e.test.ts new file mode 100644 index 00000000000..8ee31ca2a13 --- /dev/null +++ b/packages/graphql/test/e2e.test.ts @@ -0,0 +1,31 @@ +import { strictEqual } from "node:assert"; +import { describe, it } from "vitest"; +import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; + +/** + * End-to-end smoke test: compile a minimal TypeSpec schema and verify that + * the full pipeline produces GraphQL SDL output. Broader coverage (nullability, + * input/output splitting, unions, etc.) lives in dedicated test files. + */ +describe("End-to-end", () => { + it("generates valid schema for basic types", async () => { + const code = ` + @schema + namespace TestNamespace { + model Book { + id: string; + title: string; + } + + @query + op getBook(id: string): Book; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const errors = result.diagnostics.filter((d) => d.severity === "error"); + strictEqual(errors.length, 0, "Should have no errors for valid schema"); + strictEqual(result.graphQLOutput?.includes("type Book {"), true, "Should contain Book type"); + strictEqual(result.graphQLOutput?.includes("type Query {"), true, "Should contain Query type"); + }); +}); diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts index 578e11a0079..a1e1a2037b5 100644 --- a/packages/graphql/test/emitter.test.ts +++ b/packages/graphql/test/emitter.test.ts @@ -1,9 +1,9 @@ import { strictEqual } from "node:assert"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; describe("emitter", () => { - it("runs the data pipeline without errors", async () => { + it("runs the full pipeline and produces SDL output", async () => { const code = ` @schema namespace TestNamespace { @@ -25,8 +25,24 @@ describe("emitter", () => { const errors = result.diagnostics.filter((d) => d.severity === "error"); strictEqual(errors.length, 0, "Should have no errors"); - // No SDL output yet — component-based rendering is added in follow-up PRs. - strictEqual(result.graphQLOutput, undefined, "Should not produce output yet"); + expect(result.graphQLOutput).toBe(`type Book { + name: String! + page_count: Int! + published: Boolean! + price: Float! +} + +type Author { + name: String! + books: [Book!]! +} + +type Query { + getBooks: [Book!]! + getAuthors: [Author!]! +} + +`); }); it("warns when a schema has no query operations", async () => { From 08191ce3d4f1a06434cbe0647daa9683bb8c38eb Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 1 May 2026 15:39:34 -0400 Subject: [PATCH 2/5] Add unit tests for operation type components Adds component tests for QueryType, MutationType, and SubscriptionType using inline snapshots. Tests cover rendering with scalar return types, model return types (with stub type registration), parameters, and empty operation lists. Updates renderComponentToSDL utility to support skipPlaceholderQuery option for testing QueryType, while maintaining backwards compatibility with existing tests that pass context overrides directly. Also cleans up e2e and emitter tests to use consistent vitest expect() assertions with toMatchInlineSnapshot() instead of mixed strictEqual and .includes() checks. --- .../test/components/component-test-utils.tsx | 26 +- .../test/components/operation-types.test.tsx | 241 ++++++++++++++++++ packages/graphql/test/e2e.test.ts | 20 +- packages/graphql/test/emitter.test.ts | 50 ++-- 4 files changed, 305 insertions(+), 32 deletions(-) create mode 100644 packages/graphql/test/components/operation-types.test.tsx diff --git a/packages/graphql/test/components/component-test-utils.tsx b/packages/graphql/test/components/component-test-utils.tsx index 3e62b419ee1..33072977c1e 100644 --- a/packages/graphql/test/components/component-test-utils.tsx +++ b/packages/graphql/test/components/component-test-utils.tsx @@ -5,19 +5,33 @@ import type { Program } from "@typespec/compiler"; import { GraphQLSchema } from "../../src/components/graphql-schema.js"; import type { GraphQLSchemaContextValue } from "../../src/context/index.js"; +export interface RenderOptions { + /** Context overrides for the GraphQL schema context */ + contextOverrides?: Partial; + /** Skip the placeholder Query type (use when testing QueryType component) */ + skipPlaceholderQuery?: boolean; +} + /** * Renders GraphQL components in isolation and returns the printed SDL. * * Wraps children in the required context providers (TspContext + GraphQLSchemaContext) - * and always includes a placeholder Query type (required by graphql-js). + * and includes a placeholder Query type by default (required by graphql-js). * * Tests should assert on fragments of the returned SDL, ignoring the placeholder Query. + * + * @param options - Either RenderOptions object or context overrides directly (for backwards compatibility) */ export function renderComponentToSDL( program: Program, children: Children, - contextOverrides?: Partial, + options?: RenderOptions | Partial, ): string { + // Backwards compatibility: if options doesn't have RenderOptions keys, treat it as contextOverrides + const isRenderOptions = options && ("contextOverrides" in options || "skipPlaceholderQuery" in options); + const { contextOverrides, skipPlaceholderQuery } = isRenderOptions + ? (options as RenderOptions) + : { contextOverrides: options as Partial | undefined, skipPlaceholderQuery: false }; const contextValue: GraphQLSchemaContextValue = { typeGraph: { globalNamespace: program.getGlobalNamespaceType(), @@ -28,9 +42,11 @@ export function renderComponentToSDL( const schema = renderSchema( {children} - - - + {!skipPlaceholderQuery && ( + + + + )} , { namePolicy: null }, ); diff --git a/packages/graphql/test/components/operation-types.test.tsx b/packages/graphql/test/components/operation-types.test.tsx new file mode 100644 index 00000000000..31b907c34be --- /dev/null +++ b/packages/graphql/test/components/operation-types.test.tsx @@ -0,0 +1,241 @@ +import { t } from "@typespec/compiler/testing"; +import * as gql from "@alloy-js/graphql"; +import { describe, expect, it, beforeEach } from "vitest"; +import { + QueryType, + MutationType, + SubscriptionType, +} from "../../src/components/operations/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("QueryType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders nothing when no operations", async () => { + await tester.compile(t.code`model Placeholder { id: string; }`); + + const sdl = renderComponentToSDL(tester.program, ); + + // Should only contain the placeholder Query from test utils + expect(sdl).toMatchInlineSnapshot(` + "type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders single query operation with scalar return type", async () => { + const { getVersion } = await tester.compile( + t.code`op ${t.op("getVersion")}(): string;`, + ); + + const sdl = renderComponentToSDL( + tester.program, + , + { skipPlaceholderQuery: true }, + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Query { + getVersion: String! + }" + `); + }); + + it("renders query operation with model return type", async () => { + const { getBook } = await tester.compile( + t.code` + model ${t.model("Book")} { id: string; title: string; } + op ${t.op("getBook")}(id: string): Book; + `, + ); + + // Stub the Book type so buildSchema can resolve the reference + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + , + { skipPlaceholderQuery: true }, + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Book { + id: String! + title: String! + } + + type Query { + getBook(id: String!): Book! + }" + `); + }); + + it("renders multiple query operations", async () => { + const { getCount, getName } = await tester.compile( + t.code` + op ${t.op("getCount")}(): int32; + op ${t.op("getName")}(): string; + `, + ); + + const sdl = renderComponentToSDL( + tester.program, + , + { skipPlaceholderQuery: true }, + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Query { + getCount: Int! + getName: String! + }" + `); + }); + + it("renders query with parameters", async () => { + const { search } = await tester.compile( + t.code`op ${t.op("search")}(query: string, limit?: int32): string[];`, + ); + + const sdl = renderComponentToSDL( + tester.program, + , + { skipPlaceholderQuery: true }, + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Query { + search(query: String!, limit: Int!): [String!]! + }" + `); + }); +}); + +describe("MutationType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders nothing when no operations", async () => { + await tester.compile(t.code`model Placeholder { id: string; }`); + + const sdl = renderComponentToSDL(tester.program, ); + + // Should only contain the placeholder Query from test utils + expect(sdl).toMatchInlineSnapshot(` + "type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders single mutation operation", async () => { + const { deleteItem } = await tester.compile( + t.code`op ${t.op("deleteItem")}(id: string): boolean;`, + ); + + const sdl = renderComponentToSDL( + tester.program, + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Mutation { + deleteItem(id: String!): Boolean! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders mutation with input parameters", async () => { + const { createUser } = await tester.compile( + t.code` + model ${t.model("User")} { id: string; name: string; } + op ${t.op("createUser")}(name: string, email: string): User; + `, + ); + + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + } + + type Mutation { + createUser(name: String!, email: String!): User! + } + + type Query { + _placeholder: Boolean + }" + `); + }); +}); + +describe("SubscriptionType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders nothing when no operations", async () => { + await tester.compile(t.code`model Placeholder { id: string; }`); + + const sdl = renderComponentToSDL( + tester.program, + , + ); + + // Should only contain the placeholder Query from test utils + expect(sdl).toMatchInlineSnapshot(` + "type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders single subscription operation", async () => { + const { onMessage } = await tester.compile( + t.code`op ${t.op("onMessage")}(): string;`, + ); + + const sdl = renderComponentToSDL( + tester.program, + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Subscription { + onMessage: String! + } + + type Query { + _placeholder: Boolean + }" + `); + }); +}); diff --git a/packages/graphql/test/e2e.test.ts b/packages/graphql/test/e2e.test.ts index 8ee31ca2a13..c73597c7127 100644 --- a/packages/graphql/test/e2e.test.ts +++ b/packages/graphql/test/e2e.test.ts @@ -1,5 +1,4 @@ -import { strictEqual } from "node:assert"; -import { describe, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; /** @@ -24,8 +23,19 @@ describe("End-to-end", () => { const result = await emitSingleSchemaWithDiagnostics(code, {}); const errors = result.diagnostics.filter((d) => d.severity === "error"); - strictEqual(errors.length, 0, "Should have no errors for valid schema"); - strictEqual(result.graphQLOutput?.includes("type Book {"), true, "Should contain Book type"); - strictEqual(result.graphQLOutput?.includes("type Query {"), true, "Should contain Query type"); + + expect(errors).toHaveLength(0); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Book { + id: String! + title: String! + } + + type Query { + getBook(id: String!): Book! + } + + " + `); }); }); diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts index a1e1a2037b5..6f53cc35959 100644 --- a/packages/graphql/test/emitter.test.ts +++ b/packages/graphql/test/emitter.test.ts @@ -1,9 +1,8 @@ -import { strictEqual } from "node:assert"; import { describe, expect, it } from "vitest"; import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; describe("emitter", () => { - it("runs the full pipeline and produces SDL output", async () => { + it("emits multiple types and operations", async () => { const code = ` @schema namespace TestNamespace { @@ -21,28 +20,31 @@ describe("emitter", () => { @query op getAuthors(): Author[]; } `; + const result = await emitSingleSchemaWithDiagnostics(code, {}); const errors = result.diagnostics.filter((d) => d.severity === "error"); - strictEqual(errors.length, 0, "Should have no errors"); - expect(result.graphQLOutput).toBe(`type Book { - name: String! - page_count: Int! - published: Boolean! - price: Float! -} + expect(errors).toHaveLength(0); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Book { + name: String! + page_count: Int! + published: Boolean! + price: Float! + } -type Author { - name: String! - books: [Book!]! -} + type Author { + name: String! + books: [Book!]! + } -type Query { - getBooks: [Book!]! - getAuthors: [Author!]! -} + type Query { + getBooks: [Book!]! + getAuthors: [Author!]! + } -`); + " + `); }); it("warns when a schema has no query operations", async () => { @@ -54,12 +56,14 @@ type Query { } } `; + const result = await emitSingleSchemaWithDiagnostics(code, {}); const emptySchemaDiagnostics = result.diagnostics.filter( (d) => d.code === "@typespec/graphql/empty-schema", ); - strictEqual(emptySchemaDiagnostics.length, 1, "Should emit empty-schema warning"); - strictEqual(emptySchemaDiagnostics[0].severity, "warning"); + + expect(emptySchemaDiagnostics).toHaveLength(1); + expect(emptySchemaDiagnostics[0].severity).toBe("warning"); }); it("warns when an operation returns void", async () => { @@ -73,11 +77,13 @@ type Query { @mutation op doNothing(): void; } `; + const result = await emitSingleSchemaWithDiagnostics(code, {}); const voidDiagnostics = result.diagnostics.filter( (d) => d.code === "@typespec/graphql/void-operation-return", ); - strictEqual(voidDiagnostics.length, 1, "Should emit void-operation-return warning"); - strictEqual(voidDiagnostics[0].severity, "warning"); + + expect(voidDiagnostics).toHaveLength(1); + expect(voidDiagnostics[0].severity).toBe("warning"); }); }); From d511036939d76adcc34358c33ed21014c3a5707b Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 3 Jun 2026 14:04:39 -0400 Subject: [PATCH 3/5] Adapt emitter and type-collections to props-based architecture - type-collections: components take data via props instead of context - emitter: pass MutatedSchema data directly to collection components - Use minimal TypeGraph context (just globalNamespace) This aligns with the TypeGraph design where context is minimal and components receive their data through props. --- .../src/components/type-collections.tsx | 83 +++++++++++-------- packages/graphql/src/emitter.tsx | 47 +++++------ 2 files changed, 69 insertions(+), 61 deletions(-) diff --git a/packages/graphql/src/components/type-collections.tsx b/packages/graphql/src/components/type-collections.tsx index e5e0519649e..c29e24bbb62 100644 --- a/packages/graphql/src/components/type-collections.tsx +++ b/packages/graphql/src/components/type-collections.tsx @@ -1,6 +1,7 @@ import { For } from "@alloy-js/core"; import * as gql from "@alloy-js/graphql"; -import { useGraphQLSchema } from "../context/index.js"; +import type { Enum, Model, Scalar, Union } from "@typespec/compiler"; +import type { ScalarVariant } from "../mutation-engine/index.js"; import { ScalarType, EnumType, @@ -10,26 +11,26 @@ import { InputType, } from "./types/index.js"; +export interface ScalarVariantTypesProps { + scalarVariants: ScalarVariant[]; + scalars: Scalar[]; + scalarSpecifications: Map; +} + /** * Renders scalar variant types for encoded scalars (e.g., bytes + base64 -> Bytes) * AND custom user-defined scalars */ -export function ScalarVariantTypes() { - const { classifiedTypes } = useGraphQLSchema(); - +export function ScalarVariantTypes(props: ScalarVariantTypesProps) { // Get set of variant names to avoid duplicates - const variantNames = new Set( - classifiedTypes.scalarVariants.map((v) => v.graphqlName), - ); + const variantNames = new Set(props.scalarVariants.map((v) => v.graphqlName)); // Filter custom scalars to only include ones not already in variants - const customScalars = classifiedTypes.scalars.filter( - (s) => !variantNames.has(s.name), - ); + const customScalars = props.scalars.filter((s) => !variantNames.has(s.name)); return ( <> - + {(variant) => ( - {(scalar) => } + {(scalar) => ( + + )} ); } +export interface EnumTypesProps { + enums: Enum[]; +} + /** * Renders all enum types in the schema */ -export function EnumTypes() { - const { classifiedTypes } = useGraphQLSchema(); +export function EnumTypes(props: EnumTypesProps) { return ( - - {(enumType) => } - + {(enumType) => } ); } +export interface UnionTypesProps { + unions: Union[]; +} + /** * Renders all union types in the schema */ -export function UnionTypes() { - const { classifiedTypes } = useGraphQLSchema(); +export function UnionTypes(props: UnionTypesProps) { return ( - - {(union) => } - + {(union) => } ); } +export interface InterfaceTypesProps { + interfaces: Model[]; +} + /** * Renders all interface types in the schema */ -export function InterfaceTypes() { - const { classifiedTypes } = useGraphQLSchema(); +export function InterfaceTypes(props: InterfaceTypesProps) { return ( - + {(iface) => } ); } +export interface ObjectTypesProps { + models: Model[]; +} + /** * Renders all object types in the schema */ -export function ObjectTypes() { - const { classifiedTypes } = useGraphQLSchema(); +export function ObjectTypes(props: ObjectTypesProps) { return ( - - {(model) => } - + {(model) => } ); } +export interface InputTypesProps { + models: Model[]; +} + /** * Renders all input types in the schema */ -export function InputTypes() { - const { classifiedTypes } = useGraphQLSchema(); +export function InputTypes(props: InputTypesProps) { return ( - - {(model) => } - + {(model) => } ); } diff --git a/packages/graphql/src/emitter.tsx b/packages/graphql/src/emitter.tsx index 317cc3a77cd..b21feb89030 100644 --- a/packages/graphql/src/emitter.tsx +++ b/packages/graphql/src/emitter.tsx @@ -23,7 +23,7 @@ import { InputTypes, } from "./components/type-collections.js"; import { QueryType, MutationType, SubscriptionType } from "./components/operations/index.js"; -import type { ClassifiedTypes } from "./context/index.js"; +import type { GraphQLSchemaContextValue } from "./context/index.js"; /** * Main emitter entry point for GraphQL SDL generation. @@ -101,23 +101,14 @@ async function renderSchema( ): Promise { // Wrapper models from union mutations are always output — fold them into // the output bucket for the renderer. - const classifiedTypes: ClassifiedTypes = { - interfaces: mutated.interfaces, - outputModels: [...mutated.outputModels, ...mutated.wrapperModels], - inputModels: mutated.inputModels, - enums: mutated.enums, - scalars: mutated.scalars, - scalarVariants: mutated.scalarVariants, - unions: mutated.unions, - queries: mutated.queries, - mutations: mutated.mutations, - subscriptions: mutated.subscriptions, - }; + const outputModels = [...mutated.outputModels, ...mutated.wrapperModels]; - const contextValue = { - classifiedTypes, - unionMembers: mutated.unionMembers, - scalarSpecifications: mutated.scalarSpecifications, + // Build the TypeGraph context - components access the type graph through context, + // while specific data is passed via props + const contextValue: GraphQLSchemaContextValue = { + typeGraph: { + globalNamespace: schema.type, + }, }; // Determine output file name @@ -131,15 +122,19 @@ async function renderSchema( // We disable name policy validation because TypeSpec has already validated names and applied mutations. const graphqlSchema = renderAlloySchema( - - - - - - - - - + + + + + + + + + , { namePolicy: null }, ); From a0d3d2fa6d779d1296e25ebad0cdb73ab4bc38c2 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 3 Jun 2026 15:43:01 -0400 Subject: [PATCH 4/5] Fix test: optional parameters should be nullable per GraphQL spec --- packages/graphql/test/components/operation-types.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/graphql/test/components/operation-types.test.tsx b/packages/graphql/test/components/operation-types.test.tsx index 31b907c34be..9f9d860500f 100644 --- a/packages/graphql/test/components/operation-types.test.tsx +++ b/packages/graphql/test/components/operation-types.test.tsx @@ -112,9 +112,10 @@ describe("QueryType component", () => { { skipPlaceholderQuery: true }, ); + // Optional parameters are nullable per GraphQL spec expect(sdl).toMatchInlineSnapshot(` "type Query { - search(query: String!, limit: Int!): [String!]! + search(query: String!, limit: Int): [String!]! }" `); }); From c42edc5f45c845f895e7b6d7a9adc85d56fbfd3f Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 3 Jun 2026 15:43:36 -0400 Subject: [PATCH 5/5] Update follow-up-items: mark nullability bug as resolved --- packages/graphql/.claude/follow-up-items.md | 72 +++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 packages/graphql/.claude/follow-up-items.md diff --git a/packages/graphql/.claude/follow-up-items.md b/packages/graphql/.claude/follow-up-items.md new file mode 100644 index 00000000000..11ed24a2208 --- /dev/null +++ b/packages/graphql/.claude/follow-up-items.md @@ -0,0 +1,72 @@ +# GraphQL Emitter Follow-up Items + +## ~~Input Type Nullability Bug~~ (RESOLVED) + +**Status**: Fixed in PR #77 (commit ea85714d2) + +**Original issue**: Optional fields in input types were being made non-null. + +**Resolution**: Per the GraphQL spec, "nullability directly determines whether a field is required." Optional fields (`?`) should be nullable in both input and output contexts. This also enables circular references in input types (e.g., `Author.friend?: Author` → `friend: AuthorInput` is valid because it's nullable). + +**Correct behavior** (now implemented): + +| TypeSpec | Output | Input | +|--------------------|----------|----------| +| a: string | String! | String! | +| b?: string | String | String | +| c: string \| null | String | String | + +--- + +## @oneOf Input Union Bug (Priority: Medium) + +**Issue**: Unions used in input context (e.g., mutation parameters) should be converted to `@oneOf` input objects per the GraphQL spec, but the emitter crashes with "Unknown GraphQL type" when attempting this. + +**Test case that exposes the bug**: +```typespec +@schema +namespace TestNamespace { + model TextContent { + text: string; + } + + model ImageContent { + url: string; + } + + union Content { + text: TextContent, + image: ImageContent, + } + + model Post { + id: string; + content: Content; + } + + @query + op getPost(id: string): Post; + + @mutation + op createPost(content: Content): Post; +} +``` + +**Error**: +``` +Error: Unknown GraphQL type "ContentInput". + at resolveNamedType (src/schema/build/types.ts:100:11) +``` + +**Root cause**: The `GraphQLUnionMutation.mutateAsOneOfInput()` correctly creates the `@oneOf` input model (e.g., `ContentInput`), but this synthetic model is not being registered in the type registry that `buildArgsMap` uses to resolve argument types. + +**Expected behavior**: +- In output context: `union Content = TextContent | ImageContent` +- In input context: `input ContentInput @oneOf { text: TextContentInput, image: ImageContentInput }` + +**Files to investigate**: +- `src/mutation-engine/mutations/union.ts` - `mutateAsOneOfInput()` creates the @oneOf model +- `src/schema/build/types.ts` - where the type registry is built and `resolveNamedType` fails +- `src/mutation-engine/schema-mutator.ts` - may need to register synthetic types + +**Related**: Similar bug exists for complex input types used in `@operationFields` arguments (the `FilterOptions` model isn't discovered when used only as an operation field argument type).