Skip to content
Draft
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
32 changes: 32 additions & 0 deletions packages/graphql/src/components/graphql-schema.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { type Children } from "@alloy-js/core";
import type { Program } from "@typespec/compiler";
import { TspContext } from "@typespec/emitter-framework";
import {
GraphQLSchemaContext,
type GraphQLSchemaContextValue,
} from "../context/index.js";

export interface GraphQLSchemaProps {
/** TypeSpec program instance */
program: Program;
/** Context value containing classified types and type maps */
contextValue: GraphQLSchemaContextValue;
/** Child components to render */
children?: Children;
}

/**
* Root component for GraphQL schema generation
*
* Provides TspContext (program + typekit) from @typespec/emitter-framework
* and GraphQL-specific context to all child components.
*/
export function GraphQLSchema(props: GraphQLSchemaProps) {
return (
<TspContext.Provider value={{ program: props.program }}>
<GraphQLSchemaContext.Provider value={props.contextValue}>
{props.children}
</GraphQLSchemaContext.Provider>
</TspContext.Provider>
);
}
34 changes: 34 additions & 0 deletions packages/graphql/src/components/types/enum-type.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler";
import * as gql from "@alloy-js/graphql";
import { useTsp } from "@typespec/emitter-framework";

export interface EnumTypeProps {
/** The enum type to render */
type: Enum;
}

