diff --git a/packages/graphql/src/components/graphql-schema.tsx b/packages/graphql/src/components/graphql-schema.tsx new file mode 100644 index 00000000000..94b78461316 --- /dev/null +++ b/packages/graphql/src/components/graphql-schema.tsx @@ -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 ( + + + {props.children} + + + ); +} diff --git a/packages/graphql/src/components/types/enum-type.tsx b/packages/graphql/src/components/types/enum-type.tsx new file mode 100644 index 00000000000..68a19ea1d60 --- /dev/null +++ b/packages/graphql/src/components/types/enum-type.tsx @@ -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 ( + + {members.map((member) => { + const memberDoc = getDoc(program, member); + const deprecation = getDeprecationDetails(program, member); + + return ( + + ); + })} + + ); +} diff --git a/packages/graphql/src/components/types/index.ts b/packages/graphql/src/components/types/index.ts new file mode 100644 index 00000000000..f0c8f56e711 --- /dev/null +++ b/packages/graphql/src/components/types/index.ts @@ -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"; diff --git a/packages/graphql/src/components/types/scalar-type.tsx b/packages/graphql/src/components/types/scalar-type.tsx new file mode 100644 index 00000000000..8a8926e8e94 --- /dev/null +++ b/packages/graphql/src/components/types/scalar-type.tsx @@ -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 ( + + ); +} diff --git a/packages/graphql/src/components/types/union-type.tsx b/packages/graphql/src/components/types/union-type.tsx new file mode 100644 index 00000000000..4e950106d8b --- /dev/null +++ b/packages/graphql/src/components/types/union-type.tsx @@ -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 ; +} diff --git a/packages/graphql/src/lib/type-utils.ts b/packages/graphql/src/lib/type-utils.ts index 50fc9aba3ed..90123714379 100644 --- a/packages/graphql/src/lib/type-utils.ts +++ b/packages/graphql/src/lib/type-utils.ts @@ -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 !( diff --git a/packages/graphql/src/types.d.ts b/packages/graphql/src/types.d.ts index 651cc1bb81f..d8115c70622 100644 --- a/packages/graphql/src/types.d.ts +++ b/packages/graphql/src/types.d.ts @@ -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 & { [tags]: { [K in Tag]: void } }; +export type Tagged = BaseType & { + [tags]: { [K in Tag]: void }; +}; diff --git a/packages/graphql/test/components/component-test-utils.tsx b/packages/graphql/test/components/component-test-utils.tsx new file mode 100644 index 00000000000..a70bd980e77 --- /dev/null +++ b/packages/graphql/test/components/component-test-utils.tsx @@ -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, +): 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( + + {children} + + + + , + { namePolicy: null }, + ); + + return printSchema(schema); +} diff --git a/packages/graphql/test/components/enum-type.test.tsx b/packages/graphql/test/components/enum-type.test.tsx new file mode 100644 index 00000000000..418579ac461 --- /dev/null +++ b/packages/graphql/test/components/enum-type.test.tsx @@ -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>; + 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, ); + + 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, ); + + 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, ); + + 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, ); + + 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, ); + + expect(sdl).toContain("_val1_"); + expect(sdl).toContain("val_2"); + expect(sdl).not.toContain("$val1$"); + expect(sdl).not.toContain("val-2"); + }); +}); diff --git a/packages/graphql/test/components/scalar-type.test.tsx b/packages/graphql/test/components/scalar-type.test.tsx new file mode 100644 index 00000000000..14da1e0b36a --- /dev/null +++ b/packages/graphql/test/components/scalar-type.test.tsx @@ -0,0 +1,102 @@ +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it, beforeEach } from "vitest"; +import { ScalarType } from "../../src/components/types/index.js"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { getSpecifiedBy } from "../../src/lib/specified-by.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("ScalarType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a custom scalar", async () => { + const { DateTime } = await tester.compile( + t.code`scalar ${t.scalar("DateTime")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(DateTime); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("scalar DateTime"); + }); + + it("renders a scalar with doc comment description", async () => { + const { JSON } = await tester.compile( + t.code` + /** Arbitrary JSON blob */ + scalar ${t.scalar("JSON")} extends string; + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(JSON); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("Arbitrary JSON blob"); + expect(sdl).toContain("scalar JSON"); + }); + + it("renders a scalar with @specifiedBy from context", async () => { + const { MyScalar } = await tester.compile( + t.code` + @specifiedBy("https://example.com/spec") + scalar ${t.scalar("MyScalar")} extends string; + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(MyScalar); + + // Build scalarSpecifications map like the emitter does + const specUrl = getSpecifiedBy(tester.program, mutation.mutatedType); + const scalarSpecifications = new Map(); + if (specUrl) { + scalarSpecifications.set(mutation.mutatedType.name, specUrl); + } + + const sdl = renderComponentToSDL( + tester.program, + , + { scalarSpecifications }, + ); + + expect(sdl).toContain("scalar MyScalar"); + expect(sdl).toContain("@specifiedBy"); + expect(sdl).toContain("https://example.com/spec"); + }); + + it("renders a scalar without @specifiedBy when not in context", async () => { + const { MyScalar } = await tester.compile( + t.code`scalar ${t.scalar("MyScalar")} extends string;`, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(MyScalar); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("scalar MyScalar"); + expect(sdl).not.toContain("@specifiedBy"); + }); + + it("renders a scalar with sanitized name", async () => { + await tester.compile( + t.code`scalar ${t.scalar("$Bad$")} extends string;`, + ); + + const BadScalar = tester.program.getGlobalNamespaceType().scalars.get("$Bad$")!; + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateScalar(BadScalar); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("scalar _Bad_"); + expect(sdl).not.toContain("$Bad$"); + }); +}); diff --git a/packages/graphql/test/components/union-type.test.tsx b/packages/graphql/test/components/union-type.test.tsx new file mode 100644 index 00000000000..5b19b2e7d0a --- /dev/null +++ b/packages/graphql/test/components/union-type.test.tsx @@ -0,0 +1,160 @@ +import { type Union } from "@typespec/compiler"; +import { t } from "@typespec/compiler/testing"; +import * as gql from "@alloy-js/graphql"; +import { describe, expect, it, beforeEach } from "vitest"; +import { UnionType } from "../../src/components/types/index.js"; +import { + createGraphQLMutationEngine, + type GraphQLUnionMutation, + GraphQLTypeContext, +} from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +/** Assert that an output-context union mutation produced a Union (not a Model). */ +function assertUnionResult(mutation: GraphQLUnionMutation): Union { + if (mutation.mutatedType.kind !== "Union") { + throw new Error( + `Expected Union from output-context mutation, got ${mutation.mutatedType.kind}`, + ); + } + return mutation.mutatedType; +} + +describe("UnionType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a union of model types", async () => { + const { Pet } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + model ${t.model("Dog")} { breed: string; } + union ${t.union("Pet")} { cat: Cat; dog: Dog; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); + + // Union members must be registered in the schema for buildSchema to resolve them + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain("union Pet ="); + expect(sdl).toContain("Cat"); + expect(sdl).toContain("Dog"); + }); + + it("renders a union with doc comment description", async () => { + const { Result } = await tester.compile( + t.code` + model ${t.model("Success")} { value: string; } + model ${t.model("Failure")} { message: string; } + /** The result of an operation */ + union ${t.union("Result")} { success: Success; failure: Failure; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Result, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); + + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + , + ); + + expect(sdl).toContain("The result of an operation"); + expect(sdl).toContain("union Result ="); + }); + + it("renders a union with multiple model members", async () => { + const { Shape } = await tester.compile( + t.code` + model ${t.model("Circle")} { radius: float32; } + model ${t.model("Square")} { side: float32; } + model ${t.model("Triangle")} { base: float32; } + union ${t.union("Shape")} { circle: Circle; square: Square; triangle: Triangle; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Shape, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); + + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + + + + , + ); + + expect(sdl).toContain("union Shape ="); + expect(sdl).toContain("Circle"); + expect(sdl).toContain("Square"); + expect(sdl).toContain("Triangle"); + }); + + it("references wrapper type names for scalar variants", async () => { + const { Mixed } = await tester.compile( + t.code` + model ${t.model("Cat")} { name: string; } + union ${t.union("Mixed")} { cat: Cat; text: string; } + `, + ); + + const engine = createGraphQLMutationEngine(tester.program); + const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output); + const mutatedUnion = assertUnionResult(mutation); + + // Register Cat and the wrapper type that the union will reference + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + + + + , + ); + + // Scalar variant should reference wrapper type name + expect(sdl).toContain("union Mixed ="); + expect(sdl).toContain("MixedTextUnionVariant"); + expect(sdl).toContain("Cat"); + }); +});