diff --git a/packages/graphql/src/mutation-engine/engine.ts b/packages/graphql/src/mutation-engine/engine.ts index 5c29c2b5d52..1dc2d505585 100644 --- a/packages/graphql/src/mutation-engine/engine.ts +++ b/packages/graphql/src/mutation-engine/engine.ts @@ -1,10 +1,13 @@ import { + resolveUsages, + UsageFlags, type Enum, type Model, type Namespace, type Operation, type Program, type Scalar, + type UsageTracker, } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; import { @@ -47,9 +50,20 @@ const graphqlMutationRegistry = { Intrinsic: SimpleIntrinsicMutation, }; +/** + * Result of mutating a model with usage awareness. + * Contains separate mutations for input and output variants when applicable. + */ +export interface ModelMutationResult { + /** The input variant mutation (with "Input" suffix), if the model is used as input */ + input?: GraphQLModelMutation; + /** The output variant mutation (no suffix), if the model is used as output */ + output?: GraphQLModelMutation; +} + /** * GraphQL mutation engine that applies GraphQL-specific transformations - * to TypeSpec types, such as name sanitization. + * to TypeSpec types, such as name sanitization and input/output splitting. */ export class GraphQLMutationEngine { /** @@ -58,16 +72,55 @@ export class GraphQLMutationEngine { */ private engine; - constructor(program: Program, _namespace: Namespace) { + /** Usage tracker for types in the namespace */ + private usageTracker: UsageTracker; + + constructor(program: Program, namespace: Namespace) { const tk = $(program); this.engine = new MutationEngine(tk, graphqlMutationRegistry); + + // Resolve usages once at construction time + this.usageTracker = resolveUsages(namespace); + } + + /** + * Get the usage flags for a model. + */ + getUsage(model: Model): UsageFlags { + const isInput = this.usageTracker.isUsedAs(model, UsageFlags.Input); + const isOutput = this.usageTracker.isUsedAs(model, UsageFlags.Output); + + if (isInput && isOutput) { + return UsageFlags.Input | UsageFlags.Output; + } else if (isInput) { + return UsageFlags.Input; + } else if (isOutput) { + return UsageFlags.Output; + } + return UsageFlags.None; } /** - * Mutate a model, applying GraphQL name sanitization. + * Mutate a model with usage awareness. + * Returns separate input/output mutations based on how the model is used. */ - mutateModel(model: Model): GraphQLModelMutation { - return this.engine.mutate(model, new GraphQLMutationOptions()) as GraphQLModelMutation; + mutateModel(model: Model): ModelMutationResult { + const usage = this.getUsage(model); + const result: ModelMutationResult = {}; + + // Create output mutation if used as output (or no usage info) + if (usage & UsageFlags.Output || usage === UsageFlags.None) { + const outputOptions = new GraphQLMutationOptions(UsageFlags.Output); + result.output = this.engine.mutate(model, outputOptions) as GraphQLModelMutation; + } + + // Create input mutation if used as input + if (usage & UsageFlags.Input) { + const inputOptions = new GraphQLMutationOptions(UsageFlags.Input); + result.input = this.engine.mutate(model, inputOptions) as GraphQLModelMutation; + } + + return result; } /** diff --git a/packages/graphql/src/mutation-engine/index.ts b/packages/graphql/src/mutation-engine/index.ts index 3c1fe71c3bb..73ef702d08d 100644 --- a/packages/graphql/src/mutation-engine/index.ts +++ b/packages/graphql/src/mutation-engine/index.ts @@ -1,4 +1,8 @@ -export { GraphQLMutationEngine, createGraphQLMutationEngine } from "./engine.js"; +export { + GraphQLMutationEngine, + createGraphQLMutationEngine, + type ModelMutationResult, +} from "./engine.js"; export { GraphQLEnumMemberMutation, GraphQLEnumMutation, diff --git a/packages/graphql/src/mutation-engine/mutations/model.ts b/packages/graphql/src/mutation-engine/mutations/model.ts index 4599ba2745f..3c7264fa2af 100644 --- a/packages/graphql/src/mutation-engine/mutations/model.ts +++ b/packages/graphql/src/mutation-engine/mutations/model.ts @@ -1,4 +1,4 @@ -import type { MemberType, Model } from "@typespec/compiler"; +import { UsageFlags, type MemberType, type Model } from "@typespec/compiler"; import { SimpleModelMutation, type MutationInfo, @@ -7,11 +7,15 @@ import { type SimpleMutations, } from "@typespec/mutator-framework"; import { sanitizeNameForGraphQL } from "../../lib/type-utils.js"; +import type { GraphQLMutationOptions } from "../options.js"; /** - * GraphQL-specific Model mutation. + * GraphQL-specific Model mutation that sanitizes names for GraphQL compatibility. + * Adds "Input" suffix when the model is used as an input type. */ export class GraphQLModelMutation extends SimpleModelMutation { + private graphqlOptions: GraphQLMutationOptions; + constructor( engine: SimpleMutationEngine>, sourceType: Model, @@ -20,12 +24,20 @@ export class GraphQLModelMutation extends SimpleModelMutation { - model.name = sanitizeNameForGraphQL(model.name); + let name = sanitizeNameForGraphQL(model.name); + + // Add "Input" suffix for input types + if (this.graphqlOptions.usageFlag === UsageFlags.Input) { + name = `${name}Input`; + } + + model.name = name; }); super.mutate(); } diff --git a/packages/graphql/src/mutation-engine/options.ts b/packages/graphql/src/mutation-engine/options.ts index e7e8a130f6b..e4214f5982e 100644 --- a/packages/graphql/src/mutation-engine/options.ts +++ b/packages/graphql/src/mutation-engine/options.ts @@ -1,9 +1,35 @@ +import { UsageFlags } from "@typespec/compiler"; import { SimpleMutationOptions } from "@typespec/mutator-framework"; /** * GraphQL-specific mutation options. * - * Currently a simple wrapper around SimpleMutationOptions. - * Can be extended in the future to support additional GraphQL-specific options. + * Extends SimpleMutationOptions with usage-aware mutation key support, + * enabling separate mutations for input vs output type variants. */ -export class GraphQLMutationOptions extends SimpleMutationOptions {} +export class GraphQLMutationOptions extends SimpleMutationOptions { + /** + * The usage flag indicating whether this mutation is for input or output usage. + * Used to generate separate mutations for the same type when used in both contexts. + */ + readonly usageFlag: UsageFlags; + + constructor(usageFlag: UsageFlags = UsageFlags.None) { + super(); + this.usageFlag = usageFlag; + } + + /** + * Override mutationKey to include usage flag. + * This ensures the mutation engine caches separate mutations for input vs output variants. + */ + override get mutationKey(): string { + const baseKey = super.mutationKey; + if (this.usageFlag === UsageFlags.Input) { + return `${baseKey}:input`; + } else if (this.usageFlag === UsageFlags.Output) { + return `${baseKey}:output`; + } + return baseKey; + } +} diff --git a/packages/graphql/src/schema-emitter.ts b/packages/graphql/src/schema-emitter.ts index 2f86a727f24..07e02fafcae 100644 --- a/packages/graphql/src/schema-emitter.ts +++ b/packages/graphql/src/schema-emitter.ts @@ -65,17 +65,34 @@ class GraphQLSchemaEmitter { this.registry.addEnum(mutation.mutatedType); }, model: (node: Model) => { - const mutation = this.engine.mutateModel(node); - // TODO: Handle input/output variants - this.registry.addModel(mutation.mutatedType, UsageFlags.Output); + // Mutate the model - returns input/output variants + const result = this.engine.mutateModel(node); + + // Register output variant if present + if (result.output) { + this.registry.addModel(result.output.mutatedType, UsageFlags.Output); + } + + // Register input variant if present + if (result.input) { + this.registry.addModel(result.input.mutatedType, UsageFlags.Input); + } }, exitEnum: (node: Enum) => { const mutation = this.engine.mutateEnum(node); this.registry.materializeEnum(mutation.mutatedType.name); }, exitModel: (node: Model) => { - const mutation = this.engine.mutateModel(node); - this.registry.materializeModel(mutation.mutatedType.name); + // Materialize both input and output variants + const result = this.engine.mutateModel(node); + + if (result.output) { + this.registry.materializeModel(result.output.mutatedType.name); + } + + if (result.input) { + this.registry.materializeModel(result.input.mutatedType.name); + } }, }; } diff --git a/packages/graphql/src/type-maps/model.ts b/packages/graphql/src/type-maps/model.ts index 0b3b794c06d..8ff35b7cc4a 100644 --- a/packages/graphql/src/type-maps/model.ts +++ b/packages/graphql/src/type-maps/model.ts @@ -5,6 +5,8 @@ import { GraphQLString, type GraphQLFieldConfigMap, type GraphQLInputFieldConfigMap, + type GraphQLInputType, + type GraphQLOutputType, } from "graphql"; import { TypeMap, type TSPContext, type TypeKey } from "../type-maps.js"; @@ -32,31 +34,35 @@ export class ModelTypeMap extends TypeMap = {}; for (const [propName, prop] of tspModel.properties) { fields[propName] = { - type: this.mapPropertyType(prop.type), - // TODO: Add description from doc comments + type: this.mapOutputType(prop.type), }; } return new GraphQLObjectType({ name, fields }); } - private materializeInputType(name: string, tspModel: Model): GraphQLInputObjectType { + /** + * Materialize as a GraphQLInputObjectType (input type). + */ + private materializeInputType(tspModel: Model, name: string): GraphQLInputObjectType { const fields: GraphQLInputFieldConfigMap = {}; for (const [propName, prop] of tspModel.properties) { fields[propName] = { - type: this.mapPropertyType(prop.type), - // TODO: Add description from doc comments + type: this.mapInputType(prop.type), }; } @@ -64,10 +70,19 @@ export class ModelTypeMap extends TypeMap { const { ValidModel } = await tester.compile(t.code`model ${t.model("ValidModel")} { }`); const engine = createTestEngine(tester.program); - const mutation = engine.mutateModel(ValidModel); + const result = engine.mutateModel(ValidModel); - expect(mutation.mutatedType.name).toBe("ValidModel"); + // Without operations, models default to output variant + expect(result.output?.mutatedType.name).toBe("ValidModel"); }); it("renames invalid model names", async () => { @@ -118,9 +119,9 @@ describe("GraphQL Mutation Engine - Models", () => { const InvalidModel = tester.program.getGlobalNamespaceType().models.get("$Invalid$")!; const engine = createTestEngine(tester.program); - const mutation = engine.mutateModel(InvalidModel); + const result = engine.mutateModel(InvalidModel); - expect(mutation.mutatedType.name).toBe("_Invalid_"); + expect(result.output?.mutatedType.name).toBe("_Invalid_"); }); it("processes model properties through sanitization", async () => { @@ -129,10 +130,10 @@ describe("GraphQL Mutation Engine - Models", () => { ); const engine = createTestEngine(tester.program); - const mutation = engine.mutateModel(TestModel); + const result = engine.mutateModel(TestModel); - expect(mutation.mutatedType.name).toBe("TestModel"); - expect(mutation.mutatedType.properties.has("validProp")).toBe(true); + expect(result.output?.mutatedType.name).toBe("TestModel"); + expect(result.output?.mutatedType.properties.has("validProp")).toBe(true); }); }); @@ -148,8 +149,8 @@ describe("GraphQL Mutation Engine - Model Properties", () => { ); const engine = createTestEngine(tester.program); - const mutation = engine.mutateModel(M); - const prop = mutation.mutatedType.properties.get("prop"); + const result = engine.mutateModel(M); + const prop = result.output?.mutatedType.properties.get("prop"); expect(prop?.name).toBe("prop"); }); @@ -158,11 +159,11 @@ describe("GraphQL Mutation Engine - Model Properties", () => { const { M } = await tester.compile(t.code`model ${t.model("M")} { \`$prop$\`: string }`); const engine = createTestEngine(tester.program); - const mutation = engine.mutateModel(M); + const result = engine.mutateModel(M); // Check that the property was renamed in the mutated model - expect(mutation.mutatedType.properties.has("_prop_")).toBe(true); - expect(mutation.mutatedType.properties.has("$prop$")).toBe(false); + expect(result.output?.mutatedType.properties.has("_prop_")).toBe(true); + expect(result.output?.mutatedType.properties.has("$prop$")).toBe(false); }); }); @@ -246,15 +247,15 @@ describe("GraphQL Mutation Engine - Edge Cases", () => { ); const engine = createTestEngine(tester.program); - const mutation = engine.mutateModel(M); - const mutated = mutation.mutatedType; - - expect(mutated.properties.has("_prop1_")).toBe(true); - expect(mutated.properties.has("prop_2")).toBe(true); - expect(mutated.properties.has("prop_3")).toBe(true); - expect(mutated.properties.has("$prop1$")).toBe(false); - expect(mutated.properties.has("prop-2")).toBe(false); - expect(mutated.properties.has("prop.3")).toBe(false); + const result = engine.mutateModel(M); + const mutated = result.output?.mutatedType; + + expect(mutated?.properties.has("_prop1_")).toBe(true); + expect(mutated?.properties.has("prop_2")).toBe(true); + expect(mutated?.properties.has("prop_3")).toBe(true); + expect(mutated?.properties.has("$prop1$")).toBe(false); + expect(mutated?.properties.has("prop-2")).toBe(false); + expect(mutated?.properties.has("prop.3")).toBe(false); }); it("handles enum with multiple invalid members", async () => { @@ -278,29 +279,29 @@ describe("GraphQL Mutation Engine - Edge Cases", () => { const { _ValidName } = await tester.compile(t.code`model ${t.model("_ValidName")} { }`); const engine = createTestEngine(tester.program); - const mutation = engine.mutateModel(_ValidName); + const result = engine.mutateModel(_ValidName); - expect(mutation.mutatedType.name).toBe("_ValidName"); + expect(result.output?.mutatedType.name).toBe("_ValidName"); }); it("preserves names with numbers in the middle", async () => { const { Model123 } = await tester.compile(t.code`model ${t.model("Model123")} { }`); const engine = createTestEngine(tester.program); - const mutation = engine.mutateModel(Model123); + const result = engine.mutateModel(Model123); - expect(mutation.mutatedType.name).toBe("Model123"); + expect(result.output?.mutatedType.name).toBe("Model123"); }); it("handles property names starting with numbers", async () => { const { M } = await tester.compile(t.code`model ${t.model("M")} { \`123prop\`: string; }`); const engine = createTestEngine(tester.program); - const mutation = engine.mutateModel(M); - const mutated = mutation.mutatedType; + const result = engine.mutateModel(M); + const mutated = result.output?.mutatedType; - expect(mutated.properties.has("_123prop")).toBe(true); - expect(mutated.properties.has("123prop")).toBe(false); + expect(mutated?.properties.has("_123prop")).toBe(true); + expect(mutated?.properties.has("123prop")).toBe(false); }); it("handles enum member names starting with numbers", async () => { @@ -313,3 +314,86 @@ describe("GraphQL Mutation Engine - Edge Cases", () => { expect(mutated.members.has("123value")).toBe(false); }); }); + +describe("GraphQL Mutation Engine - Input/Output Splitting", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("creates input variant for models used as operation parameters", async () => { + const { Person } = await tester.compile( + t.code` + model ${t.model("Person")} { name: string } + op createPerson(person: Person): void; + `, + ); + + const engine = createTestEngine(tester.program); + const result = engine.mutateModel(Person); + + // Should have input variant with "Input" suffix + expect(result.input?.mutatedType.name).toBe("PersonInput"); + }); + + it("creates output variant for models used as return types", async () => { + const { Person } = await tester.compile( + t.code` + model ${t.model("Person")} { name: string } + op getPerson(): Person; + `, + ); + + const engine = createTestEngine(tester.program); + const result = engine.mutateModel(Person); + + // Should have output variant without suffix + expect(result.output?.mutatedType.name).toBe("Person"); + // Should not have input variant + expect(result.input).toBeUndefined(); + }); + + it("creates both variants for models used as both input and output", async () => { + const { Person } = await tester.compile( + t.code` + model ${t.model("Person")} { name: string } + op getPerson(): Person; + op updatePerson(person: Person): void; + `, + ); + + const engine = createTestEngine(tester.program); + const result = engine.mutateModel(Person); + + // Should have both variants + expect(result.output?.mutatedType.name).toBe("Person"); + expect(result.input?.mutatedType.name).toBe("PersonInput"); + }); + + it("applies name sanitization to input variants", async () => { + await tester.compile( + t.code` + model \`$Invalid$\` { name: string } + op create(data: \`$Invalid$\`): void; + `, + ); + + const InvalidModel = tester.program.getGlobalNamespaceType().models.get("$Invalid$")!; + const engine = createTestEngine(tester.program); + const result = engine.mutateModel(InvalidModel); + + // Should sanitize name AND add Input suffix + expect(result.input?.mutatedType.name).toBe("_Invalid_Input"); + }); + + it("defaults to output variant when no operations reference the model", async () => { + const { Standalone } = await tester.compile(t.code`model ${t.model("Standalone")} { }`); + + const engine = createTestEngine(tester.program); + const result = engine.mutateModel(Standalone); + + // Should only have output variant + expect(result.output?.mutatedType.name).toBe("Standalone"); + expect(result.input).toBeUndefined(); + }); +});