Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/graphql/src/lib/scalars.ts
Original file line number Diff line number Diff line change
@@ -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(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like https://github.com/microsoft/typespec/blob/ff28640117f7f58166c9455c4812acd7fc044636/packages/http-client-js/src/components/transforms/scalar-transform.tsx establishes a good pattern to follow here.

Parts of that code (like getScalarTests) should arguably part of a shared library — only the scalarTransformerMap is really emitter-specific. Might want to check with @joheredi on that.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think scalar-transform really belongs in packages/emitter-framework. There is some type-transform in there but I think that is probably not used much and might want to replace with the transforms from http-client-js.

@maorleger - fyi

scalar: Scalar,
typekit: ReturnType<typeof $>,
): 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;
}
194 changes: 146 additions & 48 deletions packages/graphql/src/registry.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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:
Expand All @@ -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<string, TSPTypeContext> = new Map();
// Global registry to prevent GraphQL type name collisions
static #globalNameRegistry = new Set<TypeKey>();

// Stores materialized GraphQL types, keyed by their GraphQL name.
private materializedGraphQLTypes: Map<string, GraphQLNamedType> = new Map();
// Type maps for different GraphQL types
#objectTypes: ObjectTypeMap;
Comment on lines +56 to +57

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we'll want a construct that can return the mapping for any type?
e.g.

#getTypeMap(tspType: Type): TypeMap<Type, Any> {};


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<typeof $>;

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<UsageFlags, string | undefined> {
// 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<Model> = {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the expected style is to use pascalCase.
Python habits are hard to break 🙂

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 => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think GraphQLOutputType | GraphQLInputType == GraphQLType

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 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",
Expand Down
24 changes: 14 additions & 10 deletions packages/graphql/src/schema-emitter.ts
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 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;
Expand All @@ -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<Diagnostic[]>] | undefined> {
Expand All @@ -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") {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (namespace.name === "TypeSpec" || namespace.name === "Reflection") {
if (["TypeSpec", "Reflection"].includes(namespace.name)) {

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);
},
};
}
Expand Down
Loading