Skip to content

Commit ce4affb

Browse files
committed
Add operation components, orchestrator, and emitter rendering
Brings the component-based GraphQL emitter online by wiring the data pipeline from the foundation skeleton into Alloy JSX rendering. New components: - components/operations/query-type.tsx, mutation-type.tsx, subscription-type.tsx: render the three GraphQL root operation types - components/operations/index.ts: barrel export - components/type-collections.tsx: orchestrator that dispatches each classified-type bucket (scalars, enums, unions, interfaces, objects, inputs) into the appropriate leaf-type component Emitter wiring: - Rename src/emitter.ts → src/emitter.tsx - Phase 5 now renders via Alloy's renderSchema, converts to SDL with graphql-js printSchema, and writes the output via emitFile - Adds output-file option handling with interpolatePath Testing: - test/e2e.test.ts: single happy-path smoke test proving the full TypeSpec → mutation → classification → rendering → SDL pipeline works - Update the existing test/emitter.test.ts data-pipeline test to now assert that SDL output is produced (Phase 5 is wired up) Broader integration coverage (nullability, input/output splitting, unions, enums, arrays, circular refs, deprecation, doc comments) lands in a follow-up PR focused on tests.
1 parent 916c54c commit ce4affb

8 files changed

Lines changed: 320 additions & 19 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { QueryType, type QueryTypeProps } from "./query-type.js";
2+
export { MutationType, type MutationTypeProps } from "./mutation-type.js";
3+
export { SubscriptionType, type SubscriptionTypeProps } from "./subscription-type.js";
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type Operation } from "@typespec/compiler";
2+
import * as gql from "@alloy-js/graphql";
3+
import { OperationField } from "../fields/index.js";
4+
5+
export interface MutationTypeProps {
6+
/** Mutation operations to render as fields */
7+
operations: Operation[];
8+
}
9+
10+
/**
11+
* Renders the GraphQL Mutation root type using Alloy's Mutation component
12+
*
13+
* Only renders if operations exist (Mutation is optional in GraphQL)
14+
*/
15+
export function MutationType(props: MutationTypeProps) {
16+
if (props.operations.length === 0) {
17+
return null;
18+
}
19+
20+
return (
21+
<gql.Mutation>
22+
{props.operations.map((op) => (
23+
<OperationField operation={op} />
24+
))}
25+
</gql.Mutation>
26+
);
27+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type Operation } from "@typespec/compiler";
2+
import * as gql from "@alloy-js/graphql";
3+
import { OperationField } from "../fields/index.js";
4+
5+
export interface QueryTypeProps {
6+
/** Query operations to render as fields */
7+
operations: Operation[];
8+
}
9+
10+
/**
11+
* Renders the GraphQL Query root type using Alloy's Query component.
12+
* Returns null if no query operations exist (the emitter will emit an
13+
* empty-schema diagnostic in that case).
14+
*/
15+
export function QueryType(props: QueryTypeProps) {
16+
if (props.operations.length === 0) {
17+
return null;
18+
}
19+
20+
return (
21+
<gql.Query>
22+
{props.operations.map((op) => (
23+
<OperationField operation={op} />
24+
))}
25+
</gql.Query>
26+
);
27+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type Operation } from "@typespec/compiler";
2+
import * as gql from "@alloy-js/graphql";
3+
import { OperationField } from "../fields/index.js";
4+
5+
export interface SubscriptionTypeProps {
6+
/** Subscription operations to render as fields */
7+
operations: Operation[];
8+
}
9+
10+
/**
11+
* Renders the GraphQL Subscription root type using Alloy's Subscription component
12+
*
13+
* Only renders if operations exist (Subscription is optional in GraphQL)
14+
*/
15+
export function SubscriptionType(props: SubscriptionTypeProps) {
16+
if (props.operations.length === 0) {
17+
return null;
18+
}
19+
20+
return (
21+
<gql.Subscription>
22+
{props.operations.map((op) => (
23+
<OperationField operation={op} />
24+
))}
25+
</gql.Subscription>
26+
);
27+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { For } from "@alloy-js/core";
2+
import * as gql from "@alloy-js/graphql";
3+
import { useGraphQLSchema } from "../context/index.js";
4+
import {
5+
ScalarType,
6+
EnumType,
7+
UnionType,
8+
InterfaceType,
9+
ObjectType,
10+
InputType,
11+
} from "./types/index.js";
12+
13+
/**
14+
* Renders scalar variant types for encoded scalars (e.g., bytes + base64 -> Bytes)
15+
* AND custom user-defined scalars
16+
*/
17+
export function ScalarVariantTypes() {
18+
const { classifiedTypes } = useGraphQLSchema();
19+
20+
// Get set of variant names to avoid duplicates
21+
const variantNames = new Set(
22+
classifiedTypes.scalarVariants.map((v) => v.graphqlName),
23+
);
24+
25+
// Filter custom scalars to only include ones not already in variants
26+
const customScalars = classifiedTypes.scalars.filter(
27+
(s) => !variantNames.has(s.name),
28+
);
29+
30+
return (
31+
<>
32+
<For each={classifiedTypes.scalarVariants}>
33+
{(variant) => (
34+
<gql.ScalarType
35+
name={variant.graphqlName}
36+
specifiedByUrl={variant.specificationUrl}
37+
/>
38+
)}
39+
</For>
40+
<For each={customScalars}>
41+
{(scalar) => <ScalarType type={scalar} />}
42+
</For>
43+
</>
44+
);
45+
}
46+
47+
/**
48+
* Renders all enum types in the schema
49+
*/
50+
export function EnumTypes() {
51+
const { classifiedTypes } = useGraphQLSchema();
52+
return (
53+
<For each={classifiedTypes.enums}>
54+
{(enumType) => <EnumType type={enumType} />}
55+
</For>
56+
);
57+
}
58+
59+
/**
60+
* Renders all union types in the schema
61+
*/
62+
export function UnionTypes() {
63+
const { classifiedTypes } = useGraphQLSchema();
64+
return (
65+
<For each={classifiedTypes.unions}>
66+
{(union) => <UnionType type={union} />}
67+
</For>
68+
);
69+
}
70+
71+
/**
72+
* Renders all interface types in the schema
73+
*/
74+
export function InterfaceTypes() {
75+
const { classifiedTypes } = useGraphQLSchema();
76+
return (
77+
<For each={classifiedTypes.interfaces}>
78+
{(iface) => <InterfaceType type={iface} />}
79+
</For>
80+
);
81+
}
82+
83+
/**
84+
* Renders all object types in the schema
85+
*/
86+
export function ObjectTypes() {
87+
const { classifiedTypes } = useGraphQLSchema();
88+
return (
89+
<For each={classifiedTypes.outputModels}>
90+
{(model) => <ObjectType type={model} />}
91+
</For>
92+
);
93+
}
94+
95+
/**
96+
* Renders all input types in the schema
97+
*/
98+
export function InputTypes() {
99+
const { classifiedTypes } = useGraphQLSchema();
100+
return (
101+
<For each={classifiedTypes.inputModels}>
102+
{(model) => <InputType type={model} />}
103+
</For>
104+
);
105+
}
Lines changed: 98 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
1-
import { type EmitContext, type Namespace } from "@typespec/compiler";
1+
import {
2+
emitFile,
3+
interpolatePath,
4+
resolvePath,
5+
type EmitContext,
6+
type Namespace,
7+
} from "@typespec/compiler";
8+
import { renderSchema as renderAlloySchema, printSchema } from "@alloy-js/graphql";
29
import { type GraphQLEmitterOptions, reportDiagnostic } from "./lib.js";
310
import { listSchemas } from "./lib/schema.js";
411
import {
512
createGraphQLMutationEngine,
613
type MutatedSchema,
714
} from "./mutation-engine/index.js";
815
import { resolveTypeUsage } from "./type-usage.js";
9-
import type { ModelVariants } from "./context/index.js";
16+
import { GraphQLSchema } from "./components/graphql-schema.js";
17+
import {
18+
ScalarVariantTypes,
19+
EnumTypes,
20+
UnionTypes,
21+
InterfaceTypes,
22+
ObjectTypes,
23+
InputTypes,
24+
} from "./components/type-collections.js";
25+
import { QueryType, MutationType, SubscriptionType } from "./components/operations/index.js";
26+
import type { ClassifiedTypes, ModelVariants } from "./context/index.js";
1027

1128
/**
1229
* The bundle of schema-wide data the renderer needs to emit SDL.
1330
*
1431
* Produced by the data pipeline (type usage → mutation → variant lookups)
15-
* and consumed by `renderSchema`. Making this explicit keeps the pipeline
16-
* and the renderer as separable stages: the pipeline decides *what* goes
17-
* in the schema, the renderer decides *how* it's serialized.
32+
* and consumed by `renderSchema`.
1833
*/
1934
export interface SchemaPipelineResult {
2035
mutated: MutatedSchema;
@@ -25,8 +40,8 @@ export interface SchemaPipelineResult {
2540
* Main emitter entry point for GraphQL SDL generation.
2641
*
2742
* Runs the data pipeline (type usage → mutation → variant lookups) and
28-
* passes the result to `renderSchema`. Component-based rendering is a stub
29-
* in this PR and will be implemented in a follow-up.
43+
* passes the result to `renderSchema`, which renders via Alloy components
44+
* and writes the SDL file.
3045
*/
3146
export async function $onEmit(context: EmitContext<GraphQLEmitterOptions>) {
3247
const schemas = listSchemas(context.program);
@@ -37,7 +52,7 @@ export async function $onEmit(context: EmitContext<GraphQLEmitterOptions>) {
3752
for (const schema of schemas) {
3853
const pipelineResult = await emitSchema(context, schema);
3954
if (pipelineResult) {
40-
renderSchema(pipelineResult);
55+
await renderSchema(context, schema, pipelineResult);
4156
}
4257
}
4358
}
@@ -65,8 +80,7 @@ async function emitSchema(
6580
const mutated = engine.mutateSchema(schema.type, typeUsage);
6681

6782
// Report void-returning operations — GraphQL fields must return a type,
68-
// so these are excluded from the schema. Mutation collected them; the
69-
// emitter decides to warn.
83+
// so these are excluded from the schema.
7084
for (const op of mutated.voidOperations) {
7185
reportDiagnostic(context.program, {
7286
code: "void-operation-return",
@@ -92,12 +106,77 @@ async function emitSchema(
92106
}
93107

94108
/**
95-
* Phase 4: Render the pipeline result to GraphQL SDL.
96-
*
97-
* Stub in this PR — does nothing. The component-based Alloy renderer that
98-
* consumes `mutated` and `modelVariants` is implemented in a follow-up PR.
109+
* Phase 4: Render the pipeline result to GraphQL SDL via Alloy components,
110+
* then write the SDL to the emitter output directory.
99111
*/
100-
function renderSchema(_result: SchemaPipelineResult): void {}
112+
async function renderSchema(
113+
context: EmitContext<GraphQLEmitterOptions>,
114+
schema: { type: Namespace; name?: string },
115+
result: SchemaPipelineResult,
116+
): Promise<void> {
117+
const { mutated, modelVariants } = result;
118+
119+
// Wrapper models from union mutations are always output — fold them into
120+
// the output bucket for the renderer.
121+
const classifiedTypes: ClassifiedTypes = {
122+
interfaces: mutated.interfaces,
123+
outputModels: [...mutated.outputModels, ...mutated.wrapperModels],
124+
inputModels: mutated.inputModels,
125+
enums: mutated.enums,
126+
scalars: mutated.scalars,
127+
scalarVariants: mutated.scalarVariants,
128+
unions: mutated.unions,
129+
queries: mutated.queries,
130+
mutations: mutated.mutations,
131+
subscriptions: mutated.subscriptions,
132+
};
133+
134+
const contextValue = {
135+
classifiedTypes,
136+
modelVariants,
137+
unionMembers: mutated.unionMembers,
138+
scalarSpecifications: mutated.scalarSpecifications,
139+
};
140+
141+
// Determine output file name
142+
const outputFilePattern = context.options["output-file"] ?? "{schema-name}.graphql";
143+
const schemaName = schema.name || "schema";
144+
const outputFileName = interpolatePath(outputFilePattern, {
145+
"schema-name": schemaName,
146+
});
147+
148+
// Render the schema using Alloy's renderSchema to get a GraphQLSchema object.
149+
// We disable name policy validation because TypeSpec has already validated names and applied mutations.
150+
const graphqlSchema = renderAlloySchema(
151+
<GraphQLSchema program={context.program} contextValue={contextValue}>
152+
<ScalarVariantTypes />
153+
<EnumTypes />
154+
<UnionTypes />
155+
<InterfaceTypes />
156+
<ObjectTypes />
157+
<InputTypes />
158+
<QueryType operations={classifiedTypes.queries} />
159+
<MutationType operations={classifiedTypes.mutations} />
160+
<SubscriptionType operations={classifiedTypes.subscriptions} />
161+
</GraphQLSchema>,
162+
{ namePolicy: null },
163+
);
164+
165+
// Convert the GraphQLSchema to SDL string using graphql-js printSchema
166+
const rawSdl = printSchema(graphqlSchema);
167+
168+
// Ensure file ends with blank line (two newlines)
169+
const sdl = rawSdl.trimEnd() + "\n\n";
170+
171+
// Write to file
172+
const outputPath = resolvePath(context.emitterOutputDir, outputFileName);
173+
174+
await emitFile(context.program, {
175+
path: outputPath,
176+
content: sdl,
177+
newLine: context.options["new-line"] ?? "lf",
178+
});
179+
}
101180

102181
/**
103182
* Build model variant lookups (name → Model) for checking which variants exist.
@@ -116,6 +195,10 @@ function buildModelVariants(mutated: MutatedSchema): ModelVariants {
116195
for (const model of mutated.inputModels) {
117196
modelVariants.inputModels.set(model.name, model);
118197
}
198+
// Wrapper models are always output variants — include them for name lookups too.
199+
for (const model of mutated.wrapperModels) {
200+
modelVariants.outputModels.set(model.name, model);
201+
}
119202

120203
return modelVariants;
121204
}

packages/graphql/test/e2e.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { strictEqual } from "node:assert";
2+
import { describe, it } from "vitest";
3+
import { emitSingleSchemaWithDiagnostics } from "./test-host.js";
4+
5+
/**
6+
* End-to-end smoke test: compile a minimal TypeSpec schema and verify that
7+
* the full pipeline produces GraphQL SDL output. Broader coverage (nullability,
8+
* input/output splitting, unions, etc.) lives in dedicated test files.
9+
*/
10+
describe("End-to-end", () => {
11+
it("generates valid schema for basic types", async () => {
12+
const code = `
13+
@schema
14+
namespace TestNamespace {
15+
model Book {
16+
id: string;
17+
title: string;
18+
}
19+
20+
@query
21+
op getBook(id: string): Book;
22+
}
23+
`;
24+
25+
const result = await emitSingleSchemaWithDiagnostics(code, {});
26+
const errors = result.diagnostics.filter((d) => d.severity === "error");
27+
strictEqual(errors.length, 0, "Should have no errors for valid schema");
28+
strictEqual(result.graphQLOutput?.includes("type Book {"), true, "Should contain Book type");
29+
strictEqual(result.graphQLOutput?.includes("type Query {"), true, "Should contain Query type");
30+
});
31+
});

0 commit comments

Comments
 (0)