Skip to content

Commit 66f0f3d

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

File tree

14 files changed

+28854
-2
lines changed

14 files changed

+28854
-2
lines changed

packages/graphql/package.json

Lines changed: 4 additions & 2 deletions
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,11 +57,12 @@
5657
],
5758
"peerDependencies": {
5859
"@typespec/compiler": "workspace:~",
59-
"@typespec/http": "workspace:~",
60-
"@typespec/emitter-framework": "^0.5.0"
60+
"@typespec/emitter-framework": "^0.5.0",
61+
"@typespec/http": "workspace:~"
6162
},
6263
"devDependencies": {
6364
"@types/node": "~22.13.13",
65+
"@typespec/mutator-framework": "workspace:~",
6466
"rimraf": "~6.0.1",
6567
"source-map-support": "~0.5.21",
6668
"typescript": "~5.8.2",

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";
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+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Enum, MemberType } from "@typespec/compiler";
2+
import {
3+
EnumMemberMutationNode,
4+
EnumMutation,
5+
EnumMutationNode,
6+
MutationEngine,
7+
MutationHalfEdge,
8+
type MutationInfo,
9+
type MutationOptions,
10+
} from "@typespec/mutator-framework";
11+
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";
12+
import type { GraphQLEnumMemberMutation } from "./enum-member.js";
13+
14+
/**
15+
* GraphQL-specific Enum mutation.
16+
*/
17+
export class GraphQLEnumMutation extends EnumMutation<MutationOptions, any, MutationEngine<any>> {
18+
#mutationNode: EnumMutationNode;
19+
20+
constructor(
21+
engine: MutationEngine<any>,
22+
sourceType: Enum,
23+
referenceTypes: MemberType[],
24+
options: MutationOptions,
25+
info: MutationInfo,
26+
) {
27+
super(engine, sourceType, referenceTypes, options, info);
28+
this.#mutationNode = this.engine.getMutationNode(this.sourceType, {
29+
mutationKey: info.mutationKey,
30+
isSynthetic: info.isSynthetic,
31+
}) as EnumMutationNode;
32+
}
33+
34+
get mutationNode() {
35+
return this.#mutationNode;
36+
}
37+
38+
get mutatedType() {
39+
return this.#mutationNode.mutatedType;
40+
}
41+
42+
/**
43+
* Creates a MutationHalfEdge that wraps the node-level edge.
44+
* This ensures proper bidirectional updates when members are renamed.
45+
*/
46+
protected startMemberEdge(): MutationHalfEdge<GraphQLEnumMutation, GraphQLEnumMemberMutation> {
47+
return new MutationHalfEdge("member", this, (tail) => {
48+
this.#mutationNode.connectMember(tail.mutationNode as EnumMemberMutationNode);
49+
});
50+
}
51+
52+
/**
53+
* Override to pass half-edge for proper bidirectional updates.
54+
*/
55+
protected override mutateMembers() {
56+
for (const member of this.sourceType.members.values()) {
57+
this.members.set(
58+
member.name,
59+
this.engine.mutate(member, this.options, this.startMemberEdge()),
60+
);
61+
}
62+
}
63+
64+
mutate() {
65+
// Apply GraphQL name sanitization via callback
66+
this.#mutationNode.mutate((enumType) => {
67+
enumType.name = sanitizeNameForGraphQL(enumType.name);
68+
});
69+
// Handle member mutations with proper edges
70+
this.mutateMembers();
71+
}
72+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { GraphQLEnumMutation } from "./enum.js";
2+
export { GraphQLEnumMemberMutation } from "./enum-member.js";
3+
export { GraphQLModelMutation } from "./model.js";
4+
export { GraphQLModelPropertyMutation } from "./model-property.js";
5+
export { GraphQLOperationMutation } from "./operation.js";
6+
export { GraphQLScalarMutation } from "./scalar.js";
7+
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { MemberType, ModelProperty } from "@typespec/compiler";
2+
import {
3+
SimpleModelPropertyMutation,
4+
type MutationInfo,
5+
type SimpleMutationEngine,
6+
type SimpleMutationOptions,
7+
type SimpleMutations,
8+
} from "@typespec/mutator-framework";
9+
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";
10+
11+
/** GraphQL-specific ModelProperty mutation. */
12+
export class GraphQLModelPropertyMutation extends SimpleModelPropertyMutation<SimpleMutationOptions> {
13+
constructor(
14+
engine: SimpleMutationEngine<SimpleMutations<SimpleMutationOptions>>,
15+
sourceType: ModelProperty,
16+
referenceTypes: MemberType[],
17+
options: SimpleMutationOptions,
18+
info: MutationInfo,
19+
) {
20+
super(engine as any, sourceType, referenceTypes, options, info);
21+
// Register rename callback BEFORE any edge connections trigger mutation.
22+
// whenMutated fires when the node is mutated (even via edge propagation),
23+
// ensuring the name is sanitized before edge callbacks read it.
24+
this.mutationNode.whenMutated((property) => {
25+
if (property) {
26+
property.name = sanitizeNameForGraphQL(property.name);
27+
}
28+
});
29+
}
30+
31+
mutate() {
32+
// Trigger mutation if not already mutated (whenMutated callback will run)
33+
this.mutationNode.mutate();
34+
super.mutate();
35+
}
36+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { MemberType, Model } from "@typespec/compiler";
2+
import {
3+
SimpleModelMutation,
4+
type MutationInfo,
5+
type SimpleMutationEngine,
6+
type SimpleMutationOptions,
7+
type SimpleMutations,
8+
} from "@typespec/mutator-framework";
9+
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";
10+
11+
/**
12+
* GraphQL-specific Model mutation.
13+
*/
14+
export class GraphQLModelMutation extends SimpleModelMutation<SimpleMutationOptions> {
15+
constructor(
16+
engine: SimpleMutationEngine<SimpleMutations<SimpleMutationOptions>>,
17+
sourceType: Model,
18+
referenceTypes: MemberType[],
19+
options: SimpleMutationOptions,
20+
info: MutationInfo,
21+
) {
22+
super(engine as any, sourceType, referenceTypes, options, info);
23+
}
24+
25+
mutate() {
26+
// Apply GraphQL name sanitization
27+
this.mutationNode.mutate((model) => {
28+
model.name = sanitizeNameForGraphQL(model.name);
29+
});
30+
super.mutate();
31+
}
32+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { MemberType, Operation } from "@typespec/compiler";
2+
import {
3+
SimpleOperationMutation,
4+
type MutationInfo,
5+
type SimpleMutationEngine,
6+
type SimpleMutationOptions,
7+
type SimpleMutations,
8+
} from "@typespec/mutator-framework";
9+
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";
10+
11+
/** GraphQL-specific Operation mutation. */
12+
export class GraphQLOperationMutation extends SimpleOperationMutation<SimpleMutationOptions> {
13+
constructor(
14+
engine: SimpleMutationEngine<SimpleMutations<SimpleMutationOptions>>,
15+
sourceType: Operation,
16+
referenceTypes: MemberType[],
17+
options: SimpleMutationOptions,
18+
info: MutationInfo,
19+
) {
20+
super(engine as any, sourceType, referenceTypes, options, info);
21+
}
22+
23+
mutate() {
24+
// Apply GraphQL name sanitization via callback
25+
this.mutationNode.mutate((operation) => {
26+
operation.name = sanitizeNameForGraphQL(operation.name);
27+
});
28+
super.mutate();
29+
}
30+
}

0 commit comments

Comments
 (0)