diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 05568ce85a2..f0902cce10d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -43,3 +43,8 @@ cspell.yaml /packages/typespec-vs/ @RodgeFu @lirenhe @bterlson @markcowl @witemple-msft @timotheeguerin @iscai-msft @catalinaperalta /packages/typespec-vscode/ @RodgeFu @lirenhe @bterlson @markcowl @witemple-msft @timotheeguerin @iscai-msft @catalinaperalta /packages/compiler/src/server/ @RodgeFu @bterlson @markcowl @witemple-msft @timotheeguerin @iscai-msft @catalinaperalta + +###################### +# GraphQL +###################### +/packages/graphql/ @bterlson @markcowl @allenjzhang @timotheeguerin diff --git a/.gitignore b/.gitignore index fb099c3ec88..b2d237a3bf9 100644 --- a/.gitignore +++ b/.gitignore @@ -243,3 +243,6 @@ packages/http-client-python/tests/.wheels/ # Turborepo .turbo + +# agents +.claude/settings.local.json diff --git a/packages/graphql/CHANGELOG.md b/packages/graphql/CHANGELOG.md new file mode 100644 index 00000000000..cefd47fc332 --- /dev/null +++ b/packages/graphql/CHANGELOG.md @@ -0,0 +1,16 @@ +# Change Log - @typespec/graphql + +## 0.1.0 + +### Features + +- Initial release of the GraphQL emitter +- Support for `@query`, `@mutation`, and `@subscription` operation decorators +- Support for `@graphqlInterface` decorator to mark models as GraphQL interfaces +- Support for `@compose` decorator to implement interfaces +- Support for `@operationFields` decorator to add operations to models +- Support for `@specifiedBy` decorator for custom scalar URLs +- Automatic input type generation with `Input` suffix +- `@oneOf` input generation for union-as-input parameters +- Visibility-based input/output type splitting +- Union flattening and scalar wrapper generation diff --git a/packages/graphql/README.md b/packages/graphql/README.md new file mode 100644 index 00000000000..43df7ac0b7f --- /dev/null +++ b/packages/graphql/README.md @@ -0,0 +1,376 @@ +# @typespec/graphql + +TypeSpec library and emitter for GraphQL. + +Generates GraphQL SDL (Schema Definition Language) from TypeSpec source files. + +## Install + +```bash +npm install @typespec/graphql +``` + +## Emitter usage + +### Via the command line + +```bash +tsp compile . --emit=@typespec/graphql +``` + +### Via the config + +```yaml +emit: + - "@typespec/graphql" +``` + +The config can be extended with options as follows: + +```yaml +emit: + - "@typespec/graphql" +options: + "@typespec/graphql": + output-file: "schema.graphql" +``` + +## Emitter options + +### `output-file` + +**Type:** `string` + +Name of the output file. Supports interpolation with `{schema-name}` for multi-schema scenarios. + +**Default:** `{schema-name}.graphql` + +### `new-line` + +**Type:** `"lf" | "crlf"` + +Set the newline character for emitting files. + +**Default:** `lf` + +### `omit-unreachable-types` + +**Type:** `boolean` + +Omit unreachable types. By default all types declared under the schema namespace will be included. With this flag on, only types referenced in an operation will be emitted. + +**Default:** `false` + +## Decorators + +### TypeSpec.GraphQL + +All decorators are in the `TypeSpec.GraphQL` namespace. You can use them with the fully qualified name (e.g., `@TypeSpec.GraphQL.query`) or import the namespace: + +```typespec +using TypeSpec.GraphQL; + +@query op getUser(id: string): User; +``` + +- [`@query`](#query) +- [`@mutation`](#mutation) +- [`@subscription`](#subscription) +- [`@graphqlInterface`](#graphqlinterface) +- [`@compose`](#compose) +- [`@operationFields`](#operationfields) +- [`@schema`](#schema) +- [`@specifiedBy`](#specifiedby) + +#### `@query` + +Specify the GraphQL Operation kind for the target operation to be `QUERY`. + +```typespec +@query +``` + +##### Target + +`Operation` + +##### Parameters + +None + +##### Examples + +```typespec +@query op getUser(id: string): User; +``` + +#### `@mutation` + +Specify the GraphQL Operation kind for the target operation to be `MUTATION`. + +```typespec +@mutation +``` + +##### Target + +`Operation` + +##### Parameters + +None + +##### Examples + +```typespec +@mutation op createUser(name: string): User; +``` + +#### `@subscription` + +Specify the GraphQL Operation kind for the target operation to be `SUBSCRIPTION`. + +```typespec +@subscription +``` + +##### Target + +`Operation` + +##### Parameters + +None + +##### Examples + +```typespec +@subscription op onUserCreated(): User; +``` + +#### `@graphqlInterface` + +Mark a model as a GraphQL Interface. Interfaces can be implemented by other models using `@compose`. + +```typespec +@graphqlInterface(options?: { interfaceOnly?: boolean }) +``` + +##### Target + +`Model` + +##### Parameters + +| Name | Type | Description | +| ------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| interfaceOnly | `valueof { interfaceOnly?: boolean }` | When true, the model will only be emitted as an interface (no "Interface" suffix). Defaults to false. | + +##### Examples + +```typespec +@graphqlInterface(#{ interfaceOnly: true }) +model Node { + id: string; +} + +@graphqlInterface +model Reactable { + reactions: Reaction[]; +} +``` + +#### `@compose` + +Specify the GraphQL interfaces that should be implemented by a model. The interfaces must be decorated with the `@graphqlInterface` decorator, and all of the interfaces' properties must be present and compatible. + +```typespec +@compose(...interfaces: Model[]) +``` + +##### Target + +`Model` + +##### Parameters + +| Name | Type | Description | +| ---------- | --------- | ------------------------------------------------ | +| interfaces | `Model[]` | The interfaces that this model should implement. | + +##### Examples + +```typespec +@graphqlInterface(#{ interfaceOnly: true }) +model Node { + id: string; +} + +@compose(Node) +model User { + ...Node; + name: string; +} +``` + +#### `@operationFields` + +Assign one or more operations or interfaces to act as fields with arguments on a model. + +```typespec +@operationFields(...operations: (Operation | Interface)[]) +``` + +##### Target + +`Model` + +##### Parameters + +| Name | Type | Description | +| ---------- | ------------------------- | -------------------------------------------- | +| operations | `(Operation \| Interface)[]` | Operations to add as fields on this model. | + +##### Examples + +```typespec +@query op followers(query: string): Person[]; + +@operationFields(followers) +model Person { + name: string; +} +``` + +This emits: + +```graphql +type Person { + name: String! + followers(query: String!): [Person!]! +} +``` + +#### `@schema` + +Mark a namespace as describing a GraphQL schema and configure schema properties. + +```typespec +@schema(options?: { name?: string }) +``` + +##### Target + +`Namespace` + +##### Parameters + +| Name | Type | Description | +| ---- | -------------------------- | ------------------------ | +| options | `valueof { name?: string }` | Schema configuration options. | + +##### Examples + +```typespec +@schema(#{ name: "MyAPI" }) +namespace MyAPI { + @query op getStatus(): string; +} +``` + +#### `@specifiedBy` + +Provide a specification URL for a custom GraphQL scalar type. This maps to the `@specifiedBy` directive in the emitted GraphQL schema. + +```typespec +@specifiedBy(url: valueof url) +``` + +##### Target + +`Scalar` + +##### Parameters + +| Name | Type | Description | +| ---- | ------------- | ---------------------------------------- | +| url | `valueof url` | URL to the scalar type specification. | + +##### Examples + +```typespec +@specifiedBy("https://scalars.graphql.org/andimarek/date-time") +scalar DateTime extends utcDateTime; +``` + +## Type mapping + +TypeSpec types are mapped to GraphQL types as follows: + +| TypeSpec | GraphQL | +| ----------------- | ------------------ | +| `string` | `String` | +| `boolean` | `Boolean` | +| `int32` | `Int` | +| `float32` | `Float` | +| `GraphQL.ID` | `ID` | +| `T[]` | `[T!]!` | +| `T \| null` | `T` (nullable) | +| `T?` | `T` (nullable) | +| Model | `type` or `input` | +| Enum | `enum` | +| Union | `union` | + +## Input types + +When a model is used as an operation parameter, it is automatically emitted as a GraphQL input type with the `Input` suffix: + +```typespec +model User { + id: string; + name: string; +} + +@mutation op createUser(user: User): User; +``` + +Emits: + +```graphql +type User { + id: String! + name: String! +} + +input UserInput { + id: String! + name: String! +} + +type Mutation { + createUser(user: UserInput!): User! +} +``` + +## Union handling + +GraphQL unions can only contain object types. When a union contains scalar types, the emitter automatically wraps them in synthetic object types: + +```typespec +union SearchResult { + User, + string, // scalar - will be wrapped +} +``` + +Emits: + +```graphql +type SearchResultStringUnionVariant { + value: String! +} + +union SearchResult = User | SearchResultStringUnionVariant +``` + +For unions used as input parameters, the emitter generates a `@oneOf` input type since GraphQL unions are output-only. diff --git a/packages/graphql/api-extractor.json b/packages/graphql/api-extractor.json new file mode 100644 index 00000000000..2069b8ac37f --- /dev/null +++ b/packages/graphql/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "../../api-extractor.base.json" +} diff --git a/packages/graphql/lib/interface.tsp b/packages/graphql/lib/interface.tsp new file mode 100644 index 00000000000..23ba30bc404 --- /dev/null +++ b/packages/graphql/lib/interface.tsp @@ -0,0 +1,42 @@ +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +/** + * Mark this model as a GraphQL Interface. Interfaces can be implemented by other models. + * + * @param interfaceOnly When true, the model will only be emitted as an interface (no + * "Interface" suffix is added to the name). Use this for models that will never be + * used directly as output/input types (e.g., Node, Connection). Defaults to false. + * + * @example + * + * ```typespec + * @graphqlInterface(#{interfaceOnly: true}) + * model Node { + * id: string; + * } + * + * @graphqlInterface + * model Person { + * name: string; + * } + * ``` + */ +extern dec graphqlInterface(target: Model, options?: valueof {interfaceOnly?: boolean}); + +/** + * Specify the GraphQL interfaces that should be implemented by a model. + * The interfaces must be decorated with the @graphqlInterface decorator, + * and all of the interfaces' properties must be present and compatible. + * + * @example + * + * ```typespec + * @compose(Influencer, Person) + * model User { + * ... Influencer; + * ... Person; + * } + */ +extern dec compose(target: Model, ...interfaces: Model[]); diff --git a/packages/graphql/lib/main.tsp b/packages/graphql/lib/main.tsp new file mode 100644 index 00000000000..4e241c72b29 --- /dev/null +++ b/packages/graphql/lib/main.tsp @@ -0,0 +1,9 @@ +import "../dist/src/tsp-index.js"; +import "./interface.tsp"; +import "./nullable.tsp"; +import "./one-of.tsp"; +import "./operation-fields.tsp"; +import "./operation-kind.tsp"; +import "./scalars.tsp"; +import "./schema.tsp"; +import "./specified-by.tsp"; diff --git a/packages/graphql/lib/nullable.tsp b/packages/graphql/lib/nullable.tsp new file mode 100644 index 00000000000..cff6fd82001 --- /dev/null +++ b/packages/graphql/lib/nullable.tsp @@ -0,0 +1,20 @@ +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..4cda3380795 --- /dev/null +++ b/packages/graphql/lib/one-of.tsp @@ -0,0 +1,14 @@ +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/lib/operation-fields.tsp b/packages/graphql/lib/operation-fields.tsp new file mode 100644 index 00000000000..19686dbb2f7 --- /dev/null +++ b/packages/graphql/lib/operation-fields.tsp @@ -0,0 +1,18 @@ +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +alias OperationOrInterface = Operation | Interface; + +/** + * Assign one or more operations or interfaces to act as fields with arguments on a model. + * + * @example + * + * ```typespec + * op followers(query: string): Person[]; + * + * @operationFields(followers) + * model Person {} + */ +extern dec operationFields(target: Model, ...operations: OperationOrInterface[]); diff --git a/packages/graphql/lib/operation-kind.tsp b/packages/graphql/lib/operation-kind.tsp new file mode 100644 index 00000000000..c50c44ceff0 --- /dev/null +++ b/packages/graphql/lib/operation-kind.tsp @@ -0,0 +1,36 @@ +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +/** + * Specify the GraphQL Operation kind for the target operation to be `MUTATION`. + * + * @example + * + * ```typespec + * @mutation op update(): string + * ``` + */ +extern dec mutation(target: Operation); + +/** + * Specify the GraphQL Operation kind for the target operation to be `QUERY`. + * + * @example + * + * ```typespec + * @query op read(): string + * ``` + */ +extern dec query(target: Operation); + +/** + * Specify the GraphQL Operation kind for the target operation to be `SUBSCRIPTION`. + * + * @example + * + * ```typespec + * @subscription op get_periodically(): string + * ``` + */ +extern dec subscription(target: Operation); diff --git a/packages/graphql/lib/scalars.tsp b/packages/graphql/lib/scalars.tsp new file mode 100644 index 00000000000..26ec0808e48 --- /dev/null +++ b/packages/graphql/lib/scalars.tsp @@ -0,0 +1,17 @@ +namespace TypeSpec.GraphQL; + +/** + * Represents a GraphQL ID scalar — a unique identifier serialized as a string. + * + * @see https://spec.graphql.org/September2025/#sec-ID + * + * @example + * + * ```typespec + * model User { + * id: GraphQL.ID; + * name: string; + * } + * ``` + */ +scalar ID extends string; diff --git a/packages/graphql/lib/schema.tsp b/packages/graphql/lib/schema.tsp new file mode 100644 index 00000000000..e3038ea5f1c --- /dev/null +++ b/packages/graphql/lib/schema.tsp @@ -0,0 +1,26 @@ +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +namespace Schema { + /** Options for configuring a GraphQL schema. */ + model SchemaOptions { + /** + * The name of the GraphQL schema. Used in the output filename when emitting + * multiple schemas (e.g., `{name}.graphql`). Defaults to `"schema"`. + */ + name?: string; + } +} + +/** + * Mark this namespace as describing a GraphQL schema and configure schema properties. + * + * @example + * + * ```typespec + * @schema(#{name: "MySchema"}) + * namespace MySchema {}; + * ``` + */ +extern dec schema(target: Namespace, options?: valueof Schema.SchemaOptions); diff --git a/packages/graphql/lib/specified-by.tsp b/packages/graphql/lib/specified-by.tsp new file mode 100644 index 00000000000..e8c3d5d683f --- /dev/null +++ b/packages/graphql/lib/specified-by.tsp @@ -0,0 +1,17 @@ +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +/** + * Provide a specification URL for a custom GraphQL scalar type. + * This maps to the `@specifiedBy` directive in the emitted GraphQL schema. + * + * @param url URL to the scalar type specification + * @example + * + * ```typespec + * @specifiedBy("https://scalars.graphql.org/jakobmerrild/long.html") + * scalar Long extends int64; + * ``` + */ +extern dec specifiedBy(target: Scalar, url: valueof url); diff --git a/packages/graphql/package.json b/packages/graphql/package.json new file mode 100644 index 00000000000..dc04888ba3a --- /dev/null +++ b/packages/graphql/package.json @@ -0,0 +1,74 @@ +{ + "name": "@typespec/graphql", + "version": "0.1.0", + "author": "Microsoft Corporation", + "description": "TypeSpec library for emitting GraphQL", + "homepage": "https://typespec.io", + "readme": "https://github.com/microsoft/typespec/blob/main/README.md", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/typespec.git" + }, + "bugs": { + "url": "https://github.com/microsoft/typespec/issues" + }, + "keywords": [ + "typespec" + ], + "type": "module", + "tspMain": "lib/main.tsp", + "main": "dist/src/index.js", + "exports": { + ".": { + "typespec": "./lib/main.tsp", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "engines": { + "node": ">=22.0.0" + }, + "dependencies": { + "@alloy-js/core": "catalog:", + "@pinterest/alloy-graphql": "link:../../../alloy-graphql/packages/graphql", + "@alloy-js/typescript": "catalog:", + "change-case": "^5.4.4", + "graphql": "^16.9.0" + }, + "scripts": { + "clean": "rimraf ./dist ./temp", + "build": "alloy build", + "watch": "alloy build --watch", + "test": "vitest run", + "test:watch": "vitest -w", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix", + "regen-docs": "tspd doc . --enable-experimental --output-dir ../../website/src/content/docs/docs/emitters/graphql/reference" + }, + "files": [ + "lib/*.tsp", + "dist/**", + "!dist/test/**" + ], + "peerDependencies": { + "@typespec/compiler": "workspace:~", + "@typespec/emitter-framework": "workspace:~", + "@typespec/http": "workspace:~", + "@typespec/mutator-framework": "workspace:~" + }, + "devDependencies": { + "@alloy-js/cli": "^0.23.0", + "@alloy-js/rollup-plugin": "^0.1.1", + "@types/node": "~22.13.13", + "@typespec/compiler": "workspace:~", + "@typespec/emitter-framework": "workspace:~", + "@typespec/http": "workspace:~", + "@typespec/mutator-framework": "workspace:~", + "@typespec/tspd": "workspace:~", + "rimraf": "~6.0.1", + "source-map-support": "~0.5.21", + "typescript": "~5.8.2", + "vitest": "^3.0.9" + } +} diff --git a/packages/graphql/src/components/fields/field.tsx b/packages/graphql/src/components/fields/field.tsx new file mode 100644 index 00000000000..3a242f8ba52 --- /dev/null +++ b/packages/graphql/src/components/fields/field.tsx @@ -0,0 +1,72 @@ +import { type ModelProperty, getDeprecationDetails, isArrayModelType } from "@typespec/compiler"; +import * as gql from "@pinterest/alloy-graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { resolveGraphQLTypeName } from "../../lib/graphql-type-name.js"; +import { hasNullableElements, isNullable } from "../../lib/nullable.js"; + +export interface FieldProps { + property: ModelProperty; + isInput: boolean; +} + +export function Field(props: FieldProps) { + const { $, program } = useTsp(); + + const doc = $.type.getDoc(props.property); + const deprecation = getDeprecationDetails(program, props.property); + const nullable = isNullable(props.property) || props.property.optional; + const type = props.property.type; + + if (type.kind === "Model" && isArrayModelType(type)) { + const elemNullable = hasNullableElements(props.property); + const typeName = resolveGraphQLTypeName(type.indexer.value); + + if (props.isInput) { + return ( + + + + ); + } + + return ( + + + + ); + } + + if (props.isInput) { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/graphql/src/components/fields/index.ts b/packages/graphql/src/components/fields/index.ts new file mode 100644 index 00000000000..0cec3b46b26 --- /dev/null +++ b/packages/graphql/src/components/fields/index.ts @@ -0,0 +1,2 @@ +export { Field, type FieldProps } from "./field.js"; +export { OperationField, type OperationFieldProps } from "./operation-field.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..e261ec29c9a --- /dev/null +++ b/packages/graphql/src/components/fields/operation-field.tsx @@ -0,0 +1,58 @@ +import { type Operation, getDeprecationDetails, isArrayModelType } from "@typespec/compiler"; +import * as gql from "@pinterest/alloy-graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { resolveGraphQLTypeName } from "../../lib/graphql-type-name.js"; +import { hasNullableElements, isNullable } from "../../lib/nullable.js"; + +export interface OperationFieldProps { + operation: Operation; +} + +export function OperationField(props: OperationFieldProps) { + const { $, program } = useTsp(); + + const doc = $.type.getDoc(props.operation); + const deprecation = getDeprecationDetails(program, props.operation); + const returnType = props.operation.returnType; + const nullable = isNullable(props.operation); + const params = Array.from(props.operation.parameters.properties.values()); + + const isList = returnType.kind === "Model" && isArrayModelType(returnType); + const typeName = isList + ? resolveGraphQLTypeName(returnType.indexer.value) + : resolveGraphQLTypeName(returnType); + const elemNullable = isList && hasNullableElements(props.operation); + + return ( + + {isList ? : undefined} + {params.map((param) => { + const paramNullable = isNullable(param) || param.optional; + const paramType = param.type; + const paramIsList = paramType.kind === "Model" && isArrayModelType(paramType); + const paramElemNullable = paramIsList && hasNullableElements(param); + const paramTypeName = paramIsList + ? resolveGraphQLTypeName(paramType.indexer.value) + : resolveGraphQLTypeName(paramType); + + return ( + + {paramIsList ? : undefined} + + ); + })} + + ); +} diff --git a/packages/graphql/src/components/schema.tsx b/packages/graphql/src/components/schema.tsx new file mode 100644 index 00000000000..cf0bfe15b4d --- /dev/null +++ b/packages/graphql/src/components/schema.tsx @@ -0,0 +1,82 @@ +import { type Model } from "@typespec/compiler"; +import * as gql from "@pinterest/alloy-graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { isInputType } from "../lib/input-type.js"; +import { isInterface } from "../lib/interface.js"; +import { getOperationFields } from "../lib/operation-fields.js"; +import { getOperationKind } from "../lib/operation-kind.js"; +import { getSpecifiedBy } from "../lib/specified-by.js"; +import { useGraphQLSchema } from "../context/index.js"; +import { EnumType } from "./types/enum-type.js"; +import { InputType } from "./types/input-type.js"; +import { InterfaceType } from "./types/interface-type.js"; +import { ObjectType } from "./types/object-type.js"; +import { ScalarType } from "./types/scalar-type.js"; +import { UnionType, type GraphQLUnion } from "./types/union-type.js"; +import { OperationField } from "./fields/index.js"; + +export function Schema() { + const { typeGraph } = useGraphQLSchema(); + const { program } = useTsp(); + const ns = typeGraph.globalNamespace; + + const operations = [...ns.operations.values()]; + const queries = operations.filter((op) => getOperationKind(program, op) === "Query"); + const mutations = operations.filter((op) => getOperationKind(program, op) === "Mutation"); + const subscriptions = operations.filter((op) => getOperationKind(program, op) === "Subscription"); + + const models = [...ns.models.values()]; + const scalars = [...ns.scalars.values()]; + const enums = [...ns.enums.values()]; + const unions = [...ns.unions.values()]; + + return ( + <> + {scalars.map((s) => ( + + ))} + {enums.map((e) => ( + + ))} + {unions.map((u) => ( + + ))} + {models.map((m) => renderModel(m))} + {queries.length > 0 && ( + + {queries.map((op) => ( + + ))} + + )} + {mutations.length > 0 && ( + + {mutations.map((op) => ( + + ))} + + )} + {subscriptions.length > 0 && ( + + {subscriptions.map((op) => ( + + ))} + + )} + + ); + + function renderModel(model: Model) { + const hasFields = model.properties.size > 0 || getOperationFields(program, model).size > 0; + if (!hasFields) return undefined; + + if (isInterface(program, model)) { + return ; + } + if (isInputType(model)) { + return ; + } + return ; + } + +} 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..7ee043052fe --- /dev/null +++ b/packages/graphql/src/components/types/enum-type.tsx @@ -0,0 +1,25 @@ +import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@pinterest/alloy-graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +export interface EnumTypeProps { + type: Enum; +} + +export function EnumType(props: EnumTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const members = [...props.type.members.values()]; + + return ( + + {members.map((member) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/types/index.ts b/packages/graphql/src/components/types/index.ts new file mode 100644 index 00000000000..1d20836ee66 --- /dev/null +++ b/packages/graphql/src/components/types/index.ts @@ -0,0 +1,6 @@ +export { EnumType, type EnumTypeProps } from "./enum-type.js"; +export { InputType, type InputTypeProps } from "./input-type.js"; +export { InterfaceType, type InterfaceTypeProps } from "./interface-type.js"; +export { ObjectType, type ObjectTypeProps } from "./object-type.js"; +export { ScalarType, type ScalarTypeProps } from "./scalar-type.js"; +export { UnionType, type UnionTypeProps, type GraphQLUnion } from "./union-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..d8972b3a720 --- /dev/null +++ b/packages/graphql/src/components/types/input-type.tsx @@ -0,0 +1,23 @@ +import { type Model, getDoc } from "@typespec/compiler"; +import * as gql from "@pinterest/alloy-graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { isOneOf } from "../../lib/one-of.js"; +import { Field } from "../fields/index.js"; + +export interface InputTypeProps { + type: Model; +} + +export function InputType(props: InputTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const properties = [...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..45901e76e28 --- /dev/null +++ b/packages/graphql/src/components/types/interface-type.tsx @@ -0,0 +1,25 @@ +import { type Model, getDoc } from "@typespec/compiler"; +import * as gql from "@pinterest/alloy-graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { getComposition } from "../../lib/interface.js"; +import { Field } from "../fields/index.js"; + +export interface InterfaceTypeProps { + type: Model; +} + +export function InterfaceType(props: InterfaceTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const properties = [...props.type.properties.values()]; + const composition = getComposition(program, props.type); + const interfaces = composition?.map((iface) => iface.name); + + 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..b0e3dbc8130 --- /dev/null +++ b/packages/graphql/src/components/types/object-type.tsx @@ -0,0 +1,30 @@ +import { type Model, getDoc } from "@typespec/compiler"; +import * as gql from "@pinterest/alloy-graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { getComposition } from "../../lib/interface.js"; +import { getOperationFields } from "../../lib/operation-fields.js"; +import { Field, OperationField } from "../fields/index.js"; + +export interface ObjectTypeProps { + type: Model; +} + +export function ObjectType(props: ObjectTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const properties = [...props.type.properties.values()]; + const composition = getComposition(program, props.type); + const interfaces = composition?.map((iface) => iface.name); + const opFields = getOperationFields(program, props.type); + + return ( + + {properties.map((prop) => ( + + ))} + {[...opFields].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..2b8c163f6b8 --- /dev/null +++ b/packages/graphql/src/components/types/scalar-type.tsx @@ -0,0 +1,21 @@ +import { type Scalar, getDoc } from "@typespec/compiler"; +import * as gql from "@pinterest/alloy-graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +export interface ScalarTypeProps { + type: Scalar; + specificationUrl?: string; +} + +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..45b726af7b9 --- /dev/null +++ b/packages/graphql/src/components/types/union-type.tsx @@ -0,0 +1,24 @@ +import { type Model, type Union, getDoc } from "@typespec/compiler"; +import * as gql from "@pinterest/alloy-graphql"; +import { useTsp } from "@typespec/emitter-framework"; + +/** + * A Union guaranteed to have a name after mutation. + * The mutation engine ensures this: anonymous unions get derived names. + */ +export interface GraphQLUnion extends Union { + name: string; +} + +export interface UnionTypeProps { + type: GraphQLUnion; +} + +export function UnionType(props: UnionTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const variants = [...props.type.variants.values()]; + const members = variants.map((v) => (v.type as Model).name); + + 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..f51826dcb89 --- /dev/null +++ b/packages/graphql/src/context/graphql-schema-context.tsx @@ -0,0 +1,21 @@ +import { type ComponentContext, createNamedContext, useContext } from "@alloy-js/core"; +import type { TypeGraph } from "../mutation-engine/type-graph.js"; + +export interface GraphQLSchemaContextValue { + typeGraph: TypeGraph; +} + +export const GraphQLSchemaContext: ComponentContext = + createNamedContext("GraphQLSchema"); + +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..cf8c0efcbbb --- /dev/null +++ b/packages/graphql/src/context/index.ts @@ -0,0 +1,5 @@ +export { + GraphQLSchemaContext, + useGraphQLSchema, + type GraphQLSchemaContextValue, +} from "./graphql-schema-context.js"; diff --git a/packages/graphql/src/emitter.tsx b/packages/graphql/src/emitter.tsx new file mode 100644 index 00000000000..76178f9d981 --- /dev/null +++ b/packages/graphql/src/emitter.tsx @@ -0,0 +1,81 @@ +import { + emitFile, + interpolatePath, + resolvePath, + type EmitContext, + type Namespace, + type Program, +} from "@typespec/compiler"; +import { TspContext } from "@typespec/emitter-framework"; +import { renderSchema as alloyRenderSchema } from "@pinterest/alloy-graphql"; +import { printSchema } from "graphql"; +import { Schema } from "./components/schema.js"; +import { GraphQLSchemaContext } from "./context/index.js"; +import { type GraphQLEmitterOptions } from "./lib.js"; +import { getOperationKind } from "./lib/operation-kind.js"; +import { listSchemas } from "./lib/schema.js"; +import { createGraphQLMutationEngine } from "./mutation-engine/index.js"; +import { mutateSchema } from "./mutation-engine/schema-mutator.js"; +import type { TypeGraph } from "./mutation-engine/type-graph.js"; +import { resolveTypeUsage } from "./type-usage.js"; + +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 typeGraph = buildSchema(context, schema.type); + if (typeGraph) { + const sdl = renderSchema(context.program, typeGraph); + if (!context.program.compilerOptions.dryRun) { + const outputFile = context.options["output-file"] ?? "{schema-name}.graphql"; + const fileName = interpolatePath(outputFile, { + "schema-name": schema.name ?? "schema", + }); + await emitFile(context.program, { + path: resolvePath(context.emitterOutputDir, fileName), + content: sdl, + newLine: context.options["new-line"] ?? "lf", + }); + } + } + } +} + +function buildSchema( + context: EmitContext, + schema: Namespace, +): TypeGraph | undefined { + const program = context.program; + const omitUnreachable = context.options["omit-unreachable-types"] ?? false; + + const typeUsage = resolveTypeUsage(program, schema, omitUnreachable); + const engine = createGraphQLMutationEngine(program); + const typeGraph = mutateSchema(program, engine, schema, typeUsage); + + // Check for GraphQL operations - skip generation if none exist + // (the diagnostic is reported by $onValidate for early IDE feedback) + const hasGraphQLOps = [...typeGraph.globalNamespace.operations.values()].some( + (op) => getOperationKind(program, op) !== undefined, + ); + if (!hasGraphQLOps) { + return undefined; + } + + return typeGraph; +} + +function renderSchema(program: Program, typeGraph: TypeGraph): string { + const graphqlSchema = alloyRenderSchema( + + + + + , + { namePolicy: null }, + ); + + return printSchema(graphqlSchema as any); +} diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts new file mode 100644 index 00000000000..1686ff2d444 --- /dev/null +++ b/packages/graphql/src/index.ts @@ -0,0 +1,5 @@ +export { $onEmit } from "./emitter.js"; +export { $lib } from "./lib.js"; +export { $decorators } from "./tsp-index.js"; + +export { createGraphQLMutationEngine } from "./mutation-engine/index.js"; diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts new file mode 100644 index 00000000000..061007d56a1 --- /dev/null +++ b/packages/graphql/src/lib.ts @@ -0,0 +1,201 @@ +import { createTypeSpecLibrary, paramMessage, type JSONSchemaType } from "@typespec/compiler"; + +export interface GraphQLEmitterOptions { + /** + * Name of the output file. + * Output file will interpolate the following values: + * - schema-name: Name of the schema if multiple + * + * @default `{schema-name}.graphql` + * + * @example Single schema + * - `schema.graphql` + * + * @example Multiple schemas + * - `Org1.Schema1.graphql` + * - `Org1.Schema2.graphql` + */ + "output-file"?: string; + + /** + * Set the newline character for emitting files. + * @default lf + */ + "new-line"?: "crlf" | "lf"; + + /** + * Omit unreachable types. + * By default all types declared under the schema namespace will be included. With this flag on only types references in an operation will be emitted. + * @default false + */ + "omit-unreachable-types"?: boolean; +} + +const EmitterOptionsSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + properties: { + "output-file": { + type: "string", + nullable: true, + description: [ + "Name of the output file.", + " Output file will interpolate the following values:", + " - schema-name: Name of the schema if multiple", + "", + " Default: `{schema-name}.graphql`", + "", + " Example Single schema", + " - `schema.graphql`", + "", + " Example Multiple schemas", + " - `Org1.Schema1.graphql`", + " - `Org1.Schema2.graphql`", + ].join("\n"), + }, + "new-line": { + type: "string", + enum: ["crlf", "lf"], + default: "lf", + nullable: true, + description: "Set the newLine character for emitting files.", + }, + "omit-unreachable-types": { + type: "boolean", + nullable: true, + description: [ + "Omit unreachable types.", + "By default all types declared under the schema namespace will be included.", + "With this flag on only types references in an operation will be emitted.", + ].join("\n"), + }, + }, + required: [], +}; + +export const libDef = { + name: "@typespec/graphql", + diagnostics: { + "graphql-operation-kind-duplicate": { + severity: "error", + messages: { + default: paramMessage`GraphQL Operation Kind already applied to \`${"entityName"}\`.`, + }, + }, + "operation-field-conflict": { + severity: "error", + messages: { + default: paramMessage`Operation \`${"operation"}\` conflicts with an existing ${"conflictType"} on model \`${"model"}\`.`, + }, + }, + "operation-field-duplicate": { + severity: "warning", + messages: { + default: paramMessage`Operation \`${"operation"}\` is defined multiple times on \`${"model"}\`.`, + }, + }, + "invalid-interface": { + severity: "error", + messages: { + default: paramMessage`All models used with \`@compose\` must be marked with \`@graphqlInterface\`, but ${"interface"} is not.`, + }, + }, + "circular-interface": { + severity: "error", + messages: { + default: "An interface cannot implement itself.", + }, + }, + "missing-interface-property": { + severity: "error", + messages: { + default: paramMessage`Model must contain property \`${"property"}\` from \`${"interface"}\` in order to implement it in GraphQL.`, + }, + }, + "incompatible-interface-property": { + severity: "error", + messages: { + default: paramMessage`Property \`${"property"}\` is incompatible with \`${"interface"}\`.`, + }, + }, + "unrecognized-union": { + severity: "error", + messages: { + default: + "Unrecognized union construction. Union must be named, a return type, a model property, or an alias.", + }, + }, + "duplicate-union-variant": { + severity: "warning", + messages: { + default: paramMessage`Union variant type "${"type"}" appears multiple times after flattening nested unions. Duplicate removed.`, + }, + }, + "empty-union": { + severity: "error", + messages: { + default: + "Union has no non-null variants. A GraphQL union must contain at least one member type.", + }, + }, + "graphql-builtin-scalar-collision": { + severity: "warning", + messages: { + default: paramMessage`Scalar "${"name"}" collides with GraphQL built-in type "${"builtinName"}". This may cause unexpected behavior. Consider renaming the scalar.`, + }, + }, + "type-name-collision": { + severity: "error", + messages: { + default: paramMessage`Type "${"name"}" collides with another type of the same name in the GraphQL schema. Consider renaming one of the types.`, + }, + }, + "operation-fields-ignored-on-input": { + severity: "warning", + messages: { + default: paramMessage`@operationFields on \`${"model"}\` is ignored in input context — GraphQL input types cannot have operation fields.`, + }, + }, + "empty-schema": { + severity: "warning", + messages: { + default: + "GraphQL schema has no operations. At minimum a Query root type is required.", + }, + }, + "empty-enum": { + severity: "error", + messages: { + default: paramMessage`Enum "${"name"}" must define at least one value. GraphQL enums cannot be empty.`, + }, + }, + "reserved-name": { + severity: "error", + messages: { + default: paramMessage`Name "${"name"}" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.`, + }, + }, + }, + emitter: { + options: EmitterOptionsSchema as JSONSchemaType, + capabilities: { + dryRun: true, + }, + }, + state: { + operationKind: { + description: + "State for the graphql operation kind decorators (@query, @mutation, @subscription)", + }, + operationFields: { description: "State for the @operationFields decorator." }, + compose: { description: "State for the @compose decorator." }, + interface: { description: "State for the @interface decorator." }, + interfaceOnly: { description: "State for @interface(#{interfaceOnly: true})." }, + schema: { description: "State for the @schema decorator." }, + specifiedBy: { description: "State for the @specifiedBy decorator." }, + }, +} as const; + +export const $lib = createTypeSpecLibrary(libDef); + +export const { reportDiagnostic, createDiagnostic, stateKeys: GraphQLKeys } = $lib; diff --git a/packages/graphql/src/lib/graphql-type-name.ts b/packages/graphql/src/lib/graphql-type-name.ts new file mode 100644 index 00000000000..de5c7134456 --- /dev/null +++ b/packages/graphql/src/lib/graphql-type-name.ts @@ -0,0 +1,30 @@ +import type { Type } from "@typespec/compiler"; + +const SCALAR_TO_GRAPHQL: Record = { + string: "String", + boolean: "Boolean", + int32: "Int", + float32: "Float", + float64: "Float", +}; + +/** + * Resolve the GraphQL type name for a mutated TypeSpec type. + * + * For std scalars, maps to GraphQL built-in names (string → String, int32 → Int). + * For all other types, returns type.name directly (mutation pipeline already set it). + */ +export function resolveGraphQLTypeName(type: Type): string { + switch (type.kind) { + case "Scalar": + return SCALAR_TO_GRAPHQL[type.name] ?? type.name; + case "Model": + return type.name; + case "Enum": + return type.name; + case "Union": + return type.name ?? "Union"; + default: + return type.kind; + } +} diff --git a/packages/graphql/src/lib/input-type.ts b/packages/graphql/src/lib/input-type.ts new file mode 100644 index 00000000000..e0134ebb007 --- /dev/null +++ b/packages/graphql/src/lib/input-type.ts @@ -0,0 +1,15 @@ +import type { DecoratorContext, DecoratorFunction, Model, Type } from "@typespec/compiler"; + +export const $inputType: DecoratorFunction = ( + _context: DecoratorContext, + _target: Model, +) => {}; + +export function isInputType(model: Model): boolean { + return model.decorators.some((d) => d.decorator === $inputType); +} + +export function setInputType(model: Model): void { + if (model.decorators.some((d) => d.decorator === $inputType)) return; + model.decorators.push({ decorator: $inputType, args: [] }); +} diff --git a/packages/graphql/src/lib/interface.ts b/packages/graphql/src/lib/interface.ts new file mode 100644 index 00000000000..d9eaa1fba26 --- /dev/null +++ b/packages/graphql/src/lib/interface.ts @@ -0,0 +1,156 @@ +import { + type DecoratorContext, + type DecoratorFunction, + type Model, + type ModelProperty, + type Program, + validateDecoratorUniqueOnNode, + walkPropertiesInherited, +} from "@typespec/compiler"; + +import { useStateMap, useStateSet } from "@typespec/compiler/utils"; +import { GraphQLKeys, reportDiagnostic } from "../lib.js"; +import { propertiesEqual } from "./utils.js"; + +declare const tags: unique symbol; +type Tagged = BaseType & { [tags]: { [K in Tag]: void } }; + +/** An Interface is a model that has been marked as an Interface */ +type Interface = Tagged; + +const [getInterface, setInterface] = useStateSet(GraphQLKeys.interface); +const [getInterfaceOnly, setInterfaceOnly] = useStateSet( + GraphQLKeys.interfaceOnly, +); +const [getComposition, setComposition, _getCompositionMap] = useStateMap( + GraphQLKeys.compose, +); + +export { + /** + * Get the implemented interfaces for a given model + * @param program Program + * @param model Model + * @returns Composed interfaces or undefined if no interfaces are composed. + */ + getComposition, + setComposition, +}; + +/** + * Check if the model is defined as a schema. + * @param program Program + * @param model Model + * @returns Boolean + */ +export function isInterface(program: Program, model: Model | Interface): model is Interface { + return !!getInterface(program, model as Interface); +} + +export function isInterfaceOnly(program: Program, model: Model): boolean { + return !!getInterfaceOnly(program, model as Interface); +} + +function validateImplementedsAreInterfaces(context: DecoratorContext, interfaces: Model[]) { + let valid = true; + + for (const iface of interfaces) { + if (!isInterface(context.program, iface)) { + valid = false; + reportDiagnostic(context.program, { + code: "invalid-interface", + format: { interface: iface.name }, + target: context.decoratorTarget, + }); + } + } + + return valid; +} + +function validateNoCircularImplementation( + context: DecoratorContext, + target: Model, + interfaces: Interface[], +) { + const valid = !isInterface(context.program, target) || !interfaces.includes(target); + if (!valid) { + reportDiagnostic(context.program, { + code: "circular-interface", + target: context.decoratorTarget, + }); + } + return valid; +} + +function validateImplementsInterfaceProperties( + context: DecoratorContext, + modelProperties: Map, + iface: Interface, +) { + let valid = true; + + for (const prop of walkPropertiesInherited(iface)) { + if (!modelProperties.has(prop.name)) { + valid = false; + reportDiagnostic(context.program, { + code: "missing-interface-property", + format: { interface: iface.name, property: prop.name }, + target: context.decoratorTarget, + }); + } else if (!propertiesEqual(modelProperties.get(prop.name)!, prop)) { + valid = false; + reportDiagnostic(context.program, { + code: "incompatible-interface-property", + format: { interface: iface.name, property: prop.name }, + target: context.decoratorTarget, + }); + } + } + + return valid; +} + +function validateImplementsInterfacesProperties( + context: DecoratorContext, + target: Model, + interfaces: Interface[], +) { + let valid = true; + const allModelProperties = new Map( + [...walkPropertiesInherited(target)].map((prop) => [prop.name, prop]), + ); + for (const iface of interfaces) { + if (!validateImplementsInterfaceProperties(context, allModelProperties, iface)) { + valid = false; + } + } + return valid; +} + +export const $graphqlInterface: DecoratorFunction = ( + context: DecoratorContext, + target: Model, + options?: { interfaceOnly?: boolean }, +) => { + validateDecoratorUniqueOnNode(context, target, $graphqlInterface); + setInterface(context.program, target as Interface); + if (options?.interfaceOnly) { + setInterfaceOnly(context.program, target as Interface); + } +}; + +export const $compose: DecoratorFunction = ( + context: DecoratorContext, + target: Model, + ...interfaces: Interface[] +) => { + validateImplementedsAreInterfaces(context, interfaces); + validateNoCircularImplementation(context, target, interfaces); + validateImplementsInterfacesProperties(context, target, interfaces); + const existingCompose = getComposition(context.program, target); + if (existingCompose) { + interfaces = [...existingCompose, ...interfaces]; + } + setComposition(context.program, target, interfaces); +}; diff --git a/packages/graphql/src/lib/naming.ts b/packages/graphql/src/lib/naming.ts new file mode 100644 index 00000000000..9f67502e223 --- /dev/null +++ b/packages/graphql/src/lib/naming.ts @@ -0,0 +1,101 @@ +import { camelCase, constantCase, pascalCase, split, splitSeparateNumbers } from "change-case"; + +export interface NamingContext { + isInput: boolean; + isInterface: boolean; + inputQualifier?: string; +} + +type NameTransform = (name: string, context: NamingContext) => string; + +function stripNamespace(name: string, _context: NamingContext): string { + const parts = name.trim().split("."); + return parts[parts.length - 1]; +} + +function sanitizeForGraphQL(name: string, _context: NamingContext): string { + name = name.replaceAll("[]", "Array"); + name = name.replaceAll(/\W/g, "_"); + if (!/^[_a-zA-Z]/.test(name)) { + name = `_${name}`; + } + return name; +} + +function splitWithAcronyms(skipStart: boolean, name: string): string[] { + const parts = split(name); + if (name === name.toUpperCase()) { + return parts; + } + return parts.flatMap((part, index) => { + if (skipStart && index === 0) return part; + if (part.match(/^[A-Z]+$/)) return part.split(""); + return part; + }); +} + +function toPascalCase(name: string, _context: NamingContext): string { + if (/^[A-Z]+$/.test(name)) { + return name; + } + return pascalCase(name, { + prefixCharacters: "_", + split: splitWithAcronyms.bind(null, false), + }); +} + +function toCamelCase(name: string, _context: NamingContext): string { + return camelCase(name, { + prefixCharacters: "_", + split: splitWithAcronyms.bind(null, true), + }); +} + +function toConstantCase(name: string, _context: NamingContext): string { + return constantCase(name, { + split: splitSeparateNumbers, + prefixCharacters: "_", + }); +} + +function applyInterfaceSuffix(name: string, context: NamingContext): string { + if (!context.isInterface) return name; + return name.endsWith("Interface") ? name : name + "Interface"; +} + +function applyInputSuffix(name: string, context: NamingContext): string { + if (!context.isInput) return name; + const qualifier = context.inputQualifier ?? ""; + const suffix = `${qualifier}Input`; + return name.endsWith(suffix) ? name : name + suffix; +} + +const baseNamePipeline: NameTransform[] = [stripNamespace, sanitizeForGraphQL, toPascalCase]; + +const typeNamePipeline: NameTransform[] = [ + ...baseNamePipeline, + applyInterfaceSuffix, + applyInputSuffix, +]; + +const fieldNamePipeline: NameTransform[] = [sanitizeForGraphQL, toCamelCase]; + +const enumMemberPipeline: NameTransform[] = [sanitizeForGraphQL, toConstantCase]; + +const noContext: NamingContext = { isInput: false, isInterface: false }; + +export function applyBaseNamePipeline(name: string): string { + return baseNamePipeline.reduce((n, transform) => transform(n, noContext), name); +} + +export function applyTypeNamePipeline(name: string, context: NamingContext): string { + return typeNamePipeline.reduce((n, transform) => transform(n, context), name); +} + +export function applyFieldNamePipeline(name: string): string { + return fieldNamePipeline.reduce((n, transform) => transform(n, noContext), name); +} + +export function applyEnumMemberPipeline(name: string): string { + return enumMemberPipeline.reduce((n, transform) => transform(n, noContext), name); +} diff --git a/packages/graphql/src/lib/nullable.ts b/packages/graphql/src/lib/nullable.ts new file mode 100644 index 00000000000..81b1aeffc2e --- /dev/null +++ b/packages/graphql/src/lib/nullable.ts @@ -0,0 +1,76 @@ +import type { + DecoratedType, + DecoratorContext, + DecoratorFunction, + Model, + ModelProperty, + Operation, + Type, + Union, +} from "@typespec/compiler"; + +/** + * 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. + * + * Marked on different targets depending on context: + * - **ModelProperty**: inline `T | null` (can't mark the shared scalar singleton) + * - **Operation**: return type `T | null` + * - **Union**: named unions like `Cat | Dog | null` (safe — new unique object) + */ +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. + * 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: [] }); +} + +/** + * 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(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(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 new file mode 100644 index 00000000000..0be21ec3be6 --- /dev/null +++ b/packages/graphql/src/lib/one-of.ts @@ -0,0 +1,30 @@ +import type { DecoratorContext, DecoratorFunction, Model } from "@typespec/compiler"; + +/** + * 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. + * These are synthetic models created by the union mutation when a union + * is used in input context — GraphQL unions are output-only, so input + * unions become @oneOf input objects. + */ +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(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/operation-fields.ts b/packages/graphql/src/lib/operation-fields.ts new file mode 100644 index 00000000000..3233d984948 --- /dev/null +++ b/packages/graphql/src/lib/operation-fields.ts @@ -0,0 +1,108 @@ +import { + walkPropertiesInherited, + type DecoratorContext, + type DecoratorFunction, + type Interface, + type Model, + type Operation, + type Program, +} from "@typespec/compiler"; +import { useStateMap } from "@typespec/compiler/utils"; +import { GraphQLKeys, reportDiagnostic } from "../lib.js"; +import { operationsEqual } from "./utils.js"; + +const [getOperationFieldsInternal, setOperationFields, _getOperationFieldsMap] = useStateMap< + Model, + Set +>(GraphQLKeys.operationFields); + +/** + * Get the operation fields for a given model + * @param program Program + * @param model Model + * @returns Set of operations defined for the model + */ +export function getOperationFields(program: Program, model: Model): Set { + return getOperationFieldsInternal(program, model) || new Set(); +} + +function validateDuplicateProperties( + context: DecoratorContext, + model: Model, + operation: Operation, +) { + const operationFields = getOperationFields(context.program, model); + if (operationFields.has(operation)) { + reportDiagnostic(context.program, { + code: "operation-field-duplicate", + format: { operation: operation.name, model: model.name }, + target: context.getArgumentTarget(0)!, + }); + return false; + } + return true; +} + +function validateNoConflictWithProperties( + context: DecoratorContext, + model: Model, + operation: Operation, +) { + const conflictTypes = []; + if ([...walkPropertiesInherited(model)].some((prop) => prop.name === operation.name)) { + conflictTypes.push("property"); // an operation and a property is always a conflict + } + const existingOperation = [...getOperationFields(context.program, model)].find( + (op) => op.name === operation.name, + ); + + if (existingOperation && !operationsEqual(existingOperation, operation)) { + conflictTypes.push("operation"); + } + for (const conflictType of conflictTypes) { + reportDiagnostic(context.program, { + code: "operation-field-conflict", + format: { operation: operation.name, model: model.name, conflictType }, + target: context.getArgumentTarget(0)!, + }); + } + return conflictTypes.length === 0; +} + +/** + * Add this operation to the model's operation fields. + * @param context DecoratorContext + * @param model Model + * @param operation Operation + */ +export function addOperationField( + context: DecoratorContext, + model: Model, + operation: Operation, +): void { + const operationFields = getOperationFields(context.program, model); + if (!validateDuplicateProperties(context, model, operation)) { + return; + } + if (!validateNoConflictWithProperties(context, model, operation)) { + return; + } + operationFields.add(operation); + setOperationFields(context.program, model, operationFields); +} + +export const $operationFields: DecoratorFunction = ( + context: DecoratorContext, + target: Model, + ...operationOrInterfaces: (Operation | Interface)[] +): void => { + for (const operationOrInterface of operationOrInterfaces) { + if (operationOrInterface.kind === "Operation") { + addOperationField(context, target, operationOrInterface); + } else { + for (const [_, operation] of operationOrInterface.operations) { + addOperationField(context, target, operation); + } + } + } +}; diff --git a/packages/graphql/src/lib/operation-kind.ts b/packages/graphql/src/lib/operation-kind.ts new file mode 100644 index 00000000000..d417602f1db --- /dev/null +++ b/packages/graphql/src/lib/operation-kind.ts @@ -0,0 +1,62 @@ +import { type DecoratorContext, type Operation } from "@typespec/compiler"; +import { SyntaxKind } from "@typespec/compiler/ast"; +import { useStateMap } from "@typespec/compiler/utils"; +import { GraphQLKeys, reportDiagnostic } from "../lib.js"; + +export type GraphQLOperationKind = "Mutation" | "Query" | "Subscription"; + +const [getOperationKind, setOperationKindInternal, _getOperationKindMap] = useStateMap< + Operation, + GraphQLOperationKind +>(GraphQLKeys.operationKind); + +function validateOperationKindUniqueOnNode(context: DecoratorContext, operation: Operation) { + const operationKindDecorators = operation.decorators.filter( + (x) => + OPERATION_KIND_DECORATORS.includes(x.decorator) && + x.node?.kind === SyntaxKind.DecoratorExpression && + x.node?.parent === operation.node, + ); + + if (operationKindDecorators.length > 1) { + reportDiagnostic(context.program, { + code: "graphql-operation-kind-duplicate", + format: { entityName: operation.name }, + target: context.decoratorTarget, + }); + return false; + } + return true; +} + +function setOperationKind( + context: DecoratorContext, + entity: Operation, + operationKind: GraphQLOperationKind, +): void { + if (validateOperationKindUniqueOnNode(context, entity)) { + setOperationKindInternal(context.program, entity, operationKind); + } +} + +function createOperationKindDecorator(operationKind: GraphQLOperationKind) { + return (context: DecoratorContext, entity: Operation) => { + setOperationKind(context, entity, operationKind); + }; +} + +export const $mutation = createOperationKindDecorator("Mutation"); +export const $query = createOperationKindDecorator("Query"); +export const $subscription = createOperationKindDecorator("Subscription"); + +export const OPERATION_KIND_DECORATORS = [$mutation, $query, $subscription]; + +export { + /** + * Get the operation kind for the given operation. + * @param program Program + * @param operation Operation + * @returns Operation kind or undefined if operation is not decorated with an operation kind. + */ + getOperationKind, +}; diff --git a/packages/graphql/src/lib/scalar-mappings.ts b/packages/graphql/src/lib/scalar-mappings.ts new file mode 100644 index 00000000000..9598fe1b4cc --- /dev/null +++ b/packages/graphql/src/lib/scalar-mappings.ts @@ -0,0 +1,256 @@ +import { type IntrinsicScalarName, type Program, type Scalar } from "@typespec/compiler"; +import { $, type Typekit } from "@typespec/compiler/typekit"; + +/** + * Represents a mapping from a TypeSpec standard library scalar to a GraphQL custom scalar. + */ +export interface ScalarMapping { + /** The GraphQL scalar name to emit */ + graphqlName: string; + /** The base GraphQL type (String, Int, or Float) */ + baseType: "String" | "Int" | "Float" | "Boolean" | "ID"; + /** Optional URL to specification for @specifiedBy directive */ + specificationUrl?: string; +} + +/** + * Mapping table for TypeSpec standard library scalars to GraphQL custom scalars. + * + * Built-in scalars (string, boolean, int32, float64, etc.) are NOT included here — + * they map directly to GraphQL built-in types and are resolved at emit time. + * This table only covers scalars that need to become custom GraphQL scalar types. + */ +const SCALAR_MAPPINGS = { + // int64 → Long (String) + int64: { + default: { + graphqlName: "Long", + baseType: "String", + specificationUrl: "http://scalars.graphql.org/jakobmerrild/long.html", + }, + }, + + // numeric → Numeric (String) + numeric: { + default: { + graphqlName: "Numeric", + baseType: "String", + }, + }, + + // decimal, decimal128 → BigDecimal (String) + decimal: { + default: { + graphqlName: "BigDecimal", + baseType: "String", + specificationUrl: "https://scalars.graphql.org/chillicream/decimal.html", + }, + }, + decimal128: { + default: { + graphqlName: "BigDecimal", + baseType: "String", + specificationUrl: "https://scalars.graphql.org/chillicream/decimal.html", + }, + }, + + // bytes — requires @encode to determine format; without encoding, no GraphQL mapping applies + bytes: { + base64: { + graphqlName: "Bytes", + baseType: "String", + specificationUrl: "https://datatracker.ietf.org/doc/html/rfc4648#section-4", + }, + base64url: { + graphqlName: "BytesUrl", + baseType: "String", + specificationUrl: "https://datatracker.ietf.org/doc/html/rfc4648#section-5", + }, + }, + + // utcDateTime — requires @encode to determine wire format; no default mapping without encoding + utcDateTime: { + rfc3339: { + graphqlName: "UTCDateTime", + baseType: "String", + specificationUrl: "https://scalars.graphql.org/chillicream/date-time.html", + }, + rfc7231: { + graphqlName: "UTCDateTimeHuman", + baseType: "String", + specificationUrl: "https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1", + }, + unixTimestamp: { + graphqlName: "UTCDateTimeUnix", + baseType: "Int", + }, + }, + + // offsetDateTime — requires @encode to determine wire format; no default mapping without encoding + offsetDateTime: { + rfc3339: { + graphqlName: "OffsetDateTime", + baseType: "String", + specificationUrl: "https://scalars.graphql.org/chillicream/date-time.html", + }, + rfc7231: { + graphqlName: "OffsetDateTimeHuman", + baseType: "String", + specificationUrl: "https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1", + }, + unixTimestamp: { + graphqlName: "OffsetDateTimeUnix", + baseType: "Int", + }, + }, + + // duration — requires @encode to determine wire format; no default mapping without encoding + duration: { + ISO8601: { + graphqlName: "Duration", + baseType: "String", + specificationUrl: "https://www.iso.org/standard/70907.html", + }, + seconds: { + graphqlName: "DurationSeconds", + baseType: "Int", // Could be Float based on context, defaulting to Int + }, + }, + + // plainDate → PlainDate (String) + plainDate: { + default: { + graphqlName: "PlainDate", + baseType: "String", + specificationUrl: "https://scalars.graphql.org/andimarek/local-date.html", + }, + }, + + // plainTime → PlainTime (String) + plainTime: { + default: { + graphqlName: "PlainTime", + baseType: "String", + specificationUrl: "https://scalars.graphql.org/apollographql/localtime-v0.1.html", + }, + }, + + // url → URL (String) + url: { + default: { + graphqlName: "URL", + baseType: "String", + specificationUrl: "https://url.spec.whatwg.org/", + }, + }, +} as const; + +type MappedScalarName = keyof typeof SCALAR_MAPPINGS; + +/** + * Check whether a scalar IS a standard library scalar (not just extends one). + * A std scalar's std base is itself. A user-defined scalar's std base is + * its ancestor (or null if it has no std ancestor). + */ +export function isStdScalar(tk: Typekit, scalar: Scalar): boolean { + return tk.scalar.getStdBase(scalar) === scalar; +} + +/** + * TypeSpec std scalar names that map directly to GraphQL built-in scalar types: + * string → String, boolean → Boolean, int32 → Int, float32/float64 → Float. + * + * These must NOT be renamed by the scalar mutation — they're resolved to + * GraphQL builtins at emit time. + * + * @see https://spec.graphql.org/September2025/#sec-Scalars.Built-in-Scalars + */ +const TSP_SCALARS_TO_GQL_BUILTINS: IntrinsicScalarName[] = [ + "string", + "boolean", + "int32", + "float32", + "float64", +]; + +/** + * Get the GraphQL scalar mapping for a scalar via its standard library ancestor. + * + * Uses `tk.scalar.getStdBase()` to find the std ancestor (e.g. `int64` for + * `scalar MyInt extends int64`), then looks up the mapping table by name. + * Returns undefined for scalars with no mapped ancestor. + * + * Note: this returns a mapping even for GraphQL builtins like `float32` + * (which inherits a mapping from `numeric`). Use {@link getCustomScalarMapping} + * when you need a mapping that should trigger renaming — it filters out builtins. + * + * @param program The TypeSpec program + * @param scalar The scalar type to map + * @param encoding Optional encoding to use instead of checking @encode on the scalar + * @returns The scalar mapping or undefined if no mapping exists + */ +export function getScalarMapping( + program: Program, + scalar: Scalar, + encoding?: string, +): ScalarMapping | undefined { + return getScalarMappingInternal($(program), scalar, encoding); +} + +/** + * Get the GraphQL custom scalar mapping for a standard library scalar — + * i.e., a mapping that should trigger renaming. + * + * Returns undefined for: + * - Scalars with no mapped ancestor + * - GraphQL builtins (string, boolean, int32, float32, float64) that should + * NOT be renamed even though they inherit a mapping via the extends chain + * (e.g. float32 → float → numeric → "Numeric") + * - Non-std scalars (user-defined scalars keep their own name) + * + * @param program The TypeSpec program + * @param scalar The scalar type to map (must be a std scalar) + * @returns The scalar mapping or undefined if the scalar shouldn't be renamed + */ +export function getCustomScalarMapping( + program: Program, + scalar: Scalar, +): ScalarMapping | undefined { + const tk = $(program); + if (!isStdScalar(tk, scalar)) return undefined; + if (TSP_SCALARS_TO_GQL_BUILTINS.some((name) => program.checker.isStdType(scalar, name))) + return undefined; + return getScalarMappingInternal(tk, scalar); +} + +function getScalarMappingInternal( + tk: Typekit, + scalar: Scalar, + encoding?: string, +): ScalarMapping | undefined { + // getStdBase walks the baseScalar chain and returns the first ancestor + // in the TypeSpec namespace (identity-safe, not name-based). + const stdBase = tk.scalar.getStdBase(scalar); + if (!stdBase || !(stdBase.name in SCALAR_MAPPINGS)) { + return undefined; + } + + const mappingTable = SCALAR_MAPPINGS[stdBase.name as MappedScalarName]; + if (!mappingTable) { + return undefined; + } + + // Encoding is checked on the original scalar, not the ancestor. + const actualEncoding = encoding ?? tk.scalar.getEncoding(scalar)?.encoding; + if (actualEncoding) { + const encodingMapping = (mappingTable as Record)[actualEncoding]; + if (encodingMapping) { + return encodingMapping; + } + } + + // Fall back to default mapping (not all mapping tables have a default) + return "default" in mappingTable + ? (mappingTable as Record).default + : undefined; +} diff --git a/packages/graphql/src/lib/schema.ts b/packages/graphql/src/lib/schema.ts new file mode 100644 index 00000000000..ec6346c2be0 --- /dev/null +++ b/packages/graphql/src/lib/schema.ts @@ -0,0 +1,74 @@ +import { + type DecoratorContext, + type DecoratorFunction, + type Namespace, + type Program, + validateDecoratorUniqueOnNode, +} from "@typespec/compiler"; + +import { useStateMap } from "@typespec/compiler/utils"; +import { GraphQLKeys } from "../lib.js"; + +export interface SchemaDetails { + name?: string; +} + +export interface Schema extends SchemaDetails { + type: Namespace; +} + +const [getSchema, setSchema, getSchemaMap] = useStateMap(GraphQLKeys.schema); + +/** + * List all the schemas defined in the TypeSpec program + * @param program Program + * @returns List of schemas. + */ +export function listSchemas(program: Program): Schema[] { + return [...getSchemaMap(program).values()]; +} + +export { + /** + * Get the schema information for the given namespace. + * @param program Program + * @param namespace Schema namespace + * @returns Schema information or undefined if namespace is not a schema namespace. + */ + getSchema, +}; + +/** + * Check if the namespace is defined as a schema. + * @param program Program + * @param namespace Namespace + * @returns Boolean + */ +export function isSchema(program: Program, namespace: Namespace): boolean { + return getSchemaMap(program).has(namespace); +} + +/** + * Mark the given namespace as a schema. + * @param program Program + * @param namespace Namespace + * @param details Schema details + */ +export function addSchema( + program: Program, + namespace: Namespace, + details: SchemaDetails = {}, +): void { + const schemaMap = getSchemaMap(program); + const existing = schemaMap.get(namespace) ?? {}; + setSchema(program, namespace, { ...existing, ...details, type: namespace }); +} + +export const $schema: DecoratorFunction = ( + context: DecoratorContext, + target: Namespace, + options: SchemaDetails = {}, +) => { + validateDecoratorUniqueOnNode(context, target, $schema); + addSchema(context.program, target, options); +}; diff --git a/packages/graphql/src/lib/specified-by.ts b/packages/graphql/src/lib/specified-by.ts new file mode 100644 index 00000000000..3047cdabae3 --- /dev/null +++ b/packages/graphql/src/lib/specified-by.ts @@ -0,0 +1,29 @@ +import { + type DecoratorContext, + type DecoratorFunction, + type Program, + type Scalar, + validateDecoratorUniqueOnNode, +} from "@typespec/compiler"; +import { useStateMap } from "@typespec/compiler/utils"; +import { GraphQLKeys } from "../lib.js"; + +const [getSpecifiedByUrl, setSpecifiedByUrl] = useStateMap(GraphQLKeys.specifiedBy); + +export { getSpecifiedByUrl, setSpecifiedByUrl }; + +/** + * Get the @specifiedBy URL for a scalar, if one has been set. + */ +export function getSpecifiedBy(program: Program, scalar: Scalar): string | undefined { + return getSpecifiedByUrl(program, scalar); +} + +export const $specifiedBy: DecoratorFunction = ( + context: DecoratorContext, + target: Scalar, + url: string, +) => { + validateDecoratorUniqueOnNode(context, target, $specifiedBy); + setSpecifiedByUrl(context.program, target, url); +}; diff --git a/packages/graphql/src/lib/template-composition.ts b/packages/graphql/src/lib/template-composition.ts new file mode 100644 index 00000000000..a8a77ee2f31 --- /dev/null +++ b/packages/graphql/src/lib/template-composition.ts @@ -0,0 +1,55 @@ +import { + getTypeName, + isArrayModelType, + isTemplateInstance, + type IndeterminateEntity, + type TemplatedType, + type Type, + type Value, +} from "@typespec/compiler"; +import { applyBaseNamePipeline } from "./naming.js"; + +function isNamedType(type: Type | Value | IndeterminateEntity): type is { name: string } & Type { + return "name" in type && typeof type.name === "string"; +} + +function resolveArgName(arg: Type): string { + if (arg.kind === "Model" && isArrayModelType(arg)) { + const rawName = getTypeName(arg); + return applyBaseNamePipeline(rawName); + } + if (isTemplateInstance(arg)) { + return composeTemplateName(arg); + } + const rawName = getTypeName(arg); + return applyBaseNamePipeline(rawName); +} + +/** + * Compose a name for a template instance by joining base name + "Of" + resolved arg names. + * Each arg is resolved recursively (nested templates produce nested "Of" names). + * Non-template types return their raw name unchanged. + * + * Examples: + * PaginatedModel → "PaginatedModelOfAdAccount" + * Map → "MapOfStringAndInt" + * Wrapper> → "WrapperOfPaginatedModelOfBoard" + */ +export function composeTemplateName(type: TemplatedType): string { + const name = type.name ?? ""; + + if (!isTemplateInstance(type)) { + return name; + } + + const args = type.templateMapper.args.filter(isNamedType); + + if (args.length === 0) { + return name; + } + + const resolvedArgs = args.map((arg) => resolveArgName(arg as Type)); + const argString = resolvedArgs.join("And"); + + return `${name}Of${argString}`; +} diff --git a/packages/graphql/src/lib/type-utils.ts b/packages/graphql/src/lib/type-utils.ts new file mode 100644 index 00000000000..4dc5423ce9c --- /dev/null +++ b/packages/graphql/src/lib/type-utils.ts @@ -0,0 +1,329 @@ +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, + type IdentifierNode, + type ModelPropertyNode, + type ModelStatementNode, + type Node, + SyntaxKind, +} from "@typespec/compiler/ast"; +import { camelCase, constantCase, pascalCase, split, splitSeparateNumbers } from "change-case"; +import { reportDiagnostic } from "../lib.js"; + +/** + * Extract the inner type from a nullable wrapper union (e.g., `string | null` → `string`). + * Matches only the `T | null` pattern (exactly 2 variants, one of which is null). + * + * These unions are not "real" unions in GraphQL terms — they're just TypeSpec's + * way of spelling "nullable T". The mutation engine replaces them with the inner type. + * + * For multi-variant unions that contain null (e.g. `Cat | Dog | null`), + * use {@link stripNullVariants} instead. + * + * @returns The non-null variant type if this is a nullable wrapper, otherwise undefined. + */ +export function unwrapNullableUnion(union: Union): Type | undefined { + if (union.variants.size !== 2) return undefined; + const variants = Array.from(union.variants.values()); + const nullVariant = variants.find((v) => isNullType(v.type)); + if (!nullVariant) return undefined; + return variants.find((v) => v !== nullVariant)?.type; +} + +/** + * Check whether a type is a `T | null` union (exactly two variants, one null). + */ +export function isNullableUnion(type: Type): boolean { + return type.kind === "Union" && unwrapNullableUnion(type) !== undefined; +} + +/** + * Strip null variants from a union, returning the remaining variants + * and whether the union contained null. + * + * Used by the mutation engine to handle unions like `Cat | Dog | null`: + * the null is removed, the remaining variants are processed as a real union, + * and the nullability is tracked separately via the nullable state map. + */ +export function stripNullVariants(union: Union): { + variants: UnionVariant[]; + isNullable: boolean; +} { + const allVariants = Array.from(union.variants.values()); + const nonNullVariants = allVariants.filter((v) => !isNullType(v.type)); + return { + variants: nonNullVariants, + isNullable: nonNullVariants.length < allVariants.length, + }; +} + +/** Generate a GraphQL type name for a templated model (e.g., `ListOfString`). */ +export function getTemplatedModelName(model: Model): string { + const name = getTypeName(model, {}); + // Strip generic type parameters from compiler type names (e.g., "List" → "List"). + // This regex matches angle-bracket syntax, not HTML — output is GraphQL SDL identifiers. + const baseName = toTypeName(name.replace(/<[^>]*>/g, "")); + const templateString = getTemplateString(model); + return templateString ? `${baseName}Of${templateString}` : baseName; +} + +function splitWithAcronyms( + splitFn: (name: string) => string[], + skipStart: boolean, + name: string, +): string[] { + const parts = splitFn(name); + + if (name === name.toUpperCase()) { + return parts; + } + // Split consecutive capital letters into individual characters for proper casing, + // e.g. "API" becomes ["A", "P", "I"] so PascalCase produces "Api" → but we preserve + // all-caps names at the toTypeName level, so this only affects mixed-case like "APIResponse". + return parts.flatMap((part, index) => { + if (skipStart && index === 0) return part; + if (part.match(/^[A-Z]+$/)) return part.split(""); + return part; + }); +} + +/** Convert a name to PascalCase for GraphQL type names. */ +export function toTypeName(name: string): string { + const sanitized = sanitizeNameForGraphQL(getNameWithoutNamespace(name)); + // Preserve all-caps names (acronyms like API, HTTP, URL) + if (/^[A-Z]+$/.test(sanitized)) { + return sanitized; + } + return pascalCase(sanitized, { + split: splitWithAcronyms.bind(null, split, false), + }); +} + +/** + * Sanitize a name to conform to GraphQL identifier format. + * Handles character-level formatting only (special chars, leading digits, array syntax). + */ +export function sanitizeNameForGraphQL(name: string, prefix: string = ""): string { + name = name.replace("[]", "Array"); + name = name.replaceAll(/\W/g, "_"); + if (!/^[_a-zA-Z]/.test(name)) { + name = `${prefix}_${name}`; + } + return name; +} + +/** Convert a name to CONSTANT_CASE for GraphQL enum members. */ +export function toEnumMemberName(enumName: string, name: string) { + return constantCase(sanitizeNameForGraphQL(name, enumName), { + split: splitSeparateNumbers, + prefixCharacters: "_", + }); +} + +/** Convert a name to camelCase for GraphQL field names. */ +export function toFieldName(name: string): string { + return camelCase(sanitizeNameForGraphQL(name), { + prefixCharacters: "_", + split: splitWithAcronyms.bind(null, split, true), + }); +} + +function getNameWithoutNamespace(name: string): string { + const parts = name.trim().split("."); + return parts[parts.length - 1]; +} + +/** Generate a GraphQL type name for a union, including anonymous unions. */ +export function getUnionName(union: Union, program: Program): string { + // Named union — use its name directly + if (union.name) { + return union.name; + } + + const ts = getTemplateString(union); + const templateString = ts ? "Of" + ts : ""; + + // Anonymous return type — name after the operation + // e.g. op getBaz(): Foo | Bar => GetBazUnion + if (isReturnType(union)) { + return `${getUnionNameForOperation(program, union)}${templateString}Union`; + } + + // Anonymous model property — name after model + property + // e.g. model Foo { bar: Bar | Baz } => FooBarUnion + const modelProperty = getModelProperty(union); + if (modelProperty) { + const propName = toTypeName(getNameForNode(modelProperty)); + const unionModel = union.node?.parent?.parent as ModelStatementNode; + const modelName = unionModel ? getNameForNode(unionModel) : ""; + return `${modelName}${propName}${templateString}Union`; + } + + // Alias — name after the alias + // e.g. alias Baz = Foo | Bar => Baz + const alias = getAlias(union); + if (alias) { + const aliasName = getNameForNode(alias); + return `${aliasName}${templateString}`; + } + + reportDiagnostic(program, { + code: "unrecognized-union", + target: union, + }); + return "UnknownUnion"; +} + +function isNamedType(type: Type | Value | IndeterminateEntity): type is { name: string } & Type { + return "name" in type && typeof (type as { name: unknown }).name === "string"; +} + +function isAliased(union: Union): boolean { + return union.node?.parent?.kind === SyntaxKind.AliasStatement; +} + +function getAlias(union: Union): AliasStatementNode | undefined { + return isAliased(union) ? (union.node?.parent as AliasStatementNode) : undefined; +} + +function isModelProperty(union: Union): boolean { + return union.node?.parent?.kind === SyntaxKind.ModelProperty; +} + +function getModelProperty(union: Union): ModelPropertyNode | undefined { + return isModelProperty(union) ? (union.node?.parent as ModelPropertyNode) : undefined; +} + +function isReturnType(type: Type): boolean { + return !!( + type.node && + type.node.parent?.kind === SyntaxKind.OperationSignatureDeclaration && + type.node.parent?.parent?.kind === SyntaxKind.OperationStatement + ); +} + +type NamedNode = Node & { id: IdentifierNode }; + +function getNameForNode(node: NamedNode): string { + return "id" in node && node.id?.kind === SyntaxKind.Identifier ? node.id.sv : ""; +} + +function getUnionNameForOperation(program: Program, union: Union): string { + const operationNode = union.node?.parent?.parent; + if (!operationNode) return "Unknown"; + const operation = program.checker.getTypeForNode(operationNode); + + return toTypeName(getTypeName(operation)); +} + +/** Convert a namespaced name to a single name by replacing dots with underscores. */ +export function getSingleNameWithNamespace(name: string): string { + return name.trim().replace(/\./g, "_"); +} + +/** + * Check if a model is an array type. + */ +export function isArray(model: Model): model is ArrayModelType { + return Boolean(model.indexer && model.indexer.key.name === "integer"); +} + +/** + * Check if a model is a record/map type. + */ +export function isRecordType(type: Model): type is RecordModelType { + return Boolean(type.indexer && type.indexer.key.name === "string"); +} + +/** Check if a model is an array of scalars or enums. */ +export function isScalarOrEnumArray(type: Model): type is ArrayModelType { + return ( + isArray(type) && (type.indexer?.value.kind === "Scalar" || type.indexer?.value.kind === "Enum") + ); +} + +/** Check if a model is an array of unions. */ +export function isUnionArray(type: Model): type is ArrayModelType { + return isArray(type) && type.indexer?.value.kind === "Union"; +} + +/** Extract the element type from an array model, or return the model itself. */ +export function unwrapModel(model: ArrayModelType): Model | Scalar | Enum | Union; +export function unwrapModel(model: Exclude): Model; +export function unwrapModel(model: Model): Model | Scalar | Enum | Union { + if (!isArray(model)) { + return model; + } + + if (model.indexer?.value.kind) { + if (["Model", "Scalar", "Enum", "Union"].includes(model.indexer.value.kind)) { + return model.indexer.value as Model | Scalar | Enum | Union; + } + throw new Error(`Unexpected array type: ${model.indexer.value.kind}`); + } + return model; +} + +/** Unwrap array types to get the inner element type. */ +export function unwrapType(type: Model): Model | Scalar | Enum | Union; +export function unwrapType(type: Type): Type; +export function unwrapType(type: Type): Type { + if (type.kind === "Model") { + return unwrapModel(type); + } + return type; +} + +/** Get the GraphQL description for a type from its doc comments. */ +export function getGraphQLDoc(program: Program, type: Type): string | undefined { + // GraphQL uses CommonMark for descriptions + // https://spec.graphql.org/October2021/#sec-Descriptions + return getDoc(program, type); +} + +/** Generate a string representation of template arguments (e.g., `StringAndInt`). */ +export function getTemplateString( + type: Type, + options: { conjunction: string } = { conjunction: "And" }, +): string { + if (isTemplateInstance(type)) { + const args = type.templateMapper.args.filter(isNamedType).map((arg) => getTypeName(arg)); + return getTemplateStringInternal(args, options); + } + return ""; +} + +function getTemplateStringInternal( + args: string[], + options: { conjunction: string } = { conjunction: "And" }, +): string { + // Apply toTypeName to convert raw compiler names (e.g., "string") to GraphQL PascalCase ("String") + 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 { + if (isScalarOrEnumArray(model)) return false; + if (isUnionArray(model)) return false; + if (isNeverType(model)) return false; + if (isRecordType(model) && [...walkPropertiesInherited(model)].length === 0) return false; + return true; +} diff --git a/packages/graphql/src/lib/utils.ts b/packages/graphql/src/lib/utils.ts new file mode 100644 index 00000000000..b775e8db8b2 --- /dev/null +++ b/packages/graphql/src/lib/utils.ts @@ -0,0 +1,53 @@ +import { + getTypeName, + walkPropertiesInherited, + type Model, + type ModelProperty, + type Operation, + type Type, +} from "@typespec/compiler"; + +function typesEqual(a: Type, b: Type): boolean { + return a === b || getTypeName(a) === getTypeName(b); +} + +export function propertiesEqual( + prop1: ModelProperty, + prop2: ModelProperty, + ignoreNames: boolean = false, +): boolean { + if (!ignoreNames && prop1.name !== prop2.name) { + return false; + } + return typesEqual(prop1.type, prop2.type) && prop1.optional === prop2.optional; +} + +export function modelsEqual(model1: Model, model2: Model, ignoreNames: boolean = false): boolean { + if (!ignoreNames && model1.name !== model2.name) { + return false; + } + const model1Properties = new Set(walkPropertiesInherited(model1)); + const model2Properties = new Set(walkPropertiesInherited(model2)); + if (model1Properties.size !== model2Properties.size) { + return false; + } + if ( + [...model1Properties].some( + (prop) => ![...model2Properties].some((p) => propertiesEqual(prop, p, false)), + ) + ) { + return false; + } + return true; +} + +export function operationsEqual( + op1: Operation, + op2: Operation, + ignoreNames: boolean = false, +): boolean { + if (!ignoreNames && op1.name !== op2.name) { + return false; + } + return typesEqual(op1.returnType, op2.returnType) && modelsEqual(op1.parameters, op2.parameters, true); +} diff --git a/packages/graphql/src/lib/visibility.ts b/packages/graphql/src/lib/visibility.ts new file mode 100644 index 00000000000..1caca41b154 --- /dev/null +++ b/packages/graphql/src/lib/visibility.ts @@ -0,0 +1,49 @@ +import { + getLifecycleVisibilityEnum, + isVisible, + type Model, + type ModelProperty, + type Program, + type VisibilityFilter, +} from "@typespec/compiler"; + +export interface GraphQLVisibilityFilters { + query: VisibilityFilter; + mutation: VisibilityFilter; + output: VisibilityFilter; +} + +export function createVisibilityFilters(program: Program): GraphQLVisibilityFilters { + const lifecycleEnum = getLifecycleVisibilityEnum(program); + const createMember = lifecycleEnum.members.get("Create")!; + const readMember = lifecycleEnum.members.get("Read")!; + const updateMember = lifecycleEnum.members.get("Update")!; + const queryMember = lifecycleEnum.members.get("Query")!; + + return { + query: { any: new Set([readMember, queryMember]) }, + mutation: { any: new Set([createMember, updateMember]) }, + output: { any: new Set([readMember]) }, + }; +} + +export function isPropertyVisible( + program: Program, + property: ModelProperty, + filter: VisibilityFilter, +): boolean { + return isVisible(program, property, filter); +} + +export function hasNoVisibleProperties( + program: Program, + model: Model, + filter: VisibilityFilter, +): boolean { + for (const prop of model.properties.values()) { + if (isVisible(program, prop, filter)) return false; + } + return true; +} + +export type { VisibilityFilter }; diff --git a/packages/graphql/src/mutation-engine/engine.ts b/packages/graphql/src/mutation-engine/engine.ts new file mode 100644 index 00000000000..f6857759a05 --- /dev/null +++ b/packages/graphql/src/mutation-engine/engine.ts @@ -0,0 +1,138 @@ +import { + type Enum, + type Model, + type Operation, + type Program, + type Scalar, + type Union, + type VisibilityFilter, +} from "@typespec/compiler"; + +import { $ } from "@typespec/compiler/typekit"; +import { + MutationEngine, + SimpleInterfaceMutation, + SimpleIntrinsicMutation, + SimpleLiteralMutation, + SimpleMutationOptions, + SimpleUnionVariantMutation, +} from "@typespec/mutator-framework"; +import { + GraphQLEnumMemberMutation, + GraphQLEnumMutation, + GraphQLModelMutation, + GraphQLModelPropertyMutation, + GraphQLOperationMutation, + GraphQLScalarMutation, + GraphQLUnionMutation, +} from "./mutations/index.js"; +import { GraphQLMutationOptions, GraphQLTypeContext } from "./options.js"; + +/** + * Registry configuration for the GraphQL mutation engine. + * Maps TypeSpec type kinds to their corresponding GraphQL mutation classes. + */ +const graphqlMutationRegistry = { + // Custom GraphQL mutations for types we need to transform + Enum: GraphQLEnumMutation, + EnumMember: GraphQLEnumMemberMutation, + Model: GraphQLModelMutation, + ModelProperty: GraphQLModelPropertyMutation, + Operation: GraphQLOperationMutation, + Scalar: GraphQLScalarMutation, + Union: GraphQLUnionMutation, + // Use Simple* classes from mutator-framework for types we don't customize + Interface: SimpleInterfaceMutation, + UnionVariant: SimpleUnionVariantMutation, + String: SimpleLiteralMutation, + Number: SimpleLiteralMutation, + Boolean: SimpleLiteralMutation, + Intrinsic: SimpleIntrinsicMutation, +}; + +/** + * GraphQL mutation engine that applies GraphQL-specific transformations + * to TypeSpec types, such as name sanitization, scalar mapping, and + * input/output type splitting via mutation keys. + * + * When an operation is mutated, parameters are automatically mutated with + * input context and return types with output context. The mutation framework's + * cache ensures each (type, context) pair produces a separate mutation. + */ +export class GraphQLMutationEngine { + // Type is inferred from the MutationEngine constructor. Explicitly typing as + // MutationEngine doesn't work because the + // generic expects instance types, not constructor types. + private engine; + + constructor(program: Program) { + const tk = $(program); + this.engine = new MutationEngine(tk, graphqlMutationRegistry); + } + + /** + * Mutate a model with explicit input/output context and optional visibility filter. + * Models mutated with different contexts produce separate cached mutations, + * allowing the same source model to have both an input and output variant. + */ + mutateModel( + model: Model, + context: GraphQLTypeContext, + visibilityFilter?: VisibilityFilter, + operationKind?: string, + inputQualifier?: string, + ): GraphQLModelMutation { + return this.engine.mutate( + model, + new GraphQLMutationOptions(context, visibilityFilter, operationKind, inputQualifier), + ) as GraphQLModelMutation; + } + + /** + * Mutate an enum, applying GraphQL name sanitization. + */ + mutateEnum(enumType: Enum): GraphQLEnumMutation { + return this.engine.mutate(enumType, new SimpleMutationOptions()) as GraphQLEnumMutation; + } + + /** + * Mutate an operation, applying GraphQL name sanitization. + * Parameters are automatically mutated with input context, + * return types with output context. + */ + mutateOperation(operation: Operation): GraphQLOperationMutation { + return this.engine.mutate(operation, new SimpleMutationOptions()) as GraphQLOperationMutation; + } + + /** + * Mutate a scalar, applying GraphQL name sanitization. + */ + mutateScalar(scalar: Scalar): GraphQLScalarMutation { + return this.engine.mutate(scalar, new SimpleMutationOptions()) as GraphQLScalarMutation; + } + + /** + * Mutate a union with explicit input/output context. + * In output context: creates wrapper types for scalar variants. mutatedType is a Union. + * In input context: replaces the union with a @oneOf input Model in the type graph, + * since GraphQL unions are output-only. mutatedType is a Model. + */ + mutateUnion( + union: Union, + context: GraphQLTypeContext, + visibilityFilter?: VisibilityFilter, + operationKind?: string, + ): GraphQLUnionMutation { + return this.engine.mutate( + union, + new GraphQLMutationOptions(context, visibilityFilter, operationKind), + ) as GraphQLUnionMutation; + } +} + +/** + * Creates a GraphQL mutation engine for the given program. + */ +export function createGraphQLMutationEngine(program: Program): GraphQLMutationEngine { + return new GraphQLMutationEngine(program); +} diff --git a/packages/graphql/src/mutation-engine/index.ts b/packages/graphql/src/mutation-engine/index.ts new file mode 100644 index 00000000000..e66e7ad38c9 --- /dev/null +++ b/packages/graphql/src/mutation-engine/index.ts @@ -0,0 +1,13 @@ +export { GraphQLMutationEngine, createGraphQLMutationEngine } from "./engine.js"; +export { + GraphQLEnumMemberMutation, + GraphQLEnumMutation, + GraphQLModelMutation, + GraphQLModelPropertyMutation, + GraphQLOperationMutation, + GraphQLScalarMutation, + GraphQLUnionMutation, +} from "./mutations/index.js"; +export { GraphQLMutationOptions, GraphQLTypeContext } from "./options.js"; +export { mutateSchema } from "./schema-mutator.js"; +export { buildTypeGraph, type TypeGraph } from "./type-graph.js"; diff --git a/packages/graphql/src/mutation-engine/mutations/enum-member.ts b/packages/graphql/src/mutation-engine/mutations/enum-member.ts new file mode 100644 index 00000000000..bcbbc50258e --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/enum-member.ts @@ -0,0 +1,49 @@ +import type { EnumMember, MemberType } from "@typespec/compiler"; +import { + EnumMemberMutation, + EnumMemberMutationNode, + MutationEngine, + type MutationInfo, + type MutationOptions, +} from "@typespec/mutator-framework"; +import { applyEnumMemberPipeline } from "../../lib/naming.js"; + +/** + * GraphQL-specific EnumMember mutation. + */ +export class GraphQLEnumMemberMutation extends EnumMemberMutation< + MutationOptions, + any, + MutationEngine +> { + #mutationNode: EnumMemberMutationNode; + + constructor( + engine: MutationEngine, + sourceType: EnumMember, + referenceTypes: MemberType[], + options: MutationOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }) as EnumMemberMutationNode; + } + + get mutationNode() { + return this.#mutationNode; + } + + get mutatedType() { + return this.#mutationNode.mutatedType; + } + + mutate() { + this.#mutationNode.mutate((member) => { + member.name = applyEnumMemberPipeline(member.name); + }); + super.mutate(); + } +} diff --git a/packages/graphql/src/mutation-engine/mutations/enum.ts b/packages/graphql/src/mutation-engine/mutations/enum.ts new file mode 100644 index 00000000000..95c6bbbc1c1 --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/enum.ts @@ -0,0 +1,76 @@ +import type { Enum, MemberType } from "@typespec/compiler"; +import { + EnumMemberMutationNode, + EnumMutation, + EnumMutationNode, + MutationEngine, + MutationHalfEdge, + type MutationInfo, + type MutationOptions, +} from "@typespec/mutator-framework"; +import { applyTypeNamePipeline } from "../../lib/naming.js"; +import type { GraphQLEnumMemberMutation } from "./enum-member.js"; + +/** + * GraphQL-specific Enum mutation. + */ +export class GraphQLEnumMutation extends EnumMutation> { + #mutationNode: EnumMutationNode; + + constructor( + engine: MutationEngine, + sourceType: Enum, + referenceTypes: MemberType[], + options: MutationOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }) as EnumMutationNode; + } + + get mutationNode() { + return this.#mutationNode; + } + + get mutatedType() { + return this.#mutationNode.mutatedType; + } + + /** + * Creates a MutationHalfEdge that wraps the node-level edge. + * This ensures proper bidirectional updates when members are renamed. + */ + protected startMemberEdge(): MutationHalfEdge { + return new MutationHalfEdge("member", this, (tail) => { + this.#mutationNode.connectMember(tail.mutationNode as EnumMemberMutationNode); + }); + } + + /** + * Override to pass half-edge for proper bidirectional updates. + */ + protected override mutateMembers() { + for (const member of this.sourceType.members.values()) { + this.members.set( + member.name, + this.engine.mutate(member, this.options, this.startMemberEdge()), + ); + } + } + + mutate() { + this.#mutationNode.mutate((enumType) => { + enumType.name = applyTypeNamePipeline(enumType.name, { + isInput: false, + isInterface: false, + }); + }); + // Handle member mutations with proper edges + this.mutateMembers(); + // Call super to finalize + super.mutate(); + } +} diff --git a/packages/graphql/src/mutation-engine/mutations/index.ts b/packages/graphql/src/mutation-engine/mutations/index.ts new file mode 100644 index 00000000000..580fba1dabe --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/index.ts @@ -0,0 +1,7 @@ +export { GraphQLEnumMemberMutation } from "./enum-member.js"; +export { GraphQLEnumMutation } from "./enum.js"; +export { GraphQLModelPropertyMutation } from "./model-property.js"; +export { GraphQLModelMutation } from "./model.js"; +export { GraphQLOperationMutation } from "./operation.js"; +export { GraphQLScalarMutation } from "./scalar.js"; +export { GraphQLUnionMutation } from "./union.js"; diff --git a/packages/graphql/src/mutation-engine/mutations/model-property.ts b/packages/graphql/src/mutation-engine/mutations/model-property.ts new file mode 100644 index 00000000000..ab4287123ec --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/model-property.ts @@ -0,0 +1,60 @@ +import { isArrayModelType, type MemberType, type ModelProperty } from "@typespec/compiler"; +import { + SimpleModelPropertyMutation, + type MutationInfo, + type SimpleMutationEngine, + type SimpleMutationOptions, + type SimpleMutations, +} from "@typespec/mutator-framework"; +import { applyFieldNamePipeline } from "../../lib/naming.js"; +import { setNullable, setNullableElements } from "../../lib/nullable.js"; +import { isNullableUnion, unwrapNullableUnion } from "../../lib/type-utils.js"; + +/** GraphQL-specific ModelProperty mutation. */ +export class GraphQLModelPropertyMutation extends SimpleModelPropertyMutation { + constructor( + engine: SimpleMutationEngine>, + sourceType: ModelProperty, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + // Register rename callback before edge connections trigger mutation. + this.mutationNode.whenMutated((property) => { + if (property) { + property.name = applyFieldNamePipeline(property.name); + } + }); + } + + mutate() { + // Snapshot nullability from the original type BEFORE mutation replaces it. + // We mark the property (not the inner type) to avoid poisoning shared singletons. + const originalType = this.sourceType.type; + + const isInlineNullable = isNullableUnion(originalType); + + // For element nullability, look through an outer `| null` wrapper to find the array. + // e.g. `(string | null)[] | null` → unwrap outer null → check array elements. + const innerType = + originalType.kind === "Union" + ? (unwrapNullableUnion(originalType) ?? originalType) + : originalType; + + const isArrayWithNullableElements = + innerType.kind === "Model" && + isArrayModelType(innerType) && + isNullableUnion(innerType.indexer.value); + + this.mutationNode.mutate(); + super.mutate(); + + if (isInlineNullable) { + setNullable(this.mutatedType); + } + if (isArrayWithNullableElements) { + setNullableElements(this.mutatedType); + } + } +} diff --git a/packages/graphql/src/mutation-engine/mutations/model.ts b/packages/graphql/src/mutation-engine/mutations/model.ts new file mode 100644 index 00000000000..2f9ed80792a --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/model.ts @@ -0,0 +1,206 @@ +import { + isArrayModelType, + isTemplateInstance, + isType, + walkPropertiesInherited, + type MemberType, + type Model, + type Program, + type Type, + type Value, +} from "@typespec/compiler"; +import { + type MutationOptions, + SimpleModelMutation, + type MutationInfo, + type SimpleMutationEngine, + type SimpleMutationOptions, + type SimpleMutations, +} from "@typespec/mutator-framework"; +import { isInterfaceOnly } from "../../lib/interface.js"; +import { applyTypeNamePipeline } from "../../lib/naming.js"; +import { composeTemplateName } from "../../lib/template-composition.js"; +import { isRecordType } from "../../lib/type-utils.js"; +import { hasNoVisibleProperties, isPropertyVisible, type VisibilityFilter } from "../../lib/visibility.js"; +import { GraphQLMutationOptions, GraphQLTypeContext } from "../options.js"; + +/** + * Maps decorator function names to the mutation context their type args + * should be mutated with. When a model is cloned, decorator args that + * reference types need re-mutation — but the context may differ from the + * model's own context (e.g., @compose args are always interfaces regardless + * of whether the model is mutated as Input or Output). + * + * Keyed by function name (not reference) because vitest can load the same + * module from different paths, creating distinct function objects. + */ +const decoratorArgContext = new Map([ + ["$compose", GraphQLTypeContext.Interface], +]); + +/** + * GraphQL-specific Model mutation. + */ +export class GraphQLModelMutation extends SimpleModelMutation { + constructor( + engine: SimpleMutationEngine>, + sourceType: Model, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + } + + /** + * The input/output context this model was mutated with, if any. + * Undefined when the model was mutated directly (not through an operation). + */ + get typeContext(): GraphQLTypeContext | undefined { + return this.options instanceof GraphQLMutationOptions ? this.options.typeContext : undefined; + } + + mutate() { + const tk = this.engine.$; + const program = tk.program; + const isInputContext = this.typeContext === GraphQLTypeContext.Input; + const isInterfaceContext = this.typeContext === GraphQLTypeContext.Interface; + + const rawName = isTemplateInstance(this.sourceType) + ? composeTemplateName(this.sourceType) + : this.sourceType.name; + const visibilityFilter = this.options instanceof GraphQLMutationOptions + ? this.options.visibilityFilter + : undefined; + const inputQualifier = + this.options instanceof GraphQLMutationOptions ? this.options.inputQualifier : undefined; + + if (this.shouldReplaceWithScalar(program, visibilityFilter)) { + // Record scalars should NOT get Input suffix - they're opaque map types with + // no structural difference between input/output. Visibility-filtered scalars + // (where all properties were removed) keep the Input suffix to distinguish variants. + const isPureRecord = isRecordType(this.sourceType) && + (walkPropertiesInherited(this.sourceType).next().done ?? false); + const scalarName = applyTypeNamePipeline(rawName, { + isInput: isInputContext && !isPureRecord, + isInterface: false, + inputQualifier: isPureRecord ? undefined : inputQualifier, + }); + this.mutationNode.replace(program.checker.createType({ + kind: "Scalar", + name: scalarName, + decorators: [], + derivedScalars: [], + constructors: new Map(), + })); + return; + } + + const needsInterfaceSuffix = + isInterfaceContext && !isInterfaceOnly(program, this.sourceType); + + this.mutationNode.mutate((model) => { + model.name = applyTypeNamePipeline(rawName, { + isInput: isInputContext, + isInterface: needsInterfaceSuffix, + inputQualifier, + }); + if (isInputContext) { + model.decorators = model.decorators.filter( + (d) => !decoratorArgContext.has(d.decorator.name), + ); + } else { + this.mutateDecoratorTypeArgs(model); + } + }); + super.mutate(); + this.flattenBaseModel(); + } + + protected override mutateProperties(newOptions: MutationOptions = this.options) { + const visibilityFilter = this.options instanceof GraphQLMutationOptions + ? this.options.visibilityFilter + : undefined; + + if (!visibilityFilter) { + super.mutateProperties(newOptions); + return; + } + + const program = this.engine.$.program; + + for (const prop of this.sourceType.properties.values()) { + if (!isPropertyVisible(program, prop, visibilityFilter)) { + this.mutationNode.mutatedType.properties.delete(prop.name); + } + } + for (const prop of this.sourceType.properties.values()) { + if (isPropertyVisible(program, prop, visibilityFilter)) { + this.properties.set( + prop.name, + this.engine.mutate(prop, newOptions, this.startPropertyEdge()), + ); + } + } + } + + private flattenBaseModel() { + if (!this.baseModel) return; + const mutated = this.mutationNode.mutatedType; + const baseProps = this.baseModel.mutatedType.properties; + const ownEntries = [...mutated.properties.entries()]; + mutated.properties.clear(); + for (const [name, prop] of baseProps) { + mutated.properties.set(name, prop); + } + for (const [name, prop] of ownEntries) { + mutated.properties.set(name, prop); + } + mutated.baseModel = undefined; + } + + private shouldReplaceWithScalar(program: Program, visibilityFilter: VisibilityFilter | undefined): boolean { + if (!this.sourceType.name || isArrayModelType(this.sourceType)) return false; + return this.willHaveNoFields(program, visibilityFilter); + } + + private willHaveNoFields(program: Program, visibilityFilter: VisibilityFilter | undefined): boolean { + // Record with no own/inherited properties → opaque map scalar + if (isRecordType(this.sourceType)) return walkPropertiesInherited(this.sourceType).next().done ?? false; + // Model declared with no properties at all + if (this.sourceType.properties.size === 0) return true; + // All properties removed by visibility filtering (e.g., all @visibility(Lifecycle.Read) in input context) + if (visibilityFilter) return hasNoVisibleProperties(program, this.sourceType, visibilityFilter); + return false; + } + + private mutateDecoratorTypeArgs(model: Model) { + for (let i = 0; i < model.decorators.length; i++) { + const dec = model.decorators[i]; + const argContext = decoratorArgContext.get(dec.decorator.name); + const options = argContext + ? new GraphQLMutationOptions(argContext) + : this.options; + + let argsChanged = false; + const newArgs = dec.args.map((arg) => { + if (this.isMutatableType(arg.value)) { + const mutation = this.engine.mutate(arg.value, options) as { mutatedType: Type }; + argsChanged = true; + return { ...arg, value: mutation.mutatedType, jsValue: mutation.mutatedType }; + } + return arg; + }); + + if (argsChanged) { + model.decorators[i] = { ...dec, args: newArgs }; + } + } + } + + private isMutatableType(value: Type | Value): value is Type { + if (!isType(value)) return false; + const kind = value.kind; + return kind === "Model" || kind === "Union" || kind === "Scalar" || kind === "Enum" || kind === "Interface" || kind === "Operation"; + } +} diff --git a/packages/graphql/src/mutation-engine/mutations/operation.ts b/packages/graphql/src/mutation-engine/mutations/operation.ts new file mode 100644 index 00000000000..e0bd59116c7 --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/operation.ts @@ -0,0 +1,94 @@ +import { isArrayModelType, type MemberType, type Operation } from "@typespec/compiler"; +import { + SimpleOperationMutation, + type MutationInfo, + type SimpleMutationEngine, + type SimpleMutationOptions, + type SimpleMutations, +} from "@typespec/mutator-framework"; +import { applyFieldNamePipeline } from "../../lib/naming.js"; +import { setNullable, setNullableElements } from "../../lib/nullable.js"; +import { isNullableUnion, unwrapNullableUnion } from "../../lib/type-utils.js"; +import { getOperationKind } from "../../lib/operation-kind.js"; +import { createVisibilityFilters } from "../../lib/visibility.js"; +import { GraphQLMutationOptions, GraphQLTypeContext } from "../options.js"; + +/** GraphQL-specific Operation mutation. */ +export class GraphQLOperationMutation extends SimpleOperationMutation { + constructor( + engine: SimpleMutationEngine>, + sourceType: Operation, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + } + + /** Mutate parameters with input context and operation-kind-aware visibility. */ + protected override mutateParameters() { + const program = this.engine.$.program; + const kind = getOperationKind(program, this.sourceType); + const filters = createVisibilityFilters(program); + const isQuery = kind === "Query" || kind === "Subscription"; + const visibilityFilter = isQuery ? filters.query : filters.mutation; + const opKind = isQuery ? "query" : "mutation"; + const inputOptions = new GraphQLMutationOptions( + GraphQLTypeContext.Input, visibilityFilter, opKind, + ); + this.parameters = this.engine.mutate( + this.sourceType.parameters, + inputOptions, + this.startParametersEdge(), + ); + } + + /** Mutate return type with output context. */ + protected override mutateReturnType() { + const program = this.engine.$.program; + const filters = createVisibilityFilters(program); + const outputOptions = new GraphQLMutationOptions(GraphQLTypeContext.Output, filters.output); + this.returnType = this.engine.mutate( + this.sourceType.returnType, + outputOptions, + this.startReturnTypeEdge(), + ); + } + + mutate() { + // Snapshot return-type nullability before mutation replaces it. + const returnType = this.sourceType.returnType; + const hasNullableReturn = isNullableUnion(returnType); + + // For element nullability, look through an outer `| null` wrapper to find the array. + // e.g. `(string | null)[] | null` → unwrap outer null → check array elements. + const innerReturnType = + returnType.kind === "Union" ? (unwrapNullableUnion(returnType) ?? returnType) : returnType; + + const hasNullableElements = + innerReturnType.kind === "Model" && + isArrayModelType(innerReturnType) && + isNullableUnion(innerReturnType.indexer.value); + + this.mutationNode.mutate((operation) => { + const iface = this.sourceType.interface; + const rawName = iface ? `${iface.name}_${operation.name}` : operation.name; + operation.name = applyFieldNamePipeline(rawName); + }); + super.mutate(); + + // Remove parameters whose type was visibility-filtered to an empty model. + for (const [name, param] of this.mutatedType.parameters.properties) { + if (param.type.kind === "Model" && !isArrayModelType(param.type) && param.type.properties.size === 0) { + this.mutatedType.parameters.properties.delete(name); + } + } + + if (hasNullableReturn) { + setNullable(this.mutatedType); + } + if (hasNullableElements) { + setNullableElements(this.mutatedType); + } + } +} diff --git a/packages/graphql/src/mutation-engine/mutations/scalar.ts b/packages/graphql/src/mutation-engine/mutations/scalar.ts new file mode 100644 index 00000000000..e7f1b25ce52 --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/scalar.ts @@ -0,0 +1,108 @@ +import type { MemberType, Scalar } from "@typespec/compiler"; +import { + SimpleScalarMutation, + type MutationInfo, + type SimpleMutationEngine, + type SimpleMutationOptions, + type SimpleMutations, +} from "@typespec/mutator-framework"; +import { reportDiagnostic } from "../../lib.js"; +import { applyTypeNamePipeline } from "../../lib/naming.js"; +import { + getCustomScalarMapping, + getScalarMapping, + isStdScalar, +} from "../../lib/scalar-mappings.js"; +import { getSpecifiedBy, setSpecifiedByUrl } from "../../lib/specified-by.js"; + +/** + * GraphQL built-in scalar type names. + * @see https://spec.graphql.org/September2025/#sec-Scalars.Built-in-Scalars + */ +const GRAPHQL_BUILTIN_SCALARS = new Set(["String", "Int", "Float", "Boolean", "ID"]); + +/** + * Check whether a scalar is the GraphQL library's `ID` scalar, or extends it. + * Walks the baseScalar chain looking for a scalar named "ID" in the + * TypeSpec.GraphQL namespace. + */ +function isGraphQLIdScalar(scalar: Scalar): boolean { + let current: Scalar | undefined = scalar; + while (current) { + if ( + current.name === "ID" && + current.namespace?.name === "GraphQL" && + current.namespace?.namespace?.name === "TypeSpec" + ) { + return true; + } + current = current.baseScalar; + } + return false; +} + +/** GraphQL-specific Scalar mutation */ +export class GraphQLScalarMutation extends SimpleScalarMutation { + constructor( + engine: SimpleMutationEngine>, + sourceType: Scalar, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + } + + mutate() { + const tk = this.engine.$; + const program = tk.program; + const mapping = getScalarMapping(program, this.sourceType); + + if (isGraphQLIdScalar(this.sourceType)) { + // GraphQL library scalar ID (or extends it) → built-in GraphQL ID type + this.mutationNode.mutate((scalar) => { + scalar.name = "ID"; + scalar.baseScalar = undefined; + }); + } else { + const customMapping = getCustomScalarMapping(program, this.sourceType); + if (customMapping) { + // Std library scalar that maps to a custom GraphQL scalar (e.g. int64 → Long) + this.mutationNode.mutate((scalar) => { + scalar.name = customMapping.graphqlName; + scalar.baseScalar = undefined; + }); + } else if (!isStdScalar(tk, this.sourceType)) { + // User-defined custom scalar — sanitize name, strip extends. + // May still have a mapping via extends chain (e.g. scalar MyInt extends int64), + // which is used for @specifiedBy below but not for renaming. + const finalName = applyTypeNamePipeline(this.sourceType.name, { + isInput: false, + isInterface: false, + }); + if (GRAPHQL_BUILTIN_SCALARS.has(finalName)) { + reportDiagnostic(program, { + code: "graphql-builtin-scalar-collision", + target: this.sourceType, + format: { name: this.sourceType.name, builtinName: finalName }, + }); + } + this.mutationNode.mutate((scalar) => { + scalar.name = finalName; + scalar.baseScalar = undefined; + }); + } + // else: Built-in std scalars (string, boolean, int32, etc.) are left untouched — + // they map to GraphQL built-in types and are resolved at emit time. + } + + // Apply @specifiedBy: explicit decorator on source wins, then mapping table + // (mapping may come from an ancestor via the extends chain) + const specUrl = getSpecifiedBy(program, this.sourceType) ?? mapping?.specificationUrl; + if (specUrl) { + setSpecifiedByUrl(program, this.mutatedType, specUrl); + } + + super.mutate(); + } +} diff --git a/packages/graphql/src/mutation-engine/mutations/union.ts b/packages/graphql/src/mutation-engine/mutations/union.ts new file mode 100644 index 00000000000..ddc53652d6c --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/union.ts @@ -0,0 +1,307 @@ +import { + type MemberType, + type Model, + type Type, + type Union, + getTypeName, +} from "@typespec/compiler"; +import { + MutationEngine, + MutationHalfEdge, + type MutationInfo, + type MutationOptions, + SimpleUnionVariantMutation, + UnionMutation, + UnionMutationNode, + UnionVariantMutationNode, +} from "@typespec/mutator-framework"; +import { reportDiagnostic } from "../../lib.js"; +import { + applyBaseNamePipeline, + applyFieldNamePipeline, + applyTypeNamePipeline, +} from "../../lib/naming.js"; +import { setNullable } from "../../lib/nullable.js"; +import { setOneOf } from "../../lib/one-of.js"; +import { getUnionName, stripNullVariants, unwrapNullableUnion } from "../../lib/type-utils.js"; +import { GraphQLMutationOptions, GraphQLTypeContext } from "../options.js"; + +/** Convert a variant name (string or symbol) to a string. */ +function variantNameToString(name: string | symbol): string { + return typeof name === "string" ? name : (name.description ?? ""); +} + +/** + * Resolve the actual mutated type from a mutation result. + * Handles the case where mutationNode.replace() was called — the inherited + * mutatedType getter doesn't reflect replacements, so we check the node directly. + */ +function resolveType(mutation: { + mutationNode: { isReplaced: boolean; replacementNode: any }; + mutatedType: Type; +}): Type { + const node = mutation.mutationNode; + if (node.isReplaced && node.replacementNode) { + return node.replacementNode.mutatedType; + } + return mutation.mutatedType; +} + +/** + * GraphQL-specific Union mutation. + * + * Output context: flattens nested unions, deduplicates, wraps scalar variants. + * Input context: replaces with @oneOf input object (GraphQL unions are output-only). + */ +export class GraphQLUnionMutation extends UnionMutation> { + #mutationNode: UnionMutationNode; + #wrapperModels: Model[] = []; + #flattenedUnion: Union | null = null; + + constructor( + engine: MutationEngine, + sourceType: Union, + referenceTypes: MemberType[], + options: MutationOptions, + info: MutationInfo, + ) { + super(engine, sourceType, referenceTypes, options, info); + this.#mutationNode = this.engine.getMutationNode(this.sourceType, { + mutationKey: info.mutationKey, + isSynthetic: info.isSynthetic, + }) as UnionMutationNode; + } + + /** The input/output context, or undefined if options aren't GraphQLMutationOptions. */ + get typeContext(): GraphQLTypeContext | undefined { + return this.options instanceof GraphQLMutationOptions ? this.options.typeContext : undefined; + } + + get mutationNode() { + return this.#mutationNode; + } + + get mutatedType(): Union | Model { + // In input context, the union node is replaced with a @oneOf Model + if (this.#mutationNode.isReplaced && this.#mutationNode.replacementNode) { + return this.#mutationNode.replacementNode.mutatedType as Model; + } + // Return flattened union if we created one, otherwise use mutation node's type + return this.#flattenedUnion || this.#mutationNode.mutatedType; + } + + /** Synthetic wrapper models for scalar union variants. */ + get wrapperModels() { + return this.#wrapperModels; + } + + /** Creates a half-edge for bidirectional variant mutation updates. */ + protected startVariantEdge(): MutationHalfEdge< + GraphQLUnionMutation, + SimpleUnionVariantMutation + > { + return new MutationHalfEdge("variant", this, (tail) => { + this.#mutationNode.connectVariant(tail.mutationNode as UnionVariantMutationNode); + }); + } + + mutate() { + // T | null is not a real union — replace with the inner type. + // Don't mark the replacement as nullable here; it's a shared singleton. + // Nullability is tracked by the container (ModelProperty or Operation). + const innerType = unwrapNullableUnion(this.sourceType); + if (innerType) { + const innerMutation = this.engine.mutate(innerType, this.options); + this.#mutationNode.replace(resolveType(innerMutation)); + return; + } + + if (this.typeContext === GraphQLTypeContext.Input) { + this.mutateAsOneOfInput(); + return; + } + + this.mutateAsOutputUnion(); + super.mutate(); + } + + /** Flatten nested unions, deduplicate, and wrap scalar variants in synthetic models. */ + private mutateAsOutputUnion() { + const tk = this.engine.$; + const program = tk.program; + + const rawName = getUnionName(this.sourceType, program); + const unionName = applyTypeNamePipeline(rawName, { isInput: false, isInterface: false }); + + const { variants: sourceVariants, isNullable: hasNull } = stripNullVariants(this.sourceType); + + const flattenedVariants = this.deduplicateVariants(this.flattenVariants(sourceVariants)); + + if (flattenedVariants.length === 0) { + reportDiagnostic(program, { code: "empty-union", target: this.sourceType }); + return; + } + + if (flattenedVariants.length === 1) { + const innerMutation = this.engine.mutate(flattenedVariants[0].type, this.options); + this.#mutationNode.replace(resolveType(innerMutation)); + if (hasNull) { + setNullable(this.mutatedType); + } + return; + } + + const needsFlattening = flattenedVariants.length !== sourceVariants.length; + + // Mutate each variant's type so it goes through the full pipeline + const mutatedVariants = flattenedVariants.map((variant) => ({ + name: variant.name, + type: resolveType(this.engine.mutate(variant.type, this.options)), + })); + + // GraphQL unions can only contain object types — wrap scalars in synthetic models + // and substitute the wrapper into the variant so the union is self-contained. + for (const variant of mutatedVariants) { + const isScalar = variant.type.kind === "Scalar" || variant.type.kind === "Intrinsic"; + + if (isScalar) { + const variantName = variantNameToString(variant.name); + const wrapperName = + applyBaseNamePipeline(unionName) + applyBaseNamePipeline(variantName) + "UnionVariant"; + + const valueProp = tk.modelProperty.create({ + name: "value", + type: variant.type, + optional: false, + }); + + const wrapperModel = tk.model.create({ + name: wrapperName, + properties: { value: valueProp }, + }); + + this.#wrapperModels.push(wrapperModel); + variant.type = wrapperModel; + } + } + + if (needsFlattening || hasNull || this.#wrapperModels.length > 0) { + const variantArray = mutatedVariants.map((variant) => { + return tk.unionVariant.create({ + name: variantNameToString(variant.name), + type: variant.type, + }); + }); + + const flattenedUnion = tk.type.clone(this.sourceType); + flattenedUnion.name = unionName; + flattenedUnion.variants.clear(); + for (const variant of variantArray) { + flattenedUnion.variants.set(variant.name, variant); + variant.union = flattenedUnion; + } + tk.type.finishType(flattenedUnion); + + this.#flattenedUnion = flattenedUnion; + } else { + this.#mutationNode.mutate((union) => { + union.name = unionName; + }); + } + + if (hasNull) { + setNullable(this.mutatedType); + } + } + + /** + * Replace with a @oneOf input object (GraphQL unions are output-only). + * @see https://spec.graphql.org/September2025/#sec-OneOf-Input-Objects + */ + private mutateAsOneOfInput() { + const tk = this.engine.$; + const program = tk.program; + + const { variants: sourceVariants, isNullable: hasNull } = stripNullVariants(this.sourceType); + + const flattenedVariants = this.deduplicateVariants(this.flattenVariants(sourceVariants)); + + if (flattenedVariants.length === 0) { + reportDiagnostic(program, { code: "empty-union", target: this.sourceType }); + return; + } + + const properties: Record> = {}; + for (const variant of flattenedVariants) { + const fieldName = applyFieldNamePipeline(variantNameToString(variant.name)); + const mutatedType = resolveType(this.engine.mutate(variant.type, this.options)); + properties[fieldName] = tk.modelProperty.create({ + name: fieldName, + type: mutatedType, + optional: true, // oneOf: exactly one must be provided + }); + } + + const unionName = getUnionName(this.sourceType, program); + const modelName = applyTypeNamePipeline(unionName, { isInput: true, isInterface: false }); + + const oneOfModel = tk.model.create({ + name: modelName, + properties, + }); + + setOneOf(oneOfModel); + + if (hasNull) { + setNullable(oneOfModel); + } + + this.#mutationNode.replace(oneOfModel); + } + + /** Recursively flatten nested unions (GraphQL doesn't support nesting). */ + private flattenVariants( + variants: readonly { name: string | symbol; type: Type }[], + seen: Set = new Set(), + ): Array<{ name: string | symbol; type: Type }> { + const flattened: Array<{ name: string | symbol; type: Type }> = []; + + for (const variant of variants) { + if (variant.type.kind === "Union") { + const nestedUnion = variant.type as Union; + if (seen.has(nestedUnion)) continue; + seen.add(nestedUnion); + + const { variants: nestedVariants } = stripNullVariants(nestedUnion); + flattened.push(...this.flattenVariants(nestedVariants, seen)); + } else { + flattened.push({ name: variant.name, type: variant.type }); + } + } + + return flattened; + } + + /** Deduplicate variants by type identity; first occurrence wins. */ + private deduplicateVariants( + variants: Array<{ name: string | symbol; type: Type }>, + ): Array<{ name: string | symbol; type: Type }> { + const seen = new Map(); + const result: Array<{ name: string | symbol; type: Type }> = []; + + for (const variant of variants) { + if (seen.has(variant.type)) { + reportDiagnostic(this.engine.$.program, { + code: "duplicate-union-variant", + format: { type: getTypeName(variant.type) }, + target: this.sourceType, + }); + } else { + seen.set(variant.type, variant); + result.push(variant); + } + } + + return result; + } +} diff --git a/packages/graphql/src/mutation-engine/options.ts b/packages/graphql/src/mutation-engine/options.ts new file mode 100644 index 00000000000..e16b48ce20d --- /dev/null +++ b/packages/graphql/src/mutation-engine/options.ts @@ -0,0 +1,53 @@ +import type { VisibilityFilter } from "@typespec/compiler"; +import { SimpleMutationOptions } from "@typespec/mutator-framework"; + +/** + * Context for how a type is used in GraphQL operations. + * Determines whether a model becomes an object type (output) or input type (input). + */ +export enum GraphQLTypeContext { + /** Type reachable from operation parameters */ + Input = "input", + /** Type reachable from operation return types */ + Output = "output", + /** Model marked with @interface — emits as a GraphQL interface declaration */ + Interface = "interface", +} + +/** + * Mutation options that carry input/output context and visibility through the type graph. + * The mutationKey ensures the framework caches variants separately. + * + * @param typeContext - structural context (Input/Output/Interface) + * @param visibilityFilter - which properties to include (from compiler's VisibilityFilter) + * @param inputQualifier - when set, distinguishes cache entries and feeds the naming pipeline + * (e.g., "Query" → UserQueryInput, "Mutation" → UserMutationInput) + */ +export class GraphQLMutationOptions extends SimpleMutationOptions { + readonly typeContext: GraphQLTypeContext; + readonly visibilityFilter?: VisibilityFilter; + /** Cache key discriminator — always set for input variants ("query" or "mutation"). */ + readonly operationKind?: string; + /** Naming qualifier — only set when operation variance requires distinct type names. */ + readonly inputQualifier?: string; + + constructor( + typeContext: GraphQLTypeContext, + visibilityFilter?: VisibilityFilter, + operationKind?: string, + inputQualifier?: string, + ) { + super(); + this.typeContext = typeContext; + this.visibilityFilter = visibilityFilter; + this.operationKind = operationKind; + this.inputQualifier = inputQualifier; + } + + override get mutationKey(): string { + if (this.operationKind) { + return `${this.typeContext}-${this.operationKind}`; + } + return this.typeContext; + } +} diff --git a/packages/graphql/src/mutation-engine/print-type.ts b/packages/graphql/src/mutation-engine/print-type.ts new file mode 100644 index 00000000000..8ffcbe02952 --- /dev/null +++ b/packages/graphql/src/mutation-engine/print-type.ts @@ -0,0 +1,32 @@ +import { isArrayModelType, type ModelProperty } from "@typespec/compiler"; +import { resolveGraphQLTypeName } from "../lib/graphql-type-name.js"; +import { hasNullableElements, isNullable } from "../lib/nullable.js"; + +/** + * Print a mutated type as its GraphQL type string representation. + * Reads the mutation engine's metadata (nullable, hasNullableElements) + * to produce the correct nullability wrapping. + * + * Examples: + * required string property → "String!" + * optional string property → "String" + * required string[] property → "[String!]!" + * optional (string | null)[] → "[String]" + */ +export function printMutatedType(prop: ModelProperty): string { + const propNullable = isNullable(prop) || prop.optional; + const elementsNullable = hasNullableElements(prop); + + const type = prop.type; + + if (type.kind === "Model" && isArrayModelType(type)) { + const elementType = type.indexer.value; + const elementName = resolveGraphQLTypeName(elementType); + const inner = elementsNullable ? elementName : `${elementName}!`; + const list = `[${inner}]`; + return propNullable ? list : `${list}!`; + } + + const name = resolveGraphQLTypeName(type); + return propNullable ? name : `${name}!`; +} 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..bf61d02921f --- /dev/null +++ b/packages/graphql/src/mutation-engine/schema-mutator.ts @@ -0,0 +1,192 @@ +import { + isArrayModelType, + navigateTypesInNamespace, + type Enum, + type Model, + type Namespace, + type Operation, + type Program, + type Scalar, + type Type, + type Union, +} from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { setInputType } from "../lib/input-type.js"; +import { isInterface } from "../lib/interface.js"; +import { getOperationFields } from "../lib/operation-fields.js"; +import { reportDiagnostic } from "../lib.js"; +import { createVisibilityFilters } from "../lib/visibility.js"; +import { isStdScalar } from "../lib/scalar-mappings.js"; +import { GraphQLTypeUsage, type TypeUsageResolver } from "../type-usage.js"; +import type { GraphQLMutationEngine } from "./engine.js"; +import type { GraphQLModelMutation } from "./mutations/model.js"; +import { GraphQLTypeContext } from "./options.js"; +import { buildTypeGraph, type TypeGraph } from "./type-graph.js"; + +/** + * Walk every type in the schema namespace, mutate it through the GraphQL + * mutation engine, and package the results into a TypeGraph. + * + * Filtering (unreachable types, array models, nullable unions) happens here + * so the engine only processes types that belong in the schema. + * + * Models used as both input and output get two mutations (Output and Input), + * producing separate entries in the TypeGraph (e.g., `Book` and `BookInput`). + */ +export function mutateSchema( + program: Program, + engine: GraphQLMutationEngine, + schema: Namespace, + typeUsage: TypeUsageResolver, +): TypeGraph { + const tk = $(program); + const mutatedTypes: Type[] = []; + const filters = createVisibilityFilters(program); + + function pushMutatedModel(mutation: GraphQLModelMutation) { + const node = mutation.mutationNode; + if (node.isReplaced && node.replacementNode) { + mutatedTypes.push(node.replacementNode.mutatedType); + } else { + mutatedTypes.push(mutation.mutatedType); + } + } + + navigateTypesInNamespace(schema, { + model: (node: Model) => { + if (isArrayModelType(node)) return; + if (typeUsage.isUnreachable(node)) return; + + const usage = typeUsage.getUsage(node); + const usedAsOutput = usage?.has(GraphQLTypeUsage.Output) ?? false; + const usedAsInput = usage?.has(GraphQLTypeUsage.Input) ?? false; + const isInterfaceModel = isInterface(program, node); + + if (isInterfaceModel) { + const mutation = engine.mutateModel(node, GraphQLTypeContext.Interface); + pushMutatedModel(mutation); + } + if (!isInterfaceModel && (usedAsOutput || !usage)) { + const mutation = engine.mutateModel(node, GraphQLTypeContext.Output, filters.output); + pushMutatedModel(mutation); + } + if (usedAsInput) { + if (getOperationFields(program, node).size > 0) { + reportDiagnostic(program, { + code: "operation-fields-ignored-on-input", + format: { model: node.name }, + target: node, + }); + } + const hasVariance = typeUsage.hasInputOperationVariance(node); + const usedByQuery = usage?.has(GraphQLTypeUsage.InputQuery) ?? false; + const usedByMutation = usage?.has(GraphQLTypeUsage.InputMutation) ?? false; + + if (hasVariance) { + const qm = engine.mutateModel(node, GraphQLTypeContext.Input, filters.query, "query", "Query"); + const mm = engine.mutateModel(node, GraphQLTypeContext.Input, filters.mutation, "mutation", "Mutation"); + if (qm.mutatedType.properties.size > 0) { + setInputType(qm.mutatedType); + pushMutatedModel(qm); + } + if (mm.mutatedType.properties.size > 0) { + setInputType(mm.mutatedType); + pushMutatedModel(mm); + } + } else { + const emitted = usedByMutation + ? engine.mutateModel(node, GraphQLTypeContext.Input, filters.mutation, "mutation") + : engine.mutateModel(node, GraphQLTypeContext.Input, filters.query, "query"); + if (emitted.mutatedType.properties.size > 0) { + setInputType(emitted.mutatedType); + pushMutatedModel(emitted); + + if (usedByQuery && usedByMutation) { + setInputType( + engine.mutateModel(node, GraphQLTypeContext.Input, filters.query, "query").mutatedType, + ); + } + } + } + } + }, + enum: (node: Enum) => { + if (typeUsage.isUnreachable(node)) return; + + const mutation = engine.mutateEnum(node); + mutatedTypes.push(mutation.mutatedType); + }, + scalar: (node: Scalar) => { + if (typeUsage.isUnreachable(node)) return; + const mutation = engine.mutateScalar(node); + mutatedTypes.push(mutation.mutatedType); + }, + union: (node: Union) => { + if (typeUsage.isUnreachable(node)) return; + + const usage = typeUsage.getUsage(node); + const usedAsOutput = usage?.has(GraphQLTypeUsage.Output) ?? false; + const usedAsInput = usage?.has(GraphQLTypeUsage.Input) ?? false; + + if (usedAsOutput || !usage) { + const mutation = engine.mutateUnion(node, GraphQLTypeContext.Output); + if (mutation.mutatedType.kind === "Union") { + mutatedTypes.push(mutation.mutatedType); + for (const wrapper of mutation.wrapperModels) { + mutatedTypes.push(wrapper); + } + } + } + + if (usedAsInput) { + const usedByQuery = usage?.has(GraphQLTypeUsage.InputQuery) ?? false; + const usedByMutation = usage?.has(GraphQLTypeUsage.InputMutation) ?? false; + const filter = usedByMutation ? filters.mutation : filters.query; + const opKind = usedByMutation ? "mutation" : "query"; + const mutation = engine.mutateUnion(node, GraphQLTypeContext.Input, filter, opKind); + const mutated = mutation.mutatedType; + if (mutated.kind === "Model") { + setInputType(mutated); + mutatedTypes.push(mutated); + } else if (mutated.kind === "Union") { + mutatedTypes.push(mutated); + } + } + }, + operation: (node: Operation) => { + const mutation = engine.mutateOperation(node); + mutatedTypes.push(mutation.mutatedType); + }, + }); + + + const seen = new Map(); + for (const type of mutatedTypes) { + if (!("name" in type) || !type.name) continue; + const name = type.name as string; + if (seen.has(name)) { + reportDiagnostic(program, { + code: "type-name-collision", + format: { name }, + target: type, + }); + } else { + seen.set(name, type); + } + } + + return buildTypeGraph(program, tk, mutatedTypes, { + shouldIncludeRef: (type) => { + if (type.kind === "Scalar") { + return !isStdScalar(tk, type) && !isLibraryScalar(type); + } + return true; + }, + }); +} + +function isLibraryScalar(scalar: { namespace?: { name: string; namespace?: { name: string } } }): boolean { + return scalar.namespace?.name === "GraphQL" && scalar.namespace?.namespace?.name === "TypeSpec"; +} + + diff --git a/packages/graphql/src/mutation-engine/type-graph.ts b/packages/graphql/src/mutation-engine/type-graph.ts new file mode 100644 index 00000000000..221d6b4ebde --- /dev/null +++ b/packages/graphql/src/mutation-engine/type-graph.ts @@ -0,0 +1,101 @@ +import { isArrayModelType, type Model, type Namespace, type Operation, type Program, type Type } from "@typespec/compiler"; +import type { Typekit } from "@typespec/compiler/typekit"; + +/** + * A self-contained type world — a namespace containing only the mutated types + * for a given stage or schema. Enables `navigateTypesInNamespace` to walk + * the mutated graph, and serves as the inter-stage / inter-emitter contract. + * + * @see https://github.com/microsoft/typespec/pull/10693#discussion_r3243305988 + * Timothee Guerin's exploration of TypeGraph at the compiler level. + */ +export interface TypeGraph { + readonly globalNamespace: Namespace; +} + +export interface BuildTypeGraphOptions { + /** + * Filter for transitively-discovered types. Return false to exclude a type + * from the graph. Root types (passed directly) are always included. + * Used by renderers to exclude built-in types they handle implicitly. + */ + shouldIncludeRef?: (type: Type) => boolean; +} + +/** + * Package a set of types into a self-contained TypeGraph. + * Adds the given root types and transitively discovers all types they + * reference (through properties, return types, parameters), producing + * a self-contained graph the renderer can resolve without external lookups. + */ +export function buildTypeGraph(program: Program, tk: Typekit, types: Type[], options?: BuildTypeGraphOptions): TypeGraph { + const globalNamespace = tk.type.clone(program.getGlobalNamespaceType()); + tk.type.finishType(globalNamespace); + + globalNamespace.models = new Map(); + globalNamespace.operations = new Map(); + globalNamespace.enums = new Map(); + globalNamespace.unions = new Map(); + globalNamespace.scalars = new Map(); + globalNamespace.interfaces = new Map(); + globalNamespace.namespaces = new Map(); + + const registered = new Set(); + const shouldIncludeRef = options?.shouldIncludeRef ?? (() => true); + + for (const type of types) { + register(globalNamespace, registered, type); + } + + return { globalNamespace }; + + function register(ns: Namespace, registered: Set, type: Type): void { + if (registered.has(type)) return; + registered.add(type); + type.isFinished = true; + + switch (type.kind) { + case "Model": + if (isArrayModelType(type)) return; + type.namespace = ns; + ns.models.set(type.name, type); + for (const prop of type.properties.values()) { + registerRef(ns, registered, prop.type); + } + break; + case "Operation": + type.namespace = ns; + ns.operations.set(type.name, type); + registerRef(ns, registered, type.returnType); + for (const param of type.parameters.properties.values()) { + registerRef(ns, registered, param.type); + } + break; + case "Enum": + type.namespace = ns; + ns.enums.set(type.name, type); + break; + case "Union": + if (!type.name) return; + type.namespace = ns; + ns.unions.set(type.name, type); + break; + case "Scalar": + type.namespace = ns; + ns.scalars.set(type.name, type); + break; + case "Interface": + type.namespace = ns; + ns.interfaces.set(type.name, type); + break; + } + } + + function registerRef(ns: Namespace, registered: Set, type: Type): void { + if (type.kind === "Model" && isArrayModelType(type) && type.indexer?.value) { + registerRef(ns, registered, type.indexer.value); + } else if (shouldIncludeRef(type)) { + register(ns, registered, type); + } + } +} diff --git a/packages/graphql/src/tsp-index.ts b/packages/graphql/src/tsp-index.ts new file mode 100644 index 00000000000..2bba1a05202 --- /dev/null +++ b/packages/graphql/src/tsp-index.ts @@ -0,0 +1,28 @@ +import type { DecoratorImplementations } from "@typespec/compiler"; +import { $lib } from "./lib.js"; +import { $compose, $graphqlInterface } from "./lib/interface.js"; +import { $nullable, $nullableElements } from "./lib/nullable.js"; +import { $oneOf } from "./lib/one-of.js"; +import { $operationFields } from "./lib/operation-fields.js"; +import { $mutation, $query, $subscription } from "./lib/operation-kind.js"; +import { $schema } from "./lib/schema.js"; +import { $specifiedBy } from "./lib/specified-by.js"; +import { $onValidate } from "./validate.js"; + +export { $lib, $onValidate }; + +export const $decorators: DecoratorImplementations = { + "TypeSpec.GraphQL": { + compose: $compose, + graphqlInterface: $graphqlInterface, + mutation: $mutation, + nullable: $nullable, + nullableElements: $nullableElements, + oneOf: $oneOf, + operationFields: $operationFields, + query: $query, + schema: $schema, + specifiedBy: $specifiedBy, + subscription: $subscription, + }, +}; diff --git a/packages/graphql/src/type-usage.ts b/packages/graphql/src/type-usage.ts new file mode 100644 index 00000000000..3187fd59aed --- /dev/null +++ b/packages/graphql/src/type-usage.ts @@ -0,0 +1,181 @@ +import { + isArrayModelType, + navigateTypesInNamespace, + type Model, + type Namespace, + type Operation, + type Program, + type Type, +} from "@typespec/compiler"; +import { getOperationKind, type GraphQLOperationKind } from "./lib/operation-kind.js"; +import { createVisibilityFilters, isPropertyVisible } from "./lib/visibility.js"; + +/** + * GraphQL-specific flags for type usage tracking (input vs output). + */ +export enum GraphQLTypeUsage { + /** Type is used as an input (operation parameter or nested within one) */ + Input = "Input", + /** Type is used as an input to a @query or @subscription operation */ + InputQuery = "InputQuery", + /** Type is used as an input to a @mutation operation */ + InputMutation = "InputMutation", + /** Type is used as an output (operation return type or nested within one) */ + Output = "Output", +} + +export interface TypeUsageResolver { + getUsage(type: Type): Set | undefined; + isUnreachable(type: Type): boolean; + /** + * Returns true if the model is used by both @query and @mutation operations + * AND the visibility filters produce different property sets for each context. + * When true, two separate input types are needed (e.g., UserQueryInput + UserMutationInput). + */ + hasInputOperationVariance(type: Type): boolean; +} + +export function resolveTypeUsage( + program: Program, + root: Namespace, + omitUnreachableTypes: boolean, +): TypeUsageResolver { + const reachableTypes = new Set(); + const usages = new Map>(); + const inputOperationVariance = new Set(); + + addUsagesInNamespace(program, root, reachableTypes, usages); + + // Pre-compute which models need split input types + const filters = createVisibilityFilters(program); + for (const [type, usage] of usages) { + if ( + type.kind === "Model" && + usage.has(GraphQLTypeUsage.InputQuery) && + usage.has(GraphQLTypeUsage.InputMutation) + ) { + const queryVisible = new Set(); + const mutationVisible = new Set(); + for (const prop of type.properties.values()) { + if (isPropertyVisible(program, prop, filters.query)) queryVisible.add(prop.name); + if (isPropertyVisible(program, prop, filters.mutation)) mutationVisible.add(prop.name); + } + if (queryVisible.size !== mutationVisible.size || ![...queryVisible].every(k => mutationVisible.has(k))) { + inputOperationVariance.add(type); + } + } + } + + if (!omitUnreachableTypes) { + const markReachable = (type: Type) => { + reachableTypes.add(type); + }; + navigateTypesInNamespace(root, { + model: markReachable, + scalar: markReachable, + enum: markReachable, + union: markReachable, + }); + } + + return { + getUsage: (type: Type) => usages.get(type), + isUnreachable: (type: Type) => !reachableTypes.has(type), + hasInputOperationVariance: (type: Type) => inputOperationVariance.has(type), + }; +} + +function trackUsage( + reachableTypes: Set, + usages: Map>, + type: Type, + usage: GraphQLTypeUsage, +) { + reachableTypes.add(type); + const existing = usages.get(type) ?? new Set(); + existing.add(usage); + usages.set(type, existing); +} + +function addUsagesInNamespace( + program: Program, + namespace: Namespace, + reachableTypes: Set, + usages: Map>, +): void { + for (const subNamespace of namespace.namespaces.values()) { + addUsagesInNamespace(program, subNamespace, reachableTypes, usages); + } + for (const iface of namespace.interfaces.values()) { + for (const operation of iface.operations.values()) { + addUsagesFromOperation(program, operation, reachableTypes, usages); + } + } + for (const operation of namespace.operations.values()) { + addUsagesFromOperation(program, operation, reachableTypes, usages); + } +} + +function inputUsageForKind(kind: GraphQLOperationKind | undefined): GraphQLTypeUsage { + if (kind === "Query" || kind === "Subscription") return GraphQLTypeUsage.InputQuery; + return GraphQLTypeUsage.InputMutation; +} + +function addUsagesFromOperation( + program: Program, + operation: Operation, + reachableTypes: Set, + usages: Map>, +): void { + const kind = getOperationKind(program, operation); + const inputUsage = inputUsageForKind(kind); + for (const param of operation.parameters.properties.values()) { + navigateReferencedTypes(param.type, GraphQLTypeUsage.Input, reachableTypes, usages); + navigateReferencedTypes(param.type, inputUsage, reachableTypes, usages); + } + navigateReferencedTypes(operation.returnType, GraphQLTypeUsage.Output, reachableTypes, usages); +} + +function navigateReferencedTypes( + type: Type, + usage: GraphQLTypeUsage, + reachableTypes: Set, + usages: Map>, + visited: Set = new Set(), +): void { + if (visited.has(type)) return; + visited.add(type); + + switch (type.kind) { + case "Model": + if (isArrayModelType(type)) { + if (type.indexer?.value) { + navigateReferencedTypes(type.indexer.value, usage, reachableTypes, usages, visited); + } + } else { + trackUsage(reachableTypes, usages, type, usage); + for (const prop of type.properties.values()) { + navigateReferencedTypes(prop.type, usage, reachableTypes, usages, visited); + } + if (type.baseModel) { + navigateReferencedTypes(type.baseModel, usage, reachableTypes, usages, visited); + } + } + break; + + case "Union": + trackUsage(reachableTypes, usages, type, usage); + for (const variant of type.variants.values()) { + navigateReferencedTypes(variant.type, usage, reachableTypes, usages, visited); + } + break; + + case "Scalar": + case "Enum": + trackUsage(reachableTypes, usages, type, usage); + break; + + default: + break; + } +} diff --git a/packages/graphql/src/validate.ts b/packages/graphql/src/validate.ts new file mode 100644 index 00000000000..48311e2b6b8 --- /dev/null +++ b/packages/graphql/src/validate.ts @@ -0,0 +1,146 @@ +import { + type DiagnosticTarget, + type Enum, + isNullType, + type Model, + type Namespace, + type Operation, + navigateTypesInNamespace, + type Program, + type Union, +} from "@typespec/compiler"; +import { reportDiagnostic } from "./lib.js"; +import { getOperationKind } from "./lib/operation-kind.js"; +import { listSchemas } from "./lib/schema.js"; + +export function $onValidate(program: Program) { + const schemas = listSchemas(program); + + // Only validate if there are explicit @schema decorators + // Tests and other usages without @schema should not trigger validation warnings + if (schemas.length === 0) { + return; + } + + for (const schema of schemas) { + validateSchema(program, schema.type); + } +} + +function validateSchema(program: Program, ns: Namespace) { + let hasGraphQLOps = false; + + navigateTypesInNamespace(ns, { + operation(op) { + if (getOperationKind(program, op) !== undefined) { + hasGraphQLOps = true; + } + validateOperation(program, op); + }, + model(model) { + validateModel(program, model); + }, + enum(enumType) { + validateEnum(program, enumType); + }, + union(unionType) { + validateUnion(program, unionType); + }, + }); + + if (!hasGraphQLOps) { + reportDiagnostic(program, { + code: "empty-schema", + target: ns, + }); + } +} + +/** + * GraphQL spec: Names must not begin with "__" (two underscores). + * https://spec.graphql.org/September2025/#sec-Names.Reserved-Names + */ +function validateReservedName(program: Program, name: string, target: DiagnosticTarget) { + if (name.startsWith("__")) { + reportDiagnostic(program, { + code: "reserved-name", + format: { name }, + target, + }); + } +} + +/** + * Validate model: check type name and property names for reserved prefix. + */ +function validateModel(program: Program, model: Model) { + // Check model name + if (model.name) { + validateReservedName(program, model.name, model); + } + + // Check property names + for (const prop of model.properties.values()) { + validateReservedName(program, prop.name, prop); + } +} + +/** + * Validate operation: check parameter names for reserved prefix. + */ +function validateOperation(program: Program, op: Operation) { + for (const param of op.parameters.properties.values()) { + validateReservedName(program, param.name, param); + } +} + +/** + * Validate union: check type name for reserved prefix and empty unions. + * https://spec.graphql.org/September2025/#sec-Unions + */ +function validateUnion(program: Program, unionType: Union) { + // Only validate named unions (not anonymous unions like `string | null`) + if (!unionType.name) { + return; + } + + validateReservedName(program, unionType.name, unionType); + + // Check for empty union: no variants, or all variants are null. + // GraphQL unions must have at least one member type. + const nonNullVariants = [...unionType.variants.values()].filter( + (v) => !isNullType(v.type), + ); + + if (nonNullVariants.length === 0) { + reportDiagnostic(program, { + code: "empty-union", + target: unionType, + }); + } +} + +/** + * GraphQL spec: Enums must define at least one value. + * https://spec.graphql.org/September2025/#sec-Enums + */ +function validateEnum(program: Program, enumType: Enum) { + // Check enum name + if (enumType.name) { + validateReservedName(program, enumType.name, enumType); + } + + // Check enum member names + for (const member of enumType.members.values()) { + validateReservedName(program, member.name, member); + } + + // Check for empty enum + if (enumType.members.size === 0) { + reportDiagnostic(program, { + code: "empty-enum", + format: { name: enumType.name }, + target: enumType, + }); + } +} 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..7d828276585 --- /dev/null +++ b/packages/graphql/test/components/enum-type.test.tsx @@ -0,0 +1,102 @@ +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } 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 { renderToSDL } from "./test-utils.js"; + +describe("EnumType component", () => { + let tester: TesterInstance; + 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 = renderToSDL(tester.program, ); + + expect(sdl).toContain("enum Color"); + expect(sdl).toContain("RED"); + expect(sdl).toContain("GREEN"); + expect(sdl).toContain("BLUE"); + }); + + it("renders enum with doc comment as 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 = renderToSDL(tester.program, ); + + expect(sdl).toContain('"The role a user can have"'); + expect(sdl).toContain("enum Role"); + }); + + 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 = renderToSDL(tester.program, ); + + expect(sdl).toContain('"Currently active"'); + expect(sdl).toContain('"No longer active"'); + }); + + 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 = renderToSDL(tester.program, ); + + expect(sdl).toContain("@deprecated"); + expect(sdl).toContain("use Active instead"); + }); + + it("renders enum with mutation-engine-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 = renderToSDL(tester.program, ); + + // Mutation engine: sanitize → CONSTANT_CASE + expect(sdl).toContain("_VAL_1"); + expect(sdl).toContain("VAL_2"); + }); +}); 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..c3e12cd0448 --- /dev/null +++ b/packages/graphql/test/components/input-type.test.tsx @@ -0,0 +1,78 @@ +import { type Model } from "@typespec/compiler"; +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { InputType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine, GraphQLTypeContext } from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderToSDL } from "./test-utils.js"; + +describe("InputType component", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic input object type", async () => { + const { Book } = await tester.compile( + t.code`model ${t.model("Book")} { title: string; year: int32; }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Book, GraphQLTypeContext.Input).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toMatch(/input BookInput \{/); + expect(sdl).toContain("title: String!"); + expect(sdl).toContain("year: Int!"); + }); + + it("renders input type with doc comment", async () => { + const { Book } = await tester.compile( + t.code` + /** Data for creating a book */ + model ${t.model("Book")} { title: string; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Book, GraphQLTypeContext.Input).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain('"Data for creating a book"'); + expect(sdl).toMatch(/input BookInput \{/); + }); + + it("renders oneOf input type with @oneOf directive", async () => { + const { SearchBy } = await tester.compile( + t.code` + union ${t.union("SearchBy")} { byName: string; byId: int32; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(SearchBy, GraphQLTypeContext.Input); + // In input context, union becomes a @oneOf Model + const mutated = mutation.mutatedType as Model; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toMatch(/input SearchByInput @oneOf \{/); + }); + + it("renders input type with optional fields", async () => { + const { Filter } = await tester.compile( + t.code`model ${t.model("Filter")} { name?: string; limit?: int32; }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Filter, GraphQLTypeContext.Input).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + // Optional fields are nullable (no !) + expect(sdl).toContain("name: String"); + expect(sdl).not.toContain("name: String!"); + }); +}); 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..00315be89ce --- /dev/null +++ b/packages/graphql/test/components/interface-type.test.tsx @@ -0,0 +1,57 @@ +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { InterfaceType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine, GraphQLTypeContext } from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderToSDL } from "./test-utils.js"; + +describe("InterfaceType component", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic interface type", async () => { + const { Node } = await tester.compile( + t.code`@graphqlInterface model ${t.model("Node")} { id: string; }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Node, GraphQLTypeContext.Interface).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toMatch(/interface NodeInterface \{/); + expect(sdl).toContain("id: String!"); + }); + + it("renders interfaceOnly interface without suffix", async () => { + const { Node } = await tester.compile( + t.code`@graphqlInterface(#{interfaceOnly: true}) model ${t.model("Node")} { id: string; }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Node, GraphQLTypeContext.Interface).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toMatch(/interface Node \{/); + expect(sdl).not.toContain("NodeInterface"); + }); + + it("renders interface with doc comment", async () => { + const { Node } = await tester.compile( + t.code` + /** A uniquely identifiable entity */ + @graphqlInterface model ${t.model("Node")} { id: string; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Node, GraphQLTypeContext.Interface).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain('"A uniquely identifiable entity"'); + }); +}); 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..b2b2b78447a --- /dev/null +++ b/packages/graphql/test/components/object-type.test.tsx @@ -0,0 +1,86 @@ +import { type Model } from "@typespec/compiler"; +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { InterfaceType, ObjectType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine, GraphQLTypeContext } from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderToSDL } from "./test-utils.js"; + +describe("ObjectType component", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic object type with fields", async () => { + const { Book } = await tester.compile( + t.code`model ${t.model("Book")} { title: string; year: int32; }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Book, GraphQLTypeContext.Output).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toMatch(/type Book \{/); + expect(sdl).toContain("title: String!"); + expect(sdl).toContain("year: Int!"); + }); + + it("renders object type with doc comment", async () => { + const { Book } = await tester.compile( + t.code` + /** A published book */ + model ${t.model("Book")} { title: string; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Book, GraphQLTypeContext.Output).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain('"A published book"'); + expect(sdl).toMatch(/type Book \{/); + }); + + it("renders object type with optional fields as nullable", async () => { + const { User } = await tester.compile( + t.code`model ${t.model("User")} { name: string; nickname?: string; }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(User, GraphQLTypeContext.Output).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain("name: String!"); + expect(sdl).toContain("nickname: String"); + // nickname should NOT have ! (it's optional/nullable) + expect(sdl).not.toContain("nickname: String!"); + }); + + it("renders object type implementing interfaces", async () => { + const { Cat, Animal } = await tester.compile( + t.code` + @graphqlInterface model ${t.model("Animal")} { name: string; } + @compose(Animal) + model ${t.model("Cat")} { name: string; breed: string; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutatedCat = engine.mutateModel(Cat, GraphQLTypeContext.Output).mutatedType; + const mutatedAnimal = engine.mutateModel(Animal, GraphQLTypeContext.Interface).mutatedType; + + const sdl = renderToSDL( + tester.program, + <> + + + , + ); + + expect(sdl).toMatch(/type Cat implements AnimalInterface \{/); + }); +}); 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..0bc33c87448 --- /dev/null +++ b/packages/graphql/test/components/scalar-type.test.tsx @@ -0,0 +1,79 @@ +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } 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 { renderToSDL } from "./test-utils.js"; + +describe("ScalarType component", () => { + let tester: TesterInstance; + 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 mutated = engine.mutateScalar(DateTime).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain("scalar DateTime"); + }); + + 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 mutated = engine.mutateScalar(JSON).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain('"Arbitrary JSON blob"'); + expect(sdl).toContain("scalar JSON"); + }); + + it("renders a scalar with @specifiedBy", 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 mutated = engine.mutateScalar(MyScalar).mutatedType; + const specUrl = getSpecifiedBy(tester.program, mutated); + + const sdl = renderToSDL( + tester.program, + , + ); + + expect(sdl).toContain("@specifiedBy"); + expect(sdl).toContain("https://example.com/spec"); + }); + + it("renders a scalar without @specifiedBy when not present", async () => { + const { MyScalar } = await tester.compile( + t.code`scalar ${t.scalar("MyScalar")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateScalar(MyScalar).mutatedType; + + const sdl = renderToSDL(tester.program, ); + + expect(sdl).toContain("scalar MyScalar"); + expect(sdl).not.toContain("@specifiedBy"); + }); +}); diff --git a/packages/graphql/test/components/test-utils.tsx b/packages/graphql/test/components/test-utils.tsx new file mode 100644 index 00000000000..679463aef16 --- /dev/null +++ b/packages/graphql/test/components/test-utils.tsx @@ -0,0 +1,35 @@ +import { type Children } from "@alloy-js/core"; +import * as gql from "@pinterest/alloy-graphql"; +import { renderSchema } from "@pinterest/alloy-graphql"; +import { printSchema } from "graphql"; +import type { Program } from "@typespec/compiler"; +import { TspContext } from "@typespec/emitter-framework"; +import { GraphQLSchemaContext } from "../../src/context/index.js"; +import type { TypeGraph } from "../../src/mutation-engine/type-graph.js"; + +/** + * Render GraphQL components in isolation and return SDL string. + * Wraps children in required context providers and adds a placeholder Query + * (graphql-js requires at least one query field). + */ +export function renderToSDL(program: Program, children: Children): string { + const typeGraph: TypeGraph = { + globalNamespace: program.getGlobalNamespaceType(), + }; + + const schema = renderSchema( + + + {children} + + + + + , + { namePolicy: null }, + ); + + // Cast needed: alloy uses graphql@17-alpha internally, our package uses graphql@16. + // At runtime both are deduped via vitest config; the type mismatch is superficial. + return printSchema(schema as any); +} 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..7a7dba69d4c --- /dev/null +++ b/packages/graphql/test/components/union-type.test.tsx @@ -0,0 +1,140 @@ +import { t, TesterInstance } from "@typespec/compiler/testing"; +import * as gql from "@pinterest/alloy-graphql"; +import { beforeEach, describe, expect, it } from "vitest"; +import { UnionType, type GraphQLUnion } from "../../src/components/types/index.js"; +import { + createGraphQLMutationEngine, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderToSDL } from "./test-utils.js"; + +describe("UnionType component", () => { + let tester: TesterInstance; + 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 = mutation.mutatedType as GraphQLUnion; + + // Union members must be registered for graphql-js to validate the schema + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain("union Pet = Cat | Dog"); + }); + + 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 = mutation.mutatedType as GraphQLUnion; + + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain('"The result of an operation"'); + expect(sdl).toContain("union Result = Success | Failure"); + }); + + it("references wrapper model 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 = mutation.mutatedType as GraphQLUnion; + + // Register the wrapper model and Cat so graphql-js can validate + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain("union Mixed = Cat | MixedTextUnionVariant"); + }); + + it("renders a union with three 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 = mutation.mutatedType as GraphQLUnion; + + const sdl = renderToSDL( + tester.program, + <> + + + + + + + + + + + , + ); + + expect(sdl).toContain("union Shape = Circle | Square | Triangle"); + }); +}); diff --git a/packages/graphql/test/crash-repro.test.ts b/packages/graphql/test/crash-repro.test.ts new file mode 100644 index 00000000000..439a37d1437 --- /dev/null +++ b/packages/graphql/test/crash-repro.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from "vitest"; +import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; + +describe("crash: Record types", () => { + it("Record should emit as scalar", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model User { + name: string; + metadata: Record; + } + @query op getUser(): User; + } + `); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toContain("type User"); + }); +}); + +describe("crash: Generics", () => { + it("instantiated generic model should emit", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model PagedResponse { + data: T[]; + totalCount: int32; + hasMore: boolean; + } + model User { name: string; } + @query op getUsers(): PagedResponse; + } + `); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toContain("type Query"); + }); +}); + +describe("crash: empty input (all fields visibility-filtered)", () => { + it("model with all read-only fields used as mutation input", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model ServerGenerated { + @visibility(Lifecycle.Read) + requestId: string; + @visibility(Lifecycle.Read) + timestamp: string; + } + @query op getInfo(): ServerGenerated; + @mutation op trigger(info: ServerGenerated): boolean; + } + `); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toContain("type Query"); + }); +}); + +describe("crash: union as input", () => { + it("union used as mutation parameter", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Cat { name: string; } + model Dog { breed: string; } + union Pet { cat: Cat, dog: Dog } + @query op getPets(): Pet[]; + @mutation op adoptPet(pet: Pet): Cat | Dog; + } + `); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toContain("type Query"); + }); + + it("union as mutation input with visibility-filtered variant types", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model User { + @visibility(Lifecycle.Read) + id: string; + + @visibility(Lifecycle.Create, Lifecycle.Update) + password: string; + + name: string; + } + model Admin { + @visibility(Lifecycle.Read) + id: string; + + role: string; + } + union Entity { user: User, admin: Admin } + @query op getEntities(): Entity[]; + @mutation op createEntity(input: Entity): Entity; + } + `); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "union Entity = User | Admin + + type User { + id: String! + name: String! + } + + input UserInput { + password: String! + name: String! + } + + type Admin { + id: String! + role: String! + } + + input AdminInput { + role: String! + } + + input EntityInput @oneOf { + user: UserInput + admin: AdminInput + } + + type Query { + getEntities: [Entity!]! + } + + type Mutation { + createEntity(input: EntityInput!): Entity! + }" + `); + }); +}); + +describe("crash: nested generics", () => { + it("nested generic BatchResult with PagedResponse[]", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model PagedResponse { data: T[]; totalCount: int32; } + model BatchResult { pages: PagedResponse[]; batchId: string; } + model Post { title: string; } + @query op getBatch(): BatchResult; + } + `); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toContain("PagedResponseOfPost"); + expect(result.graphQLOutput).toContain("BatchResultOfPost"); + }); +}); diff --git a/packages/graphql/test/e2e-manual/TEST_COVERAGE.md b/packages/graphql/test/e2e-manual/TEST_COVERAGE.md new file mode 100644 index 00000000000..783e13099e1 --- /dev/null +++ b/packages/graphql/test/e2e-manual/TEST_COVERAGE.md @@ -0,0 +1,161 @@ +# E2E Manual Test Coverage + +Manual validation of the GraphQL emitter against all TypeSpec patterns and GraphQL-specific features. +Each schema is emitted and the SDL output is verified for correctness. + +## Running + +```bash +export PATH="$HOME/.npm-global/bin:$PATH" +cd ~/code/typespec + +# Build (required after code changes) +npx -y node@22 $HOME/.npm-global/bin/pnpm --filter @typespec/graphql run build + +# Run e2e manual tests +npx -y node@22 $HOME/.npm-global/bin/pnpm --filter @typespec/graphql exec vitest run test/e2e-manual/emit.test.ts + +# SDL output files are written to test/e2e-manual/output/ +``` + +## Schema 01-core: Content Platform + +Patterns: operations, models, scalars, enums, interfaces, unions, nullability, spread, extends, deprecation, circular refs, input/output split, Records. + +| # | Pattern | Result | +|---|---------|--------| +| 1 | `@query`, `@mutation`, `@subscription` | Correct | +| 2 | `@graphqlInterface` (default) → `ReactableInterface` suffix | Correct | +| 3 | `@graphqlInterface(#{interfaceOnly: true})` → no suffix (`Node`, `Connection`) | Correct | +| 4 | `@compose(...)` single + multi (`Article implements Node`, `Review implements Node & ReactableInterface`) | Correct | +| 6 | `@specifiedBy(url)` on scalars | Correct | +| 7 | `GraphQL.ID` → `ID!` | Correct | +| 8 | Model in both input + output (`User` → `type User` + `input UserInput`) | Correct | +| 9 | Input-only model (`CreatePostInput`) | Correct | +| 10 | Nested input models (`CreateArticleInput` → `CreateAuthorInput` → `CreateReviewPolicyInput`) | Correct | +| 11 | Named union (`SearchResult`) | Correct | +| 12 | Scalar variant in union (`NotificationContentMessageUnionVariant`) | Correct | +| 14 | Anonymous union return → auto-named (`GetContentUnion`) | Correct | +| 15 | Anonymous union property → auto-named (`FeedItemContentUnion`) | Correct | +| 19 | `extends` (field flattening) | Correct (fixed in PR #101) | +| 20 | `...spread` (Timestamps/Auditable fields on User) | Correct | +| 21 | `Record` → `scalar RecordOfString` | Correct | +| 22 | `Record` → `scalar RecordOfMetric` | Correct | +| 25 | Alias as union (`Publishable = Post \| Article`) | Correct | +| 26 | Simple enum → CONSTANT_CASE | Correct | +| 27 | Enum with string values (`IMAGE_JPEG`) | Correct | +| 28 | `#deprecated` member → `@deprecated(reason: "...")` | Correct | +| 29 | Custom scalar (`DateTime`, `URL`, `Long`) | Correct | +| 30 | `field: T \| null` → no `!` | Correct | +| 31 | `field?: T` → no `!` | Correct | +| 33 | `T[] \| null` → `[T!]` | Correct | +| 34 | `(T \| null)[]` → `[T]!` | Correct | +| 35 | `(T \| null)[] \| null` → `[T]` | Correct | +| 36 | `field?: T[]` → `[T!]` (no outer `!`) | Correct | +| 37 | TypeSpec `interface` keyword → prefixed ops (`boardOpsGetBoard`) | Correct | +| 39 | Self-reference (`Comment.replies`) | Correct | +| 40 | Mutual reference (`User↔Post`) | Correct | +| 45 | All-optional model (`PostFilter`) | Correct | +| 54 | Deprecated field (`body @deprecated`) | Correct | +| 55 | Deprecated operation (`publishDraft @deprecated`) | Correct | +| 56 | Interface inheritance chain (`PagedConnection implements Connection`) | Correct | +| 59 | extends + spread combined | Correct (fixed in PR #101) | +| 60 | Circular input model (`CreateCommentInput.replies`) | Correct | +| 69 | Single-variant union → unwrapped (`getWrapped: Article!`) | Correct | + +## Schema 02-generics: Template Models + +Patterns: template instantiation, nested generics, recursive generics, generic input. + +| # | Pattern | Result | +|---|---------|--------| +| 16 | Template model `` → `PagedResponseOfUser` | Correct | +| 17 | Nested generic → `BatchResultOfPost` references `PagedResponseOfPost` | Correct | +| 57 | Generic as input → `CreateInputOfTagInput` | Correct | +| 72 | Recursive generic → `TreeNodeOfPost.children: [TreeNodeOfPost!]!` | Correct | + +## Schema 03-visibility: Visibility Filtering + +Patterns: read-only exclusion, create-only exclusion, default visibility, empty input pruning, query/mutation split. + +| # | Pattern | Result | +|---|---------|--------| +| 41 | `@visibility(Lifecycle.Read)` excluded from input (`AccountInput` has no `id`/`createdAt`) | Correct | +| 42 | `@visibility(Lifecycle.Create)` excluded from output (`Account` has no `password`) | Correct | +| 43 | Default (no decorator) → both contexts (`username`, `displayName`) | Correct | +| 44 | All-read-only model as mutation input → param pruned (`triggerJob` has no `info`) | Correct | +| — | Query/Mutation input split → `UserProfileQueryInput` vs `UserProfileMutationInput` | Correct | + +## Schema 04-records: Record Types + +Patterns: Record, Record, Record, Record. + +| # | Pattern | Result | +|---|---------|--------| +| 21 | `Record` → `scalar RecordOfString` | Correct | +| 22 | `Record` → `scalar RecordOfMetric` | Correct | +| 23 | `Record` → no fields contributed (StrictConfig has only own fields) | Correct | +| 24 | `Record` nullable → `rawData: RecordOfUnknown` | Correct | + +## Schema 05-union-input: Union as Input + +Patterns: union in mutation parameter → @oneOf input object. + +| # | Pattern | Result | +|---|---------|--------| +| 49/67 | Union as mutation param → `input PetInput @oneOf { cat: CatInput, dog: DogInput }` | Correct | + +## Schema 06-descriptions: Documentation and Deprecation + +Patterns: @doc on types/fields/params, #deprecated directive. + +| # | Pattern | Result | +|---|---------|--------| +| 52 | `@doc` / `/** */` on fields → field descriptions | Correct | +| 53 | `@doc` on operations → query/mutation descriptions | Correct | +| 54 | `#deprecated` on field → `@deprecated(reason: "...")` | Correct | +| — | `@doc` on parameters → arg descriptions | Correct | + +## Schema 07-opfields: @operationFields with Visibility + +Patterns: operation fields on models, excluded from input types, interaction with visibility and query/mutation split. + +| # | Pattern | Result | +|---|---------|--------| +| 5 | `@operationFields(op1, op2)` → fields with args on output type | Correct | +| — | Operation fields excluded from input types | Correct | +| — | Warning emitted when @operationFields model used as input | Correct | +| — | @operationFields + visibility filtering (read-only excluded from input) | Correct | +| — | @operationFields + query/mutation input split | Correct | + +## Schema 08-gaps: Remaining Patterns + +Patterns: optional+nullable, constrained generic. + +| # | Pattern | Result | +|---|---------|--------| +| 18 | Constrained generic `` → resolves to `String` | Correct | +| 32 | `field?: T \| null` → no `!` | Correct | + +## Schema 09-nested-empty: Visibility-Filtered Empty Model + +Edge case: model with property whose type is fully visibility-filtered. + +| # | Pattern | Result | +|---|---------|--------| +| — | Nested empty model from visibility filtering | Correct — replaced with scalar (fixed in PR #102) | + +## Not Tested + +| # | Pattern | Reason | +|---|---------|--------| +| 13 | Union flattening (spread) | TypeSpec union spread `...Union` syntax not supported | +| 38 | Generic interface extends | Not included (low priority) | + +## Known Bugs + +| Ticket | Summary | Status | +|--------|---------|--------| +| API-5278 | Record scalars duplicated for input/output context (`RecordOfString` + `RecordOfStringInput`) | Open | +| API-5279 | Model `extends` does not flatten base model fields into child type | Fixed (PR #101) | +| API-5280 | Emitter crashes when nested model property type is fully visibility-filtered to empty | Fixed (PR #102) | diff --git a/packages/graphql/test/e2e-manual/emit.test.ts b/packages/graphql/test/e2e-manual/emit.test.ts new file mode 100644 index 00000000000..81de0d4995a --- /dev/null +++ b/packages/graphql/test/e2e-manual/emit.test.ts @@ -0,0 +1,509 @@ +import { describe, it, expect } from "vitest"; +import { EmitterTester } from "../test-host.js"; +import { writeFileSync, mkdirSync } from "fs"; +import { join } from "path"; + +const outputDir = join(import.meta.dirname, "output"); +mkdirSync(outputDir, { recursive: true }); + +async function emitSchema(name: string, code: string) { + const [result, diagnostics] = await EmitterTester.compileAndDiagnose(code, { + compilerOptions: { + options: { "@typespec/graphql": { "output-file": "schema.graphql" } }, + }, + }); + const sdl = result.outputs["schema.graphql"] ?? ""; + const errors = diagnostics.filter((d) => d.severity === "error"); + const warnings = diagnostics.filter((d) => d.severity === "warning"); + + writeFileSync(join(outputDir, `${name}.graphql`), sdl); + if (diagnostics.length) { + writeFileSync( + join(outputDir, `${name}.diagnostics.txt`), + diagnostics.map((d) => `${d.severity}: [${d.code}] ${d.message}`).join("\n"), + ); + } + + console.log(`${name}.graphql: ${sdl.split("\n").length} lines | ${errors.length} errors, ${warnings.length} warnings`); + if (errors.length) console.log(" ERRORS:", errors.map((d) => d.message).join("; ")); + return { sdl, diagnostics, errors, warnings }; +} + +// ============================================================================= +// Schema 1: Core — operations, models, scalars, enums, interfaces, unions +// Patterns: #1-15, #19-20, #25-36, #39-40, #45, #54-55, #59 +// ============================================================================= +describe("schema: core content platform", () => { + it("emits all core patterns", async () => { + const { sdl, errors } = await emitSchema("01-core", ` + @schema(#{ name: "core" }) + namespace Core { + scalar DateTime extends utcDateTime; + @specifiedBy("https://tools.ietf.org/html/rfc3986") + scalar URL extends url; + @specifiedBy("https://spec.graphql.org/draft/#sec-Long") + scalar Long extends int64; + + enum Role { Admin, Moderator, Member, + #deprecated "use Member" + Viewer, + } + enum ContentStatus { Draft, Published, Archived, + #deprecated "use Archived" + SoftDeleted, + } + enum SortOrder { Ascending, Descending } + enum MimeType { ImageJpeg: "image/jpeg", ImagePng: "image/png", ImageWebp: "image/webp" } + + @graphqlInterface(#{interfaceOnly: true}) + model Node { id: GraphQL.ID; } + + @graphqlInterface(#{interfaceOnly: true}) + model Connection { totalCount: int32; hasNextPage: boolean; } + + @graphqlInterface + model Reactable { likeCount: int32; dislikeCount: int32; } + + @compose(Node) + model Article { ...Node; title: string; slug: string; content: string; } + + @compose(Node, Reactable) + model Review { ...Node; ...Reactable; rating: int32; text: string; reviewer: User; } + + @graphqlInterface(#{interfaceOnly: true}) + @compose(Connection) + model PagedConnection { ...Connection; pageSize: int32; currentPage: int32; } + + @compose(PagedConnection) + model ReviewConnection { ...PagedConnection; reviews: Review[]; averageRating: float32; } + + model Timestamps { createdAt: DateTime; updatedAt: DateTime; } + model Auditable { createdBy: string; lastModifiedBy: string; } + + /** A user on the platform */ + model User { + id: GraphQL.ID; + /** Display name */ + name: string; + email: string; + bio?: string; + role: Role; + avatarUrl?: URL; + followers: User[]; + following: User[]; + posts: Post[]; + phoneNumber?: string | null; + previousEmails: string[] | null; + recentSearches: (string | null)[]; + drafts: (Post | null)[] | null; + bookmarkedPostIds?: string[]; + metadata: Record; + ...Timestamps; + ...Auditable; + } + + /** A content post */ + model Post { + id: GraphQL.ID; + title: string; + #deprecated "use contentBody" + body?: string; + contentBody: string; + status: ContentStatus; + publishedAt?: DateTime; + viewCount: Long; + author: Author; + tags: Tag[]; + comments: Comment[]; + media: MediaAttachment[]; + engagement: Record; + ...Timestamps; + } + + model Author { user: User; penName?: string; } + model Comment { id: GraphQL.ID; text: string; author: User; post: Post; replies: Comment[]; parentComment?: Comment; ...Timestamps; } + model Tag { id: GraphQL.ID; name: string; slug: string; postCount: int32; } + model MediaAttachment { id: GraphQL.ID; url: URL; mimeType: MimeType; altText?: string; width?: int32; height?: int32; } + model Metric { count: Long; lastUpdated: DateTime; } + model PostFilter { authorId?: string; status?: ContentStatus; tag?: string; sortOrder?: SortOrder; } + model AuditedComment extends Timestamps { ...Auditable; commentId: GraphQL.ID; action: string; reason?: string; } + model Board { id: GraphQL.ID; name: string; description?: string; posts: Post[]; owner: User; } + + union SearchResult { user: User, post: Post, tag: Tag } + union NotificationContent { post: Post, comment: Comment, message: string } + model FeedItem { content: Article | Post | Review; relevanceScore: float32; reason: string; } + alias Publishable = Post | Article; + union WrappedArticle { article: Article } + + model CreatePostInput { title: string; contentBody: string; status?: ContentStatus; tagIds: string[]; media?: CreateMediaInput[]; } + model CreateArticleInput { title: string; slug: string; content: string; author: CreateAuthorInput; reviewPolicy?: CreateReviewPolicyInput; } + model CreateAuthorInput { userId: GraphQL.ID; penName?: string; } + model CreateReviewPolicyInput { requireApproval: boolean; minReviewers: int32; autoPublish: boolean; } + model CreateMediaInput { url: URL; mimeType: MimeType; altText?: string; } + model UpdatePostInput { title?: string; contentBody?: string; status?: ContentStatus; } + model CreateCommentInput { text: string; replies?: CreateCommentInput[]; } + + /** Fetch a user by ID */ + @query op getUser(id: GraphQL.ID): User; + @query op getUsers(limit?: int32, offset?: int32): User[]; + /** Search content */ + @query op search(query: string, limit?: int32): SearchResult[]; + @query op getPost(id: GraphQL.ID): Post | null; + @query op getFeed(userId: GraphQL.ID, cursor?: string): FeedItem[]; + @query op getContent(id: GraphQL.ID): Article | Post; + @query op getReviews(articleId: GraphQL.ID): ReviewConnection; + @query op listPosts(filter?: PostFilter, sort?: SortOrder): Post[]; + @query op getPublishable(id: GraphQL.ID): Publishable; + @query op getNotification(id: GraphQL.ID): NotificationContent; + @query op getWrapped(): WrappedArticle; + @query op getAuditLog(postId: GraphQL.ID): AuditedComment[]; + + @mutation op createUser(input: User): User; + @mutation op createPost(input: CreatePostInput): Post; + @mutation op createArticle(input: CreateArticleInput): Article; + @mutation op updatePost(id: GraphQL.ID, input: UpdatePostInput): Post; + @mutation op deletePost(id: GraphQL.ID): boolean; + @mutation op addComment(postId: GraphQL.ID, input: CreateCommentInput): Comment; + #deprecated "use createPost" + @mutation op publishDraft(draftId: GraphQL.ID): Post; + + @subscription op onPostPublished(): Post; + @subscription op onNewComment(postId: GraphQL.ID): Comment; + + interface BoardOps { + @query getBoard(id: GraphQL.ID): Board; + @query listBoards(userId: GraphQL.ID): Board[]; + @mutation createBoard(name: string, description?: string): Board; + } + } + `); + + expect(sdl).toBeTruthy(); + expect(sdl).toContain("type Query"); + expect(sdl).toContain("type Mutation"); + expect(sdl).toContain("type Subscription"); + expect(sdl).not.toMatch(/^scalar string$/m); + expect(errors.filter((d) => d.message.includes("collides"))).toHaveLength(0); + }); +}); + +// ============================================================================= +// Schema 2: Generics — template models, nested, constrained, recursive +// Patterns: #16-18, #57, #72 +// ============================================================================= +describe("schema: generics", () => { + it("emits instantiated generics including nested", async () => { + const { sdl, errors } = await emitSchema("02-generics", ` + @schema(#{ name: "generics" }) + namespace Generics { + scalar DateTime extends utcDateTime; + + model PagedResponse { data: T[]; totalCount: int32; hasMore: boolean; cursor?: string; } + model BatchResult { pages: PagedResponse[]; batchId: string; completedAt: DateTime; } + model TreeNode { value: T; children: TreeNode[]; parent?: TreeNode; } + model CreateInput { data: T; clientMutationId?: string; } + + model User { id: string; name: string; } + model Post { id: string; title: string; } + model Tag { id: string; name: string; } + + @query op getUsers(cursor?: string): PagedResponse; + @query op getBatchPosts(): BatchResult; + @query op getTree(rootId: string): TreeNode; + @mutation op batchCreateTags(input: CreateInput): Tag[]; + } + `); + + expect(sdl).toBeTruthy(); + expect(sdl).toContain("PagedResponseOfUser"); + expect(sdl).toContain("BatchResultOfPost"); + expect(sdl).toContain("PagedResponseOfPost"); + expect(sdl).toContain("TreeNodeOfPost"); + expect(errors).toHaveLength(0); + }); +}); + +// ============================================================================= +// Schema 3: Visibility — read-only, create-only, query/mutation splitting +// Patterns: #41-44 +// ============================================================================= +describe("schema: visibility", () => { + it("emits visibility-filtered input/output types", async () => { + const { sdl, errors } = await emitSchema("03-visibility", ` + @schema(#{ name: "visibility" }) + namespace Visibility { + scalar DateTime extends utcDateTime; + + model Account { + @visibility(Lifecycle.Read) id: GraphQL.ID; + @visibility(Lifecycle.Read) createdAt: DateTime; + @visibility(Lifecycle.Read) lastLoginAt: DateTime; + @visibility(Lifecycle.Create) password: string; + @visibility(Lifecycle.Create) inviteCode?: string; + username: string; + displayName: string; + isActive: boolean; + } + + @query op getAccount(id: GraphQL.ID): Account; + @mutation op createAccount(input: Account): Account; + + model ServerGenerated { + @visibility(Lifecycle.Read) requestId: string; + @visibility(Lifecycle.Read) timestamp: DateTime; + @visibility(Lifecycle.Read) serverVersion: string; + } + + @query op getServerInfo(): ServerGenerated; + @mutation op triggerJob(info: ServerGenerated): boolean; + + model UserProfile { + @visibility(Lifecycle.Read, Lifecycle.Query) id: GraphQL.ID; + @visibility(Lifecycle.Read, Lifecycle.Query) username: string; + @visibility(Lifecycle.Create, Lifecycle.Update) email: string; + @visibility(Lifecycle.Create, Lifecycle.Update) password: string; + displayName: string; + bio?: string; + } + + @query op findProfiles(filter: UserProfile): UserProfile[]; + @mutation op updateProfile(input: UserProfile): UserProfile; + } + `); + + expect(sdl).toBeTruthy(); + expect(sdl).toContain("type Account"); + expect(sdl).toContain("input AccountInput"); + expect(sdl).toMatch(/input AccountInput[^}]*password/s); + expect(sdl).not.toMatch(/input AccountInput[^}]*lastLoginAt/s); + expect(sdl).toContain("UserProfileQueryInput"); + expect(sdl).toContain("UserProfileMutationInput"); + expect(errors).toHaveLength(0); + }); +}); + +// ============================================================================= +// Schema 4: Record types +// Patterns: #21-24 +// ============================================================================= +describe("schema: record types", () => { + it("emits Record as custom scalars", async () => { + const { sdl, errors } = await emitSchema("04-records", ` + @schema(#{ name: "records" }) + namespace Records { + scalar DateTime extends utcDateTime; + model Metric { count: int32; lastUpdated: DateTime; } + + model Config { + labels: Record; + metrics: Record; + rawData: Record | null; + } + + model StrictConfig { + maxItems: int32; + enabled: boolean; + ...Record; + } + + @query op getConfig(): Config; + @query op getStrictConfig(): StrictConfig; + } + `); + + expect(sdl).toBeTruthy(); + expect(sdl).toContain("scalar RecordOfString"); + expect(sdl).toContain("scalar RecordOfMetric"); + expect(errors).toHaveLength(0); + }); + + it("emits a single scalar for Record used in both input and output contexts", async () => { + const { sdl, errors } = await emitSchema("04b-records-dedup", ` + @schema(#{ name: "records-dedup" }) + namespace RecordsDedup { + model User { + name: string; + metadata: Record; + } + + @query op getUser(): User; + @mutation op createUser(input: User): User; + } + `); + + expect(sdl).toBeTruthy(); + expect(errors).toHaveLength(0); + + // Should have exactly ONE RecordOfString scalar, not two + const matches = sdl!.match(/scalar RecordOfString/g); + expect(matches).toHaveLength(1); + + // Should NOT have RecordOfStringInput + expect(sdl).not.toContain("RecordOfStringInput"); + + // Both type and input should reference the same scalar + expect(sdl).toMatch(/type User \{[\s\S]*?metadata: RecordOfString/); + expect(sdl).toMatch(/input UserInput \{[\s\S]*?metadata: RecordOfString/); + }); +}); + +// ============================================================================= +// Schema 5: Union as input — @oneOf conversion +// Patterns: #49, #67 +// ============================================================================= +describe("schema: union as input", () => { + it("emits @oneOf input for union in mutation param", async () => { + const { sdl, errors } = await emitSchema("05-union-input", ` + @schema(#{ name: "union-input" }) + namespace UnionInput { + model Cat { name: string; indoor: boolean; } + model Dog { name: string; breed: string; } + union Pet { cat: Cat, dog: Dog } + + @query op getPets(): Pet[]; + @mutation op adoptPet(pet: Pet): Cat | Dog; + } + `); + + expect(sdl).toBeTruthy(); + expect(sdl).toContain("union Pet"); + expect(sdl).toContain("type Query"); + expect(sdl).toContain("type Mutation"); + }); +}); + +// ============================================================================= +// Schema 6: Descriptions and deprecation +// Patterns: #52-55 +// ============================================================================= +describe("schema: descriptions and deprecation", () => { + it("emits doc comments and @deprecated directives", async () => { + const { sdl } = await emitSchema("06-descriptions", ` + @schema(#{ name: "descriptions" }) + namespace Descriptions { + enum Priority { Low, Medium, High, Critical } + + model Task { + id: string; + /** The task title */ + title: string; + priority: Priority; + #deprecated "use priority field" + oldPriority?: string; + } + + /** Get tasks by priority */ + @query op getTasks(priority?: Priority): Task[]; + @mutation op setTaskPriority(taskId: string, priority: Priority): Task; + /** Get a task by ID */ + @query op getTaskById(/** The unique ID */ id: string): Task | null; + } + `); + + expect(sdl).toBeTruthy(); + expect(sdl).toContain('"The task title"'); + expect(sdl).toContain('@deprecated(reason: "use priority field")'); + expect(sdl).toContain('"Get tasks by priority"'); + expect(sdl).toContain('"The unique ID"'); + }); +}); + +// ============================================================================= +// Schema 7: @operationFields with visibility +// Patterns: #5, @operationFields + visibility + query/mutation split +// ============================================================================= +describe("schema: @operationFields with visibility", () => { + it("emits operation fields on output, excludes from input, warns", async () => { + const { sdl, errors, warnings } = await emitSchema("07-opfields", ` + @schema(#{ name: "opfields" }) + namespace OpFields { + model Post { id: GraphQL.ID; title: string; } + + @query op getUser(id: GraphQL.ID): User; + @query op getUserPosts(userId: GraphQL.ID, limit?: int32): Post[]; + @query op getUserFollowers(userId: GraphQL.ID): User[]; + @operationFields(getUser, getUserPosts, getUserFollowers) + model User { + @visibility(Lifecycle.Read) id: GraphQL.ID; + @visibility(Lifecycle.Read, Lifecycle.Query) username: string; + @visibility(Lifecycle.Create, Lifecycle.Update) password: string; + name: string; + email: string; + } + @query op searchUsers(filter: User): User[]; + @mutation op createUser(input: User): User; + } + `); + + expect(sdl).toBeTruthy(); + expect(errors).toHaveLength(0); + // Output type has operation fields + expect(sdl).toMatch(/type User \{[^}]*getUser\(/s); + expect(sdl).toMatch(/type User \{[^}]*getUserPosts\(/s); + expect(sdl).toMatch(/type User \{[^}]*getUserFollowers\(/s); + // No input variant has operation fields + expect(sdl).not.toMatch(/input[^}]*getUser\(/s); + // Warning about operation fields ignored on input + expect(warnings.some((d) => d.code === "@typespec/graphql/operation-fields-ignored-on-input")).toBe(true); + }); +}); + +// ============================================================================= +// Schema 8: Remaining gaps — optional+nullable, constrained generic +// Patterns: #18, #32 +// ============================================================================= +describe("schema: remaining patterns", () => { + it("emits optional+nullable and constrained generic", async () => { + const { sdl, errors } = await emitSchema("08-gaps", ` + @schema(#{ name: "gaps" }) + namespace Gaps { + model Item { bio?: string | null; count?: int32 | null; } + model Labeled { label: L; description: string; } + @query op getItem(): Item; + @query op getLabel(): Labeled<"category">; + } + `); + + expect(sdl).toBeTruthy(); + expect(errors).toHaveLength(0); + // optional + nullable → no ! + expect(sdl).toMatch(/bio: String[^!]/); + expect(sdl).toMatch(/count: Int[^!]/); + // Constrained generic resolves + expect(sdl).toContain("type Labeled"); + expect(sdl).toContain("label: String!"); + }); +}); + +// ============================================================================= +// Schema 9: Edge case — nested empty model from visibility (API-5280) +// ============================================================================= +describe("schema: edge case - nested visibility-filtered empty model", () => { + it("handles model with property whose type is fully visibility-filtered", async () => { + const { sdl, errors } = await emitSchema("09-nested-empty", ` + @schema(#{ name: "nested-empty" }) + namespace NestedEmpty { + model Inner { + @visibility(Lifecycle.Read) id: string; + @visibility(Lifecycle.Read) createdAt: string; + } + + model Outer { + name: string; + inner: Inner; + } + + @query op getOuter(): Outer; + @mutation op createOuter(input: Outer): Outer; + } + `); + + // This is a known edge case from code review Finding 2. + // Inner as input has 0 properties after visibility filtering. + // Expected: either omit 'inner' from OuterInput, or handle gracefully. + console.log(" [Finding 2] SDL:", sdl?.substring(0, 500)); + console.log(" [Finding 2] Errors:", errors.map((d) => d.message)); + // Don't assert pass/fail — just document current behavior + expect(sdl !== undefined || errors.length > 0).toBe(true); + }); +}); diff --git a/packages/graphql/test/e2e-manual/output/.gitignore b/packages/graphql/test/e2e-manual/output/.gitignore new file mode 100644 index 00000000000..de09f8a5df7 --- /dev/null +++ b/packages/graphql/test/e2e-manual/output/.gitignore @@ -0,0 +1,2 @@ +*.graphql +*.diagnostics.txt diff --git a/packages/graphql/test/e2e.test.ts b/packages/graphql/test/e2e.test.ts new file mode 100644 index 00000000000..f11fd5cdcff --- /dev/null +++ b/packages/graphql/test/e2e.test.ts @@ -0,0 +1,1069 @@ +import { expect, describe, it } from "vitest"; +import { expectDiagnosticEmpty } from "@typespec/compiler/testing"; +import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; + +describe("e2e: operations", () => { + it("renders query with parameters", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model User { name: string; age: int32; } + @query op getUser(id: string): User; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type User { + name: String! + age: Int! + } + + type Query { + getUser(id: String!): User! + }" + `); + }); + + it("renders mutation with input parameter model", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model User { name: string; } + @query op getUsers(): User[]; + @mutation op createUser(input: User): User; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type User { + name: String! + } + + input UserInput { + name: String! + } + + type Query { + getUsers: [User!]! + } + + type Mutation { + createUser(input: UserInput!): User! + }" + `); + }); + + it("renders subscription", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Message { text: string; } + @query op getMessages(): Message[]; + @subscription op onMessage(): Message; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Message { + text: String! + } + + type Query { + getMessages: [Message!]! + } + + type Subscription { + onMessage: Message! + }" + `); + }); + + it("renders operation with optional parameters as nullable", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model User { id: string; } + @query op searchUsers(query?: string, limit?: int32): User[]; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type User { + id: String! + } + + type Query { + searchUsers(query: String, limit: Int): [User!]! + }" + `); + }); + + it("renders operation returning nullable type", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model User { name: string; } + @query op getUser(id: string): User | null; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type User { + name: String! + } + + type Query { + getUser(id: String!): User + }" + `); + }); + + it("renders operation returning list", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Book { title: string; } + @query op getBooks(): Book[]; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Book { + title: String! + } + + type Query { + getBooks: [Book!]! + }" + `); + }); +}); + +describe("e2e: models", () => { + it("renders model with various scalar types", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Thing { + name: string; + count: int32; + price: float64; + active: boolean; + } + @query op getThing(): Thing; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Thing { + name: String! + count: Int! + price: Float! + active: Boolean! + } + + type Query { + getThing: Thing! + }" + `); + }); + + it("renders model with optional fields as nullable", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model User { name: string; nickname?: string; } + @query op getUser(): User; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type User { + name: String! + nickname: String + } + + type Query { + getUser: User! + }" + `); + }); + + it("renders model with array fields", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model User { name: string; tags: string[]; } + @query op getUser(): User; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type User { + name: String! + tags: [String!]! + } + + type Query { + getUser: User! + }" + `); + }); + + it("renders recursive model", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Person { name: string; friend?: Person; } + @query op getPerson(): Person; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Person { + name: String! + friend: Person + } + + type Query { + getPerson: Person! + }" + `); + }); + + it("renders model with doc description", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + /** A user in the system */ + model User { name: string; } + @query op getUser(): User; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + """"A user in the system""" + type User { + name: String! + } + + type Query { + getUser: User! + }" + `); + }); +}); + +describe("e2e: enums", () => { + it("renders enum with CONSTANT_CASE members", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + enum Status { Active, Inactive, PendingReview } + model Item { status: Status; } + @query op getItem(): Item; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "enum Status { + ACTIVE + INACTIVE + PENDING_REVIEW + } + + type Item { + status: Status! + } + + type Query { + getItem: Item! + }" + `); + }); + + it("renders enum with deprecated member", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + enum Status { + Active, + #deprecated "use Active" + Legacy, + } + model Item { status: Status; } + @query op getItem(): Item; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "enum Status { + ACTIVE + LEGACY @deprecated(reason: "use Active") + } + + type Item { + status: Status! + } + + type Query { + getItem: Item! + }" + `); + }); +}); + +describe("e2e: scalars", () => { + it("renders custom scalar", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + scalar DateTime extends string; + model Event { when: DateTime; } + @query op getEvent(): Event; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "scalar DateTime + + type Event { + when: DateTime! + } + + type Query { + getEvent: Event! + }" + `); + }); + + it("renders scalar with @specifiedBy", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + @specifiedBy("https://example.com/spec") + scalar JSON extends string; + model Data { payload: JSON; } + @query op getData(): Data; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "scalar JSON @specifiedBy(url: "https://example.com/spec") + + type Data { + payload: JSON! + } + + type Query { + getData: Data! + }" + `); + }); +}); + +describe("e2e: unions", () => { + it("renders named union", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Cat { name: string; } + model Dog { breed: string; } + union Pet { cat: Cat, dog: Dog } + @query op getPet(): Pet; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "union Pet = Cat | Dog + + type Cat { + name: String! + } + + type Dog { + breed: String! + } + + type Query { + getPet: Pet! + }" + `); + }); + + it("renders union with scalar variants as wrapper models", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Cat { name: string; } + union SearchResult { cat: Cat, text: string } + @query op search(): SearchResult; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "union SearchResult = Cat | SearchResultTextUnionVariant + + type Cat { + name: String! + } + + type SearchResultTextUnionVariant { + value: String! + } + + type Query { + search: SearchResult! + }" + `); + }); +}); + +describe("e2e: interfaces", () => { + it("renders @interface model as interface with suffix", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + @graphqlInterface model Animal { name: string; } + @compose(Animal) + model Cat { name: string; breed: string; } + @query op getCat(): Cat; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "interface AnimalInterface { + name: String! + } + + type Cat implements AnimalInterface { + name: String! + breed: String! + } + + type Query { + getCat: Cat! + }" + `); + }); +}); + +describe("e2e: input/output splitting", () => { + it("model used as both input and output gets two declarations", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Book { title: string; } + @query op getBooks(): Book[]; + @mutation op createBook(input: Book): Book; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Book { + title: String! + } + + input BookInput { + title: String! + } + + type Query { + getBooks: [Book!]! + } + + type Mutation { + createBook(input: BookInput!): Book! + }" + `); + }); + + it("input-only model does not produce output type", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Book { title: string; } + model CreatePayload { title: string; year: int32; } + @query op getBooks(): Book[]; + @mutation op createBook(input: CreatePayload): Book; + } + `, { "omit-unreachable-types": true }); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Book { + title: String! + } + + input CreatePayloadInput { + title: String! + year: Int! + } + + type Query { + getBooks: [Book!]! + } + + type Mutation { + createBook(input: CreatePayloadInput!): Book! + }" + `); + }); +}); + +describe("e2e: nullability combinations", () => { + it("handles nullable array elements (T | null)[]", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Item { tags: (string | null)[]; } + @query op getItem(): Item; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Item { + tags: [String]! + } + + type Query { + getItem: Item! + }" + `); + }); + + it("handles nullable array T[] | null", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Item { tags: string[] | null; } + @query op getItem(): Item; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Item { + tags: [String!] + } + + type Query { + getItem: Item! + }" + `); + }); + + it("handles both nullable: (T | null)[] | null", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Item { tags: (string | null)[] | null; } + @query op getItem(): Item; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Item { + tags: [String] + } + + type Query { + getItem: Item! + }" + `); + }); +}); + +describe("e2e: nullable scalar does not emit built-in scalar declaration", () => { + it("does not emit scalar string for nullable string field", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Foo { + id: GraphQL.ID; + value: string | null; + } + @query op getFoo(id: GraphQL.ID): Foo; + } + `); + expect(result.graphQLOutput).not.toContain("scalar string"); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Foo { + id: ID! + value: String + } + + type Query { + getFoo(id: ID!): Foo! + }" + `); + }); + + it("does not emit scalar int32 for nullable int field", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Bar { + count: int32 | null; + } + @query op getBar(): Bar; + } + `); + expect(result.graphQLOutput).not.toContain("scalar int32"); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Bar { + count: Int + } + + type Query { + getBar: Bar! + }" + `); + }); +}); + +describe("e2e: @operationFields", () => { + it("renders operation as field with arguments on object type", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + @query op getUser(id: string): User; + @operationFields(getUser) + model User { id: string; name: string; } + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + getUser(id: String!): User! + } + + type Query { + getUser(id: String!): User! + }" + `); + }); + + it("renders multiple operation fields", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + @query op getUser(id: string): User; + @query op getFriends(id: string): User[]; + @operationFields(getUser, getFriends) + model User { id: string; name: string; } + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + getUser(id: String!): User! + getFriends(id: String!): [User!]! + } + + type Query { + getFriends(id: String!): [User!]! + getUser(id: String!): User! + }" + `); + }); +}); + +describe("e2e: circular references", () => { + it("handles self-referencing model", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model TreeNode { value: string; children: TreeNode[]; parent?: TreeNode; } + @query op getRoot(): TreeNode; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type TreeNode { + value: String! + children: [TreeNode!]! + parent: TreeNode + } + + type Query { + getRoot: TreeNode! + }" + `); + }); + + it("handles mutual references between models", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Author { name: string; books: Book[]; } + model Book { title: string; author: Author; } + @query op getAuthor(): Author; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Author { + name: String! + books: [Book!]! + } + + type Book { + title: String! + author: Author! + } + + type Query { + getAuthor: Author! + }" + `); + }); +}); + +describe("e2e: anonymous unions", () => { + it("names anonymous return type union from operation", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Cat { name: string; } + model Dog { breed: string; } + @query op getPet(): Cat | Dog; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "union GetPetUnion = Cat | Dog + + type Cat { + name: String! + } + + type Dog { + breed: String! + } + + type Query { + getPet: GetPetUnion! + }" + `); + }); +}); + +describe("e2e: @compose does not produce false incompatible diagnostics", () => { + it("no diagnostics for @compose with spread properties", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + @graphqlInterface(#{interfaceOnly: true}) + model Node { id: GraphQL.ID; } + + @compose(Node) + model Article { ...Node; title: string; } + + @query op getArticle(): Article; + } + `); + expectDiagnosticEmpty(result.diagnostics); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "interface Node { + id: ID! + } + + type Article implements Node { + id: ID! + title: String! + } + + type Query { + getArticle: Article! + }" + `); + }); + + it("no diagnostics for @compose with multiple interfaces", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + @graphqlInterface(#{interfaceOnly: true}) + model Node { id: GraphQL.ID; } + + @graphqlInterface + model Named { name: string; } + + @compose(Node, Named) + model User { ...Node; ...Named; age: int32; } + + @query op getUser(): User; + } + `); + expectDiagnosticEmpty(result.diagnostics); + }); +}); + +describe("e2e: @operationFields on model used as input warns", () => { + it("warns that operation fields are ignored on input types", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + @query op getUser(id: string): User; + @operationFields(getUser) + model User { id: string; name: string; } + @mutation op createUser(input: User): User; + } + `); + expect(result.graphQLOutput).toContain("getUser(id: String!): User!"); + expect(result.graphQLOutput).not.toMatch(/input UserInput[^}]*getUser/s); + const warnings = result.diagnostics.filter(d => d.severity === "warning"); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings.some(d => d.code === "@typespec/graphql/operation-fields-ignored-on-input")).toBe(true); + }); +}); + +describe("e2e: TypeSpec interface keyword prefixes operations", () => { + it("prefixes operations from interface with interface name", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Board { id: string; name: string; } + + interface BoardOps { + @query getBoard(id: string): Board; + @mutation createBoard(name: string): Board; + } + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Board { + id: String! + name: String! + } + + type Query { + boardOpsGetBoard(id: String!): Board! + } + + type Mutation { + boardOpsCreateBoard(name: String!): Board! + }" + `); + }); +}); + +describe("e2e: visibility filtering", () => { + it("excludes read-only properties from input type", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Board { + @visibility(Lifecycle.Read) + id: string; + + @visibility(Lifecycle.Read) + createdAt: string; + + name: string; + description: string; + } + @query op getBoard(id: string): Board; + @mutation op createBoard(input: Board): Board; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Board { + id: String! + createdAt: String! + name: String! + description: String! + } + + input BoardInput { + name: String! + description: String! + } + + type Query { + getBoard(id: String!): Board! + } + + type Mutation { + createBoard(input: BoardInput!): Board! + }" + `); + }); + + it("excludes create-only properties from output type", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model User { + @visibility(Lifecycle.Read) + id: string; + + @visibility(Lifecycle.Create) + password: string; + + name: string; + } + @query op getUser(id: string): User; + @mutation op createUser(input: User): User; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + } + + input UserInput { + password: String! + name: String! + } + + type Query { + getUser(id: String!): User! + } + + type Mutation { + createUser(input: UserInput!): User! + }" + `); + }); + + it("includes all properties when no visibility decorator is set", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Item { + name: string; + count: int32; + } + @query op getItem(): Item; + @mutation op createItem(input: Item): Item; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Item { + name: String! + count: Int! + } + + input ItemInput { + name: String! + count: Int! + } + + type Query { + getItem: Item! + } + + type Mutation { + createItem(input: ItemInput!): Item! + }" + `); + }); + + it("splits input types when query and mutation have different visible properties", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model User { + @visibility(Lifecycle.Read, Lifecycle.Query) + id: string; + + @visibility(Lifecycle.Create, Lifecycle.Update) + password: string; + + name: string; + } + @query op getUser(filter: User): User; + @mutation op createUser(input: User): User; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type User { + id: String! + name: String! + } + + input UserQueryInput { + id: String! + name: String! + } + + input UserMutationInput { + password: String! + name: String! + } + + type Query { + getUser(filter: UserQueryInput!): User! + } + + type Mutation { + createUser(input: UserMutationInput!): User! + }" + `); + }); + + it("does not split input types when visibility produces same properties", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Item { + name: string; + count: int32; + } + @query op searchItems(filter: Item): Item[]; + @mutation op createItem(input: Item): Item; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "type Item { + name: String! + count: Int! + } + + input ItemInput { + name: String! + count: Int! + } + + type Query { + searchItems(filter: ItemInput!): [Item!]! + } + + type Mutation { + createItem(input: ItemInput!): Item! + }" + `); + }); +}); + +describe("e2e: extends flattening", () => { + it("flattens base model fields into child type", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + scalar DateTime extends utcDateTime; + model Timestamps { createdAt: DateTime; updatedAt: DateTime; } + model AuditedComment extends Timestamps { + commentId: string; + action: string; + } + @query op getAudit(): AuditedComment; + } + `); + expect(result.graphQLOutput).toMatchInlineSnapshot(` + "scalar DateTime + + type Timestamps { + createdAt: DateTime! + updatedAt: DateTime! + } + + type AuditedComment { + createdAt: DateTime! + updatedAt: DateTime! + commentId: String! + action: String! + } + + type Query { + getAudit: AuditedComment! + }" + `); + }); + + it("flattens multi-level inheritance", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Base { id: string; } + model Middle extends Base { name: string; } + model Child extends Middle { age: int32; } + @query op getChild(): Child; + } + `); + expect(result.graphQLOutput).toMatch(/type Child \{[^}]*id: String!/s); + expect(result.graphQLOutput).toMatch(/type Child \{[^}]*name: String!/s); + expect(result.graphQLOutput).toMatch(/type Child \{[^}]*age: Int!/s); + }); + + it("flattens base model fields into input type", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Base { id: string; } + model Child extends Base { name: string; } + @query op getChild(): Child; + @mutation op createChild(input: Child): Child; + } + `); + expect(result.graphQLOutput).toMatch(/input ChildInput[^}]*id: String!/s); + expect(result.graphQLOutput).toMatch(/input ChildInput[^}]*name: String!/s); + }); +}); + +describe("e2e: empty model becomes scalar", () => { + it("empty model referenced as property becomes scalar", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Empty {} + model Outer { name: string; inner: Empty; } + @query op getOuter(): Outer; + } + `); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toContain("scalar Empty"); + expect(result.graphQLOutput).toContain("inner: Empty!"); + }); + + it("visibility-filtered-to-empty model becomes scalar in input", async () => { + const result = await emitSingleSchemaWithDiagnostics(` + @schema namespace Test { + model Inner { + @visibility(Lifecycle.Read) id: string; + @visibility(Lifecycle.Read) createdAt: string; + } + model Outer { name: string; inner: Inner; } + @query op getOuter(): Outer; + @mutation op createOuter(input: Outer): Outer; + } + `); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toContain("scalar InnerInput"); + expect(result.graphQLOutput).toMatch(/input OuterInput[^}]*inner: InnerInput/s); + }); +}); diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts new file mode 100644 index 00000000000..79926221bac --- /dev/null +++ b/packages/graphql/test/emitter.test.ts @@ -0,0 +1,74 @@ +import { expectDiagnosticEmpty } from "@typespec/compiler/testing"; +import { expect, describe, it } from "vitest"; +import { emitSingleSchemaWithDiagnostics } from "./test-host.js"; + +describe("emitter", () => { + it("emits a schema with query operations", async () => { + const code = ` + @schema + namespace TestNamespace { + model Book { + title: string; + pageCount: int32; + } + @query op getBooks(): Book[]; + } + `; + const result = await emitSingleSchemaWithDiagnostics(code, {}); + expectDiagnosticEmpty(result.diagnostics); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toMatch(/type Query \{/); + expect(result.graphQLOutput).toContain("getBooks"); + expect(result.graphQLOutput).toMatch(/type Book \{/); + expect(result.graphQLOutput).toContain("title: String!"); + expect(result.graphQLOutput).toContain("pageCount: Int!"); + }); + + it("emits mutation and subscription root types", async () => { + const code = ` + @schema + namespace TestNamespace { + model Book { title: string; } + @query op getBooks(): Book[]; + @mutation op createBook(title: string): Book; + @subscription op onBookCreated(): Book; + } + `; + const result = await emitSingleSchemaWithDiagnostics(code, {}); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toMatch(/type Query \{/); + expect(result.graphQLOutput).toMatch(/type Mutation \{/); + expect(result.graphQLOutput).toMatch(/type Subscription \{/); + }); + + it("emits enums and scalars referenced by models", async () => { + const code = ` + @schema + namespace TestNamespace { + enum Status { Active, Inactive } + scalar DateTime extends string; + model Book { title: string; status: Status; created: DateTime; } + @query op getBooks(): Book[]; + } + `; + const result = await emitSingleSchemaWithDiagnostics(code, {}); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toMatch(/enum Status \{/); + expect(result.graphQLOutput).toContain("scalar DateTime"); + }); + + it("emits input types for operation parameters", async () => { + const code = ` + @schema + namespace TestNamespace { + model Book { title: string; } + @query op getBooks(): Book[]; + @mutation op createBook(input: Book): Book; + } + `; + const result = await emitSingleSchemaWithDiagnostics(code, {}); + expect(result.graphQLOutput).toBeDefined(); + expect(result.graphQLOutput).toMatch(/type Book \{/); + expect(result.graphQLOutput).toMatch(/input BookInput \{/); + }); +}); diff --git a/packages/graphql/test/interface.test.ts b/packages/graphql/test/interface.test.ts new file mode 100644 index 00000000000..378ebef67c9 --- /dev/null +++ b/packages/graphql/test/interface.test.ts @@ -0,0 +1,216 @@ +import { + expectDiagnosticEmpty, + expectDiagnostics, + expectTypeEquals, + t, +} from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getComposition, isInterface } from "../src/lib/interface.js"; +import { Tester } from "./test-host.js"; + +describe("@graphqlInterface", () => { + it("Marks the model as an interface", async () => { + const { TestModel, program } = await Tester.compile(t.code` + @graphqlInterface + model ${t.model("TestModel")} {} + `); + + expect(isInterface(program, TestModel)).toBe(true); + }); +}); + +describe("@compose", () => { + it("Can compose and store the composition", async () => { + const { TestModel, AnInterface, program } = await Tester.compile(t.code` + @graphqlInterface + model ${t.model("AnInterface")} {} + + @compose(AnInterface) + model ${t.model("TestModel")} {} + `); + const composition = getComposition(program, TestModel); + expect(composition).toBeDefined(); + expect(composition).toHaveLength(1); + expectTypeEquals(composition![0], AnInterface); + }); + + it("Can compose multiple interfaces", async () => { + const { TestModel, FirstInterface, SecondInterface, program } = await Tester.compile(t.code` + @graphqlInterface + model ${t.model("FirstInterface")} {} + @graphqlInterface + model ${t.model("SecondInterface")} {} + + @compose(FirstInterface, SecondInterface) + model ${t.model("TestModel")} {} + `); + + const composition = getComposition(program, TestModel); + expect(composition).toBeDefined(); + expect(composition).toHaveLength(2); + expectTypeEquals(composition![0], FirstInterface); + expectTypeEquals(composition![1], SecondInterface); + }); + + it("Can spread properties from the interface", async () => { + const diagnostics = await Tester.diagnose(` + @graphqlInterface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel { + ...AnInterface; + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("Can extend properties from the interface", async () => { + const diagnostics = await Tester.diagnose(` + @graphqlInterface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel extends AnInterface {} + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("Can copy the interface", async () => { + const diagnostics = await Tester.diagnose(` + @graphqlInterface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel is AnInterface {} + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("Can receive properties from a template", async () => { + const diagnostics = await Tester.diagnose(` + @graphqlInterface model AnInterface { + prop: string; + } + + model Template { + prop: string; + extraProp: ExtraProp; + } + + @compose(AnInterface) + model TestModel { + ...Template; + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("Requires that an implemented model is an Interface", async () => { + const diagnostics = await Tester.diagnose(` + model NotAnInterface {} + + @compose(NotAnInterface) + @test model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/invalid-interface", + message: + "All models used with `@compose` must be marked with `@graphqlInterface`, but NotAnInterface is not.", + }); + }); + + it("Requires that all implemented models are Interfaces", async () => { + const diagnostics = await Tester.diagnose(` + @graphqlInterface model AnInterface {} + model NotAnInterface {} + + @compose(AnInterface, NotAnInterface) + @test model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/invalid-interface", + message: + "All models used with `@compose` must be marked with `@graphqlInterface`, but NotAnInterface is not.", + }); + }); + + it("Allows Interfaces to implement other Interfaces", async () => { + const { AnInterface, AnotherInterface, program } = await Tester.compile(t.code` + @graphqlInterface + model ${t.model("AnotherInterface")} {} + + @compose(AnotherInterface) + @graphqlInterface + model ${t.model("AnInterface")} {} + `); + + const composition = getComposition(program, AnInterface); + expect(composition).toBeDefined(); + expect(composition).toHaveLength(1); + expectTypeEquals(composition![0], AnotherInterface); + }); + + it("Does not allow an interface to implement itself", async () => { + const diagnostics = await Tester.diagnose(` + @compose(AnInterface) + @graphqlInterface + @test model AnInterface {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/circular-interface", + message: "An interface cannot implement itself.", + }); + }); + + it("Requires that all Interface properties are implemented", async () => { + const diagnostics = await Tester.diagnose(` + @graphqlInterface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/missing-interface-property", + message: + "Model must contain property `prop` from `AnInterface` in order to implement it in GraphQL.", + }); + }); + + it("Requires that all Interface properties are compatible", async () => { + const diagnostics = await Tester.diagnose(` + @graphqlInterface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel { + prop: integer; + } + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/incompatible-interface-property", + message: "Property `prop` is incompatible with `AnInterface`.", + }); + }); + + it("Allows additional properties", async () => { + const diagnostics = await Tester.diagnose(` + @graphqlInterface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel { + prop: string; + anotherProp: integer; + } + `); + expectDiagnosticEmpty(diagnostics); + }); +}); diff --git a/packages/graphql/test/lib/naming.test.ts b/packages/graphql/test/lib/naming.test.ts new file mode 100644 index 00000000000..da718ea0f21 --- /dev/null +++ b/packages/graphql/test/lib/naming.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + applyEnumMemberPipeline, + applyFieldNamePipeline, + applyTypeNamePipeline, +} from "../../src/lib/naming.js"; + +describe("naming pipelines", () => { + describe("applyTypeNamePipeline", () => { + const noContext = { isInput: false, isInterface: false }; + + it("PascalCases a snake_case name", () => { + expect(applyTypeNamePipeline("ad_account", noContext)).toBe("AdAccount"); + }); + + it("strips namespace prefix", () => { + expect(applyTypeNamePipeline("Pinterest.API.Board", noContext)).toBe("Board"); + }); + + it("prepends underscore for names starting with digit", () => { + expect(applyTypeNamePipeline("123foo", noContext)).toBe("_123foo"); + }); + + it("handles acronyms in mixed-case names", () => { + // Each letter of the acronym becomes its own word → PascalCase capitalizes each + expect(applyTypeNamePipeline("APIResponse", noContext)).toBe("APIResponse"); + }); + + it("replaces array syntax", () => { + expect(applyTypeNamePipeline("Fruit[]", noContext)).toBe("FruitArray"); + }); + + it("replaces non-word characters with underscore", () => { + expect(applyTypeNamePipeline("user-name", noContext)).toBe("UserName"); + }); + + it("appends Input suffix when isInput is true", () => { + expect(applyTypeNamePipeline("User", { isInput: true, isInterface: false })).toBe( + "UserInput", + ); + }); + + it("does not double-append Input suffix", () => { + expect(applyTypeNamePipeline("UserInput", { isInput: true, isInterface: false })).toBe( + "UserInput", + ); + }); + + it("appends Interface suffix when isInterface is true", () => { + expect(applyTypeNamePipeline("Node", { isInput: false, isInterface: true })).toBe( + "NodeInterface", + ); + }); + + it("does not double-append Interface suffix", () => { + expect(applyTypeNamePipeline("NodeInterface", { isInput: false, isInterface: true })).toBe( + "NodeInterface", + ); + }); + + it("preserves all-caps names", () => { + expect(applyTypeNamePipeline("URL", noContext)).toBe("URL"); + }); + }); + + describe("applyFieldNamePipeline", () => { + it("camelCases a snake_case name", () => { + expect(applyFieldNamePipeline("ad_account_id")).toBe("adAccountId"); + }); + + it("camelCases a SCREAMING_SNAKE name", () => { + expect(applyFieldNamePipeline("FIRST_NAME")).toBe("firstName"); + }); + + it("sanitizes dots in field names", () => { + expect(applyFieldNamePipeline("Namespace.fieldName")).toBe("namespaceFieldName"); + }); + + it("preserves prefix underscore for names starting with digit", () => { + expect(applyFieldNamePipeline("123field")).toBe("_123field"); + }); + }); + + describe("applyEnumMemberPipeline", () => { + it("converts camelCase to CONSTANT_CASE", () => { + expect(applyEnumMemberPipeline("myValue")).toBe("MY_VALUE"); + }); + + it("converts camelCase status to CONSTANT_CASE", () => { + expect(applyEnumMemberPipeline("activeStatus")).toBe("ACTIVE_STATUS"); + }); + + it("preserves already CONSTANT_CASE", () => { + expect(applyEnumMemberPipeline("ALREADY_CONSTANT")).toBe("ALREADY_CONSTANT"); + }); + + it("handles names starting with digits", () => { + expect(applyEnumMemberPipeline("123value")).toBe("_123_VALUE"); + }); + }); +}); diff --git a/packages/graphql/test/lib/template-composition.test.ts b/packages/graphql/test/lib/template-composition.test.ts new file mode 100644 index 00000000000..7d601b22e61 --- /dev/null +++ b/packages/graphql/test/lib/template-composition.test.ts @@ -0,0 +1,107 @@ +import { resolvePath, type Model } from "@typespec/compiler"; +import { createTester, t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { composeTemplateName } from "../../src/lib/template-composition.js"; + +const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { + libraries: [], +}); + +function getReturnType(ns: Record, opName: string): Model { + return ns.operations.get(opName)!.returnType as Model; +} + +describe("composeTemplateName", () => { + it("composes single arg: PaginatedModel → PaginatedModelOfAdAccount", async () => { + const { TestNs } = await Tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model AdAccount { id: string; } + model PaginatedModel { items: T[]; } + op get(): PaginatedModel; + } + `); + + const instance = getReturnType(TestNs, "get"); + expect(composeTemplateName(instance)).toBe("PaginatedModelOfAdAccount"); + }); + + it("composes multiple args joined with And: MyMap → MyMapOfStringAndInt32", async () => { + const { TestNs } = await Tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model MyMap { key: K; value: V; } + op get(): MyMap; + } + `); + + const instance = getReturnType(TestNs, "get"); + expect(composeTemplateName(instance)).toBe("MyMapOfStringAndInt32"); + }); + + it("handles array arg: GetResponse → GetResponseOfFruitArray", async () => { + const { TestNs } = await Tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model Fruit { name: string; } + model GetResponse { data: T; } + op get(): GetResponse; + } + `); + + const instance = getReturnType(TestNs, "get"); + expect(composeTemplateName(instance)).toBe("GetResponseOfFruitArray"); + }); + + it("handles nested template: Wrapper> → WrapperOfPaginatedModelOfBoard", async () => { + const { TestNs } = await Tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model Board { id: string; } + model PaginatedModel { items: T[]; } + model Wrapper { data: T; } + op get(): Wrapper>; + } + `); + + const instance = getReturnType(TestNs, "get"); + expect(composeTemplateName(instance)).toBe("WrapperOfPaginatedModelOfBoard"); + }); + + it("handles deeply nested: A>> → AOfBOfCOfD", async () => { + const { TestNs } = await Tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model D { id: string; } + model C { c: T; } + model B { b: T; } + model A { a: T; } + op get(): A>>; + } + `); + + const instance = getReturnType(TestNs, "get"); + expect(composeTemplateName(instance)).toBe("AOfBOfCOfD"); + }); + + it("strips namespace from args: Response → ResponseOfUser", async () => { + const { TestNs } = await Tester.compile(t.code` + namespace Pinterest.API { + model User { id: string; } + } + namespace ${t.namespace("TestNs")} { + model Response { data: T; } + op get(): Response; + } + `); + + const instance = getReturnType(TestNs, "get"); + expect(composeTemplateName(instance)).toBe("ResponseOfUser"); + }); + + it("returns raw name for non-template types", async () => { + const { TestNs } = await Tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model PlainModel { id: string; } + } + `); + + const model = TestNs.models.get("PlainModel")!; + expect(composeTemplateName(model)).toBe("PlainModel"); + }); +}); diff --git a/packages/graphql/test/lib/type-utils.test.ts b/packages/graphql/test/lib/type-utils.test.ts new file mode 100644 index 00000000000..492c5c2e69d --- /dev/null +++ b/packages/graphql/test/lib/type-utils.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { + getSingleNameWithNamespace, + sanitizeNameForGraphQL, + toEnumMemberName, + toFieldName, + toTypeName, +} from "../../src/lib/type-utils.js"; + +describe("type-utils", () => { + describe("sanitizeNameForGraphQL", () => { + it("replaces special characters with underscores", () => { + expect(sanitizeNameForGraphQL("$Money$")).toBe("_Money_"); + expect(sanitizeNameForGraphQL("My-Name")).toBe("My_Name"); + expect(sanitizeNameForGraphQL("Hello.World")).toBe("Hello_World"); + }); + + it("replaces [] with Array", () => { + expect(sanitizeNameForGraphQL("Item[]")).toBe("ItemArray"); + }); + + it("leaves valid names unchanged", () => { + expect(sanitizeNameForGraphQL("ValidName")).toBe("ValidName"); + expect(sanitizeNameForGraphQL("_underscore")).toBe("_underscore"); + expect(sanitizeNameForGraphQL("name123")).toBe("name123"); + }); + + it("adds prefix for names starting with numbers", () => { + expect(sanitizeNameForGraphQL("123Name")).toBe("_123Name"); + expect(sanitizeNameForGraphQL("1")).toBe("_1"); + }); + + it("handles multiple special characters", () => { + expect(sanitizeNameForGraphQL("$My-Special.Name$")).toBe("_My_Special_Name_"); + }); + + it("handles empty prefix parameter", () => { + expect(sanitizeNameForGraphQL("123Name", "")).toBe("_123Name"); + }); + + it("uses custom prefix for invalid starting character", () => { + expect(sanitizeNameForGraphQL("123Name", "Num")).toBe("Num_123Name"); + }); + }); + + describe("toTypeName", () => { + it("converts to PascalCase", () => { + expect(toTypeName("my_name")).toBe("MyName"); + expect(toTypeName("some-value")).toBe("SomeValue"); + expect(toTypeName("hello_world")).toBe("HelloWorld"); + }); + + it("preserves all-caps acronyms", () => { + expect(toTypeName("API")).toBe("API"); + expect(toTypeName("APIResponse")).toBe("APIResponse"); + expect(toTypeName("myAPIKey")).toBe("MyAPIKey"); + expect(toTypeName("HTTPResponse")).toBe("HTTPResponse"); + }); + + it("handles namespaced names by using only the last part", () => { + expect(toTypeName("MyNamespace.MyType")).toBe("MyType"); + expect(toTypeName("A.B.C.MyType")).toBe("MyType"); + }); + + it("sanitizes and converts special characters", () => { + // Special chars become underscores, then PascalCase removes them + expect(toTypeName("my-special$name")).toBe("MySpecialName"); + expect(toTypeName("$invalid")).toBe("Invalid"); + }); + }); + + describe("toEnumMemberName", () => { + it("converts to CONSTANT_CASE", () => { + expect(toEnumMemberName("MyEnum", "myValue")).toBe("MY_VALUE"); + expect(toEnumMemberName("Status", "inProgress")).toBe("IN_PROGRESS"); + }); + + it("handles already uppercase names", () => { + expect(toEnumMemberName("MyEnum", "ACTIVE")).toBe("ACTIVE"); + }); + + it("uses enum name as prefix for invalid starting characters", () => { + expect(toEnumMemberName("Priority", "1High")).toBe("PRIORITY_1_HIGH"); + }); + + it("handles special characters", () => { + expect(toEnumMemberName("MyEnum", "value-with-dashes")).toBe("VALUE_WITH_DASHES"); + }); + + it("separates numbers", () => { + expect(toEnumMemberName("MyEnum", "value123")).toBe("VALUE_123"); + }); + }); + + describe("toFieldName", () => { + it("converts to camelCase", () => { + expect(toFieldName("MyField")).toBe("myField"); + expect(toFieldName("SOME_VALUE")).toBe("someValue"); + }); + + it("handles snake_case", () => { + expect(toFieldName("my_field_name")).toBe("myFieldName"); + }); + + it("handles special characters", () => { + expect(toFieldName("my-field")).toBe("myField"); + expect(toFieldName("$special")).toBe("_special"); + }); + + it("preserves leading underscores", () => { + expect(toFieldName("_private")).toBe("_private"); + expect(toFieldName("__internal")).toBe("__internal"); + }); + }); + + 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 new file mode 100644 index 00000000000..ea4ae208f92 --- /dev/null +++ b/packages/graphql/test/main.tsp @@ -0,0 +1,27 @@ +import "@typespec/graphql"; +using GraphQL; + +@schema(#{ name: "library-schema" }) +namespace MyLibrary { + model Book { + id: string; + title: string; + publicationDate: string; + author: Author; + } + + model Author { + id: string; + name: string; + bio?: string; + books: Book[]; + friend: Author; + } + + enum Genre { + Fiction, + NonFiction, + Mystery, + Fantasy, + } +} diff --git a/packages/graphql/test/mutation-engine/context.test.ts b/packages/graphql/test/mutation-engine/context.test.ts new file mode 100644 index 00000000000..08e4413dfb2 --- /dev/null +++ b/packages/graphql/test/mutation-engine/context.test.ts @@ -0,0 +1,184 @@ +import type { Model } from "@typespec/compiler"; +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { isOneOf } from "../../src/lib/one-of.js"; +import { + createGraphQLMutationEngine, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; + +function createTestEngine(program: Parameters[0]) { + return createGraphQLMutationEngine(program); +} + +describe("GraphQL Mutation Engine - Input/Output Context", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("produces separate mutations for input and output contexts", async () => { + const { Book } = await tester.compile(t.code`model ${t.model("Book")} { title: string; }`); + + const engine = createTestEngine(tester.program); + const inputMutation = engine.mutateModel(Book, GraphQLTypeContext.Input); + const outputMutation = engine.mutateModel(Book, GraphQLTypeContext.Output); + + // Different mutation objects (different cache entries) + expect(inputMutation).not.toBe(outputMutation); + // Both produce valid mutated types + expect(inputMutation.mutatedType.name).toBe("BookInput"); + expect(outputMutation.mutatedType.name).toBe("Book"); + }); + + it("returns cached mutation for same type and context", async () => { + const { Book } = await tester.compile(t.code`model ${t.model("Book")} { title: string; }`); + + const engine = createTestEngine(tester.program); + const first = engine.mutateModel(Book, GraphQLTypeContext.Input); + const second = engine.mutateModel(Book, GraphQLTypeContext.Input); + + expect(first).toBe(second); + }); + + it("exposes typeContext on the mutation", async () => { + const { Book } = await tester.compile(t.code`model ${t.model("Book")} { title: string; }`); + + const engine = createTestEngine(tester.program); + const inputMutation = engine.mutateModel(Book, GraphQLTypeContext.Input); + const outputMutation = engine.mutateModel(Book, GraphQLTypeContext.Output); + + expect(inputMutation.typeContext).toBe(GraphQLTypeContext.Input); + expect(outputMutation.typeContext).toBe(GraphQLTypeContext.Output); + }); +}); + +describe("GraphQL Mutation Engine - Operation Context Propagation", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("mutates operation parameters with input context", async () => { + const { Book, createBook } = await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("createBook")}(input: Book): void; + `, + ); + + const engine = createTestEngine(tester.program); + engine.mutateOperation(createBook); + + // The model should now be cached under the input key + const inputMutation = engine.mutateModel(Book, GraphQLTypeContext.Input); + expect(inputMutation.typeContext).toBe(GraphQLTypeContext.Input); + }); + + it("mutates operation return type with output context", async () => { + const { Book, getBook } = await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("getBook")}(): Book; + `, + ); + + const engine = createTestEngine(tester.program); + engine.mutateOperation(getBook); + + // The model should now be cached under the output key + const outputMutation = engine.mutateModel(Book, GraphQLTypeContext.Output); + expect(outputMutation.typeContext).toBe(GraphQLTypeContext.Output); + }); + + it("creates separate variants when model is used as both param and return", async () => { + const { Book, createBook } = await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("createBook")}(input: Book): Book; + `, + ); + + const engine = createTestEngine(tester.program); + engine.mutateOperation(createBook); + + const inputMutation = engine.mutateModel(Book, GraphQLTypeContext.Input); + const outputMutation = engine.mutateModel(Book, GraphQLTypeContext.Output); + + expect(inputMutation).not.toBe(outputMutation); + expect(inputMutation.typeContext).toBe(GraphQLTypeContext.Input); + expect(outputMutation.typeContext).toBe(GraphQLTypeContext.Output); + }); + + it("propagates input context to nested models", async () => { + const { Author, createBook } = await tester.compile( + t.code` + model ${t.model("Author")} { name: string; } + model ${t.model("Book")} { title: string; author: Author; } + op ${t.op("createBook")}(input: Book): void; + `, + ); + + const engine = createTestEngine(tester.program); + engine.mutateOperation(createBook); + + // Author should also be cached under input context via Book's property + const authorInput = engine.mutateModel(Author, GraphQLTypeContext.Input); + expect(authorInput.typeContext).toBe(GraphQLTypeContext.Input); + }); + + it("propagates output context to nested models", async () => { + const { Author, getBook } = await tester.compile( + t.code` + model ${t.model("Author")} { name: string; } + model ${t.model("Book")} { title: string; author: Author; } + op ${t.op("getBook")}(): Book; + `, + ); + + const engine = createTestEngine(tester.program); + engine.mutateOperation(getBook); + + const authorOutput = engine.mutateModel(Author, GraphQLTypeContext.Output); + expect(authorOutput.typeContext).toBe(GraphQLTypeContext.Output); + }); + + it("replaces union parameter with oneOf model via operation mutation", async () => { + const { Pet, createPet } = 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; } + op ${t.op("createPet")}(input: Pet): void; + `, + ); + + const engine = createTestEngine(tester.program); + engine.mutateOperation(createPet); + + // The union should be cached under input context and replaced with a oneOf model + const unionMutation = engine.mutateUnion(Pet, GraphQLTypeContext.Input); + expect(unionMutation.mutatedType.kind).toBe("Model"); + expect(unionMutation.mutatedType.name).toBe("PetInput"); + expect(isOneOf(unionMutation.mutatedType as Model)).toBe(true); + }); + + it("keeps union return type as union via operation mutation", async () => { + const { Pet, getPet } = 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; } + op ${t.op("getPet")}(): Pet; + `, + ); + + const engine = createTestEngine(tester.program); + engine.mutateOperation(getPet); + + // The union in output context stays a union (not replaced) + const unionMutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + expect(unionMutation.mutatedType.kind).toBe("Union"); + }); +}); diff --git a/packages/graphql/test/mutation-engine/enums.test.ts b/packages/graphql/test/mutation-engine/enums.test.ts new file mode 100644 index 00000000000..5cd9eee88d9 --- /dev/null +++ b/packages/graphql/test/mutation-engine/enums.test.ts @@ -0,0 +1,94 @@ +import type { EnumMember } from "@typespec/compiler"; +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; + +function createTestEngine(program: Parameters[0]) { + return createGraphQLMutationEngine(program); +} + +describe("GraphQL Mutation Engine - Enums", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid enum names alone", async () => { + const { ValidEnum } = await tester.compile( + t.code`enum ${t.enum("ValidEnum")} { + Value + }`, + ); + + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(ValidEnum).mutatedType; + + expect(mutated.name).toBe("ValidEnum"); + }); + + it("renames invalid enum names", async () => { + await tester.compile(` + enum \`$Invalid$\` { + Value + } + `); + + const InvalidEnum = tester.program.getGlobalNamespaceType().enums.get("$Invalid$")!; + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(InvalidEnum).mutatedType; + + expect(mutated.name).toBe("_Invalid"); + }); + + it("processes enum members through sanitization", async () => { + const { MyEnum } = await tester.compile( + t.code`enum ${t.enum("MyEnum")} { + ValidMember + }`, + ); + + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(MyEnum).mutatedType; + + expect(mutated.name).toBe("MyEnum"); + expect(mutated.members.has("VALID_MEMBER")).toBe(true); + }); +}); + +describe("GraphQL Mutation Engine - Enum Members", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid enum member names alone", async () => { + const { MyEnum } = await tester.compile( + t.code`enum ${t.enum("MyEnum")} { + ${t.enumMember("ValidMember")} + }`, + ); + + // Mutate the enum and check the member via the enum's mutation + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(MyEnum).mutatedType; + const member = mutated.members.get("VALID_MEMBER"); + + expect(member?.name).toBe("VALID_MEMBER"); + }); + + it("renames invalid enum member names", async () => { + const { MyEnum } = await tester.compile( + t.code`enum ${t.enum("MyEnum")} { + \`$Value$\` + }`, + ); + + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(MyEnum).mutatedType; + + // Check that the member was renamed in the mutated enum + const member = Array.from(mutated.members.values())[0] as EnumMember; + expect(member.name).toBe("_VALUE"); + }); +}); diff --git a/packages/graphql/test/mutation-engine/models.test.ts b/packages/graphql/test/mutation-engine/models.test.ts new file mode 100644 index 00000000000..5b20f3943ed --- /dev/null +++ b/packages/graphql/test/mutation-engine/models.test.ts @@ -0,0 +1,255 @@ +import { isArrayModelType, type Model } from "@typespec/compiler"; +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + createGraphQLMutationEngine, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; + +function createTestEngine(program: Parameters[0]) { + return createGraphQLMutationEngine(program); +} + +describe("GraphQL Mutation Engine - Models", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid model names alone", async () => { + const { ValidModel } = await tester.compile(t.code`model ${t.model("ValidModel")} { }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(ValidModel, GraphQLTypeContext.Output); + + expect(mutation.mutatedType.name).toBe("ValidModel"); + }); + + it("renames invalid model names", async () => { + await tester.compile(`model \`$Invalid$\` { x: string; }`); + + const InvalidModel = tester.program.getGlobalNamespaceType().models.get("$Invalid$")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(InvalidModel, GraphQLTypeContext.Output); + + expect(mutation.mutatedType.name).toBe("_Invalid"); + }); + + it("processes model properties through sanitization", async () => { + const { TestModel } = await tester.compile( + t.code`model ${t.model("TestModel")} { validProp: string }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(TestModel, GraphQLTypeContext.Output); + + expect(mutation.mutatedType.name).toBe("TestModel"); + expect(mutation.mutatedType.properties.has("validProp")).toBe(true); + }); +}); + +describe("GraphQL Mutation Engine - Record-to-Scalar", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("replaces named Record model with a scalar", async () => { + const { Metadata } = await tester.compile( + t.code`model ${t.model("Metadata")} is Record;`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Metadata, GraphQLTypeContext.Output); + + expect(mutation.mutationNode.isReplaced).toBe(true); + const resolved = mutation.mutationNode.replacementNode!.mutatedType; + expect(resolved).toHaveProperty("kind", "Scalar"); + expect(resolved).toHaveProperty("name", "Metadata"); + }); + + it("produces same scalar name for Record in both input and output contexts", async () => { + const { Metadata } = await tester.compile( + t.code`model ${t.model("Metadata")} is Record;`, + ); + + const engine = createTestEngine(tester.program); + const outputMutation = engine.mutateModel(Metadata, GraphQLTypeContext.Output); + const inputMutation = engine.mutateModel(Metadata, GraphQLTypeContext.Input); + + const outputScalar = outputMutation.mutationNode.replacementNode!.mutatedType; + const inputScalar = inputMutation.mutationNode.replacementNode!.mutatedType; + + // Both should produce the same scalar name - no Input suffix for Records + expect(outputScalar).toHaveProperty("name", "Metadata"); + expect(inputScalar).toHaveProperty("name", "Metadata"); + }); + + it("replaces Record model with scalar even through T | null unwrap", async () => { + const { Foo } = await tester.compile( + t.code` + model ${t.model("Metadata")} is Record; + model ${t.model("Foo")} { data: Metadata | null; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); + + const dataProp = mutation.mutatedType.properties.get("data")!; + // After T|null unwrap + Record mutation, should be a Scalar + expect(dataProp.type).toHaveProperty("kind", "Scalar"); + expect(dataProp.type).toHaveProperty("name", "Metadata"); + }); + + it("does not replace Record model that has named properties", async () => { + const { Config } = await tester.compile( + t.code`model ${t.model("Config")} { debug: boolean; ...Record; }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Config, GraphQLTypeContext.Output); + + expect(mutation.mutationNode.isReplaced).toBe(false); + expect(mutation.mutatedType.kind).toBe("Model"); + expect(mutation.mutatedType.name).toBe("Config"); + }); +}); + +describe("GraphQL Mutation Engine - Inner Nullable Array Fix", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("unwraps inner nullable union in array element for (T | null)[] | null", async () => { + const { Foo } = await tester.compile( + t.code`model ${t.model("Foo")} { tags: (string | null)[] | null; }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); + + const tagsProp = mutation.mutatedType.properties.get("tags")!; + expect(tagsProp.type.kind).toBe("Model"); + // The array's indexer value should be the unwrapped scalar, not a T | null union + const arrayModel = tagsProp.type as Model; + expect(isArrayModelType(arrayModel)).toBe(true); + expect(arrayModel.indexer!.value.kind).toBe("Scalar"); + }); +}); + +describe("GraphQL Mutation Engine - Model Properties", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid property names alone", async () => { + const { M } = await tester.compile( + t.code`model ${t.model("M")} { ${t.modelProperty("prop")}: string }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(M, GraphQLTypeContext.Output); + const prop = mutation.mutatedType.properties.get("prop"); + + expect(prop?.name).toBe("prop"); + }); + + it("renames invalid property names", async () => { + const { M } = await tester.compile(t.code`model ${t.model("M")} { \`$prop$\`: string }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(M, GraphQLTypeContext.Output); + + // Check that the property was renamed in the mutated model + expect(mutation.mutatedType.properties.has("_prop")).toBe(true); + expect(mutation.mutatedType.properties.has("$prop$")).toBe(false); + }); +}); + +describe("GraphQL Mutation Engine - Edge Cases", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("handles model with multiple invalid properties", async () => { + const { M } = await tester.compile( + t.code`model ${t.model("M")} { + \`$prop1$\`: string; + \`prop-2\`: int32; + \`prop.3\`: boolean; + }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(M, GraphQLTypeContext.Output); + const mutated = mutation.mutatedType; + + expect(mutated.properties.has("_prop1")).toBe(true); + expect(mutated.properties.has("prop_2")).toBe(true); + expect(mutated.properties.has("prop_3")).toBe(true); + expect(mutated.properties.has("$prop1$")).toBe(false); + expect(mutated.properties.has("prop-2")).toBe(false); + expect(mutated.properties.has("prop.3")).toBe(false); + }); + + it("handles enum with multiple invalid members", async () => { + const { E } = await tester.compile( + t.code`enum ${t.enum("E")} { + \`$val1$\`, + \`val-2\`, + \`val.3\` + }`, + ); + + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(E).mutatedType; + + expect(mutated.members.has("_VAL_1")).toBe(true); + expect(mutated.members.has("VAL_2")).toBe(true); + expect(mutated.members.has("VAL_3")).toBe(true); + }); + + it("preserves valid underscore-prefixed names", async () => { + const { _ValidName } = await tester.compile(t.code`model ${t.model("_ValidName")} { }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(_ValidName, GraphQLTypeContext.Output); + + expect(mutation.mutatedType.name).toBe("_ValidName"); + }); + + it("preserves names with numbers in the middle", async () => { + const { Model123 } = await tester.compile(t.code`model ${t.model("Model123")} { }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Model123, GraphQLTypeContext.Output); + + expect(mutation.mutatedType.name).toBe("Model123"); + }); + + it("handles property names starting with numbers", async () => { + const { M } = await tester.compile(t.code`model ${t.model("M")} { \`123prop\`: string; }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(M, GraphQLTypeContext.Output); + const mutated = mutation.mutatedType; + + expect(mutated.properties.has("_123prop")).toBe(true); + expect(mutated.properties.has("123prop")).toBe(false); + }); + + it("handles enum member names starting with numbers", async () => { + const { E } = await tester.compile(t.code`enum ${t.enum("E")} { \`123value\` }`); + + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(E).mutatedType; + + expect(mutated.members.has("_123_VALUE")).toBe(true); + expect(mutated.members.has("123value")).toBe(false); + }); +}); diff --git a/packages/graphql/test/mutation-engine/naming-integration.test.ts b/packages/graphql/test/mutation-engine/naming-integration.test.ts new file mode 100644 index 00000000000..b92545f2b51 --- /dev/null +++ b/packages/graphql/test/mutation-engine/naming-integration.test.ts @@ -0,0 +1,121 @@ +import type { Model } from "@typespec/compiler"; +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + createGraphQLMutationEngine, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; + +describe("Mutation Engine - Naming Pipelines", () => { + let tester: TesterInstance; + + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + describe("Model naming", () => { + it("PascalCases model names", async () => { + await tester.compile(`model ad_account { id: string; }`); + const model = tester.program.getGlobalNamespaceType().models.get("ad_account")!; + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(model, GraphQLTypeContext.Output); + + expect(mutated.mutatedType.name).toBe("AdAccount"); + }); + + it("appends Input suffix for input context", async () => { + await tester.compile(`model User { id: string; }`); + const model = tester.program.getGlobalNamespaceType().models.get("User")!; + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(model, GraphQLTypeContext.Input); + + expect(mutated.mutatedType.name).toBe("UserInput"); + }); + + it("PascalCases before appending Input suffix", async () => { + await tester.compile(`model ad_account { id: string; }`); + const model = tester.program.getGlobalNamespaceType().models.get("ad_account")!; + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(model, GraphQLTypeContext.Input); + + expect(mutated.mutatedType.name).toBe("AdAccountInput"); + }); + + it("composes template names", async () => { + const { TestNs } = await tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model Board { id: string; } + model PaginatedModel { items: T[]; } + op get(): PaginatedModel; + } + `); + + const op = TestNs.operations.get("get")!; + const templateInstance = op.returnType as Model; + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(templateInstance, GraphQLTypeContext.Output); + + expect(mutated.mutatedType.name).toBe("PaginatedModelOfBoard"); + }); + }); + + describe("ModelProperty naming", () => { + it("camelCases property names", async () => { + await tester.compile(`model Foo { ad_account_id: string; }`); + const model = tester.program.getGlobalNamespaceType().models.get("Foo")!; + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(model, GraphQLTypeContext.Output); + + const propNames = Array.from(mutated.mutatedType.properties.values()).map((p) => p.name); + expect(propNames).toContain("adAccountId"); + }); + }); + + describe("Enum naming", () => { + it("PascalCases enum names", async () => { + await tester.compile(`enum my_status { Active }`); + const enumType = tester.program.getGlobalNamespaceType().enums.get("my_status")!; + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(enumType); + + expect(mutated.mutatedType.name).toBe("MyStatus"); + }); + }); + + describe("EnumMember naming", () => { + it("CONSTANT_CASEs enum member names", async () => { + await tester.compile(`enum Status { activeStatus, inactiveStatus }`); + const enumType = tester.program.getGlobalNamespaceType().enums.get("Status")!; + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(enumType); + + const memberNames = Array.from(mutated.mutatedType.members.values()).map((m) => m.name); + expect(memberNames).toContain("ACTIVE_STATUS"); + expect(memberNames).toContain("INACTIVE_STATUS"); + }); + }); + + describe("Operation naming", () => { + it("camelCases operation names", async () => { + const { TestNs } = await tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + op get_user(): string; + } + `); + + const op = TestNs.operations.get("get_user")!; + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateOperation(op); + + expect(mutated.mutatedType.name).toBe("getUser"); + }); + }); +}); diff --git a/packages/graphql/test/mutation-engine/operations.test.ts b/packages/graphql/test/mutation-engine/operations.test.ts new file mode 100644 index 00000000000..3d498b063e1 --- /dev/null +++ b/packages/graphql/test/mutation-engine/operations.test.ts @@ -0,0 +1,77 @@ +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { isNullable } from "../../src/lib/nullable.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; + +function createTestEngine(program: Parameters[0]) { + return createGraphQLMutationEngine(program); +} + +describe("GraphQL Mutation Engine - Operations", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid operation names alone", async () => { + const { ValidOp } = await tester.compile(t.code`op ${t.op("ValidOp")}(): void;`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(ValidOp); + + expect(mutation.mutatedType.name).toBe("validOp"); + }); + + it("renames invalid operation names", async () => { + await tester.compile(`op \`$Do$\`(): void;`); + + const DoOp = tester.program.getGlobalNamespaceType().operations.get("$Do$")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(DoOp); + + expect(mutation.mutatedType.name).toBe("_do"); + }); + + it("renames operation names with hyphens", async () => { + await tester.compile(`op \`get-data\`(): void;`); + + const GetDataOp = tester.program.getGlobalNamespaceType().operations.get("get-data")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(GetDataOp); + + expect(mutation.mutatedType.name).toBe("getData"); + }); + + it("marks operation as nullable when return type is T | null", async () => { + const { getUser } = await tester.compile( + t.code` + model ${t.model("User")} { name: string; } + op ${t.op("getUser")}(): User | null; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(getUser); + + // The return type should be unwrapped to the inner type + expect(mutation.mutatedType.returnType.kind).toBe("Model"); + // The operation itself should be marked nullable + expect(isNullable(mutation.mutatedType)).toBe(true); + }); + + it("does not mark operation as nullable when return type is non-null", async () => { + const { getUser } = await tester.compile( + t.code` + model ${t.model("User")} { name: string; } + op ${t.op("getUser")}(): User; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(getUser); + + expect(mutation.mutatedType.returnType.kind).toBe("Model"); + expect(isNullable(mutation.mutatedType)).toBe(false); + }); +}); diff --git a/packages/graphql/test/mutation-engine/print-type.test.ts b/packages/graphql/test/mutation-engine/print-type.test.ts new file mode 100644 index 00000000000..1003ae83cee --- /dev/null +++ b/packages/graphql/test/mutation-engine/print-type.test.ts @@ -0,0 +1,107 @@ +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + createGraphQLMutationEngine, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { printMutatedType } from "../../src/mutation-engine/print-type.js"; +import { Tester } from "../test-host.js"; + +describe("printMutatedType", () => { + let tester: TesterInstance; + + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("required string → String!", async () => { + const { Foo } = await tester.compile(t.code`model ${t.model("Foo")} { name: string; }`); + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); + const prop = mutated.mutatedType.properties.get("name")!; + expect(printMutatedType(prop)).toBe("String!"); + }); + + it("optional string → String", async () => { + const { Foo } = await tester.compile(t.code`model ${t.model("Foo")} { name?: string; }`); + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); + const prop = mutated.mutatedType.properties.get("name")!; + expect(printMutatedType(prop)).toBe("String"); + }); + + it("string | null → String", async () => { + const { Foo } = await tester.compile(t.code`model ${t.model("Foo")} { name: string | null; }`); + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); + const prop = mutated.mutatedType.properties.get("name")!; + expect(printMutatedType(prop)).toBe("String"); + }); + + it("required string[] → [String!]!", async () => { + const { Foo } = await tester.compile(t.code`model ${t.model("Foo")} { tags: string[]; }`); + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); + const prop = mutated.mutatedType.properties.get("tags")!; + expect(printMutatedType(prop)).toBe("[String!]!"); + }); + + it("optional string[] → [String!]", async () => { + const { Foo } = await tester.compile(t.code`model ${t.model("Foo")} { tags?: string[]; }`); + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); + const prop = mutated.mutatedType.properties.get("tags")!; + expect(printMutatedType(prop)).toBe("[String!]"); + }); + + it("(string | null)[] → [String]!", async () => { + const { Foo } = await tester.compile( + t.code`model ${t.model("Foo")} { tags: (string | null)[]; }`, + ); + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); + const prop = mutated.mutatedType.properties.get("tags")!; + expect(printMutatedType(prop)).toBe("[String]!"); + }); + + it("string[] | null → [String!]", async () => { + const { Foo } = await tester.compile( + t.code`model ${t.model("Foo")} { tags: string[] | null; }`, + ); + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); + const prop = mutated.mutatedType.properties.get("tags")!; + expect(printMutatedType(prop)).toBe("[String!]"); + }); + + it("(string | null)[] | null → [String]", async () => { + const { Foo } = await tester.compile( + t.code`model ${t.model("Foo")} { tags: (string | null)[] | null; }`, + ); + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); + const prop = mutated.mutatedType.properties.get("tags")!; + expect(printMutatedType(prop)).toBe("[String]"); + }); + + it("required model type → ModelName!", async () => { + const { Foo } = await tester.compile( + t.code` + model ${t.model("Bar")} { id: string; } + model ${t.model("Foo")} { bar: Bar; } + `, + ); + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); + const prop = mutated.mutatedType.properties.get("bar")!; + expect(printMutatedType(prop)).toBe("Bar!"); + }); + + it("required int32 → Int!", async () => { + const { Foo } = await tester.compile(t.code`model ${t.model("Foo")} { count: int32; }`); + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); + const prop = mutated.mutatedType.properties.get("count")!; + expect(printMutatedType(prop)).toBe("Int!"); + }); +}); diff --git a/packages/graphql/test/mutation-engine/scalars.test.ts b/packages/graphql/test/mutation-engine/scalars.test.ts new file mode 100644 index 00000000000..6e66d9a0ce2 --- /dev/null +++ b/packages/graphql/test/mutation-engine/scalars.test.ts @@ -0,0 +1,193 @@ +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { getSpecifiedBy } from "../../src/lib/specified-by.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; + +function createTestEngine(program: Parameters[0]) { + return createGraphQLMutationEngine(program); +} + +describe("GraphQL Mutation Engine - Scalars", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid scalar names alone", async () => { + const { ValidScalar } = await tester.compile( + t.code`scalar ${t.scalar("ValidScalar")} extends string;`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(ValidScalar); + + expect(mutation.mutatedType.name).toBe("ValidScalar"); + }); + + it("renames invalid scalar names", async () => { + await tester.compile(t.code`scalar ${t.scalar("$Invalid$")} extends string;`); + + const InvalidScalar = tester.program.getGlobalNamespaceType().scalars.get("$Invalid$")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(InvalidScalar); + + expect(mutation.mutatedType.name).toBe("_Invalid"); + }); + + it("has no @specifiedBy when decorator is not applied", async () => { + const { MyScalar } = await tester.compile( + t.code`scalar ${t.scalar("MyScalar")} extends string;`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(MyScalar); + + expect(getSpecifiedBy(tester.program, mutation.mutatedType)).toBeUndefined(); + }); + + it("applies @specifiedBy from decorator to mutated scalar", async () => { + const { MyScalar } = await tester.compile( + t.code` + @specifiedBy("https://example.com/my-scalar-spec") + scalar ${t.scalar("MyScalar")} extends string; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(MyScalar); + + expect(getSpecifiedBy(tester.program, mutation.mutatedType)).toBe( + "https://example.com/my-scalar-spec", + ); + }); + + it("inherits @specifiedBy from mapped ancestor via extends chain", async () => { + const { MyDate } = await tester.compile( + t.code` + @encode("rfc3339") + scalar ${t.scalar("MyDate")} extends utcDateTime; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(MyDate); + + // User-defined name is preserved (sanitized), not replaced with mapping's graphqlName + expect(mutation.mutatedType.name).toBe("MyDate"); + // @specifiedBy inherited from utcDateTime's rfc3339 mapping + expect(getSpecifiedBy(tester.program, mutation.mutatedType)).toBe( + "https://scalars.graphql.org/chillicream/date-time.html", + ); + }); + + it("strips baseScalar from user-defined scalars", async () => { + const { MyScalar } = await tester.compile( + t.code`scalar ${t.scalar("MyScalar")} extends string;`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(MyScalar); + + expect(mutation.mutatedType.baseScalar).toBeUndefined(); + }); + + it("explicit @specifiedBy wins over inherited mapping", async () => { + const { MyDate } = await tester.compile( + t.code` + @encode("rfc3339") + @specifiedBy("https://example.com/custom-spec") + scalar ${t.scalar("MyDate")} extends utcDateTime; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(MyDate); + + expect(getSpecifiedBy(tester.program, mutation.mutatedType)).toBe( + "https://example.com/custom-spec", + ); + }); + + it("maps scalar extending GraphQL.ID to built-in ID type", async () => { + const { MyId } = await tester.compile(t.code`scalar ${t.scalar("MyId")} extends GraphQL.ID;`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(MyId); + + expect(mutation.mutatedType.name).toBe("ID"); + }); + + it("maps multi-hop extends chain through GraphQL.ID to built-in ID type", async () => { + const { SubId } = await tester.compile( + t.code` + scalar MyId extends GraphQL.ID; + scalar ${t.scalar("SubId")} extends MyId; + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(SubId); + + expect(mutation.mutatedType.name).toBe("ID"); + }); + + it("does not rename builtin std scalars even when they inherit a mapping", async () => { + // float32 inherits a mapping via float → numeric → "Numeric", but it's a + // GraphQL builtin (maps to Float) and must never be renamed. + const { M } = await tester.compile(t.code`model ${t.model("M")} { value: float32; }`); + + const engine = createTestEngine(tester.program); + const float32Scalar = M.properties.get("value")!.type; + expect(float32Scalar.kind).toBe("Scalar"); + const mutation = engine.mutateScalar(float32Scalar as any); + + expect(mutation.mutatedType.name).toBe("float32"); + }); + + it("does not rename float64 builtin scalar", async () => { + const { M } = await tester.compile(t.code`model ${t.model("M")} { value: float64; }`); + + const engine = createTestEngine(tester.program); + const float64Scalar = M.properties.get("value")!.type; + expect(float64Scalar.kind).toBe("Scalar"); + const mutation = engine.mutateScalar(float64Scalar as any); + + expect(mutation.mutatedType.name).toBe("float64"); + }); + + it("does not rename int32 builtin scalar", async () => { + const { M } = await tester.compile(t.code`model ${t.model("M")} { count: int32; }`); + + const engine = createTestEngine(tester.program); + const int32Scalar = M.properties.get("count")!.type; + expect(int32Scalar.kind).toBe("Scalar"); + const mutation = engine.mutateScalar(int32Scalar as any); + + expect(mutation.mutatedType.name).toBe("int32"); + }); + + it("still renames mapped non-builtin std scalars like int64", async () => { + const { M } = await tester.compile(t.code`model ${t.model("M")} { big: int64; }`); + + const engine = createTestEngine(tester.program); + const int64Scalar = M.properties.get("big")!.type; + expect(int64Scalar.kind).toBe("Scalar"); + const mutation = engine.mutateScalar(int64Scalar as any); + + expect(mutation.mutatedType.name).toBe("Long"); + }); + + it("warns when user-defined scalar collides with GraphQL built-in name", async () => { + const { Float } = await tester.compile(t.code`scalar ${t.scalar("Float")} extends string;`); + + const engine = createTestEngine(tester.program); + engine.mutateScalar(Float); + + const warnings = tester.program.diagnostics.filter( + (d) => d.code === "@typespec/graphql/graphql-builtin-scalar-collision", + ); + expect(warnings.length).toBe(1); + expect(warnings[0].message).toContain("Float"); + }); +}); diff --git a/packages/graphql/test/mutation-engine/schema-mutator.test.ts b/packages/graphql/test/mutation-engine/schema-mutator.test.ts new file mode 100644 index 00000000000..e86179c2e9e --- /dev/null +++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts @@ -0,0 +1,292 @@ +import { Model } from "@typespec/compiler"; +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { isInputType } from "../../src/lib/input-type.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { mutateSchema } from "../../src/mutation-engine/schema-mutator.js"; +import { resolveTypeUsage } from "../../src/type-usage.js"; +import { Tester } from "../test-host.js"; + +describe("mutateSchema", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("produces a TypeGraph with mutated models", async () => { + await tester.compile(` + model ad_account { id: int32; } + op getAccount(): ad_account; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.models.has("AdAccount")).toBe(true); + }); + + it("produces a TypeGraph with mutated operations", async () => { + await tester.compile(` + op get_items(): string; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.operations.has("getItems")).toBe(true); + }); + + it("produces a TypeGraph with mutated enums", async () => { + await tester.compile(` + enum status { active, inactive } + model Foo { s: status; } + op getFoo(): Foo; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.enums.has("Status")).toBe(true); + }); + + it("produces a TypeGraph with mutated unions", async () => { + await tester.compile(` + model Cat { name: string; } + model Dog { breed: string; } + union Pet { cat: Cat; dog: Dog; } + op getPet(): Pet; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.unions.has("Pet")).toBe(true); + }); + + it("skips unreachable types when omitUnreachableTypes is true", async () => { + await tester.compile(` + model Reachable { x: int32; } + model Unreachable { y: string; } + op getReachable(): Reachable; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, true); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.models.has("Reachable")).toBe(true); + expect(typeGraph.globalNamespace.models.has("Unreachable")).toBe(false); + }); + + it("includes all declared types when omitUnreachableTypes is false", async () => { + await tester.compile(` + model Reachable { x: int32; } + model Unreachable { y: string; } + op getReachable(): Reachable; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.models.has("Reachable")).toBe(true); + expect(typeGraph.globalNamespace.models.has("Unreachable")).toBe(true); + }); + + it("T | null unions do not appear in the TypeGraph (engine replaces with inner type)", async () => { + await tester.compile(` + union MaybeString { string, null } + model Foo { x: int32; } + op getFoo(): Foo; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // MaybeString is T | null — should NOT appear as a union in the TypeGraph + expect(typeGraph.globalNamespace.unions.has("MaybeString")).toBe(false); + }); + + it("includes wrapper models from union scalar variants", async () => { + await tester.compile(` + model Cat { name: string; } + union Mixed { cat: Cat; text: string; num: int32; } + op getMixed(): Mixed; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Scalar variants get wrapper models registered in the TypeGraph + expect(typeGraph.globalNamespace.models.has("MixedTextUnionVariant")).toBe(true); + expect(typeGraph.globalNamespace.models.has("MixedNumUnionVariant")).toBe(true); + }); + + it("produces Input variant for models used as operation parameters", async () => { + await tester.compile(` + model Book { title: string; } + op getBooks(): Book[]; + op createBook(input: Book): Book; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Book is used as both output (return) and input (parameter), + // so both variants should appear in the TypeGraph + expect(typeGraph.globalNamespace.models.has("Book")).toBe(true); + expect(typeGraph.globalNamespace.models.has("BookInput")).toBe(true); + }); + + it("does not produce Input variant for output-only models", async () => { + await tester.compile(` + model Book { title: string; } + op getBooks(): Book[]; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + expect(typeGraph.globalNamespace.models.has("Book")).toBe(true); + expect(typeGraph.globalNamespace.models.has("BookInput")).toBe(false); + }); + + it("does not produce Output variant for input-only models", async () => { + await tester.compile(` + model Book { title: string; } + model Payload { title: string; } + op getBooks(): Book[]; + op createBook(input: Payload): Book; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, true); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Payload is only used as input — should only appear as Input variant (PayloadInput) + expect(typeGraph.globalNamespace.models.has("PayloadInput")).toBe(true); + // Should NOT have an Output variant + expect(typeGraph.globalNamespace.models.has("Payload")).toBe(false); + }); + + it("marks input models with isInputType decorator", async () => { + await tester.compile(` + model Book { title: string; } + op getBooks(): Book[]; + op createBook(input: Book): Book; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + const bookOutput = typeGraph.globalNamespace.models.get("Book")!; + const bookInput = typeGraph.globalNamespace.models.get("BookInput")!; + + expect(isInputType(bookOutput)).toBe(false); + expect(isInputType(bookInput)).toBe(true); + }); + + it("mutateDecoratorTypeArgs does not corrupt source type decorator args", async () => { + const { Cat } = await tester.compile( + t.code` + @graphqlInterface model Animal { name: string; } + @compose(Animal) + model ${t.model("Cat")} { name: string; breed: string; } + op getCat(): Cat; + op createCat(input: Cat): Cat; + `, + ); + + const sourceComposeArg = (Cat as Model).decorators.find( + (d) => d.decorator.name === "$compose", + )?.args[0]; + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, true); + const engine = createGraphQLMutationEngine(tester.program); + mutateSchema(tester.program, engine, ns, typeUsage); + + // Source type's decorator args must not be modified by mutation + expect((sourceComposeArg!.value as any).name).toBe("Animal"); + }); + + it("interfaceOnly @graphqlInterface model used as output does not produce name collision", async () => { + await tester.compile(` + @graphqlInterface(#{interfaceOnly: true}) model Node { id: string; } + op getNode(): Node; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, true); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Exclusive interface: only Interface variant emitted (no suffix → "Node") + // Should NOT also emit an Output variant (which would also be "Node" → collision) + expect(typeGraph.globalNamespace.models.has("Node")).toBe(true); + const collisions = tester.program.diagnostics.filter( + (d) => d.code === "@typespec/graphql/type-name-collision", + ); + expect(collisions.length).toBe(0); + }); + + it("reports diagnostic when two types produce the same GraphQL name", async () => { + await tester.compileAndDiagnose(` + model BookInput { x: int32; } + model Book { title: string; } + op getBooks(): Book[]; + op createBook(input: Book): Book; + `); + + // Book used as input → Input mutation → "BookInput" + // BookInput declared explicitly → Output mutation → "BookInput" + // This should produce a collision diagnostic + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + mutateSchema(tester.program, engine, ns, typeUsage); + + const collisions = tester.program.diagnostics.filter( + (d) => d.code === "@typespec/graphql/type-name-collision", + ); + expect(collisions.length).toBeGreaterThan(0); + }); + + it("skips array models (they are list types, not object types)", async () => { + await tester.compile(` + model Item { name: string; } + op getItems(): Item[]; + `); + + const ns = tester.program.getGlobalNamespaceType(); + const typeUsage = resolveTypeUsage(tester.program, ns, false); + const engine = createGraphQLMutationEngine(tester.program); + const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage); + + // Item should be in the graph, but the Array model should NOT + expect(typeGraph.globalNamespace.models.has("Item")).toBe(true); + const modelNames = [...typeGraph.globalNamespace.models.keys()]; + expect(modelNames.every((n) => !n.includes("Array"))).toBe(true); + }); +}); diff --git a/packages/graphql/test/mutation-engine/type-graph.test.ts b/packages/graphql/test/mutation-engine/type-graph.test.ts new file mode 100644 index 00000000000..9be4727bd77 --- /dev/null +++ b/packages/graphql/test/mutation-engine/type-graph.test.ts @@ -0,0 +1,178 @@ +import { navigateTypesInNamespace, resolvePath } from "@typespec/compiler"; +import { createTester, t } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { beforeEach, describe, expect, it } from "vitest"; +import { buildTypeGraph } from "../../src/mutation-engine/type-graph.js"; + +const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { + libraries: [], +}); + +describe("buildTypeGraph", () => { + let tester: TesterInstance; + + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("produces a namespace containing the given types", async () => { + const { TestNs } = await tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model Foo { name: string; } + } + `); + + const tk = $(tester.program); + const foo = TestNs.models.get("Foo")!; + const graph = buildTypeGraph(tester.program, tk, [foo]); + + expect(graph.globalNamespace.models.get("Foo")).toBe(foo); + }); + + it("sets .namespace on all types to the new namespace", async () => { + const { TestNs } = await tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model Foo { name: string; } + model Bar { value: int32; } + } + `); + + const tk = $(tester.program); + const foo = TestNs.models.get("Foo")!; + const bar = TestNs.models.get("Bar")!; + const graph = buildTypeGraph(tester.program, tk, [foo, bar]); + + expect(foo.namespace).toBe(graph.globalNamespace); + expect(bar.namespace).toBe(graph.globalNamespace); + }); + + it("works with navigateTypesInNamespace", async () => { + const { TestNs } = await tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model Foo { name: string; } + model Bar { value: int32; } + } + `); + + const tk = $(tester.program); + const foo = TestNs.models.get("Foo")!; + const bar = TestNs.models.get("Bar")!; + const graph = buildTypeGraph(tester.program, tk, [foo, bar]); + + const visitedModels: string[] = []; + navigateTypesInNamespace(graph.globalNamespace, { + model: (m) => visitedModels.push(m.name), + }); + + expect(visitedModels).toContain("Foo"); + expect(visitedModels).toContain("Bar"); + }); + + it("includes mutated types in the output", async () => { + const { TestNs } = await tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model Foo { name: string; } + } + `); + + const tk = $(tester.program); + const originalFoo = TestNs.models.get("Foo")!; + + const mutatedFoo = tk.type.clone(originalFoo); + mutatedFoo.name = "FooRenamed"; + + const graph = buildTypeGraph(tester.program, tk, [mutatedFoo]); + + expect(graph.globalNamespace.models.get("FooRenamed")).toBe(mutatedFoo); + expect(mutatedFoo.namespace).toBe(graph.globalNamespace); + }); + + it("excludes types not in the output list", async () => { + const { TestNs } = await tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model Foo { name: string; } + model Bar { value: int32; } + } + `); + + const tk = $(tester.program); + const bar = TestNs.models.get("Bar")!; + + const graph = buildTypeGraph(tester.program, tk, [bar]); + + expect(graph.globalNamespace.models.has("Foo")).toBe(false); + expect(graph.globalNamespace.models.has("Bar")).toBe(true); + }); + + it("handles all type kinds", async () => { + const { TestNs } = await tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model Foo { name: string; } + enum Status { Active, Inactive } + union Pet { cat: string, dog: string } + scalar MyId extends string; + op doSomething(): void; + } + `); + + const tk = $(tester.program); + const foo = TestNs.models.get("Foo")!; + const status = TestNs.enums.get("Status")!; + const pet = TestNs.unions.get("Pet")!; + const myId = TestNs.scalars.get("MyId")!; + const doSomething = TestNs.operations.get("doSomething")!; + + const graph = buildTypeGraph(tester.program, tk, [foo, status, pet, myId, doSomething]); + + expect(graph.globalNamespace.models.has("Foo")).toBe(true); + expect(graph.globalNamespace.enums.has("Status")).toBe(true); + expect(graph.globalNamespace.unions.has("Pet")).toBe(true); + expect(graph.globalNamespace.scalars.has("MyId")).toBe(true); + expect(graph.globalNamespace.operations.has("doSomething")).toBe(true); + }); + + it("preserves decorators on types", async () => { + const { TestNs } = await tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + @doc("A foo model") + model Foo { name: string; } + } + `); + + const tk = $(tester.program); + const foo = TestNs.models.get("Foo")!; + const graph = buildTypeGraph(tester.program, tk, [foo]); + + const outputFoo = graph.globalNamespace.models.get("Foo")!; + expect(outputFoo.decorators.length).toBeGreaterThan(0); + }); + + it("supports chained stages", async () => { + const { TestNs } = await tester.compile(t.code` + namespace ${t.namespace("TestNs")} { + model Foo { name: string; } + } + `); + + const tk = $(tester.program); + const originalFoo = TestNs.models.get("Foo")!; + + const fooV1 = tk.type.clone(originalFoo); + fooV1.name = "FooV1"; + const graph1 = buildTypeGraph(tester.program, tk, [fooV1]); + + const stage2Input = graph1.globalNamespace.models.get("FooV1")!; + const fooV2 = tk.type.clone(stage2Input); + fooV2.name = "FooV2"; + const graph2 = buildTypeGraph(tester.program, tk, [fooV2]); + + expect(graph2.globalNamespace.models.has("FooV2")).toBe(true); + expect(graph2.globalNamespace).not.toBe(graph1.globalNamespace); + + const visited: string[] = []; + navigateTypesInNamespace(graph2.globalNamespace, { + model: (m) => visited.push(m.name), + }); + expect(visited).toContain("FooV2"); + }); +}); diff --git a/packages/graphql/test/mutation-engine/unions.test.ts b/packages/graphql/test/mutation-engine/unions.test.ts new file mode 100644 index 00000000000..550abb83b36 --- /dev/null +++ b/packages/graphql/test/mutation-engine/unions.test.ts @@ -0,0 +1,580 @@ +import { getDoc, type Model, type Union } from "@typespec/compiler"; +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { isNullable } from "../../src/lib/nullable.js"; +import { isOneOf } from "../../src/lib/one-of.js"; +import { + createGraphQLMutationEngine, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { printMutatedType } from "../../src/mutation-engine/print-type.js"; +import { Tester } from "../test-host.js"; + +function createTestEngine(program: Parameters[0]) { + return createGraphQLMutationEngine(program); +} + +describe("GraphQL Mutation Engine - Unions", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("replaces nullable scalar union with inner type", async () => { + const { NullableString } = await tester.compile( + t.code`union ${t.union("NullableString")} { string, null }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(NullableString, GraphQLTypeContext.Output); + + // T | null is replaced with the inner type (string scalar) + expect(mutation.mutatedType.kind).toBe("Scalar"); + 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(mutation.mutatedType)).toBe(false); + }); + + it("replaces nullable model union with inner type", async () => { + const { MaybeDog } = await tester.compile( + t.code` + model ${t.model("Dog")} { breed: string; } + union ${t.union("MaybeDog")} { Dog, null } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(MaybeDog, GraphQLTypeContext.Output); + + // Dog | null is replaced with the inner type (Dog model) + expect(mutation.mutatedType.kind).toBe("Model"); + 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(mutation.mutatedType)).toBe(false); + }); + + it("creates wrapper models 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 = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + + // Only the scalar variant (string) should get a wrapper + expect(mutation.wrapperModels).toHaveLength(1); + expect(mutation.wrapperModels[0].name).toBe("MixedTextUnionVariant"); + }); + + it("substitutes wrapper models into union variant types", async () => { + const { Mixed } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + const mutatedUnion = mutation.mutatedType as Union; + + const variants = [...mutatedUnion.variants.values()]; + expect(variants).toHaveLength(2); + + // Model variant points to the mutated model + const catVariant = variants.find((v) => v.name === "cat")!; + expect(catVariant.type.kind).toBe("Model"); + expect((catVariant.type as Model).name).toBe("Cat"); + + // Scalar variant points to the wrapper model, not the raw scalar + const textVariant = variants.find((v) => v.name === "text")!; + expect(textVariant.type.kind).toBe("Model"); + expect((textVariant.type as Model).name).toBe("MixedTextUnionVariant"); + }); + + it("does not create wrappers for model-only unions", 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 = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + + expect(mutation.wrapperModels).toHaveLength(0); + }); + + it("collapses single-scalar-variant union to the scalar type", async () => { + const { Data } = await tester.compile(t.code`union ${t.union("Data")} { text: string; }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Data, GraphQLTypeContext.Output); + + // Single variant → collapsed to the scalar directly (no union or wrapper) + expect(mutation.mutatedType.kind).toBe("Scalar"); + expect(mutation.wrapperModels).toHaveLength(0); + }); + + it("creates wrappers for multiple scalar variants", async () => { + const { Mixed } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; count: int32; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + + expect(mutation.wrapperModels).toHaveLength(2); + const names = mutation.wrapperModels.map((m) => m.name).sort(); + expect(names).toEqual(["MixedCountUnionVariant", "MixedTextUnionVariant"]); + + // All union variants point to Models (originals or wrappers) + const mutatedUnion = mutation.mutatedType as Union; + const variants = [...mutatedUnion.variants.values()]; + for (const variant of variants) { + expect(variant.type.kind).toBe("Model"); + } + }); + + it("names anonymous return type union as OperationUnion", async () => { + await tester.compile(` + model Foo { x: int32; } + model Bar { y: string; } + op getBaz(): Foo | Bar; + `); + + const getBaz = tester.program.getGlobalNamespaceType().operations.get("getBaz")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(getBaz.returnType as Union, GraphQLTypeContext.Output); + + expect(mutation.mutatedType.kind).toBe("Union"); + expect((mutation.mutatedType as Union).name).toBe("GetBazUnion"); + }); + + it("names anonymous union on model property as ModelPropertyUnion", async () => { + const { Foo } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + model ${t.model("Foo")} { pet: Cat | Dog; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); + + const petProp = mutation.mutatedType.properties.get("pet")!; + expect(petProp.type.kind).toBe("Union"); + expect((petProp.type as Union).name).toBe("FooPetUnion"); + }); + + it("collapses union to single type after flattening deduplicates to one variant", async () => { + const { Outer } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Inner")} { a: Cat; } + union ${t.union("Outer")} { inner: Inner; cat: Cat; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Outer, GraphQLTypeContext.Output); + + // Inner flattens to Cat, Outer's cat is also Cat → dedup → 1 variant → collapse + expect(mutation.mutatedType.kind).toBe("Model"); + expect(mutation.mutatedType.name).toBe("Cat"); + }); + + it("flattened union variant types get their mutation pipeline applied", async () => { + await tester.compile(` + model ad_account { id: int32; } + model board { title: string; } + union Mixed { a: ad_account; b: board; null; } + `); + + const Mixed = tester.program.getGlobalNamespaceType().unions.get("Mixed")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + + // After null-strip + flattening, variants should have mutated names + const mutatedUnion = mutation.mutatedType as Union; + const variantNames = [...mutatedUnion.variants.values()] + .map((v) => ("name" in v.type ? v.type.name : v.type.kind)) + .sort(); + expect(variantNames).toEqual(["AdAccount", "Board"]); + }); + + it("preserves decorator state (e.g. @doc) on flattened unions", async () => { + const { MaybePet } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + /** A pet or nothing */ + union ${t.union("MaybePet")} { cat: Cat; dog: Dog; null; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(MaybePet, GraphQLTypeContext.Output); + + expect(getDoc(tester.program, mutation.mutatedType)).toBe("A pet or nothing"); + }); + + it("T | null replacement gets its mutation pipeline applied", async () => { + await tester.compile(` + model ad_account { id: int32; } + union MaybeAccount { ad_account, null } + `); + + const MaybeAccount = tester.program.getGlobalNamespaceType().unions.get("MaybeAccount")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(MaybeAccount, GraphQLTypeContext.Output); + + // T | null unwraps to ad_account → mutation renames to AdAccount + expect(mutation.mutatedType.kind).toBe("Model"); + expect(mutation.mutatedType.name).toBe("AdAccount"); + }); + + it("collapsed type gets its mutation pipeline applied (e.g. naming)", async () => { + await tester.compile(` + model ad_account { id: int32; } + union Inner { a: ad_account; } + union Outer { inner: Inner; dup: ad_account; } + `); + + const Outer = tester.program.getGlobalNamespaceType().unions.get("Outer")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Outer, GraphQLTypeContext.Output); + + // Flattens to one unique type (ad_account) → collapses → mutation renames to AdAccount + expect(mutation.mutatedType.kind).toBe("Model"); + expect(mutation.mutatedType.name).toBe("AdAccount"); + }); + + it("collapses nullable multi-variant union when only one variant remains after null strip", async () => { + const { Things } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Things")} { cat: Cat; dup: Cat; null; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Things, GraphQLTypeContext.Output); + + // Strip null → Cat, Cat → dedup → 1 variant → collapse + expect(mutation.mutatedType.kind).toBe("Model"); + expect(mutation.mutatedType.name).toBe("Cat"); + expect(isNullable(mutation.mutatedType)).toBe(true); + }); + + it("handles circular type references without infinite recursion", async () => { + const { Tree } = await tester.compile( + t.code` + model ${t.model("Leaf")} { value: int32; } + model ${t.model("Tree")} { children: Tree | Leaf | null; } + `, + ); + + const engine = createTestEngine(tester.program); + // Should complete without stack overflow + const mutation = engine.mutateModel(Tree, GraphQLTypeContext.Output); + + expect(mutation.mutatedType.kind).toBe("Model"); + expect(mutation.mutatedType.name).toBe("Tree"); + }); + + it("sanitizes union name in mutated type", async () => { + const { ValidUnion } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + union ${t.union("ValidUnion")} { cat: Cat; dog: Dog; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(ValidUnion, GraphQLTypeContext.Output); + + expect(mutation.mutatedType.name).toBe("ValidUnion"); + }); + + it("string | null property → String (nullable)", async () => { + const { Foo } = await tester.compile(t.code`model ${t.model("Foo")} { name: string | null; }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); + + const nameProp = mutation.mutatedType.properties.get("name")!; + expect(nameProp.type.kind).toBe("Scalar"); + expect(printMutatedType(nameProp)).toBe("String"); + }); + + it("string[] property → [String!]!", async () => { + const { Foo } = await tester.compile(t.code`model ${t.model("Foo")} { tags: string[]; }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); + + const tagsProp = mutation.mutatedType.properties.get("tags")!; + expect(printMutatedType(tagsProp)).toBe("[String!]!"); + }); + + it("(string | null)[] property → [String]!", async () => { + const { Foo } = await tester.compile( + t.code`model ${t.model("Foo")} { tags: (string | null)[]; }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); + + const tagsProp = mutation.mutatedType.properties.get("tags")!; + expect(printMutatedType(tagsProp)).toBe("[String]!"); + }); + + it("string[] | null property → [String!]", async () => { + const { Foo } = await tester.compile( + t.code`model ${t.model("Foo")} { tags: string[] | null; }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); + + const tagsProp = mutation.mutatedType.properties.get("tags")!; + expect(printMutatedType(tagsProp)).toBe("[String!]"); + }); + + it("(string | null)[] | null property → [String]", async () => { + const { Foo } = await tester.compile( + t.code`model ${t.model("Foo")} { tags: (string | null)[] | null; }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); + + const tagsProp = mutation.mutatedType.properties.get("tags")!; + expect(printMutatedType(tagsProp)).toBe("[String]"); + }); +}); + +describe("GraphQL Mutation Engine - oneOf Input Objects", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("replaces union with oneOf model in input context", 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 = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Input); + + // Union is replaced with a Model in the type graph + expect(mutation.mutatedType.kind).toBe("Model"); + expect(mutation.mutatedType.name).toBe("PetInput"); + expect(isOneOf(mutation.mutatedType as Model)).toBe(true); + }); + + it("PascalCases oneOf model name for snake_case unions", async () => { + await tester.compile(` + model Cat { name: string; } + model Dog { breed: string; } + union pet_type { cat: Cat; dog: Dog; } + `); + + const petType = tester.program.getGlobalNamespaceType().unions.get("pet_type")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(petType, GraphQLTypeContext.Input); + + expect(mutation.mutatedType.name).toBe("PetTypeInput"); + }); + + it("camelCases oneOf field names for snake_case variants", async () => { + await tester.compile(` + model Cat { name: string; } + model Dog { breed: string; } + union Pet { my_cat: Cat; my_dog: Dog; } + `); + + const Pet = tester.program.getGlobalNamespaceType().unions.get("Pet")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Input); + const model = mutation.mutatedType as Model; + + const fieldNames = Array.from(model.properties.values()).map((p) => p.name); + expect(fieldNames).toContain("myCat"); + expect(fieldNames).toContain("myDog"); + }); + + it("oneOf model has one field per variant, all optional", 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 = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Input); + const model = mutation.mutatedType as Model; + + expect(model.properties.size).toBe(2); + expect(model.properties.has("cat")).toBe(true); + expect(model.properties.has("dog")).toBe(true); + // All fields are optional (oneOf semantics) + expect(model.properties.get("cat")!.optional).toBe(true); + expect(model.properties.get("dog")!.optional).toBe(true); + }); + + it("keeps union in output context (no replacement)", 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 = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + + expect(mutation.mutatedType.kind).toBe("Union"); + }); + + it("oneOf model handles scalar variants", async () => { + const { Data } = await tester.compile( + t.code` + model ${t.model("Foo")} { x: int32; } + union ${t.union("Data")} { text: string; num: int32; foo: Foo; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Data, GraphQLTypeContext.Input); + const model = mutation.mutatedType as Model; + + // All variants become fields — no wrapper models needed for oneOf + expect(model.properties.size).toBe(3); + expect(model.properties.has("text")).toBe(true); + expect(model.properties.has("num")).toBe(true); + expect(model.properties.has("foo")).toBe(true); + // No wrapper models created in input context + expect(mutation.wrapperModels).toHaveLength(0); + }); + + it("oneOf model flattens and deduplicates nested unions", async () => { + const { Outer } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + model ${t.model("Bird")} { wingspan: int32; } + union ${t.union("Inner")} { cat: Cat; dog: Dog; } + union ${t.union("Outer")} { inner: Inner; bird: Bird; dog2: Dog; } + `, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Outer, GraphQLTypeContext.Input); + const model = mutation.mutatedType as Model; + + // Inner is flattened: Cat + Dog from Inner, Bird from Outer + // Dog appears twice (from Inner and as dog2) — deduplicated to one + expect(model.properties.size).toBe(3); + expect(model.properties.has("cat")).toBe(true); + expect(model.properties.has("dog")).toBe(true); + expect(model.properties.has("bird")).toBe(true); + }); + + it("strips null from multi-variant union in output context", 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); + + // Null should be stripped — only Cat and Dog remain + const mutatedUnion = mutation.mutatedType as Union; + expect(mutatedUnion.kind).toBe("Union"); + expect(mutatedUnion.variants.size).toBe(2); + + // The result should be marked as nullable + expect(isNullable(mutatedUnion)).toBe(true); + }); + + it("strips null from multi-variant union in input context", 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.Input); + + // Should become a @oneOf model with 2 fields (null stripped) + const model = mutation.mutatedType as Model; + expect(model.kind).toBe("Model"); + expect(model.properties.size).toBe(2); + expect(model.properties.has("cat")).toBe(true); + expect(model.properties.has("dog")).toBe(true); + + // Should be marked as both @oneOf and nullable + expect(isOneOf(model)).toBe(true); + expect(isNullable(model)).toBe(true); + }); + + it("non-nullable union is not marked as nullable", 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 = createTestEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + + expect(isNullable(mutation.mutatedType)).toBe(false); + }); + + it("exposes typeContext on union mutation", async () => { + const { Pet } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Pet")} { cat: Cat; } + `, + ); + + const engine = createTestEngine(tester.program); + const inputMutation = engine.mutateUnion(Pet, GraphQLTypeContext.Input); + const outputMutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + + expect(inputMutation.typeContext).toBe(GraphQLTypeContext.Input); + expect(outputMutation.typeContext).toBe(GraphQLTypeContext.Output); + }); +}); diff --git a/packages/graphql/test/mutation-engine/visibility.test.ts b/packages/graphql/test/mutation-engine/visibility.test.ts new file mode 100644 index 00000000000..a4a97f49f97 --- /dev/null +++ b/packages/graphql/test/mutation-engine/visibility.test.ts @@ -0,0 +1,270 @@ +import type { Model } from "@typespec/compiler"; +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + createGraphQLMutationEngine, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { createVisibilityFilters } from "../../src/lib/visibility.js"; +import { Tester } from "../test-host.js"; + +function createTestEngine(program: Parameters[0]) { + return createGraphQLMutationEngine(program); +} + +describe("GraphQL Mutation Engine - Visibility Filtering", () => { + let tester: TesterInstance; + let filters: ReturnType; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + function mutateAsInput(engine: ReturnType, model: Model) { + filters = createVisibilityFilters(tester.program); + return engine.mutateModel(model, GraphQLTypeContext.Input, filters.mutation); + } + + function mutateAsOutput(engine: ReturnType, model: Model) { + filters = createVisibilityFilters(tester.program); + return engine.mutateModel(model, GraphQLTypeContext.Output, filters.output); + } + + describe("Input context", () => { + it("excludes read-only properties from input mutation", async () => { + const { Board } = await tester.compile(t.code` + model ${t.model("Board")} { + @visibility(Lifecycle.Read) + id: string; + + @visibility(Lifecycle.Read) + created_at: string; + + name: string; + } + `); + + const engine = createTestEngine(tester.program); + const mutation = mutateAsInput(engine, Board); + + expect(mutation.mutatedType.properties.has("name")).toBe(true); + expect(mutation.mutatedType.properties.has("id")).toBe(false); + expect(mutation.mutatedType.properties.has("created_at")).toBe(false); + }); + + it("includes create-visible properties in input mutation", async () => { + const { User } = await tester.compile(t.code` + model ${t.model("User")} { + @visibility(Lifecycle.Read) + id: string; + + @visibility(Lifecycle.Create, Lifecycle.Read) + email: string; + + name: string; + } + `); + + const engine = createTestEngine(tester.program); + const mutation = mutateAsInput(engine, User); + + expect(mutation.mutatedType.properties.has("name")).toBe(true); + expect(mutation.mutatedType.properties.has("email")).toBe(true); + expect(mutation.mutatedType.properties.has("id")).toBe(false); + }); + + it("includes properties with no visibility decorator in input mutation", async () => { + const { Item } = await tester.compile(t.code` + model ${t.model("Item")} { + name: string; + description: string; + } + `); + + const engine = createTestEngine(tester.program); + const mutation = mutateAsInput(engine, Item); + + expect(mutation.mutatedType.properties.has("name")).toBe(true); + expect(mutation.mutatedType.properties.has("description")).toBe(true); + }); + }); + + describe("Output context", () => { + it("includes read-only properties in output mutation", async () => { + const { Board } = await tester.compile(t.code` + model ${t.model("Board")} { + @visibility(Lifecycle.Read) + id: string; + + @visibility(Lifecycle.Read) + createdAt: string; + + name: string; + } + `); + + const engine = createTestEngine(tester.program); + const mutation = mutateAsOutput(engine, Board); + + expect(mutation.mutatedType.properties.has("id")).toBe(true); + expect(mutation.mutatedType.properties.has("createdAt")).toBe(true); + expect(mutation.mutatedType.properties.has("name")).toBe(true); + }); + + it("excludes create-only properties from output mutation", async () => { + const { User } = await tester.compile(t.code` + model ${t.model("User")} { + @visibility(Lifecycle.Read) + id: string; + + @visibility(Lifecycle.Create) + password: string; + + name: string; + } + `); + + const engine = createTestEngine(tester.program); + const mutation = mutateAsOutput(engine, User); + + expect(mutation.mutatedType.properties.has("id")).toBe(true); + expect(mutation.mutatedType.properties.has("name")).toBe(true); + expect(mutation.mutatedType.properties.has("password")).toBe(false); + }); + + it("includes properties with no visibility decorator in output mutation", async () => { + const { Item } = await tester.compile(t.code` + model ${t.model("Item")} { + name: string; + description: string; + } + `); + + const engine = createTestEngine(tester.program); + const mutation = mutateAsOutput(engine, Item); + + expect(mutation.mutatedType.properties.has("name")).toBe(true); + expect(mutation.mutatedType.properties.has("description")).toBe(true); + }); + }); + + describe("Edge cases", () => { + it("does not filter properties when no type context is provided", async () => { + const { Board } = await tester.compile(t.code` + model ${t.model("Board")} { + @visibility(Lifecycle.Read) + id: string; + + name: string; + } + `); + + const engine = createTestEngine(tester.program); + // Mutate without context (e.g., type not reachable from an operation) + const mutation = mutateAsOutput(engine, Board); + + // Both should be present since Output includes Read-visible properties + expect(mutation.mutatedType.properties.has("id")).toBe(true); + expect(mutation.mutatedType.properties.has("name")).toBe(true); + }); + + it("replaces with scalar when all properties are read-only in input context", async () => { + const { ReadOnlyModel } = await tester.compile(t.code` + model ${t.model("ReadOnlyModel")} { + @visibility(Lifecycle.Read) + id: string; + + @visibility(Lifecycle.Read) + status: string; + } + `); + + const engine = createTestEngine(tester.program); + const mutation = mutateAsInput(engine, ReadOnlyModel); + + expect(mutation.mutationNode.isReplaced).toBe(true); + const replacement = mutation.mutationNode.replacementNode!.mutatedType; + expect(replacement.kind).toBe("Scalar"); + if (replacement.kind === "Scalar") { + expect(replacement.name).toBe("ReadOnlyModelInput"); + } + }); + + it("strips @compose from input variants to avoid spurious validation", async () => { + const { User } = await tester.compile(t.code` + @graphqlInterface(#{interfaceOnly: true}) + model Node { + @visibility(Lifecycle.Read) + id: string; + } + + @compose(Node) + model ${t.model("User")} { + @visibility(Lifecycle.Read) + id: string; + + name: string; + } + `); + + const engine = createTestEngine(tester.program); + const mutation = mutateAsInput(engine, User); + + expect(mutation.mutatedType.properties.has("id")).toBe(false); + expect(mutation.mutatedType.properties.has("name")).toBe(true); + const hasCompose = mutation.mutatedType.decorators.some( + (d) => d.decorator.name === "$compose", + ); + expect(hasCompose).toBe(false); + }); + + it("excludes @invisible properties from both input and output", async () => { + const { Secret } = await tester.compile(t.code` + model ${t.model("Secret")} { + @invisible(Lifecycle) + internal: string; + + name: string; + } + `); + + const engine = createTestEngine(tester.program); + const inputMutation = mutateAsInput(engine, Secret); + const outputMutation = mutateAsOutput(engine, Secret); + + expect(inputMutation.mutatedType.properties.has("internal")).toBe(false); + expect(inputMutation.mutatedType.properties.has("name")).toBe(true); + expect(outputMutation.mutatedType.properties.has("internal")).toBe(false); + expect(outputMutation.mutatedType.properties.has("name")).toBe(true); + }); + + it("properties are finalized with mutated names after mutateModel returns", async () => { + const { User } = await tester.compile(t.code` + model ${t.model("User")} { + @visibility(Lifecycle.Read, Lifecycle.Query) + user_id: string; + + @visibility(Lifecycle.Create, Lifecycle.Update) + pass_word: string; + + display_name: string; + } + `); + + filters = createVisibilityFilters(tester.program); + const engine = createTestEngine(tester.program); + const queryMutation = engine.mutateModel( + User, GraphQLTypeContext.Input, filters.query, "Query", + ); + const mutMutation = engine.mutateModel( + User, GraphQLTypeContext.Input, filters.mutation, "Mutation", + ); + + const queryKeys = [...queryMutation.mutatedType.properties.keys()].sort(); + const mutKeys = [...mutMutation.mutatedType.properties.keys()].sort(); + + expect(queryKeys).toEqual(["displayName", "userId"]); + expect(mutKeys).toEqual(["displayName", "passWord"]); + expect(queryKeys.join(",")).not.toBe(mutKeys.join(",")); + }); + }); +}); diff --git a/packages/graphql/test/operation-fields.test.ts b/packages/graphql/test/operation-fields.test.ts new file mode 100644 index 00000000000..ac518164d58 --- /dev/null +++ b/packages/graphql/test/operation-fields.test.ts @@ -0,0 +1,170 @@ +import { expectDiagnosticEmpty, expectDiagnostics, t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getOperationFields } from "../src/lib/operation-fields.js"; +import { Tester } from "./test-host.js"; + +describe("@operationFields", () => { + it("can add an operation to the model", async () => { + const { program, TestModel, testOperation } = await Tester.compile(t.code` + @test op ${t.op("testOperation")}(): void; + + @operationFields(testOperation) + @test model ${t.model("TestModel")} {} + `); + + expect(getOperationFields(program, TestModel)).toContain(testOperation); + }); + + it("can add an interface to the model", async () => { + const { program, TestModel, testOperation } = await Tester.compile(t.code` + interface TestInterface { + @test op ${t.op("testOperation")}(): void; + } + + @operationFields(TestInterface) + @test model ${t.model("TestModel")} {} + `); + + expect(getOperationFields(program, TestModel)).toContain(testOperation); + }); + + it("can add an multiple operations to the model", async () => { + const { program, TestModel, testOperation1, testOperation2, testOperation3 } = + await Tester.compile(t.code` + interface TestInterface { + @test op ${t.op("testOperation1")}(): void; + @test op ${t.op("testOperation2")}(): void; + } + + @test op ${t.op("testOperation3")}(): void; + + @operationFields(TestInterface, testOperation3) + @test model ${t.model("TestModel")} {} + `); + + expect(getOperationFields(program, TestModel)).toContain(testOperation1); + expect(getOperationFields(program, TestModel)).toContain(testOperation2); + expect(getOperationFields(program, TestModel)).toContain(testOperation3); + }); + + it("will add duplicate operations with a warning", async () => { + const [{ program, TestModel, testOperation }, diagnostics] = + await Tester.compileAndDiagnose(t.code` + interface TestInterface { + @test op ${t.op("testOperation")}(): void; + } + + @operationFields(TestInterface, TestInterface.testOperation) + @test model ${t.model("TestModel")} {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-duplicate", + message: "Operation `testOperation` is defined multiple times on `TestModel`.", + }); + + expect(getOperationFields(program, TestModel)).toContain(testOperation); + }); + + describe("conflicts", () => { + it("does not allow adding operations that conflict with a field", async () => { + const diagnostics = await Tester.diagnose(` + op foo(): void; + + @operationFields(foo) + model TestModel { + foo: string; + } + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-conflict", + message: "Operation `foo` conflicts with an existing property on model `TestModel`.", + }); + }); + + it("does not allow adding operations that conflict with another operation in return type", async () => { + const diagnostics = await Tester.diagnose(` + op testOperation(): string; + + interface TestInterface { + op testOperation(): void; + } + + @operationFields(testOperation, TestInterface.testOperation) + model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-conflict", + message: + "Operation `testOperation` conflicts with an existing operation on model `TestModel`.", + }); + }); + + it("does not allow adding operations that conflict with another operation in number of arguments", async () => { + const diagnostics = await Tester.diagnose(` + op testOperation(a: string, b: integer): void; + + interface TestInterface { + op testOperation(a: string): void; + } + + @operationFields(testOperation, TestInterface.testOperation) + model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-conflict", + message: + "Operation `testOperation` conflicts with an existing operation on model `TestModel`.", + }); + }); + + it("does not allow adding operations that conflict with another operation in argument type", async () => { + const diagnostics = await Tester.diagnose(` + op testOperation(a: string): void; + + interface TestInterface { + op testOperation(a: integer): void; + } + + @operationFields(testOperation, TestInterface.testOperation) + model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-conflict", + message: + "Operation `testOperation` conflicts with an existing operation on model `TestModel`.", + }); + }); + + it("does not allow adding operations that conflict with another operation in argument name", async () => { + const diagnostics = await Tester.diagnose(` + op testOperation(a: string): void; + + interface TestInterface { + op testOperation(b: string): void; + } + + @operationFields(testOperation, TestInterface.testOperation) + model TestModel {} + `); + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/operation-field-conflict", + message: + "Operation `testOperation` conflicts with an existing operation on model `TestModel`.", + }); + }); + + it("allows adding operations with a different argument order", async () => { + const diagnostics = await Tester.diagnose(` + op testOperation(a: string, b: integer): void; + + interface TestInterface { + op testOperation(b: integer, a: string): void; + } + + @operationFields(testOperation, TestInterface.testOperation) + model TestModel {} + `); + expectDiagnosticEmpty(diagnostics); + }); + }); +}); diff --git a/packages/graphql/test/operation-kind.test.ts b/packages/graphql/test/operation-kind.test.ts new file mode 100644 index 00000000000..b9dd05937a0 --- /dev/null +++ b/packages/graphql/test/operation-kind.test.ts @@ -0,0 +1,45 @@ +import { expectDiagnostics, t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getOperationKind } from "../src/lib/operation-kind.js"; +import { Tester } from "./test-host.js"; + +describe("Operation kinds", () => { + it("declares a Mutation", async () => { + const { program, testOperation } = await Tester.compile(t.code` + @mutation @test op ${t.op("testOperation")}(): string; + `); + const operationKind = getOperationKind(program, testOperation); + expect(operationKind).toBe("Mutation"); + }); + it("declares a Query", async () => { + const { program, testOperation } = await Tester.compile(t.code` + @query @test op ${t.op("testOperation")}(): string; + `); + const operationKind = getOperationKind(program, testOperation); + expect(operationKind).toBe("Query"); + }); + it("declares a Subscription", async () => { + const { program, testOperation } = await Tester.compile(t.code` + @subscription @test op ${t.op("testOperation")}(): string; + `); + const operationKind = getOperationKind(program, testOperation); + expect(operationKind).toBe("Subscription"); + }); + it("does not allow to declare multiple operation kinds to the same type.", async () => { + const [{ program, testOperation }, diagnostics] = await Tester.compileAndDiagnose(t.code` + @query @mutation @test op ${t.op("testOperation")}(): string; + `); + expectDiagnostics(diagnostics, [ + { + code: "@typespec/graphql/graphql-operation-kind-duplicate", + message: "GraphQL Operation Kind already applied to `testOperation`.", + }, + { + code: "@typespec/graphql/graphql-operation-kind-duplicate", + message: "GraphQL Operation Kind already applied to `testOperation`.", + }, + ]); + const operationKind = getOperationKind(program, testOperation); + expect(operationKind).toBeUndefined(); + }); +}); diff --git a/packages/graphql/test/schema.test.ts b/packages/graphql/test/schema.test.ts new file mode 100644 index 00000000000..8b6b7785234 --- /dev/null +++ b/packages/graphql/test/schema.test.ts @@ -0,0 +1,34 @@ +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it } from "vitest"; +import { getSchema } from "../src/lib/schema.js"; +import { Tester } from "./test-host.js"; + +describe("@schema", () => { + it("Creates a schema with no name", async () => { + const { program, TestNamespace } = await Tester.compile(t.code` + @schema + @test namespace ${t.namespace("TestNamespace")} { + @query op health(): string; + } + `); + + const schema = getSchema(program, TestNamespace); + + expect(schema?.type).toBe(TestNamespace); + expect(schema?.name).toBeUndefined(); + }); + + it("Creates a schema with a specified name", async () => { + const { program, TestNamespace } = await Tester.compile(t.code` + @schema(#{name: "MySchema"}) + @test namespace ${t.namespace("TestNamespace")} { + @query op health(): string; + } + `); + + const schema = getSchema(program, TestNamespace); + + expect(schema?.type).toBe(TestNamespace); + expect(schema?.name).toBe("MySchema"); + }); +}); diff --git a/packages/graphql/test/test-host.ts b/packages/graphql/test/test-host.ts new file mode 100644 index 00000000000..f9ad2855673 --- /dev/null +++ b/packages/graphql/test/test-host.ts @@ -0,0 +1,71 @@ +import { type Diagnostic, resolvePath } from "@typespec/compiler"; +import { createTester, expectDiagnosticEmpty } from "@typespec/compiler/testing"; +import { ok } from "assert"; +import type { GraphQLSchema } from "graphql"; +import { buildSchema } from "graphql"; +import { expect } from "vitest"; +import type { GraphQLEmitterOptions } from "../src/lib.js"; + +const outputFileName = "schema.graphql"; + +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/graphql"], +}) + .importLibraries() + .using("TypeSpec.GraphQL"); + +export const EmitterTester = Tester.emit("@typespec/graphql"); + +export interface GraphQLTestResult { + readonly graphQLSchema?: GraphQLSchema; + readonly graphQLOutput?: string; + readonly diagnostics: readonly Diagnostic[]; +} + +export async function emitWithDiagnostics( + code: string, + options: GraphQLEmitterOptions = {}, +): Promise { + const [result, diagnostics] = await EmitterTester.compileAndDiagnose(code, { + compilerOptions: { + options: { + "@typespec/graphql": { ...options, "output-file": outputFileName }, + }, + }, + }); + + const content = result.outputs[outputFileName]; + const schema = content + ? buildSchema(content, { + assumeValidSDL: true, + noLocation: true, + }) + : undefined; + + return [ + { + graphQLSchema: schema, + graphQLOutput: content, + diagnostics, + }, + ]; +} + +export async function emitSingleSchemaWithDiagnostics( + code: string, + options: GraphQLEmitterOptions = {}, +): Promise { + const schemaRecords = await emitWithDiagnostics(code, options); + expect(schemaRecords.length).toBe(1); + return schemaRecords[0]; +} + +export async function emitSingleSchema( + code: string, + options: GraphQLEmitterOptions = {}, +): Promise { + const schemaRecord = await emitSingleSchemaWithDiagnostics(code, options); + expectDiagnosticEmpty(schemaRecord.diagnostics); + ok(schemaRecord.graphQLOutput, "Expected to have found graphql output"); + return schemaRecord.graphQLOutput; +} diff --git a/packages/graphql/test/type-usage.test.ts b/packages/graphql/test/type-usage.test.ts new file mode 100644 index 00000000000..17ae821a4c6 --- /dev/null +++ b/packages/graphql/test/type-usage.test.ts @@ -0,0 +1,251 @@ +import { t, TesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { GraphQLTypeUsage, resolveTypeUsage } from "../src/type-usage.js"; +import { Tester } from "./test-host.js"; + +describe("type-usage", () => { + let tester: TesterInstance; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + function resolve(omitUnreachableTypes = true) { + return resolveTypeUsage(tester.program, tester.program.getGlobalNamespaceType(), omitUnreachableTypes); + } + + + describe("basic output reachability", () => { + it("marks return type model as Output", async () => { + const { User } = await tester.compile( + t.code` + model ${t.model("User")} { id: string; } + @query op getUser(): User; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(User)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.getUsage(User)?.has(GraphQLTypeUsage.Input)).toBeFalsy(); + expect(resolver.isUnreachable(User)).toBe(false); + }); + }); + + describe("basic input reachability", () => { + it("marks parameter type model as Input", async () => { + const { UserInput } = await tester.compile( + t.code` + model ${t.model("UserInput")} { name: string; } + @query op createUser(input: UserInput): void; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(UserInput)?.has(GraphQLTypeUsage.Input)).toBe(true); + expect(resolver.getUsage(UserInput)?.has(GraphQLTypeUsage.Output)).toBeFalsy(); + expect(resolver.isUnreachable(UserInput)).toBe(false); + }); + }); + + describe("nested reachability", () => { + it("tracks models referenced indirectly via properties", async () => { + const { Address } = await tester.compile( + t.code` + model ${t.model("Address")} { street: string; } + model User { id: string; address: Address; } + @query op getUser(): User; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(Address)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.isUnreachable(Address)).toBe(false); + }); + }); + + describe("dual usage", () => { + it("model used as both parameter and return gets both flags", async () => { + const { Book } = await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + @query op getBook(): Book; + @mutation op updateBook(input: Book): void; + `, + ); + + const resolver = resolve(); + const usage = resolver.getUsage(Book); + expect(usage?.has(GraphQLTypeUsage.Input)).toBe(true); + expect(usage?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + + it("nested model shared across input and output in a single operation gets both flags", async () => { + const { Shared } = await tester.compile( + t.code` + model ${t.model("Shared")} { id: string; } + model InputData { shared: Shared; } + model OutputData { shared: Shared; } + @query op transform(input: InputData): OutputData; + `, + ); + + const resolver = resolve(); + const usage = resolver.getUsage(Shared); + expect(usage?.has(GraphQLTypeUsage.Input)).toBe(true); + expect(usage?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + }); + + describe("unreachable types", () => { + it("marks unreferenced type as unreachable when omitUnreachableTypes=true", async () => { + const { Orphan } = await tester.compile( + t.code` + model ${t.model("Orphan")} { value: int32; } + model Used { id: string; } + @query op getUsed(): Used; + `, + ); + + const resolver = resolve(true); + expect(resolver.isUnreachable(Orphan)).toBe(true); + expect(resolver.getUsage(Orphan)).toBeUndefined(); + }); + + it("marks unreferenced type as reachable when omitUnreachableTypes=false", async () => { + const { Orphan } = await tester.compile( + t.code` + model ${t.model("Orphan")} { value: int32; } + model Used { id: string; } + @query op getUsed(): Used; + `, + ); + + const resolver = resolve(false); + expect(resolver.isUnreachable(Orphan)).toBe(false); + // Reachable but no usage flags — it wasn't actually referenced by any operation + expect(resolver.getUsage(Orphan)).toBeUndefined(); + }); + + it("preserves usage flags for referenced types when omitUnreachableTypes=false", async () => { + const { Used } = await tester.compile( + t.code` + model ${t.model("Used")} { id: string; } + @query op getUsed(): Used; + `, + ); + + const resolver = resolve(false); + expect(resolver.isUnreachable(Used)).toBe(false); + expect(resolver.getUsage(Used)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.getUsage(Used)?.has(GraphQLTypeUsage.Input)).toBeFalsy(); + }); + }); + + describe("circular references", () => { + it("handles self-referencing model without infinite loop", async () => { + const { TreeNode } = await tester.compile( + t.code` + model ${t.model("TreeNode")} { id: string; children: TreeNode[]; } + @query op getRoot(): TreeNode; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(TreeNode)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.isUnreachable(TreeNode)).toBe(false); + }); + }); + + describe("union variant reachability", () => { + it("tracks types inside a union used in an operation", async () => { + const { Cat, Dog, 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; } + @query op getPet(): Pet; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(Pet)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.getUsage(Cat)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.getUsage(Dog)?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + }); + + describe("array element reachability", () => { + it("marks element type of array return as Output", async () => { + const { User } = await tester.compile( + t.code` + model ${t.model("User")} { id: string; } + @query op listUsers(): User[]; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(User)?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + }); + + describe("base model reachability", () => { + it("tracks parent model when child is reachable", async () => { + const { Parent } = await tester.compile( + t.code` + model ${t.model("Parent")} { id: string; } + model Child extends Parent { extra: string; } + @query op getChild(): Child; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(Parent)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.isUnreachable(Parent)).toBe(false); + }); + }); + + describe("enum and scalar reachability", () => { + it("tracks enum types referenced from operations", async () => { + const { Status } = await tester.compile( + t.code` + enum ${t.enum("Status")} { Active; Inactive; } + model User { id: string; status: Status; } + @query op getUser(): User; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(Status)?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + + it("tracks scalar types referenced from operations", async () => { + const { MyId } = await tester.compile( + t.code` + scalar ${t.scalar("MyId")} extends string; + model User { id: MyId; } + @query op getUser(): User; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(MyId)?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + }); + + describe("interface operations", () => { + it("walks operations inside interface blocks", async () => { + const { User } = await tester.compile( + t.code` + model ${t.model("User")} { id: string; } + interface UserService { + @query getUser(): User; + } + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(User)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.isUnreachable(User)).toBe(false); + }); + }); + +}); diff --git a/packages/graphql/test/validate.test.ts b/packages/graphql/test/validate.test.ts new file mode 100644 index 00000000000..a1277d0fc76 --- /dev/null +++ b/packages/graphql/test/validate.test.ts @@ -0,0 +1,306 @@ +import { expectDiagnosticEmpty, expectDiagnostics, t } from "@typespec/compiler/testing"; +import { describe, it } from "vitest"; +import { Tester } from "./test-host.js"; + +describe("$onValidate", () => { + describe("empty-schema", () => { + it("reports empty-schema when no GraphQL operations exist", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + @test namespace ${t.namespace("TestNamespace")} { + model Book { title: string; } + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/empty-schema", + severity: "warning", + message: "GraphQL schema has no operations. At minimum a Query root type is required.", + }); + }); + + it("does not report empty-schema when no @schema decorator", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + model Book { title: string; } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("does not report empty-schema when @query operation exists", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + @test namespace ${t.namespace("TestNamespace")} { + model Book { title: string; } + @query op getBooks(): Book[]; + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("does not report empty-schema when @mutation operation exists", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + @test namespace ${t.namespace("TestNamespace")} { + model Book { title: string; } + @mutation op createBook(title: string): Book; + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("does not report empty-schema when @subscription operation exists", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + @test namespace ${t.namespace("TestNamespace")} { + model Book { title: string; } + @subscription op onBookCreated(): Book; + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + }); + + describe("empty-enum", () => { + it("reports error for enum with no values", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + enum Status {} + model Book { status: Status; } + @query op getBooks(): Book[]; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/empty-enum", + severity: "error", + message: 'Enum "Status" must define at least one value. GraphQL enums cannot be empty.', + }); + }); + + it("does not report error for enum with values", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + enum Status { Active, Inactive } + model Book { status: Status; } + @query op getBooks(): Book[]; + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + }); + + describe("reserved-name", () => { + it("reports error for model name starting with __", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + model __Reserved { title: string; } + @query op get(): __Reserved; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/reserved-name", + severity: "error", + message: + 'Name "__Reserved" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.', + }); + }); + + it("reports error for property name starting with __", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + model Book { __internal: string; } + @query op getBooks(): Book[]; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/reserved-name", + severity: "error", + message: + 'Name "__internal" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.', + }); + }); + + it("reports error for operation parameter starting with __", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + model Book { title: string; } + @query op getBook(__id: string): Book; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/reserved-name", + severity: "error", + message: + 'Name "__id" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.', + }); + }); + + it("reports error for enum name starting with __", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + enum __Status { Active } + model Book { status: __Status; } + @query op getBooks(): Book[]; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/reserved-name", + severity: "error", + message: + 'Name "__Status" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.', + }); + }); + + it("reports error for enum member starting with __", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + enum Status { __Internal, Active } + model Book { status: Status; } + @query op getBooks(): Book[]; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/reserved-name", + severity: "error", + message: + 'Name "__Internal" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.', + }); + }); + + it("reports error for union name starting with __", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + model Cat { meow: string; } + model Dog { bark: string; } + union __Pet { cat: Cat, dog: Dog } + @query op getPet(): __Pet; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/reserved-name", + severity: "error", + message: + 'Name "__Pet" must not begin with "__" (two underscores), which is reserved by GraphQL for introspection.', + }); + }); + + it("does not report error for names with single underscore prefix", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + model _Book { _title: string; } + @query op _getBooks(_filter: string): _Book[]; + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("does not report error for names with underscore in middle", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + model My__Book { my__title: string; } + @query op get__Books(): My__Book[]; + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + }); + + describe("empty-union", () => { + it("reports error for union with no variants", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + union Empty {} + @query op get(): Empty; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/empty-union", + severity: "error", + message: + "Union has no non-null variants. A GraphQL union must contain at least one member type.", + }); + }); + + it("reports error for union with only null variant", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + union MaybeNothing { nothing: null } + @query op get(): MaybeNothing; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/graphql/empty-union", + severity: "error", + message: + "Union has no non-null variants. A GraphQL union must contain at least one member type.", + }); + }); + + it("does not report error for union with non-null variants", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + model Cat { meow: string; } + model Dog { bark: string; } + union Pet { cat: Cat, dog: Dog } + @query op getPet(): Pet; + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("does not report error for union with null and non-null variants", async () => { + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + model Cat { meow: string; } + union MaybeCat { cat: Cat, none: null } + @query op getCat(): MaybeCat; + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("does not validate anonymous unions", async () => { + // Anonymous unions like `string | null` are handled differently + const [_, diagnostics] = await Tester.compileAndDiagnose(t.code` + @schema + namespace TestNamespace { + model Book { title: string | null; } + @query op getBooks(): Book[]; + } + `); + + expectDiagnosticEmpty(diagnostics); + }); + }); +}); diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json new file mode 100644 index 00000000000..473e53bf77f --- /dev/null +++ b/packages/graphql/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [ + { "path": "../compiler/tsconfig.json" }, + { "path": "../emitter-framework/tsconfig.json" }, + { "path": "../http/tsconfig.json" } + ], + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "es2022", + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "jsx": "preserve", + "jsxImportSource": "@alloy-js/core", + "emitDeclarationOnly": true, + "rootDir": ".", + "outDir": "dist" + }, + "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 new file mode 100644 index 00000000000..c35108ef7bb --- /dev/null +++ b/packages/graphql/vitest.config.ts @@ -0,0 +1,18 @@ +import alloyPlugin from "@alloy-js/rollup-plugin"; +import { defineConfig, mergeConfig } from "vitest/config"; +import { defaultTypeSpecVitestConfig } from "../../vitest.config.js"; + +export default mergeConfig( + defaultTypeSpecVitestConfig, + defineConfig({ + esbuild: { + jsx: "preserve", + sourcemap: "both", + }, + plugins: [alloyPlugin()], + resolve: { + conditions: ["development"], + dedupe: ["@alloy-js/core", "graphql"], + }, + }), +); diff --git a/packages/playground-website/package.json b/packages/playground-website/package.json index b824cc08ee6..fd9ccc43900 100644 --- a/packages/playground-website/package.json +++ b/packages/playground-website/package.json @@ -64,6 +64,7 @@ "@typespec/json-schema": "workspace:^", "@typespec/openapi": "workspace:^", "@typespec/openapi3": "workspace:^", + "@typespec/graphql": "workspace:^", "@typespec/pack": "workspace:~", "@typespec/playground": "workspace:^", "@typespec/protobuf": "workspace:^", diff --git a/packages/playground-website/samples/build.js b/packages/playground-website/samples/build.js index 82c345d40b3..38c169f8038 100644 --- a/packages/playground-website/samples/build.js +++ b/packages/playground-website/samples/build.js @@ -39,4 +39,9 @@ await buildSamples_experimental(packageRoot, resolve(__dirname, "dist/samples.ts preferredEmitter: "@typespec/json-schema", description: "Emit JSON Schema from TypeSpec type definitions.", }, + "GraphQL": { + filename: "samples/graphql.tsp", + preferredEmitter: "@typespec/graphql", + description: "Generate GraphQL schemas with queries, mutations, and types.", + }, }); diff --git a/packages/playground-website/samples/graphql.tsp b/packages/playground-website/samples/graphql.tsp new file mode 100644 index 00000000000..1e91f9a8db9 --- /dev/null +++ b/packages/playground-website/samples/graphql.tsp @@ -0,0 +1,36 @@ +import "@typespec/graphql"; + +using GraphQL; + +@schema +namespace PetStore; + +/** A pet in the store */ +model Pet { + id: string; + name: string; + tag?: string; + status: PetStatus; +} + +/** Pet status in the store */ +enum PetStatus { + Available, + Pending, + Sold, +} + +/** A toy that belongs to a pet */ +model Toy { + id: string; + name: string; + pet: Pet; +} + +@query op getPet(id: string): Pet | null; +@query op listPets(status?: PetStatus, limit?: int32): Pet[]; +@query op getPetToys(petId: string): Toy[]; + +@mutation op createPet(name: string, tag: string, status: PetStatus): Pet; +@mutation op updatePet(id: string, name?: string, status?: PetStatus): Pet | null; +@mutation op deletePet(id: string): boolean; diff --git a/packages/playground-website/src/config.ts b/packages/playground-website/src/config.ts index c925e85c745..852af378663 100644 --- a/packages/playground-website/src/config.ts +++ b/packages/playground-website/src/config.ts @@ -10,6 +10,7 @@ export const TypeSpecPlaygroundConfig = { "@typespec/versioning", "@typespec/openapi3", "@typespec/json-schema", + "@typespec/graphql", "@typespec/protobuf", "@typespec/streams", "@typespec/events", diff --git a/packages/playground-website/tsconfig.json b/packages/playground-website/tsconfig.json index 4fd21b6512e..8fbcdf1aae4 100644 --- a/packages/playground-website/tsconfig.json +++ b/packages/playground-website/tsconfig.json @@ -4,7 +4,8 @@ { "path": "../compiler/tsconfig.json" }, { "path": "../rest/tsconfig.json" }, { "path": "../openapi/tsconfig.json" }, - { "path": "../openapi3/tsconfig.json" } + { "path": "../openapi3/tsconfig.json" }, + { "path": "../graphql/tsconfig.json" } ], "compilerOptions": { "outDir": "dist-dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 032a9ef6953..490afa36bab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ catalogs: specifier: ^0.23.0 version: 0.23.0 '@alloy-js/core': - specifier: ^0.23.0 + specifier: ^0.23.1 version: 0.23.1 '@alloy-js/csharp': specifier: ^0.23.0 @@ -1019,6 +1019,61 @@ importers: specifier: 'catalog:' version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@vitest/ui@4.1.7)(happy-dom@20.9.0)(jsdom@25.0.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(tsx@4.22.3)(yaml@2.9.0)) + packages/graphql: + dependencies: + '@alloy-js/core': + specifier: 'catalog:' + version: 0.23.1 + '@alloy-js/typescript': + specifier: 'catalog:' + version: 0.23.0 + '@pinterest/alloy-graphql': + specifier: link:../../../alloy-graphql/packages/graphql + version: link:../../../alloy-graphql/packages/graphql + change-case: + specifier: ^5.4.4 + version: 5.4.4 + graphql: + specifier: ^16.9.0 + version: 16.14.2 + devDependencies: + '@alloy-js/cli': + specifier: ^0.23.0 + version: 0.23.0 + '@alloy-js/rollup-plugin': + specifier: ^0.1.1 + version: 0.1.1(@babel/core@7.29.7)(@types/babel__core@7.20.5)(rollup@4.60.4) + '@types/node': + specifier: ~22.13.13 + version: 22.13.17 + '@typespec/compiler': + specifier: workspace:~ + version: link:../compiler + '@typespec/emitter-framework': + specifier: workspace:~ + version: link:../emitter-framework + '@typespec/http': + specifier: workspace:~ + version: link:../http + '@typespec/mutator-framework': + specifier: workspace:~ + version: link:../mutator-framework + '@typespec/tspd': + specifier: workspace:~ + version: link:../tspd + rimraf: + specifier: ~6.0.1 + version: 6.0.1 + source-map-support: + specifier: ~0.5.21 + version: 0.5.21 + typescript: + specifier: ~5.8.2 + version: 5.8.3 + vitest: + specifier: ^3.0.9 + version: 3.2.6(@types/debug@4.1.13)(@types/node@22.13.17)(@vitest/ui@4.1.7)(happy-dom@20.9.0)(jsdom@25.0.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + packages/html-program-viewer: dependencies: '@fluentui/react-components': @@ -1999,6 +2054,9 @@ importers: '@typespec/events': specifier: workspace:^ version: link:../events + '@typespec/graphql': + specifier: workspace:^ + version: link:../graphql '@typespec/html-program-viewer': specifier: workspace:^ version: link:../html-program-viewer @@ -7164,6 +7222,9 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@22.13.17': + resolution: {integrity: sha512-nAJuQXoyPj04uLgu+obZcSmsfOenUg6DxPKogeUy6yNCFwWaj5sBF8/G/pNo8EtBJjAfSVgfIlugR/BCOleO+g==} + '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} @@ -7382,9 +7443,23 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} + '@vitest/expect@4.1.7': resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.1.7': resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} peerDependencies: @@ -7399,18 +7474,30 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} + '@vitest/pretty-format@4.1.7': resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} + '@vitest/runner@4.1.7': resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} + '@vitest/snapshot@4.1.7': resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} + '@vitest/spy@4.1.7': resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} @@ -7422,6 +7509,9 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + '@vitest/utils@4.1.7': resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} @@ -8200,6 +8290,10 @@ packages: monocart-coverage-reports: optional: true + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacache@19.0.1: resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -9142,6 +9236,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -9672,6 +9769,10 @@ packages: grapheme-splitter@1.0.4: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + gray-matter@4.0.3: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} @@ -10203,6 +10304,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -12045,6 +12149,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@6.0.1: + resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} + engines: {node: 20 || >=22} + hasBin: true + rimraf@6.1.3: resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} engines: {node: 20 || >=22} @@ -12520,6 +12629,9 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} @@ -12673,6 +12785,9 @@ packages: resolution: {integrity: sha512-8OqlXQ35euK9+e7L68u8UwcODxkHoIkjbGsgXuARKNyQ5G6xt8nw1YPeMbxMLgCPFkToU+UEK5j05t2t8edKpQ==} engines: {node: ^16.14.0 || >= 17.3.0} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.2.3: resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==} engines: {node: '>=18'} @@ -12684,6 +12799,10 @@ packages: tinylogic@2.0.0: resolution: {integrity: sha512-dljTkiLLITtsjqBvTA1MRZQK/sGP4kI3UJKc3yA9fMzYbMF2RhcN04SeROVqJBIYYOoJMM8u0WDnhFwMSFQotw==} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -12920,6 +13039,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -12963,6 +13087,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -13230,6 +13357,11 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-checker@0.14.1: resolution: {integrity: sha512-Mv8oQc9XYBYf+XkP/riqqQCt8lBP6Iad75PZPho1lHRrpxQI0BwX2gwE10enn4f6Hgc+PvR1F7N38KARcaJtzw==} engines: {node: '>=20.19.0'} @@ -13366,6 +13498,34 @@ packages: vite: optional: true + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.1.7: resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -19013,7 +19173,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/braces@3.0.5': {} @@ -19021,7 +19181,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/responselike': 1.0.3 '@types/chai@5.2.3': @@ -19031,11 +19191,11 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/cross-spawn@6.0.6': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/d3-array@3.2.2': {} @@ -19178,7 +19338,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -19207,7 +19367,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/mdast@4.0.4': dependencies: @@ -19221,7 +19381,7 @@ snapshots: '@types/morgan@1.9.10': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/ms@2.1.0': {} @@ -19239,6 +19399,10 @@ snapshots: dependencies: undici-types: 5.26.5 + '@types/node@22.13.17': + dependencies: + undici-types: 6.20.0 + '@types/node@24.12.4': dependencies: undici-types: 7.16.0 @@ -19271,28 +19435,28 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/sarif@2.1.7': {} '@types/sax@1.2.7': dependencies: - '@types/node': 24.12.4 + '@types/node': 22.13.17 '@types/semver@7.7.1': {} '@types/send@1.2.1': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/ssri@7.1.5': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/swagger-ui-dist@3.30.6': {} @@ -19320,7 +19484,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/yargs-parser@21.0.3': {} @@ -19501,6 +19665,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/expect@3.2.6': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + tinyrainbow: 2.0.0 + '@vitest/expect@4.1.7': dependencies: '@standard-schema/spec': 1.1.0 @@ -19510,6 +19682,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@3.2.6(vite@7.3.3(@types/node@22.13.17)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.3(@types/node@22.13.17)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(tsx@4.22.3)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.7 @@ -19522,15 +19702,31 @@ snapshots: dependencies: tinyrainbow: 2.0.0 + '@vitest/pretty-format@3.2.6': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.7': dependencies: tinyrainbow: 3.1.0 + '@vitest/runner@3.2.6': + dependencies: + '@vitest/utils': 3.2.6 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.1.7': dependencies: '@vitest/utils': 4.1.7 pathe: 2.0.3 + '@vitest/snapshot@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.1.7': dependencies: '@vitest/pretty-format': 4.1.7 @@ -19542,6 +19738,10 @@ snapshots: dependencies: tinyspy: 4.0.4 + '@vitest/spy@3.2.6': + dependencies: + tinyspy: 4.0.4 + '@vitest/spy@4.1.7': {} '@vitest/ui@4.1.7(vitest@4.1.7)': @@ -19561,6 +19761,12 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vitest/utils@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.1.7': dependencies: '@vitest/pretty-format': 4.1.7 @@ -20709,6 +20915,8 @@ snapshots: yargs: 17.7.2 yargs-parser: 21.1.1 + cac@6.7.14: {} + cacache@19.0.1: dependencies: '@npmcli/fs': 4.0.0 @@ -21692,6 +21900,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} es-module-shims@2.8.1: {} @@ -21811,8 +22021,8 @@ snapshots: '@babel/parser': 7.29.7 eslint: 10.4.1 hermes-parser: 0.25.1 - zod: 4.4.3 - zod-validation-error: 4.0.2(zod@4.4.3) + zod: 3.25.76 + zod-validation-error: 4.0.2(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -22393,6 +22603,8 @@ snapshots: grapheme-splitter@1.0.4: {} + graphql@16.14.2: {} + gray-matter@4.0.3: dependencies: js-yaml: 3.14.2 @@ -22425,7 +22637,7 @@ snapshots: happy-dom@20.9.0: dependencies: - '@types/node': 25.9.1 + '@types/node': 22.13.17 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -23045,6 +23257,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -25419,6 +25633,11 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@6.0.1: + dependencies: + glob: 11.1.0 + package-json-from-dist: 1.0.1 + rimraf@6.1.3: dependencies: glob: 13.0.6 @@ -26015,6 +26234,10 @@ snapshots: strip-json-comments@5.0.3: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + strnum@2.3.0: {} structured-source@4.0.0: @@ -26222,6 +26445,8 @@ snapshots: tinyclip@0.1.13: {} + tinyexec@0.3.2: {} + tinyexec@1.2.3: {} tinyglobby@0.2.16: @@ -26231,6 +26456,8 @@ snapshots: tinylogic@2.0.0: {} + tinypool@1.1.1: {} + tinyrainbow@2.0.0: {} tinyrainbow@3.1.0: {} @@ -26433,6 +26660,8 @@ snapshots: transitivePeerDependencies: - supports-color + typescript@5.8.3: {} + typescript@5.9.3: {} typescript@6.0.3: {} @@ -26457,6 +26686,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@6.20.0: {} + undici-types@7.16.0: {} undici-types@7.24.6: {} @@ -26674,6 +26905,27 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-node@3.2.4(@types/node@22.13.17)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.3(@types/node@22.13.17)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-plugin-checker@0.14.1(eslint@10.4.1)(optionator@0.9.4)(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@babel/code-frame': 7.29.7 @@ -26705,6 +26957,21 @@ snapshots: - typescript - webpack + vite@7.3.3(@types/node@22.13.17)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 22.13.17 + fsevents: 2.3.3 + lightningcss: 1.32.0 + tsx: 4.22.3 + yaml: 2.9.0 + vite@7.3.3(@types/node@25.9.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0): dependencies: esbuild: 0.27.7 @@ -26738,6 +27005,51 @@ snapshots: optionalDependencies: vite: 7.3.3(@types/node@25.9.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.13.17)(@vitest/ui@4.1.7)(happy-dom@20.9.0)(jsdom@25.0.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@7.3.3(@types/node@22.13.17)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.3(@types/node@22.13.17)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.13.17)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 22.13.17 + '@vitest/ui': 4.1.7(vitest@4.1.7) + happy-dom: 20.9.0 + jsdom: 25.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(@vitest/ui@4.1.7)(happy-dom@20.9.0)(jsdom@25.0.1)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(tsx@4.22.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 @@ -27090,9 +27402,9 @@ snapshots: dependencies: '@types/yoga-layout': 1.9.2 - zod-validation-error@4.0.2(zod@4.4.3): + zod-validation-error@4.0.2(zod@3.25.76): dependencies: - zod: 4.4.3 + zod: 3.25.76 zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 90ebc8990d3..eeb4605a371 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,7 +8,7 @@ packages: catalog: "@alloy-js/cli": ^0.23.0 - "@alloy-js/core": ^0.23.0 + "@alloy-js/core": ^0.23.1 "@alloy-js/csharp": ^0.23.0 "@alloy-js/markdown": ^0.23.0 "@alloy-js/msbuild": ^0.23.0 diff --git a/website/src/content/current-sidebar.ts b/website/src/content/current-sidebar.ts index b6e8393692f..70e65cac054 100644 --- a/website/src/content/current-sidebar.ts +++ b/website/src/content/current-sidebar.ts @@ -177,6 +177,13 @@ const sidebar: SidebarItem[] = [ ["emitters/protobuf/guide"], "preview", ), + createLibraryReferenceStructure( + "emitters/graphql", + "GraphQL", + false, + ["emitters/graphql/guide"], + "preview", + ), { label: "Clients", items: [ diff --git a/website/src/content/docs/docs/emitters/graphql/guide.md b/website/src/content/docs/docs/emitters/graphql/guide.md new file mode 100644 index 00000000000..4aafcd289f2 --- /dev/null +++ b/website/src/content/docs/docs/emitters/graphql/guide.md @@ -0,0 +1,236 @@ +--- +title: Guide +--- + +The GraphQL emitter (`@typespec/graphql`) generates GraphQL Schema Definition Language (SDL) from TypeSpec sources. The generated schemas can be used with any GraphQL server implementation. + +## Fundamental Concepts + +The GraphQL emitter transforms TypeSpec models, operations, and enums into their GraphQL equivalents. To generate a valid GraphQL schema, your TypeSpec must follow certain conventions. + +### Schemas + +A GraphQL schema is defined by applying the [`@schema`](../reference/decorators#@TypeSpec.GraphQL.schema) decorator to a TypeSpec namespace. All types and operations within the namespace will be emitted to a single GraphQL schema file. + +```typespec +import "@typespec/graphql"; + +using GraphQL; + +@schema +namespace MyService { + // Types and operations go here +} +``` + +You can optionally specify a name for the schema, which will be used in the output filename: + +```typespec +@schema(#{ name: "petstore" }) +namespace PetStore { + // ... +} +``` + +This will generate a file named `petstore.graphql`. + +### Object Types + +TypeSpec models are transformed into GraphQL object types. For example: + +```typespec +model Pet { + id: string; + name: string; + age: int32; +} +``` + +Becomes: + +```graphql +type Pet { + id: String! + name: String! + age: Int! +} +``` + +Note that all fields are non-nullable (`!`) by default in GraphQL. See [Nullability](#nullability) for how to make fields nullable. + +### Operations + +GraphQL supports three operation types: queries, mutations, and subscriptions. Use the corresponding decorators to specify the operation kind: + +```typespec +@query op getPet(id: string): Pet; +@query op listPets(): Pet[]; + +@mutation op createPet(name: string, age: int32): Pet; +@mutation op deletePet(id: string): boolean; + +@subscription op onPetCreated(): Pet; +``` + +This generates: + +```graphql +type Query { + getPet(id: String!): Pet! + listPets: [Pet!]! +} + +type Mutation { + createPet(name: String!, age: Int!): Pet! + deletePet(id: String!): Boolean! +} + +type Subscription { + onPetCreated: Pet! +} +``` + +### Input Types + +When a model is used as an operation parameter (mutation input), the emitter automatically generates a corresponding GraphQL input type with an `Input` suffix: + +```typespec +model Pet { + name: string; + age: int32; +} + +@mutation op createPet(input: Pet): Pet; +``` + +Generates: + +```graphql +input PetInput { + name: String! + age: Int! +} + +type Mutation { + createPet(input: PetInput!): Pet! +} +``` + +### Enums + +TypeSpec enums map to GraphQL enums with member names converted to `CONSTANT_CASE`: + +```typespec +enum PetStatus { + Available, + Pending, + Sold, +} +``` + +Becomes: + +```graphql +enum PetStatus { + AVAILABLE + PENDING + SOLD +} +``` + +### Nullability + +By default, all GraphQL types are non-nullable. To make a field or return type nullable, use TypeSpec's union with `null`: + +```typespec +model Pet { + id: string; + nickname: string | null; // Nullable field +} + +@query op findPet(id: string): Pet | null; // Nullable return +``` + +Generates: + +```graphql +type Pet { + id: String! + nickname: String # No ! means nullable +} + +type Query { + findPet(id: String!): Pet # Nullable return +} +``` + +For nullable array elements, the emitter handles `Array` patterns: + +```typespec +model SearchResult { + pets: (Pet | null)[]; # Array with nullable elements +} +``` + +Generates: + +```graphql +type SearchResult { + pets: [Pet]! # Non-null array, nullable elements +} +``` + +### Interfaces + +GraphQL interfaces allow you to define a common set of fields that multiple types can implement. Use the [`@graphqlInterface`](../reference/decorators#@TypeSpec.GraphQL.graphqlInterface) decorator: + +```typespec +@graphqlInterface(#{ interfaceOnly: true }) +model Node { + id: string; +} + +@compose(Node) +model Pet { + ...Node; + name: string; +} +``` + +Generates: + +```graphql +interface Node { + id: String! +} + +type Pet implements Node { + id: String! + name: String! +} +``` + +The `interfaceOnly` option prevents the model from also being emitted as an object type (useful for abstract interfaces like `Node`). + +## Type Mappings + +| TypeSpec Type | GraphQL Type | +|--------------|--------------| +| `string` | `String` | +| `boolean` | `Boolean` | +| `int32` | `Int` | +| `float32`, `float64` | `Float` | +| `GraphQL.ID` | `ID` | +| `T[]` | `[T!]!` | +| `T \| null` | `T` (nullable) | + +## Example Configuration + +To use the GraphQL emitter, add it to your `tspconfig.yaml`: + +```yaml +emit: + - "@typespec/graphql" +``` + +The emitter will generate `.graphql` files in your output directory. diff --git a/website/src/content/docs/docs/emitters/graphql/reference/data-types.md b/website/src/content/docs/docs/emitters/graphql/reference/data-types.md new file mode 100644 index 00000000000..693a6f780f4 --- /dev/null +++ b/website/src/content/docs/docs/emitters/graphql/reference/data-types.md @@ -0,0 +1,39 @@ +--- +title: "Data types" +description: "Data types exported by @typespec/graphql" +--- + +## TypeSpec.GraphQL + +### `ID` {#TypeSpec.GraphQL.ID} + +Represents a GraphQL ID scalar — a unique identifier serialized as a string. + +```typespec +scalar TypeSpec.GraphQL.ID +``` + +#### Examples + +```typespec +model User { + id: GraphQL.ID; + name: string; +} +``` + +## TypeSpec.GraphQL.Schema + +### `SchemaOptions` {#TypeSpec.GraphQL.Schema.SchemaOptions} + +Options for configuring a GraphQL schema. + +```typespec +model TypeSpec.GraphQL.Schema.SchemaOptions +``` + +#### Properties + +| Name | Type | Description | +| ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| name? | `string` | The name of the GraphQL schema. Used in the output filename when emitting
multiple schemas (e.g., `{name}.graphql`). Defaults to `"schema"`. | diff --git a/website/src/content/docs/docs/emitters/graphql/reference/decorators.md b/website/src/content/docs/docs/emitters/graphql/reference/decorators.md new file mode 100644 index 00000000000..82644f04c05 --- /dev/null +++ b/website/src/content/docs/docs/emitters/graphql/reference/decorators.md @@ -0,0 +1,272 @@ +--- +title: "Decorators" +description: "Decorators exported by @typespec/graphql" +toc_min_heading_level: 2 +toc_max_heading_level: 3 +--- + +## TypeSpec.GraphQL + +### `@compose` {#@TypeSpec.GraphQL.compose} + +Specify the GraphQL interfaces that should be implemented by a model. +The interfaces must be decorated with the + +```typespec +@TypeSpec.GraphQL.compose(...interfaces: Model[]) +``` + +#### Target + +`Model` + +#### Parameters + +| Name | Type | Description | +| ---------- | --------- | ----------- | +| interfaces | `Model[]` | | + +#### Examples + +````typespec +@compose(Influencer, Person) +model User { + ... Influencer; + ... Person; +} + + +### `@graphqlInterface` {#@TypeSpec.GraphQL.graphqlInterface} + +Mark this model as a GraphQL Interface. Interfaces can be implemented by other models. +```typespec +@TypeSpec.GraphQL.graphqlInterface(options?: valueof { interfaceOnly: boolean }) +```` + +#### Target + +`Model` + +#### Parameters + +| Name | Type | Description | +| ------- | --------------- | ----------- | +| options | `valueof {...}` | | + +#### Examples + +```typespec +@graphqlInterface(#{ interfaceOnly: true }) +model Node { + id: string; +} + +@graphqlInterface +model Person { + name: string; +} +``` + +### `@mutation` {#@TypeSpec.GraphQL.mutation} + +Specify the GraphQL Operation kind for the target operation to be `MUTATION`. + +```typespec +@TypeSpec.GraphQL.mutation +``` + +#### Target + +`Operation` + +#### Parameters + +None + +#### Examples + +```typespec +@mutation op update(): string; +``` + +### `@nullable` {#@TypeSpec.GraphQL.nullable} + +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. + +```typespec +@TypeSpec.GraphQL.nullable +``` + +#### Target + +`ModelProperty | Operation | Union | Model` + +#### Parameters + +None + +### `@nullableElements` {#@TypeSpec.GraphQL.nullableElements} + +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!]`. + +```typespec +@TypeSpec.GraphQL.nullableElements +``` + +#### Target + +`ModelProperty | Operation` + +#### Parameters + +None + +### `@oneOf` {#@TypeSpec.GraphQL.oneOf} + +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. + +```typespec +@TypeSpec.GraphQL.oneOf +``` + +#### Target + +`Model` + +#### Parameters + +None + +### `@operationFields` {#@TypeSpec.GraphQL.operationFields} + +Assign one or more operations or interfaces to act as fields with arguments on a model. + +```typespec +@TypeSpec.GraphQL.operationFields(...operations: Operation | Interface[]) +``` + +#### Target + +`Model` + +#### Parameters + +| Name | Type | Description | +| ---------- | -------------------------- | ----------- | +| operations | `Operation \| Interface[]` | | + +#### Examples + +````typespec +op followers(query: string): Person[]; + +@operationFields(followers) +model Person {} + + +### `@query` {#@TypeSpec.GraphQL.query} + +Specify the GraphQL Operation kind for the target operation to be `QUERY`. +```typespec +@TypeSpec.GraphQL.query +```` + +#### Target + +`Operation` + +#### Parameters + +None + +#### Examples + +```typespec +@query op read(): string; +``` + +### `@schema` {#@TypeSpec.GraphQL.schema} + +Mark this namespace as describing a GraphQL schema and configure schema properties. + +```typespec +@TypeSpec.GraphQL.schema(options?: valueof TypeSpec.GraphQL.Schema.SchemaOptions) +``` + +#### Target + +`Namespace` + +#### Parameters + +| Name | Type | Description | +| ------- | -------------------------------------------------------------------------------- | ----------- | +| options | [valueof `SchemaOptions`](./data-types.md#TypeSpec.GraphQL.Schema.SchemaOptions) | | + +#### Examples + +```typespec +@schema(#{ name: "MySchema" }) +namespace MySchema { + +} +``` + +### `@specifiedBy` {#@TypeSpec.GraphQL.specifiedBy} + +Provide a specification URL for a custom GraphQL scalar type. +This maps to the `@specifiedBy` directive in the emitted GraphQL schema. + +```typespec +@TypeSpec.GraphQL.specifiedBy(url: valueof url) +``` + +#### Target + +`Scalar` + +#### Parameters + +| Name | Type | Description | +| ---- | ------------- | ------------------------------------ | +| url | `valueof url` | URL to the scalar type specification | + +#### Examples + +```typespec +@specifiedBy("https://scalars.graphql.org/jakobmerrild/long.html") +scalar Long extends int64; +``` + +### `@subscription` {#@TypeSpec.GraphQL.subscription} + +Specify the GraphQL Operation kind for the target operation to be `SUBSCRIPTION`. + +```typespec +@TypeSpec.GraphQL.subscription +``` + +#### Target + +`Operation` + +#### Parameters + +None + +#### Examples + +```typespec +@subscription op get_periodically(): string; +``` diff --git a/website/src/content/docs/docs/emitters/graphql/reference/emitter.md b/website/src/content/docs/docs/emitters/graphql/reference/emitter.md new file mode 100644 index 00000000000..178aed61f4f --- /dev/null +++ b/website/src/content/docs/docs/emitters/graphql/reference/emitter.md @@ -0,0 +1,71 @@ +--- +title: "Emitter usage" +--- + +## Emitter usage + +1. Via the command line + +```bash +tsp compile . --emit=@typespec/graphql +``` + +2. Via the config + +```yaml +emit: + - "@typespec/graphql" +``` + +The config can be extended with options as follows: + +```yaml +emit: + - "@typespec/graphql" +options: + "@typespec/graphql": + option: value +``` + +## Emitter options + +### `emitter-output-dir` + +**Type:** `absolutePath` + +Defines the emitter output directory. Defaults to `{output-dir}/@typespec/graphql` +See [Configuring output directory for more info](https://typespec.io/docs/handbook/configuration/configuration/#configuring-output-directory) + +### `output-file` + +**Type:** `string` + +Name of the output file. +Output file will interpolate the following values: + +- schema-name: Name of the schema if multiple + +Default: `{schema-name}.graphql` + +Example Single schema + +- `schema.graphql` + +Example Multiple schemas + +- `Org1.Schema1.graphql` +- `Org1.Schema2.graphql` + +### `new-line` + +**Type:** `"crlf" | "lf"` + +Set the newLine character for emitting files. + +### `omit-unreachable-types` + +**Type:** `boolean` + +Omit unreachable types. +By default all types declared under the schema namespace will be included. +With this flag on only types references in an operation will be emitted. diff --git a/website/src/content/docs/docs/emitters/graphql/reference/index.mdx b/website/src/content/docs/docs/emitters/graphql/reference/index.mdx new file mode 100644 index 00000000000..54733fc01ab --- /dev/null +++ b/website/src/content/docs/docs/emitters/graphql/reference/index.mdx @@ -0,0 +1,55 @@ +--- +title: Overview +sidebar_position: 0 +toc_min_heading_level: 2 +toc_max_heading_level: 3 +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +TypeSpec library for emitting GraphQL + +## Install + + + + +```bash +npm install @typespec/graphql +``` + + + + +```bash +npm install --save-peer @typespec/graphql +``` + + + + +## Emitter usage + +[See documentation](./emitter.md) + +## TypeSpec.GraphQL + +### Decorators + +- [`@compose`](./decorators.md#@TypeSpec.GraphQL.compose) +- [`@graphqlInterface`](./decorators.md#@TypeSpec.GraphQL.graphqlInterface) +- [`@mutation`](./decorators.md#@TypeSpec.GraphQL.mutation) +- [`@nullable`](./decorators.md#@TypeSpec.GraphQL.nullable) +- [`@nullableElements`](./decorators.md#@TypeSpec.GraphQL.nullableElements) +- [`@oneOf`](./decorators.md#@TypeSpec.GraphQL.oneOf) +- [`@operationFields`](./decorators.md#@TypeSpec.GraphQL.operationFields) +- [`@query`](./decorators.md#@TypeSpec.GraphQL.query) +- [`@schema`](./decorators.md#@TypeSpec.GraphQL.schema) +- [`@specifiedBy`](./decorators.md#@TypeSpec.GraphQL.specifiedBy) +- [`@subscription`](./decorators.md#@TypeSpec.GraphQL.subscription) + +## TypeSpec.GraphQL.Schema + +### Models + +- [`SchemaOptions`](./data-types.md#TypeSpec.GraphQL.Schema.SchemaOptions)