From 7c023401a3e6ca079f6de8812207a2b53ef1961e Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 13 Mar 2026 13:42:00 -0400 Subject: [PATCH] Add type-usage reachability analysis module resolveTypeUsage walks all operations in a namespace tree and tracks which types are reachable from operation parameters (Input) vs return types (Output). Handles circular references, array elements, base models, union variants, enums, and scalars. When omitUnreachableTypes is false, all declared types are marked reachable without adding spurious Input/Output flags. Includes 13 unit tests covering all reachability scenarios. --- packages/graphql/src/type-usage.ts | 162 +++++++++++++++ packages/graphql/src/visibility-usage.ts | 26 --- packages/graphql/test/type-usage.test.ts | 252 +++++++++++++++++++++++ 3 files changed, 414 insertions(+), 26 deletions(-) create mode 100644 packages/graphql/src/type-usage.ts delete mode 100644 packages/graphql/src/visibility-usage.ts create mode 100644 packages/graphql/test/type-usage.test.ts 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); + }); + }); +});