Skip to content

Commit 9b5fa81

Browse files
committed
feat(graphql): add input/output type splitting based on usage
- Update GraphQLMutationOptions with usageFlag and mutationKey - GraphQLModelMutation adds 'Input' suffix for input types - GraphQLMutationEngine.mutateModel() returns ModelMutationResult with separate input/output mutations based on resolveUsages() - ModelTypeMap supports GraphQLInputObjectType for input types - Schema emitter handles both input and output model variants - Add 5 new tests for input/output splitting behavior
1 parent ccc10f6 commit 9b5fa81

7 files changed

Lines changed: 298 additions & 57 deletions

File tree

packages/graphql/src/mutation-engine/engine.ts

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import {
2+
resolveUsages,
3+
UsageFlags,
24
type Enum,
35
type Model,
46
type Namespace,
57
type Operation,
68
type Program,
79
type Scalar,
10+
type UsageTracker,
811
} from "@typespec/compiler";
912
import { $ } from "@typespec/compiler/typekit";
1013
import {
@@ -47,25 +50,74 @@ const graphqlMutationRegistry = {
4750
Intrinsic: SimpleIntrinsicMutation,
4851
};
4952

53+
/**
54+
* Result of mutating a model with usage awareness.
55+
* Contains separate mutations for input and output variants when applicable.
56+
*/
57+
export interface ModelMutationResult {
58+
/** The input variant mutation (with "Input" suffix), if the model is used as input */
59+
input?: GraphQLModelMutation;
60+
/** The output variant mutation (no suffix), if the model is used as output */
61+
output?: GraphQLModelMutation;
62+
}
63+
5064
/**
5165
* GraphQL mutation engine that applies GraphQL-specific transformations
52-
* to TypeSpec types, such as name sanitization.
66+
* to TypeSpec types, such as name sanitization and input/output splitting.
5367
*/
5468
export class GraphQLMutationEngine {
5569
/** The underlying mutation engine with custom GraphQL mutation classes */
5670
private engine;
5771

58-
constructor(program: Program, _namespace: Namespace) {
72+
/** Usage tracker for types in the namespace */
73+
private usageTracker: UsageTracker;
74+
75+
constructor(program: Program, namespace: Namespace) {
5976
const tk = $(program);
6077
this.engine = new MutationEngine(tk, graphqlMutationRegistry);
78+
79+
// Resolve usages once at construction time
80+
this.usageTracker = resolveUsages(namespace);
6181
}
6282

6383
/**
64-
* Mutate a model, applying GraphQL name sanitization.
84+
* Get the usage flags for a model.
6585
*/
66-
mutateModel(model: Model): GraphQLModelMutation {
67-
const mutation = this.engine.mutate(model, new GraphQLMutationOptions());
68-
return mutation as unknown as GraphQLModelMutation;
86+
getUsage(model: Model): UsageFlags {
87+
const isInput = this.usageTracker.isUsedAs(model, UsageFlags.Input);
88+
const isOutput = this.usageTracker.isUsedAs(model, UsageFlags.Output);
89+
90+
if (isInput && isOutput) {
91+
return UsageFlags.Input | UsageFlags.Output;
92+
} else if (isInput) {
93+
return UsageFlags.Input;
94+
} else if (isOutput) {
95+
return UsageFlags.Output;
96+
}
97+
return UsageFlags.None;
98+
}
99+
100+
/**
101+
* Mutate a model with usage awareness.
102+
* Returns separate input/output mutations based on how the model is used.
103+
*/
104+
mutateModel(model: Model): ModelMutationResult {
105+
const usage = this.getUsage(model);
106+
const result: ModelMutationResult = {};
107+
108+
// Create output mutation if used as output (or no usage info)
109+
if (usage & UsageFlags.Output || usage === UsageFlags.None) {
110+
const outputOptions = new GraphQLMutationOptions(UsageFlags.Output);
111+
result.output = this.engine.mutate(model, outputOptions) as unknown as GraphQLModelMutation;
112+
}
113+
114+
// Create input mutation if used as input
115+
if (usage & UsageFlags.Input) {
116+
const inputOptions = new GraphQLMutationOptions(UsageFlags.Input);
117+
result.input = this.engine.mutate(model, inputOptions) as unknown as GraphQLModelMutation;
118+
}
119+
120+
return result;
69121
}
70122

71123
/**
@@ -102,4 +154,3 @@ export function createGraphQLMutationEngine(
102154
): GraphQLMutationEngine {
103155
return new GraphQLMutationEngine(program, namespace);
104156
}
105-

packages/graphql/src/mutation-engine/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
export { GraphQLMutationEngine, createGraphQLMutationEngine } from "./engine.js";
1+
export {
2+
GraphQLMutationEngine,
3+
createGraphQLMutationEngine,
4+
type ModelMutationResult,
5+
} from "./engine.js";
26
export {
37
GraphQLEnumMemberMutation,
48
GraphQLEnumMutation,

packages/graphql/src/mutation-engine/mutations/model.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { MemberType, Model } from "@typespec/compiler";
1+
import { UsageFlags, type MemberType, type Model } from "@typespec/compiler";
22
import {
33
SimpleModelMutation,
44
type MutationInfo,
@@ -7,11 +7,15 @@ import {
77
type SimpleMutations,
88
} from "@typespec/mutator-framework";
99
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";
10+
import type { GraphQLMutationOptions } from "../options.js";
1011

1112
/**
1213
* GraphQL-specific Model mutation that sanitizes names for GraphQL compatibility.
14+
* Adds "Input" suffix when the model is used as an input type.
1315
*/
1416
export class GraphQLModelMutation extends SimpleModelMutation<SimpleMutationOptions> {
17+
private graphqlOptions: GraphQLMutationOptions;
18+
1519
constructor(
1620
engine: SimpleMutationEngine<SimpleMutations<SimpleMutationOptions>>,
1721
sourceType: Model,
@@ -20,12 +24,20 @@ export class GraphQLModelMutation extends SimpleModelMutation<SimpleMutationOpti
2024
info: MutationInfo,
2125
) {
2226
super(engine as any, sourceType, referenceTypes, options, info);
27+
this.graphqlOptions = options as GraphQLMutationOptions;
2328
}
2429

2530
mutate() {
2631
// Apply GraphQL name sanitization
2732
this.mutationNode.mutate((model) => {
28-
model.name = sanitizeNameForGraphQL(model.name);
33+
let name = sanitizeNameForGraphQL(model.name);
34+
35+
// Add "Input" suffix for input types
36+
if (this.graphqlOptions.usageFlag === UsageFlags.Input) {
37+
name = `${name}Input`;
38+
}
39+
40+
model.name = name;
2941
});
3042
super.mutate();
3143
}
Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,35 @@
1+
import { UsageFlags } from "@typespec/compiler";
12
import { SimpleMutationOptions } from "@typespec/mutator-framework";
23

34
/**
45
* GraphQL-specific mutation options.
56
*
6-
* Currently a simple wrapper around SimpleMutationOptions.
7-
* Can be extended in the future to support additional GraphQL-specific options.
7+
* Extends SimpleMutationOptions with usage-aware mutation key support,
8+
* enabling separate mutations for input vs output type variants.
89
*/
9-
export class GraphQLMutationOptions extends SimpleMutationOptions {}
10+
export class GraphQLMutationOptions extends SimpleMutationOptions {
11+
/**
12+
* The usage flag indicating whether this mutation is for input or output usage.
13+
* Used to generate separate mutations for the same type when used in both contexts.
14+
*/
15+
readonly usageFlag: UsageFlags;
16+
17+
constructor(usageFlag: UsageFlags = UsageFlags.None) {
18+
super();
19+
this.usageFlag = usageFlag;
20+
}
21+
22+
/**
23+
* Override mutationKey to include usage flag.
24+
* This ensures the mutation engine caches separate mutations for input vs output variants.
25+
*/
26+
override get mutationKey(): string {
27+
const baseKey = super.mutationKey;
28+
if (this.usageFlag === UsageFlags.Input) {
29+
return `${baseKey}:input`;
30+
} else if (this.usageFlag === UsageFlags.Output) {
31+
return `${baseKey}:output`;
32+
}
33+
return baseKey;
34+
}
35+
}

packages/graphql/src/schema-emitter.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010
import { GraphQLSchema, validateSchema } from "graphql";
1111
import { type GraphQLEmitterOptions } from "./lib.js";
1212
import type { Schema } from "./lib/schema.js";
13-
import { createGraphQLMutationEngine, type GraphQLMutationEngine } from "./mutation-engine/index.js";
13+
import {
14+
createGraphQLMutationEngine,
15+
type GraphQLMutationEngine,
16+
} from "./mutation-engine/index.js";
1417
import { GraphQLTypeRegistry } from "./registry.js";
1518

1619
class GraphQLSchemaEmitter {
@@ -64,19 +67,35 @@ class GraphQLSchemaEmitter {
6467
this.registry.addEnum(mutation.mutatedType);
6568
},
6669
model: (node: Model) => {
67-
// Mutate the model (applies name sanitization)
68-
const mutation = this.engine!.mutateModel(node);
69-
this.registry.addModel(mutation.mutatedType);
70+
// Mutate the model - returns input/output variants
71+
const result = this.engine!.mutateModel(node);
72+
73+
// Register output variant if present
74+
if (result.output) {
75+
this.registry.addModel(result.output.mutatedType);
76+
}
77+
78+
// Register input variant if present
79+
if (result.input) {
80+
this.registry.addModel(result.input.mutatedType);
81+
}
7082
},
7183
exitEnum: (node: Enum) => {
7284
// Use mutated name for materialization
7385
const mutation = this.engine!.mutateEnum(node);
7486
this.registry.materializeEnum(mutation.mutatedType.name);
7587
},
7688
exitModel: (node: Model) => {
77-
// Use mutated name for materialization
78-
const mutation = this.engine!.mutateModel(node);
79-
this.registry.materializeModel(mutation.mutatedType.name);
89+
// Materialize both input and output variants
90+
const result = this.engine!.mutateModel(node);
91+
92+
if (result.output) {
93+
this.registry.materializeModel(result.output.mutatedType.name);
94+
}
95+
96+
if (result.input) {
97+
this.registry.materializeModel(result.input.mutatedType.name);
98+
}
8099
},
81100
};
82101
}

packages/graphql/src/type-maps/model.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
11
import type { Model } from "@typespec/compiler";
22
import {
3+
GraphQLInputObjectType,
34
GraphQLObjectType,
45
GraphQLString,
56
type GraphQLFieldConfigMap,
7+
type GraphQLInputFieldConfigMap,
8+
type GraphQLInputType,
9+
type GraphQLNamedType,
610
type GraphQLOutputType,
711
} from "graphql";
812
import { TypeMap, type TSPContext, type TypeKey } from "../type-maps.js";
913

1014
/**
11-
* TypeMap for converting TypeSpec Models to GraphQL ObjectTypes.
15+
* TypeMap for converting TypeSpec Models to GraphQL ObjectTypes or InputObjectTypes.
1216
*
1317
* Handles registration of TSP models and lazy materialization into
14-
* GraphQLObjectType instances.
18+
* GraphQLObjectType (for output) or GraphQLInputObjectType (for input).
19+
* Input types are identified by names ending in "Input" (added by GraphQLModelMutation).
1520
*/
16-
export class ModelTypeMap extends TypeMap<Model, GraphQLObjectType> {
21+
export class ModelTypeMap extends TypeMap<Model, GraphQLNamedType> {
1722
/**
1823
* Derives the type key from the context.
1924
* Uses graphqlName override if provided, otherwise uses the model's name.
@@ -23,18 +28,31 @@ export class ModelTypeMap extends TypeMap<Model, GraphQLObjectType> {
2328
}
2429

2530
/**
26-
* Materializes a TypeSpec Model into a GraphQL ObjectType.
31+
* Materializes a TypeSpec Model into a GraphQL ObjectType or InputObjectType.
2732
*/
28-
protected materialize(context: TSPContext<Model>): GraphQLObjectType {
33+
protected materialize(context: TSPContext<Model>): GraphQLNamedType {
2934
const tspModel = context.type;
3035
const name = context.graphqlName ?? tspModel.name;
3136

37+
// Determine if this is an input type based on name suffix
38+
const isInput = name.endsWith("Input");
39+
40+
if (isInput) {
41+
return this.materializeInputType(tspModel, name);
42+
} else {
43+
return this.materializeOutputType(tspModel, name);
44+
}
45+
}
46+
47+
/**
48+
* Materialize as a GraphQLObjectType (output type).
49+
*/
50+
private materializeOutputType(tspModel: Model, name: string): GraphQLObjectType {
3251
const fields: GraphQLFieldConfigMap<unknown, unknown> = {};
3352

3453
for (const [propName, prop] of tspModel.properties) {
3554
fields[propName] = {
36-
type: this.mapPropertyType(prop.type),
37-
// TODO: Add description from doc comments
55+
type: this.mapOutputType(prop.type),
3856
};
3957
}
4058

@@ -44,11 +62,38 @@ export class ModelTypeMap extends TypeMap<Model, GraphQLObjectType> {
4462
});
4563
}
4664

65+
/**
66+
* Materialize as a GraphQLInputObjectType (input type).
67+
*/
68+
private materializeInputType(tspModel: Model, name: string): GraphQLInputObjectType {
69+
const fields: GraphQLInputFieldConfigMap = {};
70+
71+
for (const [propName, prop] of tspModel.properties) {
72+
fields[propName] = {
73+
type: this.mapInputType(prop.type),
74+
};
75+
}
76+
77+
return new GraphQLInputObjectType({
78+
name,
79+
fields,
80+
});
81+
}
82+
4783
/**
4884
* Map a TypeSpec property type to a GraphQL output type.
4985
* TODO: Implement full type mapping with references to other registered types.
5086
*/
51-
private mapPropertyType(_type: unknown): GraphQLOutputType {
87+
private mapOutputType(_type: unknown): GraphQLOutputType {
88+
// Placeholder - will need to resolve references to other types
89+
return GraphQLString;
90+
}
91+
92+
/**
93+
* Map a TypeSpec property type to a GraphQL input type.
94+
* TODO: Implement full type mapping with references to other registered types.
95+
*/
96+
private mapInputType(_type: unknown): GraphQLInputType {
5297
// Placeholder - will need to resolve references to other types
5398
return GraphQLString;
5499
}

0 commit comments

Comments
 (0)