Skip to content
Merged
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
25 changes: 25 additions & 0 deletions packages/graphql/src/components/types/enum-type.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler";
import * as gql from "@alloy-js/graphql";
import { useTsp } from "@typespec/emitter-framework";

export interface EnumTypeProps {
type: Enum;
}

export function EnumType(props: EnumTypeProps) {
const { program } = useTsp();
const doc = getDoc(program, props.type);
const members = [...props.type.members.values()];

return (
<gql.EnumType name={props.type.name} description={doc}>
{members.map((member) => (
<gql.EnumValue
name={member.name}
description={getDoc(program, member)}
deprecated={getDeprecationDetails(program, member)?.message}
/>
))}
</gql.EnumType>
);
}
3 changes: 3 additions & 0 deletions packages/graphql/src/components/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { EnumType, type EnumTypeProps } from "./enum-type.js";
export { ScalarType, type ScalarTypeProps } from "./scalar-type.js";
export { UnionType, type UnionTypeProps, type GraphQLUnion } from "./union-type.js";
21 changes: 21 additions & 0 deletions packages/graphql/src/components/types/scalar-type.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { type Scalar, getDoc } from "@typespec/compiler";
import * as gql from "@alloy-js/graphql";
import { useTsp } from "@typespec/emitter-framework";

export interface ScalarTypeProps {
type: Scalar;
specificationUrl?: string;
}

export function ScalarType(props: ScalarTypeProps) {
const { program } = useTsp();
const doc = getDoc(program, props.type);

return (
<gql.ScalarType
name={props.type.name}
description={doc}
specifiedByUrl={props.specificationUrl}
/>
);
}
24 changes: 24 additions & 0 deletions packages/graphql/src/components/types/union-type.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { type Model, type Union, getDoc } from "@typespec/compiler";
import * as gql from "@alloy-js/graphql";
import { useTsp } from "@typespec/emitter-framework";

/**
* A Union guaranteed to have a name after mutation.
* The mutation engine ensures this: anonymous unions get derived names.
*/
export interface GraphQLUnion extends Union {
name: string;
}

export interface UnionTypeProps {
type: GraphQLUnion;
}

export function UnionType(props: UnionTypeProps) {
const { program } = useTsp();
const doc = getDoc(program, props.type);
const variants = [...props.type.variants.values()];
const members = variants.map((v) => (v.type as Model).name);

return <gql.UnionType name={props.type.name} description={doc} members={members} />;
}
6 changes: 6 additions & 0 deletions packages/graphql/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ export const libDef = {
default: paramMessage`Scalar "${"name"}" collides with GraphQL built-in type "${"builtinName"}". This may cause unexpected behavior. Consider renaming the scalar.`,
},
},
"type-name-collision": {
severity: "error",
messages: {
default: paramMessage`Type "${"name"}" collides with another type of the same name in the GraphQL schema. Consider renaming one of the types.`,
},
},
"empty-schema": {
severity: "warning",
messages: {
Expand Down
54 changes: 30 additions & 24 deletions packages/graphql/src/mutation-engine/mutations/union.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,31 +159,8 @@ export class GraphQLUnionMutation extends UnionMutation<MutationOptions, any, Mu
type: resolveType(this.engine.mutate(variant.type, this.options)),
}));

if (needsFlattening || hasNull) {
const variantArray = mutatedVariants.map((variant) => {
return tk.unionVariant.create({
name: variantNameToString(variant.name),
type: variant.type,
});
});

const flattenedUnion = tk.union.create({
name: unionName,
variants: variantArray,
});

this.#flattenedUnion = flattenedUnion;
} else {
this.#mutationNode.mutate((union) => {
union.name = unionName;
});
}

if (hasNull) {
setNullable(this.mutatedType);
}

// GraphQL unions can only contain object types — wrap scalars in synthetic models
// and substitute the wrapper into the variant so the union is self-contained.
for (const variant of mutatedVariants) {
const isScalar = variant.type.kind === "Scalar" || variant.type.kind === "Intrinsic";

Expand All @@ -204,8 +181,37 @@ export class GraphQLUnionMutation extends UnionMutation<MutationOptions, any, Mu
});

this.#wrapperModels.push(wrapperModel);
variant.type = wrapperModel;
}
}

if (needsFlattening || hasNull || this.#wrapperModels.length > 0) {
const variantArray = mutatedVariants.map((variant) => {
return tk.unionVariant.create({
name: variantNameToString(variant.name),
type: variant.type,
});
});

const flattenedUnion = tk.type.clone(this.sourceType);
flattenedUnion.name = unionName;
flattenedUnion.variants.clear();
for (const variant of variantArray) {
flattenedUnion.variants.set(variant.name, variant);
variant.union = flattenedUnion;
}
tk.type.finishType(flattenedUnion);

this.#flattenedUnion = flattenedUnion;
} else {
this.#mutationNode.mutate((union) => {
union.name = unionName;
});
}

if (hasNull) {
setNullable(this.mutatedType);
}
}

