-
Notifications
You must be signed in to change notification settings - Fork 1
Add basic TSP Model registration and materialization #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/graphql
Are you sure you want to change the base?
Changes from 4 commits
316dab2
e45831d
6afe5ae
b2fd7b6
ba8e7e4
3fe49ae
9a40e04
fe12937
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,10 @@ import { | |
| GraphQLBoolean, | ||
| GraphQLEnumType, | ||
| GraphQLObjectType, | ||
| GraphQLString, | ||
| type GraphQLFieldConfigMap, | ||
| type GraphQLNamedType, | ||
| type GraphQLOutputType, | ||
| type GraphQLSchemaConfig, | ||
| } from "graphql"; | ||
|
|
||
|
|
@@ -15,32 +18,30 @@ interface TSPTypeContext { | |
| usageFlags?: Set<UsageFlags>; | ||
| // 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 per-property lazy evaluation. | ||
| */ | ||
| export class GraphQLTypeRegistry { | ||
| // Stores intermediate TSP type information, keyed by TSP type name. | ||
| // TODO: make this more of a seen set | ||
| private TSPTypeContextRegistry: Map<string, TSPTypeContext> = new Map(); | ||
|
|
||
| // Stores materialized GraphQL types, keyed by their GraphQL name. | ||
|
|
@@ -49,13 +50,24 @@ 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, | ||
| }); | ||
| } | ||
|
|
||
| addModel(tspModel: Model): void { | ||
| const modelName = tspModel.name; | ||
| if (this.TSPTypeContextRegistry.has(modelName)) { | ||
| return; | ||
| } | ||
|
|
||
| this.TSPTypeContextRegistry.set(modelName, { | ||
| tspType: tspModel, | ||
| name: modelName, | ||
| // TODO: Populate usageFlags based on TSP context and other decorator context. | ||
| }); | ||
| } | ||
|
|
@@ -79,7 +91,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,9 +103,71 @@ export class GraphQLTypeRegistry { | |
| return gqlEnum; | ||
| } | ||
|
|
||
| private computeModelFields(tspModel: Model): GraphQLFieldConfigMap<any, any> { | ||
|
||
| const registry = this; | ||
|
|
||
| const fields: GraphQLFieldConfigMap<any, any> = {}; | ||
|
||
|
|
||
| // Process each property of the model | ||
| for (const [propertyName, property] of tspModel.properties) { | ||
| 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; | ||
| } | ||
|
|
||
| // 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; | ||
|
|
||
| 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; | ||
|
|
||
| if (!queryType) { | ||
| queryType = new GraphQLObjectType({ | ||
| name: "Query", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,18 @@ | ||
| 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"; | ||
| import type { Schema } from "./lib/schema.js"; | ||
| import { GraphQLTypeRegistry } from "./registry.js"; | ||
| import { exit } from "node:process"; | ||
|
|
||
| class GraphQLSchemaEmitter { | ||
| private tspSchema: Schema; | ||
|
|
@@ -52,19 +53,24 @@ class GraphQLSchemaEmitter { | |
| } | ||
|
|
||
| semanticNodeListener() { | ||
| // TODO: Add GraphQL types to registry as the TSP nodes are visited | ||
| 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) => { | ||
| // Add logic to handle the model node | ||
| 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 | ||
| this.registry.materializeModel(node.name); | ||
|
||
| }, | ||
| }; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,7 +14,9 @@ describe("@Interface", () => { | |
| TestModel: Model; | ||
| }>(` | ||
| @Interface | ||
| @test model TestModel {} | ||
| @test model TestModel { | ||
| name: string; | ||
| } | ||
|
Comment on lines
+17
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are we adding fields to all these test models?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is to fix a bunch of schema validation errors: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gotcha. That is a real issue though, as TypeSpec has no problem with empty models. |
||
| `); | ||
| 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); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,27 +1,37 @@ | ||
| 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 { | ||
| id: string; | ||
| name: string; | ||
| bio?: string; | ||
| books: Book[]; | ||
| book: Book; | ||
| friend: Author; | ||
| publisher: Publisher; | ||
| } | ||
|
|
||
|
|
||
| model Publisher { | ||
| id: string; | ||
| name: string; | ||
| book: Book; | ||
| author: Author; | ||
| } | ||
|
|
||
| enum Genre { | ||
| Fiction, | ||
| NonFiction, | ||
| Mystery, | ||
| Fantasy | ||
| Fantasy, | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we can assume that
tspModel.nameis the "GraphQL name". There are a number of transformations that might be applied to the name.Some of them (like use of the
@friendlyNamedecorator, or visibility) will be resolved by the compiler (though I think we have to e.g. callgetFriendlyName), but others we will be computing in the GraphQL emitter (like changing names to be GraphQL-friendly, addingInputsuffixes, etc).(forgive me, I may have asked this before about enums)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I might be wrong, but I don't think the TSPTypeContextRegistry is storing the GraphQL name. But, then I must ask what is the context registry storing and why?