diff --git a/packages/graphql/.claude/follow-up-items.md b/packages/graphql/.claude/follow-up-items.md new file mode 100644 index 00000000000..11ed24a2208 --- /dev/null +++ b/packages/graphql/.claude/follow-up-items.md @@ -0,0 +1,72 @@ +# GraphQL Emitter Follow-up Items + +## ~~Input Type Nullability Bug~~ (RESOLVED) + +**Status**: Fixed in PR #77 (commit ea85714d2) + +**Original issue**: Optional fields in input types were being made non-null. + +**Resolution**: Per the GraphQL spec, "nullability directly determines whether a field is required." Optional fields (`?`) should be nullable in both input and output contexts. This also enables circular references in input types (e.g., `Author.friend?: Author` → `friend: AuthorInput` is valid because it's nullable). + +**Correct behavior** (now implemented): + +| TypeSpec | Output | Input | +|--------------------|----------|----------| +| a: string | String! | String! | +| b?: string | String | String | +| c: string \| null | String | String | + +--- + +## @oneOf Input Union Bug (Priority: Medium) + +**Issue**: Unions used in input context (e.g., mutation parameters) should be converted to `@oneOf` input objects per the GraphQL spec, but the emitter crashes with "Unknown GraphQL type" when attempting this. + +**Test case that exposes the bug**: +```typespec +@schema +namespace TestNamespace { + model TextContent { + text: string; + } + + model ImageContent { + url: string; + } + + union Content { + text: TextContent, + image: ImageContent, + } + + model Post { + id: string; + content: Content; + } + + @query + op getPost(id: string): Post; + + @mutation + op createPost(content: Content): Post; +} +``` + +**Error**: +``` +Error: Unknown GraphQL type "ContentInput". + at resolveNamedType (src/schema/build/types.ts:100:11) +``` + +**Root cause**: The `GraphQLUnionMutation.mutateAsOneOfInput()` correctly creates the `@oneOf` input model (e.g., `ContentInput`), but this synthetic model is not being registered in the type registry that `buildArgsMap` uses to resolve argument types. + +**Expected behavior**: +- In output context: `union Content = TextContent | ImageContent` +- In input context: `input ContentInput @oneOf { text: TextContentInput, image: ImageContentInput }` + +**Files to investigate**: +- `src/mutation-engine/mutations/union.ts` - `mutateAsOneOfInput()` creates the @oneOf model +- `src/schema/build/types.ts` - where the type registry is built and `resolveNamedType` fails +- `src/mutation-engine/schema-mutator.ts` - may need to register synthetic types + +**Related**: Similar bug exists for complex input types used in `@operationFields` arguments (the `FilterOptions` model isn't discovered when used only as an operation field argument type). diff --git a/packages/graphql/lib/main.tsp b/packages/graphql/lib/main.tsp index 4b6c5d69627..7685a266f3e 100644 --- a/packages/graphql/lib/main.tsp +++ b/packages/graphql/lib/main.tsp @@ -1,4 +1,6 @@ import "./interface.tsp"; +import "./nullable.tsp"; +import "./one-of.tsp"; import "./operation-fields.tsp"; import "./operation-kind.tsp"; import "./scalars.tsp"; diff --git a/packages/graphql/lib/nullable.tsp b/packages/graphql/lib/nullable.tsp new file mode 100644 index 00000000000..dfbbeb1993a --- /dev/null +++ b/packages/graphql/lib/nullable.tsp @@ -0,0 +1,22 @@ +import "../dist/src/lib/nullable.js"; + +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +/** + * Mark a field, operation, or type as nullable in the emitted GraphQL schema. + * + * Applied automatically by the mutation engine when it strips `| null` from + * union types. The decorator's presence on the type's `decorators` array is + * the signal — the implementation is a no-op. + */ +extern dec nullable(target: ModelProperty | Operation | Union | Model); + +/** + * Mark a field or operation as having nullable array elements in the emitted GraphQL schema. + * + * Applied automatically by the mutation engine when it detects `Array` + * patterns. Causes the emitter to emit `[T]` instead of `[T!]`. + */ +extern dec nullableElements(target: ModelProperty | Operation); diff --git a/packages/graphql/lib/one-of.tsp b/packages/graphql/lib/one-of.tsp new file mode 100644 index 00000000000..0482f973c00 --- /dev/null +++ b/packages/graphql/lib/one-of.tsp @@ -0,0 +1,16 @@ +import "../dist/src/lib/one-of.js"; + +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +/** + * Mark a model as a `@oneOf` input object in the emitted GraphQL schema. + * + * This decorator is applied automatically by the mutation engine when it converts + * a union type in input context to a synthetic input object (since GraphQL unions + * are output-only). The emitter uses this to emit the `@oneOf` directive. + * + * @see https://spec.graphql.org/September2025/#sec-OneOf-Input-Objects + */ +extern dec oneOf(target: Model); 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..ccfacb2456d --- /dev/null +++ b/packages/graphql/src/components/fields/type-expression.tsx @@ -0,0 +1,166 @@ +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 { 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 nullable = props.isNullable || isNullable(props.type); + + // 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). + 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)) { + // The mutation engine handles input/output naming - input models are + // already suffixed with "Input" when they need to be distinguished. + 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/components/graphql-schema.tsx b/packages/graphql/src/components/graphql-schema.tsx new file mode 100644 index 00000000000..94b78461316 --- /dev/null +++ b/packages/graphql/src/components/graphql-schema.tsx @@ -0,0 +1,32 @@ +import { type Children } from "@alloy-js/core"; +import type { Program } from "@typespec/compiler"; +import { TspContext } from "@typespec/emitter-framework"; +import { + GraphQLSchemaContext, + type GraphQLSchemaContextValue, +} from "../context/index.js"; + +export interface GraphQLSchemaProps { + /** TypeSpec program instance */ + program: Program; + /** Context value containing classified types and type maps */ + contextValue: GraphQLSchemaContextValue; + /** Child components to render */ + children?: Children; +} + +/** + * Root component for GraphQL schema generation + * + * Provides TspContext (program + typekit) from @typespec/emitter-framework + * and GraphQL-specific context to all child components. + */ +export function GraphQLSchema(props: GraphQLSchemaProps) { + return ( + + + {props.children} + + + ); +} diff --git a/packages/graphql/src/components/operations/index.ts b/packages/graphql/src/components/operations/index.ts new file mode 100644 index 00000000000..bfb1fcd9df5 --- /dev/null +++ b/packages/graphql/src/components/operations/index.ts @@ -0,0 +1,3 @@ +export { QueryType, type QueryTypeProps } from "./query-type.js"; +export { MutationType, type MutationTypeProps } from "./mutation-type.js"; +export { SubscriptionType, type SubscriptionTypeProps } from "./subscription-type.js"; diff --git a/packages/graphql/src/components/operations/mutation-type.tsx b/packages/graphql/src/components/operations/mutation-type.tsx new file mode 100644 index 00000000000..0025e3b95d3 --- /dev/null +++ b/packages/graphql/src/components/operations/mutation-type.tsx @@ -0,0 +1,27 @@ +import { type Operation } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { OperationField } from "../fields/index.js"; + +export interface MutationTypeProps { + /** Mutation operations to render as fields */ + operations: Operation[]; +} + +/** + * Renders the GraphQL Mutation root type using Alloy's Mutation component + * + * Only renders if operations exist (Mutation is optional in GraphQL) + */ +export function MutationType(props: MutationTypeProps) { + if (props.operations.length === 0) { + return null; + } + + return ( + + {props.operations.map((op) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/operations/query-type.tsx b/packages/graphql/src/components/operations/query-type.tsx new file mode 100644 index 00000000000..fa3eb340e1b --- /dev/null +++ b/packages/graphql/src/components/operations/query-type.tsx @@ -0,0 +1,27 @@ +import { type Operation } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { OperationField } from "../fields/index.js"; + +export interface QueryTypeProps { + /** Query operations to render as fields */ + operations: Operation[]; +} + +/** + * Renders the GraphQL Query root type using Alloy's Query component. + * Returns null if no query operations exist (the emitter will emit an + * empty-schema diagnostic in that case). + */ +export function QueryType(props: QueryTypeProps) { + if (props.operations.length === 0) { + return null; + } + + return ( + + {props.operations.map((op) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/operations/subscription-type.tsx b/packages/graphql/src/components/operations/subscription-type.tsx new file mode 100644 index 00000000000..64031601b3c --- /dev/null +++ b/packages/graphql/src/components/operations/subscription-type.tsx @@ -0,0 +1,27 @@ +import { type Operation } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { OperationField } from "../fields/index.js"; + +export interface SubscriptionTypeProps { + /** Subscription operations to render as fields */ + operations: Operation[]; +} + +/** + * Renders the GraphQL Subscription root type using Alloy's Subscription component + * + * Only renders if operations exist (Subscription is optional in GraphQL) + */ +export function SubscriptionType(props: SubscriptionTypeProps) { + if (props.operations.length === 0) { + return null; + } + + return ( + + {props.operations.map((op) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/type-collections.tsx b/packages/graphql/src/components/type-collections.tsx new file mode 100644 index 00000000000..c29e24bbb62 --- /dev/null +++ b/packages/graphql/src/components/type-collections.tsx @@ -0,0 +1,118 @@ +import { For } from "@alloy-js/core"; +import * as gql from "@alloy-js/graphql"; +import type { Enum, Model, Scalar, Union } from "@typespec/compiler"; +import type { ScalarVariant } from "../mutation-engine/index.js"; +import { + ScalarType, + EnumType, + UnionType, + InterfaceType, + ObjectType, + InputType, +} from "./types/index.js"; + +export interface ScalarVariantTypesProps { + scalarVariants: ScalarVariant[]; + scalars: Scalar[]; + scalarSpecifications: Map; +} + +/** + * Renders scalar variant types for encoded scalars (e.g., bytes + base64 -> Bytes) + * AND custom user-defined scalars + */ +export function ScalarVariantTypes(props: ScalarVariantTypesProps) { + // Get set of variant names to avoid duplicates + const variantNames = new Set(props.scalarVariants.map((v) => v.graphqlName)); + + // Filter custom scalars to only include ones not already in variants + const customScalars = props.scalars.filter((s) => !variantNames.has(s.name)); + + return ( + <> + + {(variant) => ( + + )} + + + {(scalar) => ( + + )} + + + ); +} + +export interface EnumTypesProps { + enums: Enum[]; +} + +/** + * Renders all enum types in the schema + */ +export function EnumTypes(props: EnumTypesProps) { + return ( + {(enumType) => } + ); +} + +export interface UnionTypesProps { + unions: Union[]; +} + +/** + * Renders all union types in the schema + */ +export function UnionTypes(props: UnionTypesProps) { + return ( + {(union) => } + ); +} + +export interface InterfaceTypesProps { + interfaces: Model[]; +} + +/** + * Renders all interface types in the schema + */ +export function InterfaceTypes(props: InterfaceTypesProps) { + return ( + + {(iface) => } + + ); +} + +export interface ObjectTypesProps { + models: Model[]; +} + +/** + * Renders all object types in the schema + */ +export function ObjectTypes(props: ObjectTypesProps) { + return ( + {(model) => } + ); +} + +export interface InputTypesProps { + models: Model[]; +} + +/** + * Renders all input types in the schema + */ +export function InputTypes(props: InputTypesProps) { + return ( + {(model) => } + ); +} diff --git a/packages/graphql/src/components/types/enum-type.tsx b/packages/graphql/src/components/types/enum-type.tsx new file mode 100644 index 00000000000..68a19ea1d60 --- /dev/null +++ b/packages/graphql/src/components/types/enum-type.tsx @@ -0,0 +1,34 @@ +import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +export interface EnumTypeProps { + /** The enum type to render */ + type: Enum; +} + +/** + * Renders a GraphQL enum type declaration with members + */ +export function EnumType(props: EnumTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const members = Array.from(props.type.members.values()); + + return ( + + {members.map((member) => { + const memberDoc = getDoc(program, member); + const deprecation = getDeprecationDetails(program, member); + + return ( + + ); + })} + + ); +} diff --git a/packages/graphql/src/components/types/index.ts b/packages/graphql/src/components/types/index.ts new file mode 100644 index 00000000000..9d53ec9d26d --- /dev/null +++ b/packages/graphql/src/components/types/index.ts @@ -0,0 +1,6 @@ +export { ScalarType, type ScalarTypeProps } from "./scalar-type.js"; +export { EnumType, type EnumTypeProps } from "./enum-type.js"; +export { UnionType, type UnionTypeProps } from "./union-type.js"; +export { InterfaceType, type InterfaceTypeProps } from "./interface-type.js"; +export { ObjectType, type ObjectTypeProps } from "./object-type.js"; +export { InputType, type InputTypeProps } from "./input-type.js"; diff --git a/packages/graphql/src/components/types/input-type.tsx b/packages/graphql/src/components/types/input-type.tsx new file mode 100644 index 00000000000..48ffdd4dfc6 --- /dev/null +++ b/packages/graphql/src/components/types/input-type.tsx @@ -0,0 +1,26 @@ +import type { Model } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { Field } from "../fields/index.js"; + +export interface InputTypeProps { + /** The input type to render */ + type: Model; +} + +/** + * Renders a GraphQL input type declaration. + */ +export function InputType(props: InputTypeProps) { + const { $ } = useTsp(); + const doc = $.type.getDoc(props.type); + const properties = Array.from(props.type.properties.values()); + + return ( + + {properties.map((prop) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/types/interface-type.tsx b/packages/graphql/src/components/types/interface-type.tsx new file mode 100644 index 00000000000..2c53c2b17b8 --- /dev/null +++ b/packages/graphql/src/components/types/interface-type.tsx @@ -0,0 +1,28 @@ +import type { Model } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { Field } from "../fields/index.js"; + +export interface InterfaceTypeProps { + /** The interface type to render */ + type: Model; +} + +/** + * Renders a GraphQL interface type declaration + * + * Interfaces are marked with @Interface decorator in TypeSpec + */ +export function InterfaceType(props: InterfaceTypeProps) { + const { $ } = useTsp(); + const doc = $.type.getDoc(props.type); + const properties = Array.from(props.type.properties.values()); + + return ( + + {properties.map((prop) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/types/object-type.tsx b/packages/graphql/src/components/types/object-type.tsx new file mode 100644 index 00000000000..ace17a83dd1 --- /dev/null +++ b/packages/graphql/src/components/types/object-type.tsx @@ -0,0 +1,44 @@ +import type { Model } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { Field, OperationField } from "../fields/index.js"; +import { getComposition } from "../../lib/interface.js"; +import { getOperationFields } from "../../lib/operation-fields.js"; + +export interface ObjectTypeProps { + /** The object type to render */ + type: Model; +} + +/** + * Renders a GraphQL object type declaration + * + * Handles: + * - Regular fields from model properties + * - Interface implementations via @compose + * - Operation fields via @operationFields + */ +export function ObjectType(props: ObjectTypeProps) { + const { $, program } = useTsp(); + const doc = $.type.getDoc(props.type); + const properties = Array.from(props.type.properties.values()); + const implementations = getComposition(program, props.type); + const operationFields = getOperationFields(program, props.type); + + const implementsRefs = implementations?.map((iface) => iface.name) || []; + + return ( + + {properties.map((prop) => ( + + ))} + {Array.from(operationFields).map((op) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/types/scalar-type.tsx b/packages/graphql/src/components/types/scalar-type.tsx new file mode 100644 index 00000000000..7ecb6b3dde4 --- /dev/null +++ b/packages/graphql/src/components/types/scalar-type.tsx @@ -0,0 +1,26 @@ +import { type Scalar, getDoc } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +export interface ScalarTypeProps { + /** The scalar type to render */ + type: Scalar; + /** Optional @specifiedBy URL for the scalar */ + specificationUrl?: string; +} + +/** + * Renders a GraphQL scalar type declaration with optional @specifiedBy directive + */ +export function ScalarType(props: ScalarTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + + return ( + + ); +} diff --git a/packages/graphql/src/components/types/union-type.tsx b/packages/graphql/src/components/types/union-type.tsx new file mode 100644 index 00000000000..4e950106d8b --- /dev/null +++ b/packages/graphql/src/components/types/union-type.tsx @@ -0,0 +1,50 @@ +import { type Union, getDoc } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { getUnionName, isScalarLikeType, toTypeName } from "../../lib/type-utils.js"; + +export interface UnionTypeProps { + /** The union type to render */ + type: Union; +} + +/** + * Renders a GraphQL union type declaration + * Scalars are wrapped in object types since GraphQL unions can only contain object types + * This wrapping is done by the mutation engine + */ +export function UnionType(props: UnionTypeProps) { + const { program } = useTsp(); + const name = getUnionName(props.type, program); + const doc = getDoc(program, props.type); + const variants = Array.from(props.type.variants.values()); + + // Build the union member list, using wrapper names for scalars + // The wrapper models are created by the mutation engine + const unionMembers = variants.map((variant) => { + const variantName = + typeof variant.name === "string" ? variant.name : String(variant.name); + + if (isScalarLikeType(variant.type)) { + // Reference the wrapper type for scalars (created by mutation engine) + // Include union name to match wrapper model naming convention + return toTypeName(name) + toTypeName(variantName) + "UnionVariant"; + } else { + // For non-scalars, use the type name directly + if (variant.type.kind === "Model") { + return variant.type.name; + } else if ( + "name" in variant.type && + typeof variant.type.name === "string" + ) { + return variant.type.name; + } + throw new Error( + `Unexpected union variant type kind "${variant.type.kind}" in union "${name}". ` + + `This is a bug in the GraphQL emitter.`, + ); + } + }); + + return ; +} 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..c281141c83e --- /dev/null +++ b/packages/graphql/src/context/graphql-schema-context.tsx @@ -0,0 +1,43 @@ +import { type ComponentContext, createNamedContext, useContext } from "@alloy-js/core"; +import type { Namespace } from "@typespec/compiler"; + +/** + * A self-contained type world produced by the mutation pipeline. + * The namespace contains all mutated types for the schema. + */ +export interface TypeGraph { + readonly globalNamespace: Namespace; +} + +/** + * 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 { + typeGraph: TypeGraph; +} + +/** + * 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..b734df36476 --- /dev/null +++ b/packages/graphql/src/context/index.ts @@ -0,0 +1,6 @@ +export { + GraphQLSchemaContext, + useGraphQLSchema, + type GraphQLSchemaContextValue, + type TypeGraph, +} from "./graphql-schema-context.js"; diff --git a/packages/graphql/src/emitter.ts b/packages/graphql/src/emitter.ts deleted file mode 100644 index 6a34c0552df..00000000000 --- a/packages/graphql/src/emitter.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { EmitContext, NewLine } from "@typespec/compiler"; -import { resolvePath } from "@typespec/compiler"; -import { createGraphQLEmitter } from "./graphql-emitter.js"; -import type { GraphQLEmitterOptions } from "./lib.js"; - -const defaultOptions = { - "new-line": "lf", - "omit-unreachable-types": false, - strict: false, -} as const; - -export async function $onEmit(context: EmitContext) { - const options = resolveOptions(context); - const emitter = createGraphQLEmitter(context, options); - await emitter.emitGraphQL(); -} - -export interface ResolvedGraphQLEmitterOptions { - outputFile: string; - newLine: NewLine; - omitUnreachableTypes: boolean; - strict: boolean; -} - -export function resolveOptions( - context: EmitContext, -): ResolvedGraphQLEmitterOptions { - const resolvedOptions = { ...defaultOptions, ...context.options }; - const outputFile = resolvedOptions["output-file"] ?? "{schema-name}.graphql"; - - return { - outputFile: resolvePath(context.emitterOutputDir, outputFile), - newLine: resolvedOptions["new-line"], - omitUnreachableTypes: resolvedOptions["omit-unreachable-types"], - strict: resolvedOptions["strict"], - }; -} diff --git a/packages/graphql/src/emitter.tsx b/packages/graphql/src/emitter.tsx new file mode 100644 index 00000000000..b21feb89030 --- /dev/null +++ b/packages/graphql/src/emitter.tsx @@ -0,0 +1,156 @@ +import { + emitFile, + interpolatePath, + resolvePath, + type EmitContext, + type Namespace, +} from "@typespec/compiler"; +import { renderSchema as renderAlloySchema, printSchema } from "@alloy-js/graphql"; +import { type GraphQLEmitterOptions, reportDiagnostic } from "./lib.js"; +import { listSchemas } from "./lib/schema.js"; +import { + createGraphQLMutationEngine, + type MutatedSchema, +} from "./mutation-engine/index.js"; +import { resolveTypeUsage } from "./type-usage.js"; +import { GraphQLSchema } from "./components/graphql-schema.js"; +import { + ScalarVariantTypes, + EnumTypes, + UnionTypes, + InterfaceTypes, + ObjectTypes, + InputTypes, +} from "./components/type-collections.js"; +import { QueryType, MutationType, SubscriptionType } from "./components/operations/index.js"; +import type { GraphQLSchemaContextValue } from "./context/index.js"; + +/** + * Main emitter entry point for GraphQL SDL generation. + * + * Runs the data pipeline (type usage → mutation) and passes the result to + * `renderSchema`, which renders via Alloy components and writes the SDL file. + */ +export async function $onEmit(context: EmitContext) { + const schemas = listSchemas(context.program); + if (schemas.length === 0) { + schemas.push({ type: context.program.getGlobalNamespaceType() }); + } + + for (const schema of schemas) { + const mutated = await emitSchema(context, schema); + if (mutated) { + await renderSchema(context, schema, mutated); + } + } +} + +/** + * Run the data pipeline for a single GraphQL schema. + * + * Returns the `MutatedSchema` on success, or `undefined` if the schema + * cannot be built (e.g., no query root) — in which case a diagnostic has + * already been emitted. + */ +async function emitSchema( + context: EmitContext, + schema: { type: Namespace; name?: string }, +): Promise { + // Phase 1: Type usage tracking — determine which types are reachable from operations. + // Must run before mutation so the engine can filter on original (pre-clone) type objects. + const omitUnreachable = context.options["omit-unreachable-types"] ?? false; + const typeUsage = resolveTypeUsage(schema.type, omitUnreachable); + + // Phase 2: Mutation — transform TypeSpec types with GraphQL naming conventions + // and classify the results. The engine consumes `typeUsage` internally to + // filter unreachable types and route models into input/output/interface buckets. + const engine = createGraphQLMutationEngine(context.program); + const mutated = engine.mutateSchema(schema.type, typeUsage); + + // Report void-returning operations — GraphQL fields must return a type, + // so these are excluded from the schema. + for (const op of mutated.voidOperations) { + reportDiagnostic(context.program, { + code: "void-operation-return", + format: { name: op.name }, + target: op, + }); + } + + // GraphQL requires at least a Query root type. If there are no query operations, + // the schema cannot be built. Emit a diagnostic and skip rendering. + if (mutated.queries.length === 0) { + reportDiagnostic(context.program, { + code: "empty-schema", + target: schema.type, + }); + return undefined; + } + + return mutated; +} + +/** + * Phase 3: Render the mutated schema to GraphQL SDL via Alloy components, + * then write the SDL to the emitter output directory. + */ +async function renderSchema( + context: EmitContext, + schema: { type: Namespace; name?: string }, + mutated: MutatedSchema, +): Promise { + // Wrapper models from union mutations are always output — fold them into + // the output bucket for the renderer. + const outputModels = [...mutated.outputModels, ...mutated.wrapperModels]; + + // Build the TypeGraph context - components access the type graph through context, + // while specific data is passed via props + const contextValue: GraphQLSchemaContextValue = { + typeGraph: { + globalNamespace: schema.type, + }, + }; + + // Determine output file name + const outputFilePattern = context.options["output-file"] ?? "{schema-name}.graphql"; + const schemaName = schema.name || "schema"; + const outputFileName = interpolatePath(outputFilePattern, { + "schema-name": schemaName, + }); + + // Render the schema using Alloy's renderSchema to get a GraphQLSchema object. + // We disable name policy validation because TypeSpec has already validated names and applied mutations. + const graphqlSchema = renderAlloySchema( + + + + + + + + + + + , + { namePolicy: null }, + ); + + // Convert the GraphQLSchema to SDL string using graphql-js printSchema + const rawSdl = printSchema(graphqlSchema); + + // Ensure file ends with blank line (two newlines) + const sdl = rawSdl.trimEnd() + "\n\n"; + + // Write to file + const outputPath = resolvePath(context.emitterOutputDir, outputFileName); + + await emitFile(context.program, { + path: outputPath, + content: sdl, + newLine: context.options["new-line"] ?? "lf", + }); +} diff --git a/packages/graphql/src/graphql-emitter.ts b/packages/graphql/src/graphql-emitter.ts deleted file mode 100644 index fdb3ffca332..00000000000 --- a/packages/graphql/src/graphql-emitter.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { emitFile, interpolatePath, type EmitContext } from "@typespec/compiler"; -import { printSchema } from "graphql"; -import type { ResolvedGraphQLEmitterOptions } from "./emitter.js"; -import type { GraphQLEmitterOptions } from "./lib.js"; -import { listSchemas } from "./lib/schema.js"; -import { createSchemaEmitter } from "./schema-emitter.js"; -import type { GraphQLSchemaRecord } from "./types.js"; - -export function createGraphQLEmitter( - context: EmitContext, - options: ResolvedGraphQLEmitterOptions, -) { - const program = context.program; - - return { - emitGraphQL, - }; - - async function emitGraphQL() { - if (!program.compilerOptions.noEmit) { - const schemaRecords = await getGraphQL(); - // first, emit diagnostics - for (const schemaRecord of schemaRecords) { - program.reportDiagnostics(schemaRecord.diagnostics); - } - if (program.hasError()) { - return; - } - for (const schemaRecord of schemaRecords) { - const schemaName = schemaRecord.schema.name || "schema"; - const filePath = interpolatePath(options.outputFile, { - "schema-name": schemaName, - }); - await emitFile(program, { - path: filePath, - content: printSchema(schemaRecord.graphQLSchema), - newLine: options.newLine, - }); - } - } - } - - async function getGraphQL(): Promise { - const schemaRecords: GraphQLSchemaRecord[] = []; - const schemas = listSchemas(program); - if (schemas.length === 0) { - schemas.push({ type: program.getGlobalNamespaceType() }); - } - for (const schema of schemas) { - const schemaEmitter = createSchemaEmitter(schema, context, options); - const document = await schemaEmitter.emitSchema(); - if (document === undefined) { - continue; - } - schemaRecords.push({ - schema: schema, - graphQLSchema: document[0], - diagnostics: document[1], - }); - } - return schemaRecords; - } -} diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts index 5c94258c5da..6895025524c 100644 --- a/packages/graphql/src/lib.ts +++ b/packages/graphql/src/lib.ts @@ -160,6 +160,19 @@ export const libDef = { default: paramMessage`Scalar "${"name"}" collides with GraphQL built-in type "${"builtinName"}". This may cause unexpected behavior. Consider renaming the scalar.`, }, }, + "empty-schema": { + severity: "warning", + messages: { + default: + "A GraphQL schema must declare at least one query operation. No schema will be emitted.", + }, + }, + "void-operation-return": { + severity: "warning", + messages: { + default: paramMessage`Operation "${"name"}" returns void, which has no GraphQL equivalent. The operation will be omitted from the schema. Add a return type to include it.`, + }, + }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, @@ -174,15 +187,6 @@ export const libDef = { interface: { description: "State for the @Interface decorator." }, schema: { description: "State for the @schema decorator." }, specifiedBy: { description: "State for the @specifiedBy decorator." }, - oneOf: { description: "State for tracking @oneOf input objects created from input unions." }, - nullable: { - description: - "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 1db497d4f48..15d0daa18ec 100644 --- a/packages/graphql/src/lib/nullable.ts +++ b/packages/graphql/src/lib/nullable.ts @@ -1,8 +1,38 @@ -import type { Program, Type } from "@typespec/compiler"; -import { useStateSet } from "@typespec/compiler/utils"; -import { GraphQLKeys } from "../lib.js"; +import type { + DecoratedType, + DecoratorContext, + DecoratorFunction, + Model, + ModelProperty, + Operation, + Type, + Union, +} from "@typespec/compiler"; +import { NAMESPACE } from "../lib.js"; -const [getNullableState, setNullableState] = useStateSet(GraphQLKeys.nullable); +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +/** + * Decorator implementation for `@nullable`. + * + * No-op — the decorator's presence on the type's `decorators` array is the + * signal. No additional state storage is needed. + */ +export const $nullable: DecoratorFunction = ( + _context: DecoratorContext, + _target: ModelProperty | Operation | Union | Model, +) => {}; + +/** + * Decorator implementation for `@nullableElements`. + * + * No-op — presence on the decorators array is the signal. + */ +export const $nullableElements: DecoratorFunction = ( + _context: DecoratorContext, + _target: ModelProperty | Operation, +) => {}; /** * Check whether a type was marked nullable after null-variant stripping. @@ -12,30 +42,39 @@ const [getNullableState, setNullableState] = useStateSet(GraphQLKeys.nulla * - **Operation**: return type `T | null` * - **Union**: named unions like `Cat | Dog | null` (safe — new unique object) */ -export function isNullable(program: Program, type: Type): boolean { - return getNullableState(program, type); +export function isNullable(type: Type): boolean { + if (!isDecoratedType(type)) return false; + return type.decorators.some((d) => d.decorator === $nullable); } -/** Mark a type, property, or operation as nullable. */ -export function setNullable(program: Program, type: Type): void { - setNullableState(program, type); +/** + * Mark a type, property, or operation as nullable. + * Called by the mutation engine when null variants are stripped. + */ +export function setNullable(type: Type): void { + if (!isDecoratedType(type)) return; + if (type.decorators.some((d) => d.decorator === $nullable)) return; + type.decorators.push({ decorator: $nullable, args: [] }); } -const [getNullableElementsState, setNullableElementsState] = useStateSet( - GraphQLKeys.nullableElements, -); - /** * Check whether a property's array elements were originally `T | null`. * * For `(string | null)[]`, marks the ModelProperty so components emit * `[String]` instead of `[String!]`. */ -export function hasNullableElements(program: Program, type: Type): boolean { - return getNullableElementsState(program, type); +export function hasNullableElements(type: Type): boolean { + if (!isDecoratedType(type)) return false; + return type.decorators.some((d) => d.decorator === $nullableElements); } /** Mark a property as having nullable array elements. */ -export function setNullableElements(program: Program, type: Type): void { - setNullableElementsState(program, type); +export function setNullableElements(type: Type): void { + if (!isDecoratedType(type)) return; + if (type.decorators.some((d) => d.decorator === $nullableElements)) return; + type.decorators.push({ decorator: $nullableElements, args: [] }); +} + +function isDecoratedType(type: Type): type is Type & DecoratedType { + return "decorators" in type; } diff --git a/packages/graphql/src/lib/one-of.ts b/packages/graphql/src/lib/one-of.ts index 742eb57477b..c24c33ee448 100644 --- a/packages/graphql/src/lib/one-of.ts +++ b/packages/graphql/src/lib/one-of.ts @@ -1,8 +1,19 @@ -import type { Model, Program } from "@typespec/compiler"; -import { useStateSet } from "@typespec/compiler/utils"; -import { GraphQLKeys } from "../lib.js"; +import type { DecoratorContext, DecoratorFunction, Model } from "@typespec/compiler"; +import { NAMESPACE } from "../lib.js"; -const [getOneOfState, setOneOfState] = useStateSet(GraphQLKeys.oneOf); +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +/** + * Decorator implementation for `@oneOf`. + * + * No-op — the decorator's presence on the type's `decorators` array is the + * signal. No additional state storage is needed. + */ +export const $oneOf: DecoratorFunction = ( + _context: DecoratorContext, + _target: Model, +) => {}; /** * Check if a model has been marked as a @oneOf input object. @@ -10,13 +21,14 @@ const [getOneOfState, setOneOfState] = useStateSet(GraphQLKeys.oneOf); * is used in input context — GraphQL unions are output-only, so input * unions become @oneOf input objects. */ -export function isOneOf(program: Program, model: Model): boolean { - return getOneOfState(program, model); +export function isOneOf(model: Model): boolean { + return model.decorators.some((d) => d.decorator === $oneOf); } /** * Mark a model as a @oneOf input object. */ -export function setOneOf(program: Program, model: Model): void { - setOneOfState(program, model); +export function setOneOf(model: Model): void { + if (model.decorators.some((d) => d.decorator === $oneOf)) return; + model.decorators.push({ decorator: $oneOf, args: [] }); } 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/src/lib/type-utils.ts b/packages/graphql/src/lib/type-utils.ts index 50fc9aba3ed..3ac43bd7a94 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, @@ -128,6 +113,10 @@ export function sanitizeNameForGraphQL(name: string, prefix: string = ""): strin return name; } +/** Add "Input" suffix to a name if it doesn't already have one. */ +export function withInputSuffix(name: string): string { + return name.endsWith("Input") ? name : name + "Input"; +} /** Convert a name to CONSTANT_CASE for GraphQL enum members. */ export function toEnumMemberName(enumName: string, name: string) { @@ -233,62 +222,9 @@ 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; +/** Check if a type is a scalar (built-in or custom) or an intrinsic type like `unknown`. */ +export function isScalarLikeType(type: Type): boolean { + return type.kind === "Scalar" || type.kind === "Intrinsic"; } /** Get the GraphQL description for a type from its doc comments. */ @@ -318,15 +254,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/src/mutation-engine/engine.ts b/packages/graphql/src/mutation-engine/engine.ts index 12e32ea1510..d75975d8ac2 100644 --- a/packages/graphql/src/mutation-engine/engine.ts +++ b/packages/graphql/src/mutation-engine/engine.ts @@ -1,6 +1,7 @@ import { type Enum, type Model, + type Namespace, type Operation, type Program, type Scalar, @@ -15,6 +16,7 @@ import { SimpleLiteralMutation, SimpleUnionVariantMutation, } from "@typespec/mutator-framework"; +import type { TypeUsageResolver } from "../type-usage.js"; import { GraphQLEnumMemberMutation, GraphQLEnumMutation, @@ -25,6 +27,7 @@ import { GraphQLUnionMutation, } from "./mutations/index.js"; import { GraphQLMutationOptions, GraphQLTypeContext } from "./options.js"; +import { mutateSchema, type MutatedSchema } from "./schema-mutator.js"; /** * Registry configuration for the GraphQL mutation engine. @@ -62,8 +65,10 @@ export class GraphQLMutationEngine { // MutationEngine doesn't work because the // generic expects instance types, not constructor types. private engine; + private program: Program; constructor(program: Program) { + this.program = program; const tk = $(program); this.engine = new MutationEngine(tk, graphqlMutationRegistry); } @@ -109,6 +114,21 @@ export class GraphQLMutationEngine { mutateUnion(union: Union, context: GraphQLTypeContext): GraphQLUnionMutation { return this.engine.mutate(union, new GraphQLMutationOptions(context)) as GraphQLUnionMutation; } + + /** + * Mutate every type declared in a schema namespace and return a fully- + * classified `MutatedSchema`. This is the program-level entry point: the + * emitter calls this once and gets back models split into input/output/ + * interface buckets, operations classified by kind, and all derived + * metadata (scalar variants, specification URLs, wrapper models). + * + * `typeUsage` is consumed internally to filter unreachable types and + * determine input/output classification. Callers don't need to hold onto + * it after this call returns. + */ + mutateSchema(schema: Namespace, typeUsage: TypeUsageResolver): MutatedSchema { + return mutateSchema(this.program, this, schema, typeUsage); + } } /** diff --git a/packages/graphql/src/mutation-engine/index.ts b/packages/graphql/src/mutation-engine/index.ts index 114de40a9f6..03514ab9963 100644 --- a/packages/graphql/src/mutation-engine/index.ts +++ b/packages/graphql/src/mutation-engine/index.ts @@ -9,3 +9,4 @@ export { GraphQLScalarMutation, GraphQLUnionMutation, } from "./mutations/index.js"; +export type { MutatedSchema, ScalarVariant } from "./schema-mutator.js"; diff --git a/packages/graphql/src/mutation-engine/mutations/model-property.ts b/packages/graphql/src/mutation-engine/mutations/model-property.ts index b911bd19f68..261ba7fa397 100644 --- a/packages/graphql/src/mutation-engine/mutations/model-property.ts +++ b/packages/graphql/src/mutation-engine/mutations/model-property.ts @@ -52,10 +52,10 @@ export class GraphQLModelPropertyMutation extends SimpleModelPropertyMutation { + // Apply GraphQL name sanitization model.name = sanitizeNameForGraphQL(model.name); + // Input models get "Input" suffix (unless they already have it) + if (this.typeContext === GraphQLTypeContext.Input) { + model.name = withInputSuffix(model.name); + } }); super.mutate(); } diff --git a/packages/graphql/src/mutation-engine/mutations/operation.ts b/packages/graphql/src/mutation-engine/mutations/operation.ts index 54ca8302629..4576fb85d10 100644 --- a/packages/graphql/src/mutation-engine/mutations/operation.ts +++ b/packages/graphql/src/mutation-engine/mutations/operation.ts @@ -1,4 +1,4 @@ -import type { MemberType, Operation } from "@typespec/compiler"; +import { isArrayModelType, type MemberType, type Operation } from "@typespec/compiler"; import { SimpleOperationMutation, type MutationInfo, @@ -6,8 +6,12 @@ import { type SimpleMutationOptions, type SimpleMutations, } from "@typespec/mutator-framework"; -import { setNullable } from "../../lib/nullable.js"; -import { isNullableUnion, sanitizeNameForGraphQL } from "../../lib/type-utils.js"; +import { setNullable, setNullableElements } from "../../lib/nullable.js"; +import { + isNullableUnion, + sanitizeNameForGraphQL, + unwrapNullableUnion, +} from "../../lib/type-utils.js"; import { GraphQLMutationOptions, GraphQLTypeContext } from "../options.js"; /** GraphQL-specific Operation mutation. */ @@ -44,7 +48,18 @@ export class GraphQLOperationMutation extends SimpleOperationMutation { operation.name = sanitizeNameForGraphQL(operation.name); @@ -52,7 +67,10 @@ export class GraphQLOperationMutation extends SimpleOperationMutation> = {}; for (const variant of flattenedVariants) { const fieldName = sanitizeNameForGraphQL(variantNameToString(variant.name)); + // Mutate the variant type with the same options (preserving input context) + // so that model variants get their Input suffix. + const variantMutation = this.engine.mutate(variant.type, this.options); properties[fieldName] = tk.modelProperty.create({ name: fieldName, - type: variant.type, + type: variantMutation.mutationNode.mutatedType, optional: true, // oneOf: exactly one must be provided }); } const unionName = getUnionName(this.sourceType, program); - const modelName = sanitizeNameForGraphQL(unionName) + "Input"; + const modelName = withInputSuffix(sanitizeNameForGraphQL(unionName)); const oneOfModel = tk.model.create({ name: modelName, properties, }); - setOneOf(program, oneOfModel); + setOneOf(oneOfModel); if (hasNull) { - setNullable(program, oneOfModel); + setNullable(oneOfModel); } this.#mutationNode.replace(oneOfModel); diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts new file mode 100644 index 00000000000..7f659427a32 --- /dev/null +++ b/packages/graphql/src/mutation-engine/schema-mutator.ts @@ -0,0 +1,339 @@ +import { + getEncode, + isArrayModelType, + isUnknownType, + isVoidType, + navigateTypesInNamespace, + type Enum, + type IntrinsicType, + type Model, + type ModelProperty, + type Namespace, + type Operation, + type Program, + type Scalar, + type Type, + type Union, +} from "@typespec/compiler"; +import { isInterface } from "../lib/interface.js"; +import { getOperationKind } from "../lib/operation-kind.js"; +import { getGraphQLBuiltinName, getScalarMapping } from "../lib/scalar-mappings.js"; +import { getSpecifiedBy } from "../lib/specified-by.js"; +import { unwrapNullableUnion } from "../lib/type-utils.js"; +import { + GraphQLTypeUsage, + type TypeUsageResolver, +} from "../type-usage.js"; +import type { GraphQLMutationEngine } from "./engine.js"; +import { GraphQLTypeContext } from "./options.js"; + +/** + * 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; +} + +/** + * The fully-mutated schema produced by `GraphQLMutationEngine.mutateSchema`. + * + * Types are pre-classified into input/output/interface buckets and + * operations are pre-classified by kind, so the renderer doesn't need + * pre-mutation types or a type-usage resolver. + */ +export interface MutatedSchema { + // Pre-classified model buckets + /** Models marked with @Interface */ + interfaces: Model[]; + /** Models used as outputs (return values) or declared but unreferenced */ + outputModels: Model[]; + /** Models used as inputs (operation parameters) */ + inputModels: Model[]; + + // Non-model type buckets + enums: Enum[]; + scalars: Scalar[]; + unions: Union[]; + + // Operations, classified by kind + queries: Operation[]; + mutations: Operation[]; + subscriptions: Operation[]; + /** + * Operations that return `void`. Excluded from the kind buckets above + * since GraphQL fields must return a type. Exposed separately so the + * emitter can report a diagnostic — mutation shapes the graph; the + * emitter decides what's worth warning about. + */ + voidOperations: Operation[]; + + // Derived metadata + /** Synthetic wrapper models created by union mutations for scalar variants */ + wrapperModels: Model[]; + /** Encoded stdlib scalars mapped to GraphQL custom scalars (e.g. bytes + base64 → Bytes) */ + scalarVariants: ScalarVariant[]; + /** `@specifiedBy` URLs indexed by GraphQL scalar name */ + scalarSpecifications: Map; +} + +/** + * Mutate every type declared in the schema namespace and classify the + * results. This is the internal implementation called by + * `GraphQLMutationEngine.mutateSchema`. + * + * The engine parameter is passed in explicitly (rather than being the + * receiver) to avoid a circular import between this module and engine.ts. + */ +export function mutateSchema( + program: Program, + engine: GraphQLMutationEngine, + schema: Namespace, + typeUsage: TypeUsageResolver, +): MutatedSchema { + // Pre-classified model buckets — populated at visit time. + const interfaces: Model[] = []; + const outputModels: Model[] = []; + const inputModels: Model[] = []; + + // Non-model type buckets. + const enums: Enum[] = []; + const scalars: Scalar[] = []; + const unions: Union[] = []; + + // Operations are classified by kind inline. We also keep the full list + // (including void-returning ops) for scalar collection in Phase B — a + // void op's params can still reference stdlib scalars that need to be + // declared in the schema. + const queries: Operation[] = []; + const mutations: Operation[] = []; + const subscriptions: Operation[] = []; + const allOperations: Operation[] = []; + + // Synthetic wrapper models from union mutation (always output). + const wrapperModels: Model[] = []; + + // Void-returning operations — collected here so the emitter can report + // the diagnostic. Mutation shapes the graph; it doesn't warn. + const voidOperations: Operation[] = []; + + // Metadata. + const scalarSpecifications = new Map(); + const scalarVariantsMap = new Map(); + + // Scalar dedup: a single scalar can be declared in the namespace AND + // referenced from many properties. Mutate once, add to bucket once. + const processedScalars = new Set(); + + // Track the mutated models we've visited so we can collect referenced + // scalars from their properties after the namespace walk. + const visitedModelOriginals: Model[] = []; + + const processScalar = (node: Scalar): void => { + // Skip scalars that map directly onto GraphQL built-ins (String, Int, + // Float, Boolean, ID) — they're emitted by reference and don't need + // their own scalar declaration in the schema. + if (getGraphQLBuiltinName(program, node)) return; + + const mutation = engine.mutateScalar(node); + const graphqlName = mutation.mutatedType.name; + + if (!processedScalars.has(graphqlName)) { + processedScalars.add(graphqlName); + scalars.push(mutation.mutatedType); + + const specUrl = getSpecifiedBy(program, mutation.mutatedType); + if (specUrl) { + scalarSpecifications.set(graphqlName, specUrl); + } + } + }; + + const processScalarVariant = (target: ModelProperty): void => { + if (isUnknownType(target.type)) { + if (!scalarVariantsMap.has("Unknown")) { + scalarVariantsMap.set("Unknown", { + sourceScalar: target.type, + encoding: "default", + graphqlName: "Unknown", + specificationUrl: undefined, + }); + } + return; + } + if ( + target.type.kind === "Scalar" && + program.checker.isStdType(target.type) && + !getGraphQLBuiltinName(program, target.type) + ) { + const encodeData = getEncode(program, target); + const encoding = encodeData?.encoding; + const mapping = getScalarMapping(program, target.type, encoding); + if (mapping && !scalarVariantsMap.has(mapping.graphqlName)) { + scalarVariantsMap.set(mapping.graphqlName, { + sourceScalar: target.type, + encoding: encoding || "default", + graphqlName: mapping.graphqlName, + specificationUrl: mapping.specificationUrl, + }); + } + } + }; + + const classifyModel = (originalModel: Model): void => { + // @Interface is checked on the original (pre-clone) model, since decorator + // state is stored against original type identity, not mutated clones. + if (isInterface(program, originalModel)) { + const mutation = engine.mutateModel(originalModel, GraphQLTypeContext.Output); + interfaces.push(mutation.mutatedType); + return; + } + + const usage = typeUsage.getUsage(originalModel); + const usedAsInput = usage?.has(GraphQLTypeUsage.Input) ?? false; + const usedAsOutput = usage?.has(GraphQLTypeUsage.Output) ?? false; + + if (!usedAsInput && !usedAsOutput) { + // Reachable but not referenced by any operation — default to Output so + // that namespace-declared models still appear in the schema. Without + // this, a model declared in the schema namespace but never referenced + // by a query/mutation would silently disappear. + const mutation = engine.mutateModel(originalModel, GraphQLTypeContext.Output); + outputModels.push(mutation.mutatedType); + return; + } + + // Mutate separately for each context - input models get "Input" suffix + if (usedAsOutput) { + const mutation = engine.mutateModel(originalModel, GraphQLTypeContext.Output); + outputModels.push(mutation.mutatedType); + } + if (usedAsInput) { + const mutation = engine.mutateModel(originalModel, GraphQLTypeContext.Input); + inputModels.push(mutation.mutatedType); + } + }; + + const classifyOperation = (op: Operation): void => { + allOperations.push(op); + if (isVoidType(op.returnType)) { + voidOperations.push(op); + return; + } + const kind = getOperationKind(program, op); + if (kind === "Query") queries.push(op); + else if (kind === "Mutation") mutations.push(op); + else if (kind === "Subscription") subscriptions.push(op); + }; + + // Phase A: Walk every type declared in the namespace, mutate it, and + // classify the result. Unreachable types are skipped here so we don't + // pay the cost of mutation for types that won't appear in the schema. + navigateTypesInNamespace(schema, { + model: (node: Model) => { + if (isArrayModelType(program, node)) return; + if (typeUsage.isUnreachable(node)) return; + classifyModel(node); + visitedModelOriginals.push(node); + }, + enum: (node: Enum) => { + if (typeUsage.isUnreachable(node)) return; + const mutation = engine.mutateEnum(node); + enums.push(mutation.mutatedType); + }, + scalar: (node: Scalar) => { + processScalar(node); + }, + union: (node: Union) => { + // Skip nullable unions (e.g., string | null) — they're not union + // declarations. Nullability for these is detected at render time in + // GraphQLTypeExpression. We must NOT mutate them here because + // replace() would call setNullable() on the shared inner type (e.g., + // the string scalar singleton), poisoning all other uses of that type. + if (unwrapNullableUnion(node) !== undefined) return; + if (typeUsage.isUnreachable(node)) return; + const mutation = engine.mutateUnion(node, GraphQLTypeContext.Output); + unions.push(mutation.mutatedType as Union); + wrapperModels.push(...mutation.wrapperModels); + }, + operation: (node: Operation) => { + // Mutate the operation so parameter types point to mutated input models + // and return types point to mutated output models. + const mutation = engine.mutateOperation(node); + classifyOperation(mutation.mutatedType); + }, + }); + + // Phase B: Collect referenced scalars and scalar variants from model + // properties and operation params/returns. Standard-library scalars like + // int64 and utcDateTime aren't declared in the namespace but are + // referenced from properties, so we have to walk the type graph to find + // them. + const visitedTypes = new Set(); + + const collectReferencedScalars = (type: Type): void => { + if (visitedTypes.has(type)) return; + visitedTypes.add(type); + + if (type.kind === "Scalar") { + processScalar(type); + } else if (type.kind === "Model" && isArrayModelType(program, type)) { + if (type.indexer?.value) { + collectReferencedScalars(type.indexer.value); + } + } else if (type.kind === "Model") { + for (const prop of type.properties.values()) { + collectReferencedScalars(prop.type); + } + } else if (type.kind === "Union") { + for (const variant of type.variants.values()) { + collectReferencedScalars(variant.type); + } + } + }; + + // Uses original (pre-mutation) models because mutated scalar refs won't + // match processedScalars' dedup keys (which are GraphQL names derived + // from mutated scalars). + for (const model of visitedModelOriginals) { + for (const prop of model.properties.values()) { + collectReferencedScalars(prop.type); + processScalarVariant(prop); + } + } + + // Walk params/returns for every operation — even void-returning ones, + // since their parameters can still reference scalars. classifyOperation + // above has already excluded void ops from the queries/mutations/ + // subscriptions buckets, but we still want their param types' scalars. + for (const op of allOperations) { + for (const param of op.parameters.properties.values()) { + collectReferencedScalars(param.type); + processScalarVariant(param); + } + collectReferencedScalars(op.returnType); + } + + return { + interfaces, + outputModels, + inputModels, + enums, + scalars, + unions, + queries, + mutations, + subscriptions, + voidOperations, + wrapperModels, + scalarVariants: Array.from(scalarVariantsMap.values()), + scalarSpecifications, + }; +} diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts deleted file mode 100644 index 12e7d59ddd3..00000000000 --- a/packages/graphql/src/registry.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { UsageFlags, type Enum, type Model } from "@typespec/compiler"; -import { - GraphQLBoolean, - GraphQLEnumType, - GraphQLObjectType, - type GraphQLNamedType, - type GraphQLSchemaConfig, -} from "graphql"; - -// The TSPTypeContext interface represents the intermediate TSP type information before materialization. -// It stores the raw TSP type and any extracted metadata relevant for GraphQL generation. -interface TSPTypeContext { - tspType: Enum | Model; // Extend with other TSP types like Operation, Interface, TSP Union, etc. - name: string; - usageFlags?: Set; - // TODO: Add any other TSP-specific metadata here. -} -/** - * GraphQLTypeRegistry manages the registration and materialization of TypeSpec (TSP) - * types into their corresponding GraphQL type definitions. - * - * The registry operates in a two-stage process: - * 1. Registration: TSP types (like Enums, Models, etc.) are first registered - * along with relevant metadata (e.g., name, usage flags). This stores an - * intermediate representation (`TSPTypeContext`) without immediately creating - * GraphQL types. This stage is typically performed while traversing the TSP AST. - * Register type by calling the appropriate method (e.g., `addEnum`). - * - * 2. Materialization: When a GraphQL type is needed (e.g., to build the final - * schema or resolve a field type), the registry can materialize the TSP type - * into its GraphQL counterpart (e.g., `GraphQLEnumType`, `GraphQLObjectType`). - * Materialize types by calling the appropriate method (e.g., `materializeEnum`). - * - * This approach helps in: - * - Decoupling TSP AST traversal from GraphQL object instantiation. - * - Caching materialized GraphQL types to avoid redundant work and ensure object identity. - * - Handling forward references and circular dependencies, as types can be - * registered first and materialized later when all dependencies are known or - * by using thunks for fields/arguments. - */ -export class GraphQLTypeRegistry { - // Stores intermediate TSP type information, keyed by TSP type name. - // TODO: make this more of a seen set - private TSPTypeContextRegistry: Map = new Map(); - - // Stores materialized GraphQL types, keyed by their GraphQL name. - private materializedGraphQLTypes: Map = new Map(); - - addEnum(tspEnum: Enum): void { - const enumName = tspEnum.name; - if (this.TSPTypeContextRegistry.has(enumName)) { - // Optionally, log a warning or update if new information is more complete. - return; - } - - this.TSPTypeContextRegistry.set(enumName, { - tspType: tspEnum, - name: enumName, - // TODO: Populate usageFlags based on TSP context and other decorator context. - }); - } - - // Materializes a TSP Enum into a GraphQLEnumType. - materializeEnum(enumName: string): GraphQLEnumType | undefined { - // Check if the GraphQL type is already materialized. - if (this.materializedGraphQLTypes.has(enumName)) { - return this.materializedGraphQLTypes.get(enumName) as GraphQLEnumType; - } - - const context = this.TSPTypeContextRegistry.get(enumName); - if (!context || context.tspType.kind !== "Enum") { - // TODO: Handle error or warning for missing context. - return undefined; - } - - const tspEnum = context.tspType as Enum; - - const gqlEnum = new GraphQLEnumType({ - name: context.name, - values: Object.fromEntries( - Array.from(tspEnum.members.values()).map((member) => [ - member.name, - { - value: member.value ?? member.name, - }, - ]), - ), - }); - - this.materializedGraphQLTypes.set(enumName, gqlEnum); - return gqlEnum; - } - - materializeSchemaConfig(): GraphQLSchemaConfig { - const allMaterializedGqlTypes = Array.from(this.materializedGraphQLTypes.values()); - let queryType = this.materializedGraphQLTypes.get("Query") as GraphQLObjectType | undefined; - if (!queryType) { - queryType = new GraphQLObjectType({ - name: "Query", - fields: { - _: { - type: GraphQLBoolean, - description: - "A placeholder field. If you are seeing this, it means no operations were defined that could be emitted.", - }, - }, - }); - } - - return { - query: queryType, - types: allMaterializedGqlTypes.length > 0 ? allMaterializedGqlTypes : null, - }; - } -} diff --git a/packages/graphql/src/schema-emitter.ts b/packages/graphql/src/schema-emitter.ts deleted file mode 100644 index b922fe49b0a..00000000000 --- a/packages/graphql/src/schema-emitter.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - createDiagnosticCollector, - navigateTypesInNamespace, - type Diagnostic, - type DiagnosticCollector, - type EmitContext, - type Enum, - type Model, -} from "@typespec/compiler"; -import { GraphQLSchema, validateSchema } from "graphql"; -import { type GraphQLEmitterOptions } from "./lib.js"; -import type { Schema } from "./lib/schema.js"; -import { GraphQLTypeRegistry } from "./registry.js"; - -class GraphQLSchemaEmitter { - private tspSchema: Schema; - private context: EmitContext; - private options: GraphQLEmitterOptions; - private diagnostics: DiagnosticCollector; - private registry: GraphQLTypeRegistry; - constructor( - tspSchema: Schema, - context: EmitContext, - options: GraphQLEmitterOptions, - ) { - // Initialize any properties if needed, including the registry - this.tspSchema = tspSchema; - this.context = context; - this.options = options; - this.diagnostics = createDiagnosticCollector(); - this.registry = new GraphQLTypeRegistry(); - } - - async emitSchema(): Promise<[GraphQLSchema, Readonly] | undefined> { - const schemaNamespace = this.tspSchema.type; - // Logic to emit the GraphQL schema - navigateTypesInNamespace(schemaNamespace, this.semanticNodeListener()); - const schemaConfig = this.registry.materializeSchemaConfig(); - const schema = new GraphQLSchema(schemaConfig); - // validate the schema - const validationErrors = validateSchema(schema); - validationErrors.forEach((error) => { - this.diagnostics.add({ - message: error.message, - code: "GraphQLSchemaValidationError", - target: this.tspSchema.type, - severity: "error", - }); - }); - return [schema, this.diagnostics.diagnostics]; - } - - semanticNodeListener() { - // TODO: Add GraphQL types to registry as the TSP nodes are visited - return { - enum: (node: Enum) => { - this.registry.addEnum(node); - }, - model: (node: Model) => { - // Add logic to handle the model node - }, - exitEnum: (node: Enum) => { - this.registry.materializeEnum(node.name); - }, - exitModel: (node: Model) => { - // Add logic to handle the exit of the model node - }, - }; - } -} - -export function createSchemaEmitter( - schema: Schema, - context: EmitContext, - options: GraphQLEmitterOptions, -): GraphQLSchemaEmitter { - // Placeholder for creating a GraphQL schema emitter - return new GraphQLSchemaEmitter(schema, context, options); -} - -export type { GraphQLSchemaEmitter }; diff --git a/packages/graphql/src/type-maps.ts b/packages/graphql/src/type-maps.ts deleted file mode 100644 index eefb2b8aa33..00000000000 --- a/packages/graphql/src/type-maps.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { UsageFlags, type Type } from "@typespec/compiler"; -import type { GraphQLType } from "graphql"; - -/** - * TypeSpec context for type mapping - * @template T - The TypeSpec type - */ -export interface TSPContext { - type: T; // The TypeSpec type - usageFlag: UsageFlags; // How the type is being used (input, output, etc.) - graphqlName?: string; // Optional GraphQL type name override (e.g., "ModelInput" for input types) - metadata: Record; // Additional metadata -} - -/** - * Nominal type for keys in the TypeMap - */ -type TypeKey = string & { __typeKey: any }; - -/** - * Base TypeMap for all GraphQL type mappings - * @template T - The TypeSpec type constrained to TSP's Type - * @template G - The GraphQL type constrained to GraphQL's GraphQLType - */ -export abstract class TypeMap { - // Map of materialized GraphQL types - protected materializedMap = new Map(); - - // Map of registration contexts - protected registrationMap = new Map>(); - - /** - * Register a TypeSpec type with context for later materialization - * @param context - The TypeSpec context - * @returns The name used for registration as a TypeKey - */ - register(context: TSPContext): TypeKey { - // Check if the type is already registered - const name = this.getNameFromContext(context); - if (this.isRegistered(name)) { - throw new Error(`Type ${name} is already registered`); - } - - // Register the type - this.registrationMap.set(name, context); - return name; - } - - /** - * Get the materialized GraphQL type - * @param name - The type name as a TypeKey - * @returns The materialized GraphQL type or undefined - */ - get(name: TypeKey): G | undefined { - // Return already materialized type if available - if (this.materializedMap.has(name)) { - return this.materializedMap.get(name); - } - - // Attempt to materialize if registered - const context = this.registrationMap.get(name); - if (context) { - const materializedType = this.materialize(context); - this.materializedMap.set(name, materializedType); - return materializedType; - } - - return undefined; - } - - /** - * Check if a type is registered - */ - isRegistered(name: string): boolean { - return this.registrationMap.has(name as TypeKey); - } - - /** - * Get all materialized types - */ - getAllMaterialized(): MapIterator { - return this.materializedMap.values(); - } - - /** - * Reset the type map - */ - reset(): void { - this.materializedMap.clear(); - this.registrationMap.clear(); - } - - /** - * Get a name from a context - */ - protected abstract getNameFromContext(context: TSPContext): TypeKey; - - /** - * Materialize a type from a context - */ - protected abstract materialize(context: TSPContext): G; -} diff --git a/packages/graphql/src/types.d.ts b/packages/graphql/src/types.d.ts index 651cc1bb81f..d8115c70622 100644 --- a/packages/graphql/src/types.d.ts +++ b/packages/graphql/src/types.d.ts @@ -1,22 +1,5 @@ -import type { Diagnostic } from "@typespec/compiler"; -import type { GraphQLSchema } from "graphql"; -import type { Schema } from "./lib/schema.ts"; - -/** - * A record containing the GraphQL schema corresponding to - * a particular schema definition. - */ -export interface GraphQLSchemaRecord { - /** The declared schema that generated this GraphQL schema */ - readonly schema: Schema; - - /** The GraphQLSchema */ - readonly graphQLSchema: GraphQLSchema; - - /** The diagnostics created for this schema */ - readonly diagnostics: readonly Diagnostic[]; -} - declare const tags: unique symbol; -type Tagged = BaseType & { [tags]: { [K in Tag]: void } }; +export type Tagged = BaseType & { + [tags]: { [K in Tag]: void }; +}; diff --git a/packages/graphql/test/arrays.test.ts b/packages/graphql/test/arrays.test.ts new file mode 100644 index 00000000000..38ba71b34c4 --- /dev/null +++ b/packages/graphql/test/arrays.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +describe("arrays", () => { + it("supports array types", async () => { + const code = ` + @schema + namespace TestNamespace { + model Tag { + name: string; + color: string; + } + + model Article { + id: string; + title: string; + tags: Tag[]; + categories: string[]; + } + + @query + op getArticle(id: string): Article; + + @query + op listArticles(): Article[]; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Tag { + name: String! + color: String! + } + + type Article { + id: String! + title: String! + tags: [Tag!]! + categories: [String!]! + } + + type Query { + getArticle(id: String!): Article! + listArticles: [Article!]! + } + + " + `); + }); + + it("emits list types for array properties", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + tags: string[]; + } + + @query + op getUser(): User; + } + `; + + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + tags: [String!]! + } + + type Query { + getUser: User! + } + + " + `); + }); + + it("emits nullable list items for optional element types", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + tags: (string | null)[]; + } + + @query + op getUser(): User; + } + `; + + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + tags: [String]! + } + + type Query { + getUser: User! + } + + " + `); + }); +}); diff --git a/packages/graphql/test/circular-references.test.ts b/packages/graphql/test/circular-references.test.ts new file mode 100644 index 00000000000..0a5470e6ff4 --- /dev/null +++ b/packages/graphql/test/circular-references.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +describe("circular references", () => { + it("handles circular references between models", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + posts: Post[]; + } + + model Post { + id: string; + title: string; + author: User; + comments: Comment[]; + } + + model Comment { + id: string; + text: string; + author: User; + post: Post; + } + + @query + op getUser(id: string): User; + + @query + op getPost(id: string): Post; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + posts: [Post!]! + } + + type Post { + id: String! + title: String! + author: User! + comments: [Comment!]! + } + + type Comment { + id: String! + text: String! + author: User! + post: Post! + } + + type Query { + getUser(id: String!): User! + getPost(id: String!): Post! + } + + " + `); + }); +}); diff --git a/packages/graphql/test/components/component-test-utils.tsx b/packages/graphql/test/components/component-test-utils.tsx new file mode 100644 index 00000000000..33072977c1e --- /dev/null +++ b/packages/graphql/test/components/component-test-utils.tsx @@ -0,0 +1,55 @@ +import { type Children } from "@alloy-js/core"; +import * as gql from "@alloy-js/graphql"; +import { renderSchema, printSchema } from "@alloy-js/graphql"; +import type { Program } from "@typespec/compiler"; +import { GraphQLSchema } from "../../src/components/graphql-schema.js"; +import type { GraphQLSchemaContextValue } from "../../src/context/index.js"; + +export interface RenderOptions { + /** Context overrides for the GraphQL schema context */ + contextOverrides?: Partial; + /** Skip the placeholder Query type (use when testing QueryType component) */ + skipPlaceholderQuery?: boolean; +} + +/** + * Renders GraphQL components in isolation and returns the printed SDL. + * + * Wraps children in the required context providers (TspContext + GraphQLSchemaContext) + * and includes a placeholder Query type by default (required by graphql-js). + * + * Tests should assert on fragments of the returned SDL, ignoring the placeholder Query. + * + * @param options - Either RenderOptions object or context overrides directly (for backwards compatibility) + */ +export function renderComponentToSDL( + program: Program, + children: Children, + options?: RenderOptions | Partial, +): string { + // Backwards compatibility: if options doesn't have RenderOptions keys, treat it as contextOverrides + const isRenderOptions = options && ("contextOverrides" in options || "skipPlaceholderQuery" in options); + const { contextOverrides, skipPlaceholderQuery } = isRenderOptions + ? (options as RenderOptions) + : { contextOverrides: options as Partial | undefined, skipPlaceholderQuery: false }; + const contextValue: GraphQLSchemaContextValue = { + typeGraph: { + globalNamespace: program.getGlobalNamespaceType(), + }, + ...contextOverrides, + }; + + const schema = renderSchema( + + {children} + {!skipPlaceholderQuery && ( + + + + )} + , + { namePolicy: null }, + ); + + return printSchema(schema); +} diff --git a/packages/graphql/test/components/enum-type.test.tsx b/packages/graphql/test/components/enum-type.test.tsx new file mode 100644 index 00000000000..f43a7ee8efa --- /dev/null +++ b/packages/graphql/test/components/enum-type.test.tsx @@ -0,0 +1,144 @@ +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it, beforeEach } from "vitest"; +import { EnumType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("EnumType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic enum", async () => { + const { Color } = await tester.compile( + t.code`enum ${t.enum("Color")} { Red, Green, Blue }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Color).mutatedType; + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "enum Color { + Red + Green + Blue + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders enum with doc comment description", async () => { + const { Role } = await tester.compile( + t.code` + /** The role a user can have */ + enum ${t.enum("Role")} { Admin, User } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Role).mutatedType; + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + """"The role a user can have""" + enum Role { + Admin + User + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders enum with member descriptions", async () => { + const { Status } = await tester.compile( + t.code` + enum ${t.enum("Status")} { + /** Currently active */ + Active, + /** No longer active */ + Inactive, + } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Status).mutatedType; + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "enum Status { + """Currently active""" + Active + + """No longer active""" + Inactive + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders enum with deprecated members", async () => { + const { Status } = await tester.compile( + t.code` + enum ${t.enum("Status")} { + Active, + #deprecated "use Active instead" + Legacy, + } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Status).mutatedType; + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "enum Status { + Active + Legacy @deprecated(reason: "use Active instead") + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders enum with sanitized member names", async () => { + const { E } = await tester.compile( + t.code`enum ${t.enum("E")} { \`$val1$\`, \`val-2\` }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(E).mutatedType; + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "enum E { + _val1_ + val_2 + } + + type Query { + _placeholder: Boolean + }" + `); + }); +}); diff --git a/packages/graphql/test/components/input-type.test.tsx b/packages/graphql/test/components/input-type.test.tsx new file mode 100644 index 00000000000..c5693f46a07 --- /dev/null +++ b/packages/graphql/test/components/input-type.test.tsx @@ -0,0 +1,102 @@ +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it, beforeEach } from "vitest"; +import { InputType } from "../../src/components/types/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("InputType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic input type with fields", async () => { + const { CreateUser } = await tester.compile( + t.code`model ${t.model("CreateUser")} { name: string; email: string; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "input CreateUser { + name: String! + email: String! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders with doc comment description", async () => { + const { LoginInput } = await tester.compile( + t.code` + /** Credentials for login */ + model ${t.model("LoginInput")} { username: string; password: string; } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + """"Credentials for login""" + input LoginInput { + username: String! + password: String! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders optional fields as nullable", async () => { + // In GraphQL, optional fields without defaults are 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 { UpdateUser } = await tester.compile( + t.code`model ${t.model("UpdateUser")} { name?: string; bio?: string; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "input UpdateUser { + name: String + bio: String + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders array fields as list types", async () => { + const { TagInput } = await tester.compile( + t.code`model ${t.model("TagInput")} { values: string[]; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "input TagInput { + values: [String!]! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("throws error for empty model (GraphQL requires at least one field)", async () => { + const { Empty } = await tester.compile(t.code`model ${t.model("Empty")} {}`); + + expect(() => { + renderComponentToSDL(tester.program, ); + }).toThrow(/must define fields/); + }); +}); diff --git a/packages/graphql/test/components/interface-type.test.tsx b/packages/graphql/test/components/interface-type.test.tsx new file mode 100644 index 00000000000..b87fbc2136a --- /dev/null +++ b/packages/graphql/test/components/interface-type.test.tsx @@ -0,0 +1,117 @@ +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it, beforeEach } from "vitest"; +import { InterfaceType } from "../../src/components/types/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("InterfaceType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic interface with fields", async () => { + const { Node } = await tester.compile( + t.code` + @Interface + model ${t.model("Node")} { id: string; } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "interface Node { + id: String! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders with doc comment description", async () => { + const { Entity } = await tester.compile( + t.code` + /** A base entity */ + @Interface + model ${t.model("Entity")} { id: string; } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + """"A base entity""" + interface Entity { + id: String! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders multiple fields with correct types", async () => { + const { Timestamped } = await tester.compile( + t.code` + @Interface + model ${t.model("Timestamped")} { + createdAt: string; + updatedAt: string; + version: int32; + } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "interface Timestamped { + createdAt: String! + updatedAt: String! + version: Int! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders optional fields as nullable", async () => { + const { Described } = await tester.compile( + t.code` + @Interface + model ${t.model("Described")} { description?: string; } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "interface Described { + description: String + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("throws error for empty interface (GraphQL requires at least one field)", async () => { + const { Empty } = await tester.compile( + t.code` + @Interface + model ${t.model("Empty")} {} + `, + ); + + expect(() => { + renderComponentToSDL(tester.program, ); + }).toThrow(/must define fields/); + }); +}); diff --git a/packages/graphql/test/components/object-type.test.tsx b/packages/graphql/test/components/object-type.test.tsx new file mode 100644 index 00000000000..3401dfd79bb --- /dev/null +++ b/packages/graphql/test/components/object-type.test.tsx @@ -0,0 +1,243 @@ +import { t } from "@typespec/compiler/testing"; +import * as gql from "@alloy-js/graphql"; +import { describe, expect, it, beforeEach } from "vitest"; +import { ObjectType } from "../../src/components/types/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("ObjectType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic object type with fields", async () => { + const { User } = await tester.compile( + t.code`model ${t.model("User")} { name: string; age: int32; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "type User { + name: String! + age: Int! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders with doc comment description", async () => { + const { Item } = await tester.compile( + t.code` + /** A store item */ + model ${t.model("Item")} { title: string; } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + """"A store item""" + type Item { + title: String! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders optional fields as nullable", async () => { + const { Profile } = await tester.compile( + t.code`model ${t.model("Profile")} { bio?: string; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "type Profile { + bio: String + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders array fields as list types", async () => { + const { Post } = await tester.compile( + t.code`model ${t.model("Post")} { tags: string[]; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "type Post { + tags: [String!]! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders field with doc comment description", async () => { + const { Thing } = await tester.compile( + t.code` + model ${t.model("Thing")} { + /** The display name */ + name: string; + } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "type Thing { + """The display name""" + name: String! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders deprecated fields", async () => { + const { Entry } = await tester.compile( + t.code` + model ${t.model("Entry")} { + current: string; + #deprecated "use current instead" + old: string; + } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "type Entry { + current: String! + old: String! @deprecated(reason: "use current instead") + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders with interface implementation via @compose", async () => { + const { Pet } = await tester.compile( + t.code` + @Interface + model ${t.model("Node")} { id: string; } + + @compose(Node) + model ${t.model("Pet")} { ...Node; name: string; } + `, + ); + + // Register the Node interface in the schema so buildSchema can resolve it + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "interface Node { + id: String! + } + + type Pet implements Node { + id: String! + name: String! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders operation fields via @operationFields", async () => { + const { Book } = await tester.compile( + t.code` + @operationFields(getRelated) + model ${t.model("Book")} { title: string; } + + op getRelated(limit: int32): Book[]; + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "type Book { + title: String! + getRelated(limit: Int!): [Book!]! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders fields that reference other models", async () => { + const { Author } = await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + model ${t.model("Author")} { name: string; favoriteBook: Book; } + `, + ); + + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Book { + title: String! + } + + type Author { + name: String! + favoriteBook: Book! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("throws error for empty model (GraphQL requires at least one field)", async () => { + const { Empty } = await tester.compile(t.code`model ${t.model("Empty")} {}`); + + expect(() => { + renderComponentToSDL(tester.program, ); + }).toThrow(/must define fields/); + }); +}); diff --git a/packages/graphql/test/components/operation-types.test.tsx b/packages/graphql/test/components/operation-types.test.tsx new file mode 100644 index 00000000000..9f9d860500f --- /dev/null +++ b/packages/graphql/test/components/operation-types.test.tsx @@ -0,0 +1,242 @@ +import { t } from "@typespec/compiler/testing"; +import * as gql from "@alloy-js/graphql"; +import { describe, expect, it, beforeEach } from "vitest"; +import { + QueryType, + MutationType, + SubscriptionType, +} from "../../src/components/operations/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("QueryType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders nothing when no operations", async () => { + await tester.compile(t.code`model Placeholder { id: string; }`); + + const sdl = renderComponentToSDL(tester.program, ); + + // Should only contain the placeholder Query from test utils + expect(sdl).toMatchInlineSnapshot(` + "type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders single query operation with scalar return type", async () => { + const { getVersion } = await tester.compile( + t.code`op ${t.op("getVersion")}(): string;`, + ); + + const sdl = renderComponentToSDL( + tester.program, + , + { skipPlaceholderQuery: true }, + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Query { + getVersion: String! + }" + `); + }); + + it("renders query operation with model return type", async () => { + const { getBook } = await tester.compile( + t.code` + model ${t.model("Book")} { id: string; title: string; } + op ${t.op("getBook")}(id: string): Book; + `, + ); + + // Stub the Book type so buildSchema can resolve the reference + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + , + { skipPlaceholderQuery: true }, + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Book { + id: String! + title: String! + } + + type Query { + getBook(id: String!): Book! + }" + `); + }); + + it("renders multiple query operations", async () => { + const { getCount, getName } = await tester.compile( + t.code` + op ${t.op("getCount")}(): int32; + op ${t.op("getName")}(): string; + `, + ); + + const sdl = renderComponentToSDL( + tester.program, + , + { skipPlaceholderQuery: true }, + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Query { + getCount: Int! + getName: String! + }" + `); + }); + + it("renders query with parameters", async () => { + const { search } = await tester.compile( + t.code`op ${t.op("search")}(query: string, limit?: int32): string[];`, + ); + + const sdl = renderComponentToSDL( + tester.program, + , + { skipPlaceholderQuery: true }, + ); + + // Optional parameters are nullable per GraphQL spec + expect(sdl).toMatchInlineSnapshot(` + "type Query { + search(query: String!, limit: Int): [String!]! + }" + `); + }); +}); + +describe("MutationType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders nothing when no operations", async () => { + await tester.compile(t.code`model Placeholder { id: string; }`); + + const sdl = renderComponentToSDL(tester.program, ); + + // Should only contain the placeholder Query from test utils + expect(sdl).toMatchInlineSnapshot(` + "type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders single mutation operation", async () => { + const { deleteItem } = await tester.compile( + t.code`op ${t.op("deleteItem")}(id: string): boolean;`, + ); + + const sdl = renderComponentToSDL( + tester.program, + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Mutation { + deleteItem(id: String!): Boolean! + } + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders mutation with input parameters", async () => { + const { createUser } = await tester.compile( + t.code` + model ${t.model("User")} { id: string; name: string; } + op ${t.op("createUser")}(name: string, email: string): User; + `, + ); + + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + } + + type Mutation { + createUser(name: String!, email: String!): User! + } + + type Query { + _placeholder: Boolean + }" + `); + }); +}); + +describe("SubscriptionType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders nothing when no operations", async () => { + await tester.compile(t.code`model Placeholder { id: string; }`); + + const sdl = renderComponentToSDL( + tester.program, + , + ); + + // Should only contain the placeholder Query from test utils + expect(sdl).toMatchInlineSnapshot(` + "type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders single subscription operation", async () => { + const { onMessage } = await tester.compile( + t.code`op ${t.op("onMessage")}(): string;`, + ); + + const sdl = renderComponentToSDL( + tester.program, + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Subscription { + onMessage: String! + } + + type Query { + _placeholder: Boolean + }" + `); + }); +}); diff --git a/packages/graphql/test/components/scalar-type.test.tsx b/packages/graphql/test/components/scalar-type.test.tsx new file mode 100644 index 00000000000..6bd723a4fad --- /dev/null +++ b/packages/graphql/test/components/scalar-type.test.tsx @@ -0,0 +1,123 @@ +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it, beforeEach } from "vitest"; +import { ScalarType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { getSpecifiedBy } from "../../src/lib/specified-by.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("ScalarType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a custom scalar", async () => { + const { DateTime } = await tester.compile( + t.code`scalar ${t.scalar("DateTime")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(DateTime); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "scalar DateTime + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders a scalar with doc comment description", async () => { + const { JSON } = await tester.compile( + t.code` + /** Arbitrary JSON blob */ + scalar ${t.scalar("JSON")} extends string; + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(JSON); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + """"Arbitrary JSON blob""" + scalar JSON + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders a scalar with @specifiedBy from prop", async () => { + const { MyScalar } = await tester.compile( + t.code` + @specifiedBy("https://example.com/spec") + scalar ${t.scalar("MyScalar")} extends string; + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(MyScalar); + + // Get spec URL like the emitter does + const specUrl = getSpecifiedBy(tester.program, mutation.mutatedType); + + const sdl = renderComponentToSDL( + tester.program, + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "scalar MyScalar @specifiedBy(url: "https://example.com/spec") + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders a scalar without @specifiedBy when not in context", async () => { + const { MyScalar } = await tester.compile( + t.code`scalar ${t.scalar("MyScalar")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(MyScalar); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "scalar MyScalar + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders a scalar with sanitized name", async () => { + await tester.compile( + t.code`scalar ${t.scalar("$Bad$")} extends string;`, + ); + + const BadScalar = tester.program.getGlobalNamespaceType().scalars.get("$Bad$")!; + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(BadScalar); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toMatchInlineSnapshot(` + "scalar _Bad_ + + type Query { + _placeholder: Boolean + }" + `); + }); +}); diff --git a/packages/graphql/test/components/union-type.test.tsx b/packages/graphql/test/components/union-type.test.tsx new file mode 100644 index 00000000000..a1970b419e2 --- /dev/null +++ b/packages/graphql/test/components/union-type.test.tsx @@ -0,0 +1,212 @@ +import { type Union } from "@typespec/compiler"; +import { t } from "@typespec/compiler/testing"; +import * as gql from "@alloy-js/graphql"; +import { describe, expect, it, beforeEach } from "vitest"; +import { UnionType } from "../../src/components/types/index.js"; +import { + createGraphQLMutationEngine, + type GraphQLUnionMutation, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +/** Assert that an output-context union mutation produced a Union (not a Model). */ +function assertUnionResult(mutation: GraphQLUnionMutation): Union { + if (mutation.mutatedType.kind !== "Union") { + throw new Error( + `Expected Union from output-context mutation, got ${mutation.mutatedType.kind}`, + ); + } + return mutation.mutatedType; +} + +describe("UnionType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a union of model types", async () => { + const { Pet } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + union ${t.union("Pet")} { cat: Cat; dog: Dog; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); + + // Union members must be registered in the schema for buildSchema to resolve them + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Cat { + name: String! + } + + type Dog { + breed: String! + } + + union Pet = Cat | Dog + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders a union with doc comment description", async () => { + const { Result } = await tester.compile( + t.code` + model ${t.model("Success")} { value: string; } + model ${t.model("Failure")} { message: string; } + /** The result of an operation */ + union ${t.union("Result")} { success: Success; failure: Failure; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Result, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); + + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Success { + value: String! + } + + type Failure { + message: String! + } + + """The result of an operation""" + union Result = Success | Failure + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("renders a union with multiple model members", async () => { + const { Shape } = await tester.compile( + t.code` + model ${t.model("Circle")} { radius: float32; } + model ${t.model("Square")} { side: float32; } + model ${t.model("Triangle")} { base: float32; } + union ${t.union("Shape")} { circle: Circle; square: Square; triangle: Triangle; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Shape, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); + + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + + + + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Circle { + radius: Float! + } + + type Square { + side: Float! + } + + type Triangle { + base: Float! + } + + union Shape = Circle | Square | Triangle + + type Query { + _placeholder: Boolean + }" + `); + }); + + it("references wrapper type names for scalar variants", async () => { + const { Mixed } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); + + // Register Cat and the wrapper type that the union will reference + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toMatchInlineSnapshot(` + "type Cat { + name: String! + } + + type MixedTextUnionVariant { + value: String! + } + + union Mixed = Cat | MixedTextUnionVariant + + type Query { + _placeholder: Boolean + }" + `); + }); +}); diff --git a/packages/graphql/test/deprecation.test.ts b/packages/graphql/test/deprecation.test.ts new file mode 100644 index 00000000000..33d43c81470 --- /dev/null +++ b/packages/graphql/test/deprecation.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +describe("deprecation", () => { + it("supports @deprecated directive", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + #deprecated "Use email instead" + username: string; + } + + @query + op getUser(id: string): User; + + #deprecated "Use getUserById instead" + @query + op findUser(id: string): User; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + username: String! @deprecated(reason: "Use email instead") + } + + type Query { + getUser(id: String!): User! + findUser(id: String!): User! @deprecated(reason: "Use getUserById instead") + } + + " + `); + }); +}); diff --git a/packages/graphql/test/diagnostics.test.ts b/packages/graphql/test/diagnostics.test.ts new file mode 100644 index 00000000000..91f9d9a18f8 --- /dev/null +++ b/packages/graphql/test/diagnostics.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; + +/** + * Integration tests for GraphQL emitter diagnostics. + * Tests that appropriate warnings and errors are reported. + */ +describe("diagnostics", () => { + describe("empty-schema", () => { + it("warns when schema has no operations", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + } + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const diagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/empty-schema", + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].severity).toBe("warning"); + expect(diagnostics[0].message).toContain("at least one query operation"); + }); + + it("warns when schema has only mutations (no query)", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + } + + @mutation + op createUser(name: string): User; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const diagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/empty-schema", + ); + + expect(diagnostics).toHaveLength(1); + }); + + it("warns when schema has only subscriptions (no query)", async () => { + const code = ` + @schema + namespace TestNamespace { + model Event { + id: string; + } + + @subscription + op onEvent(): Event; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const diagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/empty-schema", + ); + + expect(diagnostics).toHaveLength(1); + }); + }); + + describe("void-operation-return", () => { + it("warns when operation returns void", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + } + + @query + op getUsers(): User[]; + + @mutation + op doSomething(): void; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const diagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/void-operation-return", + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].severity).toBe("warning"); + expect(diagnostics[0].message).toContain("doSomething"); + }); + + it("warns for multiple void operations", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + } + + @query + op getUsers(): User[]; + + @mutation + op doFirst(): void; + + @mutation + op doSecond(): void; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const diagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/void-operation-return", + ); + + expect(diagnostics).toHaveLength(2); + }); + }); + + describe("union diagnostics", () => { + // TODO: This test crashes in Alloy's schema builder - nested unions aren't supported yet + it.skip("warns on duplicate union variants after flattening", async () => { + const code = ` + @schema + namespace TestNamespace { + model A { a: string; } + model B { b: string; } + + union Inner { + a: A, + b: B, + } + + // Outer includes A directly and via Inner (which contains A) + union Outer { + a: A, + inner: Inner, + } + + model Container { + value: Outer; + } + + @query + op get(): Container; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const diagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/duplicate-union-variant", + ); + + // A appears twice after flattening Inner into Outer + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + expect(diagnostics[0].severity).toBe("warning"); + }); + + }); + + describe("interface diagnostics", () => { + it("errors when @compose used with non-interface", async () => { + const code = ` + @schema + namespace TestNamespace { + model NotAnInterface { + id: string; + } + + @compose(NotAnInterface) + model User { + id: string; + name: string; + } + + @query + op getUser(): User; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const diagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/invalid-interface", + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].severity).toBe("error"); + }); + + it("errors when interface property is missing", async () => { + const code = ` + @schema + namespace TestNamespace { + @Interface + model Node { + id: string; + } + + @compose(Node) + model User { + // Missing 'id' property! + name: string; + } + + @query + op getUser(): User; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const diagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/missing-interface-property", + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].severity).toBe("error"); + }); + + it("errors when interface property type is incompatible", async () => { + const code = ` + @schema + namespace TestNamespace { + @Interface + model Node { + id: string; + } + + @compose(Node) + model User { + id: int32; // Wrong type! + name: string; + } + + @query + op getUser(): User; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const diagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/incompatible-interface-property", + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].severity).toBe("error"); + }); + + it("errors on circular interface implementation", async () => { + const code = ` + @schema + namespace TestNamespace { + @compose(SelfRef) + @Interface + model SelfRef { + id: string; + } + + @compose(SelfRef) + model User { + id: string; + } + + @query + op getUser(): User; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const diagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/circular-interface", + ); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].severity).toBe("error"); + }); + }); + + describe("operation-kind diagnostics", () => { + it("errors when multiple operation kinds applied", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + } + + @query + op getUsers(): User[]; + + @query + @mutation + op conflicting(): User; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const diagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/graphql-operation-kind-duplicate", + ); + + expect(diagnostics.length).toBeGreaterThanOrEqual(1); + expect(diagnostics[0].severity).toBe("error"); + }); + }); + + describe("no errors for valid schemas", () => { + it("produces no errors for well-formed schema", async () => { + const code = ` + @schema + namespace TestNamespace { + @Interface + model Node { + id: string; + } + + @compose(Node) + model User { + id: string; + name: string; + } + + @query + op getUser(id: string): User; + + @mutation + op createUser(name: string): User; + + @subscription + op onUserCreated(): User; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const errors = result.diagnostics.filter((d) => d.severity === "error"); + + expect(errors).toHaveLength(0); + expect(result.graphQLOutput).toBeDefined(); + }); + }); +}); diff --git a/packages/graphql/test/doc-comments.test.ts b/packages/graphql/test/doc-comments.test.ts new file mode 100644 index 00000000000..9156e9fc77b --- /dev/null +++ b/packages/graphql/test/doc-comments.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +describe("doc comments", () => { + it("propagates doc comments to model descriptions", async () => { + const code = ` + @schema + namespace TestNamespace { + /** A user in the system */ + model User { + id: string; + name: string; + } + + @query + op getUser(id: string): User; + } + `; + + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + """"A user in the system""" + type User { + id: String! + name: String! + } + + type Query { + getUser(id: String!): User! + } + + " + `); + }); + + it("propagates doc comments to field descriptions", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + /** The unique identifier */ + id: string; + /** The user's display name */ + name: string; + } + + @query + op getUser(id: string): User; + } + `; + + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + """The unique identifier""" + id: String! + + """The user's display name""" + name: String! + } + + type Query { + getUser(id: String!): User! + } + + " + `); + }); + + it("propagates doc comments to enum descriptions", async () => { + const code = ` + @schema + namespace TestNamespace { + /** The role of a user */ + enum Role { + /** Administrator with full access */ + Admin, + /** Regular user */ + User, + } + + model Person { + role: Role; + } + + @query + op getPerson(): Person; + } + `; + + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + """"The role of a user""" + enum Role { + """Administrator with full access""" + Admin + + """Regular user""" + User + } + + type Person { + role: Role! + } + + type Query { + getPerson: Person! + } + + " + `); + }); +}); diff --git a/packages/graphql/test/e2e.test.ts b/packages/graphql/test/e2e.test.ts new file mode 100644 index 00000000000..b5024093988 --- /dev/null +++ b/packages/graphql/test/e2e.test.ts @@ -0,0 +1,435 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema, emitSingleSchemaWithDiagnostics } from "./test-host.js"; + +/** + * End-to-end integration tests that compile complete TypeSpec schemas and verify + * the full GraphQL SDL output. These tests exercise the entire pipeline: + * TypeSpec parsing → mutation → classification → component rendering → SDL output. + * + * For focused tests of individual features, see: + * - arrays.test.ts + * - circular-references.test.ts + * - deprecation.test.ts + * - doc-comments.test.ts + * - enums.test.ts + * - input-output-splitting.test.ts + * - nullability.test.ts + * - scalars.test.ts + * - unions.test.ts + */ +describe("End-to-end", () => { + it("generates valid schema for basic types", async () => { + const code = ` + @schema + namespace TestNamespace { + model Book { + id: string; + title: string; + } + + @query + op getBook(id: string): Book; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const errors = result.diagnostics.filter((d) => d.severity === "error"); + + expect(errors).toHaveLength(0); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Book { + id: String! + title: String! + } + + type Query { + getBook(id: String!): Book! + } + + " + `); + }); + + it("generates a complete API schema with queries, mutations, enums, interfaces, and input/output splitting", async () => { + const code = ` + @schema + namespace PetStore { + /** The species of a pet */ + enum Species { + Dog, + Cat, + Bird, + Fish, + } + + /** Shared fields for all entities */ + @Interface + model Entity { + id: string; + createdAt: string; + } + + /** A pet owner */ + @compose(Entity) + model Owner { + ...Entity; + name: string; + email: string; + } + + /** A pet in the store */ + @compose(Entity) + model Pet { + ...Entity; + name: string; + species: Species; + /** The pet's age in years */ + age: int32; + owner: Owner; + vaccinated: boolean; + nicknames: string[]; + notes?: string; + } + + /** Statistics about the store */ + model StoreStats { + totalPets: int32; + totalOwners: int32; + } + + @query + op getPet(id: string): Pet; + + @query + op listPets(species?: Species, limit?: int32): Pet[]; + + @query + op getStoreStats(): StoreStats; + + @mutation + op createPet(pet: Pet): Pet; + + @mutation + op updatePet(id: string, pet: Pet): Pet; + + @mutation + op deletePet(id: string): boolean; + + @subscription + op onPetAdded(): Pet; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + """"The species of a pet""" + enum Species { + Dog + Cat + Bird + Fish + } + + """Shared fields for all entities""" + interface Entity { + id: String! + createdAt: String! + } + + """A pet owner""" + type Owner implements Entity { + id: String! + createdAt: String! + name: String! + email: String! + } + + """A pet in the store""" + type Pet implements Entity { + id: String! + createdAt: String! + name: String! + species: Species! + + """The pet's age in years""" + age: Int! + owner: Owner! + vaccinated: Boolean! + nicknames: [String!]! + notes: String + } + + """Statistics about the store""" + type StoreStats { + totalPets: Int! + totalOwners: Int! + } + + """A pet owner""" + input OwnerInput { + id: String! + createdAt: String! + name: String! + email: String! + } + + """A pet in the store""" + input PetInput { + id: String! + createdAt: String! + name: String! + species: Species! + + """The pet's age in years""" + age: Int! + owner: OwnerInput! + vaccinated: Boolean! + nicknames: [String!]! + notes: String + } + + type Query { + getPet(id: String!): Pet! + listPets(species: Species, limit: Int): [Pet!]! + getStoreStats: StoreStats! + } + + type Mutation { + createPet(pet: PetInput!): Pet! + updatePet(id: String!, pet: PetInput!): Pet! + deletePet(id: String!): Boolean! + } + + type Subscription { + onPetAdded: Pet! + } + + " + `); + }); + + describe("empty schema diagnostics", () => { + it("emits empty-schema warning when no query operations defined", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + } + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const warnings = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/empty-schema", + ); + + expect(warnings).toHaveLength(1); + expect(warnings[0].severity).toBe("warning"); + expect(result.graphQLOutput).toBeUndefined(); + }); + + it("emits empty-schema warning when only mutations exist (no query)", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + } + + @mutation + op setUserName(id: string, name: string): User; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const warnings = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/empty-schema", + ); + + expect(warnings).toHaveLength(1); + expect(result.graphQLOutput).toBeUndefined(); + }); + }); + + it("handles all GraphQL field types in a single model", async () => { + const code = ` + @schema + namespace TestNamespace { + scalar DateTime; + + enum Role { Admin, User } + + @Interface + model Node { id: string; } + + model Tag { name: string; } + + @compose(Node) + model Article { + ...Node; + title: string; + published: DateTime; + role: Role; + tags: Tag[]; + viewCount: int32; + rating?: float32; + } + + @query + op getArticle(id: string): Article; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "scalar DateTime + + enum Role { + Admin + User + } + + interface Node { + id: String! + } + + type Tag { + name: String! + } + + type Article implements Node { + id: String! + title: String! + published: DateTime! + role: Role! + tags: [Tag!]! + viewCount: Int! + rating: Float + } + + type Query { + getArticle(id: String!): Article! + } + + " + `); + }); + + it("generates valid SDL for complex e-commerce schema", async () => { + const code = ` + @schema + namespace ECommerce { + enum OrderStatus { Pending, Shipped, Delivered } + + @Interface + model Timestamped { + createdAt: string; + updatedAt: string; + } + + @compose(Timestamped) + model Product { + ...Timestamped; + id: string; + name: string; + price: float32; + } + + model OrderItem { + product: Product; + quantity: int32; + } + + @compose(Timestamped) + model Order { + ...Timestamped; + id: string; + items: OrderItem[]; + status: OrderStatus; + } + + @query op getProduct(id: string): Product; + @query op getOrder(id: string): Order; + @mutation op createOrder(order: Order): Order; + @subscription op onOrderStatusChanged(orderId: string): Order; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "enum OrderStatus { + Pending + Shipped + Delivered + } + + interface Timestamped { + createdAt: String! + updatedAt: String! + } + + type Product implements Timestamped { + createdAt: String! + updatedAt: String! + id: String! + name: String! + price: Float! + } + + type OrderItem { + product: Product! + quantity: Int! + } + + type Order implements Timestamped { + createdAt: String! + updatedAt: String! + id: String! + items: [OrderItem!]! + status: OrderStatus! + } + + input ProductInput { + createdAt: String! + updatedAt: String! + id: String! + name: String! + price: Float! + } + + input OrderItemInput { + product: ProductInput! + quantity: Int! + } + + input OrderInput { + createdAt: String! + updatedAt: String! + id: String! + items: [OrderItemInput!]! + status: OrderStatus! + } + + type Query { + getProduct(id: String!): Product! + getOrder(id: String!): Order! + } + + type Mutation { + createOrder(order: OrderInput!): Order! + } + + type Subscription { + onOrderStatusChanged(orderId: String!): Order! + } + + " + `); + }); +}); diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts index bd0b1a74566..6f53cc35959 100644 --- a/packages/graphql/test/emitter.test.ts +++ b/packages/graphql/test/emitter.test.ts @@ -1,18 +1,8 @@ -import { strictEqual } from "node:assert"; -import { describe, it } from "vitest"; -import { emitSingleSchema } from "./test-host.js"; - -// For now, the expected output is a placeholder string. -// In the future, this should be replaced with the actual GraphQL schema output. -const expectedGraphQLSchema = `type Query { - """ - A placeholder field. If you are seeing this, it means no operations were defined that could be emitted. - """ - _: Boolean -}`; - -describe("name", () => { - it("Emits a schema.graphql file with placeholder text", async () => { +import { describe, expect, it } from "vitest"; +import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; + +describe("emitter", () => { + it("emits multiple types and operations", async () => { const code = ` @schema namespace TestNamespace { @@ -26,11 +16,74 @@ describe("name", () => { name: string; books: Book[]; } - op getBooks(): Book[]; - op getAuthors(): Author[]; + @query op getBooks(): Book[]; + @query op getAuthors(): Author[]; } `; - const results = await emitSingleSchema(code, {}); - strictEqual(results, expectedGraphQLSchema); + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const errors = result.diagnostics.filter((d) => d.severity === "error"); + + expect(errors).toHaveLength(0); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Book { + name: String! + page_count: Int! + published: Boolean! + price: Float! + } + + type Author { + name: String! + books: [Book!]! + } + + type Query { + getBooks: [Book!]! + getAuthors: [Author!]! + } + + " + `); + }); + + it("warns when a schema has no query operations", async () => { + const code = ` + @schema + namespace TestNamespace { + model Book { + name: string; + } + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const emptySchemaDiagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/empty-schema", + ); + + expect(emptySchemaDiagnostics).toHaveLength(1); + expect(emptySchemaDiagnostics[0].severity).toBe("warning"); + }); + + it("warns when an operation returns void", async () => { + const code = ` + @schema + namespace TestNamespace { + model Book { + name: string; + } + @query op getBooks(): Book[]; + @mutation op doNothing(): void; + } + `; + + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const voidDiagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/void-operation-return", + ); + + expect(voidDiagnostics).toHaveLength(1); + expect(voidDiagnostics[0].severity).toBe("warning"); }); }); diff --git a/packages/graphql/test/enums.test.ts b/packages/graphql/test/enums.test.ts new file mode 100644 index 00000000000..58dacec0ed0 --- /dev/null +++ b/packages/graphql/test/enums.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +describe("enums", () => { + it("supports enums with descriptions", async () => { + const code = ` + @schema + namespace TestNamespace { + /** The status of an order */ + enum OrderStatus { + /** Order has been placed but not processed */ + Pending, + /** Order is being prepared */ + Processing, + /** Order has been shipped */ + Shipped, + /** Order has been delivered */ + Delivered, + /** Order was cancelled */ + Cancelled, + } + + model Order { + id: string; + status: OrderStatus; + } + + @query + op getOrder(id: string): Order; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + """"The status of an order""" + enum OrderStatus { + """Order has been placed but not processed""" + Pending + + """Order is being prepared""" + Processing + + """Order has been shipped""" + Shipped + + """Order has been delivered""" + Delivered + + """Order was cancelled""" + Cancelled + } + + type Order { + id: String! + status: OrderStatus! + } + + type Query { + getOrder(id: String!): Order! + } + + " + `); + }); + + describe("numeric values", () => { + it("uses member names (not values) for enum members with numeric values", async () => { + const code = ` + @schema + namespace TestNamespace { + /** Enum with Values */ + enum Hour { + Nothing: 0, + HalfofHalf: 0.25, + SweetSpot: 0.5, + AlmostFull: 0.75, + } + + model Schedule { + duration: Hour; + } + + @query + op getSchedule(): Schedule; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + """"Enum with Values""" + enum Hour { + Nothing + HalfofHalf + SweetSpot + AlmostFull + } + + type Schedule { + duration: Hour! + } + + type Query { + getSchedule: Schedule! + } + + " + `); + }); + + it("uses member names (not values) for enum members with negative values", async () => { + const code = ` + @schema + namespace TestNamespace { + enum Boundary { + zero: 0, + negOne: -1, + one: 1, + } + + model Range { + boundary: Boundary; + } + + @query + op getRange(): Range; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "enum Boundary { + zero + negOne + one + } + + type Range { + boundary: Boundary! + } + + type Query { + getRange: Range! + } + + " + `); + }); + }); +}); diff --git a/packages/graphql/test/input-output-splitting.test.ts b/packages/graphql/test/input-output-splitting.test.ts new file mode 100644 index 00000000000..4cedd5eddb0 --- /dev/null +++ b/packages/graphql/test/input-output-splitting.test.ts @@ -0,0 +1,522 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +describe("input/output type splitting", () => { + it("generates both type and input when model used as both input and output", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + email: string; + } + + @mutation + op createUser(user: User): User; + + @query + op getUser(id: string): User; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + email: String! + } + + input UserInput { + id: String! + name: String! + email: String! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + createUser(user: UserInput!): User! + } + + " + `); + }); + + it("generates only output type when model used only as output", async () => { + const code = ` + @schema + namespace TestNamespace { + model Book { + id: string; + title: string; + } + + @query + op getBook(id: string): Book; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Book { + id: String! + title: String! + } + + type Query { + getBook(id: String!): Book! + } + + " + `); + }); + + it("generates only input type when model used only as input", async () => { + const code = ` + @schema + namespace TestNamespace { + model CreateBookInput { + title: string; + authorId: string; + } + + @query + op getBooks(): string[]; + + @mutation + op createBook(input: CreateBookInput): string; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "input CreateBookInput { + title: String! + authorId: String! + } + + type Query { + getBooks: [String!]! + } + + type Mutation { + createBook(input: CreateBookInput!): String! + } + + " + `); + }); + + it("handles nested models with input/output splitting", async () => { + const code = ` + @schema + namespace TestNamespace { + model Address { + street: string; + city: string; + } + + model User { + id: string; + name: string; + address: Address; + } + + @mutation + op createUser(user: User): User; + + @query + op getUser(id: string): User; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Address { + street: String! + city: String! + } + + type User { + id: String! + name: String! + address: Address! + } + + input AddressInput { + street: String! + city: String! + } + + input UserInput { + id: String! + name: String! + address: AddressInput! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + createUser(user: UserInput!): User! + } + + " + `); + }); + + it("handles arrays with input/output splitting", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + friends: User[]; + } + + @mutation + op createUser(user: User): User; + + @query + op getUser(id: string): User; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + friends: [User!]! + } + + input UserInput { + id: String! + name: String! + friends: [UserInput!]! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + createUser(user: UserInput!): User! + } + + " + `); + }); + + it("handles complex scenario with multiple models and operations", async () => { + const code = ` + @schema + namespace TestNamespace { + model Author { + id: string; + name: string; + bio?: string; + } + + model Book { + id: string; + title: string; + author: Author; + } + + model CreateBookInput { + title: string; + authorId: string; + } + + // Author used as both input and output + @mutation + op createAuthor(author: Author): Author; + + @query + op getAuthor(id: string): Author; + + // Book used only as output + @query + op getBook(id: string): Book; + + // CreateBookInput used only as input, Book as output + @mutation + op createBook(input: CreateBookInput): Book; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Author { + id: String! + name: String! + bio: String + } + + type Book { + id: String! + title: String! + author: Author! + } + + input AuthorInput { + id: String! + name: String! + bio: String + } + + input CreateBookInput { + title: String! + authorId: String! + } + + type Query { + getAuthor(id: String!): Author! + getBook(id: String!): Book! + } + + type Mutation { + createAuthor(author: AuthorInput!): Author! + createBook(input: CreateBookInput!): Book! + } + + " + `); + }); + + it("handles models with optional fields in input/output splitting", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + bio?: string; + age?: int32; + } + + @query + op getUser(id: string): User; + + @mutation + op updateUser(id: string, user: User): User; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + bio: String + age: Int + } + + input UserInput { + id: String! + name: String! + bio: String + age: Int + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + updateUser(id: String!, user: UserInput!): User! + } + + " + `); + }); + + it("handles complex nested input/output splitting with correct variant references", async () => { + const code = ` + @schema + namespace TestNamespace { + model Address { + street: string; + city: string; + country: string; + } + + model ContactInfo { + email: string; + phone?: string; + address: Address; + } + + model User { + id: string; + name: string; + contact: ContactInfo; + friends: User[]; + } + + @mutation + op createUser(name: string, contact: ContactInfo): User; + + @query + op getUser(id: string): User; + + @mutation + op updateUser(id: string, user: User): User; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Address { + street: String! + city: String! + country: String! + } + + type ContactInfo { + email: String! + phone: String + address: Address! + } + + type User { + id: String! + name: String! + contact: ContactInfo! + friends: [User!]! + } + + input AddressInput { + street: String! + city: String! + country: String! + } + + input ContactInfoInput { + email: String! + phone: String + address: AddressInput! + } + + input UserInput { + id: String! + name: String! + contact: ContactInfoInput! + friends: [UserInput!]! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + createUser(name: String!, contact: ContactInfoInput!): User! + updateUser(id: String!, user: UserInput!): User! + } + + " + `); + }); + + it("handles deeply nested models with input/output splitting", async () => { + const code = ` + @schema + namespace TestNamespace { + model Country { + code: string; + name: string; + } + + model City { + name: string; + country: Country; + } + + model Address { + street: string; + city: City; + } + + model User { + id: string; + address: Address; + } + + @query + op getUser(id: string): User; + + @mutation + op createUser(user: User): User; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Country { + code: String! + name: String! + } + + type City { + name: String! + country: Country! + } + + type Address { + street: String! + city: City! + } + + type User { + id: String! + address: Address! + } + + input CountryInput { + code: String! + name: String! + } + + input CityInput { + name: String! + country: CountryInput! + } + + input AddressInput { + street: String! + city: CityInput! + } + + input UserInput { + id: String! + address: AddressInput! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + createUser(user: UserInput!): User! + } + + " + `); + }); +}); diff --git a/packages/graphql/test/interfaces-sdl.test.ts b/packages/graphql/test/interfaces-sdl.test.ts new file mode 100644 index 00000000000..2959a175959 --- /dev/null +++ b/packages/graphql/test/interfaces-sdl.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +/** + * Integration tests for GraphQL interface SDL output. + * For decorator behavior tests, see interface.test.ts. + */ +describe("interfaces SDL output", () => { + it("emits interface type declaration", async () => { + const code = ` + @schema + namespace TestNamespace { + /** A node with a unique identifier */ + @Interface + model Node { + id: string; + } + + @compose(Node) + model User { + id: string; + name: string; + } + + @query + op getUser(id: string): User; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + """"A node with a unique identifier""" + interface Node { + id: String! + } + + type User implements Node { + id: String! + name: String! + } + + type Query { + getUser(id: String!): User! + } + + " + `); + }); + + it("emits model implementing multiple interfaces", async () => { + const code = ` + @schema + namespace TestNamespace { + @Interface + model Node { + id: string; + } + + @Interface + model Timestamped { + createdAt: string; + updatedAt: string; + } + + @compose(Node, Timestamped) + model Article { + id: string; + createdAt: string; + updatedAt: string; + title: string; + body: string; + } + + @query + op getArticle(id: string): Article; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "interface Node { + id: String! + } + + interface Timestamped { + createdAt: String! + updatedAt: String! + } + + type Article implements Node & Timestamped { + id: String! + createdAt: String! + updatedAt: String! + title: String! + body: String! + } + + type Query { + getArticle(id: String!): Article! + } + + " + `); + }); + + it("emits interface implementing another interface", async () => { + const code = ` + @schema + namespace TestNamespace { + @Interface + model Node { + id: string; + } + + @Interface + @compose(Node) + model Authored { + id: string; + authorId: string; + } + + @compose(Authored) + model Post { + id: string; + authorId: string; + title: string; + } + + @query + op getPost(id: string): Post; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "interface Node { + id: String! + } + + interface Authored { + id: String! + authorId: String! + } + + type Post implements Authored { + id: String! + authorId: String! + title: String! + } + + type Query { + getPost(id: String!): Post! + } + + " + `); + }); + + it("emits multiple models implementing same interface", async () => { + const code = ` + @schema + namespace TestNamespace { + /** A searchable entity */ + @Interface + model Searchable { + searchText: string; + } + + @compose(Searchable) + model Product { + id: string; + searchText: string; + name: string; + price: float64; + } + + @compose(Searchable) + model Article { + id: string; + searchText: string; + title: string; + body: string; + } + + @query + op getProduct(id: string): Product; + + @query + op getArticle(id: string): Article; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + """"A searchable entity""" + interface Searchable { + searchText: String! + } + + type Product implements Searchable { + searchText: String! + id: String! + name: String! + price: Float! + } + + type Article implements Searchable { + searchText: String! + id: String! + title: String! + body: String! + } + + type Query { + getProduct(id: String!): Product! + getArticle(id: String!): Article! + } + + " + `); + }); + + it("emits interface with complex field types", async () => { + const code = ` + @schema + namespace TestNamespace { + model Tag { + name: string; + } + + @Interface + model Taggable { + tags: Tag[]; + } + + @compose(Taggable) + model Post { + id: string; + tags: Tag[]; + title: string; + } + + @query + op getPost(id: string): Post; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "interface Taggable { + tags: [Tag!]! + } + + type Tag { + name: String! + } + + type Post implements Taggable { + tags: [Tag!]! + id: String! + title: String! + } + + type Query { + getPost(id: String!): Post! + } + + " + `); + }); + + it("handles interface with optional fields", async () => { + const code = ` + @schema + namespace TestNamespace { + @Interface + model Auditable { + createdBy: string; + modifiedBy?: string; + } + + @compose(Auditable) + model Document { + id: string; + createdBy: string; + modifiedBy?: string; + content: string; + } + + @query + op getDocument(id: string): Document; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "interface Auditable { + createdBy: String! + modifiedBy: String + } + + type Document implements Auditable { + createdBy: String! + modifiedBy: String + id: String! + content: String! + } + + type Query { + getDocument(id: String!): Document! + } + + " + `); + }); +}); 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"); - }); - }); - }); diff --git a/packages/graphql/test/main.tsp b/packages/graphql/test/main.tsp index ea4ae208f92..09d6f971cbf 100644 --- a/packages/graphql/test/main.tsp +++ b/packages/graphql/test/main.tsp @@ -1,27 +1,338 @@ import "@typespec/graphql"; -using GraphQL; -@schema(#{ name: "library-schema" }) -namespace MyLibrary { - model Book { +using TypeSpec.GraphQL; + +@schema +namespace TestSchema { + // =================================================================== + // 1. Custom Scalars + // User-defined scalars emit as `scalar ` in GraphQL. + // =================================================================== + + /** An ISO-8601 date-time string */ + scalar DateTime; + + /** Arbitrary JSON blob */ + scalar JSON; + + // =================================================================== + // 2. Built-in Scalar Mappings + // TypeSpec std-lib scalars map to GraphQL custom scalars automatically: + // int64 → Long, numeric → Numeric, decimal/decimal128 → BigDecimal, + // url → URL (with @specifiedBy), plainDate → PlainDate, + // plainTime → PlainTime + // =================================================================== + + model ScalarShowcase { + /** Maps to GraphQL Long custom scalar */ + bigNumber: int64; + /** Maps to GraphQL Numeric custom scalar */ + preciseValue: numeric; + /** Maps to GraphQL BigDecimal custom scalar */ + amount: decimal; + /** Also maps to BigDecimal (deduped) */ + amount128: decimal128; + /** Maps to GraphQL URL custom scalar with @specifiedBy */ + homepage: url; + /** Maps to GraphQL PlainDate custom scalar */ + birthday: plainDate; + /** Maps to GraphQL PlainTime custom scalar */ + alarmTime: plainTime; + } + + // =================================================================== + // 3. Enums + // Enum members are converted to CONSTANT_CASE in GraphQL. + // =================================================================== + + /** The role a user can have in the system */ + enum Role { + Admin, + Moderator, + User, + Guest, + } + + /** Content rating categories */ + enum ContentRating { + GeneralAudience, + ParentalGuidance, + Restricted, + } + + /** Status of a post */ + enum PostStatus { + Draft, + Published, + Archived, + } + + // =================================================================== + // 4. GraphQL Interfaces via @Interface and @compose + // @Interface marks a model as a GraphQL interface. + // @compose specifies which interfaces a model implements. + // =================================================================== + + /** A node with a globally unique identifier */ + @Interface + model Node { + id: string; + } + + /** A timestamped entity */ + @Interface + model Timestamped { + createdAt: DateTime; + updatedAt: DateTime; + } + + /** An interface can implement another interface */ + @Interface + @compose(Node) + model Authored { id: string; + authorId: string; + } + + // =================================================================== + // 5. Output Types (models used only as return values) + // These emit as `type` in GraphQL. + // =================================================================== + + /** A user profile */ + @compose(Node, Timestamped) + model User { + id: string; + createdAt: DateTime; + updatedAt: DateTime; + username: string; + email: string; + displayName?: string; + /** Nullable field (not optional, but value can be null) */ + avatarUrl: url | null; + role: Role; + posts: Post[]; + friends: User[]; + } + + /** A blog post */ + @compose(Node, Timestamped, Authored) + model Post { + id: string; + createdAt: DateTime; + updatedAt: DateTime; + authorId: string; + title: string; + body: string; + /** Optional nullable — both omittable and can be null */ + subtitle?: string | null; + status: PostStatus; + rating: ContentRating; + tags: string[]; + comments: Comment[]; + metadata: JSON; + } + + /** A comment on a post */ + @compose(Node, Authored) + model Comment { + id: string; + authorId: string; + postId: string; + text: string; + /** Optional field — nullable in GraphQL output */ + parentCommentId?: string; + } + + // =================================================================== + // 6. Input/Output Splitting + // Models used as both mutation params and return values + // are emitted as both `type Foo` and `input FooInput`. + // =================================================================== + + /** Geographic coordinates */ + model GeoLocation { + latitude: float64; + longitude: float64; + /** Optional altitude in meters */ + altitude?: float64; + } + + /** A physical address */ + model Address { + street: string; + city: string; + state?: string; + postalCode: string; + country: string; + location?: GeoLocation; + } + + // =================================================================== + // 7. Input-only Types + // Models used only as mutation parameters emit as `input` only. + // =================================================================== + + /** Input for creating a new post */ + model CreatePostInput { title: string; - publicationDate: string; - author: Author; + body: string; + tags: string[]; } - model Author { + /** Input for updating an existing post (all fields nullable for partial updates) */ + model UpdatePostInput { + title: string | null; + body: string | null; + tags: string[] | null; + } + + /** Pagination parameters (all fields nullable — omitting means "no constraint") */ + model PaginationInput { + first: int32 | null; + after: string | null; + last: int32 | null; + before: string | null; + } + + // =================================================================== + // 8. Unions + // Named unions emit as `union Foo = A | B`. + // Anonymous unions in return types get auto-named. + // =================================================================== + + /** A notification a user may receive */ + model Notification { + id: string; + message: string; + read: boolean; + } + + /** A system-level alert */ + model SystemAlert { + id: string; + severity: string; + description: string; + } + + /** Named union of object types */ + union FeedItem { + post: Post, + comment: Comment, + } + + /** Named union of different event types */ + union NotificationEvent { + notification: Notification, + alert: SystemAlert, + } + + // =================================================================== + // 9. Field Arguments via @operationFields + // Operations assigned to models become fields with arguments. + // =================================================================== + + /** Resolve paginated followers for a user */ + op followers(first: int32, after: string | null): User[]; + + /** Search a user's posts by keyword */ + op searchPosts(query: string, limit: int32): Post[]; + + @operationFields(followers, searchPosts) + model UserProfile { id: string; - name: string; + username: string; bio?: string; - books: Book[]; - friend: Author; } - enum Genre { - Fiction, - NonFiction, - Mystery, - Fantasy, + // =================================================================== + // 10. Queries + // @query marks an operation as a Query root field. + // =================================================================== + + @query + op getScalarShowcase(): ScalarShowcase; + + @query + op getUser(id: string): User; + + @query + op getPost(id: string): Post; + + /** Returns a list of posts with optional pagination */ + @query + op listPosts(pagination: PaginationInput): Post[]; + + @query + op getUserProfile(id: string): UserProfile; + + @query op getFeedItem(id: string): FeedItem; + + @query op getNotificationEvent(userId: string): NotificationEvent; + + @query + op getNotification(userId: string): Notification; + + // =================================================================== + // 11. Mutations + // @mutation marks an operation as a Mutation root field. + // Parameters that are model types get emitted as input types. + // =================================================================== + + @mutation + op createPost(input: CreatePostInput): Post; + + @mutation + op updatePost(id: string, input: UpdatePostInput): Post; + + @mutation + op deletePost(id: string): boolean; + + /** Mutation using a model as both input and output → triggers input/output splitting */ + @mutation + op updateAddress(userId: string, address: Address): Address; + + // =================================================================== + // 12. Subscriptions + // @subscription marks an operation as a Subscription root field. + // =================================================================== + + @subscription + op onPostCreated(): Post; + + @subscription + op onCommentAdded(postId: string): Comment; + + @subscription + op onNotification(userId: string): Notification; + + // =================================================================== + // 13. Nullability & Optionality Showcase + // Demonstrates the four combinations: + // required non-null → Type! + // optional → Type (nullable in output) + // nullable → Type (nullable) + // optional + null → Type (nullable) + // =================================================================== + + model NullabilityShowcase { + /** Required non-null → String! */ + required: string; + /** Optional → String (nullable in output) */ + optional?: string; + /** Nullable → String (nullable) */ + nullable: string | null; + /** Optional and nullable → String (nullable) */ + optionalNullable?: string | null; + + /** Required list of non-null items → [String!]! */ + requiredList: string[]; + /** Optional list → [String!] (nullable list) */ + optionalList?: string[]; + /** List of nullable items → [String]! */ + nullableItemList: Array; } + + @query + op getNullabilityShowcase(): NullabilityShowcase; } diff --git a/packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts b/packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts index 4e543b6a2e5..d26ae19e3a3 100644 --- a/packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts +++ b/packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts @@ -216,7 +216,7 @@ describe("GraphQL Mutation Engine - Operations", () => { // 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); + expect(isNullable(mutation.mutatedType)).toBe(true); }); it("does not mark operation as nullable when return type is non-null", async () => { @@ -231,7 +231,55 @@ describe("GraphQL Mutation Engine - Operations", () => { const mutation = engine.mutateOperation(getUser); expect(mutation.mutatedType.returnType.kind).toBe("Model"); - expect(isNullable(tester.program, mutation.mutatedType)).toBe(false); + expect(isNullable(mutation.mutatedType)).toBe(false); + }); + + it("does not mark operation as nullableElements when return type is plain array", async () => { + const { getTags } = await tester.compile( + t.code`op ${t.op("getTags")}(): string[];`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(getTags); + + expect(isNullable(mutation.mutatedType)).toBe(false); + expect(hasNullableElements(mutation.mutatedType)).toBe(false); + }); + + it("marks operation as nullableElements when return type is (T | null)[]", async () => { + const { getTags } = await tester.compile( + t.code`op ${t.op("getTags")}(): (string | null)[];`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(getTags); + + expect(isNullable(mutation.mutatedType)).toBe(false); + expect(hasNullableElements(mutation.mutatedType)).toBe(true); + }); + + it("marks operation as nullable only when return type is T[] | null", async () => { + const { getTags } = await tester.compile( + t.code`op ${t.op("getTags")}(): string[] | null;`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(getTags); + + expect(isNullable(mutation.mutatedType)).toBe(true); + expect(hasNullableElements(mutation.mutatedType)).toBe(false); + }); + + it("marks operation as both nullable and nullableElements when return type is (T | null)[] | null", async () => { + const { getTags } = await tester.compile( + t.code`op ${t.op("getTags")}(): (string | null)[] | null;`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(getTags); + + expect(isNullable(mutation.mutatedType)).toBe(true); + expect(hasNullableElements(mutation.mutatedType)).toBe(true); }); }); @@ -533,7 +581,7 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(mutation.wrapperModels).toHaveLength(0); // 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); + expect(isNullable(mutation.mutatedType)).toBe(false); }); it("replaces nullable model union with inner type", async () => { @@ -552,7 +600,7 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(mutation.wrapperModels).toHaveLength(0); // 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); + expect(isNullable(mutation.mutatedType)).toBe(false); }); it("creates wrapper models for scalar variants", async () => { @@ -647,8 +695,8 @@ describe("GraphQL Mutation Engine - Unions", () => { // 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); + expect(isNullable(nameProp!.type)).toBe(false); + expect(isNullable(nameProp!)).toBe(true); }); it("does not mark non-nullable array property as nullable or nullableElements", async () => { @@ -661,8 +709,8 @@ describe("GraphQL Mutation Engine - Unions", () => { const tagsProp = mutation.mutatedType.properties.get("tags"); expect(tagsProp).toBeDefined(); - expect(isNullable(tester.program, tagsProp!)).toBe(false); - expect(hasNullableElements(tester.program, tagsProp!)).toBe(false); + expect(isNullable(tagsProp!)).toBe(false); + expect(hasNullableElements(tagsProp!)).toBe(false); }); it("marks (T | null)[] property as nullableElements only", async () => { @@ -675,8 +723,8 @@ describe("GraphQL Mutation Engine - Unions", () => { const tagsProp = mutation.mutatedType.properties.get("tags"); expect(tagsProp).toBeDefined(); - expect(isNullable(tester.program, tagsProp!)).toBe(false); - expect(hasNullableElements(tester.program, tagsProp!)).toBe(true); + expect(isNullable(tagsProp!)).toBe(false); + expect(hasNullableElements(tagsProp!)).toBe(true); }); it("marks T[] | null property as nullable only", async () => { @@ -689,8 +737,8 @@ describe("GraphQL Mutation Engine - Unions", () => { const tagsProp = mutation.mutatedType.properties.get("tags"); expect(tagsProp).toBeDefined(); - expect(isNullable(tester.program, tagsProp!)).toBe(true); - expect(hasNullableElements(tester.program, tagsProp!)).toBe(false); + expect(isNullable(tagsProp!)).toBe(true); + expect(hasNullableElements(tagsProp!)).toBe(false); }); it("marks (T | null)[] | null property as both nullable and hasNullableElements", async () => { @@ -705,9 +753,9 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(tagsProp).toBeDefined(); // The outer `| null` marks the property as nullable - expect(isNullable(tester.program, tagsProp!)).toBe(true); + expect(isNullable(tagsProp!)).toBe(true); // The inner `(T | null)` marks the property as having nullable elements - expect(hasNullableElements(tester.program, tagsProp!)).toBe(true); + expect(hasNullableElements(tagsProp!)).toBe(true); }); }); @@ -728,8 +776,8 @@ describe("GraphQL Mutation Engine - Input/Output Context", () => { // Different mutation objects (different cache entries) expect(inputMutation).not.toBe(outputMutation); - // Both produce valid mutated types - expect(inputMutation.mutatedType.name).toBe("Book"); + // Input context adds "Input" suffix, output context doesn't + expect(inputMutation.mutatedType.name).toBe("BookInput"); expect(outputMutation.mutatedType.name).toBe("Book"); }); @@ -867,7 +915,7 @@ describe("GraphQL Mutation Engine - Operation Context Propagation", () => { const unionMutation = engine.mutateUnion(Pet, GraphQLTypeContext.Input); expect(unionMutation.mutatedType.kind).toBe("Model"); expect(unionMutation.mutatedType.name).toBe("PetInput"); - expect(isOneOf(tester.program, unionMutation.mutatedType as Model)).toBe(true); + expect(isOneOf(unionMutation.mutatedType as Model)).toBe(true); }); it("keeps union return type as union via operation mutation", async () => { @@ -910,7 +958,7 @@ describe("GraphQL Mutation Engine - oneOf Input Objects", () => { // Union is replaced with a Model in the type graph expect(mutation.mutatedType.kind).toBe("Model"); expect(mutation.mutatedType.name).toBe("PetInput"); - expect(isOneOf(tester.program, mutation.mutatedType as Model)).toBe(true); + expect(isOneOf(mutation.mutatedType as Model)).toBe(true); }); it("oneOf model has one field per variant, all optional", async () => { @@ -1011,7 +1059,7 @@ describe("GraphQL Mutation Engine - oneOf Input Objects", () => { expect(mutatedUnion.variants.size).toBe(2); // The result should be marked as nullable - expect(isNullable(tester.program, mutatedUnion)).toBe(true); + expect(isNullable(mutatedUnion)).toBe(true); }); it("strips null from multi-variant union in input context", async () => { @@ -1034,8 +1082,8 @@ describe("GraphQL Mutation Engine - oneOf Input Objects", () => { expect(model.properties.has("dog")).toBe(true); // Should be marked as both @oneOf and nullable - expect(isOneOf(tester.program, model)).toBe(true); - expect(isNullable(tester.program, model)).toBe(true); + expect(isOneOf(model)).toBe(true); + expect(isNullable(model)).toBe(true); }); it("non-nullable union is not marked as nullable", async () => { @@ -1050,7 +1098,7 @@ describe("GraphQL Mutation Engine - oneOf Input Objects", () => { const engine = createTestEngine(tester.program); const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); - expect(isNullable(tester.program, mutation.mutatedType)).toBe(false); + expect(isNullable(mutation.mutatedType)).toBe(false); }); it("exposes typeContext on union mutation", async () => { @@ -1068,4 +1116,188 @@ describe("GraphQL Mutation Engine - oneOf Input Objects", () => { expect(inputMutation.typeContext).toBe(GraphQLTypeContext.Input); expect(outputMutation.typeContext).toBe(GraphQLTypeContext.Output); }); + + it("nullable decorator survives type cloning", async () => { + const { Pet } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + union ${t.union("Pet")} { cat: Cat; dog: Dog; null; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + + // Multi-variant union with null: null is stripped, result marked nullable + expect(isNullable(mutation.mutatedType)).toBe(true); + + // Simulate a downstream mutation stage cloning the type + const clone = tester.program.checker.cloneType(mutation.mutatedType); + expect(isNullable(clone)).toBe(true); + }); +}); + +describe("GraphQL Mutation Engine - Input suffix", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("adds Input suffix when mutated with Input context", async () => { + const { Pet } = await tester.compile(t.code`model ${t.model("Pet")} { name: string }`); + + const engine = createTestEngine(tester.program); + const inputMutation = engine.mutateModel(Pet, GraphQLTypeContext.Input); + + expect(inputMutation.mutatedType.name).toBe("PetInput"); + }); + + it("does not add Input suffix when mutated with Output context", async () => { + const { Pet } = await tester.compile(t.code`model ${t.model("Pet")} { name: string }`); + + const engine = createTestEngine(tester.program); + const outputMutation = engine.mutateModel(Pet, GraphQLTypeContext.Output); + + expect(outputMutation.mutatedType.name).toBe("Pet"); + }); + + it("does not double-suffix models already ending in Input", async () => { + const { CreateBookInput } = await tester.compile( + t.code`model ${t.model("CreateBookInput")} { title: string; }`, + ); + + const engine = createTestEngine(tester.program); + const inputMutation = engine.mutateModel(CreateBookInput, GraphQLTypeContext.Input); + + // Should remain "CreateBookInput", not become "CreateBookInputInput" + expect(inputMutation.mutatedType.name).toBe("CreateBookInput"); + }); +}); + +describe("GraphQL Mutation Engine - Nullable union replacement", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("replaces nullable union with mutated inner type in input context", async () => { + const { Container } = await tester.compile( + t.code` + model ${t.model("Address")} { street: string; } + model ${t.model("Container")} { address: Address | null; } + `, + ); + + // Get the nullable union type from the property + const addressProp = Container.properties.get("address")!; + const nullableUnion = addressProp.type as Union; + + const engine = createTestEngine(tester.program); + const unionMutation = engine.mutateUnion(nullableUnion, GraphQLTypeContext.Input); + + // The replacement should be the mutated model with Input suffix + expect(unionMutation.mutatedType.kind).toBe("Model"); + expect(unionMutation.mutatedType.name).toBe("AddressInput"); + }); + + it("replaces nullable union with original type name in output context", async () => { + const { Container } = await tester.compile( + t.code` + model ${t.model("Address")} { street: string; } + model ${t.model("Container")} { address: Address | null; } + `, + ); + + // Get the nullable union type from the property + const addressProp = Container.properties.get("address")!; + const nullableUnion = addressProp.type as Union; + + const engine = createTestEngine(tester.program); + const unionMutation = engine.mutateUnion(nullableUnion, GraphQLTypeContext.Output); + + // In output context, should be unwrapped to Address (no Input suffix) + expect(unionMutation.mutatedType.kind).toBe("Model"); + expect(unionMutation.mutatedType.name).toBe("Address"); + }); +}); + +describe("GraphQL Mutation Engine - Operation type references", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("operation parameters reference mutated input models", async () => { + const { createBook } = await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("createBook")}(book: Book): void; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(createBook); + + // Get the parameter type from the mutated operation + const bookParam = mutation.mutatedType.parameters.properties.get("book"); + expect(bookParam).toBeDefined(); + + // The parameter type should be the mutated model (BookInput), not the original (Book) + expect(bookParam!.type.kind).toBe("Model"); + expect((bookParam!.type as Model).name).toBe("BookInput"); + }); + + it("operation return type references mutated output models", async () => { + const { getBook } = await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("getBook")}(): Book; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(getBook); + + // The return type should be the mutated model (Book, no suffix for output) + expect(mutation.mutatedType.returnType.kind).toBe("Model"); + expect((mutation.mutatedType.returnType as Model).name).toBe("Book"); + }); + + it("operation with both input and output references correct variants", async () => { + const { updateBook } = await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("updateBook")}(book: Book): Book; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(updateBook); + + // Parameter should reference BookInput + const bookParam = mutation.mutatedType.parameters.properties.get("book"); + expect((bookParam!.type as Model).name).toBe("BookInput"); + + // Return type should reference Book (output variant) + expect((mutation.mutatedType.returnType as Model).name).toBe("Book"); + }); + + it("operation with nullable parameter references mutated type", async () => { + const { createOrder } = await tester.compile( + t.code` + model ${t.model("Address")} { street: string; } + op ${t.op("createOrder")}(address: Address | null): void; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(createOrder); + + // The nullable union should be unwrapped and reference the mutated type + const addressParam = mutation.mutatedType.parameters.properties.get("address"); + expect(addressParam).toBeDefined(); + expect(addressParam!.type.kind).toBe("Model"); + expect((addressParam!.type as Model).name).toBe("AddressInput"); + }); }); diff --git a/packages/graphql/test/nullability.test.ts b/packages/graphql/test/nullability.test.ts new file mode 100644 index 00000000000..d31cb71c4e1 --- /dev/null +++ b/packages/graphql/test/nullability.test.ts @@ -0,0 +1,955 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +/** + * Tests for Nullability & Optionality + * + * Design doc rule: In input context, `?` (optional) does NOT make a field nullable. + * Only `| null` makes a field nullable in inputs. In output context, `?` means nullable + * (existing behavior, unchanged). + * + * Design doc table: + * | TypeSpec | Output | Input | + * |--------------------|----------|----------| + * | a: string | String! | String! | + * | b?: string | String | String! | <-- key difference + * | c: string | null | String | String | + * | d?: string | null | String | String | + */ +describe("Nullability vs. Optionality", () => { + describe("Output type nullability (unchanged behavior)", () => { + it("required field is non-null", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { a: string; } + @query op get(): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + a: String! + } + + type Query { + get: Foo! + } + + " + `); + }); + + it("optional field is nullable", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { b?: string; } + @query op get(): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + b: String + } + + type Query { + get: Foo! + } + + " + `); + }); + + it("nullable union is nullable", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { c: string | null; } + @query op get(): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + c: String + } + + type Query { + get: Foo! + } + + " + `); + }); + + it("optional nullable union is nullable", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { d?: string | null; } + @query op get(): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + d: String + } + + type Query { + get: Foo! + } + + " + `); + }); + }); + + describe("Input type nullability (new behavior)", () => { + it("required field is non-null in input", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { a: string; } + @query op get(): Foo; + @mutation op set(foo: Foo): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + a: String! + } + + input FooInput { + a: String! + } + + type Query { + get: Foo! + } + + type Mutation { + set(foo: FooInput!): Foo! + } + + " + `); + }); + + it("optional field is nullable in input (per GraphQL spec)", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { b?: string; } + @query op get(): Foo; + @mutation op set(foo: Foo): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + b: String + } + + input FooInput { + b: String + } + + type Query { + get: Foo! + } + + type Mutation { + set(foo: FooInput!): Foo! + } + + " + `); + }); + + it("nullable union is nullable in input", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { c: string | null; } + @query op get(): Foo; + @mutation op set(foo: Foo): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + c: String + } + + input FooInput { + c: String + } + + type Query { + get: Foo! + } + + type Mutation { + set(foo: FooInput!): Foo! + } + + " + `); + }); + + it("optional nullable union is nullable in input", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { d?: string | null; } + @query op get(): Foo; + @mutation op set(foo: Foo): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + d: String + } + + input FooInput { + d: String + } + + type Query { + get: Foo! + } + + type Mutation { + set(foo: FooInput!): Foo! + } + + " + `); + }); + }); + + describe("Design doc full example", () => { + it("handles all four nullability combinations in both input and output", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { + a: string; + b?: string; + c: string | null; + d?: string | null; + } + @query op getFoo(): Foo; + @mutation op setFoo(foo: Foo): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + a: String! + b: String + c: String + d: String + } + + input FooInput { + a: String! + b: String + c: String + d: String + } + + type Query { + getFoo: Foo! + } + + type Mutation { + setFoo(foo: FooInput!): Foo! + } + + " + `); + }); + + it("handles design doc User model example", async () => { + const code = ` + @schema + namespace TestNamespace { + model Pet { + name: string; + species: string; + } + + model User { + id: int32; + name: string; + pronouns?: string; + birthYear: int32 | null; + pet: Pet | null; + } + + @query op getUser(id: int32): User; + @mutation op createUser(user: User): User; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Pet { + name: String! + species: String! + } + + type User { + id: Int! + name: String! + pronouns: String + birthYear: Int + pet: Pet + } + + input PetInput { + name: String! + species: String! + } + + input UserInput { + id: Int! + name: String! + pronouns: String + birthYear: Int + pet: PetInput + } + + type Query { + getUser(id: Int!): User! + } + + type Mutation { + createUser(user: UserInput!): User! + } + + " + `); + }); + }); + + describe("Operation parameters", () => { + it("required params are non-null", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { id: string; name: string; } + @query op getUser(id: string): User; + @mutation op createUser(user: User): User; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + } + + input UserInput { + id: String! + name: String! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + createUser(user: UserInput!): User! + } + + " + `); + }); + + it("optional params are nullable (per GraphQL spec)", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { id: string; name: string; } + @query op getUser(id: string): User; + @mutation op patchUser(user?: User): User; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + } + + input UserInput { + id: String! + name: String! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + patchUser(user: UserInput): User! + } + + " + `); + }); + + it("nullable params remain nullable", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { id: string; name: string; } + @query op getUser(id: string): User; + @mutation op patchUser(user: User | null): User; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + } + + input UserInput { + id: String! + name: String! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + patchUser(user: UserInput): User! + } + + " + `); + }); + + it("optional nullable params remain nullable", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { id: string; name: string; } + @query op getUser(id: string): User; + @mutation op patchUser(user?: User | null): User; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + } + + input UserInput { + id: String! + name: String! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + patchUser(user: UserInput): User! + } + + " + `); + }); + + it("optional scalar params are non-null in input context", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { id: string; } + @query op getUser(id: string): User; + @mutation op updateUser(id: string, email?: string, phone?: string): User; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + updateUser(id: String!, email: String, phone: String): User! + } + + " + `); + }); + + it("nullable scalar params remain nullable in input context", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { id: string; } + @query op getUser(id: string): User; + @mutation op updateUser(id: string, email: string | null): User; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + updateUser(id: String!, email: String): User! + } + + " + `); + }); + }); + + describe("Array fields in input context", () => { + it("required array is non-null list in input", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { tags: string[]; } + @query op get(): Foo; + @mutation op set(foo: Foo): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + tags: [String!]! + } + + input FooInput { + tags: [String!]! + } + + type Query { + get: Foo! + } + + type Mutation { + set(foo: FooInput!): Foo! + } + + " + `); + }); + + it("optional array is still non-null list in input", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { tags?: string[]; } + @query op get(): Foo; + @mutation op set(foo: Foo): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + tags: [String!] + } + + input FooInput { + tags: [String!] + } + + type Query { + get: Foo! + } + + type Mutation { + set(foo: FooInput!): Foo! + } + + " + `); + }); + + it("nullable array remains nullable in input", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { tags: string[] | null; } + @query op get(): Foo; + @mutation op set(foo: Foo): Foo; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + tags: [String!] + } + + input FooInput { + tags: [String!] + } + + type Query { + get: Foo! + } + + type Mutation { + set(foo: FooInput!): Foo! + } + + " + `); + }); + }); + + describe("Nested model input references", () => { + it("nested optional models become non-null in input", async () => { + const code = ` + @schema + namespace TestNamespace { + model Address { + street: string; + city: string; + } + model User { + name: string; + address?: Address; + } + @query op get(id: string): User; + @mutation op create(user: User): User; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Address { + street: String! + city: String! + } + + type User { + name: String! + address: Address + } + + input AddressInput { + street: String! + city: String! + } + + input UserInput { + name: String! + address: AddressInput + } + + type Query { + get(id: String!): User! + } + + type Mutation { + create(user: UserInput!): User! + } + + " + `); + }); + + it("nested nullable models stay nullable in input", async () => { + const code = ` + @schema + namespace TestNamespace { + model Address { + street: string; + city: string; + } + model User { + name: string; + address: Address | null; + } + @query op get(id: string): User; + @mutation op create(user: User): User; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Address { + street: String! + city: String! + } + + type User { + name: String! + address: Address + } + + input AddressInput { + street: String! + city: String! + } + + input UserInput { + name: String! + address: AddressInput + } + + type Query { + get(id: String!): User! + } + + type Mutation { + create(user: UserInput!): User! + } + + " + `); + }); + }); + + describe("Output field nullability", () => { + it("marks optional fields as nullable", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + nickname?: string; + } + + @query + op getUser(): User; + } + `; + + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + nickname: String + } + + type Query { + getUser: User! + } + + " + `); + }); + + it("marks T | null union fields as nullable", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + bio: string | null; + } + + @query + op getUser(): User; + } + `; + + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + bio: String + } + + type Query { + getUser: User! + } + + " + `); + }); + + it("handles optional properties correctly in both output and input", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + email?: string; + phoneNumber?: string; + } + + @query + op getUser(id: string): User; + + @mutation + op updateUser(id: string, email?: string, phoneNumber?: string): User; + } + `; + + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + email: String + phoneNumber: String + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + updateUser(id: String!, email: String, phoneNumber: String): User! + } + + " + `); + }); + }); + + describe("Comprehensive nullability combinations", () => { + it("handles all combinations of optional and nullable from design doc table", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { + a: string; + b?: string; + c: string | null; + d?: string | null; + } + + @query + op getFoo(): Foo; + + @mutation + op patchFoo(foo: Foo): Foo; + } + `; + + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + a: String! + b: String + c: String + d: String + } + + input FooInput { + a: String! + b: String + c: String + d: String + } + + type Query { + getFoo: Foo! + } + + type Mutation { + patchFoo(foo: FooInput!): Foo! + } + + " + `); + }); + + it("handles list nullability variations", async () => { + const code = ` + @schema + namespace TestNamespace { + model Foo { + a: string[]; + b: Array; + c?: string[]; + d: string[] | null; + } + + @query + op getFoo(): Foo; + } + `; + + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type Foo { + a: [String!]! + b: [String]! + c: [String!] + d: [String!] + } + + type Query { + getFoo: Foo! + } + + " + `); + }); + }); + + describe("Query parameter nullability", () => { + it("optional query params are non-null (input context)", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { id: string; name: string; } + @query op listUsers(limit?: int32, offset?: int32): User[]; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + } + + type Query { + listUsers(limit: Int, offset: Int): [User!]! + } + + " + `); + }); + + it("nullable query params stay nullable", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { id: string; name: string; } + @query op listUsers(limit: int32 | null): User[]; + } + `; + const result = await emitSingleSchema(code, {}); + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + } + + type Query { + listUsers(limit: Int): [User!]! + } + + " + `); + }); + }); +}); diff --git a/packages/graphql/test/operation-fields-sdl.test.ts b/packages/graphql/test/operation-fields-sdl.test.ts new file mode 100644 index 00000000000..739f4de918c --- /dev/null +++ b/packages/graphql/test/operation-fields-sdl.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +/** + * Integration tests for @operationFields SDL output. + * For decorator behavior tests, see operation-fields.test.ts. + */ +describe("@operationFields SDL output", () => { + it("emits object type with field arguments from operation", async () => { + const code = ` + @schema + namespace TestNamespace { + model Post { + id: string; + title: string; + } + + /** Get posts with pagination */ + op posts(first: int32, after: string | null): Post[]; + + @operationFields(posts) + model User { + id: string; + name: string; + } + + @query + op getUser(id: string): User; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Post { + id: String! + title: String! + } + + type User { + id: String! + name: String! + + """Get posts with pagination""" + posts(first: Int!, after: String): [Post!]! + } + + type Query { + getUser(id: String!): User! + } + + " + `); + }); + + it("emits object type with multiple operation fields", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + } + + model Post { + id: string; + title: string; + } + + op followers(limit: int32): User[]; + op posts(status: string | null): Post[]; + op followersCount(): int32; + + @operationFields(followers, posts, followersCount) + model UserProfile { + id: string; + username: string; + bio?: string; + } + + @query + op getUserProfile(id: string): UserProfile; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + } + + type Post { + id: String! + title: String! + } + + type UserProfile { + id: String! + username: String! + bio: String + followers(limit: Int!): [User!]! + posts(status: String): [Post!]! + followersCount: Int! + } + + type Query { + getUserProfile(id: String!): UserProfile! + } + + " + `); + }); + + it("emits operation fields from interface operations", async () => { + const code = ` + @schema + namespace TestNamespace { + model Comment { + id: string; + text: string; + } + + interface Commentable { + op comments(limit: int32, offset: int32): Comment[]; + } + + @operationFields(Commentable) + model Article { + id: string; + title: string; + } + + @query + op getArticle(id: string): Article; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Comment { + id: String! + text: String! + } + + type Article { + id: String! + title: String! + comments(limit: Int!, offset: Int!): [Comment!]! + } + + type Query { + getArticle(id: String!): Article! + } + + " + `); + }); + + it("emits operation field with multiple arguments", async () => { + const code = ` + @schema + namespace TestNamespace { + model Activity { + id: string; + type: string; + timestamp: string; + } + + op activities(minDate: string | null, maxDate: string | null, limit: int32): Activity[]; + + @operationFields(activities) + model User { + id: string; + name: string; + } + + @query + op getUser(id: string): User; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Activity { + id: String! + type: String! + timestamp: String! + } + + type User { + id: String! + name: String! + activities(minDate: String, maxDate: String, limit: Int!): [Activity!]! + } + + type Query { + getUser(id: String!): User! + } + + " + `); + }); + + it("emits operation field returning scalar", async () => { + const code = ` + @schema + namespace TestNamespace { + op connectionCount(): int32; + op isVerified(): boolean; + op rating(): float64; + + @operationFields(connectionCount, isVerified, rating) + model Profile { + id: string; + name: string; + } + + @query + op getProfile(id: string): Profile; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Profile { + id: String! + name: String! + connectionCount: Int! + isVerified: Boolean! + rating: Float! + } + + type Query { + getProfile(id: String!): Profile! + } + + " + `); + }); +}); diff --git a/packages/graphql/test/scalars.test.ts b/packages/graphql/test/scalars.test.ts new file mode 100644 index 00000000000..74e48c55c2c --- /dev/null +++ b/packages/graphql/test/scalars.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +describe("scalars", () => { + describe("custom scalars", () => { + it("emits user-defined scalars", async () => { + const code = ` + @schema + namespace TestNamespace { + /** An ISO-8601 date-time string */ + scalar DateTime; + + /** Arbitrary JSON blob */ + scalar JSON; + + model Event { + id: string; + timestamp: DateTime; + metadata: JSON; + } + + @query + op getEvent(id: string): Event; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + """"An ISO-8601 date-time string""" + scalar DateTime + + """Arbitrary JSON blob""" + scalar JSON + + type Event { + id: String! + timestamp: DateTime! + metadata: JSON! + } + + type Query { + getEvent(id: String!): Event! + } + + " + `); + }); + }); + + describe("built-in scalar mappings", () => { + it("maps int64 to Long scalar", async () => { + const code = ` + @schema + namespace TestNamespace { + model Data { + bigNumber: int64; + } + + @query + op getData(): Data; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "scalar Long @specifiedBy(url: "http://scalars.graphql.org/jakobmerrild/long.html") + + type Data { + bigNumber: Long! + } + + type Query { + getData: Data! + } + + " + `); + }); + + it("maps numeric to Numeric scalar", async () => { + const code = ` + @schema + namespace TestNamespace { + model Data { + preciseValue: numeric; + } + + @query + op getData(): Data; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "scalar Numeric + + type Data { + preciseValue: Numeric! + } + + type Query { + getData: Data! + } + + " + `); + }); + + it("maps decimal and decimal128 to BigDecimal scalar", async () => { + const code = ` + @schema + namespace TestNamespace { + model Data { + amount: decimal; + amount128: decimal128; + } + + @query + op getData(): Data; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "scalar BigDecimal @specifiedBy(url: "https://scalars.graphql.org/chillicream/decimal.html") + + type Data { + amount: BigDecimal! + amount128: BigDecimal! + } + + type Query { + getData: Data! + } + + " + `); + }); + + it("maps url to URL scalar with @specifiedBy directive", async () => { + const code = ` + @schema + namespace TestNamespace { + model Website { + homepage: url; + } + + @query + op getWebsite(): Website; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "scalar URL @specifiedBy(url: "https://url.spec.whatwg.org/") + + type Website { + homepage: URL! + } + + type Query { + getWebsite: Website! + } + + " + `); + }); + + it("maps plainDate to PlainDate scalar", async () => { + const code = ` + @schema + namespace TestNamespace { + model Person { + birthday: plainDate; + } + + @query + op getPerson(): Person; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "scalar PlainDate @specifiedBy(url: "https://scalars.graphql.org/andimarek/local-date.html") + + type Person { + birthday: PlainDate! + } + + type Query { + getPerson: Person! + } + + " + `); + }); + + it("maps plainTime to PlainTime scalar", async () => { + const code = ` + @schema + namespace TestNamespace { + model Alarm { + time: plainTime; + } + + @query + op getAlarm(): Alarm; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "scalar PlainTime @specifiedBy(url: "https://scalars.graphql.org/apollographql/localtime-v0.1.html") + + type Alarm { + time: PlainTime! + } + + type Query { + getAlarm: Alarm! + } + + " + `); + }); + + it("deduplicates scalar declarations when same scalar used multiple times", async () => { + const code = ` + @schema + namespace TestNamespace { + model Financial { + price: decimal; + cost: decimal; + tax: decimal128; + } + + @query + op getFinancial(): Financial; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "scalar BigDecimal @specifiedBy(url: "https://scalars.graphql.org/chillicream/decimal.html") + + type Financial { + price: BigDecimal! + cost: BigDecimal! + tax: BigDecimal! + } + + type Query { + getFinancial: Financial! + } + + " + `); + }); + }); + + describe("primitive scalar mappings", () => { + it("maps TypeSpec primitives to GraphQL scalars", async () => { + const code = ` + @schema + namespace TestNamespace { + model Primitives { + str: string; + int: int32; + bigInt: int64; + float: float32; + double: float64; + bool: boolean; + } + + @query + op getPrimitives(): Primitives; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "scalar Long @specifiedBy(url: "http://scalars.graphql.org/jakobmerrild/long.html") + + type Primitives { + str: String! + int: Int! + bigInt: Long! + float: Float! + double: Float! + bool: Boolean! + } + + type Query { + getPrimitives: Primitives! + } + + " + `); + }); + }); +}); diff --git a/packages/graphql/test/subscriptions.test.ts b/packages/graphql/test/subscriptions.test.ts new file mode 100644 index 00000000000..5d23e0df966 --- /dev/null +++ b/packages/graphql/test/subscriptions.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +describe("subscriptions", () => { + it("emits basic subscription operation", async () => { + const code = ` + @schema + namespace TestNamespace { + model Message { + id: string; + text: string; + sender: string; + } + + @query + op getMessages(): Message[]; + + @subscription + op onNewMessage(): Message; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Message { + id: String! + text: String! + sender: String! + } + + type Query { + getMessages: [Message!]! + } + + type Subscription { + onNewMessage: Message! + } + + " + `); + }); + + it("emits subscription with arguments", async () => { + const code = ` + @schema + namespace TestNamespace { + model ChatMessage { + id: string; + roomId: string; + text: string; + } + + @query + op getRooms(): string[]; + + @subscription + op onMessageInRoom(roomId: string): ChatMessage; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type ChatMessage { + id: String! + roomId: String! + text: String! + } + + type Query { + getRooms: [String!]! + } + + type Subscription { + onMessageInRoom(roomId: String!): ChatMessage! + } + + " + `); + }); + + it("emits multiple subscriptions", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + status: string; + } + + model Notification { + id: string; + message: string; + } + + @query + op getUser(id: string): User; + + @subscription + op onUserStatusChanged(userId: string): User; + + @subscription + op onNotification(userId: string): Notification; + + @subscription + op onUserJoined(): User; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + status: String! + } + + type Notification { + id: String! + message: String! + } + + type Query { + getUser(id: String!): User! + } + + type Subscription { + onUserStatusChanged(userId: String!): User! + onNotification(userId: String!): Notification! + onUserJoined: User! + } + + " + `); + }); + + it("emits subscription returning array", async () => { + const code = ` + @schema + namespace TestNamespace { + model StockPrice { + symbol: string; + price: float64; + } + + @query + op getStocks(): StockPrice[]; + + @subscription + op onPriceUpdates(symbols: string[]): StockPrice[]; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type StockPrice { + symbol: String! + price: Float! + } + + type Query { + getStocks: [StockPrice!]! + } + + type Subscription { + onPriceUpdates(symbols: [String!]!): [StockPrice!]! + } + + " + `); + }); + + it("emits subscription with complex input type", async () => { + const code = ` + @schema + namespace TestNamespace { + model FilterOptions { + minPrice: float64; + maxPrice: float64; + categories: string[]; + } + + model Product { + id: string; + name: string; + price: float64; + } + + @query + op getProducts(): Product[]; + + @subscription + op onProductMatching(filter: FilterOptions): Product; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "type Product { + id: String! + name: String! + price: Float! + } + + input FilterOptionsInput { + minPrice: Float! + maxPrice: Float! + categories: [String!]! + } + + type Query { + getProducts: [Product!]! + } + + type Subscription { + onProductMatching(filter: FilterOptionsInput!): Product! + } + + " + `); + }); +}); diff --git a/packages/graphql/test/unions.test.ts b/packages/graphql/test/unions.test.ts new file mode 100644 index 00000000000..d84cf4ed6a9 --- /dev/null +++ b/packages/graphql/test/unions.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vitest"; +import { emitSingleSchema } from "./test-host.js"; + +describe("unions", () => { + it("supports named union types", async () => { + const code = ` + @schema + namespace TestNamespace { + model TextContent { + text: string; + } + + model ImageContent { + url: string; + alt: string; + } + + model VideoContent { + url: string; + duration: int32; + } + + /** Content can be text, image, or video */ + union Content { + text: TextContent, + image: ImageContent, + video: VideoContent, + } + + model Post { + id: string; + content: Content; + } + + @query + op getPost(id: string): Post; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + """"Content can be text, image, or video""" + union Content = TextContent | ImageContent | VideoContent + + type TextContent { + text: String! + } + + type ImageContent { + url: String! + alt: String! + } + + type VideoContent { + url: String! + duration: Int! + } + + type Post { + id: String! + content: Content! + } + + type Query { + getPost(id: String!): Post! + } + + " + `); + }); + + it("supports anonymous unions in return types", async () => { + const code = ` + @schema + namespace TestNamespace { + model User { + id: string; + name: string; + } + + model Error { + message: string; + } + + @query + op findUser(id: string): User | Error; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "union FindUserUnion = User | Error + + type User { + id: String! + name: String! + } + + type Error { + message: String! + } + + type Query { + findUser(id: String!): FindUserUnion! + } + + " + `); + }); + + describe("scalar wrapping", () => { + it("wraps scalars in union variants", async () => { + const code = ` + @schema + namespace TestNamespace { + /** Named Union of Scalars */ + union TwoScalars { + text: string, + numeric: float32, + } + + model Data { + value: TwoScalars; + } + + @query + op getData(): Data; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + """"Named Union of Scalars""" + union TwoScalars = TwoScalarsTextUnionVariant | TwoScalarsNumericUnionVariant + + type Data { + value: TwoScalars! + } + + type TwoScalarsTextUnionVariant { + value: String! + } + + type TwoScalarsNumericUnionVariant { + value: Float! + } + + type Query { + getData: Data! + } + + " + `); + }); + + it("wraps only scalars in mixed unions", async () => { + const code = ` + @schema + namespace TestNamespace { + model FullAddress { + street: string; + city: string; + } + + model BasicAddress { + city: string; + } + + union CompositeAddress { + oneLineAddress: string, + fullAddress: FullAddress, + basicAddress: BasicAddress, + } + + model Location { + address: CompositeAddress; + } + + @query + op getLocation(): Location; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "union CompositeAddress = CompositeAddressOneLineAddressUnionVariant | FullAddress | BasicAddress + + type FullAddress { + street: String! + city: String! + } + + type BasicAddress { + city: String! + } + + type Location { + address: CompositeAddress! + } + + type CompositeAddressOneLineAddressUnionVariant { + value: String! + } + + type Query { + getLocation: Location! + } + + " + `); + }); + }); + + it("flattens nested unions into single union", async () => { + const code = ` + @schema + namespace TestNamespace { + model Bear { name: string; } + model Lion { name: string; } + model Cat { name: string; } + model Dog { name: string; } + + /** Named Union */ + union Animal { + bear: Bear, + lion: Lion, + } + + /** Nested Union */ + union Pet { + cat: Cat, + dog: Dog, + animal: Animal, + } + + model Owner { + pet: Pet; + } + + @query + op getOwner(): Owner; + } + `; + + const result = await emitSingleSchema(code, {}); + + expect(result).toMatchInlineSnapshot(` + "union Pet = Cat | Dog | Bear | Lion + + """Named Union""" + union Animal = Bear | Lion + + type Bear { + name: String! + } + + type Lion { + name: String! + } + + type Cat { + name: String! + } + + type Dog { + name: String! + } + + type Owner { + pet: Pet! + } + + type Query { + getOwner: Owner! + } + + " + `); + }); +}); 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()], + }), +);