Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"dependencies": {
"@alloy-js/core": "^0.11.0",
"@alloy-js/typescript": "^0.11.0",
"change-case": "^5.4.4",
"graphql": "^16.9.0"
},
"scripts": {
Expand All @@ -56,8 +57,9 @@
],
"peerDependencies": {
"@typespec/compiler": "workspace:~",
"@typespec/emitter-framework": "workspace:~",
"@typespec/http": "workspace:~",
"@typespec/emitter-framework": "^0.5.0"
"@typespec/mutator-framework": "workspace:~"
},
"devDependencies": {
"@types/node": "~22.13.13",
Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { $onEmit } from "./emitter.js";
export { $lib } from "./lib.js";
export { $decorators } from "./tsp-index.js";

export { createGraphQLMutationEngine } from "./mutation-engine/index.js";
103 changes: 103 additions & 0 deletions packages/graphql/src/mutation-engine/engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {
type Enum,
type Model,
type Namespace,
type Operation,
type Program,
type Scalar,
} from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
import {
MutationEngine,
SimpleInterfaceMutation,
SimpleIntrinsicMutation,
SimpleLiteralMutation,
SimpleUnionMutation,
SimpleUnionVariantMutation,
} from "@typespec/mutator-framework";
import {
GraphQLEnumMemberMutation,
GraphQLEnumMutation,
GraphQLModelMutation,
GraphQLModelPropertyMutation,
GraphQLOperationMutation,
GraphQLScalarMutation,
} from "./mutations/index.js";
import { GraphQLMutationOptions } from "./options.js";

/**
* Registry configuration for the GraphQL mutation engine.
* Maps TypeSpec type kinds to their corresponding GraphQL mutation classes.
*/
const graphqlMutationRegistry = {
// Custom GraphQL mutations for types we need to transform
Enum: GraphQLEnumMutation,
EnumMember: GraphQLEnumMemberMutation,
Model: GraphQLModelMutation,
ModelProperty: GraphQLModelPropertyMutation,
Operation: GraphQLOperationMutation,
Scalar: GraphQLScalarMutation,
// Use Simple* classes from mutator-framework for types we don't customize
Interface: SimpleInterfaceMutation,
Union: SimpleUnionMutation,
UnionVariant: SimpleUnionVariantMutation,
String: SimpleLiteralMutation,
Number: SimpleLiteralMutation,
Boolean: SimpleLiteralMutation,
Intrinsic: SimpleIntrinsicMutation,
};

/**
* GraphQL mutation engine that applies GraphQL-specific transformations
* to TypeSpec types, such as name sanitization.
*/
export class GraphQLMutationEngine {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand the implementation of the mutation framework, a MutationEngine should capture the mutations for one type of transformation; i.e., we should have a specific GraphQLNamingMutationEngine, and other engines that capture the various different transformations we want to run, rather than a global "GraphQL" engine.

I'm using the HttpCanonicalizer engine as the reference point here — it implements a very specific type of transformation rather than "HTTP" transformations broadly.

It also seems that SimpleMutationEngine might be sufficient for what we need here.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discussed a bit offline, but just to share in context-- I think that the GraphQL mutations should all share an engine for a couple of reasons:

  1. We've broken the GraphQL mutations out into Individual "transformations" which is helpful for conceptualizing all of the ways the TSP schema must be altered to match GraphQL conventions. However, individual transformations aren't useful in isolation, they all need to be applied to give us the correct resulting GraphQL schema.
  2. When we make multiple transformations in the same engine we have the benefit of reusing a shared cache and performing a single traversal of the TSP graph.
  3. Using one mutation engine will simplify the consumer API when using the GraphQL Mutation Engine from within emitters.

If we find that this approach becomes unwieldy, then we could look into splitting into multiple engines. For now, I think this is the right approach! Open to discussing further if you feel very strongly!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm 👍 on moving forward and seeing how it works out. I think it will be important that we set and keep guardrails about what does and doesn't go into the "GraphQL" mutation engine — e.g. only things that are backed up by some part of the GraphQL spec.

/**
* The underlying mutation engine configured with GraphQL-specific mutation classes.
* Type is inferred from graphqlMutationRegistry to avoid complex generic constraints.
*/
private engine;

constructor(program: Program, _namespace: Namespace) {
const tk = $(program);
this.engine = new MutationEngine(tk, graphqlMutationRegistry);
}

/**
* Mutate a model, applying GraphQL name sanitization.
*/
mutateModel(model: Model): GraphQLModelMutation {
return this.engine.mutate(model, new GraphQLMutationOptions()) as GraphQLModelMutation;
}

/**
* Mutate an enum, applying GraphQL name sanitization.
*/
mutateEnum(enumType: Enum): GraphQLEnumMutation {
return this.engine.mutate(enumType, new GraphQLMutationOptions()) as GraphQLEnumMutation;
}

/**
* Mutate an operation, applying GraphQL name sanitization.
*/
mutateOperation(operation: Operation): GraphQLOperationMutation {
return this.engine.mutate(operation, new GraphQLMutationOptions()) as GraphQLOperationMutation;
}

/**
* Mutate a scalar, applying GraphQL name sanitization.
*/
mutateScalar(scalar: Scalar): GraphQLScalarMutation {
return this.engine.mutate(scalar, new GraphQLMutationOptions()) as GraphQLScalarMutation;
}
}

/**
* Creates a GraphQL mutation engine for the given program and namespace.
*/
export function createGraphQLMutationEngine(
program: Program,
namespace: Namespace,
): GraphQLMutationEngine {
return new GraphQLMutationEngine(program, namespace);
}
10 changes: 10 additions & 0 deletions packages/graphql/src/mutation-engine/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export { GraphQLMutationEngine, createGraphQLMutationEngine } from "./engine.js";
export {
GraphQLEnumMemberMutation,
GraphQLEnumMutation,
GraphQLModelMutation,
GraphQLModelPropertyMutation,
GraphQLOperationMutation,
GraphQLScalarMutation,
} from "./mutations/index.js";
export { GraphQLMutationOptions } from "./options.js";
56 changes: 56 additions & 0 deletions packages/graphql/src/mutation-engine/mutations/enum-member.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { EnumMember, MemberType } from "@typespec/compiler";
import {
EnumMemberMutation,
EnumMemberMutationNode,
MutationEngine,
type MutationInfo,
type MutationOptions,
} from "@typespec/mutator-framework";
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";

/**
* GraphQL-specific EnumMember mutation.
*/
export class GraphQLEnumMemberMutation extends EnumMemberMutation<
MutationOptions,
any,
MutationEngine<any>
> {
#mutationNode: EnumMemberMutationNode;

constructor(
engine: MutationEngine<any>,
sourceType: EnumMember,
referenceTypes: MemberType[],
options: MutationOptions,
info: MutationInfo,
) {
super(engine, sourceType, referenceTypes, options, info);
this.#mutationNode = this.engine.getMutationNode(this.sourceType, {
mutationKey: info.mutationKey,
isSynthetic: info.isSynthetic,
}) as EnumMemberMutationNode;
// Register rename callback BEFORE any edge connections trigger mutation.
// whenMutated fires when the node is mutated (even via edge propagation),
// ensuring the name is sanitized before edge callbacks read it.
this.#mutationNode.whenMutated((member) => {
if (member) {
member.name = sanitizeNameForGraphQL(member.name);
}
});
Comment on lines +36 to +40

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Help me understand why we're taking this approach for enum members and model properties, but not any of the other types.

}

