From 316dab28e174dc48a8e0c25a92fe951ba43bdea4 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 28 May 2025 13:34:32 -0400 Subject: [PATCH 1/8] Add model registration/materialization and circular/forward reference handling --- packages/graphql/src/registry.ts | 85 +++++++++++++++++++++++--- packages/graphql/src/schema-emitter.ts | 8 +-- packages/graphql/test/main.tsp | 18 ++++-- 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts index b1fd29e64d0..f9c03879466 100644 --- a/packages/graphql/src/registry.ts +++ b/packages/graphql/src/registry.ts @@ -3,7 +3,9 @@ import { GraphQLBoolean, GraphQLEnumType, GraphQLObjectType, + GraphQLString, type GraphQLNamedType, + type GraphQLOutputType, type GraphQLSchemaConfig, } from "graphql"; @@ -15,32 +17,30 @@ interface TSPTypeContext { usageFlags?: Set; // 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: * - Decoupling TSP AST traversal from GraphQL object instantiation. * - Caching materialized GraphQL types to avoid redundant work and ensure object identity. - * - Handling forward references and circular dependencies, as types can be - * registered first and materialized later when all dependencies are known or - * by using thunks for fields/arguments. + * - Handling forward references and circular dependencies through thunks */ 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(); // Stores materialized GraphQL types, keyed by their GraphQL name. @@ -60,6 +60,20 @@ export class GraphQLTypeRegistry { }); } + addModel(tspModel: Model): void { + const modelName = tspModel.name; + if (this.TSPTypeContextRegistry.has(modelName)) { + // Optionally, log a warning or update if new information is more complete. + return; + } + + this.TSPTypeContextRegistry.set(modelName, { + tspType: tspModel, + name: modelName, + // TODO: Populate usageFlags based on TSP context and other decorator context. + }); + } + // Materializes a TSP Enum into a GraphQLEnumType. materializeEnum(enumName: string): GraphQLEnumType | undefined { // Check if the GraphQL type is already materialized. @@ -79,7 +93,7 @@ export class GraphQLTypeRegistry { name: context.name, values: Object.fromEntries( Array.from(tspEnum.members.values()).map((member) => [ - member.name, + member.name, { value: member.value ?? member.name, }, @@ -91,6 +105,59 @@ export class GraphQLTypeRegistry { return gqlEnum; } + private computeModelFields(tspModel: Model): Record { + const fields: Record = {}; + + // Process each property of the model + for (const [propertyName, property] of tspModel.properties) { + // For now, we'll handle only simple scalar types and references to other models + // TODO: Add proper type resolution based on the property type + let fieldType: GraphQLOutputType = GraphQLString; // Default to string for now + + // If the property type is a reference to another type, try to materialize it + if (property.type.kind === "Model") { + const referencedType = this.materializeModel(property.type.name); + if (referencedType) { + fieldType = referencedType; + } + } else if (property.type.kind === "Enum") { + const referencedType = this.materializeEnum(property.type.name); + if (referencedType) { + fieldType = referencedType; + } + } + + fields[propertyName] = { type: fieldType }; + } + + return fields; + } + + // Materializes a TSP Model into a GraphQLObjectType. + materializeModel(modelName: string): GraphQLObjectType | undefined { + // Check if the GraphQL type is already materialized. + if (this.materializedGraphQLTypes.has(modelName)) { + return this.materializedGraphQLTypes.get(modelName) as GraphQLObjectType; + } + + const context = this.TSPTypeContextRegistry.get(modelName); + if (!context || context.tspType.kind !== "Model") { + // TODO: Handle error or warning for missing context. + return undefined; + } + + const tspModel = context.tspType as Model; + + // Create the GraphQL object type with a thunk for fields to handle forward references + const gqlObjectType = new GraphQLObjectType({ + name: context.name, + fields: () => this.computeModelFields(tspModel), + }); + + this.materializedGraphQLTypes.set(modelName, gqlObjectType); + return gqlObjectType; + } + materializeSchemaConfig(): GraphQLSchemaConfig { const allMaterializedGqlTypes = Array.from(this.materializedGraphQLTypes.values()); let queryType = this.materializedGraphQLTypes.get("Query") as GraphQLObjectType | undefined; diff --git a/packages/graphql/src/schema-emitter.ts b/packages/graphql/src/schema-emitter.ts index 0238c5a028e..427d8d3c571 100644 --- a/packages/graphql/src/schema-emitter.ts +++ b/packages/graphql/src/schema-emitter.ts @@ -11,7 +11,6 @@ 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; @@ -52,19 +51,20 @@ class GraphQLSchemaEmitter { } semanticNodeListener() { - // TODO: Add GraphQL types to registry as the TSP nodes are visited return { enum: (node: Enum) => { this.registry.addEnum(node); }, model: (node: Model) => { - // Add logic to handle the model node + // Register the model in the registry + this.registry.addModel(node); }, exitEnum: (node: Enum) => { this.registry.materializeEnum(node.name); }, exitModel: (node: Model) => { - // Add logic to handle the exit of the model node + // Materialize the model after all its properties have been processed + this.registry.materializeModel(node.name); }, }; } diff --git a/packages/graphql/test/main.tsp b/packages/graphql/test/main.tsp index a2f1e62e0d9..0195f37fe42 100644 --- a/packages/graphql/test/main.tsp +++ b/packages/graphql/test/main.tsp @@ -1,13 +1,15 @@ import "@typespec/graphql"; using GraphQL; -@schema(#{name: "library-schema"}) +@schema(#{ name: "library-schema" }) namespace MyLibrary { model Book { id: string; title: string; publicationDate: string; author: Author; + prequel: Book; + genre: Genre; } model Author { @@ -16,12 +18,20 @@ namespace MyLibrary { bio?: string; books: Book[]; friend: Author; + publisher: Publisher; } - + + model Publisher { + id: string; + name: string; + book: Book; + author: Author; + } + enum Genre { Fiction, NonFiction, Mystery, - Fantasy + Fantasy, } -} \ No newline at end of file +} From e45831d87298d90eb0c5b4e746886ea22f818be4 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 28 May 2025 14:56:24 -0400 Subject: [PATCH 2/8] Update tests --- packages/graphql/src/graphql-emitter.ts | 7 +--- packages/graphql/src/registry.ts | 1 - packages/graphql/src/schema-emitter.ts | 10 ++++- packages/graphql/test/emitter.test.ts | 24 +++++++++-- packages/graphql/test/interface.test.ts | 40 ++++++++++++++----- .../graphql/test/operation-fields.test.ts | 20 +++++++--- 6 files changed, 74 insertions(+), 28 deletions(-) diff --git a/packages/graphql/src/graphql-emitter.ts b/packages/graphql/src/graphql-emitter.ts index d200219b719..fdb3ffca332 100644 --- a/packages/graphql/src/graphql-emitter.ts +++ b/packages/graphql/src/graphql-emitter.ts @@ -1,9 +1,4 @@ -import { - emitFile, - getNamespaceFullName, - interpolatePath, - type EmitContext, -} from "@typespec/compiler"; +import { emitFile, interpolatePath, type EmitContext } from "@typespec/compiler"; import { printSchema } from "graphql"; import type { ResolvedGraphQLEmitterOptions } from "./emitter.js"; import type { GraphQLEmitterOptions } from "./lib.js"; diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts index f9c03879466..05e7e0921f1 100644 --- a/packages/graphql/src/registry.ts +++ b/packages/graphql/src/registry.ts @@ -173,7 +173,6 @@ export class GraphQLTypeRegistry { }, }); } - return { query: queryType, types: allMaterializedGqlTypes.length > 0 ? allMaterializedGqlTypes : null, diff --git a/packages/graphql/src/schema-emitter.ts b/packages/graphql/src/schema-emitter.ts index 427d8d3c571..783de1d0dad 100644 --- a/packages/graphql/src/schema-emitter.ts +++ b/packages/graphql/src/schema-emitter.ts @@ -1,11 +1,13 @@ import { createDiagnosticCollector, + ListenerFlow, navigateTypesInNamespace, type Diagnostic, type DiagnosticCollector, type EmitContext, type Enum, type Model, + type Namespace, } from "@typespec/compiler"; import { GraphQLSchema, validateSchema } from "graphql"; import { type GraphQLEmitterOptions } from "./lib.js"; @@ -52,18 +54,22 @@ class GraphQLSchemaEmitter { semanticNodeListener() { return { + namespace: (namespace: Namespace) => { + if (namespace.name === "TypeSpec" || namespace.name === "Reflection") { + return ListenerFlow.NoRecursion; + } + return; + }, enum: (node: Enum) => { this.registry.addEnum(node); }, model: (node: Model) => { - // Register the model in the registry this.registry.addModel(node); }, exitEnum: (node: Enum) => { this.registry.materializeEnum(node.name); }, exitModel: (node: Model) => { - // Materialize the model after all its properties have been processed this.registry.materializeModel(node.name); }, }; diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts index bd0b1a74566..40efa765e3f 100644 --- a/packages/graphql/test/emitter.test.ts +++ b/packages/graphql/test/emitter.test.ts @@ -2,9 +2,23 @@ import { strictEqual } from "node:assert"; import { describe, it } from "vitest"; 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 { +// For now, the expected output contains a placeholder string and model property types as String for scalar types that are not yet supported by the emitter. +// In the future, this should be replaced with the correct GraphQL schema output. +const expectedGraphQLSchema = `type Author { + name: String + book: Book + coauthor: Author +} + +type Book { + name: String + page_count: String + published: String + price: String + author: Author +} + +type Query { """ A placeholder field. If you are seeing this, it means no operations were defined that could be emitted. """ @@ -21,10 +35,12 @@ describe("name", () => { page_count: int32; published: boolean; price: float64; + author: Author; } model Author { name: string; - books: Book[]; + book: Book; + coauthor: Author; } op getBooks(): Book[]; op getAuthors(): Author[]; diff --git a/packages/graphql/test/interface.test.ts b/packages/graphql/test/interface.test.ts index deb9339c41a..117200745c8 100644 --- a/packages/graphql/test/interface.test.ts +++ b/packages/graphql/test/interface.test.ts @@ -14,7 +14,9 @@ describe("@Interface", () => { TestModel: Model; }>(` @Interface - @test model TestModel {} + @test model TestModel { + name: string; + } `); expectDiagnosticEmpty(diagnostics); @@ -29,10 +31,14 @@ describe("@compose", () => { AnInterface: Interface; }>(` @Interface - @test model AnInterface {} + @test model AnInterface { + prop: string; + } @compose(AnInterface) - @test model TestModel {} + @test model TestModel { + prop: string; + } `); expectDiagnosticEmpty(diagnostics); @@ -50,12 +56,18 @@ describe("@compose", () => { SecondInterface: Interface; }>(` @Interface - @test model FirstInterface {} + @test model FirstInterface { + prop: string; + } @Interface - @test model SecondInterface {} + @test model SecondInterface { + prop: string; + } @compose(FirstInterface, SecondInterface) - @test model TestModel {} + @test model TestModel { + prop: string; + } `); expectDiagnosticEmpty(diagnostics); @@ -87,7 +99,9 @@ describe("@compose", () => { } @compose(AnInterface) - model TestModel extends AnInterface {} + model TestModel extends AnInterface { + another_prop: string; + } `); expectDiagnosticEmpty(diagnostics); }); @@ -99,7 +113,9 @@ describe("@compose", () => { } @compose(AnInterface) - model TestModel is AnInterface {} + model TestModel is AnInterface { + another_prop: string; + } `); expectDiagnosticEmpty(diagnostics); }); @@ -158,11 +174,15 @@ describe("@compose", () => { AnotherInterface: Interface; }>(` @Interface - @test model AnotherInterface {} + @test model AnotherInterface { + prop: string; + } @compose(AnotherInterface) @Interface - @test model AnInterface {} + @test model AnInterface { + prop: string; + } `); expectDiagnosticEmpty(diagnostics); diff --git a/packages/graphql/test/operation-fields.test.ts b/packages/graphql/test/operation-fields.test.ts index c7b9c26b736..2a147a21ecb 100644 --- a/packages/graphql/test/operation-fields.test.ts +++ b/packages/graphql/test/operation-fields.test.ts @@ -13,7 +13,9 @@ describe("@operationFields", () => { @test op testOperation(): void; @operationFields(testOperation) - @test model TestModel {} + @test model TestModel { + name: string; + } `); expectDiagnosticEmpty(diagnostics); @@ -30,7 +32,9 @@ describe("@operationFields", () => { } @operationFields(TestInterface) - @test model TestModel {} + @test model TestModel { + prop: string; + } `); expectDiagnosticEmpty(diagnostics); @@ -53,7 +57,9 @@ describe("@operationFields", () => { @test op testOperation3(): void; @operationFields(TestInterface, testOperation3) - @test model TestModel {} + @test model TestModel { + prop: string; + } `); expectDiagnosticEmpty(diagnostics); @@ -72,7 +78,9 @@ describe("@operationFields", () => { } @operationFields(TestInterface, TestInterface.testOperation) - @test model TestModel {} + @test model TestModel { + prop: string; + } `); expectDiagnostics(diagnostics, { code: "@typespec/graphql/operation-field-duplicate", @@ -179,7 +187,9 @@ describe("@operationFields", () => { } @operationFields(testOperation, TestInterface.testOperation) - model TestModel {} + model TestModel { + prop: string; + } `); expectDiagnosticEmpty(diagnostics); }); From 6afe5ae0ac3a1739ae63d96e3b859c0ff6e84314 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 28 May 2025 15:19:29 -0400 Subject: [PATCH 3/8] Cleanup comments --- packages/graphql/src/registry.ts | 8 ++------ packages/graphql/test/main.tsp | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts index 05e7e0921f1..83c447cb9b0 100644 --- a/packages/graphql/src/registry.ts +++ b/packages/graphql/src/registry.ts @@ -49,21 +49,18 @@ export class GraphQLTypeRegistry { 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; } this.TSPTypeContextRegistry.set(enumName, { tspType: tspEnum, name: enumName, - // TODO: Populate usageFlags based on TSP context and other decorator context. }); } addModel(tspModel: Model): void { const modelName = tspModel.name; if (this.TSPTypeContextRegistry.has(modelName)) { - // Optionally, log a warning or update if new information is more complete. return; } @@ -110,9 +107,8 @@ export class GraphQLTypeRegistry { // Process each property of the model for (const [propertyName, property] of tspModel.properties) { - // For now, we'll handle only simple scalar types and references to other models - // TODO: Add proper type resolution based on the property type - let fieldType: GraphQLOutputType = GraphQLString; // Default to string for now + // TODO: Add proper type resolution based on the property type, default to string for now + let fieldType: GraphQLOutputType = GraphQLString; // If the property type is a reference to another type, try to materialize it if (property.type.kind === "Model") { diff --git a/packages/graphql/test/main.tsp b/packages/graphql/test/main.tsp index 0195f37fe42..4228e5f9561 100644 --- a/packages/graphql/test/main.tsp +++ b/packages/graphql/test/main.tsp @@ -16,7 +16,7 @@ namespace MyLibrary { id: string; name: string; bio?: string; - books: Book[]; + book: Book; friend: Author; publisher: Publisher; } From b2fd7b62c801efb9c3f304a3388b272b05942ceb Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 29 May 2025 15:33:11 -0400 Subject: [PATCH 4/8] Implement lazy loading for model properties --- packages/graphql/src/registry.ts | 56 +++++++++++++++++++------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts index 83c447cb9b0..9e306a788d8 100644 --- a/packages/graphql/src/registry.ts +++ b/packages/graphql/src/registry.ts @@ -4,6 +4,7 @@ import { GraphQLEnumType, GraphQLObjectType, GraphQLString, + type GraphQLFieldConfigMap, type GraphQLNamedType, type GraphQLOutputType, type GraphQLSchemaConfig, @@ -37,7 +38,7 @@ interface TSPTypeContext { * This approach helps in: * - Decoupling TSP AST traversal from GraphQL object instantiation. * - Caching materialized GraphQL types to avoid redundant work and ensure object identity. - * - Handling forward references and circular dependencies through thunks + * - Handling forward references and circular dependencies through per-property lazy evaluation. */ export class GraphQLTypeRegistry { // Stores intermediate TSP type information, keyed by TSP type name. @@ -102,28 +103,38 @@ export class GraphQLTypeRegistry { return gqlEnum; } - private computeModelFields(tspModel: Model): Record { - const fields: Record = {}; + private computeModelFields(tspModel: Model): GraphQLFieldConfigMap { + const registry = this; + + const fields: GraphQLFieldConfigMap = {}; // Process each property of the model for (const [propertyName, property] of tspModel.properties) { - // TODO: Add proper type resolution based on the property type, default to string for now - let fieldType: GraphQLOutputType = GraphQLString; - - // If the property type is a reference to another type, try to materialize it - if (property.type.kind === "Model") { - const referencedType = this.materializeModel(property.type.name); - if (referencedType) { - fieldType = referencedType; - } - } else if (property.type.kind === "Enum") { - const referencedType = this.materializeEnum(property.type.name); - if (referencedType) { - fieldType = referencedType; - } - } - - fields[propertyName] = { type: fieldType }; + const fieldConfig: any = {}; + + // Define a getter for the type property to enable lazy evaluation per field + Object.defineProperty(fieldConfig, "type", { + get: function () { + // TODO: Add proper type resolution based on the property type, default to string for now + let fieldType: GraphQLOutputType = GraphQLString; + + // If the property type is a reference to another type, try to materialize it + if (property.type.kind === "Model") { + const referencedType = registry.materializeModel(property.type.name); + if (referencedType) { + fieldType = referencedType; + } + } else if (property.type.kind === "Enum") { + const referencedType = registry.materializeEnum(property.type.name); + if (referencedType) { + fieldType = referencedType; + } + } + + return fieldType; + }, + }); + fields[propertyName] = fieldConfig; } return fields; @@ -144,10 +155,9 @@ export class GraphQLTypeRegistry { const tspModel = context.tspType as Model; - // Create the GraphQL object type with a thunk for fields to handle forward references const gqlObjectType = new GraphQLObjectType({ name: context.name, - fields: () => this.computeModelFields(tspModel), + fields: this.computeModelFields(tspModel), }); this.materializedGraphQLTypes.set(modelName, gqlObjectType); @@ -157,6 +167,7 @@ export class GraphQLTypeRegistry { materializeSchemaConfig(): GraphQLSchemaConfig { const allMaterializedGqlTypes = Array.from(this.materializedGraphQLTypes.values()); let queryType = this.materializedGraphQLTypes.get("Query") as GraphQLObjectType | undefined; + if (!queryType) { queryType = new GraphQLObjectType({ name: "Query", @@ -169,6 +180,7 @@ export class GraphQLTypeRegistry { }, }); } + return { query: queryType, types: allMaterializedGqlTypes.length > 0 ? allMaterializedGqlTypes : null, From ba8e7e49560b1a480153472e3f97ef71d2c413d3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 4 Jun 2025 16:11:24 -0400 Subject: [PATCH 5/8] refactor: extract TypeMap classes to separate module --- packages/graphql/src/type-maps.ts | 392 ++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 packages/graphql/src/type-maps.ts diff --git a/packages/graphql/src/type-maps.ts b/packages/graphql/src/type-maps.ts new file mode 100644 index 00000000000..f3cd92468c2 --- /dev/null +++ b/packages/graphql/src/type-maps.ts @@ -0,0 +1,392 @@ +import { UsageFlags, type Enum, type Model } from "@typespec/compiler"; +import { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + type GraphQLEnumValueConfig, + type GraphQLFieldConfigMap, + type GraphQLInputFieldConfigMap, + type GraphQLInputType, + type GraphQLOutputType, + type GraphQLFieldConfigArgumentMap, +} from "graphql"; + +/** + * TypeSpec context for type mapping + * @template T - The TypeSpec type + */ +export interface TSPContext { + type: T; // The TypeSpec type + usageFlag: UsageFlags; // How the type is being used + name?: string; // Optional name override + metadata?: Record; // Optional 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 { + private 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; + } +} + +/** + * Base TypeMap for all GraphQL type mappings + * @template T - The TypeSpec type + * @template G - The GraphQL type + */ +export abstract class TypeMap { + // Map of materialized GraphQL types + protected materializedMap = new Map(); + + // Map of registration contexts + protected registrationMap = new Map>(); + + /** + * Register a TypeSpec type with context for later materialization + * @param context - The TypeSpec context + * @returns The name used for registration + */ + register(context: TSPContext): string { + const name = this.getNameFromContext(context); + + // Check for conflicts with existing registrations + const existing = this.registrationMap.get(name); + if (existing && existing.usageFlag !== context.usageFlag) { + throw new Error( + `Type conflict for "${name}": attempting to register as ${UsageFlags[context.usageFlag]} but already registered as ${UsageFlags[existing.usageFlag]}` + ); + } + + this.registrationMap.set(name, context); + return name; + } + + /** + * Get the materialized GraphQL type + * @param name - The type name + * @returns The materialized GraphQL type or undefined + */ + get(name: string): G | undefined { + // Return already materialized type if available + if (this.materializedMap.has(name)) { + return this.materializedMap.get(name); + } + + // Attempt to materialize if registered + const context = this.registrationMap.get(name); + if (context) { + const materializedType = this.materialize(context); + if (materializedType) { + this.materializedMap.set(name, materializedType); + return materializedType; + } + } + + return undefined; + } + + /** + * Check if a type is registered + */ + isRegistered(name: string): boolean { + return this.registrationMap.has(name); + } + + /** + * Get all materialized types + */ + getAllMaterialized(): G[] { + return Array.from(this.materializedMap.values()); + } + + /** + * Reset the type map + */ + reset(): void { + this.materializedMap.clear(); + this.registrationMap.clear(); + } + + /** + * Get a name from a context + */ + protected abstract getNameFromContext(context: TSPContext): string; + + /** + * Materialize a type from a context + */ + protected abstract materialize(context: TSPContext): G | undefined; +} + +/** + * TypeMap for GraphQL Object types (output types) + */ +export class ObjectTypeMap extends TypeMap { + // Maps for fields by model name + private modelFieldMaps = new Map(); + + // For handling interfaces + private interfacesMap = new Map(); + + /** + * Get a name from a context + */ + protected override getNameFromContext(context: TSPContext): string { + return context.name || context.type.name || ''; + } + + /** + * 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) || []; + } + + /** + * Materialize a GraphQL object type + */ + protected override materialize(context: TSPContext): GraphQLObjectType | undefined { + const modelName = this.getNameFromContext(context); + + return new GraphQLObjectType({ + name: modelName, + fields: () => this.materializeFields(modelName), + interfaces: () => this.getInterfaces(modelName) + }); + } + + /** + * Materialize fields for a model + */ + private 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.isList) { + fieldType = new GraphQLNonNull(new GraphQLList(fieldType)); + } + + result[fieldName] = { + type: fieldType, + args: config.args ? config.args() : undefined + }; + }); + + return result; + } +} + +/** + * TypeMap for GraphQL Input types + */ +export class InputTypeMap extends TypeMap { + // Maps for fields by model name + private modelFieldMaps = new Map(); + + /** + * Get a name from a context + */ + protected override getNameFromContext(context: TSPContext): string { + return context.name || `${context.type.name || ''}Input`; + } + + /** + * Register a field for an input model + */ + registerField( + modelName: string, + fieldName: string, + type: ThunkGraphQLType, + isOptional: boolean, + isList: boolean + ): void { + if (!this.modelFieldMaps.has(modelName)) { + this.modelFieldMaps.set(modelName, new ModelFieldMap()); + } + + this.modelFieldMaps.get(modelName)!.addField( + fieldName, + type, + isOptional, + isList + ); + } + + /** + * Materialize a GraphQL input type + */ + protected override materialize(context: TSPContext): GraphQLInputObjectType | undefined { + const modelName = this.getNameFromContext(context); + + return new GraphQLInputObjectType({ + name: modelName, + fields: () => this.materializeFields(modelName) + }); + } + + /** + * Materialize fields for an input model + */ + private materializeFields(modelName: string): GraphQLInputFieldConfigMap { + const fieldMap = this.modelFieldMaps.get(modelName); + if (!fieldMap) { + return {}; + } + + const result: GraphQLInputFieldConfigMap = {}; + const fieldThunks = fieldMap.getFieldThunks(); + + fieldThunks.forEach((config, fieldName) => { + let fieldType = config.type() as GraphQLInputType; + + if (fieldType instanceof GraphQLObjectType) { + throw new Error( + `Input model "${modelName}" has a field "${fieldName}" that is an output type. It should be an input type.` + ); + } + + if (config.isList) { + fieldType = new GraphQLNonNull(new GraphQLList(fieldType)); + } + + result[fieldName] = { + type: fieldType + }; + }); + + return result; + } +} + +/** + * TypeMap for GraphQL Enum types + */ +export class EnumTypeMap extends TypeMap { + + /** + * Get a name from a context + */ + protected override getNameFromContext(context: TSPContext): string { + return context.name || context.type.name || ''; + } + + /** + * Sanitize enum member names for GraphQL compatibility + */ + private sanitizeEnumMemberName(name: string): string { + // Basic sanitization - replace invalid characters and ensure it starts with letter/underscore + return name.replace(/[^A-Za-z0-9_]/g, '_').replace(/^[^A-Za-z_]/, '_$&'); + } + + /** + * Materialize a GraphQL enum type + */ + protected override materialize(context: TSPContext): GraphQLEnumType | undefined { + const enumType = context.type; + const name = this.getNameFromContext(context); + + return new GraphQLEnumType({ + name, + values: Array.from(enumType.members.values()).reduce<{ + [key: string]: GraphQLEnumValueConfig; + }>((acc, member) => { + acc[this.sanitizeEnumMemberName(member.name)] = { + value: member.value ?? member.name, + }; + return acc; + }, {}) + }); + } +} \ No newline at end of file From 3fe49ae9a72ab3d4f74aed3c17711e200d9c3b18 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 4 Jun 2025 16:11:33 -0400 Subject: [PATCH 6/8] refactor: extract scalar mapping to dedicated module --- packages/graphql/src/lib/scalars.ts | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 packages/graphql/src/lib/scalars.ts diff --git a/packages/graphql/src/lib/scalars.ts b/packages/graphql/src/lib/scalars.ts new file mode 100644 index 00000000000..6200cf001c4 --- /dev/null +++ b/packages/graphql/src/lib/scalars.ts @@ -0,0 +1,31 @@ +import { + GraphQLBoolean, + GraphQLString, + GraphQLInt, + GraphQLFloat, + type GraphQLInputType, + type GraphQLOutputType, +} from "graphql"; + +/** + * Map TypeSpec scalar types to GraphQL scalar types + */ +export function mapScalarToGraphQL(scalarName: string): GraphQLInputType | GraphQLOutputType { + switch (scalarName) { + case "string": + return GraphQLString; + case "int32": + case "integer": + return GraphQLInt; + case "int64": + // GraphQL doesn't have int64, use string representation + return GraphQLString; + case "float32": + case "float64": + return GraphQLFloat; + case "boolean": + return GraphQLBoolean; + default: + return GraphQLString; + } +} \ No newline at end of file From 9a40e04ee13c60c41853799ab758778132dfa9ad Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 4 Jun 2025 16:11:42 -0400 Subject: [PATCH 7/8] test: add usage tracking tests for conditional input type registration --- packages/graphql/test/usage-tracking.test.ts | 55 ++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 packages/graphql/test/usage-tracking.test.ts diff --git a/packages/graphql/test/usage-tracking.test.ts b/packages/graphql/test/usage-tracking.test.ts new file mode 100644 index 00000000000..154388c5d3b --- /dev/null +++ b/packages/graphql/test/usage-tracking.test.ts @@ -0,0 +1,55 @@ +import { strictEqual } from "node:assert"; +import { describe, it } from "vitest"; +import { emitSchemaForUsageTest } from "./test-host.js"; + +describe("Usage Tracking", () => { + it("Only generates input types for models used as inputs", async () => { + const typeSpecCode = ` + // Model used only in query return types (output-only usage) + model User { + id: string; + name: string; + email: string; + } + + // Model used in both mutation parameters (input) and return types (output) + model Product { + id: string; + title: string; + price: float32; + } + + // Query that returns User (creates output usage only) + op getUser(id: string): User; + + // Mutation that takes Product as input and returns it (creates both input and output usage) + op createProduct(productData: Product): Product; + `; + + const generatedSchema = await emitSchemaForUsageTest(typeSpecCode); + + // User should have GraphQL output type but NO input type (only used in query returns) + strictEqual(generatedSchema.includes("type User"), true, "User output type should be generated"); + strictEqual(generatedSchema.includes("input UserInput"), false, "User input type should NOT be generated"); + + // Product should have BOTH GraphQL output type AND input type (used in mutations and returns) + strictEqual(generatedSchema.includes("type Product"), true, "Product output type should be generated"); + strictEqual(generatedSchema.includes("input ProductInput"), true, "Product input type should be generated"); + }); + + it("Handles models with no operations (output-only by default)", async () => { + const typeSpecCode = ` + // Model defined but not referenced by any operations + model OrphanedModel { + id: string; + metadata: string; + } + `; + + const generatedSchema = await emitSchemaForUsageTest(typeSpecCode); + + // Orphaned models should get output type by default, but no input type + strictEqual(generatedSchema.includes("type OrphanedModel"), true, "Orphaned model should get output type"); + strictEqual(generatedSchema.includes("input OrphanedModelInput"), false, "Orphaned model should NOT get input type"); + }); +}); \ No newline at end of file From fe1293739ebe00cb29f303518edfa43abfd97722 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 4 Jun 2025 16:11:51 -0400 Subject: [PATCH 8/8] feat: implement registry refactoring with usage tracking and array improvements --- packages/graphql/src/registry.ts | 440 ++++++++++++++++++------- packages/graphql/src/schema-emitter.ts | 54 ++- packages/graphql/test/emitter.test.ts | 6 +- packages/graphql/test/main.tsp | 10 +- packages/graphql/test/test-host.ts | 14 + 5 files changed, 392 insertions(+), 132 deletions(-) diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts index 9e306a788d8..b37bf740f99 100644 --- a/packages/graphql/src/registry.ts +++ b/packages/graphql/src/registry.ts @@ -1,172 +1,368 @@ -import { UsageFlags, type Enum, type Model } from "@typespec/compiler"; +import { + isArrayModelType, + UsageFlags, + type Enum, + type Model, + type ModelProperty, + type Program, + type UsageTracker, +} from "@typespec/compiler"; import { GraphQLBoolean, - GraphQLEnumType, GraphQLObjectType, GraphQLString, - type GraphQLFieldConfigMap, + type GraphQLEnumType, + type GraphQLInputObjectType, + type GraphQLInputType, type GraphQLNamedType, type GraphQLOutputType, type GraphQLSchemaConfig, } from "graphql"; +import { mapScalarToGraphQL } from "./lib/scalars.js"; +import { EnumTypeMap, InputTypeMap, ObjectTypeMap, type TSPContext } from "./type-maps.js"; + +/** + * Model type name tracking for usage flags + */ +interface ModelTypeNames { + [UsageFlags.Input]?: string; + [UsageFlags.Output]?: string; + [UsageFlags.None]?: string; +} + +/** + * Registry for managing model type names with usage flags + */ +class ModelTypeRegistry { + private typeNames = new Map(); + + /** + * Register a type name with a usage flag + */ + registerTypeName(modelName: string, usageFlag: UsageFlags): string { + if (!this.typeNames.has(modelName)) { + this.typeNames.set(modelName, {}); + } + + const typeNames = this.typeNames.get(modelName)!; -// 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. -interface TSPTypeContext { - tspType: Enum | Model; // Extend with other TSP types like Operation, Interface, TSP Union, etc. - name: string; - usageFlags?: Set; - // TODO: Add any other TSP-specific metadata here. + let graphqlTypeName: string; + if (usageFlag === UsageFlags.Input) { + graphqlTypeName = `${modelName}Input`; + } else { + graphqlTypeName = modelName; + } + + typeNames[usageFlag] = graphqlTypeName; + return graphqlTypeName; + } + + /** + * Get all GraphQL type names for a model + */ + getModelTypeNames(modelName: string): ModelTypeNames { + return this.typeNames.get(modelName) || {}; + } + + /** + * Reset the registry + */ + reset(): void { + this.typeNames.clear(); + } } /** * 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. - * 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`). - * Materialize types by calling the appropriate method (e.g., `materializeEnum`). - * - * This approach helps in: - * - Decoupling TSP AST traversal from GraphQL object instantiation. - * - Caching materialized GraphQL types to avoid redundant work and ensure object identity. - * - Handling forward references and circular dependencies through per-property lazy evaluation. + * This registry uses a sophisticated type mapping system with specialized maps for + * different GraphQL types, thunk-based field handling, and proper usage context tracking. */ export class GraphQLTypeRegistry { - // Stores intermediate TSP type information, keyed by TSP type name. - private TSPTypeContextRegistry: Map = new Map(); + // Type name registry + private modelTypeNames = new ModelTypeRegistry(); - // Stores materialized GraphQL types, keyed by their GraphQL name. - private materializedGraphQLTypes: Map = new Map(); + // Type maps for different GraphQL types + private objectTypes: ObjectTypeMap; + private inputTypes: InputTypeMap; + private enumTypes: EnumTypeMap; - addEnum(tspEnum: Enum): void { - const enumName = tspEnum.name; - if (this.TSPTypeContextRegistry.has(enumName)) { - return; - } + // Usage tracker for determining input vs output usage + private usageTracker?: UsageTracker; + + // Program instance for TypeSpec utilities + private program: Program; - this.TSPTypeContextRegistry.set(enumName, { - tspType: tspEnum, - name: enumName, - }); + constructor(program: Program) { + this.program = program; + // Initialize type maps with necessary dependencies + this.objectTypes = new ObjectTypeMap(); + this.inputTypes = new InputTypeMap(); + this.enumTypes = new EnumTypeMap(); } - addModel(tspModel: Model): void { - const modelName = tspModel.name; - if (this.TSPTypeContextRegistry.has(modelName)) { - return; - } + /** + * Set the usage tracker for determining input vs output usage + */ + setUsageTracker(usageTracker: UsageTracker): void { + this.usageTracker = usageTracker; + } - this.TSPTypeContextRegistry.set(modelName, { - tspType: tspModel, - name: modelName, - // TODO: Populate usageFlags based on TSP context and other decorator context. - }); + /** + * Add a model to the registry + */ + addModel(model: Model): void { + const modelName = model.name; + if (!modelName) return; + + // Always register for output usage (GraphQL object type) + const outputTypeName = this.modelTypeNames.registerTypeName(modelName, UsageFlags.Output); + const outputContext: TSPContext = { + type: model, + usageFlag: UsageFlags.Output, + name: outputTypeName, + }; + this.objectTypes.register(outputContext); + + // Only register for input usage if the model is actually used as input + if (this.usageTracker?.isUsedAs(model, UsageFlags.Input)) { + const inputTypeName = this.modelTypeNames.registerTypeName(modelName, UsageFlags.Input); + const inputContext: TSPContext = { + type: model, + usageFlag: UsageFlags.Input, + name: inputTypeName, + }; + this.inputTypes.register(inputContext); + } } - // 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; + /** + * Materialize a model for all its registered usage contexts + */ + materializeModelWithAllUsages(modelName: string): { + outputType?: GraphQLObjectType; + inputType?: GraphQLInputObjectType; + } { + const result: { + outputType?: GraphQLObjectType; + inputType?: GraphQLInputObjectType; + } = {}; + + // Get the type names for this model + const typeNames = this.getModelTypeNames(modelName); + + // Materialize output type if registered + const outputTypeName = typeNames[UsageFlags.Output]; + if (outputTypeName) { + result.outputType = this.materializeModel(outputTypeName); } - const context = this.TSPTypeContextRegistry.get(enumName); - if (!context || context.tspType.kind !== "Enum") { - // TODO: Handle error or warning for missing context. - return undefined; + // Materialize input type if registered + const inputTypeName = typeNames[UsageFlags.Input]; + if (inputTypeName) { + result.inputType = this.materializeInputModel(inputTypeName); } - const tspEnum = context.tspType as Enum; + return result; + } - const gqlEnum = new GraphQLEnumType({ - name: context.name, - values: Object.fromEntries( - Array.from(tspEnum.members.values()).map((member) => [ - member.name, - { - value: member.value ?? member.name, - }, - ]), - ), - }); + /** + * Add an enum to the registry + */ + addEnum(tspEnum: Enum): void { + const context: TSPContext = { + type: tspEnum, + usageFlag: UsageFlags.Output, // Enums are typically output types + name: tspEnum.name, + }; + + this.enumTypes.register(context); + } - this.materializedGraphQLTypes.set(enumName, gqlEnum); - return gqlEnum; + /** + * Get all GraphQL type names for a model + */ + getModelTypeNames(modelName: string): ModelTypeNames { + return this.modelTypeNames.getModelTypeNames(modelName); } - private computeModelFields(tspModel: Model): GraphQLFieldConfigMap { - const registry = this; + /** + * Add a model property using TypeSpec ModelProperty + */ + addModelProperty(parentModelName: string, property: ModelProperty): void { + const propertyName = property.name; - const fields: GraphQLFieldConfigMap = {}; + // Determine if the property is optional + const isOptional = property.optional; - // Process each property of the model - for (const [propertyName, property] of tspModel.properties) { - const fieldConfig: any = {}; + // Determine if the property is a list/array using TypeSpec's built-in utility + const isList = property.type.kind === "Model" && isArrayModelType(this.program, property.type); - // Define a getter for the type property to enable lazy evaluation per field - Object.defineProperty(fieldConfig, "type", { - get: function () { - // TODO: Add proper type resolution based on the property type, default to string for now - let fieldType: GraphQLOutputType = GraphQLString; + // Get all GraphQL type names for the model + const typeNames = this.getModelTypeNames(parentModelName); - // If the property type is a reference to another type, try to materialize it - if (property.type.kind === "Model") { - const referencedType = registry.materializeModel(property.type.name); - if (referencedType) { - fieldType = referencedType; + // Add to output type map with output-specific thunk + const outputTypeName = typeNames[UsageFlags.Output]; + if (outputTypeName) { + const outputTypeThunk = () => { + return this.resolvePropertyType(property, UsageFlags.Output); + }; + + this.objectTypes.registerField( + outputTypeName, + propertyName, + outputTypeThunk, + isOptional, + isList, + ); + } + + // Add to input type map with input-specific thunk + const inputTypeName = typeNames[UsageFlags.Input]; + if (inputTypeName) { + const inputTypeThunk = () => { + return this.resolvePropertyType(property, UsageFlags.Input); + }; + + this.inputTypes.registerField( + inputTypeName, + propertyName, + inputTypeThunk, + isOptional, + isList, + ); + } + } + + /** + * Resolve the GraphQL type for a model property with usage context + */ + private resolvePropertyType( + property: ModelProperty, + usageFlag: UsageFlags, + ): GraphQLInputType | GraphQLOutputType { + const propertyType = property.type; + + switch (propertyType.kind) { + case "Scalar": + // Map TypeSpec scalars to GraphQL scalars + return mapScalarToGraphQL(propertyType.name); + + case "Model": + // Check if this is an array type - resolve to element type directly (non-recursive) + if (isArrayModelType(this.program, propertyType)) { + const elementType = propertyType.indexer!.value; + return this.resolveElementType(elementType, usageFlag); + } + + // For regular models, reference the registered type + if (propertyType.name) { + if (usageFlag === UsageFlags.Input) { + const referencedInputType = this.materializeInputModel(propertyType.name); + if (referencedInputType) { + return referencedInputType; } - } else if (property.type.kind === "Enum") { - const referencedType = registry.materializeEnum(property.type.name); - if (referencedType) { - fieldType = referencedType; + } else { + const referencedOutputType = this.materializeModel(propertyType.name); + if (referencedOutputType) { + return referencedOutputType; } } + } + // Fallback to string if model not found + return GraphQLString; - return fieldType; - }, - }); - fields[propertyName] = fieldConfig; - } + case "Enum": + // Reference to an enum (enums work for both input and output) + if (propertyType.name) { + const referencedEnum = this.materializeEnum(propertyType.name); + if (referencedEnum) { + return referencedEnum; + } + } + // Fallback to string if enum not found + return GraphQLString; - return fields; + default: + // Default to GraphQL String for unknown types + return GraphQLString; + } } - // Materializes a TSP Model into a GraphQLObjectType. - materializeModel(modelName: string): GraphQLObjectType | undefined { - // Check if the GraphQL type is already materialized. - if (this.materializedGraphQLTypes.has(modelName)) { - return this.materializedGraphQLTypes.get(modelName) as GraphQLObjectType; - } + /** + * Resolve array element type directly + */ + private resolveElementType( + elementType: any, + usageFlag: UsageFlags, + ): GraphQLInputType | GraphQLOutputType { + switch (elementType.kind) { + case "Scalar": + return mapScalarToGraphQL(elementType.name); + + case "Model": + if (elementType.name) { + if (usageFlag === UsageFlags.Input) { + const referencedInputType = this.materializeInputModel(elementType.name); + if (referencedInputType) { + return referencedInputType; + } + } else { + const referencedOutputType = this.materializeModel(elementType.name); + if (referencedOutputType) { + return referencedOutputType; + } + } + } + return GraphQLString; + + case "Enum": + if (elementType.name) { + const referencedEnum = this.materializeEnum(elementType.name); + if (referencedEnum) { + return referencedEnum; + } + } + return GraphQLString; - const context = this.TSPTypeContextRegistry.get(modelName); - if (!context || context.tspType.kind !== "Model") { - // TODO: Handle error or warning for missing context. - return undefined; + default: + return GraphQLString; } + } - const tspModel = context.tspType as Model; + /** + * Materialize a TSP Enum into a GraphQLEnumType + */ + materializeEnum(enumName: string): GraphQLEnumType | undefined { + return this.enumTypes.get(enumName); + } - const gqlObjectType = new GraphQLObjectType({ - name: context.name, - fields: this.computeModelFields(tspModel), - }); + /** + * Materialize a TSP Model into a GraphQLObjectType + */ + materializeModel(modelName: string): GraphQLObjectType | undefined { + return this.objectTypes.get(modelName); + } - this.materializedGraphQLTypes.set(modelName, gqlObjectType); - return gqlObjectType; + /** + * Materialize a TSP Model into a GraphQLInputObjectType + */ + materializeInputModel(modelName: string): GraphQLInputObjectType | undefined { + return this.inputTypes.get(modelName); } + /** + * Generate the GraphQL schema configuration + */ materializeSchemaConfig(): GraphQLSchemaConfig { - const allMaterializedGqlTypes = Array.from(this.materializedGraphQLTypes.values()); - let queryType = this.materializedGraphQLTypes.get("Query") as GraphQLObjectType | undefined; + const allMaterializedGqlTypes: GraphQLNamedType[] = [ + ...this.objectTypes.getAllMaterialized(), + ...this.inputTypes.getAllMaterialized(), + ...this.enumTypes.getAllMaterialized(), + ]; + + let queryType = this.objectTypes.get("Query"); if (!queryType) { queryType = new GraphQLObjectType({ @@ -186,4 +382,14 @@ export class GraphQLTypeRegistry { types: allMaterializedGqlTypes.length > 0 ? allMaterializedGqlTypes : null, }; } + + /** + * Reset all registries to their initial state + */ + reset(): void { + this.modelTypeNames.reset(); + this.objectTypes.reset(); + this.inputTypes.reset(); + this.enumTypes.reset(); + } } diff --git a/packages/graphql/src/schema-emitter.ts b/packages/graphql/src/schema-emitter.ts index 783de1d0dad..37d9c90b89a 100644 --- a/packages/graphql/src/schema-emitter.ts +++ b/packages/graphql/src/schema-emitter.ts @@ -2,14 +2,20 @@ import { createDiagnosticCollector, ListenerFlow, navigateTypesInNamespace, + resolveUsages, + UsageFlags, type Diagnostic, type DiagnosticCollector, type EmitContext, type Enum, type Model, + type ModelProperty, type Namespace, } from "@typespec/compiler"; -import { GraphQLSchema, validateSchema } from "graphql"; +import { + GraphQLSchema, + validateSchema +} from "graphql"; import { type GraphQLEmitterOptions } from "./lib.js"; import type { Schema } from "./lib/schema.js"; import { GraphQLTypeRegistry } from "./registry.js"; @@ -20,26 +26,36 @@ class GraphQLSchemaEmitter { private options: GraphQLEmitterOptions; private diagnostics: DiagnosticCollector; private registry: GraphQLTypeRegistry; + constructor( tspSchema: Schema, context: EmitContext, options: GraphQLEmitterOptions, ) { - // Initialize any properties if needed, including the registry this.tspSchema = tspSchema; 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> { const schemaNamespace = this.tspSchema.type; - // Logic to emit the GraphQL schema + + // Analyze usage patterns in the schema namespace + const usageTracker = resolveUsages(schemaNamespace); + + // Set the usage tracker in the registry + this.registry.setUsageTracker(usageTracker); + + // Single pass: Register types, process fields, and materialize navigateTypesInNamespace(schemaNamespace, this.semanticNodeListener()); + + // Generate the final schema const schemaConfig = this.registry.materializeSchemaConfig(); const schema = new GraphQLSchema(schemaConfig); - // validate the schema + + // Validate the schema const validationErrors = validateSchema(schema); validationErrors.forEach((error) => { this.diagnostics.add({ @@ -49,9 +65,26 @@ class GraphQLSchemaEmitter { severity: "error", }); }); + return [schema, this.diagnostics.diagnostics]; } + /** + * Single-pass semantic node listener + * + * Two-Phase Processing Pattern + * ============================ + * + * Registration Phase (on visit): + * - Register models/enums when encountered to make them known to the registry + * - Enables forward references and circular dependency resolution + * - Creates thunks for deferred type resolution + * + * Materialization Phase (on exit): + * - Create actual GraphQL types with all fields resolved + * - Thunks can safely resolve since all referenced types are registered + * - Produces complete GraphQL type definitions + */ semanticNodeListener() { return { namespace: (namespace: Namespace) => { @@ -66,11 +99,19 @@ class GraphQLSchemaEmitter { model: (node: Model) => { this.registry.addModel(node); }, + modelProperty: (property: ModelProperty) => { + const parentModel = property.model; + if (parentModel?.name) { + this.registry.addModelProperty(parentModel.name, property); + } + }, exitEnum: (node: Enum) => { this.registry.materializeEnum(node.name); }, exitModel: (node: Model) => { - this.registry.materializeModel(node.name); + if (node.name) { + this.registry.materializeModelWithAllUsages(node.name); + } }, }; } @@ -81,7 +122,6 @@ export function createSchemaEmitter( context: EmitContext, options: GraphQLEmitterOptions, ): GraphQLSchemaEmitter { - // Placeholder for creating a GraphQL schema emitter return new GraphQLSchemaEmitter(schema, context, options); } diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts index 40efa765e3f..bc3a3c0a2fa 100644 --- a/packages/graphql/test/emitter.test.ts +++ b/packages/graphql/test/emitter.test.ts @@ -12,9 +12,9 @@ const expectedGraphQLSchema = `type Author { type Book { name: String - page_count: String - published: String - price: String + page_count: Int + published: Boolean + price: Float author: Author } diff --git a/packages/graphql/test/main.tsp b/packages/graphql/test/main.tsp index 4228e5f9561..186f7cf532d 100644 --- a/packages/graphql/test/main.tsp +++ b/packages/graphql/test/main.tsp @@ -16,16 +16,16 @@ namespace MyLibrary { id: string; name: string; bio?: string; - book: Book; - friend: Author; - publisher: Publisher; + books: Book[]; + friends: Author[]; + publishers: Publisher[]; } model Publisher { id: string; name: string; - book: Book; - author: Author; + books: Book[]; + authors: Author[]; } enum Genre { diff --git a/packages/graphql/test/test-host.ts b/packages/graphql/test/test-host.ts index 6e7568dcb47..92a7e889cf9 100644 --- a/packages/graphql/test/test-host.ts +++ b/packages/graphql/test/test-host.ts @@ -107,3 +107,17 @@ export async function emitSingleSchema( ok(schemaRecord.graphQLOutput, "Expected to have found graphql output"); return schemaRecord.graphQLOutput; } + +/** + * Test usage tracking by creating a simple schema with output-only models + */ +export async function emitSchemaForUsageTest(code: string): Promise { + const testCode = ` +@schema +namespace TestUsage { + ${code} +} + `; + + return await emitSingleSchema(testCode, {}); +}