Skip to content
Open
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
140 changes: 112 additions & 28 deletions packages/graphql/src/emitter.ts
Original file line number Diff line number Diff line change
@@ -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<GraphQLEmitterOptions>) {
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<GraphQLEmitterOptions>,
): 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<SchemaPipelineResult | undefined> {
// 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;
}
63 changes: 0 additions & 63 deletions packages/graphql/src/graphql-emitter.ts

This file was deleted.

13 changes: 13 additions & 0 deletions packages/graphql/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GraphQLEmitterOptions>,
Expand Down
20 changes: 20 additions & 0 deletions packages/graphql/src/mutation-engine/engine.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type Enum,
type Model,
type Namespace,
type Operation,
type Program,
type Scalar,
Expand All @@ -15,6 +16,7 @@ import {
SimpleLiteralMutation,
SimpleUnionVariantMutation,
} from "@typespec/mutator-framework";
import type { TypeUsageResolver } from "../type-usage.js";
import {
GraphQLEnumMemberMutation,
GraphQLEnumMutation,
Expand All @@ -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.
Expand Down Expand Up @@ -62,8 +65,10 @@ export class GraphQLMutationEngine {
// MutationEngine<typeof graphqlMutationRegistry> 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);
}
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/graphql/src/mutation-engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export {
GraphQLScalarMutation,
GraphQLUnionMutation,
} from "./mutations/index.js";
export type { MutatedSchema } from "./schema-mutator.js";
Loading