Skip to content

Commit 3673956

Browse files
committed
Replace state maps with decorators for mutation engine metadata
1 parent 3f77e30 commit 3673956

10 files changed

Lines changed: 232 additions & 64 deletions

File tree

packages/graphql/lib/main.tsp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import "./interface.tsp";
2+
import "./nullable.tsp";
3+
import "./one-of.tsp";
24
import "./operation-fields.tsp";
35
import "./operation-kind.tsp";
46
import "./scalars.tsp";

packages/graphql/lib/nullable.tsp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import "../dist/src/lib/nullable.js";
2+
3+
using TypeSpec.Reflection;
4+
5+
namespace TypeSpec.GraphQL;
6+
7+
/**
8+
* Mark a field, operation, or type as nullable in the emitted GraphQL schema.
9+
*
10+
* Applied automatically by the mutation engine when it strips `| null` from
11+
* union types. The decorator's presence on the type's `decorators` array is
12+
* the signal — the implementation is a no-op.
13+
*/
14+
extern dec nullable(target: ModelProperty | Operation | Union | Model);
15+
16+
/**
17+
* Mark a field or operation as having nullable array elements in the emitted GraphQL schema.
18+
*
19+
* Applied automatically by the mutation engine when it detects `Array<T | null>`
20+
* patterns. Causes the emitter to emit `[T]` instead of `[T!]`.
21+
*/
22+
extern dec nullableElements(target: ModelProperty | Operation);

packages/graphql/lib/one-of.tsp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import "../dist/src/lib/one-of.js";
2+
3+
using TypeSpec.Reflection;
4+
5+
namespace TypeSpec.GraphQL;
6+
7+
/**
8+
* Mark a model as a `@oneOf` input object in the emitted GraphQL schema.
9+
*
10+
* This decorator is applied automatically by the mutation engine when it converts
11+
* a union type in input context to a synthetic input object (since GraphQL unions
12+
* are output-only). The emitter uses this to emit the `@oneOf` directive.
13+
*
14+
* @see https://spec.graphql.org/September2025/#sec-OneOf-Input-Objects
15+
*/
16+
extern dec oneOf(target: Model);

packages/graphql/src/lib.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -174,15 +174,6 @@ export const libDef = {
174174
interface: { description: "State for the @Interface decorator." },
175175
schema: { description: "State for the @schema decorator." },
176176
specifiedBy: { description: "State for the @specifiedBy decorator." },
177-
oneOf: { description: "State for tracking @oneOf input objects created from input unions." },
178-
nullable: {
179-
description:
180-
"State for tracking types and properties marked nullable after null-variant stripping by the mutation engine.",
181-
},
182-
nullableElements: {
183-
description:
184-
"State for tracking properties whose array element type was originally T | null before mutation.",
185-
},
186177
},
187178
} as const;
188179

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,38 @@
1-
import type { Program, Type } from "@typespec/compiler";
2-
import { useStateSet } from "@typespec/compiler/utils";
3-
import { GraphQLKeys } from "../lib.js";
1+
import type {
2+
DecoratedType,
3+
DecoratorContext,
4+
DecoratorFunction,
5+
Model,
6+
ModelProperty,
7+
Operation,
8+
Type,
9+
Union,
10+
} from "@typespec/compiler";
11+
import { NAMESPACE } from "../lib.js";
412

5-
const [getNullableState, setNullableState] = useStateSet<Type>(GraphQLKeys.nullable);
13+
// This will set the namespace for decorators implemented in this file
14+
export const namespace = NAMESPACE;
15+
16+
/**
17+
* Decorator implementation for `@nullable`.
18+
*
19+
* No-op — the decorator's presence on the type's `decorators` array is the
20+
* signal. No additional state storage is needed.
21+
*/
22+
export const $nullable: DecoratorFunction = (
23+
_context: DecoratorContext,
24+
_target: ModelProperty | Operation | Union | Model,
25+
) => {};
26+
27+
/**
28+
* Decorator implementation for `@nullableElements`.
29+
*
30+
* No-op — presence on the decorators array is the signal.
31+
*/
32+
export const $nullableElements: DecoratorFunction = (
33+
_context: DecoratorContext,
34+
_target: ModelProperty | Operation,
35+
) => {};
636

