diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 05568ce85a2..6cc1dc94063 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/ @steverice @swatkatz @fionabronwen @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/emitter-framework/tsconfig.json b/packages/emitter-framework/tsconfig.json index dba4a03a4ea..c151945e895 100644 --- a/packages/emitter-framework/tsconfig.json +++ b/packages/emitter-framework/tsconfig.json @@ -7,6 +7,7 @@ "strict": true, "skipLibCheck": true, "isolatedModules": true, + "composite": true, "declaration": true, "sourceMap": true, "declarationMap": true, diff --git a/packages/graphql/CHANGELOG.md b/packages/graphql/CHANGELOG.md new file mode 100644 index 00000000000..f682fb88cd1 --- /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 `@Interface` 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..d7023093bc7 --- /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) +- [`@Interface`](#interface) +- [`@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; +``` + +#### `@Interface` + +Mark a model as a GraphQL Interface. Interfaces can be implemented by other models using `@compose`. + +```typespec +@Interface(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 +@Interface(#{ interfaceOnly: true }) +model Node { + id: string; +} + +@Interface +model Reactable { + reactions: Reaction[]; +} +``` + +#### `@compose` + +Specify the GraphQL interfaces that should be implemented by a model. The interfaces must be decorated with the `@Interface` 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 +@Interface(#{ 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..7210e1bf2aa --- /dev/null +++ b/packages/graphql/lib/interface.tsp @@ -0,0 +1,44 @@ +import "../dist/src/lib/interface.js"; + +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 + * @Interface(#{interfaceOnly: true}) + * model Node { + * id: string; + * } + * + * @Interface + * model Person { + * name: string; + * } + * ``` + */ +extern dec Interface(target: Model, options?: valueof {interfaceOnly?: boolean}); + +/** + * Specify the GraphQL interfaces that should be implemented by a model. + * The interfaces must be decorated with the @Interface 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..7685a266f3e --- /dev/null +++ b/packages/graphql/lib/main.tsp @@ -0,0 +1,8 @@ +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..dfbbeb1993a --- /dev/null +++ b/packages/graphql/lib/nullable.tsp @@ -0,0 +1,22 @@ +import "../dist/src/lib/nullable.js"; + +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +/** + * Mark a field, operation, or type as nullable in the emitted GraphQL schema. + * + * Applied automatically by the mutation engine when it strips `| null` from + * union types. The decorator's presence on the type's `decorators` array is + * the signal — the implementation is a no-op. + */ +extern dec nullable(target: ModelProperty | Operation | Union | Model); + +/** + * Mark a field or operation as having nullable array elements in the emitted GraphQL schema. + * + * Applied automatically by the mutation engine when it detects `Array` + * patterns. Causes the emitter to emit `[T]` instead of `[T!]`. + */ +extern dec nullableElements(target: ModelProperty | Operation); diff --git a/packages/graphql/lib/one-of.tsp b/packages/graphql/lib/one-of.tsp new file mode 100644 index 00000000000..0482f973c00 --- /dev/null +++ b/packages/graphql/lib/one-of.tsp @@ -0,0 +1,16 @@ +import "../dist/src/lib/one-of.js"; + +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +/** + * Mark a model as a `@oneOf` input object in the emitted GraphQL schema. + * + * This decorator is applied automatically by the mutation engine when it converts + * a union type in input context to a synthetic input object (since GraphQL unions + * are output-only). The emitter uses this to emit the `@oneOf` directive. + * + * @see https://spec.graphql.org/September2025/#sec-OneOf-Input-Objects + */ +extern dec oneOf(target: Model); diff --git a/packages/graphql/lib/operation-fields.tsp b/packages/graphql/lib/operation-fields.tsp new file mode 100644 index 00000000000..80f5dcc48fb --- /dev/null +++ b/packages/graphql/lib/operation-fields.tsp @@ -0,0 +1,20 @@ +import "../dist/src/lib/operation-fields.js"; + +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..088b07e55c8 --- /dev/null +++ b/packages/graphql/lib/operation-kind.tsp @@ -0,0 +1,38 @@ +import "../dist/src/lib/operation-kind.js"; + +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..8b7e4473f26 --- /dev/null +++ b/packages/graphql/lib/schema.tsp @@ -0,0 +1,28 @@ +import "../dist/src/lib/schema.js"; + +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..8f7f95f1c2b --- /dev/null +++ b/packages/graphql/lib/specified-by.tsp @@ -0,0 +1,19 @@ +import "../dist/src/lib/specified-by.js"; + +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..83f906daea3 --- /dev/null +++ b/packages/graphql/package.json @@ -0,0 +1,72 @@ +{ + "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": ">=20.0.0" + }, + "dependencies": { + "@alloy-js/core": "^0.22.0", + "@alloy-js/graphql": "link:../../../alloy/packages/graphql", + "@alloy-js/typescript": "^0.22.0", + "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" + }, + "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.22.0", + "@alloy-js/rollup-plugin": "^0.1.0", + "@types/node": "~22.13.13", + "@typespec/compiler": "workspace:~", + "@typespec/emitter-framework": "workspace:~", + "@typespec/http": "workspace:~", + "@typespec/mutator-framework": "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..ff475b07f92 --- /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 "@alloy-js/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..f6af1194b60 --- /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 "@alloy-js/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..be4a3070ddf --- /dev/null +++ b/packages/graphql/src/components/schema.tsx @@ -0,0 +1,82 @@ +import { type Model } from "@typespec/compiler"; +import * as gql from "@alloy-js/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..8b98089d2d1 --- /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 "@alloy-js/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..3d3788146b8 --- /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 "@alloy-js/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..66463f0fec1 --- /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 "@alloy-js/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..a401895874e --- /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 "@alloy-js/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..4ea4410fbc6 --- /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 "@alloy-js/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..387861f03b1 --- /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 "@alloy-js/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..450f0604472 --- /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 "@alloy-js/graphql"; +import { printSchema } from "graphql"; +import { Schema } from "./components/schema.js"; +import { GraphQLSchemaContext } from "./context/index.js"; +import { type GraphQLEmitterOptions, reportDiagnostic } 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); + 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); + + const hasQueryOps = [...typeGraph.globalNamespace.operations.values()].some( + (op) => getOperationKind(program, op) !== undefined, + ); + if (!hasQueryOps) { + reportDiagnostic(program, { + code: "empty-schema", + target: schema, + }); + 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..c2dfcc0275d --- /dev/null +++ b/packages/graphql/src/lib.ts @@ -0,0 +1,188 @@ +import { createTypeSpecLibrary, paramMessage, type JSONSchemaType } from "@typespec/compiler"; + +export const NAMESPACE = "TypeSpec.GraphQL"; + +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 as an \`@Interface\`, 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.", + }, + }, + }, + emitter: { + options: EmitterOptionsSchema as JSONSchemaType, + }, + 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..fcd2ba08965 --- /dev/null +++ b/packages/graphql/src/lib/input-type.ts @@ -0,0 +1,18 @@ +import type { DecoratorContext, DecoratorFunction, Model, Type } from "@typespec/compiler"; +import { NAMESPACE } from "../lib.js"; + +export const namespace = NAMESPACE; + +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..d8e8894c7b9 --- /dev/null +++ b/packages/graphql/src/lib/interface.ts @@ -0,0 +1,159 @@ +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, NAMESPACE, reportDiagnostic } from "../lib.js"; +import { propertiesEqual } from "./utils.js"; + +declare const tags: unique symbol; +type Tagged = BaseType & { [tags]: { [K in Tag]: void } }; + +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +/** 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 $Interface: DecoratorFunction = ( + context: DecoratorContext, + target: Model, + options?: { interfaceOnly?: boolean }, +) => { + validateDecoratorUniqueOnNode(context, target, $Interface); + 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..15d0daa18ec --- /dev/null +++ b/packages/graphql/src/lib/nullable.ts @@ -0,0 +1,80 @@ +import type { + DecoratedType, + DecoratorContext, + DecoratorFunction, + Model, + ModelProperty, + Operation, + Type, + Union, +} from "@typespec/compiler"; +import { NAMESPACE } from "../lib.js"; + +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +/** + * Decorator implementation for `@nullable`. + * + * No-op — the decorator's presence on the type's `decorators` array is the + * signal. No additional state storage is needed. + */ +export const $nullable: DecoratorFunction = ( + _context: DecoratorContext, + _target: ModelProperty | Operation | Union | Model, +) => {}; + +/** + * Decorator implementation for `@nullableElements`. + * + * No-op — presence on the decorators array is the signal. + */ +export const $nullableElements: DecoratorFunction = ( + _context: DecoratorContext, + _target: ModelProperty | Operation, +) => {}; + +/** + * Check whether a type was marked nullable after null-variant stripping. + * + * 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..c24c33ee448 --- /dev/null +++ b/packages/graphql/src/lib/one-of.ts @@ -0,0 +1,34 @@ +import type { DecoratorContext, DecoratorFunction, Model } from "@typespec/compiler"; +import { NAMESPACE } from "../lib.js"; + +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +/** + * Decorator implementation for `@oneOf`. + * + * No-op — the decorator's presence on the type's `decorators` array is the + * signal. No additional state storage is needed. + */ +export const $oneOf: DecoratorFunction = ( + _context: DecoratorContext, + _target: Model, +) => {}; + +/** + * Check if a model has been marked as a @oneOf input object. + * 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..3000d14095b --- /dev/null +++ b/packages/graphql/src/lib/operation-fields.ts @@ -0,0 +1,111 @@ +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, NAMESPACE, reportDiagnostic } from "../lib.js"; +import { operationsEqual } from "./utils.js"; + +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +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..5b7c401f121 --- /dev/null +++ b/packages/graphql/src/lib/operation-kind.ts @@ -0,0 +1,65 @@ +import { type DecoratorContext, type Operation } from "@typespec/compiler"; +import { SyntaxKind } from "@typespec/compiler/ast"; +import { useStateMap } from "@typespec/compiler/utils"; +import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js"; + +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +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..e41579d2652 --- /dev/null +++ b/packages/graphql/src/lib/schema.ts @@ -0,0 +1,77 @@ +import { + type DecoratorContext, + type DecoratorFunction, + type Namespace, + type Program, + validateDecoratorUniqueOnNode, +} from "@typespec/compiler"; + +import { useStateMap } from "@typespec/compiler/utils"; +import { GraphQLKeys, NAMESPACE } from "../lib.js"; + +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +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..1dd4907da35 --- /dev/null +++ b/packages/graphql/src/lib/specified-by.ts @@ -0,0 +1,32 @@ +import { + type DecoratorContext, + type DecoratorFunction, + type Program, + type Scalar, + validateDecoratorUniqueOnNode, +} from "@typespec/compiler"; +import { useStateMap } from "@typespec/compiler/utils"; +import { GraphQLKeys, NAMESPACE } from "../lib.js"; + +// This will set the namespace for decorators implemented in this file +export const namespace = NAMESPACE; + +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..90e0c82f1f6 --- /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..2ac9155ee10 --- /dev/null +++ b/packages/graphql/src/tsp-index.ts @@ -0,0 +1,20 @@ +import type { DecoratorImplementations } from "@typespec/compiler"; +import { NAMESPACE } from "./lib.js"; +import { $compose, $Interface } from "./lib/interface.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"; + +export const $decorators: DecoratorImplementations = { + [NAMESPACE]: { + compose: $compose, + Interface: $Interface, + mutation: $mutation, + query: $query, + operationFields: $operationFields, + 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/test/components/enum-type.test.tsx b/packages/graphql/test/components/enum-type.test.tsx new file mode 100644 index 00000000000..dbc52639e86 --- /dev/null +++ b/packages/graphql/test/components/enum-type.test.tsx @@ -0,0 +1,102 @@ +import { t } 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: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic enum", async () => { + const { Color } = await tester.compile( + t.code`enum ${t.enum("Color")} { Red, Green, Blue }`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutated = engine.mutateEnum(Color).mutatedType; + + const sdl = 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..069748c310a --- /dev/null +++ b/packages/graphql/test/components/input-type.test.tsx @@ -0,0 +1,78 @@ +import { type Model } from "@typespec/compiler"; +import { t } 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: Awaited>; + 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..3e5c7903409 --- /dev/null +++ b/packages/graphql/test/components/interface-type.test.tsx @@ -0,0 +1,57 @@ +import { t } 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: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic interface type", async () => { + const { Node } = await tester.compile( + t.code`@Interface 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`@Interface(#{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 */ + @Interface 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..96bd9a53a1f --- /dev/null +++ b/packages/graphql/test/components/object-type.test.tsx @@ -0,0 +1,86 @@ +import { type Model } from "@typespec/compiler"; +import { t } 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: Awaited>; + 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` + @Interface 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..d71ed1b89bf --- /dev/null +++ b/packages/graphql/test/components/scalar-type.test.tsx @@ -0,0 +1,79 @@ +import { t } 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: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a custom scalar", async () => { + const { DateTime } = await tester.compile( + t.code`scalar ${t.scalar("DateTime")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const 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..e88af9200e5 --- /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 "@alloy-js/graphql"; +import { renderSchema } from "@alloy-js/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..897bdd67d48 --- /dev/null +++ b/packages/graphql/test/components/union-type.test.tsx @@ -0,0 +1,140 @@ +import { t } from "@typespec/compiler/testing"; +import * as gql from "@alloy-js/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: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a union of model types", async () => { + const { Pet } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + union ${t.union("Pet")} { cat: Cat; dog: Dog; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + const mutatedUnion = 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..68df36ea89e --- /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 | `@Interface` (default) → `ReactableInterface` suffix | Correct | +| 3 | `@Interface(#{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..9477579083b --- /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" } + + @Interface(#{interfaceOnly: true}) + model Node { id: GraphQL.ID; } + + @Interface(#{interfaceOnly: true}) + model Connection { totalCount: int32; hasNextPage: boolean; } + + @Interface + 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; } + + @Interface(#{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..960ab8d784c --- /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 { + @Interface 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 { + @Interface(#{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 { + @Interface(#{interfaceOnly: true}) + model Node { id: GraphQL.ID; } + + @Interface + 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..fec0f995e06 --- /dev/null +++ b/packages/graphql/test/emitter.test.ts @@ -0,0 +1,74 @@ +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, {}); + const errors = result.diagnostics.filter((d) => d.severity === "error"); + expect(errors).toHaveLength(0); + 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..576b393f590 --- /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("@Interface", () => { + it("Marks the model as an interface", async () => { + const { TestModel, program } = await Tester.compile(t.code` + @Interface + 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` + @Interface + 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` + @Interface + model ${t.model("FirstInterface")} {} + @Interface + 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(` + @Interface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel { + ...AnInterface; + } + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("Can extend properties from the interface", async () => { + const diagnostics = await Tester.diagnose(` + @Interface model AnInterface { + prop: string; + } + + @compose(AnInterface) + model TestModel extends AnInterface {} + `); + expectDiagnosticEmpty(diagnostics); + }); + + it("Can copy the interface", async () => { + const diagnostics = await Tester.diagnose(` + @Interface 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(` + @Interface 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 as an `@Interface`, but NotAnInterface is not.", + }); + }); + + it("Requires that all implemented models are Interfaces", async () => { + const diagnostics = await Tester.diagnose(` + @Interface 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 as an `@Interface`, but NotAnInterface is not.", + }); + }); + + it("Allows Interfaces to implement other Interfaces", async () => { + const { AnInterface, AnotherInterface, program } = await Tester.compile(t.code` + @Interface + model ${t.model("AnotherInterface")} {} + + @compose(AnotherInterface) + @Interface + 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) + @Interface + @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(` + @Interface 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(` + @Interface 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(` + @Interface 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..c54cbc7b740 --- /dev/null +++ b/packages/graphql/test/lib/template-composition.test.ts @@ -0,0 +1,113 @@ +import { resolvePath, type Model } from "@typespec/compiler"; +import { createTester, t } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { composeTemplateName } from "../../src/lib/template-composition.js"; + +const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { + libraries: [], +}); + +describe("composeTemplateName", () => { + let tester: Awaited>; + + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + function getReturnType(ns: Record, opName: string): Model { + return ns.operations.get(opName)!.returnType as Model; + } + + 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..d46d94c9ee9 --- /dev/null +++ b/packages/graphql/test/mutation-engine/context.test.ts @@ -0,0 +1,184 @@ +import type { Model } from "@typespec/compiler"; +import { t } 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: Awaited>; + 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: Awaited>; + 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..6e90513dbf9 --- /dev/null +++ b/packages/graphql/test/mutation-engine/enums.test.ts @@ -0,0 +1,94 @@ +import type { EnumMember } from "@typespec/compiler"; +import { t } 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: Awaited>; + 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( + t.code`enum ${t.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: Awaited>; + 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..af099036ea8 --- /dev/null +++ b/packages/graphql/test/mutation-engine/models.test.ts @@ -0,0 +1,255 @@ +import { isArrayModelType, type Model } from "@typespec/compiler"; +import { t } 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: Awaited>; + 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(t.code`model ${t.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: Awaited>; + 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: Awaited>; + 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: Awaited>; + 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: Awaited>; + 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..b5e2e60c6be --- /dev/null +++ b/packages/graphql/test/mutation-engine/naming-integration.test.ts @@ -0,0 +1,121 @@ +import type { Model } from "@typespec/compiler"; +import { t } 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: Awaited>; + + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + describe("Model naming", () => { + it("PascalCases model names", async () => { + await tester.compile(t.code`model ${t.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(t.code`model ${t.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(t.code`model ${t.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(t.code`model ${t.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(t.code`enum ${t.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(t.code`enum ${t.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..55745aedeff --- /dev/null +++ b/packages/graphql/test/mutation-engine/operations.test.ts @@ -0,0 +1,77 @@ +import { t } 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: Awaited>; + 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(t.code`op ${t.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(t.code`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..ea909553db2 --- /dev/null +++ b/packages/graphql/test/mutation-engine/print-type.test.ts @@ -0,0 +1,107 @@ +import { t } 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: Awaited>; + + 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..32c95640ebf --- /dev/null +++ b/packages/graphql/test/mutation-engine/scalars.test.ts @@ -0,0 +1,193 @@ +import { t } 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: Awaited>; + 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..f89eface0bc --- /dev/null +++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts @@ -0,0 +1,322 @@ +import { t } 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: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("produces a TypeGraph with mutated models", async () => { + await tester.compile( + t.code` + model ${t.model("ad_account")} { id: int32; } + op ${t.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( + t.code` + op ${t.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( + t.code` + enum ${t.enum("status")} { active, inactive } + model ${t.model("Foo")} { s: status; } + op ${t.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( + 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 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( + t.code` + model ${t.model("Reachable")} { x: int32; } + model ${t.model("Unreachable")} { y: string; } + op ${t.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( + t.code` + model ${t.model("Reachable")} { x: int32; } + model ${t.model("Unreachable")} { y: string; } + op ${t.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( + t.code` + union ${t.union("MaybeString")} { string, null } + model ${t.model("Foo")} { x: int32; } + op ${t.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( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; num: int32; } + op ${t.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( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + op ${t.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( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.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( + t.code` + model ${t.model("Book")} { title: string; } + model ${t.model("Payload")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + op ${t.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( + t.code` + model ${t.model("Book")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + op ${t.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 () => { + await tester.compile( + t.code` + @Interface model ${t.model("Animal")} { name: string; } + @compose(Animal) + model ${t.model("Cat")} { name: string; breed: string; } + op ${t.op("getCat")}(): Cat; + op ${t.op("createCat")}(input: Cat): Cat; + `, + ); + + const ns = tester.program.getGlobalNamespaceType(); + const sourceCat = ns.models.get("Cat")!; + const sourceComposeArg = sourceCat.decorators.find( + (d) => d.decorator.name === "$compose", + )?.args[0]; + + 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 @Interface model used as output does not produce name collision", async () => { + await tester.compile( + t.code` + @Interface(#{interfaceOnly: true}) model ${t.model("Node")} { id: string; } + op ${t.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 () => { + const [_, diagnostics] = await tester.compileAndDiagnose( + t.code` + model ${t.model("BookInput")} { x: int32; } + model ${t.model("Book")} { title: string; } + op ${t.op("getBooks")}(): Book[]; + op ${t.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( + t.code` + model ${t.model("Item")} { name: string; } + op ${t.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..e396943f253 --- /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: Awaited>; + + 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..723d2d01637 --- /dev/null +++ b/packages/graphql/test/mutation-engine/unions.test.ts @@ -0,0 +1,592 @@ +import { getDoc, type Model, type Union } from "@typespec/compiler"; +import { t } 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: Awaited>; + 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( + t.code` + model ${t.model("Foo")} { x: int32; } + model ${t.model("Bar")} { y: string; } + op ${t.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( + t.code` + model ${t.model("ad_account")} { id: int32; } + model ${t.model("board")} { title: string; } + union ${t.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( + t.code` + model ${t.model("ad_account")} { id: int32; } + union ${t.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( + t.code` + model ${t.model("ad_account")} { id: int32; } + union ${t.union("Inner")} { a: ad_account; } + union ${t.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: Awaited>; + 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( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + union ${t.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( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + union ${t.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..dc9c4c7f0ac --- /dev/null +++ b/packages/graphql/test/mutation-engine/visibility.test.ts @@ -0,0 +1,270 @@ +import type { Model } from "@typespec/compiler"; +import { t } 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: Awaited>; + 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` + @Interface(#{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..5411aaf0ffd --- /dev/null +++ b/packages/graphql/test/schema.test.ts @@ -0,0 +1,30 @@ +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")} {} + `); + + 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")} {} + `); + + 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..e981a290e66 --- /dev/null +++ b/packages/graphql/test/type-usage.test.ts @@ -0,0 +1,251 @@ +import { t } 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: Awaited>; + 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/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/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",