diff --git a/packages/graphql/src/emitter.ts b/packages/graphql/src/emitter.ts index 6a34c0552df..baa5b68d63c 100644 --- a/packages/graphql/src/emitter.ts +++ b/packages/graphql/src/emitter.ts @@ -1,37 +1,121 @@ -import type { EmitContext, NewLine } from "@typespec/compiler"; -import { resolvePath } from "@typespec/compiler"; -import { createGraphQLEmitter } from "./graphql-emitter.js"; -import type { GraphQLEmitterOptions } from "./lib.js"; +import { type EmitContext, type Namespace } from "@typespec/compiler"; +import { type GraphQLEmitterOptions, reportDiagnostic } from "./lib.js"; +import { listSchemas } from "./lib/schema.js"; +import { + createGraphQLMutationEngine, + type MutatedSchema, +} from "./mutation-engine/index.js"; +import { resolveTypeUsage } from "./type-usage.js"; +import type { ModelVariants } from "./context/index.js"; -const defaultOptions = { - "new-line": "lf", - "omit-unreachable-types": false, - strict: false, -} as const; +/** + * The bundle of schema-wide data the renderer needs to emit SDL. + * + * Produced by the data pipeline (type usage → mutation → variant lookups) + * and consumed by `renderSchema`. Making this explicit keeps the pipeline + * and the renderer as separable stages: the pipeline decides *what* goes + * in the schema, the renderer decides *how* it's serialized. + */ +export interface SchemaPipelineResult { + mutated: MutatedSchema; + modelVariants: ModelVariants; +} +/** + * Main emitter entry point for GraphQL SDL generation. + * + * Runs the data pipeline (type usage → mutation → variant lookups) and + * passes the result to `renderSchema`. Component-based rendering is a stub + * in this PR and will be implemented in a follow-up. + */ export async function $onEmit(context: EmitContext) { - const options = resolveOptions(context); - const emitter = createGraphQLEmitter(context, options); - await emitter.emitGraphQL(); -} + const schemas = listSchemas(context.program); + if (schemas.length === 0) { + schemas.push({ type: context.program.getGlobalNamespaceType() }); + } -export interface ResolvedGraphQLEmitterOptions { - outputFile: string; - newLine: NewLine; - omitUnreachableTypes: boolean; - strict: boolean; + for (const schema of schemas) { + const pipelineResult = await emitSchema(context, schema); + if (pipelineResult) { + renderSchema(pipelineResult); + } + } } -export function resolveOptions( +/** + * Run the data pipeline for a single GraphQL schema. + * + * Returns the `SchemaPipelineResult` on success, or `undefined` if the schema + * cannot be built (e.g., no query root) — in which case a diagnostic has + * already been emitted. + */ +async function emitSchema( context: EmitContext, -): ResolvedGraphQLEmitterOptions { - const resolvedOptions = { ...defaultOptions, ...context.options }; - const outputFile = resolvedOptions["output-file"] ?? "{schema-name}.graphql"; - - return { - outputFile: resolvePath(context.emitterOutputDir, outputFile), - newLine: resolvedOptions["new-line"], - omitUnreachableTypes: resolvedOptions["omit-unreachable-types"], - strict: resolvedOptions["strict"], + schema: { type: Namespace; name?: string }, +): Promise { + // Phase 1: Type usage tracking — determine which types are reachable from operations. + // Must run before mutation so the engine can filter on original (pre-clone) type objects. + const omitUnreachable = context.options["omit-unreachable-types"] ?? false; + const typeUsage = resolveTypeUsage(schema.type, omitUnreachable); + + // Phase 2: Mutation — transform TypeSpec types with GraphQL naming conventions + // and classify the results. The engine consumes `typeUsage` internally to + // filter unreachable types and route models into input/output/interface buckets. + const engine = createGraphQLMutationEngine(context.program); + const mutated = engine.mutateSchema(schema.type, typeUsage); + + // Report void-returning operations — GraphQL fields must return a type, + // so these are excluded from the schema. Mutation collected them; the + // emitter decides to warn. + for (const op of mutated.voidOperations) { + reportDiagnostic(context.program, { + code: "void-operation-return", + format: { name: op.name }, + target: op, + }); + } + + // GraphQL requires at least a Query root type. If there are no query operations, + // the schema cannot be built. Emit a diagnostic and skip rendering. + if (mutated.queries.length === 0) { + reportDiagnostic(context.program, { + code: "empty-schema", + target: schema.type, + }); + return undefined; + } + + // Phase 3: Build model variant lookups for use by the renderer. + const modelVariants = buildModelVariants(mutated); + + return { mutated, modelVariants }; +} + +/** + * Phase 4: Render the pipeline result to GraphQL SDL. + * + * Stub in this PR — does nothing. The component-based Alloy renderer that + * consumes `mutated` and `modelVariants` is implemented in a follow-up PR. + */ +function renderSchema(_result: SchemaPipelineResult): void {} + +/** + * Build model variant lookups (name → Model) for checking which variants exist. + * Used by the renderer to decide when to append the "Input" suffix during type + * resolution. + */ +function buildModelVariants(mutated: MutatedSchema): ModelVariants { + const modelVariants: ModelVariants = { + outputModels: new Map(), + inputModels: new Map(), }; + + for (const model of mutated.outputModels) { + modelVariants.outputModels.set(model.name, model); + } + for (const model of mutated.inputModels) { + modelVariants.inputModels.set(model.name, model); + } + + return modelVariants; } diff --git a/packages/graphql/src/graphql-emitter.ts b/packages/graphql/src/graphql-emitter.ts deleted file mode 100644 index fdb3ffca332..00000000000 --- a/packages/graphql/src/graphql-emitter.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { emitFile, interpolatePath, type EmitContext } from "@typespec/compiler"; -import { printSchema } from "graphql"; -import type { ResolvedGraphQLEmitterOptions } from "./emitter.js"; -import type { GraphQLEmitterOptions } from "./lib.js"; -import { listSchemas } from "./lib/schema.js"; -import { createSchemaEmitter } from "./schema-emitter.js"; -import type { GraphQLSchemaRecord } from "./types.js"; - -export function createGraphQLEmitter( - context: EmitContext, - options: ResolvedGraphQLEmitterOptions, -) { - const program = context.program; - - return { - emitGraphQL, - }; - - async function emitGraphQL() { - if (!program.compilerOptions.noEmit) { - const schemaRecords = await getGraphQL(); - // first, emit diagnostics - for (const schemaRecord of schemaRecords) { - program.reportDiagnostics(schemaRecord.diagnostics); - } - if (program.hasError()) { - return; - } - for (const schemaRecord of schemaRecords) { - const schemaName = schemaRecord.schema.name || "schema"; - const filePath = interpolatePath(options.outputFile, { - "schema-name": schemaName, - }); - await emitFile(program, { - path: filePath, - content: printSchema(schemaRecord.graphQLSchema), - newLine: options.newLine, - }); - } - } - } - - async function getGraphQL(): Promise { - const schemaRecords: GraphQLSchemaRecord[] = []; - const schemas = listSchemas(program); - if (schemas.length === 0) { - schemas.push({ type: program.getGlobalNamespaceType() }); - } - for (const schema of schemas) { - const schemaEmitter = createSchemaEmitter(schema, context, options); - const document = await schemaEmitter.emitSchema(); - if (document === undefined) { - continue; - } - schemaRecords.push({ - schema: schema, - graphQLSchema: document[0], - diagnostics: document[1], - }); - } - return schemaRecords; - } -} diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts index d41852f5602..6895025524c 100644 --- a/packages/graphql/src/lib.ts +++ b/packages/graphql/src/lib.ts @@ -160,6 +160,19 @@ export const libDef = { default: paramMessage`Scalar "${"name"}" collides with GraphQL built-in type "${"builtinName"}". This may cause unexpected behavior. Consider renaming the scalar.`, }, }, + "empty-schema": { + severity: "warning", + messages: { + default: + "A GraphQL schema must declare at least one query operation. No schema will be emitted.", + }, + }, + "void-operation-return": { + severity: "warning", + messages: { + default: paramMessage`Operation "${"name"}" returns void, which has no GraphQL equivalent. The operation will be omitted from the schema. Add a return type to include it.`, + }, + }, }, emitter: { options: EmitterOptionsSchema as JSONSchemaType, diff --git a/packages/graphql/src/mutation-engine/engine.ts b/packages/graphql/src/mutation-engine/engine.ts index 12e32ea1510..d75975d8ac2 100644 --- a/packages/graphql/src/mutation-engine/engine.ts +++ b/packages/graphql/src/mutation-engine/engine.ts @@ -1,6 +1,7 @@ import { type Enum, type Model, + type Namespace, type Operation, type Program, type Scalar, @@ -15,6 +16,7 @@ import { SimpleLiteralMutation, SimpleUnionVariantMutation, } from "@typespec/mutator-framework"; +import type { TypeUsageResolver } from "../type-usage.js"; import { GraphQLEnumMemberMutation, GraphQLEnumMutation, @@ -25,6 +27,7 @@ import { GraphQLUnionMutation, } from "./mutations/index.js"; import { GraphQLMutationOptions, GraphQLTypeContext } from "./options.js"; +import { mutateSchema, type MutatedSchema } from "./schema-mutator.js"; /** * Registry configuration for the GraphQL mutation engine. @@ -62,8 +65,10 @@ export class GraphQLMutationEngine { // MutationEngine doesn't work because the // generic expects instance types, not constructor types. private engine; + private program: Program; constructor(program: Program) { + this.program = program; const tk = $(program); this.engine = new MutationEngine(tk, graphqlMutationRegistry); } @@ -109,6 +114,21 @@ export class GraphQLMutationEngine { mutateUnion(union: Union, context: GraphQLTypeContext): GraphQLUnionMutation { return this.engine.mutate(union, new GraphQLMutationOptions(context)) as GraphQLUnionMutation; } + + /** + * Mutate every type declared in a schema namespace and return a fully- + * classified `MutatedSchema`. This is the program-level entry point: the + * emitter calls this once and gets back models split into input/output/ + * interface buckets, operations classified by kind, and all derived + * metadata (scalar variants, specification URLs, wrapper models). + * + * `typeUsage` is consumed internally to filter unreachable types and + * determine input/output classification. Callers don't need to hold onto + * it after this call returns. + */ + mutateSchema(schema: Namespace, typeUsage: TypeUsageResolver): MutatedSchema { + return mutateSchema(this.program, this, schema, typeUsage); + } } /** diff --git a/packages/graphql/src/mutation-engine/index.ts b/packages/graphql/src/mutation-engine/index.ts index 114de40a9f6..e17dfa2966b 100644 --- a/packages/graphql/src/mutation-engine/index.ts +++ b/packages/graphql/src/mutation-engine/index.ts @@ -9,3 +9,4 @@ export { GraphQLScalarMutation, GraphQLUnionMutation, } from "./mutations/index.js"; +export type { MutatedSchema } from "./schema-mutator.js"; diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts new file mode 100644 index 00000000000..86d5a62ac93 --- /dev/null +++ b/packages/graphql/src/mutation-engine/schema-mutator.ts @@ -0,0 +1,315 @@ +import { + getEncode, + isArrayModelType, + isUnknownType, + isVoidType, + navigateTypesInNamespace, + type Enum, + type Model, + type ModelProperty, + type Namespace, + type Operation, + type Program, + type Scalar, + type Type, + type Union, +} from "@typespec/compiler"; +import type { ScalarVariant } from "../context/index.js"; +import { isInterface } from "../lib/interface.js"; +import { getOperationKind } from "../lib/operation-kind.js"; +import { getGraphQLBuiltinName, getScalarMapping } from "../lib/scalar-mappings.js"; +import { getSpecifiedBy } from "../lib/specified-by.js"; +import { unwrapNullableUnion } from "../lib/type-utils.js"; +import { + GraphQLTypeUsage, + type TypeUsageResolver, +} from "../type-usage.js"; +import type { GraphQLMutationEngine } from "./engine.js"; +import { GraphQLTypeContext } from "./options.js"; + +/** + * The fully-mutated schema produced by `GraphQLMutationEngine.mutateSchema`. + * + * Types are pre-classified into input/output/interface buckets and + * operations are pre-classified by kind, so the renderer doesn't need + * pre-mutation types or a type-usage resolver. + */ +export interface MutatedSchema { + // Pre-classified model buckets + /** Models marked with @Interface */ + interfaces: Model[]; + /** Models used as outputs (return values) or declared but unreferenced */ + outputModels: Model[]; + /** Models used as inputs (operation parameters) */ + inputModels: Model[]; + + // Non-model type buckets + enums: Enum[]; + scalars: Scalar[]; + unions: Union[]; + + // Operations, classified by kind + queries: Operation[]; + mutations: Operation[]; + subscriptions: Operation[]; + /** + * Operations that return `void`. Excluded from the kind buckets above + * since GraphQL fields must return a type. Exposed separately so the + * emitter can report a diagnostic — mutation shapes the graph; the + * emitter decides what's worth warning about. + */ + voidOperations: Operation[]; + + // Derived metadata + /** Synthetic wrapper models created by union mutations for scalar variants */ + wrapperModels: Model[]; + /** Encoded stdlib scalars mapped to GraphQL custom scalars (e.g. bytes + base64 → Bytes) */ + scalarVariants: ScalarVariant[]; + /** `@specifiedBy` URLs indexed by GraphQL scalar name */ + scalarSpecifications: Map; +} + +/** + * Mutate every type declared in the schema namespace and classify the + * results. This is the internal implementation called by + * `GraphQLMutationEngine.mutateSchema`. + * + * The engine parameter is passed in explicitly (rather than being the + * receiver) to avoid a circular import between this module and engine.ts. + */ +export function mutateSchema( + program: Program, + engine: GraphQLMutationEngine, + schema: Namespace, + typeUsage: TypeUsageResolver, +): MutatedSchema { + // Pre-classified model buckets — populated at visit time. + const interfaces: Model[] = []; + const outputModels: Model[] = []; + const inputModels: Model[] = []; + + // Non-model type buckets. + const enums: Enum[] = []; + const scalars: Scalar[] = []; + const unions: Union[] = []; + + // Operations are classified by kind inline. We also keep the full list + // (including void-returning ops) for scalar collection in Phase B — a + // void op's params can still reference stdlib scalars that need to be + // declared in the schema. + const queries: Operation[] = []; + const mutations: Operation[] = []; + const subscriptions: Operation[] = []; + const allOperations: Operation[] = []; + + // Synthetic wrapper models from union mutation (always output). + const wrapperModels: Model[] = []; + + // Void-returning operations — collected here so the emitter can report + // the diagnostic. Mutation shapes the graph; it doesn't warn. + const voidOperations: Operation[] = []; + + // Metadata. + const scalarSpecifications = new Map(); + const scalarVariantsMap = new Map(); + + // Scalar dedup: a single scalar can be declared in the namespace AND + // referenced from many properties. Mutate once, add to bucket once. + const processedScalars = new Set(); + + // Track the mutated models we've visited so we can collect referenced + // scalars from their properties after the namespace walk. + const visitedModelOriginals: Model[] = []; + + const processScalar = (node: Scalar): void => { + // Skip scalars that map directly onto GraphQL built-ins (String, Int, + // Float, Boolean, ID) — they're emitted by reference and don't need + // their own scalar declaration in the schema. + if (getGraphQLBuiltinName(program, node)) return; + + const mutation = engine.mutateScalar(node); + const graphqlName = mutation.mutatedType.name; + + if (!processedScalars.has(graphqlName)) { + processedScalars.add(graphqlName); + scalars.push(mutation.mutatedType); + + const specUrl = getSpecifiedBy(program, mutation.mutatedType); + if (specUrl) { + scalarSpecifications.set(graphqlName, specUrl); + } + } + }; + + const processScalarVariant = (target: ModelProperty): void => { + if (isUnknownType(target.type)) { + if (!scalarVariantsMap.has("Unknown")) { + scalarVariantsMap.set("Unknown", { + sourceScalar: target.type, + encoding: "default", + graphqlName: "Unknown", + specificationUrl: undefined, + }); + } + return; + } + if ( + target.type.kind === "Scalar" && + program.checker.isStdType(target.type) && + !getGraphQLBuiltinName(program, target.type) + ) { + const encodeData = getEncode(program, target); + const encoding = encodeData?.encoding; + const mapping = getScalarMapping(program, target.type, encoding); + if (mapping && !scalarVariantsMap.has(mapping.graphqlName)) { + scalarVariantsMap.set(mapping.graphqlName, { + sourceScalar: target.type, + encoding: encoding || "default", + graphqlName: mapping.graphqlName, + specificationUrl: mapping.specificationUrl, + }); + } + } + }; + + const classifyModel = (originalModel: Model, mutatedModel: Model): void => { + // @Interface is checked on the original (pre-clone) model, since decorator + // state is stored against original type identity, not mutated clones. + if (isInterface(program, originalModel)) { + interfaces.push(mutatedModel); + return; + } + + const usage = typeUsage.getUsage(originalModel); + const usedAsInput = usage?.has(GraphQLTypeUsage.Input) ?? false; + const usedAsOutput = usage?.has(GraphQLTypeUsage.Output) ?? false; + + if (!usedAsInput && !usedAsOutput) { + // Reachable but not referenced by any operation — default to Output so + // that namespace-declared models still appear in the schema. Without + // this, a model declared in the schema namespace but never referenced + // by a query/mutation would silently disappear. + outputModels.push(mutatedModel); + return; + } + if (usedAsOutput) outputModels.push(mutatedModel); + if (usedAsInput) inputModels.push(mutatedModel); + }; + + const classifyOperation = (op: Operation): void => { + allOperations.push(op); + if (isVoidType(op.returnType)) { + voidOperations.push(op); + return; + } + const kind = getOperationKind(program, op); + if (kind === "Query") queries.push(op); + else if (kind === "Mutation") mutations.push(op); + else if (kind === "Subscription") subscriptions.push(op); + }; + + // Phase A: Walk every type declared in the namespace, mutate it, and + // classify the result. Unreachable types are skipped here so we don't + // pay the cost of mutation for types that won't appear in the schema. + navigateTypesInNamespace(schema, { + model: (node: Model) => { + if (isArrayModelType(program, node)) return; + if (typeUsage.isUnreachable(node)) return; + const mutation = engine.mutateModel(node, GraphQLTypeContext.Output); + classifyModel(node, mutation.mutatedType); + visitedModelOriginals.push(node); + }, + enum: (node: Enum) => { + if (typeUsage.isUnreachable(node)) return; + const mutation = engine.mutateEnum(node); + enums.push(mutation.mutatedType); + }, + scalar: (node: Scalar) => { + processScalar(node); + }, + union: (node: Union) => { + // Skip nullable unions (e.g., string | null) — they're not union + // declarations. Nullability for these is detected at render time in + // GraphQLTypeExpression. We must NOT mutate them here because + // replace() would call setNullable() on the shared inner type (e.g., + // the string scalar singleton), poisoning all other uses of that type. + if (unwrapNullableUnion(node) !== undefined) return; + if (typeUsage.isUnreachable(node)) return; + const mutation = engine.mutateUnion(node, GraphQLTypeContext.Output); + unions.push(mutation.mutatedType as Union); + wrapperModels.push(...mutation.wrapperModels); + }, + operation: (node: Operation) => { + // Operations pass through unmutated. classifyOperation collects void + // ops for the emitter to report on, and routes the rest by + // @query/@mutation/@subscription. + classifyOperation(node); + }, + }); + + // Phase B: Collect referenced scalars and scalar variants from model + // properties and operation params/returns. Standard-library scalars like + // int64 and utcDateTime aren't declared in the namespace but are + // referenced from properties, so we have to walk the type graph to find + // them. + const visitedTypes = new Set(); + + const collectReferencedScalars = (type: Type): void => { + if (visitedTypes.has(type)) return; + visitedTypes.add(type); + + if (type.kind === "Scalar") { + processScalar(type); + } else if (type.kind === "Model" && isArrayModelType(program, type)) { + if (type.indexer?.value) { + collectReferencedScalars(type.indexer.value); + } + } else if (type.kind === "Model") { + for (const prop of type.properties.values()) { + collectReferencedScalars(prop.type); + } + } else if (type.kind === "Union") { + for (const variant of type.variants.values()) { + collectReferencedScalars(variant.type); + } + } + }; + + // Uses original (pre-mutation) models because mutated scalar refs won't + // match processedScalars' dedup keys (which are GraphQL names derived + // from mutated scalars). + for (const model of visitedModelOriginals) { + for (const prop of model.properties.values()) { + collectReferencedScalars(prop.type); + processScalarVariant(prop); + } + } + + // Walk params/returns for every operation — even void-returning ones, + // since their parameters can still reference scalars. classifyOperation + // above has already excluded void ops from the queries/mutations/ + // subscriptions buckets, but we still want their param types' scalars. + for (const op of allOperations) { + for (const param of op.parameters.properties.values()) { + collectReferencedScalars(param.type); + processScalarVariant(param); + } + collectReferencedScalars(op.returnType); + } + + return { + interfaces, + outputModels, + inputModels, + enums, + scalars, + unions, + queries, + mutations, + subscriptions, + voidOperations, + wrapperModels, + scalarVariants: Array.from(scalarVariantsMap.values()), + scalarSpecifications, + }; +} diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts deleted file mode 100644 index 12e7d59ddd3..00000000000 --- a/packages/graphql/src/registry.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { UsageFlags, type Enum, type Model } from "@typespec/compiler"; -import { - GraphQLBoolean, - GraphQLEnumType, - GraphQLObjectType, - type GraphQLNamedType, - type GraphQLSchemaConfig, -} from "graphql"; - -// 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. -} -/** - * 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, as types can be - * registered first and materialized later when all dependencies are known or - * 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(); - - // Stores materialized GraphQL types, keyed by their GraphQL name. - private materializedGraphQLTypes: Map = new Map(); - - 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. - }); - } - - // 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; - } - - const context = this.TSPTypeContextRegistry.get(enumName); - if (!context || context.tspType.kind !== "Enum") { - // TODO: Handle error or warning for missing context. - return undefined; - } - - const tspEnum = context.tspType as Enum; - - const gqlEnum = new GraphQLEnumType({ - name: context.name, - values: Object.fromEntries( - Array.from(tspEnum.members.values()).map((member) => [ - member.name, - { - value: member.value ?? member.name, - }, - ]), - ), - }); - - this.materializedGraphQLTypes.set(enumName, gqlEnum); - return gqlEnum; - } - - 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", - fields: { - _: { - type: GraphQLBoolean, - description: - "A placeholder field. If you are seeing this, it means no operations were defined that could be emitted.", - }, - }, - }); - } - - 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 deleted file mode 100644 index b922fe49b0a..00000000000 --- a/packages/graphql/src/schema-emitter.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - createDiagnosticCollector, - navigateTypesInNamespace, - type Diagnostic, - type DiagnosticCollector, - type EmitContext, - type Enum, - type Model, -} 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"; - -class GraphQLSchemaEmitter { - private tspSchema: Schema; - private context: EmitContext; - 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(); - } - - async emitSchema(): Promise<[GraphQLSchema, Readonly] | undefined> { - const schemaNamespace = this.tspSchema.type; - // Logic to emit the GraphQL schema - navigateTypesInNamespace(schemaNamespace, this.semanticNodeListener()); - const schemaConfig = this.registry.materializeSchemaConfig(); - const schema = new GraphQLSchema(schemaConfig); - // validate the schema - const validationErrors = validateSchema(schema); - validationErrors.forEach((error) => { - this.diagnostics.add({ - message: error.message, - code: "GraphQLSchemaValidationError", - target: this.tspSchema.type, - severity: "error", - }); - }); - return [schema, this.diagnostics.diagnostics]; - } - - 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 - }, - exitEnum: (node: Enum) => { - this.registry.materializeEnum(node.name); - }, - exitModel: (node: Model) => { - // Add logic to handle the exit of the model node - }, - }; - } -} - -export function createSchemaEmitter( - schema: Schema, - context: EmitContext, - options: GraphQLEmitterOptions, -): GraphQLSchemaEmitter { - // Placeholder for creating a GraphQL schema emitter - return new GraphQLSchemaEmitter(schema, context, options); -} - -export type { GraphQLSchemaEmitter }; diff --git a/packages/graphql/src/type-maps.ts b/packages/graphql/src/type-maps.ts deleted file mode 100644 index eefb2b8aa33..00000000000 --- a/packages/graphql/src/type-maps.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { UsageFlags, type Type } from "@typespec/compiler"; -import type { GraphQLType } 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 (input, output, etc.) - graphqlName?: string; // Optional GraphQL type name override (e.g., "ModelInput" for input types) - metadata: Record; // Additional metadata -} - -/** - * Nominal type for keys in the TypeMap - */ -type TypeKey = string & { __typeKey: any }; - -/** - * Base TypeMap for all GraphQL type mappings - * @template T - The TypeSpec type constrained to TSP's Type - * @template G - The GraphQL type constrained to GraphQL's GraphQLType - */ -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 as a TypeKey - */ - register(context: TSPContext): TypeKey { - // Check if the type is already registered - const name = this.getNameFromContext(context); - if (this.isRegistered(name)) { - throw new Error(`Type ${name} is already registered`); - } - - // Register the type - this.registrationMap.set(name, context); - return name; - } - - /** - * Get the materialized GraphQL type - * @param name - The type name as a TypeKey - * @returns The materialized GraphQL type or undefined - */ - get(name: TypeKey): 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); - this.materializedMap.set(name, materializedType); - return materializedType; - } - - return undefined; - } - - /** - * Check if a type is registered - */ - isRegistered(name: string): boolean { - return this.registrationMap.has(name as TypeKey); - } - - /** - * Get all materialized types - */ - getAllMaterialized(): MapIterator { - return 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): TypeKey; - - /** - * Materialize a type from a context - */ - protected abstract materialize(context: TSPContext): G; -} diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts index bd0b1a74566..578e11a0079 100644 --- a/packages/graphql/test/emitter.test.ts +++ b/packages/graphql/test/emitter.test.ts @@ -1,18 +1,9 @@ import { strictEqual } from "node:assert"; import { describe, it } from "vitest"; -import { emitSingleSchema } from "./test-host.js"; +import { emitSingleSchemaWithDiagnostics } 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 { - """ - A placeholder field. If you are seeing this, it means no operations were defined that could be emitted. - """ - _: Boolean -}`; - -describe("name", () => { - it("Emits a schema.graphql file with placeholder text", async () => { +describe("emitter", () => { + it("runs the data pipeline without errors", async () => { const code = ` @schema namespace TestNamespace { @@ -26,11 +17,51 @@ describe("name", () => { name: string; books: Book[]; } - op getBooks(): Book[]; - op getAuthors(): Author[]; + @query op getBooks(): Book[]; + @query op getAuthors(): Author[]; + } + `; + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const errors = result.diagnostics.filter((d) => d.severity === "error"); + strictEqual(errors.length, 0, "Should have no errors"); + + // No SDL output yet — component-based rendering is added in follow-up PRs. + strictEqual(result.graphQLOutput, undefined, "Should not produce output yet"); + }); + + it("warns when a schema has no query operations", async () => { + const code = ` + @schema + namespace TestNamespace { + model Book { + name: string; + } + } + `; + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const emptySchemaDiagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/empty-schema", + ); + strictEqual(emptySchemaDiagnostics.length, 1, "Should emit empty-schema warning"); + strictEqual(emptySchemaDiagnostics[0].severity, "warning"); + }); + + it("warns when an operation returns void", async () => { + const code = ` + @schema + namespace TestNamespace { + model Book { + name: string; + } + @query op getBooks(): Book[]; + @mutation op doNothing(): void; } `; - const results = await emitSingleSchema(code, {}); - strictEqual(results, expectedGraphQLSchema); + const result = await emitSingleSchemaWithDiagnostics(code, {}); + const voidDiagnostics = result.diagnostics.filter( + (d) => d.code === "@typespec/graphql/void-operation-return", + ); + strictEqual(voidDiagnostics.length, 1, "Should emit void-operation-return warning"); + strictEqual(voidDiagnostics[0].severity, "warning"); }); });