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..00f4a951e33 --- /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, type GraphQLUnion } 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..387861f03b1 --- /dev/null +++ b/packages/graphql/src/components/types/union-type.tsx @@ -0,0 +1,24 @@ +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: 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 Model).name); + + return ; +} 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/mutations/union.ts b/packages/graphql/src/mutation-engine/mutations/union.ts index e8ee273d9c2..ddc53652d6c 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,37 @@ 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.type.clone(this.sourceType); + flattenedUnion.name = unionName; + flattenedUnion.variants.clear(); + for (const variant of variantArray) { + flattenedUnion.variants.set(variant.name, variant); + variant.union = flattenedUnion; + } + tk.type.finishType(flattenedUnion); + + this.#flattenedUnion = flattenedUnion; + } else { + this.#mutationNode.mutate((union) => { + union.name = unionName; + }); + } + + if (hasNull) { + setNullable(this.mutatedType); + } } /** diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts index ee6559028cb..cc0c6978f20 100644 --- a/packages/graphql/src/mutation-engine/schema-mutator.ts +++ b/packages/graphql/src/mutation-engine/schema-mutator.ts @@ -11,7 +11,8 @@ import { type Union, } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; -import type { TypeUsageResolver } from "../type-usage.js"; +import { reportDiagnostic } from "../lib.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 +23,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, @@ -37,8 +41,18 @@ export function mutateSchema( 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); + 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) => { if (typeUsage.isUnreachable(node)) return; @@ -66,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/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..897bdd67d48 --- /dev/null +++ b/packages/graphql/test/components/union-type.test.tsx @@ -0,0 +1,140 @@ +import { t } from "@typespec/compiler/testing"; +import * as gql from "@alloy-js/graphql"; +import { beforeEach, describe, expect, it } from "vitest"; +import { UnionType, type GraphQLUnion } 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 GraphQLUnion; + + // 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 GraphQLUnion; + + 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 GraphQLUnion; + + // 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 GraphQLUnion; + + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + + + + , + ); + + expect(sdl).toContain("union Shape = Circle | Square | Triangle"); + }); +}); diff --git a/packages/graphql/test/mutation-engine/schema-mutator.test.ts b/packages/graphql/test/mutation-engine/schema-mutator.test.ts index c69327594a2..5fd9684c287 100644 --- a/packages/graphql/test/mutation-engine/schema-mutator.test.ts +++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts @@ -150,6 +150,88 @@ 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("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("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` diff --git a/packages/graphql/test/mutation-engine/unions.test.ts b/packages/graphql/test/mutation-engine/unions.test.ts index 08c2ba8d4df..723d2d01637 100644 --- a/packages/graphql/test/mutation-engine/unions.test.ts +++ b/packages/graphql/test/mutation-engine/unions.test.ts @@ -1,4 +1,4 @@ -import type { Model, Union } from "@typespec/compiler"; +import { getDoc, type Model, type Union } from "@typespec/compiler"; import { t } from "@typespec/compiler/testing"; import { beforeEach, describe, expect, it } from "vitest"; import { isNullable } from "../../src/lib/nullable.js"; @@ -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 () => { @@ -185,6 +218,22 @@ describe("GraphQL Mutation Engine - Unions", () => { 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` 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"], + }, }), );