diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 23e32a7590c..aa3715047ac 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -30,15 +30,16 @@ "node": ">=20.0.0" }, "dependencies": { - "@alloy-js/core": "^0.11.0", - "@alloy-js/typescript": "^0.11.0", + "@alloy-js/core": "^0.22.0", + "@alloy-js/graphql": "^0.1.0", + "@alloy-js/typescript": "^0.22.0", "change-case": "^5.4.4", "graphql": "^16.9.0" }, "scripts": { "clean": "rimraf ./dist ./temp", - "build": "tsc -p .", - "watch": "tsc --watch", + "build": "alloy build", + "watch": "alloy build --watch", "test": "vitest run", "test:watch": "vitest -w", "lint": "eslint . --max-warnings=0", @@ -56,6 +57,8 @@ "@typespec/mutator-framework": "workspace:~" }, "devDependencies": { + "@alloy-js/cli": "^0.22.0", + "@alloy-js/rollup-plugin": "^0.1.0", "@types/node": "~22.13.13", "@typespec/compiler": "workspace:~", "@typespec/emitter-framework": "workspace:~", diff --git a/packages/graphql/src/components/fields/field.tsx b/packages/graphql/src/components/fields/field.tsx new file mode 100644 index 00000000000..ed5c9d0e5ac --- /dev/null +++ b/packages/graphql/src/components/fields/field.tsx @@ -0,0 +1,76 @@ +import { type ModelProperty, getDoc, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { GraphQLTypeResolutionContext } from "../../context/index.js"; +import { isNullable, hasNullableElements } from "../../lib/nullable.js"; +import { GraphQLTypeExpression } from "./type-expression.js"; + +export interface FieldProps { + /** The model property to render as a field */ + property: ModelProperty; + /** Whether this field is in an input type context */ + isInput: boolean; +} + +/** + * Renders a GraphQL field (property on a type or input type) + * + * Automatically handles: + * - Description from doc comments + * - Type resolution with input/output awareness + * - Nullability based on optional flag and nullable unions + * - Array types using Field.List + * - Directives like @deprecated + * + * Uses gql.Field for output fields and gql.InputField for input fields + */ +export function Field(props: FieldProps) { + const { program } = useTsp(); + const mode = props.isInput ? "input" : "output"; + + const doc = getDoc(program, props.property); + const deprecation = getDeprecationDetails(program, props.property); + + return ( + + + {(typeInfo) => { + if (props.isInput) { + return ( + + {typeInfo.isList ? ( + + ) : undefined} + + ); + } + + return ( + + {typeInfo.isList ? ( + + ) : undefined} + + ); + }} + + + ); +} diff --git a/packages/graphql/src/components/fields/index.ts b/packages/graphql/src/components/fields/index.ts new file mode 100644 index 00000000000..8eb36ee9c42 --- /dev/null +++ b/packages/graphql/src/components/fields/index.ts @@ -0,0 +1,7 @@ +export { Field, type FieldProps } from "./field.js"; +export { OperationField, type OperationFieldProps } from "./operation-field.js"; +export { + GraphQLTypeExpression, + type GraphQLTypeExpressionProps, + type GraphQLTypeInfo, +} from "./type-expression.js"; diff --git a/packages/graphql/src/components/fields/operation-field.tsx b/packages/graphql/src/components/fields/operation-field.tsx new file mode 100644 index 00000000000..490d32376a7 --- /dev/null +++ b/packages/graphql/src/components/fields/operation-field.tsx @@ -0,0 +1,91 @@ +import { type Operation, getDoc, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { GraphQLTypeResolutionContext } from "../../context/index.js"; +import { isNullable, hasNullableElements } from "../../lib/nullable.js"; +import { GraphQLTypeExpression } from "./type-expression.js"; + +export interface OperationFieldProps { + /** The operation to render as a field */ + operation: Operation; +} + +/** + * Renders an operation as a field with arguments. + * + * Used for @operationFields decorator where operations become + * fields on a type with parameters as arguments. + * + * Nullability for parameters is tracked by the mutation engine on each + * mutated ModelProperty (see nullable.ts). This component queries the + * state maps and passes the results to GraphQLTypeExpression. + */ +export function OperationField(props: OperationFieldProps) { + const { program } = useTsp(); + const params = Array.from(props.operation.parameters.properties.values()); + const doc = getDoc(program, props.operation); + const deprecation = getDeprecationDetails(program, props.operation); + + return ( + + + {(returnTypeInfo) => ( + + {returnTypeInfo.isList ? ( + + ) : undefined} + + {params.map((param) => ( + + {(paramTypeInfo) => ( + + {paramTypeInfo.isList ? ( + + ) : undefined} + + )} + + ))} + + + )} + + + ); +} diff --git a/packages/graphql/src/components/fields/type-expression.tsx b/packages/graphql/src/components/fields/type-expression.tsx new file mode 100644 index 00000000000..81c1233ba1d --- /dev/null +++ b/packages/graphql/src/components/fields/type-expression.tsx @@ -0,0 +1,208 @@ +import { + type Type, + type Scalar, + type ModelProperty, + getEncode, + isUnknownType, +} from "@typespec/compiler"; +import { type Children, useContext } from "@alloy-js/core"; +import { useTsp } from "@typespec/emitter-framework"; +import { GraphQLTypeResolutionContext, useGraphQLSchema } from "../../context/index.js"; +import { isNullable } from "../../lib/nullable.js"; +import { unwrapNullableUnion, getUnionName } from "../../lib/type-utils.js"; +import { getGraphQLBuiltinName, getScalarMapping } from "../../lib/scalar-mappings.js"; + +/** + * Information about a resolved GraphQL type + */ +export interface GraphQLTypeInfo { + /** The base type name (without wrappers) */ + typeName: string; + /** Whether this is a list type */ + isList: boolean; + /** Whether the field itself is non-null */ + isNonNull: boolean; + /** Whether list items are non-null (only meaningful if isList is true) */ + itemNonNull: boolean; +} + +export interface GraphQLTypeExpressionProps { + type: Type; + isOptional: boolean; + /** Whether this type was marked nullable (from property-level tracking) */ + isNullable?: boolean; + /** Whether this property's array elements were originally T | null (from property-level tracking) */ + hasNullableElements?: boolean; + /** The property or parameter that contains the type (for @encode checking) */ + targetType?: Type; + key?: string; + children: (typeInfo: GraphQLTypeInfo) => Children; +} + +/** + * Resolves a TypeSpec type to GraphQL type information. + * + * Follows the emitter framework's TypeExpression component pattern: a single + * component that encapsulates type resolution logic, using context hooks for + * program/typekit access and Typekit predicates for type narrowing. + * + * Uses render props (children function) because GraphQL SDL rendering components + * (`gql.Field`, `gql.InputField`) consume structured data, not raw type strings. + * + * Nullability comes from two sources: + * 1. Property-level: `isNullable` prop, set by the Field component when the + * mutation engine marked the property as nullable (for inline T | null unions). + * 2. Type-level: `isNullable(program, type)` state map check, used for named + * multi-variant unions (Cat | Dog | null) where the engine creates a new + * unique union object. + */ +export function GraphQLTypeExpression(props: GraphQLTypeExpressionProps) { + const { $, program } = useTsp(); + const { modelVariants } = useGraphQLSchema(); + const resolutionContext = useContext(GraphQLTypeResolutionContext); + const mode = resolutionContext?.mode ?? "output"; + + // Nullability from property-level tracking (inline T | null) or + // type-level state map (named multi-variant unions with null stripped). + // See nullable.ts for the full architectural explanation. + const nullable = props.isNullable || isNullable(program, props.type); + + // GraphQL non-null rules: + // - Output fields: non-null unless optional (?) or nullable (| null) + // - Input fields: always non-null unless nullable (| null); optionality is + // expressed via default values, not nullability + const isNonNull = nullable ? false : mode === "input" || !props.isOptional; + + // Unwrap inline T | null unions that haven't been processed by the mutation engine. + // This handles cases where the type reaches us still as a union (e.g., array elements + // like Array, or operation parameters). + if ($.union.is(props.type)) { + const innerType = unwrapNullableUnion(props.type); + if (innerType) { + return ( + + {(innerInfo) => + props.children({ + ...innerInfo, + isNonNull: false, + }) + } + + ); + } + } + + // Arrays — recurse for element type + if ($.array.is(props.type)) { + const elementType = $.array.getElementType(props.type); + // Check if the element type is nullable from three sources: + // 1. Property-level: hasNullableElements prop (mutation engine detected Array + // and marked the property before the union was replaced with the inner type) + // 2. Type-level: isNullable state map on the element type itself + // 3. Inline union: element type is still a T | null union (not yet processed) + const elementIsNullable = + props.hasNullableElements || + isNullable(program, elementType) || + ($.union.is(elementType) && unwrapNullableUnion(elementType) !== undefined); + + return ( + + {(elementInfo) => + props.children({ + typeName: elementInfo.typeName, + isList: true, + isNonNull, + itemNonNull: !elementIsNullable, + }) + } + + ); + } + + // Resolve base type name + const typeName = resolveBaseTypeName(); + + return props.children({ + typeName, + isList: false, + isNonNull, + itemNonNull: false, + }); + + /** + * Resolve the base type name using Typekit predicates for type narrowing. + */ + function resolveBaseTypeName(): string { + const type = props.type; + + // Intrinsic unknown + if (isUnknownType(type)) { + return "Unknown"; + } + + // Scalars + if ($.scalar.is(type)) { + const builtinName = getGraphQLBuiltinName(program, type); + if (builtinName) return builtinName; + + // Standard library scalars with encoding-specific mappings + if (program.checker.isStdType(type)) { + if ( + props.targetType && + ($.scalar.is(props.targetType) || props.targetType.kind === "ModelProperty") + ) { + const encodeData = getEncode( + program, + props.targetType as Scalar | ModelProperty, + ); + const mapping = getScalarMapping(program, type, encodeData?.encoding); + if (mapping) return mapping.graphqlName; + } + + const mapping = getScalarMapping(program, type); + if (mapping) return mapping.graphqlName; + } + + return type.name; + } + + // Models — check whether "Input" suffix is needed + if ($.model.is(type)) { + // Name-based lookup: modelVariants maps are keyed by name because both + // input and output variants share the same source model identity — the + // distinction is purely nominal at the GraphQL output level. + const hasOutputVariant = modelVariants.outputModels.has(type.name); + const hasInputVariant = modelVariants.inputModels.has(type.name); + + if (mode === "input" && hasOutputVariant && hasInputVariant) { + return `${type.name}Input`; + } + return type.name; + } + + // Enums + if ($.enum.is(type)) { + return type.name; + } + + // Unions + if ($.union.is(type)) { + return getUnionName(type, program); + } + + throw new Error( + `Unexpected type kind "${type.kind}" in resolveBaseTypeName. ` + + `This is a bug in the GraphQL emitter.`, + ); + } +} diff --git a/packages/graphql/src/context/graphql-schema-context.tsx b/packages/graphql/src/context/graphql-schema-context.tsx new file mode 100644 index 00000000000..db817783a95 --- /dev/null +++ b/packages/graphql/src/context/graphql-schema-context.tsx @@ -0,0 +1,99 @@ +import { type ComponentContext, createNamedContext, useContext } from "@alloy-js/core"; +import { + type Model, + type Enum, + type IntrinsicType, + type Scalar, + type Union, + type Operation, +} from "@typespec/compiler"; + +/** + * Classified types separated by category for schema generation + */ +export interface ClassifiedTypes { + /** Interface types marked with @Interface */ + interfaces: Model[]; + /** Models used as output types (return values) */ + outputModels: Model[]; + /** Models used as input types (parameters) */ + inputModels: Model[]; + /** Enum types */ + enums: Enum[]; + /** Custom scalar types */ + scalars: Scalar[]; + /** Scalar variants for encoded scalars (e.g., bytes + base64 → Bytes) */ + scalarVariants: ScalarVariant[]; + /** Union types */ + unions: Union[]; + /** Query operations */ + queries: Operation[]; + /** Mutation operations */ + mutations: Operation[]; + /** Subscription operations */ + subscriptions: Operation[]; +} + +/** + * Model variant lookups for quick checking whether output and/or input variants exist. + * Used to determine when to append "Input" suffix during type resolution. + */ +export interface ModelVariants { + /** Output model variants indexed by name */ + outputModels: Map; + /** Input model variants indexed by name */ + inputModels: Map; +} + +/** + * Scalar variant information for encoded scalars. + * When a scalar has @encode, we emit it as a different GraphQL scalar (e.g., bytes + base64 → Bytes) + */ +export interface ScalarVariant { + /** The original TypeSpec scalar type, or IntrinsicType for `unknown` */ + sourceScalar: Scalar | IntrinsicType; + /** The encoding used (e.g., "base64", "rfc3339") */ + encoding: string; + /** The GraphQL scalar name to emit (e.g., "Bytes", "UTCDateTime") */ + graphqlName: string; + /** Optional specification URL for @specifiedBy directive */ + specificationUrl?: string; +} + +/** + * Context value containing GraphQL-specific schema information. + * + * For access to the TypeSpec program and typekit, use `useTsp()` from + * `@typespec/emitter-framework` instead. + */ +export interface GraphQLSchemaContextValue { + /** Classified types for schema generation */ + classifiedTypes: ClassifiedTypes; + /** Model variant lookups for input/output type resolution */ + modelVariants: ModelVariants; + /** Scalar specification URLs for @specifiedBy directives */ + scalarSpecifications: Map; +} + +/** + * Context provider for GraphQL schema generation + */ +export const GraphQLSchemaContext: ComponentContext = + createNamedContext("GraphQLSchema"); + +/** + * Hook to access GraphQL schema context + * @returns The GraphQL schema context value + * @throws Error if used outside of GraphQLSchemaContext.Provider + */ +export function useGraphQLSchema(): GraphQLSchemaContextValue { + const context = useContext(GraphQLSchemaContext); + + if (!context) { + throw new Error( + "useGraphQLSchema must be used within GraphQLSchemaContext.Provider." + ); + } + + return context; +} diff --git a/packages/graphql/src/context/index.ts b/packages/graphql/src/context/index.ts new file mode 100644 index 00000000000..3b7c98f37f8 --- /dev/null +++ b/packages/graphql/src/context/index.ts @@ -0,0 +1,14 @@ +export { + GraphQLSchemaContext, + useGraphQLSchema, + type GraphQLSchemaContextValue, + type ClassifiedTypes, + type ModelVariants, + type ScalarVariant, +} from "./graphql-schema-context.js"; + +export { + GraphQLTypeResolutionContext, + type TypeResolutionMode, + type GraphQLTypeResolutionContextValue, +} from "./type-resolution-context.js"; diff --git a/packages/graphql/src/context/type-resolution-context.tsx b/packages/graphql/src/context/type-resolution-context.tsx new file mode 100644 index 00000000000..9a7b4819504 --- /dev/null +++ b/packages/graphql/src/context/type-resolution-context.tsx @@ -0,0 +1,20 @@ +import { type ComponentContext, createNamedContext } from "@alloy-js/core"; + +/** + * Type resolution mode for context-aware type name resolution + */ +export type TypeResolutionMode = "input" | "output"; + +/** + * Context value for type resolution + */ +export interface GraphQLTypeResolutionContextValue { + /** Whether we're resolving types in input or output context */ + mode: TypeResolutionMode; +} + +/** + * Context provider for type resolution mode + */ +export const GraphQLTypeResolutionContext: ComponentContext = + createNamedContext("TypeResolution"); diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts index ac79ad37859..5c94258c5da 100644 --- a/packages/graphql/src/lib.ts +++ b/packages/graphql/src/lib.ts @@ -177,7 +177,11 @@ export const libDef = { oneOf: { description: "State for tracking @oneOf input objects created from input unions." }, nullable: { description: - "State for tracking types that were determined to be nullable from null-variant stripping.", + "State for tracking types and properties marked nullable after null-variant stripping by the mutation engine.", + }, + nullableElements: { + description: + "State for tracking properties whose array element type was originally T | null before mutation.", }, }, } as const; diff --git a/packages/graphql/src/lib/nullable.ts b/packages/graphql/src/lib/nullable.ts index 65425108390..2c914b15e54 100644 --- a/packages/graphql/src/lib/nullable.ts +++ b/packages/graphql/src/lib/nullable.ts @@ -1,3 +1,49 @@ +/** + * Nullable tracking for the GraphQL mutation pipeline. + * + * ## Why state maps? + * + * The GraphQL mutation engine strips `null` variants from unions before the + * component layer renders SDL. By the time components see a type, the + * structural evidence of nullability is gone — a `string | null` property's + * type has already been replaced with the bare `string` scalar. + * + * State maps bridge this gap: the mutation engine records nullability facts + * during transformation, and components query them at render time. + * + * (This differs from the C# emitter, which has no mutation engine and detects + * `T | null` unions structurally at render time. That approach doesn't work + * here because our mutation engine rewrites the type graph first.) + * + * ## Two tracking dimensions + * + * **Field nullability** (`isNullable`): The field/property itself is nullable. + * + * Marked on different targets depending on the source: + * - *Property-level*: For inline `T | null` (e.g., `bio: string | null`), + * the union is replaced with the shared scalar singleton. Marking the + * singleton would poison every use of that scalar, so we mark the + * **ModelProperty** instead. Set by `GraphQLModelPropertyMutation`. + * - *Operation-level*: For `op getUser(): User | null`, the return type + * union is replaced with the inner type. We mark the **Operation** + * itself. Set by `GraphQLOperationMutation`. + * - *Type-level*: For named multi-variant unions with null (e.g., + * `union Pet { Cat, Dog, null }`), the engine creates a new unique union + * object without the null variant. This new object is safe to mark + * directly. Set by `GraphQLUnionMutation`. + * + * Components call `isNullable(program, property)`, + * `isNullable(program, operation)`, or `isNullable(program, type)` — + * all use the same state set. + * + * **Element nullability** (`hasNullableElements`): Array elements are nullable. + * + * For `tags: (string | null)[]`, the mutation engine replaces the element + * union with the inner scalar but the array itself is non-null. We mark the + * **ModelProperty** so the component layer can emit `[String]` (nullable + * elements) instead of `[String!]`. Set by `GraphQLModelPropertyMutation`. + */ + import type { Program, Type } from "@typespec/compiler"; import { useStateSet } from "@typespec/compiler/utils"; import { GraphQLKeys } from "../lib.js"; @@ -5,17 +51,47 @@ import { GraphQLKeys } from "../lib.js"; const [getNullableState, setNullableState] = useStateSet(GraphQLKeys.nullable); /** - * Check if a type has been marked as nullable due to null-variant stripping. - * For example, `Cat | Dog | null` becomes `union CatDog` marked as nullable. + * Check whether a type or property was marked nullable by the mutation engine. + * + * Works on both type-level targets (named unions with null stripped) and + * property-level targets (inline `T | null` on a ModelProperty). */ export function isNullable(program: Program, type: Type): boolean { return getNullableState(program, type); } /** - * Mark a type as nullable. Called by the mutation engine when null variants - * are stripped from a union during processing. + * Mark a type or property as nullable. Called by the mutation engine when + * null variants are stripped during processing. + * + * @see {@link GraphQLModelPropertyMutation} — marks ModelProperty for inline `T | null` + * @see {@link GraphQLOperationMutation} — marks Operation for `op foo(): T | null` + * @see {@link GraphQLUnionMutation} — marks the new union for named `Cat | Dog | null` */ export function setNullable(program: Program, type: Type): void { setNullableState(program, type); } + +const [getNullableElementsState, setNullableElementsState] = useStateSet( + GraphQLKeys.nullableElements, +); + +/** + * Check whether a property's array elements were originally `T | null`. + * + * For `tags: (string | null)[]`, the mutation engine replaces the element + * type with the bare scalar, but the component layer still needs to know + * that elements should be emitted without `!` (e.g., `[String]` not `[String!]`). + */ +export function hasNullableElements(program: Program, type: Type): boolean { + return getNullableElementsState(program, type); +} + +/** + * Mark a property as having nullable array elements. + * + * @see {@link GraphQLModelPropertyMutation} — detects `Array` pattern + */ +export function setNullableElements(program: Program, type: Type): void { + setNullableElementsState(program, type); +} diff --git a/packages/graphql/src/lib/scalar-mappings.ts b/packages/graphql/src/lib/scalar-mappings.ts index 20cdf846536..0f444cb5d80 100644 --- a/packages/graphql/src/lib/scalar-mappings.ts +++ b/packages/graphql/src/lib/scalar-mappings.ts @@ -158,17 +158,38 @@ export function isStdScalar(tk: Typekit, scalar: Scalar): boolean { } /** - * TypeSpec std scalar names that map directly to GraphQL built-in scalar types: - * string → String, boolean → Boolean, int32 → Int, float32/float64 → Float. + * TypeSpec std scalars that map directly to GraphQL built-in scalar types. * * These must NOT be renamed by the scalar mutation — they're resolved to * GraphQL builtins at emit time. * * @see https://spec.graphql.org/September2025/#sec-Scalars.Built-in-Scalars */ -const TSP_SCALARS_TO_GQL_BUILTINS: IntrinsicScalarName[] = [ - "string", "boolean", "int32", "float32", "float64", -]; +// Note: GraphQL's `ID` built-in is not mapped here — no TypeSpec scalar maps +// to `ID` by default. It requires an explicit decorator (e.g., @id). +const GQL_BUILTIN_SCALARS: Partial> = { + string: "String", + boolean: "Boolean", + int32: "Int", + float32: "Float", + float64: "Float", +}; + +const TSP_SCALARS_TO_GQL_BUILTINS = Object.keys(GQL_BUILTIN_SCALARS) as IntrinsicScalarName[]; + +/** + * Get the GraphQL built-in scalar name for a TypeSpec scalar, if it is one. + * Uses identity-based check via typekit to avoid false positives from + * user-defined scalars that share the same name in a different namespace. + * Returns undefined for non-builtin scalars. + */ +export function getGraphQLBuiltinName(program: Program, scalar: Scalar): string | undefined { + const checker = program.checker; + for (const [name, gqlName] of Object.entries(GQL_BUILTIN_SCALARS)) { + if (checker.isStdType(scalar, name as IntrinsicScalarName)) return gqlName; + } + return undefined; +} /** * Get the GraphQL scalar mapping for a scalar via its standard library ancestor. diff --git a/packages/graphql/src/mutation-engine/mutations/model-property.ts b/packages/graphql/src/mutation-engine/mutations/model-property.ts index 709d7247972..75f4d390076 100644 --- a/packages/graphql/src/mutation-engine/mutations/model-property.ts +++ b/packages/graphql/src/mutation-engine/mutations/model-property.ts @@ -6,7 +6,8 @@ import { type SimpleMutationOptions, type SimpleMutations, } from "@typespec/mutator-framework"; -import { sanitizeNameForGraphQL } from "../../lib/type-utils.js"; +import { setNullable, setNullableElements } from "../../lib/nullable.js"; +import { isArray, unwrapNullableUnion, sanitizeNameForGraphQL } from "../../lib/type-utils.js"; /** GraphQL-specific ModelProperty mutation. */ export class GraphQLModelPropertyMutation extends SimpleModelPropertyMutation { @@ -29,8 +30,43 @@ export class GraphQLModelPropertyMutation extends SimpleModelPropertyMutation (e.g., `tags: (string | null)[]`) + // The array's element type (indexer value) is a T | null union. + const isArrayWithNullableElements = + originalType.kind === "Model" && + isArray(originalType) && + originalType.indexer.value.kind === "Union" && + unwrapNullableUnion(originalType.indexer.value) !== undefined; + + // Trigger mutation (whenMutated callback sanitizes the name) this.mutationNode.mutate(); super.mutate(); + + // Mark the mutated *property* (not the type) as nullable. + // The component layer checks isNullable(program, property) to determine + // whether a field should omit the ! (non-null) wrapper. + if (isInlineNullable) { + setNullable(this.engine.$.program, this.mutatedType); + } + + // Mark the property as having nullable array elements. + // The component layer checks hasNullableElements(program, property) to + // emit [String] instead of [String!]. + if (isArrayWithNullableElements) { + setNullableElements(this.engine.$.program, this.mutatedType); + } } } diff --git a/packages/graphql/src/mutation-engine/mutations/operation.ts b/packages/graphql/src/mutation-engine/mutations/operation.ts index 21d973703f8..37ab954dcb7 100644 --- a/packages/graphql/src/mutation-engine/mutations/operation.ts +++ b/packages/graphql/src/mutation-engine/mutations/operation.ts @@ -6,7 +6,8 @@ import { type SimpleMutationOptions, type SimpleMutations, } from "@typespec/mutator-framework"; -import { sanitizeNameForGraphQL } from "../../lib/type-utils.js"; +import { setNullable } from "../../lib/nullable.js"; +import { unwrapNullableUnion, sanitizeNameForGraphQL } from "../../lib/type-utils.js"; import { GraphQLMutationOptions, GraphQLTypeContext } from "../options.js"; /** GraphQL-specific Operation mutation. */ @@ -48,10 +49,25 @@ export class GraphQLOperationMutation extends SimpleOperationMutation { operation.name = sanitizeNameForGraphQL(operation.name); }); super.mutate(); + + // Mark the mutated operation as having a nullable return type. + // The OperationField component checks isNullable(program, operation) + // to determine whether the return field should omit the ! wrapper. + if (hasNullableReturn) { + setNullable(this.engine.$.program, this.mutatedType); + } } } diff --git a/packages/graphql/src/mutation-engine/mutations/union.ts b/packages/graphql/src/mutation-engine/mutations/union.ts index d1833b78c91..47cbfa34033 100644 --- a/packages/graphql/src/mutation-engine/mutations/union.ts +++ b/packages/graphql/src/mutation-engine/mutations/union.ts @@ -112,7 +112,16 @@ export class GraphQLUnionMutation extends UnionMutation { expect(mutation.mutatedType.name).toBe("get_data"); }); + + it("marks operation as nullable when return type is T | null", async () => { + const { getUser } = await tester.compile( + t.code` + model ${t.model("User")} { name: string; } + op ${t.op("getUser")}(): User | null; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(getUser); + + // The return type should be unwrapped to the inner type + expect(mutation.mutatedType.returnType.kind).toBe("Model"); + // The operation itself should be marked nullable + expect(isNullable(tester.program, mutation.mutatedType)).toBe(true); + }); + + it("does not mark operation as nullable when return type is non-null", async () => { + const { getUser } = await tester.compile( + t.code` + model ${t.model("User")} { name: string; } + op ${t.op("getUser")}(): User; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(getUser); + + expect(mutation.mutatedType.returnType.kind).toBe("Model"); + expect(isNullable(tester.program, mutation.mutatedType)).toBe(false); + }); }); describe("GraphQL Mutation Engine - Scalars", () => { @@ -499,7 +531,9 @@ describe("GraphQL Mutation Engine - Unions", () => { // T | null is replaced with the inner type (string scalar) expect(mutation.mutatedType.kind).toBe("Scalar"); expect(mutation.wrapperModels).toHaveLength(0); - expect(isNullable(tester.program, mutation.mutatedType)).toBe(true); + // The replacement type is NOT marked nullable — nullability for inline T | null + // is tracked on the model property, not the shared scalar singleton. + expect(isNullable(tester.program, mutation.mutatedType)).toBe(false); }); it("replaces nullable model union with inner type", async () => { @@ -516,7 +550,9 @@ describe("GraphQL Mutation Engine - Unions", () => { // Dog | null is replaced with the inner type (Dog model) expect(mutation.mutatedType.kind).toBe("Model"); expect(mutation.wrapperModels).toHaveLength(0); - expect(isNullable(tester.program, mutation.mutatedType)).toBe(true); + // The replacement type is NOT marked nullable — nullability for inline T | null + // is tracked on the model property, not the shared type. + expect(isNullable(tester.program, mutation.mutatedType)).toBe(false); }); it("creates wrapper models for scalar variants", async () => { @@ -596,7 +632,7 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(mutation.mutatedType.name).toBe("ValidUnion"); }); - it("strips T | null on model property to inner type and marks nullable", async () => { + it("strips T | null on model property to inner type and marks property nullable", async () => { const { Foo } = await tester.compile( t.code`model ${t.model("Foo")} { name: string | null; }`, ); @@ -609,8 +645,10 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(nameProp).toBeDefined(); expect(nameProp!.type.kind).toBe("Scalar"); - // The inner type should be marked as nullable - expect(isNullable(tester.program, nameProp!.type)).toBe(true); + // Nullability is tracked on the property, not the inner type. + // The shared scalar singleton must NOT be marked nullable (would poison all uses). + expect(isNullable(tester.program, nameProp!.type)).toBe(false); + expect(isNullable(tester.program, nameProp!)).toBe(true); }); }); diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json index ad68b784463..d2f18d1ce33 100644 --- a/packages/graphql/tsconfig.json +++ b/packages/graphql/tsconfig.json @@ -1,10 +1,18 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "useDefineForClassFields": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "es2022", + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "jsx": "preserve", + "jsxImportSource": "@alloy-js/core", + "emitDeclarationOnly": true, "rootDir": ".", - "outDir": "dist", - "verbatimModuleSyntax": true + "outDir": "dist" }, - "include": ["src", "test"] + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/graphql/vitest.config.ts b/packages/graphql/vitest.config.ts index 63cad767f57..b45007f3475 100644 --- a/packages/graphql/vitest.config.ts +++ b/packages/graphql/vitest.config.ts @@ -1,4 +1,14 @@ +import alloyPlugin from "@alloy-js/rollup-plugin"; import { defineConfig, mergeConfig } from "vitest/config"; import { defaultTypeSpecVitestConfig } from "../../vitest.config.js"; -export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); +export default mergeConfig( + defaultTypeSpecVitestConfig, + defineConfig({ + esbuild: { + jsx: "preserve", + sourcemap: "both", + }, + plugins: [alloyPlugin()], + }), +);