diff --git a/.chronus/changes/auto-decorator-setter-2026-7-14-11-0-0.md b/.chronus/changes/auto-decorator-setter-2026-7-14-11-0-0.md new file mode 100644 index 00000000000..831f769d2f2 --- /dev/null +++ b/.chronus/changes/auto-decorator-setter-2026-7-14-11-0-0.md @@ -0,0 +1,13 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Add `setAutoDecorator` API to programmatically apply an `auto` decorator to a target, mirroring what the synthesized `auto dec` implementation does when the decorator is written in source. This lets emitters and mutators mark synthetic types without reaching into the program state map directly. + +```ts +import { setAutoDecorator } from "@typespec/compiler"; + +setAutoDecorator(program, "MyLib.myFlag", target); +``` diff --git a/.chronus/changes/tester-mount-library-tspconfig-2026-7-14-11-0-2.md b/.chronus/changes/tester-mount-library-tspconfig-2026-7-14-11-0-2.md new file mode 100644 index 00000000000..70b623b5b66 --- /dev/null +++ b/.chronus/changes/tester-mount-library-tspconfig-2026-7-14-11-0-2.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +`createTester` now mounts each discovered library's `tspconfig.yaml` into the virtual file system, so experimental features a library opts into (e.g. `auto-decorators`) are honored when compiling against the tester. diff --git a/.chronus/changes/tspd-auto-decorator-setter-2026-7-14-11-0-1.md b/.chronus/changes/tspd-auto-decorator-setter-2026-7-14-11-0-1.md new file mode 100644 index 00000000000..84bbb9b0a5a --- /dev/null +++ b/.chronus/changes/tspd-auto-decorator-setter-2026-7-14-11-0-1.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/tspd" +--- + +`tspd gen-extern-signature` now also generates a typed setter (e.g. `setMyFlag`, `setMyLabel`) for each `auto` decorator, alongside the existing `is*`/`get*` readers. diff --git a/packages/graphql/generated-defs/TypeSpec.GraphQL.ts b/packages/graphql/generated-defs/TypeSpec.GraphQL.ts new file mode 100644 index 00000000000..16ea56e7827 --- /dev/null +++ b/packages/graphql/generated-defs/TypeSpec.GraphQL.ts @@ -0,0 +1,220 @@ +import { + type DecoratorContext, + type DecoratorValidatorCallbacks, + hasAutoDecorator, + type Interface, + type Model, + type ModelProperty, + type Namespace, + type Operation, + type Program, + type Scalar, + setAutoDecorator, + type Union, +} from "@typespec/compiler"; + +export interface SchemaOptions { + readonly name?: string; +} + +/** + * Mark this model as a GraphQL Interface. Interfaces can be implemented by other models + * using the `@compose` decorator. + * + * @param options .interfaceOnly When true, the model will only be emitted as an interface + * (no "Interface" suffix is added to the name). Use this for abstract interfaces that + * will never be used directly as output/input types (e.g., Node, Connection). Defaults to false. + * @example + * ```typespec + * @graphqlInterface(#{ interfaceOnly: true }) + * model Node { + * id: string; + * } + * + * @compose(Node) + * model User { + * ...Node; + * name: string; + * } + * // Emits: interface Node { id: String! } + * // type User implements Node { id: String!; name: String! } + * ``` + */ +export type GraphqlInterfaceDecorator = ( + context: DecoratorContext, + target: Model, + options?: { + readonly interfaceOnly?: boolean; + }, +) => DecoratorValidatorCallbacks | void; + +/** + * Specify the GraphQL interfaces that should be implemented by a model. + * The interfaces must be decorated with the `@graphqlInterface` decorator, + * and all of the interfaces' properties must be present and compatible. + * + * @example + * ```typespec + * @graphqlInterface(#{ interfaceOnly: true }) + * model Node { + * id: string; + * } + * + * @compose(Node) + * model User { + * ...Node; + * name: string; + * } + * ``` + */ +export type ComposeDecorator = ( + context: DecoratorContext, + target: Model, + ...interfaces: Model[] +) => DecoratorValidatorCallbacks | void; + +/** + * Assign one or more operations or interfaces to act as fields with arguments on a model. + * The operations become fields on the GraphQL type with their parameters as arguments. + * + * @example + * ```typespec + * op followers(query: string): Person[]; + * + * @operationFields(followers) + * model Person { + * name: string; + * } + * // Emits: type Person { name: String!; followers(query: String!): [Person!]! } + * ``` + */ +export type OperationFieldsDecorator = ( + context: DecoratorContext, + target: Model, + ...operations: (Operation | Interface)[] +) => DecoratorValidatorCallbacks | void; + +/** + * Specify the GraphQL Operation kind for the target operation to be `MUTATION`. + * + * @example + * ```typespec + * @mutation op createUser(name: string): User; + * ``` + */ +export type MutationDecorator = ( + context: DecoratorContext, + target: Operation, +) => DecoratorValidatorCallbacks | void; + +/** + * Specify the GraphQL Operation kind for the target operation to be `QUERY`. + * + * @example + * ```typespec + * @query op getUser(id: string): User; + * ``` + */ +export type QueryDecorator = ( + context: DecoratorContext, + target: Operation, +) => DecoratorValidatorCallbacks | void; + +/** + * Specify the GraphQL Operation kind for the target operation to be `SUBSCRIPTION`. + * + * @example + * ```typespec + * @subscription op onUserCreated(): User; + * ``` + */ +export type SubscriptionDecorator = ( + context: DecoratorContext, + target: Operation, +) => DecoratorValidatorCallbacks | void; + +/** + * Mark this namespace as describing a GraphQL schema and configure schema properties. + * All types and operations within the namespace will be emitted to a single GraphQL schema file. + * + * @example + * ```typespec + * @schema(#{ name: "MyAPI" }) + * namespace MyAPI { + * model User { id: string; name: string; } + * @query op getUser(id: string): User; + * } + * // Emits: MyAPI.graphql + * ``` + */ +export type SchemaDecorator = ( + context: DecoratorContext, + target: Namespace, + options?: SchemaOptions, +) => DecoratorValidatorCallbacks | void; + +/** + * 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/andimarek/date-time") + * scalar DateTime extends utcDateTime; + * ``` + */ +export type SpecifiedByDecorator = ( + context: DecoratorContext, + target: Scalar, + url: string, +) => DecoratorValidatorCallbacks | void; + +export type TypeSpecGraphQLDecorators = { + graphqlInterface: GraphqlInterfaceDecorator; + compose: ComposeDecorator; + operationFields: OperationFieldsDecorator; + mutation: MutationDecorator; + query: QueryDecorator; + subscription: SubscriptionDecorator; + schema: SchemaDecorator; + specifiedBy: SpecifiedByDecorator; +}; + +export function isInputType(program: Program, target: Model): boolean { + return hasAutoDecorator(program, "TypeSpec.GraphQL.inputType", target); +} + +export function setInputType(program: Program, target: Model): void { + setAutoDecorator(program, "TypeSpec.GraphQL.inputType", target); +} + +export function isNullable( + program: Program, + target: ModelProperty | Operation | Union | Model, +): boolean { + return hasAutoDecorator(program, "TypeSpec.GraphQL.nullable", target); +} + +export function setNullable( + program: Program, + target: ModelProperty | Operation | Union | Model, +): void { + setAutoDecorator(program, "TypeSpec.GraphQL.nullable", target); +} + +export function isNullableElements(program: Program, target: ModelProperty | Operation): boolean { + return hasAutoDecorator(program, "TypeSpec.GraphQL.nullableElements", target); +} + +export function setNullableElements(program: Program, target: ModelProperty | Operation): void { + setAutoDecorator(program, "TypeSpec.GraphQL.nullableElements", target); +} + +export function isOneOf(program: Program, target: Model): boolean { + return hasAutoDecorator(program, "TypeSpec.GraphQL.oneOf", target); +} + +export function setOneOf(program: Program, target: Model): void { + setAutoDecorator(program, "TypeSpec.GraphQL.oneOf", target); +} diff --git a/packages/graphql/generated-defs/TypeSpec.GraphQL.ts-test.ts b/packages/graphql/generated-defs/TypeSpec.GraphQL.ts-test.ts new file mode 100644 index 00000000000..5224d584e53 --- /dev/null +++ b/packages/graphql/generated-defs/TypeSpec.GraphQL.ts-test.ts @@ -0,0 +1,10 @@ +// An error in the imports would mean that the decorator is not exported or +// doesn't have the right name. + +import { $decorators } from "@typespec/graphql"; +import type { TypeSpecGraphQLDecorators } from "./TypeSpec.GraphQL.js"; + +/** + * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... + */ +const _decs: TypeSpecGraphQLDecorators = $decorators["TypeSpec.GraphQL"]; diff --git a/packages/graphql/lib/input-type.tsp b/packages/graphql/lib/input-type.tsp new file mode 100644 index 00000000000..267cd7e1b9c --- /dev/null +++ b/packages/graphql/lib/input-type.tsp @@ -0,0 +1,12 @@ +using TypeSpec.Reflection; + +namespace TypeSpec.GraphQL; + +/** + * Mark a model as a GraphQL input type in the emitted schema. + * + * This decorator is applied automatically by the mutation engine when it produces + * a model that is used in input position. The emitter uses this to emit the model + * as an `input` type rather than an object `type`. + */ +internal auto dec inputType(target: Model); diff --git a/packages/graphql/lib/main.tsp b/packages/graphql/lib/main.tsp index 4e241c72b29..a05ff78a47e 100644 --- a/packages/graphql/lib/main.tsp +++ b/packages/graphql/lib/main.tsp @@ -1,5 +1,6 @@ import "../dist/src/tsp-index.js"; import "./interface.tsp"; +import "./input-type.tsp"; import "./nullable.tsp"; import "./one-of.tsp"; import "./operation-fields.tsp"; diff --git a/packages/graphql/lib/nullable.tsp b/packages/graphql/lib/nullable.tsp index a4fae4d8a40..f8b0f119837 100644 --- a/packages/graphql/lib/nullable.tsp +++ b/packages/graphql/lib/nullable.tsp @@ -6,10 +6,9 @@ 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. + * union types, and can also be applied directly in TypeSpec source. */ -internal extern dec nullable(target: ModelProperty | Operation | Union | Model); +internal auto dec nullable(target: ModelProperty | Operation | Union | Model); /** * Mark a field or operation as having nullable array elements in the emitted GraphQL schema. @@ -17,4 +16,4 @@ internal extern dec nullable(target: ModelProperty | Operation | Union | Model); * Applied automatically by the mutation engine when it detects `Array` * patterns. Causes the emitter to emit `[T]` instead of `[T!]`. */ -internal extern dec nullableElements(target: ModelProperty | Operation); +internal auto dec nullableElements(target: ModelProperty | Operation); diff --git a/packages/graphql/lib/one-of.tsp b/packages/graphql/lib/one-of.tsp index 7dfc6f12aa9..17d7091446c 100644 --- a/packages/graphql/lib/one-of.tsp +++ b/packages/graphql/lib/one-of.tsp @@ -11,4 +11,4 @@ namespace TypeSpec.GraphQL; * * @see https://spec.graphql.org/September2025/#sec-OneOf-Input-Objects */ -internal extern dec oneOf(target: Model); +internal auto dec oneOf(target: Model); diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 278a2f76fd7..63f34784230 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -38,8 +38,9 @@ }, "scripts": { "clean": "rimraf ./dist ./temp", - "build": "alloy build", + "build": "pnpm gen-extern-signature && alloy build", "watch": "alloy build --watch", + "gen-extern-signature": "tspd --enable-experimental gen-extern-signature .", "test": "vitest run", "test:watch": "vitest -w", "lint": "oxlint . --deny-warnings", @@ -48,6 +49,7 @@ }, "files": [ "lib/*.tsp", + "tspconfig.yaml", "dist/**", "!dist/test/**" ], diff --git a/packages/graphql/src/components/fields/field.tsx b/packages/graphql/src/components/fields/field.tsx index 6b2fc1660a6..5b51fe71c02 100644 --- a/packages/graphql/src/components/fields/field.tsx +++ b/packages/graphql/src/components/fields/field.tsx @@ -1,8 +1,8 @@ import * as gql from "@pinterest/alloy-graphql"; import { type ModelProperty, getDeprecationDetails, isArrayModelType } from "@typespec/compiler"; import { useTsp } from "@typespec/emitter-framework"; +import { isNullable, isNullableElements } from "../../../generated-defs/TypeSpec.GraphQL.js"; import { resolveGraphQLTypeName } from "../../lib/graphql-type-name.js"; -import { hasNullableElements, isNullable } from "../../lib/nullable.js"; export interface FieldProps { property: ModelProperty; @@ -14,11 +14,11 @@ export function Field(props: FieldProps) { const doc = $.type.getDoc(props.property); const deprecation = getDeprecationDetails(program, props.property); - const nullable = isNullable(props.property) || props.property.optional; + const nullable = isNullable(program, props.property) || props.property.optional; const type = props.property.type; if (type.kind === "Model" && isArrayModelType(type)) { - const elemNullable = hasNullableElements(props.property); + const elemNullable = isNullableElements(program, props.property); const typeName = resolveGraphQLTypeName(type.indexer.value, program); if (props.isInput) { diff --git a/packages/graphql/src/components/fields/operation-field.tsx b/packages/graphql/src/components/fields/operation-field.tsx index 49148a967b9..e26f2833b80 100644 --- a/packages/graphql/src/components/fields/operation-field.tsx +++ b/packages/graphql/src/components/fields/operation-field.tsx @@ -1,8 +1,8 @@ import * as gql from "@pinterest/alloy-graphql"; import { type Operation, getDeprecationDetails, isArrayModelType } from "@typespec/compiler"; import { useTsp } from "@typespec/emitter-framework"; +import { isNullable, isNullableElements } from "../../../generated-defs/TypeSpec.GraphQL.js"; import { resolveGraphQLTypeName } from "../../lib/graphql-type-name.js"; -import { hasNullableElements, isNullable } from "../../lib/nullable.js"; export interface OperationFieldProps { operation: Operation; @@ -14,14 +14,14 @@ export function OperationField(props: OperationFieldProps) { const doc = $.type.getDoc(props.operation); const deprecation = getDeprecationDetails(program, props.operation); const returnType = props.operation.returnType; - const nullable = isNullable(props.operation); + const nullable = isNullable(program, 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, program) : resolveGraphQLTypeName(returnType, program); - const elemNullable = isList && hasNullableElements(props.operation); + const elemNullable = isList && isNullableElements(program, props.operation); return ( {isList ? : undefined} {params.map((param) => { - const paramNullable = isNullable(param) || param.optional; + const paramNullable = isNullable(program, param) || param.optional; const paramType = param.type; const paramIsList = paramType.kind === "Model" && isArrayModelType(paramType); - const paramElemNullable = paramIsList && hasNullableElements(param); + const paramElemNullable = paramIsList && isNullableElements(program, param); const paramTypeName = paramIsList ? resolveGraphQLTypeName(paramType.indexer.value, program) : resolveGraphQLTypeName(paramType, program); diff --git a/packages/graphql/src/components/schema.tsx b/packages/graphql/src/components/schema.tsx index f4d9eff83db..e56e3a508ca 100644 --- a/packages/graphql/src/components/schema.tsx +++ b/packages/graphql/src/components/schema.tsx @@ -1,8 +1,8 @@ import * as gql from "@pinterest/alloy-graphql"; import { type Model } from "@typespec/compiler"; import { useTsp } from "@typespec/emitter-framework"; +import { isInputType } from "../../generated-defs/TypeSpec.GraphQL.js"; import { useGraphQLSchema } from "../context/index.js"; -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"; @@ -73,7 +73,7 @@ export function Schema() { if (isInterface(program, model)) { return ; } - if (isInputType(model)) { + if (isInputType(program, model)) { return ; } return ; diff --git a/packages/graphql/src/components/types/input-type.tsx b/packages/graphql/src/components/types/input-type.tsx index fc36b2c31d1..59697a08e6b 100644 --- a/packages/graphql/src/components/types/input-type.tsx +++ b/packages/graphql/src/components/types/input-type.tsx @@ -1,7 +1,7 @@ import * as gql from "@pinterest/alloy-graphql"; import { type Model, getDoc } from "@typespec/compiler"; import { useTsp } from "@typespec/emitter-framework"; -import { isOneOf } from "../../lib/one-of.js"; +import { isOneOf } from "../../../generated-defs/TypeSpec.GraphQL.js"; import { Field } from "../fields/index.js"; export interface InputTypeProps { @@ -14,7 +14,11 @@ export function InputType(props: InputTypeProps) { const properties = [...props.type.properties.values()]; return ( - + {properties.map((prop) => ( ))} diff --git a/packages/graphql/src/lib/input-type.ts b/packages/graphql/src/lib/input-type.ts deleted file mode 100644 index e0ea24e2922..00000000000 --- a/packages/graphql/src/lib/input-type.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { DecoratorContext, DecoratorFunction, Model } from "@typespec/compiler"; - -export const $inputType: DecoratorFunction = (_context: DecoratorContext, _target: Model) => {}; - -export function isInputType(model: Model): boolean { - return model.decorators.some((d) => d.decorator === $inputType); -} - -export function setInputType(model: Model): void { - if (model.decorators.some((d) => d.decorator === $inputType)) return; - model.decorators.push({ decorator: $inputType, args: [] }); -} diff --git a/packages/graphql/src/lib/interface.ts b/packages/graphql/src/lib/interface.ts index a136ce3b1a4..47145041f42 100644 --- a/packages/graphql/src/lib/interface.ts +++ b/packages/graphql/src/lib/interface.ts @@ -1,6 +1,5 @@ import { type DecoratorContext, - type DecoratorFunction, type Model, type ModelProperty, type Program, @@ -9,6 +8,10 @@ import { } from "@typespec/compiler"; import { useStateMap, useStateSet } from "@typespec/compiler/utils"; +import type { + ComposeDecorator, + GraphqlInterfaceDecorator, +} from "../../generated-defs/TypeSpec.GraphQL.js"; import { GraphQLKeys, reportDiagnostic } from "../lib.js"; import { propertiesEqual } from "./utils.js"; @@ -126,11 +129,7 @@ function validateImplementsInterfacesProperties( return valid; } -export const $graphqlInterface: DecoratorFunction = ( - context: DecoratorContext, - target: Model, - options?: { interfaceOnly?: boolean }, -) => { +export const $graphqlInterface: GraphqlInterfaceDecorator = (context, target, options) => { validateDecoratorUniqueOnNode(context, target, $graphqlInterface); setInterface(context.program, target as Interface); if (options?.interfaceOnly) { @@ -138,17 +137,14 @@ export const $graphqlInterface: DecoratorFunction = ( } }; -export const $compose: DecoratorFunction = ( - context: DecoratorContext, - target: Model, - ...interfaces: Interface[] -) => { - validateImplementedsAreInterfaces(context, interfaces); - validateNoCircularImplementation(context, target, interfaces); - validateImplementsInterfacesProperties(context, target, interfaces); +export const $compose: ComposeDecorator = (context, target, ...interfaces) => { + validateImplementedsAreInterfaces(context, interfaces as Interface[]); + validateNoCircularImplementation(context, target, interfaces as Interface[]); + validateImplementsInterfacesProperties(context, target, interfaces as Interface[]); const existingCompose = getComposition(context.program, target); + let composed = interfaces as Interface[]; if (existingCompose) { - interfaces = [...existingCompose, ...interfaces]; + composed = [...existingCompose, ...composed]; } - setComposition(context.program, target, interfaces); + setComposition(context.program, target, composed); }; diff --git a/packages/graphql/src/lib/nullable.ts b/packages/graphql/src/lib/nullable.ts deleted file mode 100644 index 81b1aeffc2e..00000000000 --- a/packages/graphql/src/lib/nullable.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { - DecoratedType, - DecoratorContext, - DecoratorFunction, - Model, - ModelProperty, - Operation, - Type, - Union, -} from "@typespec/compiler"; - -/** - * Decorator implementation for `@nullable`. - * - * No-op — the decorator's presence on the type's `decorators` array is the - * signal. No additional state storage is needed. - */ -export const $nullable: DecoratorFunction = ( - _context: DecoratorContext, - _target: ModelProperty | Operation | Union | Model, -) => {}; - -/** - * Decorator implementation for `@nullableElements`. - * - * No-op — presence on the decorators array is the signal. - */ -export const $nullableElements: DecoratorFunction = ( - _context: DecoratorContext, - _target: ModelProperty | Operation, -) => {}; - -/** - * Check whether a type was marked nullable after null-variant stripping. - * - * Marked on different targets depending on context: - * - **ModelProperty**: inline `T | null` (can't mark the shared scalar singleton) - * - **Operation**: return type `T | null` - * - **Union**: named unions like `Cat | Dog | null` (safe — new unique object) - */ -export function isNullable(type: Type): boolean { - if (!isDecoratedType(type)) return false; - return type.decorators.some((d) => d.decorator === $nullable); -} - -/** - * Mark a type, property, or operation as nullable. - * Called by the mutation engine when null variants are stripped. - */ -export function setNullable(type: Type): void { - if (!isDecoratedType(type)) return; - if (type.decorators.some((d) => d.decorator === $nullable)) return; - type.decorators.push({ decorator: $nullable, args: [] }); -} - -/** - * Check whether a property's array elements were originally `T | null`. - * - * For `(string | null)[]`, marks the ModelProperty so components emit - * `[String]` instead of `[String!]`. - */ -export function hasNullableElements(type: Type): boolean { - if (!isDecoratedType(type)) return false; - return type.decorators.some((d) => d.decorator === $nullableElements); -} - -/** Mark a property as having nullable array elements. */ -export function setNullableElements(type: Type): void { - if (!isDecoratedType(type)) return; - if (type.decorators.some((d) => d.decorator === $nullableElements)) return; - type.decorators.push({ decorator: $nullableElements, args: [] }); -} - -function isDecoratedType(type: Type): type is Type & DecoratedType { - return "decorators" in type; -} diff --git a/packages/graphql/src/lib/one-of.ts b/packages/graphql/src/lib/one-of.ts deleted file mode 100644 index f130c0b1a87..00000000000 --- a/packages/graphql/src/lib/one-of.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { DecoratorContext, DecoratorFunction, Model } from "@typespec/compiler"; - -/** - * Decorator implementation for `@oneOf`. - * - * No-op — the decorator's presence on the type's `decorators` array is the - * signal. No additional state storage is needed. - */ -export const $oneOf: DecoratorFunction = (_context: DecoratorContext, _target: Model) => {}; - -/** - * Check if a model has been marked as a @oneOf input object. - * These are synthetic models created by the union mutation when a union - * is used in input context — GraphQL unions are output-only, so input - * unions become @oneOf input objects. - */ -export function isOneOf(model: Model): boolean { - return model.decorators.some((d) => d.decorator === $oneOf); -} - -/** - * Mark a model as a @oneOf input object. - */ -export function setOneOf(model: Model): void { - if (model.decorators.some((d) => d.decorator === $oneOf)) return; - model.decorators.push({ decorator: $oneOf, args: [] }); -} diff --git a/packages/graphql/src/lib/operation-fields.ts b/packages/graphql/src/lib/operation-fields.ts index 3233d984948..6d02d6a53b0 100644 --- a/packages/graphql/src/lib/operation-fields.ts +++ b/packages/graphql/src/lib/operation-fields.ts @@ -1,13 +1,13 @@ import { walkPropertiesInherited, type DecoratorContext, - type DecoratorFunction, type Interface, type Model, type Operation, type Program, } from "@typespec/compiler"; import { useStateMap } from "@typespec/compiler/utils"; +import type { OperationFieldsDecorator } from "../../generated-defs/TypeSpec.GraphQL.js"; import { GraphQLKeys, reportDiagnostic } from "../lib.js"; import { operationsEqual } from "./utils.js"; @@ -91,12 +91,12 @@ export function addOperationField( setOperationFields(context.program, model, operationFields); } -export const $operationFields: DecoratorFunction = ( - context: DecoratorContext, - target: Model, - ...operationOrInterfaces: (Operation | Interface)[] +export const $operationFields: OperationFieldsDecorator = ( + context, + target, + ...operationOrInterfaces ): void => { - for (const operationOrInterface of operationOrInterfaces) { + for (const operationOrInterface of operationOrInterfaces as (Operation | Interface)[]) { if (operationOrInterface.kind === "Operation") { addOperationField(context, target, operationOrInterface); } else { diff --git a/packages/graphql/src/lib/operation-kind.ts b/packages/graphql/src/lib/operation-kind.ts index d417602f1db..00b93a46492 100644 --- a/packages/graphql/src/lib/operation-kind.ts +++ b/packages/graphql/src/lib/operation-kind.ts @@ -1,6 +1,11 @@ import { type DecoratorContext, type Operation } from "@typespec/compiler"; import { SyntaxKind } from "@typespec/compiler/ast"; import { useStateMap } from "@typespec/compiler/utils"; +import type { + MutationDecorator, + QueryDecorator, + SubscriptionDecorator, +} from "../../generated-defs/TypeSpec.GraphQL.js"; import { GraphQLKeys, reportDiagnostic } from "../lib.js"; export type GraphQLOperationKind = "Mutation" | "Query" | "Subscription"; @@ -45,9 +50,9 @@ function createOperationKindDecorator(operationKind: GraphQLOperationKind) { }; } -export const $mutation = createOperationKindDecorator("Mutation"); -export const $query = createOperationKindDecorator("Query"); -export const $subscription = createOperationKindDecorator("Subscription"); +export const $mutation: MutationDecorator = createOperationKindDecorator("Mutation"); +export const $query: QueryDecorator = createOperationKindDecorator("Query"); +export const $subscription: SubscriptionDecorator = createOperationKindDecorator("Subscription"); export const OPERATION_KIND_DECORATORS = [$mutation, $query, $subscription]; diff --git a/packages/graphql/src/lib/schema.ts b/packages/graphql/src/lib/schema.ts index ec6346c2be0..56389e5749e 100644 --- a/packages/graphql/src/lib/schema.ts +++ b/packages/graphql/src/lib/schema.ts @@ -1,12 +1,7 @@ -import { - type DecoratorContext, - type DecoratorFunction, - type Namespace, - type Program, - validateDecoratorUniqueOnNode, -} from "@typespec/compiler"; +import { type Namespace, type Program, validateDecoratorUniqueOnNode } from "@typespec/compiler"; import { useStateMap } from "@typespec/compiler/utils"; +import type { SchemaDecorator } from "../../generated-defs/TypeSpec.GraphQL.js"; import { GraphQLKeys } from "../lib.js"; export interface SchemaDetails { @@ -64,11 +59,7 @@ export function addSchema( setSchema(program, namespace, { ...existing, ...details, type: namespace }); } -export const $schema: DecoratorFunction = ( - context: DecoratorContext, - target: Namespace, - options: SchemaDetails = {}, -) => { +export const $schema: SchemaDecorator = (context, target, options) => { 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 index 3047cdabae3..e87d14c2f7c 100644 --- a/packages/graphql/src/lib/specified-by.ts +++ b/packages/graphql/src/lib/specified-by.ts @@ -1,11 +1,6 @@ -import { - type DecoratorContext, - type DecoratorFunction, - type Program, - type Scalar, - validateDecoratorUniqueOnNode, -} from "@typespec/compiler"; +import { type Program, type Scalar, validateDecoratorUniqueOnNode } from "@typespec/compiler"; import { useStateMap } from "@typespec/compiler/utils"; +import type { SpecifiedByDecorator } from "../../generated-defs/TypeSpec.GraphQL.js"; import { GraphQLKeys } from "../lib.js"; const [getSpecifiedByUrl, setSpecifiedByUrl] = useStateMap(GraphQLKeys.specifiedBy); @@ -19,11 +14,7 @@ export function getSpecifiedBy(program: Program, scalar: Scalar): string | undef return getSpecifiedByUrl(program, scalar); } -export const $specifiedBy: DecoratorFunction = ( - context: DecoratorContext, - target: Scalar, - url: string, -) => { +export const $specifiedBy: SpecifiedByDecorator = (context, target, url) => { validateDecoratorUniqueOnNode(context, target, $specifiedBy); setSpecifiedByUrl(context.program, target, url); }; diff --git a/packages/graphql/src/mutation-engine/mutations/model-property.ts b/packages/graphql/src/mutation-engine/mutations/model-property.ts index ab4287123ec..c04dddf3286 100644 --- a/packages/graphql/src/mutation-engine/mutations/model-property.ts +++ b/packages/graphql/src/mutation-engine/mutations/model-property.ts @@ -6,8 +6,8 @@ import { type SimpleMutationOptions, type SimpleMutations, } from "@typespec/mutator-framework"; +import { setNullable, setNullableElements } from "../../../generated-defs/TypeSpec.GraphQL.js"; 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. */ @@ -29,8 +29,12 @@ export class GraphQLModelPropertyMutation extends SimpleModelPropertyMutation 0) { - setInputType(qm.mutatedType); + setInputType(program, qm.mutatedType); pushMutatedModel(qm); } if (mm.mutatedType.properties.size > 0) { - setInputType(mm.mutatedType); + setInputType(program, mm.mutatedType); pushMutatedModel(mm); } } else { @@ -110,11 +110,12 @@ export function mutateSchema( ? 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); + setInputType(program, emitted.mutatedType); pushMutatedModel(emitted); if (usedByQuery && usedByMutation) { setInputType( + program, engine.mutateModel(node, GraphQLTypeContext.Input, filters.query, "query") .mutatedType, ); @@ -161,7 +162,7 @@ export function mutateSchema( const mutation = engine.mutateUnion(node, GraphQLTypeContext.Input, filter, opKind); const mutated = mutation.mutatedType; if (mutated.kind === "Model") { - setInputType(mutated); + setInputType(program, mutated); mutatedTypes.push(mutated); } else if (mutated.kind === "Union") { mutatedTypes.push(mutated); diff --git a/packages/graphql/src/tsp-index.ts b/packages/graphql/src/tsp-index.ts index 2bba1a05202..a239a7f71c6 100644 --- a/packages/graphql/src/tsp-index.ts +++ b/packages/graphql/src/tsp-index.ts @@ -1,8 +1,6 @@ -import type { DecoratorImplementations } from "@typespec/compiler"; +import type { TypeSpecGraphQLDecorators } from "../generated-defs/TypeSpec.GraphQL.js"; import { $lib } from "./lib.js"; import { $compose, $graphqlInterface } from "./lib/interface.js"; -import { $nullable, $nullableElements } from "./lib/nullable.js"; -import { $oneOf } from "./lib/one-of.js"; import { $operationFields } from "./lib/operation-fields.js"; import { $mutation, $query, $subscription } from "./lib/operation-kind.js"; import { $schema } from "./lib/schema.js"; @@ -11,18 +9,15 @@ import { $onValidate } from "./validate.js"; export { $lib, $onValidate }; -export const $decorators: DecoratorImplementations = { +export const $decorators = { "TypeSpec.GraphQL": { compose: $compose, graphqlInterface: $graphqlInterface, mutation: $mutation, - nullable: $nullable, - nullableElements: $nullableElements, - oneOf: $oneOf, operationFields: $operationFields, query: $query, schema: $schema, specifiedBy: $specifiedBy, subscription: $subscription, - }, + } satisfies TypeSpecGraphQLDecorators, }; diff --git a/packages/graphql/test/mutation-engine/context.test.ts b/packages/graphql/test/mutation-engine/context.test.ts index 4752657703d..8048feb63ea 100644 --- a/packages/graphql/test/mutation-engine/context.test.ts +++ b/packages/graphql/test/mutation-engine/context.test.ts @@ -1,7 +1,7 @@ import type { Model } from "@typespec/compiler"; import { t, type TesterInstance } from "@typespec/compiler/testing"; import { beforeEach, describe, expect, it } from "vitest"; -import { isOneOf } from "../../src/lib/one-of.js"; +import { isOneOf } from "../../generated-defs/TypeSpec.GraphQL.js"; import { createGraphQLMutationEngine, GraphQLTypeContext, @@ -161,7 +161,7 @@ describe("GraphQL Mutation Engine - Operation Context Propagation", () => { const unionMutation = engine.mutateUnion(Pet, GraphQLTypeContext.Input); expect(unionMutation.mutatedType.kind).toBe("Model"); expect(unionMutation.mutatedType.name).toBe("PetInput"); - expect(isOneOf(unionMutation.mutatedType as Model)).toBe(true); + expect(isOneOf(tester.program, unionMutation.mutatedType as Model)).toBe(true); }); it("keeps union return type as union via operation mutation", async () => { diff --git a/packages/graphql/test/mutation-engine/operations.test.ts b/packages/graphql/test/mutation-engine/operations.test.ts index be7ae041526..7c8f3bf46d3 100644 --- a/packages/graphql/test/mutation-engine/operations.test.ts +++ b/packages/graphql/test/mutation-engine/operations.test.ts @@ -1,6 +1,6 @@ import { t, type TesterInstance } from "@typespec/compiler/testing"; import { beforeEach, describe, expect, it } from "vitest"; -import { isNullable } from "../../src/lib/nullable.js"; +import { isNullable } from "../../generated-defs/TypeSpec.GraphQL.js"; import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; import { Tester } from "../test-host.js"; @@ -57,7 +57,7 @@ describe("GraphQL Mutation Engine - Operations", () => { // The return type should be unwrapped to the inner type expect(mutation.mutatedType.returnType.kind).toBe("Model"); // The operation itself should be marked nullable - expect(isNullable(mutation.mutatedType)).toBe(true); + expect(isNullable(tester.program, mutation.mutatedType)).toBe(true); }); it("does not mark operation as nullable when return type is non-null", async () => { @@ -72,6 +72,6 @@ describe("GraphQL Mutation Engine - Operations", () => { const mutation = engine.mutateOperation(getUser); expect(mutation.mutatedType.returnType.kind).toBe("Model"); - expect(isNullable(mutation.mutatedType)).toBe(false); + expect(isNullable(tester.program, 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 index d6c65e36b90..b641f815e63 100644 --- a/packages/graphql/test/mutation-engine/print-type.test.ts +++ b/packages/graphql/test/mutation-engine/print-type.test.ts @@ -19,7 +19,7 @@ describe("printMutatedType", () => { const engine = createGraphQLMutationEngine(tester.program); const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); const prop = mutated.mutatedType.properties.get("name")!; - expect(printMutatedType(prop)).toBe("String!"); + expect(printMutatedType(tester.program, prop)).toBe("String!"); }); it("optional string → String", async () => { @@ -27,7 +27,7 @@ describe("printMutatedType", () => { const engine = createGraphQLMutationEngine(tester.program); const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); const prop = mutated.mutatedType.properties.get("name")!; - expect(printMutatedType(prop)).toBe("String"); + expect(printMutatedType(tester.program, prop)).toBe("String"); }); it("string | null → String", async () => { @@ -35,7 +35,7 @@ describe("printMutatedType", () => { const engine = createGraphQLMutationEngine(tester.program); const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); const prop = mutated.mutatedType.properties.get("name")!; - expect(printMutatedType(prop)).toBe("String"); + expect(printMutatedType(tester.program, prop)).toBe("String"); }); it("required string[] → [String!]!", async () => { @@ -43,7 +43,7 @@ describe("printMutatedType", () => { const engine = createGraphQLMutationEngine(tester.program); const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); const prop = mutated.mutatedType.properties.get("tags")!; - expect(printMutatedType(prop)).toBe("[String!]!"); + expect(printMutatedType(tester.program, prop)).toBe("[String!]!"); }); it("optional string[] → [String!]", async () => { @@ -51,7 +51,7 @@ describe("printMutatedType", () => { const engine = createGraphQLMutationEngine(tester.program); const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); const prop = mutated.mutatedType.properties.get("tags")!; - expect(printMutatedType(prop)).toBe("[String!]"); + expect(printMutatedType(tester.program, prop)).toBe("[String!]"); }); it("(string | null)[] → [String]!", async () => { @@ -61,7 +61,7 @@ describe("printMutatedType", () => { const engine = createGraphQLMutationEngine(tester.program); const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); const prop = mutated.mutatedType.properties.get("tags")!; - expect(printMutatedType(prop)).toBe("[String]!"); + expect(printMutatedType(tester.program, prop)).toBe("[String]!"); }); it("string[] | null → [String!]", async () => { @@ -71,7 +71,7 @@ describe("printMutatedType", () => { const engine = createGraphQLMutationEngine(tester.program); const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); const prop = mutated.mutatedType.properties.get("tags")!; - expect(printMutatedType(prop)).toBe("[String!]"); + expect(printMutatedType(tester.program, prop)).toBe("[String!]"); }); it("(string | null)[] | null → [String]", async () => { @@ -81,7 +81,7 @@ describe("printMutatedType", () => { const engine = createGraphQLMutationEngine(tester.program); const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); const prop = mutated.mutatedType.properties.get("tags")!; - expect(printMutatedType(prop)).toBe("[String]"); + expect(printMutatedType(tester.program, prop)).toBe("[String]"); }); it("required model type → ModelName!", async () => { @@ -94,7 +94,7 @@ describe("printMutatedType", () => { const engine = createGraphQLMutationEngine(tester.program); const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); const prop = mutated.mutatedType.properties.get("bar")!; - expect(printMutatedType(prop)).toBe("Bar!"); + expect(printMutatedType(tester.program, prop)).toBe("Bar!"); }); it("required int32 → Int!", async () => { @@ -102,6 +102,6 @@ describe("printMutatedType", () => { const engine = createGraphQLMutationEngine(tester.program); const mutated = engine.mutateModel(Foo, GraphQLTypeContext.Output); const prop = mutated.mutatedType.properties.get("count")!; - expect(printMutatedType(prop)).toBe("Int!"); + expect(printMutatedType(tester.program, prop)).toBe("Int!"); }); }); diff --git a/packages/graphql/test/mutation-engine/schema-mutator.test.ts b/packages/graphql/test/mutation-engine/schema-mutator.test.ts index 60c94278532..2e2b166c15c 100644 --- a/packages/graphql/test/mutation-engine/schema-mutator.test.ts +++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts @@ -1,7 +1,7 @@ import type { Model } from "@typespec/compiler"; import { t, type TesterInstance } from "@typespec/compiler/testing"; import { beforeEach, describe, expect, it } from "vitest"; -import { isInputType } from "../../src/lib/input-type.js"; +import { isInputType } from "../../generated-defs/TypeSpec.GraphQL.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"; @@ -203,8 +203,8 @@ describe("mutateSchema", () => { const bookOutput = typeGraph.globalNamespace.models.get("Book")!; const bookInput = typeGraph.globalNamespace.models.get("BookInput")!; - expect(isInputType(bookOutput)).toBe(false); - expect(isInputType(bookInput)).toBe(true); + expect(isInputType(tester.program, bookOutput)).toBe(false); + expect(isInputType(tester.program, bookInput)).toBe(true); }); it("mutateDecoratorTypeArgs does not corrupt source type decorator args", async () => { diff --git a/packages/graphql/test/mutation-engine/unions.test.ts b/packages/graphql/test/mutation-engine/unions.test.ts index 02f2bb2d242..2873818c00a 100644 --- a/packages/graphql/test/mutation-engine/unions.test.ts +++ b/packages/graphql/test/mutation-engine/unions.test.ts @@ -1,8 +1,7 @@ import { getDoc, type Model, type Union } from "@typespec/compiler"; import { t, type TesterInstance } from "@typespec/compiler/testing"; import { beforeEach, describe, expect, it } from "vitest"; -import { isNullable } from "../../src/lib/nullable.js"; -import { isOneOf } from "../../src/lib/one-of.js"; +import { isNullable, isOneOf } from "../../generated-defs/TypeSpec.GraphQL.js"; import { createGraphQLMutationEngine, GraphQLTypeContext, @@ -33,7 +32,7 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(mutation.wrapperModels).toHaveLength(0); // The replacement type is NOT marked nullable — nullability for inline T | null // is tracked on the model property, not the shared scalar singleton. - expect(isNullable(mutation.mutatedType)).toBe(false); + expect(isNullable(tester.program, mutation.mutatedType)).toBe(false); }); it("replaces nullable model union with inner type", async () => { @@ -52,7 +51,7 @@ describe("GraphQL Mutation Engine - Unions", () => { expect(mutation.wrapperModels).toHaveLength(0); // The replacement type is NOT marked nullable — nullability for inline T | null // is tracked on the model property, not the shared type. - expect(isNullable(mutation.mutatedType)).toBe(false); + expect(isNullable(tester.program, mutation.mutatedType)).toBe(false); }); it("creates wrapper models for scalar variants", async () => { @@ -275,7 +274,7 @@ describe("GraphQL Mutation Engine - Unions", () => { // 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); + expect(isNullable(tester.program, mutation.mutatedType)).toBe(true); }); it("handles circular type references without infinite recursion", async () => { @@ -317,7 +316,7 @@ describe("GraphQL Mutation Engine - Unions", () => { const nameProp = mutation.mutatedType.properties.get("name")!; expect(nameProp.type.kind).toBe("Scalar"); - expect(printMutatedType(nameProp)).toBe("String"); + expect(printMutatedType(tester.program, nameProp)).toBe("String"); }); it("string[] property → [String!]!", async () => { @@ -327,7 +326,7 @@ describe("GraphQL Mutation Engine - Unions", () => { const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); const tagsProp = mutation.mutatedType.properties.get("tags")!; - expect(printMutatedType(tagsProp)).toBe("[String!]!"); + expect(printMutatedType(tester.program, tagsProp)).toBe("[String!]!"); }); it("(string | null)[] property → [String]!", async () => { @@ -339,7 +338,7 @@ describe("GraphQL Mutation Engine - Unions", () => { const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); const tagsProp = mutation.mutatedType.properties.get("tags")!; - expect(printMutatedType(tagsProp)).toBe("[String]!"); + expect(printMutatedType(tester.program, tagsProp)).toBe("[String]!"); }); it("string[] | null property → [String!]", async () => { @@ -351,7 +350,7 @@ describe("GraphQL Mutation Engine - Unions", () => { const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); const tagsProp = mutation.mutatedType.properties.get("tags")!; - expect(printMutatedType(tagsProp)).toBe("[String!]"); + expect(printMutatedType(tester.program, tagsProp)).toBe("[String!]"); }); it("(string | null)[] | null property → [String]", async () => { @@ -363,7 +362,7 @@ describe("GraphQL Mutation Engine - Unions", () => { const mutation = engine.mutateModel(Foo, GraphQLTypeContext.Output); const tagsProp = mutation.mutatedType.properties.get("tags")!; - expect(printMutatedType(tagsProp)).toBe("[String]"); + expect(printMutatedType(tester.program, tagsProp)).toBe("[String]"); }); }); @@ -388,7 +387,7 @@ describe("GraphQL Mutation Engine - oneOf Input Objects", () => { // Union is replaced with a Model in the type graph expect(mutation.mutatedType.kind).toBe("Model"); expect(mutation.mutatedType.name).toBe("PetInput"); - expect(isOneOf(mutation.mutatedType as Model)).toBe(true); + expect(isOneOf(tester.program, mutation.mutatedType as Model)).toBe(true); }); it("PascalCases oneOf model name for snake_case unions", async () => { @@ -520,7 +519,7 @@ describe("GraphQL Mutation Engine - oneOf Input Objects", () => { expect(mutatedUnion.variants.size).toBe(2); // The result should be marked as nullable - expect(isNullable(mutatedUnion)).toBe(true); + expect(isNullable(tester.program, mutatedUnion)).toBe(true); }); it("strips null from multi-variant union in input context", async () => { @@ -543,8 +542,8 @@ describe("GraphQL Mutation Engine - oneOf Input Objects", () => { expect(model.properties.has("dog")).toBe(true); // Should be marked as both @oneOf and nullable - expect(isOneOf(model)).toBe(true); - expect(isNullable(model)).toBe(true); + expect(isOneOf(tester.program, model)).toBe(true); + expect(isNullable(tester.program, model)).toBe(true); }); it("non-nullable union is not marked as nullable", async () => { @@ -559,7 +558,7 @@ describe("GraphQL Mutation Engine - oneOf Input Objects", () => { const engine = createTestEngine(tester.program); const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); - expect(isNullable(mutation.mutatedType)).toBe(false); + expect(isNullable(tester.program, mutation.mutatedType)).toBe(false); }); it("exposes typeContext on union mutation", async () => { diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json index e484d9c89a5..9a6bdaa9fe7 100644 --- a/packages/graphql/tsconfig.json +++ b/packages/graphql/tsconfig.json @@ -14,6 +14,12 @@ "rootDir": ".", "outDir": "dist" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "test/**/*.tsx", + "generated-defs/**/*.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/packages/graphql/tspconfig.yaml b/packages/graphql/tspconfig.yaml new file mode 100644 index 00000000000..be1576f644f --- /dev/null +++ b/packages/graphql/tspconfig.yaml @@ -0,0 +1,7 @@ +# Opt this library into the experimental `auto-decorators` feature so that +# `@nullable` / `@nullableElements` (declared as `auto dec` in lib/nullable.tsp) +# are permitted in this library's own source. Per-package feature enablement is +# resolved from the owning package's config, so consumers do not need to enable it. +kind: project +features: + - auto-decorators