From 5f26c2583eaae025bcff4aff55bbba9582f4b6ad Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 14 Apr 2026 14:14:44 -0400 Subject: [PATCH 1/5] Add Alloy infrastructure, context system, and field components Introduce the foundation for the component-based GraphQL emitter: - Build/config: Add @alloy-js/core, @alloy-js/graphql dependencies, configure JSX transpilation (tsconfig, vitest) - Context system: GraphQLSchemaContext with ClassifiedTypes, ModelVariants, and ScalarVariant interfaces - Field components: Field, OperationField, and GraphQLTypeExpression for rendering model properties and operations as GraphQL SDL - Type resolution: GraphQLTypeExpression handles scalars, models, enums, unions, arrays, and nullability using an isInput prop to distinguish input vs output context - Scalar mappings: Add getGraphQLBuiltinName() for built-in scalar identity checks --- packages/graphql/package.json | 11 +- .../graphql/src/components/fields/field.tsx | 62 +++++++ .../graphql/src/components/fields/index.ts | 7 + .../src/components/fields/operation-field.tsx | 80 ++++++++ .../src/components/fields/type-expression.tsx | 172 ++++++++++++++++++ .../src/context/graphql-schema-context.tsx | 99 ++++++++++ packages/graphql/src/context/index.ts | 8 + packages/graphql/src/lib/scalar-mappings.ts | 15 ++ packages/graphql/tsconfig.json | 16 +- packages/graphql/vitest.config.ts | 12 +- 10 files changed, 473 insertions(+), 9 deletions(-) create mode 100644 packages/graphql/src/components/fields/field.tsx create mode 100644 packages/graphql/src/components/fields/index.ts create mode 100644 packages/graphql/src/components/fields/operation-field.tsx create mode 100644 packages/graphql/src/components/fields/type-expression.tsx create mode 100644 packages/graphql/src/context/graphql-schema-context.tsx create mode 100644 packages/graphql/src/context/index.ts 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..1e98b1b58b5 --- /dev/null +++ b/packages/graphql/src/components/fields/field.tsx @@ -0,0 +1,62 @@ +import { type ModelProperty, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +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; +} + +export function Field(props: FieldProps) { + const { $, program } = useTsp(); + + const doc = $.type.getDoc(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..66d18e16102 --- /dev/null +++ b/packages/graphql/src/components/fields/operation-field.tsx @@ -0,0 +1,80 @@ +import { type Operation, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +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. + */ +export function OperationField(props: OperationFieldProps) { + const { $, program } = useTsp(); + const params = Array.from(props.operation.parameters.properties.values()); + const doc = $.type.getDoc(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..89273e49b9f --- /dev/null +++ b/packages/graphql/src/components/fields/type-expression.tsx @@ -0,0 +1,172 @@ +import { + type Type, + type Scalar, + type ModelProperty, + getEncode, + isUnknownType, +} from "@typespec/compiler"; +import { type Children } from "@alloy-js/core"; +import { useTsp } from "@typespec/emitter-framework"; +import { 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 is in an input position (operation parameter or input model field) */ + isInput: 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; + children: (typeInfo: GraphQLTypeInfo) => Children; +} + +export function GraphQLTypeExpression(props: GraphQLTypeExpressionProps) { + const { $, program } = useTsp(); + const { modelVariants } = useGraphQLSchema(); + + const nullable = props.isNullable || isNullable(props.type); + + // Input fields are non-null unless nullable; optionality is expressed via + // default values. Output fields are non-null unless optional or nullable. + const isNonNull = nullable ? false : props.isInput || !props.isOptional; + + // Unwrap T | null unions the mutation engine didn't process (e.g., array + // elements, operation parameters that arrive here still wrapped). + if ($.union.is(props.type)) { + const innerType = unwrapNullableUnion(props.type); + if (innerType) { + return ( + + {(innerInfo) => + props.children({ + ...innerInfo, + isNonNull: false, + }) + } + + ); + } + } + + if ($.array.is(props.type)) { + const elementType = $.array.getElementType(props.type); + // Element nullability: from mutation engine property-level tracking, from the + // element type's own state map, or from an inline T | null union still present. + const elementIsNullable = + props.hasNullableElements || + isNullable(elementType) || + ($.union.is(elementType) && unwrapNullableUnion(elementType) !== undefined); + + return ( + + {(elementInfo) => + props.children({ + typeName: elementInfo.typeName, + isList: true, + isNonNull, + itemNonNull: !elementIsNullable, + }) + } + + ); + } + + const typeName = resolveBaseTypeName(); + + return props.children({ + typeName, + isList: false, + isNonNull, + itemNonNull: false, + }); + + function resolveBaseTypeName(): string { + const type = props.type; + + if (isUnknownType(type)) { + return "Unknown"; + } + + if ($.scalar.is(type)) { + const builtinName = getGraphQLBuiltinName(program, type); + if (builtinName) return builtinName; + + // Std scalars with encoding-specific mappings (e.g., bytes + base64 -> Bytes) + 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; + } + + if ($.model.is(type)) { + // Both input and output variants share the same source model identity, + // so we use name-based lookup to determine if both variants exist. + const hasOutputVariant = modelVariants.outputModels.has(type.name); + const hasInputVariant = modelVariants.inputModels.has(type.name); + + if (props.isInput && hasOutputVariant && hasInputVariant) { + return `${type.name}Input`; + } + return type.name; + } + + if ($.enum.is(type)) { + return type.name; + } + + 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..b353281709c --- /dev/null +++ b/packages/graphql/src/context/index.ts @@ -0,0 +1,8 @@ +export { + GraphQLSchemaContext, + useGraphQLSchema, + type GraphQLSchemaContextValue, + type ClassifiedTypes, + type ModelVariants, + type ScalarVariant, +} from "./graphql-schema-context.js"; diff --git a/packages/graphql/src/lib/scalar-mappings.ts b/packages/graphql/src/lib/scalar-mappings.ts index 20cdf846536..5956fce34f4 100644 --- a/packages/graphql/src/lib/scalar-mappings.ts +++ b/packages/graphql/src/lib/scalar-mappings.ts @@ -170,6 +170,21 @@ const TSP_SCALARS_TO_GQL_BUILTINS: IntrinsicScalarName[] = [ "string", "boolean", "int32", "float32", "float64", ]; +/** + * Map a TypeSpec std scalar to its GraphQL built-in scalar name, if any. + * + * Returns undefined for non-builtin scalars. Uses checker.isStdType + * (name + namespace) which works on both original and mutated scalars. + */ +export function getGraphQLBuiltinName(program: Program, scalar: Scalar): string | undefined { + if (program.checker.isStdType(scalar, "string")) return "String"; + if (program.checker.isStdType(scalar, "boolean")) return "Boolean"; + if (program.checker.isStdType(scalar, "int32")) return "Int"; + if (program.checker.isStdType(scalar, "float32")) return "Float"; + if (program.checker.isStdType(scalar, "float64")) return "Float"; + return undefined; +} + /** * Get the GraphQL scalar mapping for a scalar via its standard library ancestor. * 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()], + }), +); From 157e5291e1ca9fcd241eb2861d164f68d3044736 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 1 May 2026 11:08:56 -0400 Subject: [PATCH 2/5] Remove ModelVariants - mutation engine handles input/output naming The ModelVariants lookup structure was originally designed to help components decide when to append "Input" suffix to model names. However, the mutation engine now fully handles this: - Input models are mutated with the "Input" suffix already applied - Property type references are rewired to point to the correct variants This commit removes the unused ModelVariants interface and the lookup logic from GraphQLTypeExpression, simplifying the component to just use the model's name directly. Also adds unionMembers to context (needed for union rendering). --- .../src/components/fields/type-expression.tsx | 12 ++---------- .../src/context/graphql-schema-context.tsx | 15 ++------------- packages/graphql/src/context/index.ts | 1 - 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/packages/graphql/src/components/fields/type-expression.tsx b/packages/graphql/src/components/fields/type-expression.tsx index 89273e49b9f..34c781902cd 100644 --- a/packages/graphql/src/components/fields/type-expression.tsx +++ b/packages/graphql/src/components/fields/type-expression.tsx @@ -7,7 +7,6 @@ import { } from "@typespec/compiler"; import { type Children } from "@alloy-js/core"; import { useTsp } from "@typespec/emitter-framework"; -import { 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"; @@ -42,7 +41,6 @@ export interface GraphQLTypeExpressionProps { export function GraphQLTypeExpression(props: GraphQLTypeExpressionProps) { const { $, program } = useTsp(); - const { modelVariants } = useGraphQLSchema(); const nullable = props.isNullable || isNullable(props.type); @@ -145,14 +143,8 @@ export function GraphQLTypeExpression(props: GraphQLTypeExpressionProps) { } if ($.model.is(type)) { - // Both input and output variants share the same source model identity, - // so we use name-based lookup to determine if both variants exist. - const hasOutputVariant = modelVariants.outputModels.has(type.name); - const hasInputVariant = modelVariants.inputModels.has(type.name); - - if (props.isInput && hasOutputVariant && hasInputVariant) { - return `${type.name}Input`; - } + // The mutation engine handles input/output naming - input models are + // already suffixed with "Input" when they need to be distinguished. return type.name; } diff --git a/packages/graphql/src/context/graphql-schema-context.tsx b/packages/graphql/src/context/graphql-schema-context.tsx index db817783a95..16f951d78dc 100644 --- a/packages/graphql/src/context/graphql-schema-context.tsx +++ b/packages/graphql/src/context/graphql-schema-context.tsx @@ -34,17 +34,6 @@ export interface ClassifiedTypes { 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) @@ -69,8 +58,8 @@ export interface ScalarVariant { export interface GraphQLSchemaContextValue { /** Classified types for schema generation */ classifiedTypes: ClassifiedTypes; - /** Model variant lookups for input/output type resolution */ - modelVariants: ModelVariants; + /** Ordered member names for each union, keyed by the mutated Union */ + unionMembers: Map; /** Scalar specification URLs for @specifiedBy directives */ scalarSpecifications: Map; } diff --git a/packages/graphql/src/context/index.ts b/packages/graphql/src/context/index.ts index b353281709c..2fcde392d71 100644 --- a/packages/graphql/src/context/index.ts +++ b/packages/graphql/src/context/index.ts @@ -3,6 +3,5 @@ export { useGraphQLSchema, type GraphQLSchemaContextValue, type ClassifiedTypes, - type ModelVariants, type ScalarVariant, } from "./graphql-schema-context.js"; From a4248e4e47fb555145fb649765609d6ee4af5c06 Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 1 May 2026 11:27:35 -0400 Subject: [PATCH 3/5] Remove dead code from type-utils.ts Remove unused utility functions that were superseded by the mutation engine: - getTemplatedModelName (mutation engine handles template naming) - getSingleNameWithNamespace (never used in production) - isArray, isRecordType, isScalarOrEnumArray, isUnionArray (use compiler's isArrayModelType) - unwrapModel, unwrapType (never imported) - isTrueModel (never imported) Also removes the corresponding test for getSingleNameWithNamespace. --- packages/graphql/src/lib/type-utils.ts | 85 -------------------- packages/graphql/test/lib/type-utils.test.ts | 15 ---- 2 files changed, 100 deletions(-) diff --git a/packages/graphql/src/lib/type-utils.ts b/packages/graphql/src/lib/type-utils.ts index 50fc9aba3ed..2cd0f5b22e0 100644 --- a/packages/graphql/src/lib/type-utils.ts +++ b/packages/graphql/src/lib/type-utils.ts @@ -1,21 +1,14 @@ import { - type ArrayModelType, - type Enum, getDoc, getTypeName, type IndeterminateEntity, - isNeverType, isNullType, isTemplateInstance, - type Model, type Program, - type RecordModelType, - type Scalar, type Type, type Union, type UnionVariant, type Value, - walkPropertiesInherited, } from "@typespec/compiler"; import { type AliasStatementNode, @@ -75,14 +68,6 @@ export function stripNullVariants(union: Union): { }; } -/** Generate a GraphQL type name for a templated model (e.g., `ListOfString`). */ -export function getTemplatedModelName(model: Model): string { - const name = getTypeName(model, {}); - const baseName = toTypeName(name.replace(/<[^>]*>/g, "")); - const templateString = getTemplateString(model); - return templateString ? `${baseName}Of${templateString}` : baseName; -} - function splitWithAcronyms( splitFn: (name: string) => string[], skipStart: boolean, @@ -233,64 +218,6 @@ function getUnionNameForOperation(program: Program, union: Union): string { return toTypeName(getTypeName(operation)); } -/** Convert a namespaced name to a single name by replacing dots with underscores. */ -export function getSingleNameWithNamespace(name: string): string { - return name.trim().replace(/\./g, "_"); -} - -/** - * Check if a model is an array type. - */ -export function isArray(model: Model): model is ArrayModelType { - return Boolean(model.indexer && model.indexer.key.name === "integer"); -} - -/** - * Check if a model is a record/map type. - */ -export function isRecordType(type: Model): type is RecordModelType { - return Boolean(type.indexer && type.indexer.key.name === "string"); -} - -/** Check if a model is an array of scalars or enums. */ -export function isScalarOrEnumArray(type: Model): type is ArrayModelType { - return ( - isArray(type) && (type.indexer?.value.kind === "Scalar" || type.indexer?.value.kind === "Enum") - ); -} - -/** Check if a model is an array of unions. */ -export function isUnionArray(type: Model): type is ArrayModelType { - return isArray(type) && type.indexer?.value.kind === "Union"; -} - -/** Extract the element type from an array model, or return the model itself. */ -export function unwrapModel(model: ArrayModelType): Model | Scalar | Enum | Union; -export function unwrapModel(model: Exclude): Model; -export function unwrapModel(model: Model): Model | Scalar | Enum | Union { - if (!isArray(model)) { - return model; - } - - if (model.indexer?.value.kind) { - if (["Model", "Scalar", "Enum", "Union"].includes(model.indexer.value.kind)) { - return model.indexer.value as Model | Scalar | Enum | Union; - } - throw new Error(`Unexpected array type: ${model.indexer.value.kind}`); - } - return model; -} - -/** Unwrap array types to get the inner element type. */ -export function unwrapType(type: Model): Model | Scalar | Enum | Union; -export function unwrapType(type: Type): Type; -export function unwrapType(type: Type): Type { - if (type.kind === "Model") { - return unwrapModel(type); - } - return type; -} - /** Get the GraphQL description for a type from its doc comments. */ export function getGraphQLDoc(program: Program, type: Type): string | undefined { // GraphQL uses CommonMark for descriptions @@ -318,15 +245,3 @@ function getTemplateStringInternal( return args.length > 0 ? args.map(toTypeName).join(options.conjunction) : ""; } -/** Check if a model should be emitted as a GraphQL object type (not an array, record, or never). */ -export function isTrueModel(model: Model): boolean { - return !( - // Array of scalars/enums — represented as a list type, not an object type - isScalarOrEnumArray(model) || - // Array of unions — represented as a list type, not an object type - isUnionArray(model) || - isNeverType(model) || - // Pure record with no properties — emitted as a custom scalar, not an object type - (isRecordType(model) && [...walkPropertiesInherited(model)].length === 0) - ); -} diff --git a/packages/graphql/test/lib/type-utils.test.ts b/packages/graphql/test/lib/type-utils.test.ts index aa5cb5ee45c..0fe12748de5 100644 --- a/packages/graphql/test/lib/type-utils.test.ts +++ b/packages/graphql/test/lib/type-utils.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - getSingleNameWithNamespace, sanitizeNameForGraphQL, toEnumMemberName, toFieldName, @@ -113,18 +112,4 @@ describe("type-utils", () => { }); }); - describe("getSingleNameWithNamespace", () => { - it("replaces dots with underscores", () => { - expect(getSingleNameWithNamespace("My.Namespace.Type")).toBe("My_Namespace_Type"); - }); - - it("trims whitespace", () => { - expect(getSingleNameWithNamespace(" My.Type ")).toBe("My_Type"); - }); - - it("handles names without namespace", () => { - expect(getSingleNameWithNamespace("MyType")).toBe("MyType"); - }); - }); - }); From 3dc3204fb8549de5d3c5741d74a09131e99d0509 Mon Sep 17 00:00:00 2001 From: Fiona Date: Tue, 2 Jun 2026 16:00:56 -0400 Subject: [PATCH 4/5] Replace auxiliary data structures with TypeGraph in schema context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove ClassifiedTypes, ScalarVariant, unionMembers, and scalarSpecifications from GraphQLSchemaContextValue. The context now holds a single TypeGraph — a self-contained namespace of mutated types that downstream components can walk directly. Classification (input/output/interface) and scalar metadata are derived from the TypeGraph at render time rather than pre-computed into parallel arrays and side-channel maps. --- .../src/context/graphql-schema-context.tsx | 61 +++---------------- packages/graphql/src/context/index.ts | 3 +- 2 files changed, 9 insertions(+), 55 deletions(-) diff --git a/packages/graphql/src/context/graphql-schema-context.tsx b/packages/graphql/src/context/graphql-schema-context.tsx index 16f951d78dc..c281141c83e 100644 --- a/packages/graphql/src/context/graphql-schema-context.tsx +++ b/packages/graphql/src/context/graphql-schema-context.tsx @@ -1,67 +1,22 @@ 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"; +import type { Namespace } from "@typespec/compiler"; /** - * Classified types separated by category for schema generation + * A self-contained type world produced by the mutation pipeline. + * The namespace contains all mutated types for the schema. */ -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[]; +export interface TypeGraph { + readonly globalNamespace: Namespace; } /** - * 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. + * Context value containing the mutated type graph for schema generation. * * 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; - /** Ordered member names for each union, keyed by the mutated Union */ - unionMembers: Map; - /** Scalar specification URLs for @specifiedBy directives */ - scalarSpecifications: Map; + typeGraph: TypeGraph; } /** @@ -80,7 +35,7 @@ export function useGraphQLSchema(): GraphQLSchemaContextValue { if (!context) { throw new Error( - "useGraphQLSchema must be used within GraphQLSchemaContext.Provider." + "useGraphQLSchema must be used within GraphQLSchemaContext.Provider.", ); } diff --git a/packages/graphql/src/context/index.ts b/packages/graphql/src/context/index.ts index 2fcde392d71..b734df36476 100644 --- a/packages/graphql/src/context/index.ts +++ b/packages/graphql/src/context/index.ts @@ -2,6 +2,5 @@ export { GraphQLSchemaContext, useGraphQLSchema, type GraphQLSchemaContextValue, - type ClassifiedTypes, - type ScalarVariant, + type TypeGraph, } from "./graphql-schema-context.js"; From ea85714d2936970aa590bc79f97342aa03c614f4 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 3 Jun 2026 15:40:41 -0400 Subject: [PATCH 5/5] Fix nullability: optional fields should be nullable per GraphQL spec Per the GraphQL spec, 'nullability directly determines whether a field is required'. Optional fields (?) should be nullable in both input and output contexts. The previous logic made optional input fields non-null, which: 1. Violated the GraphQL specification 2. Caused crashes on circular references (e.g., Author.friend?: Author became AuthorInput.friend: AuthorInput!, an invalid non-null cycle) --- .../graphql/src/components/fields/type-expression.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/graphql/src/components/fields/type-expression.tsx b/packages/graphql/src/components/fields/type-expression.tsx index 34c781902cd..ccfacb2456d 100644 --- a/packages/graphql/src/components/fields/type-expression.tsx +++ b/packages/graphql/src/components/fields/type-expression.tsx @@ -44,9 +44,11 @@ export function GraphQLTypeExpression(props: GraphQLTypeExpressionProps) { const nullable = props.isNullable || isNullable(props.type); - // Input fields are non-null unless nullable; optionality is expressed via - // default values. Output fields are non-null unless optional or nullable. - const isNonNull = nullable ? false : props.isInput || !props.isOptional; + // Fields are non-null unless nullable or optional. + // In GraphQL, optional fields are represented as nullable (per spec: + // "nullability directly determines whether a field is required"). + // This also allows circular references in input types (e.g., Author.friend?: Author). + const isNonNull = !nullable && !props.isOptional; // Unwrap T | null unions the mutation engine didn't process (e.g., array // elements, operation parameters that arrive here still wrapped).