Skip to content

Commit 9f878e2

Browse files
committed
feat(graphql): add GraphQL mutation engine with name sanitization
1 parent d96eb30 commit 9f878e2

File tree

14 files changed

+735
-12
lines changed

14 files changed

+735
-12
lines changed

packages/graphql/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"dependencies": {
3939
"@alloy-js/core": "^0.11.0",
4040
"@alloy-js/typescript": "^0.11.0",
41+
"change-case": "^5.4.4",
4142
"graphql": "^16.9.0"
4243
},
4344
"scripts": {
@@ -56,8 +57,9 @@
5657
],
5758
"peerDependencies": {
5859
"@typespec/compiler": "workspace:~",
60+
"@typespec/emitter-framework": "^0.5.0",
5961
"@typespec/http": "workspace:~",
60-
"@typespec/emitter-framework": "^0.5.0"
62+
"@typespec/mutator-framework": "workspace:~"
6163
},
6264
"devDependencies": {
6365
"@types/node": "~22.13.13",

packages/graphql/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export { $onEmit } from "./emitter.js";
22
export { $lib } from "./lib.js";
33
export { $decorators } from "./tsp-index.js";
4+
5+
export { createGraphQLMutationEngine } from "./mutation-engine/index.js";

packages/graphql/src/lib/type-utils.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,8 @@ import {
2525
type UnionStatementNode,
2626
} from "@typespec/compiler/ast";
2727
import { camelCase, constantCase, pascalCase, split, splitSeparateNumbers } from "change-case";
28-
import { GraphQLScalarType } from "graphql";
29-
30-
export const ANY_SCALAR = new GraphQLScalarType({
31-
name: "Any",
32-
});
3328

29+
/** Generate a GraphQL type name for a templated model (e.g., `ListOfString`). */
3430
export function getTemplatedModelName(model: Model): string {
3531
const name = getTypeName(model, {});
3632
const baseName = toTypeName(name.replace(/<[^>]*>/g, ""));
@@ -56,12 +52,14 @@ function splitWithAcronyms(
5652
});
5753
}
5854

55+
/** Convert a name to PascalCase for GraphQL type names. */
5956
export function toTypeName(name: string): string {
6057
return pascalCase(sanitizeNameForGraphQL(getNameWithoutNamespace(name)), {
6158
split: splitWithAcronyms.bind(null, split, false),
6259
});
6360
}
6461

62+
/** Sanitize a name to be a valid GraphQL identifier. */
6563
export function sanitizeNameForGraphQL(name: string, prefix: string = ""): string {
6664
name = name.replace("[]", "Array");
6765
name = name.replaceAll(/\W/g, "_");
@@ -71,13 +69,15 @@ export function sanitizeNameForGraphQL(name: string, prefix: string = ""): strin
7169
return name;
7270
}
7371

72+
/** Convert a name to CONSTANT_CASE for GraphQL enum members. */
7473
export function toEnumMemberName(enumName: string, name: string) {
7574
return constantCase(sanitizeNameForGraphQL(name, enumName), {
7675
split: splitSeparateNumbers,
7776
prefixCharacters: "_",
7877
});
7978
}
8079