737
/**
838
* Check whether a type was marked nullable after null-variant stripping.
@@ -12,30 +42,39 @@ const [getNullableState, setNullableState] = useStateSet<Type>(GraphQLKeys.nulla
1242
* - **Operation**: return type `T | null`
1343
* - **Union**: named unions like `Cat | Dog | null` (safe — new unique object)
1444
*/
15-
export function isNullable(program: Program, type: Type): boolean {
16-
return getNullableState(program, type);
45+
export function isNullable(type: Type): boolean {
46+
if (!isDecoratedType(type)) return false;
47+
return type.decorators.some((d) => d.decorator === $nullable);
1748
}
1849

19-
/** Mark a type, property, or operation as nullable. */
20-
export function setNullable(program: Program, type: Type): void {
21-
setNullableState(program, type);
50+
/**
51+
* Mark a type, property, or operation as nullable.
52+
* Called by the mutation engine when null variants are stripped.
53+
*/
54+
export function setNullable(type: Type): void {
55+
if (!isDecoratedType(type)) return;
56+
if (type.decorators.some((d) => d.decorator === $nullable)) return;
57+
type.decorators.push({ decorator: $nullable, args: [] });
2258
}
2359

24-
const [getNullableElementsState, setNullableElementsState] = useStateSet<Type>(
25-
GraphQLKeys.nullableElements,
26-
);
27-
2860
/**
2961
* Check whether a property's array elements were originally `T | null`.
3062
*
3163
* For `(string | null)[]`, marks the ModelProperty so components emit
3264
* `[String]` instead of `[String!]`.
3365
*/
34-
export function hasNullableElements(program: Program, type: Type): boolean {
35-
return getNullableElementsState(program, type);
66+
export function hasNullableElements(type: Type): boolean {
67+
if (!isDecoratedType(type)) return false;
68+
return type.decorators.some((d) => d.decorator === $nullableElements);
3669
}
3770

3871
/** Mark a property as having nullable array elements. */
39-
export function setNullableElements(program: Program, type: Type): void {
40-
setNullableElementsState(program, type);
72+
export function setNullableElements(type: Type): void {
73+
if (!isDecoratedType(type)) return;
74+
if (type.decorators.some((d) => d.decorator === $nullableElements)) return;
75+
type.decorators.push({ decorator: $nullableElements, args: [] });
76+
}
77+
78+
function isDecoratedType(type: Type): type is Type & DecoratedType {
79+
return "decorators" in type;
4180
}

packages/graphql/src/lib/one-of.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
1-
import type { Model, Program } from "@typespec/compiler";
2-
import { useStateSet } from "@typespec/compiler/utils";
3-
import { GraphQLKeys } from "../lib.js";
1+
import type { DecoratorContext, DecoratorFunction, Model } from "@typespec/compiler";
2+
import { NAMESPACE } from "../lib.js";
43

5-
const [getOneOfState, setOneOfState] = useStateSet<Model>(GraphQLKeys.oneOf);
4+
// This will set the namespace for decorators implemented in this file
5+
export const namespace = NAMESPACE;
6+
7+
/**
8+
* Decorator implementation for `@oneOf`.
9+
*
10+
* No-op — the decorator's presence on the type's `decorators` array is the
11+
* signal. No additional state storage is needed.
12+
*/
13+
export const $oneOf: DecoratorFunction = (
14+
_context: DecoratorContext,
15+
_target: Model,
16+
) => {};
617

718
/**
819
* Check if a model has been marked as a @oneOf input object.
920
* These are synthetic models created by the union mutation when a union
1021
* is used in input context — GraphQL unions are output-only, so input
1122
* unions become @oneOf input objects.
1223
*/
13-
export function isOneOf(program: Program, model: Model): boolean {
14-
return getOneOfState(program, model);
24+
export function isOneOf(model: Model): boolean {
25+
return model.decorators.some((d) => d.decorator === $oneOf);
1526
}
1627

1728
/**
1829
* Mark a model as a @oneOf input object.
1930
*/
20-
export function setOneOf(program: Program, model: Model): void {
21-
setOneOfState(program, model);
31+
export function setOneOf(model: Model): void {
32+
if (model.decorators.some((d) => d.decorator === $oneOf)) return;
33+
model.decorators.push({ decorator: $oneOf, args: [] });
2234
}

packages/graphql/src/mutation-engine/mutations/model-property.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,10 @@ export class GraphQLModelPropertyMutation extends SimpleModelPropertyMutation<Si
5252
super.mutate();
5353

5454
if (isInlineNullable) {
55-
setNullable(this.engine.$.program, this.mutatedType);
55+
setNullable(this.mutatedType);
5656
}
5757
if (isArrayWithNullableElements) {
58-
setNullableElements(this.engine.$.program, this.mutatedType);
58+
setNullableElements(this.mutatedType);
5959
}
6060
}
6161
}

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

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
import type { MemberType, Operation } from "@typespec/compiler";
1+
import { isArrayModelType, type MemberType, type Operation } from "@typespec/compiler";
22
import {
33
SimpleOperationMutation,
44
type MutationInfo,
55
type SimpleMutationEngine,
66
type SimpleMutationOptions,
77
type SimpleMutations,
88
} from "@typespec/mutator-framework";
9-
import { setNullable } from "../../lib/nullable.js";
10-
import { isNullableUnion, sanitizeNameForGraphQL } from "../../lib/type-utils.js";
9+
import { setNullable, setNullableElements } from "../../lib/nullable.js";
10+
import {
11+
isNullableUnion,
12+
sanitizeNameForGraphQL,
13+
unwrapNullableUnion,
14+
} from "../../lib/type-utils.js";
1115
import { GraphQLMutationOptions, GraphQLTypeContext } from "../options.js";
1216

1317
/** GraphQL-specific Operation mutation. */
@@ -44,15 +48,29 @@ export class GraphQLOperationMutation extends SimpleOperationMutation<SimpleMuta
4448

4549
mutate() {
4650
// Snapshot return-type nullability before mutation replaces it.
47-
const hasNullableReturn = isNullableUnion(this.sourceType.returnType);
51+
const returnType = this.sourceType.returnType;
52+
const hasNullableReturn = isNullableUnion(returnType);
53+
54+
// For element nullability, look through an outer `| null` wrapper to find the array.
55+
// e.g. `(string | null)[] | null` → unwrap outer null → check array elements.
56+
const innerReturnType =
57+
returnType.kind === "Union" ? (unwrapNullableUnion(returnType) ?? returnType) : returnType;
58+
59+
const hasNullableElements =
60+
innerReturnType.kind === "Model" &&
61+
isArrayModelType(innerReturnType) &&
62+
isNullableUnion(innerReturnType.indexer.value);
4863

4964
this.mutationNode.mutate((operation) => {
5065
operation.name = sanitizeNameForGraphQL(operation.name);
5166
});
5267
super.mutate();
5368

5469
if (hasNullableReturn) {
55-
setNullable(this.engine.$.program, this.mutatedType);
70+
setNullable(this.mutatedType);
71+
}
72+
if (hasNullableElements) {
73+
setNullableElements(this.mutatedType);
5674
}
5775
}
5876
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export class GraphQLUnionMutation extends UnionMutation<MutationOptions, any, Mu
142142
}
143143

144144
if (hasNull) {
145-
setNullable(program, this.mutatedType);
145+
setNullable(this.mutatedType);
146146
}
147147

148148
// GraphQL unions can only contain object types — wrap scalars in synthetic models
@@ -207,10 +207,10 @@ export class GraphQLUnionMutation extends UnionMutation<MutationOptions, any, Mu
207207
properties,
208208
});
209209

210-
setOneOf(program, oneOfModel);
210+
setOneOf(oneOfModel);
211211

212212
if (hasNull) {
213-
setNullable(program, oneOfModel);
213+
setNullable(oneOfModel);
214214
}
215215

216216
this.#mutationNode.replace(oneOfModel);

0 commit comments

Comments
 (0)