/**
* Renders a GraphQL enum type declaration with members
*/
export function EnumType(props: EnumTypeProps) {
const { program } = useTsp();
const doc = getDoc(program, props.type);
const members = Array.from(props.type.members.values());

return (
<gql.EnumType name={props.type.name} description={doc}>
{members.map((member) => {
const memberDoc = getDoc(program, member);
const deprecation = getDeprecationDetails(program, member);

return (
<gql.EnumValue
name={member.name}
description={memberDoc}
deprecated={deprecation ? deprecation.message : undefined}
/>
);
})}
</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 { ScalarType, type ScalarTypeProps } from "./scalar-type.js";
export { EnumType, type EnumTypeProps } from "./enum-type.js";
export { UnionType, type UnionTypeProps } from "./union-type.js";
27 changes: 27 additions & 0 deletions packages/graphql/src/components/types/scalar-type.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { type Scalar, getDoc } from "@typespec/compiler";
import * as gql from "@alloy-js/graphql";
import { useTsp } from "@typespec/emitter-framework";
import { useGraphQLSchema } from "../../context/index.js";

export interface ScalarTypeProps {
/** The scalar type to render */
type: Scalar;
}

/**
* Renders a GraphQL scalar type declaration with optional @specifiedBy directive
*/
export function ScalarType(props: ScalarTypeProps) {
const { program } = useTsp();
const { scalarSpecifications } = useGraphQLSchema();
const doc = getDoc(program, props.type);
const specificationUrl = scalarSpecifications.get(props.type.name);

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

export interface UnionTypeProps {
/** The union type to render */
type: Union;
}

/**
* Renders a GraphQL union type declaration
* Scalars are wrapped in object types since GraphQL unions can only contain object types
* This wrapping is done by the mutation engine
*/
export function UnionType(props: UnionTypeProps) {
const { program } = useTsp();
const name = getUnionName(props.type, program);
const doc = getDoc(program, props.type);
const variants = Array.from(props.type.variants.values());

// Build the union member list, using wrapper names for scalars
// The wrapper models are created by the mutation engine
const unionMembers = variants.map((variant) => {
const variantName =
typeof variant.name === "string" ? variant.name : String(variant.name);

if (isScalarLikeType(variant.type)) {
// Reference the wrapper type for scalars (created by mutation engine)
// Include union name to match wrapper model naming convention
return toTypeName(name) + toTypeName(variantName) + "UnionVariant";
} else {
// For non-scalars, use the type name directly
if (variant.type.kind === "Model") {
return variant.type.name;
} else if (
"name" in variant.type &&
typeof variant.type.name === "string"
) {
return variant.type.name;
}
throw new Error(
`Unexpected union variant type kind "${variant.type.kind}" in union "${name}". ` +
`This is a bug in the GraphQL emitter.`,
);
}
});

return <gql.UnionType name={name} description={doc} members={unionMembers} />;
}
5 changes: 5 additions & 0 deletions packages/graphql/src/lib/type-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,11 @@ function getTemplateStringInternal(
return args.length > 0 ? args.map(toTypeName).join(options.conjunction) : "";
}

/** Check if a type is a scalar (built-in or custom) or an intrinsic type like `unknown`. */
export function isScalarLikeType(type: Type): boolean {
return type.kind === "Scalar" || type.kind === "Intrinsic";
}

/** Check if a model should be emitted as a GraphQL object type (not an array, record, or never). */
export function isTrueModel(model: Model): boolean {
return !(
Expand Down
23 changes: 3 additions & 20 deletions packages/graphql/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,5 @@
import type { Diagnostic } from "@typespec/compiler";
import type { GraphQLSchema } from "graphql";
import type { Schema } from "./lib/schema.ts";

/**
* A record containing the GraphQL schema corresponding to
* a particular schema definition.
*/
export interface GraphQLSchemaRecord {
/** The declared schema that generated this GraphQL schema */
readonly schema: Schema;

/** The GraphQLSchema */
readonly graphQLSchema: GraphQLSchema;

/** The diagnostics created for this schema */
readonly diagnostics: readonly Diagnostic[];
}

declare const tags: unique symbol;

type Tagged<BaseType, Tag extends PropertyKey> = BaseType & { [tags]: { [K in Tag]: void } };
export type Tagged<BaseType, Tag extends PropertyKey> = BaseType & {
[tags]: { [K in Tag]: void };
};
50 changes: 50 additions & 0 deletions packages/graphql/test/components/component-test-utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type Children } from "@alloy-js/core";
import * as gql from "@alloy-js/graphql";
import { renderSchema, printSchema } from "@alloy-js/graphql";
import type { Program } from "@typespec/compiler";
import { GraphQLSchema } from "../../src/components/graphql-schema.js";
import type { GraphQLSchemaContextValue } from "../../src/context/index.js";

/**
* Renders GraphQL components in isolation and returns the printed SDL.
*
* Wraps children in the required context providers (TspContext + GraphQLSchemaContext)
* and always includes a placeholder Query type (required by graphql-js).
*
* Tests should assert on fragments of the returned SDL, ignoring the placeholder Query.
*/
export function renderComponentToSDL(
program: Program,
children: Children,
contextOverrides?: Partial<GraphQLSchemaContextValue>,
): string {
const contextValue: GraphQLSchemaContextValue = {
classifiedTypes: {
interfaces: [],
outputModels: [],
inputModels: [],
enums: [],
scalars: [],
scalarVariants: [],
unions: [],
queries: [],
mutations: [],
subscriptions: [],
},
modelVariants: { outputModels: new Map(), inputModels: new Map() },
scalarSpecifications: new Map(),
...contextOverrides,
};

const schema = renderSchema(
<GraphQLSchema program={program} contextValue={contextValue}>
{children}
<gql.Query>
<gql.Field name="_placeholder" type={gql.Boolean} nonNull={false} />
</gql.Query>
</GraphQLSchema>,
{ namePolicy: null },
);

return printSchema(schema);
}
107 changes: 107 additions & 0 deletions packages/graphql/test/components/enum-type.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { t } from "@typespec/compiler/testing";
import { describe, expect, it, beforeEach } 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 { renderComponentToSDL } from "./component-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 = renderComponentToSDL(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 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 = renderComponentToSDL(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 = renderComponentToSDL(tester.program, <EnumType type={mutated} />);

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

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 = renderComponentToSDL(tester.program, <EnumType type={mutated} />);

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

it("renders enum with 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 = renderComponentToSDL(tester.program, <EnumType type={mutated} />);

expect(sdl).toContain("_val1_");
expect(sdl).toContain("val_2");
expect(sdl).not.toContain("$val1$");
expect(sdl).not.toContain("val-2");
});
});
Loading