80+
/** Convert a name to camelCase for GraphQL field names. */
8181
export function toFieldName(name: string): string {
8282
return camelCase(sanitizeNameForGraphQL(name), {
8383
prefixCharacters: "_",
@@ -90,6 +90,7 @@ function getNameWithoutNamespace(name: string): string {
9090
return parts[parts.length - 1];
9191
}
9292

93+
/** Generate a GraphQL type name for a union, including anonymous unions. */
9394
export function getUnionName(union: Union, program: Program): string {
9495
// SyntaxKind.UnionExpression: Foo | Bar
9596
// SyntaxKind.UnionStatement: union FooBarUnion { Foo, Bar }
@@ -169,33 +170,38 @@ function getUnionNameForOperation(program: Program, union: Union): string {
169170
return toTypeName(getTypeName(operation));
170171
}
171172

173+
/** Convert a namespaced name to a single name by replacing dots with underscores. */
172174
export function getSingleNameWithNamespace(name: string): string {
173175
return name.trim().replace(/\./g, "_");
174176
}
175177

176-
// TODO: To replace this with the type-utils isArrayModelType function
178+
/**
179+
* Check if a model is an array type.
180+
*/
177181
export function isArray(model: Model): model is ArrayModelType {
178182
return Boolean(model.indexer && model.indexer.key.name === "integer");
179183
}
180184

181-
// TODO: To replace this with the type-utils isRecordModelType function
182-
// The type-utils function takes an used program as an argument
183-
// and this function is used in the selector which does not have access to
184-
// the program
185+
/**
186+
* Check if a model is a record/map type.
187+
*/
185188
export function isRecordType(type: Model): type is RecordModelType {
186189
return Boolean(type.indexer && type.indexer.key.name === "string");
187190
}
188191

192+
/** Check if a model is an array of scalars or enums. */
189193
export function isScalarOrEnumArray(type: Model): type is ArrayModelType {
190194
return (
191195
isArray(type) && (type.indexer?.value.kind === "Scalar" || type.indexer?.value.kind === "Enum")
192196
);
193197
}
194198

199+
/** Check if a model is an array of unions. */
195200
export function isUnionArray(type: Model): type is ArrayModelType {
196201
return isArray(type) && type.indexer?.value.kind === "Union";
197202
}
198203

204+
/** Extract the element type from an array model, or return the model itself. */
199205
export function unwrapModel(model: ArrayModelType): Model | Scalar | Enum | Union;
200206
export function unwrapModel(model: Exclude<Model, ArrayModelType>): Model;
201207
export function unwrapModel(model: Model): Model | Scalar | Enum | Union {
@@ -212,6 +218,7 @@ export function unwrapModel(model: Model): Model | Scalar | Enum | Union {
212218
return model;
213219
}
214220

221+
/** Unwrap array types to get the inner element type. */
215222
export function unwrapType(type: Model): Model | Scalar | Enum | Union;
216223
export function unwrapType(type: Type): Type;
217224
export function unwrapType(type: Type): Type {
@@ -221,6 +228,7 @@ export function unwrapType(type: Type): Type {
221228
return type;
222229
}
223230

231+
/** Get the GraphQL description for a type from its doc comments. */
224232
export function getGraphQLDoc(program: Program, type: Type): string | undefined {
225233
// GraphQL uses CommonMark for descriptions
226234
// https://spec.graphql.org/October2021/#sec-Descriptions
@@ -239,11 +247,12 @@ ${getTypeName(type)}
239247

240248
if (doc) {
241249
doc = doc.trim();
242-
doc.replaceAll("\\n", "\n");
250+
doc = doc.replaceAll("\\n", "\n");
243251
}
244252
return doc;
245253
}
246254

255+
/** Generate a string representation of template arguments (e.g., `StringAndInt`). */
247256
export function getTemplateString(
248257
type: Type,
249258
options: { conjunction: string; prefix: string } = { conjunction: "And", prefix: "" },
@@ -264,6 +273,7 @@ function getTemplateStringInternal(
264273
: "";
265274
}
266275

276+
/** Check if a model should be emitted as a GraphQL object type (not an array, record, or never). */
267277
export function isTrueModel(model: Model): boolean {
268278
/* eslint-disable no-fallthrough */
269279
switch (true) {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {
2+
type Enum,
3+
type Model,
4+
type Namespace,
5+
type Operation,
6+
type Program,
7+
type Scalar,
8+
} from "@typespec/compiler";
9+
import { $ } from "@typespec/compiler/typekit";
10+
import {
11+
MutationEngine,
12+
SimpleInterfaceMutation,
13+
SimpleIntrinsicMutation,
14+
SimpleLiteralMutation,
15+
SimpleUnionMutation,
16+
SimpleUnionVariantMutation,
17+
} from "@typespec/mutator-framework";
18+
import {
19+
GraphQLEnumMemberMutation,
20+
GraphQLEnumMutation,
21+
GraphQLModelMutation,
22+
GraphQLModelPropertyMutation,
23+
GraphQLOperationMutation,
24+
GraphQLScalarMutation,
25+
} from "./mutations/index.js";
26+
import { GraphQLMutationOptions } from "./options.js";
27+
28+
/**
29+
* Registry configuration for the GraphQL mutation engine.
30+
* Maps TypeSpec type kinds to their corresponding GraphQL mutation classes.
31+
*/
32+
const graphqlMutationRegistry = {
33+
// Custom GraphQL mutations for types we need to transform
34+
Enum: GraphQLEnumMutation,
35+
EnumMember: GraphQLEnumMemberMutation,
36+
Model: GraphQLModelMutation,
37+
ModelProperty: GraphQLModelPropertyMutation,
38+
Operation: GraphQLOperationMutation,
39+
Scalar: GraphQLScalarMutation,
40+
// Use Simple* classes from mutator-framework for types we don't customize
41+
Interface: SimpleInterfaceMutation,
42+
Union: SimpleUnionMutation,
43+
UnionVariant: SimpleUnionVariantMutation,
44+
String: SimpleLiteralMutation,
45+
Number: SimpleLiteralMutation,
46+
Boolean: SimpleLiteralMutation,
47+
Intrinsic: SimpleIntrinsicMutation,
48+
};
49+
50+
/**
51+
* GraphQL mutation engine that applies GraphQL-specific transformations
52+
* to TypeSpec types, such as name sanitization.
53+
*/
54+
export class GraphQLMutationEngine {
55+
/**
56+
* The underlying mutation engine configured with GraphQL-specific mutation classes.
57+
* Type is inferred from graphqlMutationRegistry to avoid complex generic constraints.
58+
*/
59+
private engine;
60+
61+
constructor(program: Program, _namespace: Namespace) {
62+
const tk = $(program);
63+
this.engine = new MutationEngine(tk, graphqlMutationRegistry);
64+
}
65+
66+
/**
67+
* Mutate a model, applying GraphQL name sanitization.
68+
*/
69+
mutateModel(model: Model): GraphQLModelMutation {
70+
const mutation = this.engine.mutate(model, new GraphQLMutationOptions());
71+
// Cast needed: engine returns base Mutation type, but registry ensures GraphQLModelMutation
72+
return mutation as unknown as GraphQLModelMutation;
73+
}
74+
75+
/**
76+
* Mutate an enum, applying GraphQL name sanitization.
77+
*/
78+
mutateEnum(enumType: Enum): GraphQLEnumMutation {
79+
const mutation = this.engine.mutate(enumType, new GraphQLMutationOptions());
80+
// Cast needed: engine returns base Mutation type, but registry ensures GraphQLEnumMutation
81+
return mutation as unknown as GraphQLEnumMutation;
82+
}
83+
84+
/**
85+
* Mutate an operation, applying GraphQL name sanitization.
86+
*/
87+
mutateOperation(operation: Operation): GraphQLOperationMutation {
88+
const mutation = this.engine.mutate(operation, new GraphQLMutationOptions());
89+
// Cast needed: engine returns base Mutation type, but registry ensures GraphQLOperationMutation
90+
return mutation as unknown as GraphQLOperationMutation;
91+
}
92+
93+
/**
94+
* Mutate a scalar, applying GraphQL name sanitization.
95+
*/
96+
mutateScalar(scalar: Scalar): GraphQLScalarMutation {
97+
const mutation = this.engine.mutate(scalar, new GraphQLMutationOptions());
98+
// Cast needed: engine returns base Mutation type, but registry ensures GraphQLScalarMutation
99+
return mutation as unknown as GraphQLScalarMutation;
100+
}
101+
}
102+
103+
/**
104+
* Creates a GraphQL mutation engine for the given program and namespace.
105+
*/
106+
export function createGraphQLMutationEngine(
107+
program: Program,
108+
namespace: Namespace,
109+
): GraphQLMutationEngine {
110+
return new GraphQLMutationEngine(program, namespace);
111+
}
112+
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export { GraphQLMutationEngine, createGraphQLMutationEngine } from "./engine.js";
2+
export {
3+
GraphQLEnumMemberMutation,
4+
GraphQLEnumMutation,
5+
GraphQLModelMutation,
6+
GraphQLModelPropertyMutation,
7+
GraphQLOperationMutation,
8+
GraphQLScalarMutation,
9+
} from "./mutations/index.js";
10+
export { GraphQLMutationOptions } from "./options.js";
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { EnumMember, MemberType } from "@typespec/compiler";
2+
import {
3+
EnumMemberMutation,
4+
EnumMemberMutationNode,
5+
MutationEngine,
6+
type MutationInfo,
7+
type MutationOptions,
8+
} from "@typespec/mutator-framework";
9+
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";
10+
11+
/**
12+
* GraphQL-specific EnumMember mutation.
13+
*/
14+
export class GraphQLEnumMemberMutation extends EnumMemberMutation<
15+
MutationOptions,
16+
any,
17+
MutationEngine<any>
18+
> {
19+
#mutationNode: EnumMemberMutationNode;
20+
21+
constructor(
22+
engine: MutationEngine<any>,
23+
sourceType: EnumMember,
24+
referenceTypes: MemberType[],
25+
options: MutationOptions,
26+
info: MutationInfo,
27+
) {
28+
super(engine, sourceType, referenceTypes, options, info);
29+
this.#mutationNode = this.engine.getMutationNode(this.sourceType, {
30+
mutationKey: info.mutationKey,
31+
isSynthetic: info.isSynthetic,
32+
}) as EnumMemberMutationNode;
33+
// Register rename callback BEFORE any edge connections trigger mutation.
34+
// whenMutated fires when the node is mutated (even via edge propagation),
35+
// ensuring the name is sanitized before edge callbacks read it.
36+
this.#mutationNode.whenMutated((member) => {
37+
if (member) {
38+
member.name = sanitizeNameForGraphQL(member.name);
39+
}
40+
});
41+
}
42+
43+
get mutationNode() {
44+
return this.#mutationNode;
45+
}
46+
47+
get mutatedType() {
48+
return this.#mutationNode.mutatedType;
49+
}
50+
51+
mutate() {
52+
// Trigger mutation if not already mutated (whenMutated callback will run)
53+
this.#mutationNode.mutate();
54+
super.mutate();
55+
}
56+
}

0 commit comments

Comments
 (0)