diff --git a/packages/graphql/src/lib/scalars.ts b/packages/graphql/src/lib/scalars.ts new file mode 100644 index 00000000000..df718e6d98c --- /dev/null +++ b/packages/graphql/src/lib/scalars.ts @@ -0,0 +1,52 @@ +import type { Scalar } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { + GraphQLBoolean, + GraphQLFloat, + GraphQLInt, + GraphQLString, + type GraphQLScalarType, +} from "graphql"; + +/** + * Map TypeSpec scalar types to GraphQL Built-in types + */ +export function mapScalarToGraphQL( + scalar: Scalar, + typekit: ReturnType, +): GraphQLScalarType { + // Check for string type + if (typekit.scalar.isString(scalar)) { + return GraphQLString; + } + + // Check for integer types + if ( + typekit.scalar.isInt8(scalar) || + typekit.scalar.isInt16(scalar) || + typekit.scalar.isInt32(scalar) || + typekit.scalar.isSafeint(scalar) || + typekit.scalar.isUint8(scalar) || + typekit.scalar.isUint16(scalar) || + typekit.scalar.isUint32(scalar) + ) { + return GraphQLInt; + } + + // Check for float types + if ( + typekit.scalar.isFloat32(scalar) || + typekit.scalar.isFloat64(scalar) || + typekit.scalar.isFloat(scalar) + ) { + return GraphQLFloat; + } + + // Check for boolean type + if (typekit.scalar.isBoolean(scalar)) { + return GraphQLBoolean; + } + + // Default to string for unknown scalar types + return GraphQLString; +} diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts index b1fd29e64d0..8a799ab02c6 100644 --- a/packages/graphql/src/registry.ts +++ b/packages/graphql/src/registry.ts @@ -1,11 +1,22 @@ -import { UsageFlags, type Enum, type Model } from "@typespec/compiler"; +import { + UsageFlags, + type Enum, + type Model, + type ModelProperty, + type Program, + type Type, +} from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; import { GraphQLBoolean, - GraphQLEnumType, GraphQLObjectType, - type GraphQLNamedType, + GraphQLString, + type GraphQLInputType, + type GraphQLOutputType, type GraphQLSchemaConfig, } from "graphql"; +import { mapScalarToGraphQL } from "./lib/scalars.js"; +import { ObjectTypeMap, type TSPContext, type TypeKey } from "./type-maps.js"; // The TSPTypeContext interface represents the intermediate TSP type information before materialization. // It stores the raw TSP type and any extracted metadata relevant for GraphQL generation. @@ -16,19 +27,19 @@ interface TSPTypeContext { // TODO: Add any other TSP-specific metadata here. } /** - * GraphQLTypeRegistry manages the registration and materialization of TypeSpec (TSP) + * GraphQLTypeRegistry manages the registration and materialization of TypeSpec (TSP) * types into their corresponding GraphQL type definitions. * * The registry operates in a two-stage process: * 1. Registration: TSP types (like Enums, Models, etc.) are first registered * along with relevant metadata (e.g., name, usage flags). This stores an * intermediate representation (`TSPTypeContext`) without immediately creating - * GraphQL types. This stage is typically performed while traversing the TSP AST. + * GraphQL types. This stage is typically performed while traversing the TSP AST. * Register type by calling the appropriate method (e.g., `addEnum`). - * + * * 2. Materialization: When a GraphQL type is needed (e.g., to build the final * schema or resolve a field type), the registry can materialize the TSP type - * into its GraphQL counterpart (e.g., `GraphQLEnumType`, `GraphQLObjectType`). + * into its GraphQL counterpart (e.g., `GraphQLEnumType`, `GraphQLObjectType`). * Materialize types by calling the appropriate method (e.g., `materializeEnum`). * * This approach helps in: @@ -39,61 +50,148 @@ interface TSPTypeContext { * by using thunks for fields/arguments. */ export class GraphQLTypeRegistry { - // Stores intermediate TSP type information, keyed by TSP type name. - // TODO: make this more of a seen set - private TSPTypeContextRegistry: Map = new Map(); + // Global registry to prevent GraphQL type name collisions + static #globalNameRegistry = new Set(); - // Stores materialized GraphQL types, keyed by their GraphQL name. - private materializedGraphQLTypes: Map = new Map(); + // Type maps for different GraphQL types + #objectTypes: ObjectTypeMap; - addEnum(tspEnum: Enum): void { - const enumName = tspEnum.name; - if (this.TSPTypeContextRegistry.has(enumName)) { - // Optionally, log a warning or update if new information is more complete. - return; - } + // Program reference for using TypeSpec utilities + #program: Program; + + // TypeSpec typekit for easy access to TypeSpec utilities + #typekit: ReturnType; + + constructor(program: Program) { + // Initialize type maps with necessary dependencies + this.#objectTypes = new ObjectTypeMap(); + this.#program = program; + this.#typekit = $(program); + } - this.TSPTypeContextRegistry.set(enumName, { - tspType: tspEnum, - name: enumName, - // TODO: Populate usageFlags based on TSP context and other decorator context. - }); + /** + * Reset the global name registry + */ + static resetGlobalRegistry(): void { + GraphQLTypeRegistry.#globalNameRegistry.clear(); } - // Materializes a TSP Enum into a GraphQLEnumType. - materializeEnum(enumName: string): GraphQLEnumType | undefined { - // Check if the GraphQL type is already materialized. - if (this.materializedGraphQLTypes.has(enumName)) { - return this.materializedGraphQLTypes.get(enumName) as GraphQLEnumType; + /** + * Get GraphQL type names for a model based on usage flags + * Returns a mapping of usage flags to their corresponding GraphQL type names + */ + #getModelTypeNames(modelName: string): Record { + // For now, we only support output types + // TODO: Add support for input types when InputTypeMap is implemented + const outputTypeName = this.#objectTypes.isRegistered(modelName) ? modelName : undefined; + + return { + [UsageFlags.None]: undefined, + [UsageFlags.Input]: undefined, // TODO: Implement when InputTypeMap is added + [UsageFlags.Output]: outputTypeName, + }; + } + + /** + * Register a TSP Model + */ + addModel(model: Model): void { + const model_context: TSPContext = { + type: model, + usageFlag: UsageFlags.Output, + graphqlName: model.name, + metadata: {}, + }; + + // Check if the model name already exists in the global registry + const graphqlName = model_context.graphqlName as TypeKey; + if (GraphQLTypeRegistry.#globalNameRegistry.has(graphqlName)) { + throw new Error( + `GraphQL type name '${graphqlName}' is already registered. Type names must be unique across the entire schema.`, + ); } - const context = this.TSPTypeContextRegistry.get(enumName); - if (!context || context.tspType.kind !== "Enum") { - // TODO: Handle error or warning for missing context. - return undefined; + this.#objectTypes.register(model_context); + GraphQLTypeRegistry.#globalNameRegistry.add(graphqlName); + + // TODO: Register input types for models + } + + /** + * Materializes a TSP Model into a GraphQLObjectType. + */ + materializeModel(modelName: string): GraphQLObjectType | undefined { + const model = this.#objectTypes.get(modelName as TypeKey); + return model; // This will be undefined for models with no fields, which is correct + } + + /** + * Register a model property + */ + addModelProperty(property: ModelProperty): void { + // Only process properties that have a parent model + if (!property.model) { + return; } - const tspEnum = context.tspType as Enum; + // Create a thunk for the property type that will be resolved later + const typeThunk = (): GraphQLOutputType | GraphQLInputType => { + return this.#mapTypeSpecToGraphQL(property.type); + }; + + // Check if this property represents a list/array type + const isListType = + this.#typekit.model.is(property.type) && this.#typekit.array.is(property.type); - const gqlEnum = new GraphQLEnumType({ - name: context.name, - values: Object.fromEntries( - Array.from(tspEnum.members.values()).map((member) => [ - member.name, - { - value: member.value ?? member.name, - }, - ]), - ), - }); + // Register the field with the object type map + this.#objectTypes.registerField( + property.model.name, // modelName + property.name, // fieldName + typeThunk, // type (thunk) + property.optional, // isOptional + isListType, // isList + undefined, // args + ); + } + + /** + * Maps a TypeSpec type to a GraphQL type + */ + #mapTypeSpecToGraphQL(type: Type): GraphQLOutputType | GraphQLInputType { + if (this.#typekit.scalar.is(type)) { + return mapScalarToGraphQL(type, this.#typekit); + } + + if (this.#typekit.model.is(type)) { + if (this.#typekit.array.is(type)) { + const elementType = this.#typekit.array.getElementType(type); + const graphqlElementType = this.#mapTypeSpecToGraphQL(elementType); + // Return the array element type directly for now, the GraphQLList wrapper + // will be applied in the materializeFields method based on the isList flag + return graphqlElementType; + } + // For regular model types, get the materialized GraphQL object type + const modelType = this.#objectTypes.get(type.name as TypeKey); + if (!modelType) { + throw new Error( + `Referenced model ${type.name} not found. Make sure it's registered before being referenced.`, + ); + } + return modelType; + } - this.materializedGraphQLTypes.set(enumName, gqlEnum); - return gqlEnum; + // For unsupported types, log a warning and default to string + console.warn(`Unsupported TypeSpec type: ${type.kind}, defaulting to GraphQLString`); + return GraphQLString; } + /** + * Materialize the schema configuration for GraphQL + */ materializeSchemaConfig(): GraphQLSchemaConfig { - const allMaterializedGqlTypes = Array.from(this.materializedGraphQLTypes.values()); - let queryType = this.materializedGraphQLTypes.get("Query") as GraphQLObjectType | undefined; + // TODO: Add other types to allMaterializedGqlTypes + const allMaterializedGqlTypes = Array.from(this.#objectTypes.getAllMaterialized()); + let queryType = this.#objectTypes.get("Query" as TypeKey) as GraphQLObjectType | undefined; if (!queryType) { queryType = new GraphQLObjectType({ name: "Query", diff --git a/packages/graphql/src/schema-emitter.ts b/packages/graphql/src/schema-emitter.ts index 0238c5a028e..50831f37e82 100644 --- a/packages/graphql/src/schema-emitter.ts +++ b/packages/graphql/src/schema-emitter.ts @@ -1,17 +1,18 @@ import { createDiagnosticCollector, + ListenerFlow, navigateTypesInNamespace, type Diagnostic, type DiagnosticCollector, type EmitContext, - type Enum, type Model, + type ModelProperty, + type Namespace, } from "@typespec/compiler"; import { GraphQLSchema, validateSchema } from "graphql"; import { type GraphQLEmitterOptions } from "./lib.js"; import type { Schema } from "./lib/schema.js"; import { GraphQLTypeRegistry } from "./registry.js"; -import { exit } from "node:process"; class GraphQLSchemaEmitter { private tspSchema: Schema; @@ -29,7 +30,7 @@ class GraphQLSchemaEmitter { this.context = context; this.options = options; this.diagnostics = createDiagnosticCollector(); - this.registry = new GraphQLTypeRegistry(); + this.registry = new GraphQLTypeRegistry(context.program); } async emitSchema(): Promise<[GraphQLSchema, Readonly] | undefined> { @@ -54,17 +55,20 @@ class GraphQLSchemaEmitter { semanticNodeListener() { // TODO: Add GraphQL types to registry as the TSP nodes are visited return { - enum: (node: Enum) => { - this.registry.addEnum(node); + namespace: (namespace: Namespace) => { + if (namespace.name === "TypeSpec" || namespace.name === "Reflection") { + return ListenerFlow.NoRecursion; + } + return; }, model: (node: Model) => { - // Add logic to handle the model node - }, - exitEnum: (node: Enum) => { - this.registry.materializeEnum(node.name); + this.registry.addModel(node); }, exitModel: (node: Model) => { - // Add logic to handle the exit of the model node + this.registry.materializeModel(node.name); + }, + modelProperty: (node: ModelProperty) => { + this.registry.addModelProperty(node); }, }; } diff --git a/packages/graphql/src/type-maps.ts b/packages/graphql/src/type-maps.ts index a1555f7dfb1..897f618005c 100644 --- a/packages/graphql/src/type-maps.ts +++ b/packages/graphql/src/type-maps.ts @@ -1,5 +1,16 @@ -import { UsageFlags, type Type } from "@typespec/compiler"; -import type { GraphQLType } from "graphql"; +import { UsageFlags, type Model, type Type } from "@typespec/compiler"; +import { + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + type GraphQLFieldConfigArgumentMap, + type GraphQLFieldConfigMap, + type GraphQLInputType, + type GraphQLOutputType, + type GraphQLType, +} from "graphql"; /** * TypeSpec context for type mapping @@ -12,10 +23,58 @@ export interface TSPContext { metadata: Record; // Additional metadata } +/** + * Thunk types for lazy evaluation of GraphQL types and arguments + */ +export type ThunkGraphQLType = () => GraphQLInputType | GraphQLOutputType; +export type ThunkGraphQLFieldConfigArgumentMap = () => GraphQLFieldConfigArgumentMap; + +/** + * Configuration for thunk-based field definitions + */ +export interface ThunkFieldConfig { + type: ThunkGraphQLType; + isOptional: boolean; + isList: boolean; + args?: ThunkGraphQLFieldConfigArgumentMap; +} + +/** + * Model field map to store thunk field configurations + */ +export class ModelFieldMap { + #fieldMap = new Map(); + + /** + * Add a field with thunk configuration + */ + addField( + fieldName: string, + type: ThunkGraphQLType, + isOptional: boolean, + isList: boolean, + args?: ThunkGraphQLFieldConfigArgumentMap, + ): void { + this.#fieldMap.set(fieldName, { + type, + isOptional, + isList, + args, + }); + } + + /** + * Get all field thunk configurations + */ + getFieldThunks(): Map { + return this.#fieldMap; + } +} + /** * Nominal type for keys in the TypeMap */ -type TypeKey = string & { __typeKey: any }; +export type TypeKey = string & { __typeKey: any }; /** * Base TypeMap for all GraphQL type mappings @@ -23,7 +82,6 @@ type TypeKey = string & { __typeKey: any }; * @template G - The GraphQL type constrained to GraphQL's GraphQLType */ export abstract class TypeMap { - // Map of materialized GraphQL types protected materializedMap = new Map(); @@ -101,3 +159,143 @@ export abstract class TypeMap { */ protected abstract materialize(context: TSPContext): G; } + +/** + * TypeMap for GraphQL Object types (output types) + */ +export class ObjectTypeMap extends TypeMap { + // Maps for fields by model name + #modelFieldMaps = new Map(); + + // For handling interfaces + #interfacesMap = new Map(); + + /** + * Get a name from a context + */ + protected override getNameFromContext(context: TSPContext): TypeKey { + return (context.graphqlName as TypeKey) || (context.type.name as TypeKey) || ("" as TypeKey); + } + + /** + * Register a field for a model + */ + registerField( + modelName: string, + fieldName: string, + type: ThunkGraphQLType, + isOptional: boolean, + isList: boolean, + args?: ThunkGraphQLFieldConfigArgumentMap, + ): void { + if (!this.#modelFieldMaps.has(modelName)) { + this.#modelFieldMaps.set(modelName, new ModelFieldMap()); + } + + this.#modelFieldMaps.get(modelName)!.addField(fieldName, type, isOptional, isList, args); + } + + /** + * Add an interface to a model + */ + addInterface(modelName: string, interfaceType: GraphQLInterfaceType): void { + if (!this.#interfacesMap.has(modelName)) { + this.#interfacesMap.set(modelName, []); + } + this.#interfacesMap.get(modelName)!.push(interfaceType); + } + + /** + * Get interfaces for a model + */ + getInterfaces(modelName: string): GraphQLInterfaceType[] { + return this.#interfacesMap.get(modelName) || []; + } + + /** + * Check if a model has any fields + */ + hasFields(modelName: string): boolean { + const fieldMap = this.#modelFieldMaps.get(modelName); + return fieldMap ? fieldMap.getFieldThunks().size > 0 : false; + } + + /** + * Get the materialized GraphQL type, but only if it has fields + * @param name - The type name as a TypeKey + * @returns The materialized GraphQL type or undefined + */ + override get(name: TypeKey): GraphQLObjectType | undefined { + // Return already materialized type if available + if (this.materializedMap.has(name)) { + return this.materializedMap.get(name); + } + + // Check if the model has fields before attempting to materialize + // GraphQL requires object types to have at least one field + if (!this.hasFields(name)) { + return undefined; + } + + // Attempt to materialize if registered + const context = this.registrationMap.get(name); + if (context) { + const materializedType = this.materialize(context); + this.materializedMap.set(name, materializedType); + return materializedType; + } + + return undefined; + } + + /** + * Materialize a GraphQL object type + */ + protected override materialize(context: TSPContext): GraphQLObjectType { + const modelName = this.getNameFromContext(context); + + return new GraphQLObjectType({ + name: modelName, + fields: () => this.#materializeFields(modelName), + interfaces: () => this.getInterfaces(modelName), + }); + } + + /** + * Materialize fields for a model + */ + #materializeFields(modelName: string): GraphQLFieldConfigMap { + const fieldMap = this.#modelFieldMaps.get(modelName); + if (!fieldMap) { + return {}; + } + + const result: GraphQLFieldConfigMap = {}; + const fieldThunks = fieldMap.getFieldThunks(); + + fieldThunks.forEach((config, fieldName) => { + let fieldType = config.type() as GraphQLOutputType; + + if (fieldType instanceof GraphQLInputObjectType) { + throw new Error( + `Model "${modelName}" has a field "${fieldName}" that is an input type. It should be an output type.`, + ); + } + + if (!config.isOptional) { + fieldType = new GraphQLNonNull(fieldType); + } + + if (config.isList) { + fieldType = new GraphQLNonNull(new GraphQLList(fieldType)); + } + + result[fieldName] = { + type: fieldType, + args: config.args ? config.args() : undefined, + }; + }); + + return result; + } +} diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts index bd0b1a74566..4722d6979c2 100644 --- a/packages/graphql/test/emitter.test.ts +++ b/packages/graphql/test/emitter.test.ts @@ -4,7 +4,19 @@ import { emitSingleSchema } from "./test-host.js"; // For now, the expected output is a placeholder string. // In the future, this should be replaced with the actual GraphQL schema output. -const expectedGraphQLSchema = `type Query { +const expectedGraphQLSchema = `type Book { + name: String! + page_count: Int! + published: Boolean! + price: Float! +} + +type Author { + name: String! + books: [Book!]! +} + +type Query { """ A placeholder field. If you are seeing this, it means no operations were defined that could be emitted. """ diff --git a/packages/graphql/test/main.tsp b/packages/graphql/test/main.tsp index a2f1e62e0d9..4102827c2f5 100644 --- a/packages/graphql/test/main.tsp +++ b/packages/graphql/test/main.tsp @@ -8,6 +8,7 @@ namespace MyLibrary { title: string; publicationDate: string; author: Author; + price: float32; } model Author { @@ -16,6 +17,7 @@ namespace MyLibrary { bio?: string; books: Book[]; friend: Author; + age: int32; } enum Genre { diff --git a/packages/graphql/test/schema.test.ts b/packages/graphql/test/schema.test.ts index 121ba7e8809..c8a86e61478 100644 --- a/packages/graphql/test/schema.test.ts +++ b/packages/graphql/test/schema.test.ts @@ -2,6 +2,7 @@ import type { Namespace } from "@typespec/compiler"; import { expectDiagnosticEmpty } from "@typespec/compiler/testing"; import { describe, expect, it } from "vitest"; import { getSchema } from "../src/lib/schema.js"; +import { GraphQLTypeRegistry } from "../src/registry.js"; import { compileAndDiagnose } from "./test-host.js"; describe("@schema", () => { @@ -35,3 +36,64 @@ describe("@schema", () => { expect(schema?.name).toBe("MySchema"); }); }); + +describe("GraphQLTypeRegistry - Collision Detection", () => { + // Mock a basic TypeSpec Model for testing + function createMockModel(name: string): any { + return { + kind: "Model", + name, + properties: new Map(), + baseModel: undefined, + derivedModels: [], + decorators: [], + namespace: undefined, + node: undefined, + indexer: undefined, + sourceModel: undefined, + }; + } + + // Mock a basic TypeSpec Program for testing + function createMockProgram(): any { + return { + checker: { + getStdType: (name: string) => ({ kind: "Scalar", name }), + }, + }; + } + + it("should prevent registering models with duplicate names", () => { + // Reset global registry before test + GraphQLTypeRegistry.resetGlobalRegistry(); + + const program = createMockProgram(); + const registry = new GraphQLTypeRegistry(program); + + const model1 = createMockModel("User"); + const model2 = createMockModel("User"); // Same name + + // First registration should succeed + expect(() => registry.addModel(model1)).not.toThrow(); + + // Second registration with same name should fail + expect(() => registry.addModel(model2)).toThrow( + "GraphQL type name 'User' is already registered. Type names must be unique across the entire schema.", + ); + }); + + it("should allow registering models with different names", () => { + // Reset global registry before test + GraphQLTypeRegistry.resetGlobalRegistry(); + + const program = createMockProgram(); + const registry = new GraphQLTypeRegistry(program); // Fresh registry instance + + const user = createMockModel("User"); + const book = createMockModel("Book"); + + // Both registrations should succeed + expect(() => registry.addModel(user)).not.toThrow(); + expect(() => registry.addModel(book)).not.toThrow(); + }); +});