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
3 changes: 3 additions & 0 deletions packages/graphql/src/components/types/index.ts
Original file line number Diff line number Diff line change
@@ -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";
36 changes: 36 additions & 0 deletions packages/graphql/src/components/types/input-type.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<gql.InputObjectType name={inputTypeName} description={doc}>
{properties.map((prop) => (
<Field property={prop} isInput={true} />
))}
</gql.InputObjectType>
);
}
28 changes: 28 additions & 0 deletions packages/graphql/src/components/types/interface-type.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<gql.InterfaceType name={props.type.name} description={doc}>
{properties.map((prop) => (
<Field property={prop} isInput={false} />
))}
</gql.InterfaceType>
);
}
48 changes: 48 additions & 0 deletions packages/graphql/src/components/types/object-type.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<gql.ObjectType
name={props.type.name}
description={doc}
interfaces={implementsRefs}
>
{properties.map((prop) => (
<Field property={prop} isInput={false} />
))}
{Array.from(operationFields).map((op) => (
<OperationField operation={op} />
))}
</gql.ObjectType>
);
}
87 changes: 87 additions & 0 deletions packages/graphql/test/components/input-type.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof Tester.createInstance>>;
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, <InputType type={CreateUser} />);

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, <InputType type={LoginInput} />);

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, <InputType type={UpdateUser} />);

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, <InputType type={Pet} />, {
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, <InputType type={CreatePet} />);

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, <InputType type={TagInput} />);

expect(sdl).toContain("values: [String!]!");
});
});
75 changes: 75 additions & 0 deletions packages/graphql/test/components/interface-type.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof Tester.createInstance>>;
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, <InterfaceType type={Node} />);

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, <InterfaceType type={Entity} />);

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, <InterfaceType type={Timestamped} />);

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, <InterfaceType type={Described} />);

expect(sdl).toContain("description: String");
expect(sdl).not.toContain("description: String!");
});
});
Loading