diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 23e32a7590c..aa3715047ac 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -30,15 +30,16 @@ "node": ">=20.0.0" }, "dependencies": { - "@alloy-js/core": "^0.11.0", - "@alloy-js/typescript": "^0.11.0", + "@alloy-js/core": "^0.22.0", + "@alloy-js/graphql": "^0.1.0", + "@alloy-js/typescript": "^0.22.0", "change-case": "^5.4.4", "graphql": "^16.9.0" }, "scripts": { "clean": "rimraf ./dist ./temp", - "build": "tsc -p .", - "watch": "tsc --watch", + "build": "alloy build", + "watch": "alloy build --watch", "test": "vitest run", "test:watch": "vitest -w", "lint": "eslint . --max-warnings=0", @@ -56,6 +57,8 @@ "@typespec/mutator-framework": "workspace:~" }, "devDependencies": { + "@alloy-js/cli": "^0.22.0", + "@alloy-js/rollup-plugin": "^0.1.0", "@types/node": "~22.13.13", "@typespec/compiler": "workspace:~", "@typespec/emitter-framework": "workspace:~", diff --git a/packages/graphql/src/components/fields/field.tsx b/packages/graphql/src/components/fields/field.tsx new file mode 100644 index 00000000000..1e98b1b58b5 --- /dev/null +++ b/packages/graphql/src/components/fields/field.tsx @@ -0,0 +1,62 @@ +import { type ModelProperty, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { isNullable, hasNullableElements } from "../../lib/nullable.js"; +import { GraphQLTypeExpression } from "./type-expression.js"; + +export interface FieldProps { + /** The model property to render as a field */ + property: ModelProperty; + /** Whether this field is in an input type context */ + isInput: boolean; +} + +export function Field(props: FieldProps) { + const { $, program } = useTsp(); + + const doc = $.type.getDoc(props.property); + const deprecation = getDeprecationDetails(program, props.property); + + return ( + + {(typeInfo) => { + if (props.isInput) { + return ( + + {typeInfo.isList ? ( + + ) : undefined} + + ); + } + + return ( + + {typeInfo.isList ? ( + + ) : undefined} + + ); + }} + + ); +} diff --git a/packages/graphql/src/components/fields/index.ts b/packages/graphql/src/components/fields/index.ts new file mode 100644 index 00000000000..8eb36ee9c42 --- /dev/null +++ b/packages/graphql/src/components/fields/index.ts @@ -0,0 +1,7 @@ +export { Field, type FieldProps } from "./field.js"; +export { OperationField, type OperationFieldProps } from "./operation-field.js"; +export { + GraphQLTypeExpression, + type GraphQLTypeExpressionProps, + type GraphQLTypeInfo, +} from "./type-expression.js"; diff --git a/packages/graphql/src/components/fields/operation-field.tsx b/packages/graphql/src/components/fields/operation-field.tsx new file mode 100644 index 00000000000..66d18e16102 --- /dev/null +++ b/packages/graphql/src/components/fields/operation-field.tsx @@ -0,0 +1,80 @@ +import { type Operation, getDeprecationDetails } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { isNullable, hasNullableElements } from "../../lib/nullable.js"; +import { GraphQLTypeExpression } from "./type-expression.js"; + +export interface OperationFieldProps { + /** The operation to render as a field */ + operation: Operation; +} + +/** + * Renders an operation as a field with arguments, used for @operationFields. + */ +export function OperationField(props: OperationFieldProps) { + const { $, program } = useTsp(); + const params = Array.from(props.operation.parameters.properties.values()); + const doc = $.type.getDoc(props.operation); + const deprecation = getDeprecationDetails(program, props.operation); + + return ( + + {(returnTypeInfo) => ( + + {returnTypeInfo.isList ? ( + + ) : undefined} + {params.map((param) => ( + + {(paramTypeInfo) => ( + + {paramTypeInfo.isList ? ( + + ) : undefined} + + )} + + ))} + + )} + + ); +} diff --git a/packages/graphql/src/components/fields/type-expression.tsx b/packages/graphql/src/components/fields/type-expression.tsx new file mode 100644 index 00000000000..ccfacb2456d --- /dev/null +++ b/packages/graphql/src/components/fields/type-expression.tsx @@ -0,0 +1,166 @@ +import { + type Type, + type Scalar, + type ModelProperty, + getEncode, + isUnknownType, +} from "@typespec/compiler"; +import { type Children } from "@alloy-js/core"; +import { useTsp } from "@typespec/emitter-framework"; +import { isNullable } from "../../lib/nullable.js"; +import { unwrapNullableUnion, getUnionName } from "../../lib/type-utils.js"; +import { getGraphQLBuiltinName, getScalarMapping } from "../../lib/scalar-mappings.js"; + +/** + * Information about a resolved GraphQL type + */ +export interface GraphQLTypeInfo { + /** The base type name (without wrappers) */ + typeName: string; + /** Whether this is a list type */ + isList: boolean; + /** Whether the field itself is non-null */ + isNonNull: boolean; + /** Whether list items are non-null (only meaningful if isList is true) */ + itemNonNull: boolean; +} + +export interface GraphQLTypeExpressionProps { + type: Type; + isOptional: boolean; + /** Whether this type is in an input position (operation parameter or input model field) */ + isInput: boolean; + /** Whether this type was marked nullable (from property-level tracking) */ + isNullable?: boolean; + /** Whether this property's array elements were originally T | null (from property-level tracking) */ + hasNullableElements?: boolean; + /** The property or parameter that contains the type (for @encode checking) */ + targetType?: Type; + children: (typeInfo: GraphQLTypeInfo) => Children; +} + +export function GraphQLTypeExpression(props: GraphQLTypeExpressionProps) { + const { $, program } = useTsp(); + + const nullable = props.isNullable || isNullable(props.type); + + // Fields are non-null unless nullable or optional. + // In GraphQL, optional fields are represented as nullable (per spec: + // "nullability directly determines whether a field is required"). + // This also allows circular references in input types (e.g., Author.friend?: Author). + const isNonNull = !nullable && !props.isOptional; + + // Unwrap T | null unions the mutation engine didn't process (e.g., array + // elements, operation parameters that arrive here still wrapped). + if ($.union.is(props.type)) { + const innerType = unwrapNullableUnion(props.type); + if (innerType) { + return ( + + {(innerInfo) => + props.children({ + ...innerInfo, + isNonNull: false, + }) + } + + ); + } + } + + if ($.array.is(props.type)) { + const elementType = $.array.getElementType(props.type); + // Element nullability: from mutation engine property-level tracking, from the + // element type's own state map, or from an inline T | null union still present. + const elementIsNullable = + props.hasNullableElements || + isNullable(elementType) || + ($.union.is(elementType) && unwrapNullableUnion(elementType) !== undefined); + + return ( + + {(elementInfo) => + props.children({ + typeName: elementInfo.typeName, + isList: true, + isNonNull, + itemNonNull: !elementIsNullable, + }) + } + + ); + } + + const typeName = resolveBaseTypeName(); + + return props.children({ + typeName, + isList: false, + isNonNull, + itemNonNull: false, + }); + + function resolveBaseTypeName(): string { + const type = props.type; + + if (isUnknownType(type)) { + return "Unknown"; + } + + if ($.scalar.is(type)) { + const builtinName = getGraphQLBuiltinName(program, type); + if (builtinName) return builtinName; + + // Std scalars with encoding-specific mappings (e.g., bytes + base64 -> Bytes) + if (program.checker.isStdType(type)) { + if ( + props.targetType && + ($.scalar.is(props.targetType) || props.targetType.kind === "ModelProperty") + ) { + const encodeData = getEncode( + program, + props.targetType as Scalar | ModelProperty, + ); + const mapping = getScalarMapping(program, type, encodeData?.encoding); + if (mapping) return mapping.graphqlName; + } + + const mapping = getScalarMapping(program, type); + if (mapping) return mapping.graphqlName; + } + + return type.name; + } + + if ($.model.is(type)) { + // The mutation engine handles input/output naming - input models are + // already suffixed with "Input" when they need to be distinguished. + return type.name; + } + + if ($.enum.is(type)) { + return type.name; + } + + if ($.union.is(type)) { + return getUnionName(type, program); + } + + throw new Error( + `Unexpected type kind "${type.kind}" in resolveBaseTypeName. ` + + `This is a bug in the GraphQL emitter.`, + ); + } +} diff --git a/packages/graphql/src/context/graphql-schema-context.tsx b/packages/graphql/src/context/graphql-schema-context.tsx new file mode 100644 index 00000000000..c281141c83e --- /dev/null +++ b/packages/graphql/src/context/graphql-schema-context.tsx @@ -0,0 +1,43 @@ +import { type ComponentContext, createNamedContext, useContext } from "@alloy-js/core"; +import type { Namespace } from "@typespec/compiler"; + +/** + * A self-contained type world produced by the mutation pipeline. + * The namespace contains all mutated types for the schema. + */ +export interface TypeGraph { + readonly globalNamespace: Namespace; +} + +/** + * Context value containing the mutated type graph for schema generation. + * + * For access to the TypeSpec program and typekit, use `useTsp()` from + * `@typespec/emitter-framework` instead. + */ +export interface GraphQLSchemaContextValue { + typeGraph: TypeGraph; +} + +/** + * Context provider for GraphQL schema generation + */ +export const GraphQLSchemaContext: ComponentContext = + createNamedContext("GraphQLSchema"); + +/** + * Hook to access GraphQL schema context + * @returns The GraphQL schema context value + * @throws Error if used outside of GraphQLSchemaContext.Provider + */ +export function useGraphQLSchema(): GraphQLSchemaContextValue { + const context = useContext(GraphQLSchemaContext); + + if (!context) { + throw new Error( + "useGraphQLSchema must be used within GraphQLSchemaContext.Provider.", + ); + } + + return context; +} diff --git a/packages/graphql/src/context/index.ts b/packages/graphql/src/context/index.ts new file mode 100644 index 00000000000..b734df36476 --- /dev/null +++ b/packages/graphql/src/context/index.ts @@ -0,0 +1,6 @@ +export { + GraphQLSchemaContext, + useGraphQLSchema, + type GraphQLSchemaContextValue, + type TypeGraph, +} from "./graphql-schema-context.js"; diff --git a/packages/graphql/src/lib/scalar-mappings.ts b/packages/graphql/src/lib/scalar-mappings.ts index 20cdf846536..5956fce34f4 100644 --- a/packages/graphql/src/lib/scalar-mappings.ts +++ b/packages/graphql/src/lib/scalar-mappings.ts @@ -170,6 +170,21 @@ const TSP_SCALARS_TO_GQL_BUILTINS: IntrinsicScalarName[] = [ "string", "boolean", "int32", "float32", "float64", ]; +/** + * Map a TypeSpec std scalar to its GraphQL built-in scalar name, if any. + * + * Returns undefined for non-builtin scalars. Uses checker.isStdType + * (name + namespace) which works on both original and mutated scalars. + */ +export function getGraphQLBuiltinName(program: Program, scalar: Scalar): string | undefined { + if (program.checker.isStdType(scalar, "string")) return "String"; + if (program.checker.isStdType(scalar, "boolean")) return "Boolean"; + if (program.checker.isStdType(scalar, "int32")) return "Int"; + if (program.checker.isStdType(scalar, "float32")) return "Float"; + if (program.checker.isStdType(scalar, "float64")) return "Float"; + return undefined; +} + /** * Get the GraphQL scalar mapping for a scalar via its standard library ancestor. * diff --git a/packages/graphql/src/lib/type-utils.ts b/packages/graphql/src/lib/type-utils.ts index 50fc9aba3ed..2cd0f5b22e0 100644 --- a/packages/graphql/src/lib/type-utils.ts +++ b/packages/graphql/src/lib/type-utils.ts @@ -1,21 +1,14 @@ import { - type ArrayModelType, - type Enum, getDoc, getTypeName, type IndeterminateEntity, - isNeverType, isNullType, isTemplateInstance, - type Model, type Program, - type RecordModelType, - type Scalar, type Type, type Union, type UnionVariant, type Value, - walkPropertiesInherited, } from "@typespec/compiler"; import { type AliasStatementNode, @@ -75,14 +68,6 @@ export function stripNullVariants(union: Union): { }; } -/** Generate a GraphQL type name for a templated model (e.g., `ListOfString`). */ -export function getTemplatedModelName(model: Model): string { - const name = getTypeName(model, {}); - const baseName = toTypeName(name.replace(/<[^>]*>/g, "")); - const templateString = getTemplateString(model); - return templateString ? `${baseName}Of${templateString}` : baseName; -} - function splitWithAcronyms( splitFn: (name: string) => string[], skipStart: boolean, @@ -233,64 +218,6 @@ function getUnionNameForOperation(program: Program, union: Union): string { return toTypeName(getTypeName(operation)); } -/** Convert a namespaced name to a single name by replacing dots with underscores. */ -export function getSingleNameWithNamespace(name: string): string { - return name.trim().replace(/\./g, "_"); -} - -/** - * Check if a model is an array type. - */ -export function isArray(model: Model): model is ArrayModelType { - return Boolean(model.indexer && model.indexer.key.name === "integer"); -} - -/** - * Check if a model is a record/map type. - */ -export function isRecordType(type: Model): type is RecordModelType { - return Boolean(type.indexer && type.indexer.key.name === "string"); -} - -/** Check if a model is an array of scalars or enums. */ -export function isScalarOrEnumArray(type: Model): type is ArrayModelType { - return ( - isArray(type) && (type.indexer?.value.kind === "Scalar" || type.indexer?.value.kind === "Enum") - ); -} - -/** Check if a model is an array of unions. */ -export function isUnionArray(type: Model): type is ArrayModelType { - return isArray(type) && type.indexer?.value.kind === "Union"; -} - -/** Extract the element type from an array model, or return the model itself. */ -export function unwrapModel(model: ArrayModelType): Model | Scalar | Enum | Union; -export function unwrapModel(model: Exclude): Model; -export function unwrapModel(model: Model): Model | Scalar | Enum | Union { - if (!isArray(model)) { - return model; - } - - if (model.indexer?.value.kind) { - if (["Model", "Scalar", "Enum", "Union"].includes(model.indexer.value.kind)) { - return model.indexer.value as Model | Scalar | Enum | Union; - } - throw new Error(`Unexpected array type: ${model.indexer.value.kind}`); - } - return model; -} - -/** Unwrap array types to get the inner element type. */ -export function unwrapType(type: Model): Model | Scalar | Enum | Union; -export function unwrapType(type: Type): Type; -export function unwrapType(type: Type): Type { - if (type.kind === "Model") { - return unwrapModel(type); - } - return type; -} - /** Get the GraphQL description for a type from its doc comments. */ export function getGraphQLDoc(program: Program, type: Type): string | undefined { // GraphQL uses CommonMark for descriptions @@ -318,15 +245,3 @@ function getTemplateStringInternal( return args.length > 0 ? args.map(toTypeName).join(options.conjunction) : ""; } -/** Check if a model should be emitted as a GraphQL object type (not an array, record, or never). */ -export function isTrueModel(model: Model): boolean { - return !( - // Array of scalars/enums — represented as a list type, not an object type - isScalarOrEnumArray(model) || - // Array of unions — represented as a list type, not an object type - isUnionArray(model) || - isNeverType(model) || - // Pure record with no properties — emitted as a custom scalar, not an object type - (isRecordType(model) && [...walkPropertiesInherited(model)].length === 0) - ); -} diff --git a/packages/graphql/test/lib/type-utils.test.ts b/packages/graphql/test/lib/type-utils.test.ts index aa5cb5ee45c..0fe12748de5 100644 --- a/packages/graphql/test/lib/type-utils.test.ts +++ b/packages/graphql/test/lib/type-utils.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - getSingleNameWithNamespace, sanitizeNameForGraphQL, toEnumMemberName, toFieldName, @@ -113,18 +112,4 @@ describe("type-utils", () => { }); }); - describe("getSingleNameWithNamespace", () => { - it("replaces dots with underscores", () => { - expect(getSingleNameWithNamespace("My.Namespace.Type")).toBe("My_Namespace_Type"); - }); - - it("trims whitespace", () => { - expect(getSingleNameWithNamespace(" My.Type ")).toBe("My_Type"); - }); - - it("handles names without namespace", () => { - expect(getSingleNameWithNamespace("MyType")).toBe("MyType"); - }); - }); - }); diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json index ad68b784463..d2f18d1ce33 100644 --- a/packages/graphql/tsconfig.json +++ b/packages/graphql/tsconfig.json @@ -1,10 +1,18 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "useDefineForClassFields": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "es2022", + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "jsx": "preserve", + "jsxImportSource": "@alloy-js/core", + "emitDeclarationOnly": true, "rootDir": ".", - "outDir": "dist", - "verbatimModuleSyntax": true + "outDir": "dist" }, - "include": ["src", "test"] + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/graphql/vitest.config.ts b/packages/graphql/vitest.config.ts index 63cad767f57..b45007f3475 100644 --- a/packages/graphql/vitest.config.ts +++ b/packages/graphql/vitest.config.ts @@ -1,4 +1,14 @@ +import alloyPlugin from "@alloy-js/rollup-plugin"; import { defineConfig, mergeConfig } from "vitest/config"; import { defaultTypeSpecVitestConfig } from "../../vitest.config.js"; -export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); +export default mergeConfig( + defaultTypeSpecVitestConfig, + defineConfig({ + esbuild: { + jsx: "preserve", + sourcemap: "both", + }, + plugins: [alloyPlugin()], + }), +);