Skip to content

Commit 6d2bb29

Browse files
committed
Add simple type components (Enum, Scalar, Union) with component tests
Add the first set of Alloy JSX components for GraphQL type emission: - EnumType: renders GraphQL enum definitions with member descriptions and deprecation - ScalarType: renders custom scalar definitions with @specifiedBy support - UnionType: renders union type definitions with model and scalar variant members - GraphQLSchema: root context wrapper providing TspContext and GraphQLSchemaContext Add component-level tests using renderSchema + printSchema to exercise the real Alloy rendering pipeline. Each component is tested in isolation with a lightweight test helper (renderComponentToSDL) that provides minimal context. 14 new tests covering: basic rendering, doc comments, deprecation, name sanitization, @specifiedBy, union member registration, and scalar wrappers.
1 parent 37fd9d9 commit 6d2bb29

10 files changed

Lines changed: 560 additions & 20 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { type Children } from "@alloy-js/core";
2+
import type { Program } from "@typespec/compiler";
3+
import { TspContext } from "@typespec/emitter-framework";
4+
import {
5+
GraphQLSchemaContext,
6+
type GraphQLSchemaContextValue,
7+
} from "../context/index.js";
8+
9+
export interface GraphQLSchemaProps {
10+
/** TypeSpec program instance */
11+
program: Program;
12+
/** Context value containing classified types and type maps */
13+
contextValue: GraphQLSchemaContextValue;
14+
/** Child components to render */
15+
children?: Children;
16+
}
17+
18+
/**
19+
* Root component for GraphQL schema generation
20+
*
21+
* Provides TspContext (program + typekit) from @typespec/emitter-framework
22+
* and GraphQL-specific context to all child components.
23+
*/
24+
export function GraphQLSchema(props: GraphQLSchemaProps) {
25+
return (
26+
<TspContext.Provider value={{ program: props.program }}>
27+
<GraphQLSchemaContext.Provider value={props.contextValue}>
28+
{props.children}
29+
</GraphQLSchemaContext.Provider>
30+
</TspContext.Provider>
31+
);
32+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler";
2+
import * as gql from "@alloy-js/graphql";
3+
import { useTsp } from "@typespec/emitter-framework";
4+
5+
export interface EnumTypeProps {
6+
/** The enum type to render */
7+
type: Enum;
8+
}
9+
10+
/**
11+
* Renders a GraphQL enum type declaration with members
12+
*/
13+
export function EnumType(props: EnumTypeProps) {
14+
const { program } = useTsp();
15+
const doc = getDoc(program, props.type);
16+
const members = Array.from(props.type.members.values());
17+
18+
return (
19+
<gql.EnumType name={props.type.name} description={doc}>
20+
{members.map((member) => {
21+
const memberDoc = getDoc(program, member);
22+
const deprecation = getDeprecationDetails(program, member);
23+
24+
return (
25+
<gql.EnumValue
26+
name={member.name}
27+
description={memberDoc}
28+
deprecated={deprecation ? deprecation.message : undefined}
29+
/>
30+
);
31+
})}
32+
</gql.EnumType>
33+
);
34+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { ScalarType, type ScalarTypeProps } from "./scalar-type.js";
2+
export { EnumType, type EnumTypeProps } from "./enum-type.js";
3+
export { UnionType, type UnionTypeProps } from "./union-type.js";
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { type Scalar, getDoc } from "@typespec/compiler";
2+
import * as gql from "@alloy-js/graphql";
3+
import { useTsp } from "@typespec/emitter-framework";
4+
import { useGraphQLSchema } from "../../context/index.js";
5+
6+
export interface ScalarTypeProps {
7+
/** The scalar type to render */
8+
type: Scalar;
9+
}
10+
11+
/**
12+
* Renders a GraphQL scalar type declaration with optional @specifiedBy directive
13+
*/
14+
export function ScalarType(props: ScalarTypeProps) {
15+
const { program } = useTsp();
16+
const { scalarSpecifications } = useGraphQLSchema();
17+
const doc = getDoc(program, props.type);
18+
const specificationUrl = scalarSpecifications.get(props.type.name);
19+
20+
return (
21+
<gql.ScalarType
22+
name={props.type.name}
23+
description={doc}
24+
specifiedByUrl={specificationUrl}
25+
/>
26+
);
27+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { type Type, type Union, getDoc } from "@typespec/compiler";
2+
import * as gql from "@alloy-js/graphql";
3+
import { useTsp } from "@typespec/emitter-framework";
4+
import { getUnionName, toTypeName } from "../../lib/type-utils.js";
5+
6+
export interface UnionTypeProps {
7+
/** The union type to render */
8+
type: Union;
9+
}
10+
11+
/**
12+
* Check if a type is a scalar (built-in or custom)
13+
*/
14+
function isScalarType(type: Type): boolean {
15+
return type.kind === "Scalar" || type.kind === "Intrinsic";
16+
}
17+
18+
/**
19+
* Renders a GraphQL union type declaration
20+
* Scalars are wrapped in object types since GraphQL unions can only contain object types
21+
* This wrapping is done by the mutation engine
22+
*/
23+
export function UnionType(props: UnionTypeProps) {
24+
const { program } = useTsp();
25+
const name = getUnionName(props.type, program);
26+
const doc = getDoc(program, props.type);
27+
const variants = Array.from(props.type.variants.values());
28+
29+
// Build the union member list, using wrapper names for scalars
30+
// The wrapper models are created by the mutation engine
31+
const unionMembers = variants.map((variant) => {
32+
const variantName =
33+
typeof variant.name === "string" ? variant.name : String(variant.name);
34+
35+
if (isScalarType(variant.type)) {
36+
// Reference the wrapper type for scalars (created by mutation engine)
37+
// Include union name to match wrapper model naming convention
38+
return toTypeName(name) + toTypeName(variantName) + "UnionVariant";
39+
} else {
40+
// For non-scalars, use the type name directly
41+
if (variant.type.kind === "Model") {
42+
return variant.type.name;
43+
} else if (
44+
"name" in variant.type &&
45+
typeof variant.type.name === "string"
46+
) {
47+
return variant.type.name;
48+
}
49+
throw new Error(
50+
`Unexpected union variant type kind "${variant.type.kind}" in union "${name}". ` +
51+
`This is a bug in the GraphQL emitter.`,
52+
);
53+
}
54+
});
55+
56+
return <gql.UnionType name={name} description={doc} members={unionMembers} />;
57+
}

packages/graphql/src/types.d.ts

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,5 @@
1-
import type { Diagnostic } from "@typespec/compiler";
2-
import type { GraphQLSchema } from "graphql";
3-
import type { Schema } from "./lib/schema.ts";
4-
5-
/**
6-
* A record containing the GraphQL schema corresponding to
7-
* a particular schema definition.
8-
*/
9-
export interface GraphQLSchemaRecord {
10-
/** The declared schema that generated this GraphQL schema */
11-
readonly schema: Schema;
12-
13-
/** The GraphQLSchema */
14-
readonly graphQLSchema: GraphQLSchema;
15-
16-
/** The diagnostics created for this schema */
17-
readonly diagnostics: readonly Diagnostic[];
18-
}
19-
201
declare const tags: unique symbol;
212

22-
type Tagged<BaseType, Tag extends PropertyKey> = BaseType & { [tags]: { [K in Tag]: void } };
3+
export type Tagged<BaseType, Tag extends PropertyKey> = BaseType & {
4+
[tags]: { [K in Tag]: void };
5+
};
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { type Children } from "@alloy-js/core";
2+
import * as gql from "@alloy-js/graphql";
3+
import { renderSchema, printSchema } from "@alloy-js/graphql";
4+
import type { Program } from "@typespec/compiler";
5+
import { GraphQLSchema } from "../../src/components/graphql-schema.js";
6+
import type { GraphQLSchemaContextValue } from "../../src/context/index.js";
7+
8+
/**
9+
* Renders GraphQL components in isolation and returns the printed SDL.
10+
*
11+
* Wraps children in the required context providers (TspContext + GraphQLSchemaContext)
12+
* and always includes a placeholder Query type (required by graphql-js).
13+
*
14+
* Tests should assert on fragments of the returned SDL, ignoring the placeholder Query.
15+
*/
16+
export function renderComponentToSDL(
17+
program: Program,
18+
children: Children,
19+
contextOverrides?: Partial<GraphQLSchemaContextValue>,
20+
): string {
21+
const contextValue: GraphQLSchemaContextValue = {
22+
classifiedTypes: {
23+
interfaces: [],
24+
outputModels: [],
25+
inputModels: [],
26+
enums: [],
27+
scalars: [],
28+
scalarVariants: [],
29+
unions: [],
30+
queries: [],
31+
mutations: [],
32+
subscriptions: [],
33+
},
34+
modelVariants: { outputModels: new Map(), inputModels: new Map() },
35+
scalarSpecifications: new Map(),
36+
...contextOverrides,
37+
};
38+
39+
const schema = renderSchema(
40+
<GraphQLSchema program={program} contextValue={contextValue}>
41+
{children}
42+
<gql.Query>
43+
<gql.Field name="_placeholder" type={gql.Boolean} nonNull={false} />
44+
</gql.Query>
45+
</GraphQLSchema>,
46+
{ namePolicy: null },
47+
);
48+
49+
return printSchema(schema);
50+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { t } from "@typespec/compiler/testing";
2+
import { describe, expect, it, beforeEach } from "vitest";
3+
import { EnumType } from "../../src/components/types/index.js";
4+
import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js";
5+
import { Tester } from "../test-host.js";
6+
import { renderComponentToSDL } from "./component-test-utils.js";
7+
8+
describe("EnumType component", () => {
9+
let tester: Awaited<ReturnType<typeof Tester.createInstance>>;
10+
beforeEach(async () => {
11+
tester = await Tester.createInstance();
12+
});
13+
14+
it("renders a basic enum", async () => {
15+
const { Color } = await tester.compile(
16+
t.code`enum ${t.enum("Color")} { Red, Green, Blue }`,
17+
);
18+
19+
const engine = createGraphQLMutationEngine(tester.program);
20+
const mutated = engine.mutateEnum(Color).mutatedType;
21+
22+
const sdl = renderComponentToSDL(tester.program, <EnumType type={mutated} />);
23+
24+
expect(sdl).toContain("enum Color {");
25+
expect(sdl).toContain("Red");
26+
expect(sdl).toContain("Green");
27+
expect(sdl).toContain("Blue");
28+
});
29+
30+
it("renders enum with doc comment description", async () => {
31+
const { Role } = await tester.compile(
32+
t.code`
33+
/** The role a user can have */
34+
enum ${t.enum("Role")} { Admin, User }
35+
`,
36+
);
37+
38+
const engine = createGraphQLMutationEngine(tester.program);
39+
const mutated = engine.mutateEnum(Role).mutatedType;
40+
41+
const sdl = renderComponentToSDL(tester.program, <EnumType type={mutated} />);
42+
43+
expect(sdl).toContain("The role a user can have");
44+
expect(sdl).toContain("enum Role {");
45+
});
46+
47+
it("renders enum with member descriptions", async () => {
48+
const { Status } = await tester.compile(
49+
t.code`
50+
enum ${t.enum("Status")} {
51+
/** Currently active */
52+
Active,
53+
/** No longer active */
54+
Inactive,
55+
}
56+
`,
57+
);
58+
59+
const engine = createGraphQLMutationEngine(tester.program);
60+
const mutated = engine.mutateEnum(Status).mutatedType;
61+
62+
const sdl = renderComponentToSDL(tester.program, <EnumType type={mutated} />);
63+
64+
expect(sdl).toContain("Currently active");
65+
expect(sdl).toContain("Active");
66+
expect(sdl).toContain("No longer active");
67+
expect(sdl).toContain("Inactive");
68+
});
69+
70+
it("renders enum with deprecated members", async () => {
71+
const { Status } = await tester.compile(
72+
t.code`
73+
enum ${t.enum("Status")} {
74+
Active,
75+
#deprecated "use Active instead"
76+
Legacy,
77+
}
78+
`,
79+
);
80+
81+
const engine = createGraphQLMutationEngine(tester.program);
82+
const mutated = engine.mutateEnum(Status).mutatedType;
83+
84+
const sdl = renderComponentToSDL(tester.program, <EnumType type={mutated} />);
85+
86+
expect(sdl).toContain("Active");
87+
expect(sdl).toContain("Legacy");
88+
expect(sdl).toContain("@deprecated");
89+
expect(sdl).toContain("use Active instead");
90+
});
91+
92+
it("renders enum with sanitized member names", async () => {
93+
const { E } = await tester.compile(
94+
t.code`enum ${t.enum("E")} { \`$val1$\`, \`val-2\` }`,
95+
);
96+
97+
const engine = createGraphQLMutationEngine(tester.program);
98+
const mutated = engine.mutateEnum(E).mutatedType;
99+
100+
const sdl = renderComponentToSDL(tester.program, <EnumType type={mutated} />);
101+
102+
expect(sdl).toContain("_val1_");
103+
expect(sdl).toContain("val_2");
104+
expect(sdl).not.toContain("$val1$");
105+
expect(sdl).not.toContain("val-2");
106+
});
107+
});

0 commit comments

Comments
 (0)