diff --git a/packages/graphql/src/type-usage.ts b/packages/graphql/src/type-usage.ts new file mode 100644 index 00000000000..b8996a5f659 --- /dev/null +++ b/packages/graphql/src/type-usage.ts @@ -0,0 +1,162 @@ +import { + isArrayModelType, + navigateTypesInNamespace, + type Namespace, + type Operation, + type Type, +} from "@typespec/compiler"; + +/** + * GraphQL-specific flags for type usage tracking (input vs output). + */ +export enum GraphQLTypeUsage { + /** Type is used as an input (operation parameter or nested within one) */ + Input = "Input", + /** Type is used as an output (operation return type or nested within one) */ + Output = "Output", +} + +export interface TypeUsageResolver { + /** Get the set of usage flags for a type, or undefined if never referenced by an operation */ + getUsage(type: Type): Set | undefined; + /** Returns true if the type should not be included in the schema */ + isUnreachable(type: Type): boolean; +} + +/** + * Walk all operations in a namespace tree to determine type reachability and + * input/output classification. + * + * Produces two independent results: + * - **Reachability**: whether a type should be included in the emitted schema. + * - **Usage**: whether a type is used as Input, Output, or both. + * + * When `omitUnreachableTypes` is false, all types declared in the namespace + * are considered reachable regardless of whether an operation references them. + */ +export function resolveTypeUsage( + root: Namespace, + omitUnreachableTypes: boolean, +): TypeUsageResolver { + // Two independent concerns tracked in a single walk: + // reachableTypes — should this type appear in the schema? + // usages — is this type used as Input, Output, or both? + const reachableTypes = new Set(); + const usages = new Map>(); + + addUsagesInNamespace(root, reachableTypes, usages); + + // When all declared types should be emitted, mark them reachable. + if (!omitUnreachableTypes) { + const markReachable = (type: Type) => { + reachableTypes.add(type); + }; + navigateTypesInNamespace(root, { + model: markReachable, + scalar: markReachable, + enum: markReachable, + union: markReachable, + }); + } + + return { + getUsage: (type: Type) => usages.get(type), + isUnreachable: (type: Type) => !reachableTypes.has(type), + }; +} + +function trackUsage( + reachableTypes: Set, + usages: Map>, + type: Type, + usage: GraphQLTypeUsage, +) { + reachableTypes.add(type); + const existing = usages.get(type) ?? new Set(); + existing.add(usage); + usages.set(type, existing); +} + +/** + * Recursively walk a namespace and all sub-namespaces, tracking type usage + * from operations. + */ +function addUsagesInNamespace( + namespace: Namespace, + reachableTypes: Set, + usages: Map>, +): void { + for (const subNamespace of namespace.namespaces.values()) { + addUsagesInNamespace(subNamespace, reachableTypes, usages); + } + for (const iface of namespace.interfaces.values()) { + for (const operation of iface.operations.values()) { + addUsagesFromOperation(operation, reachableTypes, usages); + } + } + for (const operation of namespace.operations.values()) { + addUsagesFromOperation(operation, reachableTypes, usages); + } +} + +/** + * For a single operation, mark parameter types as Input and return type as Output. + */ +function addUsagesFromOperation( + operation: Operation, + reachableTypes: Set, + usages: Map>, +): void { + for (const param of operation.parameters.properties.values()) { + navigateReferencedTypes(param.type, GraphQLTypeUsage.Input, reachableTypes, usages); + } + navigateReferencedTypes(operation.returnType, GraphQLTypeUsage.Output, reachableTypes, usages); +} + +/** + * Recursively walk a type graph, tracking reachability and usage classification. + * Handles circular references via a visited set. + */ +function navigateReferencedTypes( + type: Type, + usage: GraphQLTypeUsage, + reachableTypes: Set, + usages: Map>, + visited: Set = new Set(), +): void { + if (visited.has(type)) return; + visited.add(type); + + switch (type.kind) { + case "Model": + if (isArrayModelType(type)) { + if (type.indexer?.value) { + navigateReferencedTypes(type.indexer.value, usage, reachableTypes, usages, visited); + } + } else { + trackUsage(reachableTypes, usages, type, usage); + for (const prop of type.properties.values()) { + navigateReferencedTypes(prop.type, usage, reachableTypes, usages, visited); + } + if (type.baseModel) { + navigateReferencedTypes(type.baseModel, usage, reachableTypes, usages, visited); + } + } + break; + + case "Union": + trackUsage(reachableTypes, usages, type, usage); + for (const variant of type.variants.values()) { + navigateReferencedTypes(variant.type, usage, reachableTypes, usages, visited); + } + break; + + case "Scalar": + case "Enum": + trackUsage(reachableTypes, usages, type, usage); + break; + + default: + break; + } +} diff --git a/packages/graphql/src/visibility-usage.ts b/packages/graphql/src/visibility-usage.ts deleted file mode 100644 index 7b09a8861fe..00000000000 --- a/packages/graphql/src/visibility-usage.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Namespace, Program, Type } from "@typespec/compiler"; -import type { Visibility } from "@typespec/http"; - -export interface VisibilityUsageTracker { - // This Visibility might change to be GraphQL specific - getUsage(type: Type): Set | undefined; - isUnreachable(type: Type): boolean; -} - -export function resolveVisibilityUsage( - program: Program, - root: Namespace, - omitUnreachableTypes: boolean, -): VisibilityUsageTracker { - // Track usages and return visibility tracker - return { - getUsage: (type: Type) => { - // Placeholder for actual implementation - return new Set(); - }, - isUnreachable: (type: Type) => { - // Placeholder for actual implementation - return false; - }, - }; -} diff --git a/packages/graphql/test/type-usage.test.ts b/packages/graphql/test/type-usage.test.ts new file mode 100644 index 00000000000..ef8e8df1891 --- /dev/null +++ b/packages/graphql/test/type-usage.test.ts @@ -0,0 +1,252 @@ +import { t } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { GraphQLTypeUsage, resolveTypeUsage } from "../src/type-usage.js"; +import { Tester } from "./test-host.js"; + +describe("type-usage", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + function resolve(omitUnreachableTypes = true) { + return resolveTypeUsage( + tester.program.getGlobalNamespaceType(), + omitUnreachableTypes, + ); + } + + describe("basic output reachability", () => { + it("marks return type model as Output", async () => { + const { User } = await tester.compile( + t.code` + model ${t.model("User")} { id: string; } + @query op getUser(): User; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(User)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.getUsage(User)?.has(GraphQLTypeUsage.Input)).toBeFalsy(); + expect(resolver.isUnreachable(User)).toBe(false); + }); + }); + + describe("basic input reachability", () => { + it("marks parameter type model as Input", async () => { + const { UserInput } = await tester.compile( + t.code` + model ${t.model("UserInput")} { name: string; } + @query op createUser(input: UserInput): void; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(UserInput)?.has(GraphQLTypeUsage.Input)).toBe(true); + expect(resolver.getUsage(UserInput)?.has(GraphQLTypeUsage.Output)).toBeFalsy(); + expect(resolver.isUnreachable(UserInput)).toBe(false); + }); + }); + + describe("nested reachability", () => { + it("tracks models referenced indirectly via properties", async () => { + const { Address } = await tester.compile( + t.code` + model ${t.model("Address")} { street: string; } + model User { id: string; address: Address; } + @query op getUser(): User; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(Address)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.isUnreachable(Address)).toBe(false); + }); + }); + + describe("dual usage", () => { + it("model used as both parameter and return gets both flags", async () => { + const { Book } = await tester.compile( + t.code` + model ${t.model("Book")} { title: string; } + @query op getBook(): Book; + @mutation op updateBook(input: Book): void; + `, + ); + + const resolver = resolve(); + const usage = resolver.getUsage(Book); + expect(usage?.has(GraphQLTypeUsage.Input)).toBe(true); + expect(usage?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + + it("nested model shared across input and output in a single operation gets both flags", async () => { + const { Shared } = await tester.compile( + t.code` + model ${t.model("Shared")} { id: string; } + model InputData { shared: Shared; } + model OutputData { shared: Shared; } + @query op transform(input: InputData): OutputData; + `, + ); + + const resolver = resolve(); + const usage = resolver.getUsage(Shared); + expect(usage?.has(GraphQLTypeUsage.Input)).toBe(true); + expect(usage?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + }); + + describe("unreachable types", () => { + it("marks unreferenced type as unreachable when omitUnreachableTypes=true", async () => { + const { Orphan } = await tester.compile( + t.code` + model ${t.model("Orphan")} { value: int32; } + model Used { id: string; } + @query op getUsed(): Used; + `, + ); + + const resolver = resolve(true); + expect(resolver.isUnreachable(Orphan)).toBe(true); + expect(resolver.getUsage(Orphan)).toBeUndefined(); + }); + + it("marks unreferenced type as reachable when omitUnreachableTypes=false", async () => { + const { Orphan } = await tester.compile( + t.code` + model ${t.model("Orphan")} { value: int32; } + model Used { id: string; } + @query op getUsed(): Used; + `, + ); + + const resolver = resolve(false); + expect(resolver.isUnreachable(Orphan)).toBe(false); + // Reachable but no usage flags — it wasn't actually referenced by any operation + expect(resolver.getUsage(Orphan)).toBeUndefined(); + }); + + it("preserves usage flags for referenced types when omitUnreachableTypes=false", async () => { + const { Used } = await tester.compile( + t.code` + model ${t.model("Used")} { id: string; } + @query op getUsed(): Used; + `, + ); + + const resolver = resolve(false); + expect(resolver.isUnreachable(Used)).toBe(false); + expect(resolver.getUsage(Used)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.getUsage(Used)?.has(GraphQLTypeUsage.Input)).toBeFalsy(); + }); + }); + + describe("circular references", () => { + it("handles self-referencing model without infinite loop", async () => { + const { TreeNode } = await tester.compile( + t.code` + model ${t.model("TreeNode")} { id: string; children: TreeNode[]; } + @query op getRoot(): TreeNode; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(TreeNode)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.isUnreachable(TreeNode)).toBe(false); + }); + }); + + describe("union variant reachability", () => { + it("tracks types inside a union used in an operation", async () => { + const { Cat, Dog, 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; } + @query op getPet(): Pet; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(Pet)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.getUsage(Cat)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.getUsage(Dog)?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + }); + + describe("array element reachability", () => { + it("marks element type of array return as Output", async () => { + const { User } = await tester.compile( + t.code` + model ${t.model("User")} { id: string; } + @query op listUsers(): User[]; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(User)?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + }); + + describe("base model reachability", () => { + it("tracks parent model when child is reachable", async () => { + const { Parent } = await tester.compile( + t.code` + model ${t.model("Parent")} { id: string; } + model Child extends Parent { extra: string; } + @query op getChild(): Child; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(Parent)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.isUnreachable(Parent)).toBe(false); + }); + }); + + describe("enum and scalar reachability", () => { + it("tracks enum types referenced from operations", async () => { + const { Status } = await tester.compile( + t.code` + enum ${t.enum("Status")} { Active; Inactive; } + model User { id: string; status: Status; } + @query op getUser(): User; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(Status)?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + + it("tracks scalar types referenced from operations", async () => { + const { MyId } = await tester.compile( + t.code` + scalar ${t.scalar("MyId")} extends string; + model User { id: MyId; } + @query op getUser(): User; + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(MyId)?.has(GraphQLTypeUsage.Output)).toBe(true); + }); + }); + + describe("interface operations", () => { + it("walks operations inside interface blocks", async () => { + const { User } = await tester.compile( + t.code` + model ${t.model("User")} { id: string; } + interface UserService { + @query getUser(): User; + } + `, + ); + + const resolver = resolve(); + expect(resolver.getUsage(User)?.has(GraphQLTypeUsage.Output)).toBe(true); + expect(resolver.isUnreachable(User)).toBe(false); + }); + }); +});