get mutationNode() {
return this.#mutationNode;
}

get mutatedType() {
return this.#mutationNode.mutatedType;
}

mutate() {
// Trigger mutation if not already mutated (whenMutated callback will run)
this.#mutationNode.mutate();
super.mutate();
}
}
72 changes: 72 additions & 0 deletions packages/graphql/src/mutation-engine/mutations/enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Enum, MemberType } from "@typespec/compiler";
import {
EnumMemberMutationNode,
EnumMutation,
EnumMutationNode,
MutationEngine,
MutationHalfEdge,
type MutationInfo,
type MutationOptions,
} from "@typespec/mutator-framework";
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";
import type { GraphQLEnumMemberMutation } from "./enum-member.js";

/**
* GraphQL-specific Enum mutation.
*/
export class GraphQLEnumMutation extends EnumMutation<MutationOptions, any, MutationEngine<any>> {
#mutationNode: EnumMutationNode;

constructor(
engine: MutationEngine<any>,
sourceType: Enum,
referenceTypes: MemberType[],
options: MutationOptions,
info: MutationInfo,
) {
super(engine, sourceType, referenceTypes, options, info);
this.#mutationNode = this.engine.getMutationNode(this.sourceType, {
mutationKey: info.mutationKey,
isSynthetic: info.isSynthetic,
}) as EnumMutationNode;
}

get mutationNode() {
return this.#mutationNode;
}

get mutatedType() {
return this.#mutationNode.mutatedType;
}

