-
Notifications
You must be signed in to change notification settings - Fork 1
Add GraphQL mutation engine for TSP type transformations #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: fionabronwen/type-utils
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; |
| 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 { | ||
| /** | ||
| * 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); | ||
| } | ||
| 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"; |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm taking this approach so that we can hook into There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That makes sense; what I'm asking is why do the other types (models, operations, etc) not need this / are doing it differently. |
||
| } | ||
|
|
||
| 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(); | ||
| } | ||
| } | ||
| 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(); | ||
| } | ||
| } |
| 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(); | ||
| } | ||
| } |
| 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(); | ||
| } | ||
| } |
| 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(); | ||
| } | ||
| } |
There was a problem hiding this comment.
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
MutationEngineshould capture the mutations for one type of transformation; i.e., we should have a specificGraphQLNamingMutationEngine, and other engines that capture the various different transformations we want to run, rather than a global "GraphQL" engine.I'm using the
HttpCanonicalizerengine as the reference point here — it implements a very specific type of transformation rather than "HTTP" transformations broadly.It also seems that
SimpleMutationEnginemight be sufficient for what we need here.There was a problem hiding this comment.
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:
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!
There was a problem hiding this comment.
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.