From 0079c566b497a7a5c11dc6040e4d5514599174f0 Mon Sep 17 00:00:00 2001 From: Swati Kumar Date: Tue, 9 Jun 2026 16:09:21 +0000 Subject: [PATCH 1/7] Substitute wrapper models into union variant types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After creating synthetic wrapper models for scalar union variants, replace the variant's type reference with the wrapper model. This makes the union self-contained — all variant types are now Models, so the renderer can use variant.type.name uniformly without reconstructing wrapper names. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mutation-engine/mutations/union.ts | 50 ++++++++++--------- .../test/mutation-engine/unions.test.ts | 33 ++++++++++++ 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/packages/graphql/src/mutation-engine/mutations/union.ts b/packages/graphql/src/mutation-engine/mutations/union.ts index e8ee273d9c2..0791f77235d 100644 --- a/packages/graphql/src/mutation-engine/mutations/union.ts +++ b/packages/graphql/src/mutation-engine/mutations/union.ts @@ -159,31 +159,8 @@ export class GraphQLUnionMutation extends UnionMutation { - return tk.unionVariant.create({ - name: variantNameToString(variant.name), - type: variant.type, - }); - }); - - const flattenedUnion = tk.union.create({ - name: unionName, - variants: variantArray, - }); - - this.#flattenedUnion = flattenedUnion; - } else { - this.#mutationNode.mutate((union) => { - union.name = unionName; - }); - } - - if (hasNull) { - setNullable(this.mutatedType); - } - // GraphQL unions can only contain object types — wrap scalars in synthetic models + // and substitute the wrapper into the variant so the union is self-contained. for (const variant of mutatedVariants) { const isScalar = variant.type.kind === "Scalar" || variant.type.kind === "Intrinsic"; @@ -204,8 +181,33 @@ export class GraphQLUnionMutation extends UnionMutation 0) { + const variantArray = mutatedVariants.map((variant) => { + return tk.unionVariant.create({ + name: variantNameToString(variant.name), + type: variant.type, + }); + }); + + const flattenedUnion = tk.union.create({ + name: unionName, + variants: variantArray, + }); + + this.#flattenedUnion = flattenedUnion; + } else { + this.#mutationNode.mutate((union) => { + union.name = unionName; + }); + } + + if (hasNull) { + setNullable(this.mutatedType); + } } /** diff --git a/packages/graphql/test/mutation-engine/unions.test.ts b/packages/graphql/test/mutation-engine/unions.test.ts index 08c2ba8d4df..b8c88291e86 100644 --- a/packages/graphql/test/mutation-engine/unions.test.ts +++ b/packages/graphql/test/mutation-engine/unions.test.ts @@ -71,6 +71,32 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(mutation.wrapperModels[0].name).toBe("MixedTextUnionVariant"); }); + it("substitutes wrapper models into union variant types", async () => { + const { Mixed } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + const mutatedUnion = mutation.mutatedType as Union; + + const variants = [...mutatedUnion.variants.values()]; + expect(variants).toHaveLength(2); + + // Model variant points to the mutated model + const catVariant = variants.find((v) => v.name === "cat")!; + expect(catVariant.type.kind).toBe("Model"); + expect((catVariant.type as Model).name).toBe("Cat"); + + // Scalar variant points to the wrapper model, not the raw scalar + const textVariant = variants.find((v) => v.name === "text")!; + expect(textVariant.type.kind).toBe("Model"); + expect((textVariant.type as Model).name).toBe("MixedTextUnionVariant"); + }); + it("does not create wrappers for model-only unions", async () => { const { Pet } = await tester.compile( t.code` @@ -111,6 +137,13 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(mutation.wrapperModels).toHaveLength(2); const names = mutation.wrapperModels.map((m) => m.name).sort(); expect(names).toEqual(["MixedCountUnionVariant", "MixedTextUnionVariant"]); + + // All union variants point to Models (originals or wrappers) + const mutatedUnion = mutation.mutatedType as Union; + const variants = [...mutatedUnion.variants.values()]; + for (const variant of variants) { + expect(variant.type.kind).toBe("Model"); + } }); it("names anonymous return type union as OperationUnion", async () => { From 8e7d6fcd2a6f944cc8fb63d1e18d5e43badeed8b Mon Sep 17 00:00:00 2001 From: Swati Kumar Date: Tue, 9 Jun 2026 16:09:32 +0000 Subject: [PATCH 2/7] Add EnumType, ScalarType, UnionType components with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin rendering components that consume mutated types from the TypeGraph and delegate to @alloy-js/graphql. No render-time name logic — all names come from type.name (set by the mutation engine). Includes test infrastructure: renderToSDL helper and vitest config for deduping @alloy-js/core and graphql across linked packages. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/types/enum-type.tsx | 25 ++++ .../graphql/src/components/types/index.ts | 3 + .../src/components/types/scalar-type.tsx | 21 +++ .../src/components/types/union-type.tsx | 16 ++ .../test/components/enum-type.test.tsx | 102 +++++++++++++ .../test/components/scalar-type.test.tsx | 79 ++++++++++ .../graphql/test/components/test-utils.tsx | 35 +++++ .../test/components/union-type.test.tsx | 141 ++++++++++++++++++ packages/graphql/vitest.config.ts | 4 + 9 files changed, 426 insertions(+) create mode 100644 packages/graphql/src/components/types/enum-type.tsx create mode 100644 packages/graphql/src/components/types/index.ts create mode 100644 packages/graphql/src/components/types/scalar-type.tsx create mode 100644 packages/graphql/src/components/types/union-type.tsx create mode 100644 packages/graphql/test/components/enum-type.test.tsx create mode 100644 packages/graphql/test/components/scalar-type.test.tsx create mode 100644 packages/graphql/test/components/test-utils.tsx create mode 100644 packages/graphql/test/components/union-type.test.tsx diff --git a/packages/graphql/src/components/types/enum-type.tsx b/packages/graphql/src/components/types/enum-type.tsx new file mode 100644 index 00000000000..8b98089d2d1 --- /dev/null +++ b/packages/graphql/src/components/types/enum-type.tsx @@ -0,0 +1,25 @@ +import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +export interface EnumTypeProps { + type: Enum; +} + +export function EnumType(props: EnumTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const members = [...props.type.members.values()]; + + return ( + + {members.map((member) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/types/index.ts b/packages/graphql/src/components/types/index.ts new file mode 100644 index 00000000000..2a6d0bf2b75 --- /dev/null +++ b/packages/graphql/src/components/types/index.ts @@ -0,0 +1,3 @@ +export { EnumType, type EnumTypeProps } from "./enum-type.js"; +export { ScalarType, type ScalarTypeProps } from "./scalar-type.js"; +export { UnionType, type UnionTypeProps } from "./union-type.js"; diff --git a/packages/graphql/src/components/types/scalar-type.tsx b/packages/graphql/src/components/types/scalar-type.tsx new file mode 100644 index 00000000000..4ea4410fbc6 --- /dev/null +++ b/packages/graphql/src/components/types/scalar-type.tsx @@ -0,0 +1,21 @@ +import { type Scalar, getDoc } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +export interface ScalarTypeProps { + type: Scalar; + specificationUrl?: string; +} + +export function ScalarType(props: ScalarTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + + return ( + + ); +} diff --git a/packages/graphql/src/components/types/union-type.tsx b/packages/graphql/src/components/types/union-type.tsx new file mode 100644 index 00000000000..1d0360fe4af --- /dev/null +++ b/packages/graphql/src/components/types/union-type.tsx @@ -0,0 +1,16 @@ +import { type Union, getDoc } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +export interface UnionTypeProps { + type: Union; +} + +export function UnionType(props: UnionTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const variants = [...props.type.variants.values()]; + const members = variants.map((v) => (v.type as { name: string }).name); + + return ; +} diff --git a/packages/graphql/test/components/enum-type.test.tsx b/packages/graphql/test/components/enum-type.test.tsx new file mode 100644 index 00000000000..dbc52639e86 --- /dev/null +++ b/packages/graphql/test/components/enum-type.test.tsx @@ -0,0 +1,102 @@ +import { t } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { EnumType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderToSDL } from "./test-utils.js"; + +describe("EnumType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic enum", async () => { + const { Color } = await tester.compile( + t.code`enum ${t.enum("Color")} { Red, Green, Blue }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Color).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain("enum Color"); + expect(sdl).toContain("RED"); + expect(sdl).toContain("GREEN"); + expect(sdl).toContain("BLUE"); + }); + + it("renders enum with doc comment as description", async () => { + const { Role } = await tester.compile( + t.code` + /** The role a user can have */ + enum ${t.enum("Role")} { Admin, User } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Role).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain('"The role a user can have"'); + expect(sdl).toContain("enum Role"); + }); + + it("renders enum with member descriptions", async () => { + const { Status } = await tester.compile( + t.code` + enum ${t.enum("Status")} { + /** Currently active */ + Active, + /** No longer active */ + Inactive, + } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Status).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain('"Currently active"'); + expect(sdl).toContain('"No longer active"'); + }); + + it("renders enum with deprecated members", async () => { + const { Status } = await tester.compile( + t.code` + enum ${t.enum("Status")} { + Active, + #deprecated "use Active instead" + Legacy, + } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Status).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain("@deprecated"); + expect(sdl).toContain("use Active instead"); + }); + + it("renders enum with mutation-engine-sanitized member names", async () => { + const { E } = await tester.compile( + t.code`enum ${t.enum("E")} { \`$val1$\`, \`val-2\` }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(E).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + // Mutation engine: sanitize → CONSTANT_CASE + expect(sdl).toContain("_VAL_1"); + expect(sdl).toContain("VAL_2"); + }); +}); diff --git a/packages/graphql/test/components/scalar-type.test.tsx b/packages/graphql/test/components/scalar-type.test.tsx new file mode 100644 index 00000000000..d71ed1b89bf --- /dev/null +++ b/packages/graphql/test/components/scalar-type.test.tsx @@ -0,0 +1,79 @@ +import { t } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { ScalarType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { getSpecifiedBy } from "../../src/lib/specified-by.js"; +import { Tester } from "../test-host.js"; +import { renderToSDL } from "./test-utils.js"; + +describe("ScalarType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a custom scalar", async () => { + const { DateTime } = await tester.compile( + t.code`scalar ${t.scalar("DateTime")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateScalar(DateTime).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain("scalar DateTime"); + }); + + it("renders a scalar with doc comment description", async () => { + const { JSON } = await tester.compile( + t.code` + /** Arbitrary JSON blob */ + scalar ${t.scalar("JSON")} extends string; + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateScalar(JSON).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain('"Arbitrary JSON blob"'); + expect(sdl).toContain("scalar JSON"); + }); + + it("renders a scalar with @specifiedBy", async () => { + const { MyScalar } = await tester.compile( + t.code` + @specifiedBy("https://example.com/spec") + scalar ${t.scalar("MyScalar")} extends string; + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateScalar(MyScalar).mutatedType; + const specUrl = getSpecifiedBy(tester.program, mutated); + + const sdl = renderToSDL( + tester.program, + , + ); + + expect(sdl).toContain("@specifiedBy"); + expect(sdl).toContain("https://example.com/spec"); + }); + + it("renders a scalar without @specifiedBy when not present", async () => { + const { MyScalar } = await tester.compile( + t.code`scalar ${t.scalar("MyScalar")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateScalar(MyScalar).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain("scalar MyScalar"); + expect(sdl).not.toContain("@specifiedBy"); + }); +}); diff --git a/packages/graphql/test/components/test-utils.tsx b/packages/graphql/test/components/test-utils.tsx new file mode 100644 index 00000000000..e88af9200e5 --- /dev/null +++ b/packages/graphql/test/components/test-utils.tsx @@ -0,0 +1,35 @@ +import { type Children } from "@alloy-js/core"; +import * as gql from "@alloy-js/graphql"; +import { renderSchema } from "@alloy-js/graphql"; +import { printSchema } from "graphql"; +import type { Program } from "@typespec/compiler"; +import { TspContext } from "@typespec/emitter-framework"; +import { GraphQLSchemaContext } from "../../src/context/index.js"; +import type { TypeGraph } from "../../src/mutation-engine/type-graph.js"; + +/** + * Render GraphQL components in isolation and return SDL string. + * Wraps children in required context providers and adds a placeholder Query + * (graphql-js requires at least one query field). + */ +export function renderToSDL(program: Program, children: Children): string { + const typeGraph: TypeGraph = { + globalNamespace: program.getGlobalNamespaceType(), + }; + + const schema = renderSchema( + + + {children} + + + + + , + { namePolicy: null }, + ); + + // Cast needed: alloy uses graphql@17-alpha internally, our package uses graphql@16. + // At runtime both are deduped via vitest config; the type mismatch is superficial. + return printSchema(schema as any); +} diff --git a/packages/graphql/test/components/union-type.test.tsx b/packages/graphql/test/components/union-type.test.tsx new file mode 100644 index 00000000000..293cfc0236e --- /dev/null +++ b/packages/graphql/test/components/union-type.test.tsx @@ -0,0 +1,141 @@ +import { type Union } from "@typespec/compiler"; +import { t } from "@typespec/compiler/testing"; +import * as gql from "@alloy-js/graphql"; +import { beforeEach, describe, expect, it } from "vitest"; +import { UnionType } from "../../src/components/types/index.js"; +import { + createGraphQLMutationEngine, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderToSDL } from "./test-utils.js"; + +describe("UnionType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a union of model types", async () => { + const { Pet } = 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; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + const mutatedUnion = mutation.mutatedType as Union; + + // Union members must be registered for graphql-js to validate the schema + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain("union Pet = Cat | Dog"); + }); + + it("renders a union with doc comment description", async () => { + const { Result } = await tester.compile( + t.code` + model ${t.model("Success")} { value: string; } + model ${t.model("Failure")} { message: string; } + /** The result of an operation */ + union ${t.union("Result")} { success: Success; failure: Failure; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Result, GraphQLTypeContext.Output); + const mutatedUnion = mutation.mutatedType as Union; + + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain('"The result of an operation"'); + expect(sdl).toContain("union Result = Success | Failure"); + }); + + it("references wrapper model names for scalar variants", async () => { + const { Mixed } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + const mutatedUnion = mutation.mutatedType as Union; + + // Register the wrapper model and Cat so graphql-js can validate + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain("union Mixed = Cat | MixedTextUnionVariant"); + }); + + it("renders a union with three model members", async () => { + const { Shape } = await tester.compile( + t.code` + model ${t.model("Circle")} { radius: float32; } + model ${t.model("Square")} { side: float32; } + model ${t.model("Triangle")} { base: float32; } + union ${t.union("Shape")} { circle: Circle; square: Square; triangle: Triangle; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Shape, GraphQLTypeContext.Output); + const mutatedUnion = mutation.mutatedType as Union; + + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + + + + , + ); + + expect(sdl).toContain("union Shape = Circle | Square | Triangle"); + }); +}); diff --git a/packages/graphql/vitest.config.ts b/packages/graphql/vitest.config.ts index b45007f3475..c35108ef7bb 100644 --- a/packages/graphql/vitest.config.ts +++ b/packages/graphql/vitest.config.ts @@ -10,5 +10,9 @@ export default mergeConfig( sourcemap: "both", }, plugins: [alloyPlugin()], + resolve: { + conditions: ["development"], + dedupe: ["@alloy-js/core", "graphql"], + }, }), ); From 91152aa4e8ea3fc5925dcd42e3ca292cc0ed8c7d Mon Sep 17 00:00:00 2001 From: Swati Kumar Date: Tue, 9 Jun 2026 18:11:44 +0000 Subject: [PATCH 3/7] Mutate input-context models in schema-mutator Models used as operation parameters now get a second mutation pass with GraphQLTypeContext.Input, producing the Input-suffixed variant (e.g., BookInput) alongside the Output variant in the TypeGraph. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mutation-engine/schema-mutator.ts | 16 +++++++- .../mutation-engine/schema-mutator.test.ts | 37 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts index ee6559028cb..5733c57e069 100644 --- a/packages/graphql/src/mutation-engine/schema-mutator.ts +++ b/packages/graphql/src/mutation-engine/schema-mutator.ts @@ -11,7 +11,7 @@ import { type Union, } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; -import type { TypeUsageResolver } from "../type-usage.js"; +import { GraphQLTypeUsage, 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"; @@ -22,6 +22,9 @@ import { buildTypeGraph, type TypeGraph } from "./type-graph.js"; * * Filtering (unreachable types, array models, nullable unions) happens here * so the engine only processes types that belong in the schema. + * + * Models used as both input and output get two mutations (Output and Input), + * producing separate entries in the TypeGraph (e.g., `Book` and `BookInput`). */ export function mutateSchema( program: Program, @@ -31,6 +34,7 @@ export function mutateSchema( ): TypeGraph { const tk = $(program); const mutatedTypes: Type[] = []; + const modelsToInputMutate: Model[] = []; navigateTypesInNamespace(schema, { model: (node: Model) => { @@ -39,6 +43,11 @@ export function mutateSchema( const mutation = engine.mutateModel(node, GraphQLTypeContext.Output); mutatedTypes.push(mutation.mutatedType); + + const usage = typeUsage.getUsage(node); + if (usage?.has(GraphQLTypeUsage.Input)) { + modelsToInputMutate.push(node); + } }, enum: (node: Enum) => { if (typeUsage.isUnreachable(node)) return; @@ -66,5 +75,10 @@ export function mutateSchema( }, }); + for (const model of modelsToInputMutate) { + const mutation = engine.mutateModel(model, GraphQLTypeContext.Input); + mutatedTypes.push(mutation.mutatedType); + } + return buildTypeGraph(program, tk, mutatedTypes); } diff --git a/packages/graphql/test/mutation-engine/schema-mutator.test.ts b/packages/graphql/test/mutation-engine/schema-mutator.test.ts index c69327594a2..b087369d7fe 100644 --- a/packages/graphql/test/mutation-engine/schema-mutator.test.ts +++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts @@ -150,6 +150,43 @@ describe("mutateSchema", () => { expect(typeGraph.globalNamespace.models.has("MixedNumUnionVariant")).toBe(true); }); + it("produces Input variant for models used as operation parameters", async () => { + await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + op ${t.op("createBook")}(input: Book): Book; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Book is used as both output (return) and input (parameter), + // so both variants should appear in the TypeGraph + expect(typeGraph.globalNamespace.models.has("Book")).toBe(true); + expect(typeGraph.globalNamespace.models.has("BookInput")).toBe(true); + }); + + it("does not produce Input variant for output-only models", async () => { + await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + `, + ); + + 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("Book")).toBe(true); + expect(typeGraph.globalNamespace.models.has("BookInput")).toBe(false); + }); + it("skips array models (they are list types, not object types)", async () => { await tester.compile( t.code` From c7131f00d1839bf98041c9a4846fbebb59f90b38 Mon Sep 17 00:00:00 2001 From: Swati Kumar Date: Tue, 9 Jun 2026 18:40:12 +0000 Subject: [PATCH 4/7] Fix input/output model splitting to respect type usage Only produce an Output mutation for models actually used as output (or unreferenced models defaulting to output). Input-only models now only get an Input mutation, preventing spurious output types in the TypeGraph. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mutation-engine/schema-mutator.ts | 21 +++++++++---------- .../mutation-engine/schema-mutator.test.ts | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts index 5733c57e069..8a8a3c3b5d7 100644 --- a/packages/graphql/src/mutation-engine/schema-mutator.ts +++ b/packages/graphql/src/mutation-engine/schema-mutator.ts @@ -34,19 +34,23 @@ export function mutateSchema( ): TypeGraph { const tk = $(program); const mutatedTypes: Type[] = []; - const modelsToInputMutate: Model[] = []; 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); - const usage = typeUsage.getUsage(node); - if (usage?.has(GraphQLTypeUsage.Input)) { - modelsToInputMutate.push(node); + const usedAsOutput = usage?.has(GraphQLTypeUsage.Output) ?? false; + const usedAsInput = usage?.has(GraphQLTypeUsage.Input) ?? false; + + if (usedAsOutput || !usage) { + const mutation = engine.mutateModel(node, GraphQLTypeContext.Output); + mutatedTypes.push(mutation.mutatedType); + } + if (usedAsInput) { + const mutation = engine.mutateModel(node, GraphQLTypeContext.Input); + mutatedTypes.push(mutation.mutatedType); } }, enum: (node: Enum) => { @@ -75,10 +79,5 @@ export function mutateSchema( }, }); - for (const model of modelsToInputMutate) { - const mutation = engine.mutateModel(model, GraphQLTypeContext.Input); - mutatedTypes.push(mutation.mutatedType); - } - return buildTypeGraph(program, tk, mutatedTypes); } diff --git a/packages/graphql/test/mutation-engine/schema-mutator.test.ts b/packages/graphql/test/mutation-engine/schema-mutator.test.ts index b087369d7fe..db97d5a8092 100644 --- a/packages/graphql/test/mutation-engine/schema-mutator.test.ts +++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts @@ -187,6 +187,27 @@ describe("mutateSchema", () => { expect(typeGraph.globalNamespace.models.has("BookInput")).toBe(false); }); + it("does not produce Output variant for input-only models", async () => { + await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + model ${t.model("Payload")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + op ${t.op("createBook")}(input: Payload): Book; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, true); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Payload is only used as input — should only appear as Input variant (PayloadInput) + expect(typeGraph.globalNamespace.models.has("PayloadInput")).toBe(true); + // Should NOT have an Output variant + expect(typeGraph.globalNamespace.models.has("Payload")).toBe(false); + }); + it("skips array models (they are list types, not object types)", async () => { await tester.compile( t.code` From 2a3c29e2791b427e73ee3a7fbe858fa5ef624ea8 Mon Sep 17 00:00:00 2001 From: Swati Kumar Date: Tue, 9 Jun 2026 18:48:17 +0000 Subject: [PATCH 5/7] Add type-name-collision diagnostic for duplicate GraphQL type names When two different TypeSpec types produce the same GraphQL name (e.g., explicit model BookInput + model Book used as input both yielding "BookInput"), report an error diagnostic instead of silently overwriting in the TypeGraph. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/graphql/src/lib.ts | 6 +++++ .../src/mutation-engine/schema-mutator.ts | 16 +++++++++++++ .../mutation-engine/schema-mutator.test.ts | 24 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts index 9b9946e4513..4e4033a206a 100644 --- a/packages/graphql/src/lib.ts +++ b/packages/graphql/src/lib.ts @@ -162,6 +162,12 @@ export const libDef = { default: paramMessage`Scalar "${"name"}" collides with GraphQL built-in type "${"builtinName"}". This may cause unexpected behavior. Consider renaming the scalar.`, }, }, + "type-name-collision": { + severity: "error", + messages: { + default: paramMessage`Type "${"name"}" collides with another type of the same name in the GraphQL schema. Consider renaming one of the types.`, + }, + }, "empty-schema": { severity: "warning", messages: { diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts index 8a8a3c3b5d7..cc0c6978f20 100644 --- a/packages/graphql/src/mutation-engine/schema-mutator.ts +++ b/packages/graphql/src/mutation-engine/schema-mutator.ts @@ -11,6 +11,7 @@ import { type Union, } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; +import { reportDiagnostic } from "../lib.js"; import { GraphQLTypeUsage, type TypeUsageResolver } from "../type-usage.js"; import type { GraphQLMutationEngine } from "./engine.js"; import { GraphQLTypeContext } from "./options.js"; @@ -79,5 +80,20 @@ export function mutateSchema( }, }); + const seen = new Map(); + for (const type of mutatedTypes) { + if (!("name" in type) || !type.name) continue; + const name = type.name as string; + if (seen.has(name)) { + reportDiagnostic(program, { + code: "type-name-collision", + format: { name }, + target: type, + }); + } else { + seen.set(name, type); + } + } + return buildTypeGraph(program, tk, mutatedTypes); } diff --git a/packages/graphql/test/mutation-engine/schema-mutator.test.ts b/packages/graphql/test/mutation-engine/schema-mutator.test.ts index db97d5a8092..5fd9684c287 100644 --- a/packages/graphql/test/mutation-engine/schema-mutator.test.ts +++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts @@ -208,6 +208,30 @@ describe("mutateSchema", () => { expect(typeGraph.globalNamespace.models.has("Payload")).toBe(false); }); + it("reports diagnostic when two types produce the same GraphQL name", async () => { + const [_, diagnostics] = await tester.compileAndDiagnose( + t.code` + model ${t.model("BookInput")} { x: int32; } + model ${t.model("Book")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + op ${t.op("createBook")}(input: Book): Book; + `, + ); + + // Book used as input → Input mutation → "BookInput" + // BookInput declared explicitly → Output mutation → "BookInput" + // This should produce a collision diagnostic + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(ns, false); + const engine = createGraphQLMutationEngine(tester.program); + mutateSchema(tester.program, engine, ns, typeUsage); + + const collisions = tester.program.diagnostics.filter( + (d) => d.code === "@typespec/graphql/type-name-collision", + ); + expect(collisions.length).toBeGreaterThan(0); + }); + it("skips array models (they are list types, not object types)", async () => { await tester.compile( t.code` From 3b602e45229188103e413c14bcc7466be0dcabc4 Mon Sep 17 00:00:00 2001 From: Swati Kumar Date: Tue, 9 Jun 2026 19:05:17 +0000 Subject: [PATCH 6/7] Preserve decorator state on flattened unions via clone When creating a synthetic flattened union (after null-stripping or scalar wrapping), clone the source union instead of creating from scratch. This preserves decorators and the AST node, so getDoc/getDeprecationDetails work on the flattened result. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mutation-engine/mutations/union.ts | 12 ++++++++---- .../test/mutation-engine/unions.test.ts | 18 +++++++++++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/graphql/src/mutation-engine/mutations/union.ts b/packages/graphql/src/mutation-engine/mutations/union.ts index 0791f77235d..ddc53652d6c 100644 --- a/packages/graphql/src/mutation-engine/mutations/union.ts +++ b/packages/graphql/src/mutation-engine/mutations/union.ts @@ -193,10 +193,14 @@ export class GraphQLUnionMutation extends UnionMutation { expect(variantNames).toEqual(["AdAccount", "Board"]); }); + it("preserves decorator state (e.g. @doc) on flattened unions", async () => { + const { MaybePet } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + /** A pet or nothing */ + union ${t.union("MaybePet")} { cat: Cat; dog: Dog; null; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(MaybePet, GraphQLTypeContext.Output); + + expect(getDoc(tester.program, mutation.mutatedType)).toBe("A pet or nothing"); + }); + it("T | null replacement gets its mutation pipeline applied", async () => { await tester.compile( t.code` From b4f20095e0160862850f2932b5ab27557af4d1d3 Mon Sep 17 00:00:00 2001 From: Swati Kumar Date: Tue, 9 Jun 2026 19:17:16 +0000 Subject: [PATCH 7/7] Replace unsafe casts with GraphQLUnion interface and typekit guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GraphQLUnion narrows Union.name to string (mutation engine guarantee) - $.model.is() typekit guard for variant types (established repo pattern) - No runtime throws — type safety from interface contract + narrowing Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/graphql/src/components/types/index.ts | 2 +- .../graphql/src/components/types/union-type.tsx | 16 ++++++++++++---- .../graphql/test/components/union-type.test.tsx | 11 +++++------ 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/graphql/src/components/types/index.ts b/packages/graphql/src/components/types/index.ts index 2a6d0bf2b75..00f4a951e33 100644 --- a/packages/graphql/src/components/types/index.ts +++ b/packages/graphql/src/components/types/index.ts @@ -1,3 +1,3 @@ export { EnumType, type EnumTypeProps } from "./enum-type.js"; export { ScalarType, type ScalarTypeProps } from "./scalar-type.js"; -export { UnionType, type UnionTypeProps } from "./union-type.js"; +export { UnionType, type UnionTypeProps, type GraphQLUnion } from "./union-type.js"; diff --git a/packages/graphql/src/components/types/union-type.tsx b/packages/graphql/src/components/types/union-type.tsx index 1d0360fe4af..387861f03b1 100644 --- a/packages/graphql/src/components/types/union-type.tsx +++ b/packages/graphql/src/components/types/union-type.tsx @@ -1,16 +1,24 @@ -import { type Union, getDoc } from "@typespec/compiler"; +import { type Model, type Union, getDoc } from "@typespec/compiler"; import * as gql from "@alloy-js/graphql"; import { useTsp } from "@typespec/emitter-framework"; +/** + * A Union guaranteed to have a name after mutation. + * The mutation engine ensures this: anonymous unions get derived names. + */ +export interface GraphQLUnion extends Union { + name: string; +} + export interface UnionTypeProps { - type: Union; + type: GraphQLUnion; } export function UnionType(props: UnionTypeProps) { const { program } = useTsp(); const doc = getDoc(program, props.type); const variants = [...props.type.variants.values()]; - const members = variants.map((v) => (v.type as { name: string }).name); + const members = variants.map((v) => (v.type as Model).name); - return ; + return ; } diff --git a/packages/graphql/test/components/union-type.test.tsx b/packages/graphql/test/components/union-type.test.tsx index 293cfc0236e..897bdd67d48 100644 --- a/packages/graphql/test/components/union-type.test.tsx +++ b/packages/graphql/test/components/union-type.test.tsx @@ -1,8 +1,7 @@ -import { type Union } from "@typespec/compiler"; import { t } from "@typespec/compiler/testing"; import * as gql from "@alloy-js/graphql"; import { beforeEach, describe, expect, it } from "vitest"; -import { UnionType } from "../../src/components/types/index.js"; +import { UnionType, type GraphQLUnion } from "../../src/components/types/index.js"; import { createGraphQLMutationEngine, GraphQLTypeContext, @@ -27,7 +26,7 @@ describe("UnionType component", () => { const engine = createGraphQLMutationEngine(tester.program); const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); - const mutatedUnion = mutation.mutatedType as Union; + const mutatedUnion = mutation.mutatedType as GraphQLUnion; // Union members must be registered for graphql-js to validate the schema const sdl = renderToSDL( @@ -58,7 +57,7 @@ describe("UnionType component", () => { const engine = createGraphQLMutationEngine(tester.program); const mutation = engine.mutateUnion(Result, GraphQLTypeContext.Output); - const mutatedUnion = mutation.mutatedType as Union; + const mutatedUnion = mutation.mutatedType as GraphQLUnion; const sdl = renderToSDL( tester.program, @@ -87,7 +86,7 @@ describe("UnionType component", () => { const engine = createGraphQLMutationEngine(tester.program); const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); - const mutatedUnion = mutation.mutatedType as Union; + const mutatedUnion = mutation.mutatedType as GraphQLUnion; // Register the wrapper model and Cat so graphql-js can validate const sdl = renderToSDL( @@ -118,7 +117,7 @@ describe("UnionType component", () => { const engine = createGraphQLMutationEngine(tester.program); const mutation = engine.mutateUnion(Shape, GraphQLTypeContext.Output); - const mutatedUnion = mutation.mutatedType as Union; + const mutatedUnion = mutation.mutatedType as GraphQLUnion; const sdl = renderToSDL( tester.program,