/**
* Creates a MutationHalfEdge that wraps the node-level edge.
* This ensures proper bidirectional updates when members are renamed.
*/
protected startMemberEdge(): MutationHalfEdge<GraphQLEnumMutation, GraphQLEnumMemberMutation> {
return new MutationHalfEdge("member", this, (tail) => {
this.#mutationNode.connectMember(tail.mutationNode as EnumMemberMutationNode);
});
}

/**
* Override to pass half-edge for proper bidirectional updates.
*/
protected override mutateMembers() {
for (const member of this.sourceType.members.values()) {
this.members.set(
member.name,
this.engine.mutate(member, this.options, this.startMemberEdge()),
);
}
}

mutate() {
// Apply GraphQL name sanitization via callback
this.#mutationNode.mutate((enumType) => {
enumType.name = sanitizeNameForGraphQL(enumType.name);
});
// Handle member mutations with proper edges
this.mutateMembers();
}
}
7 changes: 7 additions & 0 deletions packages/graphql/src/mutation-engine/mutations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { GraphQLEnumMutation } from "./enum.js";
export { GraphQLEnumMemberMutation } from "./enum-member.js";
export { GraphQLModelMutation } from "./model.js";
export { GraphQLModelPropertyMutation } from "./model-property.js";
export { GraphQLOperationMutation } from "./operation.js";
export { GraphQLScalarMutation } from "./scalar.js";

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { MemberType, ModelProperty } from "@typespec/compiler";
import {
SimpleModelPropertyMutation,
type MutationInfo,
type SimpleMutationEngine,
type SimpleMutationOptions,
type SimpleMutations,
} from "@typespec/mutator-framework";
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";

/** GraphQL-specific ModelProperty mutation. */
export class GraphQLModelPropertyMutation extends SimpleModelPropertyMutation<SimpleMutationOptions> {
constructor(
engine: SimpleMutationEngine<SimpleMutations<SimpleMutationOptions>>,
sourceType: ModelProperty,
referenceTypes: MemberType[],
options: SimpleMutationOptions,
info: MutationInfo,
) {
super(engine as any, sourceType, referenceTypes, options, info);
// Register rename callback BEFORE any edge connections trigger mutation.
// whenMutated fires when the node is mutated (even via edge propagation),
// ensuring the name is sanitized before edge callbacks read it.
this.mutationNode.whenMutated((property) => {
if (property) {
property.name = sanitizeNameForGraphQL(property.name);
}
});
}

mutate() {
// Trigger mutation if not already mutated (whenMutated callback will run)
this.mutationNode.mutate();
super.mutate();
}
}
32 changes: 32 additions & 0 deletions packages/graphql/src/mutation-engine/mutations/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { MemberType, Model } from "@typespec/compiler";
import {
SimpleModelMutation,
type MutationInfo,
type SimpleMutationEngine,
type SimpleMutationOptions,
type SimpleMutations,
} from "@typespec/mutator-framework";
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";

/**
* GraphQL-specific Model mutation.
*/
export class GraphQLModelMutation extends SimpleModelMutation<SimpleMutationOptions> {
constructor(
engine: SimpleMutationEngine<SimpleMutations<SimpleMutationOptions>>,
sourceType: Model,
referenceTypes: MemberType[],
options: SimpleMutationOptions,
info: MutationInfo,
) {
super(engine as any, sourceType, referenceTypes, options, info);
}

mutate() {
// Apply GraphQL name sanitization
this.mutationNode.mutate((model) => {
model.name = sanitizeNameForGraphQL(model.name);
});
super.mutate();
}
}
30 changes: 30 additions & 0 deletions packages/graphql/src/mutation-engine/mutations/operation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { MemberType, Operation } from "@typespec/compiler";
import {
SimpleOperationMutation,
type MutationInfo,
type SimpleMutationEngine,
type SimpleMutationOptions,
type SimpleMutations,
} from "@typespec/mutator-framework";
import { sanitizeNameForGraphQL } from "../../lib/type-utils.js";

/** GraphQL-specific Operation mutation. */
export class GraphQLOperationMutation extends SimpleOperationMutation<SimpleMutationOptions> {
constructor(
engine: SimpleMutationEngine<SimpleMutations<SimpleMutationOptions>>,
sourceType: Operation,
referenceTypes: MemberType[],
options: SimpleMutationOptions,
info: MutationInfo,
) {
super(engine as any, sourceType, referenceTypes, options, info);
}

mutate() {
// Apply GraphQL name sanitization via callback
this.mutationNode.mutate((operation) => {
operation.name = sanitizeNameForGraphQL(operation.name);
});
super.mutate();
}
}
Loading