Skip to content

Commit 67bd766

Browse files
committed
Centralize union member names in mutation engine
1 parent 1b00a27 commit 67bd766

9 files changed

Lines changed: 82 additions & 64 deletions

File tree

packages/graphql/src/components/types/enum-type.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler";
1+
import { type Enum, getDeprecationDetails } from "@typespec/compiler";
22
import * as gql from "@alloy-js/graphql";
33
import { useTsp } from "@typespec/emitter-framework";
44

@@ -11,14 +11,14 @@ export interface EnumTypeProps {
1111
* Renders a GraphQL enum type declaration with members
1212
*/
1313
export function EnumType(props: EnumTypeProps) {
14-
const { program } = useTsp();
15-
const doc = getDoc(program, props.type);
14+
const { $, program } = useTsp();
15+
const doc = $.type.getDoc(props.type);
1616
const members = Array.from(props.type.members.values());
1717

1818
return (
1919
<gql.EnumType name={props.type.name} description={doc}>
2020
{members.map((member) => {
21-
const memberDoc = getDoc(program, member);
21+
const memberDoc = $.type.getDoc(member);
2222
const deprecation = getDeprecationDetails(program, member);
2323

2424
return (

packages/graphql/src/components/types/scalar-type.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type Scalar, getDoc } from "@typespec/compiler";
1+
import type { Scalar } from "@typespec/compiler";
22
import * as gql from "@alloy-js/graphql";
33
import { useTsp } from "@typespec/emitter-framework";
44
import { useGraphQLSchema } from "../../context/index.js";
@@ -12,9 +12,9 @@ export interface ScalarTypeProps {
1212
* Renders a GraphQL scalar type declaration with optional @specifiedBy directive
1313
*/
1414
export function ScalarType(props: ScalarTypeProps) {
15-
const { program } = useTsp();
15+
const { $ } = useTsp();
1616
const { scalarSpecifications } = useGraphQLSchema();
17-
const doc = getDoc(program, props.type);
17+
const doc = $.type.getDoc(props.type);
1818
const specificationUrl = scalarSpecifications.get(props.type.name);
1919

2020
return (
Lines changed: 20 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,34 @@
1-
import { type Union, getDoc } from "@typespec/compiler";
1+
import type { Union } from "@typespec/compiler";
22
import * as gql from "@alloy-js/graphql";
33
import { useTsp } from "@typespec/emitter-framework";
4-
import { getUnionName, isScalarLikeType, toTypeName } from "../../lib/type-utils.js";
4+
import { useGraphQLSchema } from "../../context/index.js";
5+
import { getUnionName } from "../../lib/type-utils.js";
56

67
export interface UnionTypeProps {
78
/** The union type to render */
89
type: Union;
910
}
1011

1112
/**
12-
* Renders a GraphQL union type declaration
13-
* Scalars are wrapped in object types since GraphQL unions can only contain object types
14-
* This wrapping is done by the mutation engine
13+
* Renders a GraphQL union type declaration.
14+
*
15+
* Member names (wrapper names for scalar variants, GraphQL type names for
16+
* object variants) are produced by the mutation engine and read from
17+
* `GraphQLSchemaContextValue.unionMembers`. The component only decides
18+
* how to render, not what the members are.
1519
*/
1620
export function UnionType(props: UnionTypeProps) {
17-
const { program } = useTsp();
21+
const { $, program } = useTsp();
22+
const { unionMembers } = useGraphQLSchema();
1823
const name = getUnionName(props.type, program);
19-
const doc = getDoc(program, props.type);
20-
const variants = Array.from(props.type.variants.values());
24+
const doc = $.type.getDoc(props.type);
25+
const members = unionMembers.get(props.type);
2126

22-
// Build the union member list, using wrapper names for scalars
23-
// The wrapper models are created by the mutation engine
24-
const unionMembers = variants.map((variant) => {
25-
const variantName =
26-
typeof variant.name === "string" ? variant.name : String(variant.name);
27-
28-
if (isScalarLikeType(variant.type)) {
29-
// Reference the wrapper type for scalars (created by mutation engine)
30-
// Include union name to match wrapper model naming convention
31-
return toTypeName(name) + toTypeName(variantName) + "UnionVariant";
32-
} else {
33-
// For non-scalars, use the type name directly
34-
if (variant.type.kind === "Model") {
35-
return variant.type.name;
36-
} else if (
37-
"name" in variant.type &&
38-
typeof variant.type.name === "string"
39-
) {
40-
return variant.type.name;
41-
}
42-
throw new Error(
43-
`Unexpected union variant type kind "${variant.type.kind}" in union "${name}". ` +
44-
`This is a bug in the GraphQL emitter.`,
45-
);
46-
}
47-
});
48-
49-
return <gql.UnionType name={name} description={doc} members={unionMembers} />;
27+
return (
28+
<gql.UnionType
29+
name={name}
30+
description={doc}
31+
members={members ? [...members] : []}
32+
/>
33+
);
5034
}

packages/graphql/src/context/graphql-schema-context.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ export interface ScalarVariant {
5858
export interface GraphQLSchemaContextValue {
5959
/** Classified types for schema generation */
6060
classifiedTypes: ClassifiedTypes;
61-
/** Ordered member names for each union, keyed by the mutated Union */
61+
/**
62+
* Ordered member names for each output union, keyed by the mutated Union.
63+
* Consumed by UnionType to avoid reconstructing the naming convention
64+
* the mutation engine already applied.
65+
*/
6266
unionMembers: Map<Union, readonly string[]>;
6367
/** Scalar specification URLs for @specifiedBy directives */
6468
scalarSpecifications: Map<string, string>;

packages/graphql/src/lib/type-utils.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -318,11 +318,6 @@ function getTemplateStringInternal(
318318
return args.length > 0 ? args.map(toTypeName).join(options.conjunction) : "";
319319
}
320320

321-
/** Check if a type is a scalar (built-in or custom) or an intrinsic type like `unknown`. */
322-
export function isScalarLikeType(type: Type): boolean {
323-
return type.kind === "Scalar" || type.kind === "Intrinsic";
324-
}
325-
326321
/** Check if a model should be emitted as a GraphQL object type (not an array, record, or never). */
327322
export function isTrueModel(model: Model): boolean {
328323
return !(

packages/graphql/src/mutation-engine/mutations/union.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ function variantNameToString(name: string | symbol): string {
3535
export class GraphQLUnionMutation extends UnionMutation<MutationOptions, any, MutationEngine<any>> {
3636
#mutationNode: UnionMutationNode;
3737
#wrapperModels: Model[] = [];
38+
#memberNames: string[] = [];
3839
#flattenedUnion: Union | null = null;
3940

4041
constructor(
@@ -76,6 +77,15 @@ export class GraphQLUnionMutation extends UnionMutation<MutationOptions, any, Mu
7677
return this.#wrapperModels;
7778
}
7879

80+
/**
81+
* Ordered member names for the output union (wrapper name for scalars,
82+
* GraphQL type name for object variants). Empty for input/@oneOf unions
83+
* and nullable-union replacements.
84+
*/
85+
get memberNames(): readonly string[] {
86+
return this.#memberNames;
87+
}
88+
7989
/** Creates a half-edge for bidirectional variant mutation updates. */
8090
protected startVariantEdge(): MutationHalfEdge<
8191
GraphQLUnionMutation,
@@ -145,7 +155,9 @@ export class GraphQLUnionMutation extends UnionMutation<MutationOptions, any, Mu
145155
setNullable(this.mutatedType);
146156
}
147157

148-
// GraphQL unions can only contain object types — wrap scalars in synthetic models
158+
// GraphQL unions can only contain object types — wrap scalars in synthetic models.
159+
// Record member names in variant order so the renderer doesn't have to
160+
// reconstruct the naming convention.
149161
for (const variant of flattenedVariants) {
150162
const isScalar = variant.type.kind === "Scalar" || variant.type.kind === "Intrinsic";
151163

@@ -166,6 +178,9 @@ export class GraphQLUnionMutation extends UnionMutation<MutationOptions, any, Mu
166178
});
167179

168180
this.#wrapperModels.push(wrapperModel);
181+
this.#memberNames.push(wrapperName);
182+
} else if ("name" in variant.type && typeof variant.type.name === "string") {
183+
this.#memberNames.push(sanitizeNameForGraphQL(variant.type.name));
169184
}
170185
}
171186
}

packages/graphql/src/mutation-engine/schema-mutator.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ export interface MutatedSchema {
6363
// Derived metadata
6464
/** Synthetic wrapper models created by union mutations for scalar variants */
6565
wrapperModels: Model[];
66+
/**
67+
* Ordered member names for each output union, keyed by the mutated Union.
68+
* Entries are the GraphQL names that appear on the right-hand side of the
69+
* `union X = A | B | ...` declaration.
70+
*/
71+
unionMembers: Map<Union, readonly string[]>;
6672
/** Encoded stdlib scalars mapped to GraphQL custom scalars (e.g. bytes + base64 → Bytes) */
6773
scalarVariants: ScalarVariant[];
6874
/** `@specifiedBy` URLs indexed by GraphQL scalar name */
@@ -105,6 +111,9 @@ export function mutateSchema(
105111
// Synthetic wrapper models from union mutation (always output).
106112
const wrapperModels: Model[] = [];
107113

114+
// Ordered member names for each output union, keyed by the mutated Union.
115+
const unionMembers = new Map<Union, readonly string[]>();
116+
108117
// Void-returning operations — collected here so the emitter can report
109118
// the diagnostic. Mutation shapes the graph; it doesn't warn.
110119
const voidOperations: Operation[] = [];
@@ -236,8 +245,10 @@ export function mutateSchema(
236245
if (unwrapNullableUnion(node) !== undefined) return;
237246
if (typeUsage.isUnreachable(node)) return;
238247
const mutation = engine.mutateUnion(node, GraphQLTypeContext.Output);
239-
unions.push(mutation.mutatedType as Union);
248+
const mutatedUnion = mutation.mutatedType as Union;
249+
unions.push(mutatedUnion);
240250
wrapperModels.push(...mutation.wrapperModels);
251+
unionMembers.set(mutatedUnion, mutation.memberNames);
241252
},
242253
operation: (node: Operation) => {
243254
// Operations pass through unmutated. classifyOperation collects void
@@ -309,6 +320,7 @@ export function mutateSchema(
309320
subscriptions,
310321
voidOperations,
311322
wrapperModels,
323+
unionMembers,
312324
scalarVariants: Array.from(scalarVariantsMap.values()),
313325
scalarSpecifications,
314326
};

packages/graphql/test/components/component-test-utils.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function renderComponentToSDL(
3131
mutations: [],
3232
subscriptions: [],
3333
},
34-
modelVariants: { outputModels: new Map(), inputModels: new Map() },
34+
unionMembers: new Map(),
3535
scalarSpecifications: new Map(),
3636
...contextOverrides,
3737
};

packages/graphql/test/components/union-type.test.tsx

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,13 @@ describe("UnionType component", () => {
5252
</gql.ObjectType>
5353
<UnionType type={mutatedUnion} />
5454
</>,
55+
{ unionMembers: new Map([[mutatedUnion, mutation.memberNames]]) },
5556
);
5657

5758
expect(sdl).toContain("union Pet =");
58-
expect(sdl).toContain("Cat");
59-
expect(sdl).toContain("Dog");
59+
for (const member of mutation.memberNames) {
60+
expect(sdl).toContain(member);
61+
}
6062
});
6163

6264
it("renders a union with doc comment description", async () => {
@@ -84,6 +86,7 @@ describe("UnionType component", () => {
8486
</gql.ObjectType>
8587
<UnionType type={mutatedUnion} />
8688
</>,
89+
{ unionMembers: new Map([[mutatedUnion, mutation.memberNames]]) },
8790
);
8891

8992
expect(sdl).toContain("The result of an operation");
@@ -118,12 +121,13 @@ describe("UnionType component", () => {
118121
</gql.ObjectType>
119122
<UnionType type={mutatedUnion} />
120123
</>,
124+
{ unionMembers: new Map([[mutatedUnion, mutation.memberNames]]) },
121125
);
122126

123127
expect(sdl).toContain("union Shape =");
124-
expect(sdl).toContain("Circle");
125-
expect(sdl).toContain("Square");
126-
expect(sdl).toContain("Triangle");
128+
for (const member of mutation.memberNames) {
129+
expect(sdl).toContain(member);
130+
}
127131
});
128132

129133
it("references wrapper type names for scalar variants", async () => {
@@ -138,23 +142,27 @@ describe("UnionType component", () => {
138142
const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output);
139143
const mutatedUnion = assertUnionResult(mutation);
140144

141-
// Register Cat and the wrapper type that the union will reference
145+
// Register the mutation's wrapper models so buildSchema resolves the members.
142146
const sdl = renderComponentToSDL(
143147
tester.program,
144148
<>
145149
<gql.ObjectType name="Cat">
146150
<gql.Field name="name" type={gql.String} nonNull />
147151
</gql.ObjectType>
148-
<gql.ObjectType name="MixedTextUnionVariant">
149-
<gql.Field name="value" type={gql.String} nonNull />
150-
</gql.ObjectType>
152+
{mutation.wrapperModels.map((wrapper) => (
153+
<gql.ObjectType name={wrapper.name}>
154+
<gql.Field name="value" type={gql.String} nonNull />
155+
</gql.ObjectType>
156+
))}
151157
<UnionType type={mutatedUnion} />
152158
</>,
159+
{ unionMembers: new Map([[mutatedUnion, mutation.memberNames]]) },
153160
);
154161

155-
// Scalar variant should reference wrapper type name
162+
// Every member the engine reported should land in the SDL.
156163
expect(sdl).toContain("union Mixed =");
157-
expect(sdl).toContain("MixedTextUnionVariant");
158-
expect(sdl).toContain("Cat");
164+
for (const member of mutation.memberNames) {
165+
expect(sdl).toContain(member);
166+
}
159167
});
160168
});

0 commit comments

Comments
 (0)