From e26c0b46c70f4543e37d75bccb8525e990deba8c Mon Sep 17 00:00:00 2001 From: Swati Kumar Date: Mon, 8 Jun 2026 20:46:07 +0000 Subject: [PATCH 1/4] Replace legacy emitter with mutation pipeline and TypeGraph output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete legacy emitter infrastructure (graphql-emitter.ts, registry.ts, schema-emitter.ts, type-maps.ts, types.d.ts) - New $onEmit: type-usage → mutation engine → buildTypeGraph → render (stub) - Add schema-mutator.ts: walks namespace, mutates all types via engine, produces TypeGraph. No pre-classified buckets — renderer classifies at render time using decorators and typeUsage. - Add empty-schema diagnostic when no operations exist - Emitter test updated to verify pipeline runs (rendering TBD) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/graphql/src/emitter.ts | 78 +++++++----- packages/graphql/src/graphql-emitter.ts | 63 ---------- packages/graphql/src/lib.ts | 7 ++ packages/graphql/src/lib/interface.ts | 4 +- packages/graphql/src/mutation-engine/index.ts | 2 + .../src/mutation-engine/schema-mutator.ts | 71 +++++++++++ packages/graphql/src/registry.ts | 115 ------------------ packages/graphql/src/schema-emitter.ts | 81 ------------ packages/graphql/src/type-maps.ts | 102 ---------------- packages/graphql/src/types.d.ts | 22 ---- packages/graphql/test/emitter.test.ts | 23 ++-- 11 files changed, 140 insertions(+), 428 deletions(-) delete mode 100644 packages/graphql/src/graphql-emitter.ts create mode 100644 packages/graphql/src/mutation-engine/schema-mutator.ts delete mode 100644 packages/graphql/src/registry.ts delete mode 100644 packages/graphql/src/schema-emitter.ts delete mode 100644 packages/graphql/src/type-maps.ts delete mode 100644 packages/graphql/src/types.d.ts diff --git a/packages/graphql/src/emitter.ts b/packages/graphql/src/emitter.ts index 6a34c0552df..03a3b77e3eb 100644 --- a/packages/graphql/src/emitter.ts +++ b/packages/graphql/src/emitter.ts @@ -1,37 +1,57 @@ -import type { EmitContext, NewLine } from "@typespec/compiler"; -import { resolvePath } from "@typespec/compiler"; -import { createGraphQLEmitter } from "./graphql-emitter.js"; -import type { GraphQLEmitterOptions } from "./lib.js"; - -const defaultOptions = { - "new-line": "lf", - "omit-unreachable-types": false, - strict: false, -} as const; +import { type EmitContext, type Namespace } from "@typespec/compiler"; +import { type GraphQLEmitterOptions, reportDiagnostic } from "./lib.js"; +import { listSchemas } from "./lib/schema.js"; +import { createGraphQLMutationEngine } from "./mutation-engine/index.js"; +import { mutateSchema } from "./mutation-engine/schema-mutator.js"; +import type { TypeGraph } from "./mutation-engine/type-graph.js"; +import { resolveTypeUsage } from "./type-usage.js"; +/** + * Main emitter entry point for GraphQL SDL generation. + * + * Pipeline: type-usage → mutation → buildTypeGraph → render. + * Rendering is a stub in this PR — will be implemented when the Schema + * orchestrator component is added. + */ export async function $onEmit(context: EmitContext) { - const options = resolveOptions(context); - const emitter = createGraphQLEmitter(context, options); - await emitter.emitGraphQL(); -} + const schemas = listSchemas(context.program); + if (schemas.length === 0) { + schemas.push({ type: context.program.getGlobalNamespaceType() }); + } -export interface ResolvedGraphQLEmitterOptions { - outputFile: string; - newLine: NewLine; - omitUnreachableTypes: boolean; - strict: boolean; + for (const schema of schemas) { + const typeGraph = emitSchema(context, schema.type); + if (typeGraph) { + renderSchema(typeGraph, schema.name); + } + } } -export function resolveOptions( +function emitSchema( context: EmitContext, -): ResolvedGraphQLEmitterOptions { - const resolvedOptions = { ...defaultOptions, ...context.options }; - const outputFile = resolvedOptions["output-file"] ?? "{schema-name}.graphql"; + schema: Namespace, +): TypeGraph | undefined { + const program = context.program; + const omitUnreachable = context.options["omit-unreachable-types"] ?? false; + + const typeUsage = resolveTypeUsage(schema, omitUnreachable); + const engine = createGraphQLMutationEngine(program); + const typeGraph = mutateSchema(program, engine, schema, typeUsage); + + if (typeGraph.globalNamespace.operations.size === 0) { + reportDiagnostic(program, { + code: "empty-schema", + target: schema, + }); + return undefined; + } + + return typeGraph; +} - return { - outputFile: resolvePath(context.emitterOutputDir, outputFile), - newLine: resolvedOptions["new-line"], - omitUnreachableTypes: resolvedOptions["omit-unreachable-types"], - strict: resolvedOptions["strict"], - }; +function renderSchema(_typeGraph: TypeGraph, _schemaName?: string): void { + // Stub — will be replaced with Alloy component rendering: + // + // + // } diff --git a/packages/graphql/src/graphql-emitter.ts b/packages/graphql/src/graphql-emitter.ts deleted file mode 100644 index fdb3ffca332..00000000000 --- a/packages/graphql/src/graphql-emitter.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { emitFile, interpolatePath, type EmitContext } from "@typespec/compiler"; -import { printSchema } from "graphql"; -import type { ResolvedGraphQLEmitterOptions } from "./emitter.js"; -import type { GraphQLEmitterOptions } from "./lib.js"; -import { listSchemas } from "./lib/schema.js"; -import { createSchemaEmitter } from "./schema-emitter.js"; -import type { GraphQLSchemaRecord } from "./types.js"; - -export function createGraphQLEmitter( - context: EmitContext, - options: ResolvedGraphQLEmitterOptions, -) { - const program = context.program; - - return { - emitGraphQL, - }; - - async function emitGraphQL() { - if (!program.compilerOptions.noEmit) { - const schemaRecords = await getGraphQL(); - // first, emit diagnostics - for (const schemaRecord of schemaRecords) { - program.reportDiagnostics(schemaRecord.diagnostics); - } - if (program.hasError()) { - return; - } - for (const schemaRecord of schemaRecords) { - const schemaName = schemaRecord.schema.name || "schema"; - const filePath = interpolatePath(options.outputFile, { - "schema-name": schemaName, - }); - await emitFile(program, { - path: filePath, - content: printSchema(schemaRecord.graphQLSchema), - newLine: options.newLine, - }); - } - } - } - - async function getGraphQL(): Promise { - const schemaRecords: GraphQLSchemaRecord[] = []; - const schemas = listSchemas(program); - if (schemas.length === 0) { - schemas.push({ type: program.getGlobalNamespaceType() }); - } - for (const schema of schemas) { - const schemaEmitter = createSchemaEmitter(schema, context, options); - const document = await schemaEmitter.emitSchema(); - if (document === undefined) { - continue; - } - schemaRecords.push({ - schema: schema, - graphQLSchema: document[0], - diagnostics: document[1], - }); - } - return schemaRecords; - } -} diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts index 156ddba9127..9b9946e4513 100644 --- a/packages/graphql/src/lib.ts +++ b/packages/graphql/src/lib.ts @@ -162,6 +162,13 @@ export const libDef = { default: paramMessage`Scalar "${"name"}" collides with GraphQL built-in type "${"builtinName"}". This may cause unexpected behavior. Consider renaming the scalar.`, }, }, + "empty-schema": { + severity: "warning", + messages: { + default: + "GraphQL schema has no operations. At minimum a Query root type is required.", + }, + }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, diff --git a/packages/graphql/src/lib/interface.ts b/packages/graphql/src/lib/interface.ts index cf76213f2a2..bb97cfcd93b 100644 --- a/packages/graphql/src/lib/interface.ts +++ b/packages/graphql/src/lib/interface.ts @@ -10,9 +10,11 @@ import { import { useStateMap, useStateSet } from "@typespec/compiler/utils"; import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js"; -import type { Tagged } from "../types.d.ts"; import { propertiesEqual } from "./utils.js"; +declare const tags: unique symbol; +type Tagged = BaseType & { [tags]: { [K in Tag]: void } }; + // This will set the namespace for decorators implemented in this file export const namespace = NAMESPACE; diff --git a/packages/graphql/src/mutation-engine/index.ts b/packages/graphql/src/mutation-engine/index.ts index 131420ec0ed..e66e7ad38c9 100644 --- a/packages/graphql/src/mutation-engine/index.ts +++ b/packages/graphql/src/mutation-engine/index.ts @@ -9,3 +9,5 @@ export { GraphQLUnionMutation, } from "./mutations/index.js"; export { GraphQLMutationOptions, GraphQLTypeContext } from "./options.js"; +export { mutateSchema } from "./schema-mutator.js"; +export { buildTypeGraph, type TypeGraph } from "./type-graph.js"; diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts new file mode 100644 index 00000000000..4ab4faf51d5 --- /dev/null +++ b/packages/graphql/src/mutation-engine/schema-mutator.ts @@ -0,0 +1,71 @@ +import { + isArrayModelType, + navigateTypesInNamespace, + type Enum, + type Model, + type Namespace, + type Operation, + type Program, + type Scalar, + type Type, + type Union, +} from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { unwrapNullableUnion } from "../lib/type-utils.js"; +import type { TypeUsageResolver } from "../type-usage.js"; +import type { GraphQLMutationEngine } from "./engine.js"; +import { GraphQLTypeContext } from "./options.js"; +import { buildTypeGraph, type TypeGraph } from "./type-graph.js"; + +/** + * Walk every type in the schema namespace, mutate it through the GraphQL + * mutation engine, and package the results into a TypeGraph. + * + * Filtering (unreachable types, array models, nullable unions) happens here + * so the engine only processes types that belong in the schema. + */ +export function mutateSchema( + program: Program, + engine: GraphQLMutationEngine, + schema: Namespace, + typeUsage: TypeUsageResolver, +): TypeGraph { + const tk = $(program); + const mutatedTypes: Type[] = []; + + navigateTypesInNamespace(schema, { + model: (node: Model) => { + if (isArrayModelType(node)) return; + if (typeUsage.isUnreachable(node)) return; + + const mutation = engine.mutateModel(node, GraphQLTypeContext.Output); + mutatedTypes.push(mutation.mutatedType); + }, + enum: (node: Enum) => { + if (typeUsage.isUnreachable(node)) return; + + const mutation = engine.mutateEnum(node); + mutatedTypes.push(mutation.mutatedType); + }, + scalar: (node: Scalar) => { + const mutation = engine.mutateScalar(node); + mutatedTypes.push(mutation.mutatedType); + }, + union: (node: Union) => { + if (unwrapNullableUnion(node) !== undefined) return; + if (typeUsage.isUnreachable(node)) return; + + const mutation = engine.mutateUnion(node, GraphQLTypeContext.Output); + mutatedTypes.push(mutation.mutatedType); + for (const wrapper of mutation.wrapperModels) { + mutatedTypes.push(wrapper); + } + }, + operation: (node: Operation) => { + const mutation = engine.mutateOperation(node); + mutatedTypes.push(mutation.mutatedType); + }, + }); + + return buildTypeGraph(program, tk, mutatedTypes); +} diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts deleted file mode 100644 index 12e7d59ddd3..00000000000 --- a/packages/graphql/src/registry.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { UsageFlags, type Enum, type Model } from "@typespec/compiler"; -import { - GraphQLBoolean, - GraphQLEnumType, - GraphQLObjectType, - type GraphQLNamedType, - type GraphQLSchemaConfig, -} from "graphql"; - -// The TSPTypeContext interface represents the intermediate TSP type information before materialization. -// It stores the raw TSP type and any extracted metadata relevant for GraphQL generation. -interface TSPTypeContext { - tspType: Enum | Model; // Extend with other TSP types like Operation, Interface, TSP Union, etc. - name: string; - usageFlags?: Set; - // TODO: Add any other TSP-specific metadata here. -} -/** - * GraphQLTypeRegistry manages the registration and materialization of TypeSpec (TSP) - * types into their corresponding GraphQL type definitions. - * - * The registry operates in a two-stage process: - * 1. Registration: TSP types (like Enums, Models, etc.) are first registered - * along with relevant metadata (e.g., name, usage flags). This stores an - * intermediate representation (`TSPTypeContext`) without immediately creating - * GraphQL types. This stage is typically performed while traversing the TSP AST. - * Register type by calling the appropriate method (e.g., `addEnum`). - * - * 2. Materialization: When a GraphQL type is needed (e.g., to build the final - * schema or resolve a field type), the registry can materialize the TSP type - * into its GraphQL counterpart (e.g., `GraphQLEnumType`, `GraphQLObjectType`). - * Materialize types by calling the appropriate method (e.g., `materializeEnum`). - * - * This approach helps in: - * - Decoupling TSP AST traversal from GraphQL object instantiation. - * - Caching materialized GraphQL types to avoid redundant work and ensure object identity. - * - Handling forward references and circular dependencies, as types can be - * registered first and materialized later when all dependencies are known or - * by using thunks for fields/arguments. - */ -export class GraphQLTypeRegistry { - // Stores intermediate TSP type information, keyed by TSP type name. - // TODO: make this more of a seen set - private TSPTypeContextRegistry: Map = new Map(); - - // Stores materialized GraphQL types, keyed by their GraphQL name. - private materializedGraphQLTypes: Map = new Map(); - - addEnum(tspEnum: Enum): void { - const enumName = tspEnum.name; - if (this.TSPTypeContextRegistry.has(enumName)) { - // Optionally, log a warning or update if new information is more complete. - return; - } - - this.TSPTypeContextRegistry.set(enumName, { - tspType: tspEnum, - name: enumName, - // TODO: Populate usageFlags based on TSP context and other decorator context. - }); - } - - // Materializes a TSP Enum into a GraphQLEnumType. - materializeEnum(enumName: string): GraphQLEnumType | undefined { - // Check if the GraphQL type is already materialized. - if (this.materializedGraphQLTypes.has(enumName)) { - return this.materializedGraphQLTypes.get(enumName) as GraphQLEnumType; - } - - const context = this.TSPTypeContextRegistry.get(enumName); - if (!context || context.tspType.kind !== "Enum") { - // TODO: Handle error or warning for missing context. - return undefined; - } - - const tspEnum = context.tspType as Enum; - - const gqlEnum = new GraphQLEnumType({ - name: context.name, - values: Object.fromEntries( - Array.from(tspEnum.members.values()).map((member) => [ - member.name, - { - value: member.value ?? member.name, - }, - ]), - ), - }); - - this.materializedGraphQLTypes.set(enumName, gqlEnum); - return gqlEnum; - } - - materializeSchemaConfig(): GraphQLSchemaConfig { - const allMaterializedGqlTypes = Array.from(this.materializedGraphQLTypes.values()); - let queryType = this.materializedGraphQLTypes.get("Query") as GraphQLObjectType | undefined; - if (!queryType) { - queryType = new GraphQLObjectType({ - name: "Query", - fields: { - _: { - type: GraphQLBoolean, - description: - "A placeholder field. If you are seeing this, it means no operations were defined that could be emitted.", - }, - }, - }); - } - - return { - query: queryType, - types: allMaterializedGqlTypes.length > 0 ? allMaterializedGqlTypes : null, - }; - } -} diff --git a/packages/graphql/src/schema-emitter.ts b/packages/graphql/src/schema-emitter.ts deleted file mode 100644 index b922fe49b0a..00000000000 --- a/packages/graphql/src/schema-emitter.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - createDiagnosticCollector, - navigateTypesInNamespace, - type Diagnostic, - type DiagnosticCollector, - type EmitContext, - type Enum, - type Model, -} from "@typespec/compiler"; -import { GraphQLSchema, validateSchema } from "graphql"; -import { type GraphQLEmitterOptions } from "./lib.js"; -import type { Schema } from "./lib/schema.js"; -import { GraphQLTypeRegistry } from "./registry.js"; - -class GraphQLSchemaEmitter { - private tspSchema: Schema; - private context: EmitContext; - private options: GraphQLEmitterOptions; - private diagnostics: DiagnosticCollector; - private registry: GraphQLTypeRegistry; - constructor( - tspSchema: Schema, - context: EmitContext, - options: GraphQLEmitterOptions, - ) { - // Initialize any properties if needed, including the registry - this.tspSchema = tspSchema; - this.context = context; - this.options = options; - this.diagnostics = createDiagnosticCollector(); - this.registry = new GraphQLTypeRegistry(); - } - - async emitSchema(): Promise<[GraphQLSchema, Readonly] | undefined> { - const schemaNamespace = this.tspSchema.type; - // Logic to emit the GraphQL schema - navigateTypesInNamespace(schemaNamespace, this.semanticNodeListener()); - const schemaConfig = this.registry.materializeSchemaConfig(); - const schema = new GraphQLSchema(schemaConfig); - // validate the schema - const validationErrors = validateSchema(schema); - validationErrors.forEach((error) => { - this.diagnostics.add({ - message: error.message, - code: "GraphQLSchemaValidationError", - target: this.tspSchema.type, - severity: "error", - }); - }); - return [schema, this.diagnostics.diagnostics]; - } - - semanticNodeListener() { - // TODO: Add GraphQL types to registry as the TSP nodes are visited - return { - enum: (node: Enum) => { - this.registry.addEnum(node); - }, - model: (node: Model) => { - // Add logic to handle the model node - }, - exitEnum: (node: Enum) => { - this.registry.materializeEnum(node.name); - }, - exitModel: (node: Model) => { - // Add logic to handle the exit of the model node - }, - }; - } -} - -export function createSchemaEmitter( - schema: Schema, - context: EmitContext, - options: GraphQLEmitterOptions, -): GraphQLSchemaEmitter { - // Placeholder for creating a GraphQL schema emitter - return new GraphQLSchemaEmitter(schema, context, options); -} - -export type { GraphQLSchemaEmitter }; diff --git a/packages/graphql/src/type-maps.ts b/packages/graphql/src/type-maps.ts deleted file mode 100644 index eefb2b8aa33..00000000000 --- a/packages/graphql/src/type-maps.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { UsageFlags, type Type } from "@typespec/compiler"; -import type { GraphQLType } from "graphql"; - -/** - * TypeSpec context for type mapping - * @template T - The TypeSpec type - */ -export interface TSPContext { - type: T; // The TypeSpec type - usageFlag: UsageFlags; // How the type is being used (input, output, etc.) - graphqlName?: string; // Optional GraphQL type name override (e.g., "ModelInput" for input types) - metadata: Record; // Additional metadata -} - -/** - * Nominal type for keys in the TypeMap - */ -type TypeKey = string & { __typeKey: any }; - -/** - * Base TypeMap for all GraphQL type mappings - * @template T - The TypeSpec type constrained to TSP's Type - * @template G - The GraphQL type constrained to GraphQL's GraphQLType - */ -export abstract class TypeMap { - // Map of materialized GraphQL types - protected materializedMap = new Map(); - - // Map of registration contexts - protected registrationMap = new Map>(); - - /** - * Register a TypeSpec type with context for later materialization - * @param context - The TypeSpec context - * @returns The name used for registration as a TypeKey - */ - register(context: TSPContext): TypeKey { - // Check if the type is already registered - const name = this.getNameFromContext(context); - if (this.isRegistered(name)) { - throw new Error(`Type ${name} is already registered`); - } - - // Register the type - this.registrationMap.set(name, context); - return name; - } - - /** - * Get the materialized GraphQL type - * @param name - The type name as a TypeKey - * @returns The materialized GraphQL type or undefined - */ - get(name: TypeKey): G | undefined { - // Return already materialized type if available - if (this.materializedMap.has(name)) { - return this.materializedMap.get(name); - } - - // Attempt to materialize if registered - const context = this.registrationMap.get(name); - if (context) { - const materializedType = this.materialize(context); - this.materializedMap.set(name, materializedType); - return materializedType; - } - - return undefined; - } - - /** - * Check if a type is registered - */ - isRegistered(name: string): boolean { - return this.registrationMap.has(name as TypeKey); - } - - /** - * Get all materialized types - */ - getAllMaterialized(): MapIterator { - return this.materializedMap.values(); - } - - /** - * Reset the type map - */ - reset(): void { - this.materializedMap.clear(); - this.registrationMap.clear(); - } - - /** - * Get a name from a context - */ - protected abstract getNameFromContext(context: TSPContext): TypeKey; - - /** - * Materialize a type from a context - */ - protected abstract materialize(context: TSPContext): G; -} diff --git a/packages/graphql/src/types.d.ts b/packages/graphql/src/types.d.ts deleted file mode 100644 index 651cc1bb81f..00000000000 --- a/packages/graphql/src/types.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Diagnostic } from "@typespec/compiler"; -import type { GraphQLSchema } from "graphql"; -import type { Schema } from "./lib/schema.ts"; - -/** - * A record containing the GraphQL schema corresponding to - * a particular schema definition. - */ -export interface GraphQLSchemaRecord { - /** The declared schema that generated this GraphQL schema */ - readonly schema: Schema; - - /** The GraphQLSchema */ - readonly graphQLSchema: GraphQLSchema; - - /** The diagnostics created for this schema */ - readonly diagnostics: readonly Diagnostic[]; -} - -declare const tags: unique symbol; - -type Tagged = BaseType & { [tags]: { [K in Tag]: void } }; diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts index bd0b1a74566..b43c7817f88 100644 --- a/packages/graphql/test/emitter.test.ts +++ b/packages/graphql/test/emitter.test.ts @@ -1,18 +1,8 @@ -import { strictEqual } from "node:assert"; import { describe, it } from "vitest"; -import { emitSingleSchema } from "./test-host.js"; +import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; -// For now, the expected output is a placeholder string. -// In the future, this should be replaced with the actual GraphQL schema output. -const expectedGraphQLSchema = `type Query { - """ - A placeholder field. If you are seeing this, it means no operations were defined that could be emitted. - """ - _: Boolean -}`; - -describe("name", () => { - it("Emits a schema.graphql file with placeholder text", async () => { +describe("emitter", () => { + it("runs the mutation pipeline without errors", async () => { const code = ` @schema namespace TestNamespace { @@ -30,7 +20,10 @@ describe("name", () => { op getAuthors(): Author[]; } `; - const results = await emitSingleSchema(code, {}); - strictEqual(results, expectedGraphQLSchema); + const result = await emitSingleSchemaWithDiagnostics(code, {}); + // Rendering is a stub — no output expected yet. + // This test verifies the pipeline (type-usage → mutation → buildTypeGraph) completes. + // Output assertions will be added when the Schema orchestrator component lands. + result; // no-op, just ensure no throw }); }); From 2ab84e376c566e11538f3ed058565072aca74d58 Mon Sep 17 00:00:00 2001 From: Swati Kumar Date: Mon, 8 Jun 2026 23:03:47 +0000 Subject: [PATCH 2/4] Add schema-mutator tests covering filtering and TypeGraph output Tests verify: - Mutated models/enums/scalars/unions/operations appear in TypeGraph - Unreachable types are excluded when omitUnreachableTypes is true - All declared types included when omitUnreachableTypes is false - T | null unions are not registered in the TypeGraph - Union wrapper models (scalar variants) are included - Array models are skipped (they're list types, not object types) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mutation-engine/schema-mutator.test.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 packages/graphql/test/mutation-engine/schema-mutator.test.ts diff --git a/packages/graphql/test/mutation-engine/schema-mutator.test.ts b/packages/graphql/test/mutation-engine/schema-mutator.test.ts new file mode 100644 index 00000000000..e0265e6e755 --- /dev/null +++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts @@ -0,0 +1,171 @@ +import { t } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { mutateSchema } from "../../src/mutation-engine/schema-mutator.js"; +import { resolveTypeUsage } from "../../src/type-usage.js"; +import { Tester } from "../test-host.js"; + +describe("mutateSchema", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("produces a TypeGraph with mutated models", async () => { + await tester.compile( + t.code` + model ${t.model("ad_account")} { id: int32; } + op ${t.op("getAccount")}(): ad_account; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.models.has("AdAccount")).toBe(true); + }); + + it("produces a TypeGraph with mutated operations", async () => { + await tester.compile( + t.code` + op ${t.op("get_items")}(): string; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.operations.has("getItems")).toBe(true); + }); + + it("produces a TypeGraph with mutated enums", async () => { + await tester.compile( + t.code` + enum ${t.enum("status")} { active, inactive } + model ${t.model("Foo")} { s: status; } + op ${t.op("getFoo")}(): Foo; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.enums.has("Status")).toBe(true); + }); + + it("produces a TypeGraph with mutated unions", async () => { + await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + union ${t.union("Pet")} { cat: Cat; dog: Dog; } + op ${t.op("getPet")}(): Pet; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.unions.has("Pet")).toBe(true); + }); + + it("skips unreachable types when omitUnreachableTypes is true", async () => { + await tester.compile( + t.code` + model ${t.model("Reachable")} { x: int32; } + model ${t.model("Unreachable")} { y: string; } + op ${t.op("getReachable")}(): Reachable; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, true); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.models.has("Reachable")).toBe(true); + expect(typeGraph.globalNamespace.models.has("Unreachable")).toBe(false); + }); + + it("includes all declared types when omitUnreachableTypes is false", async () => { + await tester.compile( + t.code` + model ${t.model("Reachable")} { x: int32; } + model ${t.model("Unreachable")} { y: string; } + op ${t.op("getReachable")}(): Reachable; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.models.has("Reachable")).toBe(true); + expect(typeGraph.globalNamespace.models.has("Unreachable")).toBe(true); + }); + + it("skips T | null unions (nullable wrappers are not real unions)", async () => { + await tester.compile( + t.code` + union ${t.union("MaybeString")} { string, null } + model ${t.model("Foo")} { x: int32; } + op ${t.op("getFoo")}(): Foo; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // MaybeString is T | null — should NOT appear as a union in the TypeGraph + expect(typeGraph.globalNamespace.unions.has("MaybeString")).toBe(false); + }); + + it("includes wrapper models from union scalar variants", async () => { + await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; num: int32; } + op ${t.op("getMixed")}(): Mixed; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Scalar variants get wrapper models registered in the TypeGraph + expect(typeGraph.globalNamespace.models.has("MixedTextUnionVariant")).toBe(true); + expect(typeGraph.globalNamespace.models.has("MixedNumUnionVariant")).toBe(true); + }); + + it("skips array models (they are list types, not object types)", async () => { + await tester.compile( + t.code` + model ${t.model("Item")} { name: string; } + op ${t.op("getItems")}(): Item[]; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Item should be in the graph, but the Array model should NOT + expect(typeGraph.globalNamespace.models.has("Item")).toBe(true); + const modelNames = [...typeGraph.globalNamespace.models.keys()]; + expect(modelNames.every((n) => !n.includes("Array"))).toBe(true); + }); +}); From 607a90e7c73424f2953b95116dc9edd7609afcc2 Mon Sep 17 00:00:00 2001 From: Swati Kumar Date: Mon, 8 Jun 2026 23:05:40 +0000 Subject: [PATCH 3/4] Remove unnecessary unwrapNullableUnion check from schema-mutator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mutation engine already handles T | null unions correctly (replaces with inner type). The pre-check was a premature optimization that added code without benefit — engine.mutate() is cached. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/graphql/src/mutation-engine/schema-mutator.ts | 2 -- packages/graphql/test/mutation-engine/schema-mutator.test.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts index 4ab4faf51d5..d91ed8188cc 100644 --- a/packages/graphql/src/mutation-engine/schema-mutator.ts +++ b/packages/graphql/src/mutation-engine/schema-mutator.ts @@ -11,7 +11,6 @@ import { type Union, } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; -import { unwrapNullableUnion } from "../lib/type-utils.js"; import type { TypeUsageResolver } from "../type-usage.js"; import type { GraphQLMutationEngine } from "./engine.js"; import { GraphQLTypeContext } from "./options.js"; @@ -52,7 +51,6 @@ export function mutateSchema( mutatedTypes.push(mutation.mutatedType); }, union: (node: Union) => { - if (unwrapNullableUnion(node) !== undefined) return; if (typeUsage.isUnreachable(node)) return; const mutation = engine.mutateUnion(node, GraphQLTypeContext.Output); diff --git a/packages/graphql/test/mutation-engine/schema-mutator.test.ts b/packages/graphql/test/mutation-engine/schema-mutator.test.ts index e0265e6e755..c69327594a2 100644 --- a/packages/graphql/test/mutation-engine/schema-mutator.test.ts +++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts @@ -113,7 +113,7 @@ describe("mutateSchema", () => { expect(typeGraph.globalNamespace.models.has("Unreachable")).toBe(true); }); - it("skips T | null unions (nullable wrappers are not real unions)", async () => { + it("T | null unions do not appear in the TypeGraph (engine replaces with inner type)", async () => { await tester.compile( t.code` union ${t.union("MaybeString")} { string, null } From 4ddffc1570d03b5a1807c1be943f61f5a835ce67 Mon Sep 17 00:00:00 2001 From: Swati Kumar Date: Mon, 8 Jun 2026 23:12:56 +0000 Subject: [PATCH 4/4] Add unreachable check for scalars in schema-mutator Without this, navigateTypesInNamespace on the global namespace visits built-in TypeSpec scalars (string, int32, etc.) from the TypeSpec sub-namespace, adding them to the TypeGraph unnecessarily. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/graphql/src/mutation-engine/schema-mutator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts index d91ed8188cc..ee6559028cb 100644 --- a/packages/graphql/src/mutation-engine/schema-mutator.ts +++ b/packages/graphql/src/mutation-engine/schema-mutator.ts @@ -47,6 +47,7 @@ export function mutateSchema( mutatedTypes.push(mutation.mutatedType); }, scalar: (node: Scalar) => { + if (typeUsage.isUnreachable(node)) return; const mutation = engine.mutateScalar(node); mutatedTypes.push(mutation.mutatedType); },