/**
Expand Down
35 changes: 32 additions & 3 deletions packages/graphql/src/mutation-engine/schema-mutator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
type Union,
} from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
import type { TypeUsageResolver } from "../type-usage.js";
import { reportDiagnostic } from "../lib.js";
import { GraphQLTypeUsage, type TypeUsageResolver } from "../type-usage.js";
import type { GraphQLMutationEngine } from "./engine.js";
import { GraphQLTypeContext } from "./options.js";
import { buildTypeGraph, type TypeGraph } from "./type-graph.js";
Expand All @@ -22,6 +23,9 @@ import { buildTypeGraph, type TypeGraph } from "./type-graph.js";
*
* Filtering (unreachable types, array models, nullable unions) happens here
* so the engine only processes types that belong in the schema.
*
* Models used as both input and output get two mutations (Output and Input),
* producing separate entries in the TypeGraph (e.g., `Book` and `BookInput`).
*/
export function mutateSchema(
program: Program,
Expand All @@ -37,8 +41,18 @@ export function mutateSchema(
if (isArrayModelType(node)) return;
if (typeUsage.isUnreachable(node)) return;

const mutation = engine.mutateModel(node, GraphQLTypeContext.Output);
mutatedTypes.push(mutation.mutatedType);
const usage = typeUsage.getUsage(node);
const usedAsOutput = usage?.has(GraphQLTypeUsage.Output) ?? false;
const usedAsInput = usage?.has(GraphQLTypeUsage.Input) ?? false;

if (usedAsOutput || !usage) {
const mutation = engine.mutateModel(node, GraphQLTypeContext.Output);
mutatedTypes.push(mutation.mutatedType);
}
if (usedAsInput) {
const mutation = engine.mutateModel(node, GraphQLTypeContext.Input);
mutatedTypes.push(mutation.mutatedType);
}
},
enum: (node: Enum) => {
if (typeUsage.isUnreachable(node)) return;
Expand Down Expand Up @@ -66,5 +80,20 @@ export function mutateSchema(
},
});

const seen = new Map<string, Type>();
for (const type of mutatedTypes) {
if (!("name" in type) || !type.name) continue;
const name = type.name as string;
if (seen.has(name)) {
reportDiagnostic(program, {
code: "type-name-collision",
format: { name },
target: type,
});
} else {
seen.set(name, type);
}
}

return buildTypeGraph(program, tk, mutatedTypes);
}
102 changes: 102 additions & 0 deletions packages/graphql/test/components/enum-type.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { t } from "@typespec/compiler/testing";
import { beforeEach, describe, expect, it } from "vitest";
import { EnumType } from "../../src/components/types/index.js";
import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js";
import { Tester } from "../test-host.js";
import { renderToSDL } from "./test-utils.js";

describe("EnumType component", () => {
let tester: Awaited<ReturnType<typeof Tester.createInstance>>;
beforeEach(async () => {
tester = await Tester.createInstance();
});

it("renders a basic enum", async () => {
const { Color } = await tester.compile(
t.code`enum ${t.enum("Color")} { Red, Green, Blue }`,
);

const engine = createGraphQLMutationEngine(tester.program);
const mutated = engine.mutateEnum(Color).mutatedType;

const sdl = renderToSDL(tester.program, <EnumType type={mutated} />);

expect(sdl).toContain("enum Color");
expect(sdl).toContain("RED");
expect(sdl).toContain("GREEN");
expect(sdl).toContain("BLUE");
});

it("renders enum with doc comment as description", async () => {
const { Role } = await tester.compile(
t.code`
/** The role a user can have */
enum ${t.enum("Role")} { Admin, User }
`,
);

const engine = createGraphQLMutationEngine(tester.program);
const mutated = engine.mutateEnum(Role).mutatedType;

const sdl = renderToSDL(tester.program, <EnumType type={mutated} />);

expect(sdl).toContain('"The role a user can have"');
expect(sdl).toContain("enum Role");
});

it("renders enum with member descriptions", async () => {
const { Status } = await tester.compile(
t.code`
enum ${t.enum("Status")} {
/** Currently active */
Active,
/** No longer active */
Inactive,
}
`,
);

const engine = createGraphQLMutationEngine(tester.program);
const mutated = engine.mutateEnum(Status).mutatedType;

const sdl = renderToSDL(tester.program, <EnumType type={mutated} />);

expect(sdl).toContain('"Currently active"');
expect(sdl).toContain('"No longer active"');
});

it("renders enum with deprecated members", async () => {
const { Status } = await tester.compile(
t.code`
enum ${t.enum("Status")} {
Active,
#deprecated "use Active instead"
Legacy,
}
`,
);

const engine = createGraphQLMutationEngine(tester.program);
const mutated = engine.mutateEnum(Status).mutatedType;

const sdl = renderToSDL(tester.program, <EnumType type={mutated} />);

expect(sdl).toContain("@deprecated");
expect(sdl).toContain("use Active instead");
});

it("renders enum with mutation-engine-sanitized member names", async () => {
const { E } = await tester.compile(
t.code`enum ${t.enum("E")} { \`$val1$\`, \`val-2\` }`,
);

const engine = createGraphQLMutationEngine(tester.program);
const mutated = engine.mutateEnum(E).mutatedType;

const sdl = renderToSDL(tester.program, <EnumType type={mutated} />);

// Mutation engine: sanitize → CONSTANT_CASE
expect(sdl).toContain("_VAL_1");
expect(sdl).toContain("VAL_2");
});
});
Loading