diff --git a/packages/graphql/src/components/types/index.ts b/packages/graphql/src/components/types/index.ts index f0c8f56e711..9d53ec9d26d 100644 --- a/packages/graphql/src/components/types/index.ts +++ b/packages/graphql/src/components/types/index.ts @@ -1,3 +1,6 @@ export { ScalarType, type ScalarTypeProps } from "./scalar-type.js"; export { EnumType, type EnumTypeProps } from "./enum-type.js"; export { UnionType, type UnionTypeProps } from "./union-type.js"; +export { InterfaceType, type InterfaceTypeProps } from "./interface-type.js"; +export { ObjectType, type ObjectTypeProps } from "./object-type.js"; +export { InputType, type InputTypeProps } from "./input-type.js"; diff --git a/packages/graphql/src/components/types/input-type.tsx b/packages/graphql/src/components/types/input-type.tsx new file mode 100644 index 00000000000..39acc731e4d --- /dev/null +++ b/packages/graphql/src/components/types/input-type.tsx @@ -0,0 +1,36 @@ +import { type Model, getDoc } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { useGraphQLSchema } from "../../context/index.js"; +import { Field } from "../fields/index.js"; + +export interface InputTypeProps { + /** The input type to render */ + type: Model; +} + +/** + * Renders a GraphQL input type declaration + * + * Determines the correct input type name: + * - If the model is also an output type, appends "Input" + * - Otherwise, uses the name as-is + */ +export function InputType(props: InputTypeProps) { + const { program } = useTsp(); + const { modelVariants } = useGraphQLSchema(); + const doc = getDoc(program, props.type); + const properties = Array.from(props.type.properties.values()); + + // If there's an output variant with the same name, add Input suffix + const hasOutputVariant = modelVariants.outputModels.has(props.type.name); + const inputTypeName = hasOutputVariant ? `${props.type.name}Input` : props.type.name; + + return ( + + {properties.map((prop) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/types/interface-type.tsx b/packages/graphql/src/components/types/interface-type.tsx new file mode 100644 index 00000000000..ef268baa9b5 --- /dev/null +++ b/packages/graphql/src/components/types/interface-type.tsx @@ -0,0 +1,28 @@ +import { type Model, getDoc } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { Field } from "../fields/index.js"; + +export interface InterfaceTypeProps { + /** The interface type to render */ + type: Model; +} + +/** + * Renders a GraphQL interface type declaration + * + * Interfaces are marked with @Interface decorator in TypeSpec + */ +export function InterfaceType(props: InterfaceTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const properties = Array.from(props.type.properties.values()); + + return ( + + {properties.map((prop) => ( + + ))} + + ); +} diff --git a/packages/graphql/src/components/types/object-type.tsx b/packages/graphql/src/components/types/object-type.tsx new file mode 100644 index 00000000000..270e63fd97b --- /dev/null +++ b/packages/graphql/src/components/types/object-type.tsx @@ -0,0 +1,48 @@ +import { type Model, getDoc } from "@typespec/compiler"; +import * as gql from "@alloy-js/graphql"; +import { useTsp } from "@typespec/emitter-framework"; +import { Field, OperationField } from "../fields/index.js"; +import { getComposition } from "../../lib/interface.js"; +import { getOperationFields } from "../../lib/operation-fields.js"; + +export interface ObjectTypeProps { + /** The object type to render */ + type: Model; +} + +/** + * Renders a GraphQL object type declaration + * + * Handles: + * - Regular fields from model properties + * - Interface implementations via @compose + * - Operation fields via @operationFields + */ +export function ObjectType(props: ObjectTypeProps) { + const { program } = useTsp(); + const doc = getDoc(program, props.type); + const properties = Array.from(props.type.properties.values()); + const implementations = getComposition(program, props.type); + const operationFields = getOperationFields(program, props.type); + + // Convert interface implementations to string references. + // Note: getComposition returns original (pre-mutation) models from decorator state. + // This works because the mutation engine doesn't rename models, so iface.name + // matches the name used by InterfaceType for the same model. + const implementsRefs = implementations?.map((iface) => iface.name) || []; + + return ( + + {properties.map((prop) => ( + + ))} + {Array.from(operationFields).map((op) => ( + + ))} + + ); +} diff --git a/packages/graphql/test/components/input-type.test.tsx b/packages/graphql/test/components/input-type.test.tsx new file mode 100644 index 00000000000..cfc40279701 --- /dev/null +++ b/packages/graphql/test/components/input-type.test.tsx @@ -0,0 +1,87 @@ +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it, beforeEach } from "vitest"; +import { InputType } from "../../src/components/types/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("InputType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic input type with fields", async () => { + const { CreateUser } = await tester.compile( + t.code`model ${t.model("CreateUser")} { name: string; email: string; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("input CreateUser {"); + expect(sdl).toContain("name: String!"); + expect(sdl).toContain("email: String!"); + }); + + it("renders with doc comment description", async () => { + const { LoginInput } = await tester.compile( + t.code` + /** Credentials for login */ + model ${t.model("LoginInput")} { username: string; password: string; } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("Credentials for login"); + expect(sdl).toContain("input LoginInput {"); + }); + + it("renders optional fields as non-null (GraphQL input convention)", async () => { + // In GraphQL, input fields are always non-null; optionality is expressed + // via default values, not nullability. Only `| null` makes them nullable. + const { UpdateUser } = await tester.compile( + t.code`model ${t.model("UpdateUser")} { name?: string; bio?: string; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("name: String!"); + expect(sdl).toContain("bio: String!"); + }); + + it("appends Input suffix when model has an output variant", async () => { + const { Pet } = await tester.compile( + t.code`model ${t.model("Pet")} { name: string; }`, + ); + + const sdl = renderComponentToSDL(tester.program, , { + modelVariants: { + outputModels: new Map([["Pet", Pet]]), + inputModels: new Map([["Pet", Pet]]), + }, + }); + + expect(sdl).toContain("input PetInput {"); + }); + + it("uses original name when no output variant exists", async () => { + const { CreatePet } = await tester.compile( + t.code`model ${t.model("CreatePet")} { name: string; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("input CreatePet {"); + expect(sdl).not.toContain("CreatePetInput"); + }); + + it("renders array fields as list types", async () => { + const { TagInput } = await tester.compile( + t.code`model ${t.model("TagInput")} { values: string[]; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("values: [String!]!"); + }); +}); diff --git a/packages/graphql/test/components/interface-type.test.tsx b/packages/graphql/test/components/interface-type.test.tsx new file mode 100644 index 00000000000..904e6cdf77a --- /dev/null +++ b/packages/graphql/test/components/interface-type.test.tsx @@ -0,0 +1,75 @@ +import { t } from "@typespec/compiler/testing"; +import { describe, expect, it, beforeEach } from "vitest"; +import { InterfaceType } from "../../src/components/types/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("InterfaceType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic interface with fields", async () => { + const { Node } = await tester.compile( + t.code` + @Interface + model ${t.model("Node")} { id: string; } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("interface Node {"); + expect(sdl).toContain("id: String!"); + }); + + it("renders with doc comment description", async () => { + const { Entity } = await tester.compile( + t.code` + /** A base entity */ + @Interface + model ${t.model("Entity")} { id: string; } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("A base entity"); + expect(sdl).toContain("interface Entity {"); + }); + + it("renders multiple fields with correct types", async () => { + const { Timestamped } = await tester.compile( + t.code` + @Interface + model ${t.model("Timestamped")} { + createdAt: string; + updatedAt: string; + version: int32; + } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("interface Timestamped {"); + expect(sdl).toContain("createdAt: String!"); + expect(sdl).toContain("updatedAt: String!"); + expect(sdl).toContain("version: Int!"); + }); + + it("renders optional fields as nullable", async () => { + const { Described } = await tester.compile( + t.code` + @Interface + model ${t.model("Described")} { description?: string; } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("description: String"); + expect(sdl).not.toContain("description: String!"); + }); +}); diff --git a/packages/graphql/test/components/object-type.test.tsx b/packages/graphql/test/components/object-type.test.tsx new file mode 100644 index 00000000000..79b73c01759 --- /dev/null +++ b/packages/graphql/test/components/object-type.test.tsx @@ -0,0 +1,122 @@ +import { t } from "@typespec/compiler/testing"; +import * as gql from "@alloy-js/graphql"; +import { describe, expect, it, beforeEach } from "vitest"; +import { ObjectType } from "../../src/components/types/index.js"; +import { Tester } from "../test-host.js"; +import { renderComponentToSDL } from "./component-test-utils.js"; + +describe("ObjectType component", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("renders a basic object type with fields", async () => { + const { User } = await tester.compile( + t.code`model ${t.model("User")} { name: string; age: int32; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("type User {"); + expect(sdl).toContain("name: String!"); + expect(sdl).toContain("age: Int!"); + }); + + it("renders with doc comment description", async () => { + const { Item } = await tester.compile( + t.code` + /** A store item */ + model ${t.model("Item")} { title: string; } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("A store item"); + expect(sdl).toContain("type Item {"); + }); + + it("renders optional fields as nullable", async () => { + const { Profile } = await tester.compile( + t.code`model ${t.model("Profile")} { bio?: string; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("bio: String"); + expect(sdl).not.toContain("bio: String!"); + }); + + it("renders array fields as list types", async () => { + const { Post } = await tester.compile( + t.code`model ${t.model("Post")} { tags: string[]; }`, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("tags: [String!]!"); + }); + + it("renders field with doc comment description", async () => { + const { Thing } = await tester.compile( + t.code` + model ${t.model("Thing")} { + /** The display name */ + name: string; + } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("The display name"); + expect(sdl).toContain("name: String!"); + }); + + it("renders deprecated fields", async () => { + const { Entry } = await tester.compile( + t.code` + model ${t.model("Entry")} { + current: string; + #deprecated "use current instead" + old: string; + } + `, + ); + + const sdl = renderComponentToSDL(tester.program, ); + + expect(sdl).toContain("current: String!"); + expect(sdl).toContain("old: String!"); + expect(sdl).toContain("@deprecated"); + expect(sdl).toContain("use current instead"); + }); + + it("renders with interface implementation via @compose", async () => { + const { Pet } = await tester.compile( + t.code` + @Interface + model ${t.model("Node")} { id: string; } + + @compose(Node) + model ${t.model("Pet")} { ...Node; name: string; } + `, + ); + + // Register the Node interface in the schema so buildSchema can resolve it + const sdl = renderComponentToSDL( + tester.program, + <> + + + + + , + ); + + expect(sdl).toContain("type Pet implements Node {"); + expect(sdl).toContain("id: String!"); + expect(sdl).toContain("name: String!"); + }); +});