From 83f1ddcf58dadaee7c66c6084418af2905af8779 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Jan 2026 17:39:49 -0500 Subject: [PATCH] feat(graphql): add GraphQL mutation engine with name sanitization --- packages/graphql/package.json | 4 +- packages/graphql/src/index.ts | 2 + .../graphql/src/mutation-engine/engine.ts | 103 ++++++ packages/graphql/src/mutation-engine/index.ts | 10 + .../mutation-engine/mutations/enum-member.ts | 56 ++++ .../src/mutation-engine/mutations/enum.ts | 72 ++++ .../src/mutation-engine/mutations/index.ts | 7 + .../mutations/model-property.ts | 36 ++ .../src/mutation-engine/mutations/model.ts | 32 ++ .../mutation-engine/mutations/operation.ts | 30 ++ .../src/mutation-engine/mutations/scalar.ts | 30 ++ .../graphql/src/mutation-engine/options.ts | 9 + .../graphql-mutation-engine.test.ts | 315 ++++++++++++++++++ 13 files changed, 705 insertions(+), 1 deletion(-) create mode 100644 packages/graphql/src/mutation-engine/engine.ts create mode 100644 packages/graphql/src/mutation-engine/index.ts create mode 100644 packages/graphql/src/mutation-engine/mutations/enum-member.ts create mode 100644 packages/graphql/src/mutation-engine/mutations/enum.ts create mode 100644 packages/graphql/src/mutation-engine/mutations/index.ts create mode 100644 packages/graphql/src/mutation-engine/mutations/model-property.ts create mode 100644 packages/graphql/src/mutation-engine/mutations/model.ts create mode 100644 packages/graphql/src/mutation-engine/mutations/operation.ts create mode 100644 packages/graphql/src/mutation-engine/mutations/scalar.ts create mode 100644 packages/graphql/src/mutation-engine/options.ts create mode 100644 packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 4c63017ffa5..90960661c6c 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -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": { @@ -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", diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index db6fe94e5fb..1686ff2d444 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -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"; diff --git a/packages/graphql/src/mutation-engine/engine.ts b/packages/graphql/src/mutation-engine/engine.ts new file mode 100644 index 00000000000..5c29c2b5d52 --- /dev/null +++ b/packages/graphql/src/mutation-engine/engine.ts @@ -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); +} diff --git a/packages/graphql/src/mutation-engine/index.ts b/packages/graphql/src/mutation-engine/index.ts new file mode 100644 index 00000000000..3c1fe71c3bb --- /dev/null +++ b/packages/graphql/src/mutation-engine/index.ts @@ -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"; diff --git a/packages/graphql/src/mutation-engine/mutations/enum-member.ts b/packages/graphql/src/mutation-engine/mutations/enum-member.ts new file mode 100644 index 00000000000..214558bfc9c --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/enum-member.ts @@ -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 +> { + #mutationNode: EnumMemberMutationNode; + + constructor( + engine: MutationEngine, + 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); + } + }); + } + + 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(); + } +} diff --git a/packages/graphql/src/mutation-engine/mutations/enum.ts b/packages/graphql/src/mutation-engine/mutations/enum.ts new file mode 100644 index 00000000000..cb8e26e5545 --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/enum.ts @@ -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> { + #mutationNode: EnumMutationNode; + + constructor( + engine: MutationEngine, + 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 { + 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(); + } +} diff --git a/packages/graphql/src/mutation-engine/mutations/index.ts b/packages/graphql/src/mutation-engine/mutations/index.ts new file mode 100644 index 00000000000..3562d058dd5 --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/index.ts @@ -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"; + diff --git a/packages/graphql/src/mutation-engine/mutations/model-property.ts b/packages/graphql/src/mutation-engine/mutations/model-property.ts new file mode 100644 index 00000000000..4e9c821b13d --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/model-property.ts @@ -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 { + constructor( + engine: SimpleMutationEngine>, + 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(); + } +} diff --git a/packages/graphql/src/mutation-engine/mutations/model.ts b/packages/graphql/src/mutation-engine/mutations/model.ts new file mode 100644 index 00000000000..4599ba2745f --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/model.ts @@ -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 { + constructor( + engine: SimpleMutationEngine>, + 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(); + } +} diff --git a/packages/graphql/src/mutation-engine/mutations/operation.ts b/packages/graphql/src/mutation-engine/mutations/operation.ts new file mode 100644 index 00000000000..f738be2aa7e --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/operation.ts @@ -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 { + constructor( + engine: SimpleMutationEngine>, + 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(); + } +} diff --git a/packages/graphql/src/mutation-engine/mutations/scalar.ts b/packages/graphql/src/mutation-engine/mutations/scalar.ts new file mode 100644 index 00000000000..92b804d94c5 --- /dev/null +++ b/packages/graphql/src/mutation-engine/mutations/scalar.ts @@ -0,0 +1,30 @@ +import type { MemberType, Scalar } from "@typespec/compiler"; +import { + SimpleScalarMutation, + type MutationInfo, + type SimpleMutationEngine, + type SimpleMutationOptions, + type SimpleMutations, +} from "@typespec/mutator-framework"; +import { sanitizeNameForGraphQL } from "../../lib/type-utils.js"; + +/** GraphQL-specific Scalar mutation */ +export class GraphQLScalarMutation extends SimpleScalarMutation { + constructor( + engine: SimpleMutationEngine>, + sourceType: Scalar, + referenceTypes: MemberType[], + options: SimpleMutationOptions, + info: MutationInfo, + ) { + super(engine as any, sourceType, referenceTypes, options, info); + } + + mutate() { + // Apply GraphQL name sanitization via callback + this.mutationNode.mutate((scalar) => { + scalar.name = sanitizeNameForGraphQL(scalar.name); + }); + super.mutate(); + } +} diff --git a/packages/graphql/src/mutation-engine/options.ts b/packages/graphql/src/mutation-engine/options.ts new file mode 100644 index 00000000000..e7e8a130f6b --- /dev/null +++ b/packages/graphql/src/mutation-engine/options.ts @@ -0,0 +1,9 @@ +import { SimpleMutationOptions } from "@typespec/mutator-framework"; + +/** + * GraphQL-specific mutation options. + * + * Currently a simple wrapper around SimpleMutationOptions. + * Can be extended in the future to support additional GraphQL-specific options. + */ +export class GraphQLMutationOptions extends SimpleMutationOptions {} diff --git a/packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts b/packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts new file mode 100644 index 00000000000..83b0a9e39c8 --- /dev/null +++ b/packages/graphql/test/mutation-engine/graphql-mutation-engine.test.ts @@ -0,0 +1,315 @@ +import type { EnumMember } from "@typespec/compiler"; +import { t } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js"; +import { Tester } from "../test-host.js"; + +/** + * Helper to create the engine with the global namespace. + * For unit tests, we use the global namespace since individual types + * aren't placed in a custom namespace. + */ +function createTestEngine(program: Parameters[0]) { + return createGraphQLMutationEngine(program, program.getGlobalNamespaceType()); +} + +describe("GraphQL Mutation Engine - Enums", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid enum names alone", async () => { + const { ValidEnum } = await tester.compile( + t.code`enum ${t.enum("ValidEnum")} { + Value + }`, + ); + + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(ValidEnum).mutatedType; + + expect(mutated.name).toBe("ValidEnum"); + }); + + it("renames invalid enum names", async () => { + await tester.compile( + t.code`enum ${t.enum("$Invalid$")} { + Value + }`, + ); + + const InvalidEnum = tester.program.getGlobalNamespaceType().enums.get("$Invalid$")!; + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(InvalidEnum).mutatedType; + + expect(mutated.name).toBe("_Invalid_"); + }); + + it("processes enum members through sanitization", async () => { + const { MyEnum } = await tester.compile( + t.code`enum ${t.enum("MyEnum")} { + ValidMember + }`, + ); + + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(MyEnum).mutatedType; + + expect(mutated.name).toBe("MyEnum"); + expect(mutated.members.has("ValidMember")).toBe(true); + }); +}); + +describe("GraphQL Mutation Engine - Enum Members", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid enum member names alone", async () => { + const { MyEnum } = await tester.compile( + t.code`enum ${t.enum("MyEnum")} { + ${t.enumMember("ValidMember")} + }`, + ); + + // Mutate the enum and check the member via the enum's mutation + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(MyEnum).mutatedType; + const member = mutated.members.get("ValidMember"); + + expect(member?.name).toBe("ValidMember"); + }); + + it("renames invalid enum member names", async () => { + const { MyEnum } = await tester.compile( + t.code`enum ${t.enum("MyEnum")} { + \`$Value$\` + }`, + ); + + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(MyEnum).mutatedType; + + // Check that the member was renamed in the mutated enum + const member = Array.from(mutated.members.values())[0] as EnumMember; + expect(member.name).toBe("_Value_"); + }); +}); + +describe("GraphQL Mutation Engine - Models", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid model names alone", async () => { + const { ValidModel } = await tester.compile(t.code`model ${t.model("ValidModel")} { }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(ValidModel); + + expect(mutation.mutatedType.name).toBe("ValidModel"); + }); + + it("renames invalid model names", async () => { + await tester.compile(t.code`model ${t.model("$Invalid$")} { }`); + + const InvalidModel = tester.program.getGlobalNamespaceType().models.get("$Invalid$")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(InvalidModel); + + expect(mutation.mutatedType.name).toBe("_Invalid_"); + }); + + it("processes model properties through sanitization", async () => { + const { TestModel } = await tester.compile( + t.code`model ${t.model("TestModel")} { validProp: string }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(TestModel); + + expect(mutation.mutatedType.name).toBe("TestModel"); + expect(mutation.mutatedType.properties.has("validProp")).toBe(true); + }); +}); + +describe("GraphQL Mutation Engine - Model Properties", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid property names alone", async () => { + const { M } = await tester.compile( + t.code`model ${t.model("M")} { ${t.modelProperty("prop")}: string }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(M); + const prop = mutation.mutatedType.properties.get("prop"); + + expect(prop?.name).toBe("prop"); + }); + + it("renames invalid property names", async () => { + const { M } = await tester.compile(t.code`model ${t.model("M")} { \`$prop$\`: string }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(M); + + // Check that the property was renamed in the mutated model + expect(mutation.mutatedType.properties.has("_prop_")).toBe(true); + expect(mutation.mutatedType.properties.has("$prop$")).toBe(false); + }); +}); + +describe("GraphQL Mutation Engine - Operations", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid operation names alone", async () => { + const { ValidOp } = await tester.compile(t.code`op ${t.op("ValidOp")}(): void;`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(ValidOp); + + expect(mutation.mutatedType.name).toBe("ValidOp"); + }); + + it("renames invalid operation names", async () => { + await tester.compile(t.code`op ${t.op("$Do$")}(): void;`); + + const DoOp = tester.program.getGlobalNamespaceType().operations.get("$Do$")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(DoOp); + + expect(mutation.mutatedType.name).toBe("_Do_"); + }); + + it("renames operation names with hyphens", async () => { + await tester.compile(t.code`op \`get-data\`(): void;`); + + const GetDataOp = tester.program.getGlobalNamespaceType().operations.get("get-data")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateOperation(GetDataOp); + + expect(mutation.mutatedType.name).toBe("get_data"); + }); +}); + +describe("GraphQL Mutation Engine - Scalars", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("leaves valid scalar names alone", async () => { + const { ValidScalar } = await tester.compile( + t.code`scalar ${t.scalar("ValidScalar")} extends string;`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(ValidScalar); + + expect(mutation.mutatedType.name).toBe("ValidScalar"); + }); + + it("renames invalid scalar names", async () => { + await tester.compile(t.code`scalar ${t.scalar("$Invalid$")} extends string;`); + + const InvalidScalar = tester.program.getGlobalNamespaceType().scalars.get("$Invalid$")!; + const engine = createTestEngine(tester.program); + const mutation = engine.mutateScalar(InvalidScalar); + + expect(mutation.mutatedType.name).toBe("_Invalid_"); + }); +}); + +describe("GraphQL Mutation Engine - Edge Cases", () => { + let tester: Awaited>; + beforeEach(async () => { + tester = await Tester.createInstance(); + }); + + it("handles model with multiple invalid properties", async () => { + const { M } = await tester.compile( + t.code`model ${t.model("M")} { + \`$prop1$\`: string; + \`prop-2\`: int32; + \`prop.3\`: boolean; + }`, + ); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(M); + const mutated = mutation.mutatedType; + + expect(mutated.properties.has("_prop1_")).toBe(true); + expect(mutated.properties.has("prop_2")).toBe(true); + expect(mutated.properties.has("prop_3")).toBe(true); + expect(mutated.properties.has("$prop1$")).toBe(false); + expect(mutated.properties.has("prop-2")).toBe(false); + expect(mutated.properties.has("prop.3")).toBe(false); + }); + + it("handles enum with multiple invalid members", async () => { + const { E } = await tester.compile( + t.code`enum ${t.enum("E")} { + \`$val1$\`, + \`val-2\`, + \`val.3\` + }`, + ); + + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(E).mutatedType; + + expect(mutated.members.has("_val1_")).toBe(true); + expect(mutated.members.has("val_2")).toBe(true); + expect(mutated.members.has("val_3")).toBe(true); + }); + + it("preserves valid underscore-prefixed names", async () => { + const { _ValidName } = await tester.compile(t.code`model ${t.model("_ValidName")} { }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(_ValidName); + + expect(mutation.mutatedType.name).toBe("_ValidName"); + }); + + it("preserves names with numbers in the middle", async () => { + const { Model123 } = await tester.compile(t.code`model ${t.model("Model123")} { }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(Model123); + + expect(mutation.mutatedType.name).toBe("Model123"); + }); + + it("handles property names starting with numbers", async () => { + const { M } = await tester.compile(t.code`model ${t.model("M")} { \`123prop\`: string; }`); + + const engine = createTestEngine(tester.program); + const mutation = engine.mutateModel(M); + const mutated = mutation.mutatedType; + + expect(mutated.properties.has("_123prop")).toBe(true); + expect(mutated.properties.has("123prop")).toBe(false); + }); + + it("handles enum member names starting with numbers", async () => { + const { E } = await tester.compile(t.code`enum ${t.enum("E")} { \`123value\` }`); + + const engine = createTestEngine(tester.program); + const mutated = engine.mutateEnum(E).mutatedType; + + expect(mutated.members.has("_123value")).toBe(true); + expect(mutated.members.has("123value")).toBe(false); + }); +});