From 2bac10b5518c0d11f3c1613adc7d835f356f1f0d Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 4 Dec 2025 12:59:29 -0500 Subject: [PATCH 1/2] Add rename-types transformer to graphql package - Add rename-types.transform.ts for GraphQL name sanitization - Add sanitizeNameForGraphQL utility function - Add transformer.ts to define graphql transformer library - Add comprehensive tests for rename-types transform - Update package.json with mutator-framework dependency --- packages/graphql/package.json | 72 ++++ packages/graphql/src/index.ts | 4 + packages/graphql/src/lib/type-utils.ts | 285 ++++++++++++++++ packages/graphql/src/transformer.ts | 13 + .../transformers/rename-types.transform.ts | 85 +++++ .../rename-types.transform.test.ts | 311 ++++++++++++++++++ packages/graphql/tsconfig.json | 11 + 7 files changed, 781 insertions(+) create mode 100644 packages/graphql/package.json create mode 100644 packages/graphql/src/index.ts create mode 100644 packages/graphql/src/lib/type-utils.ts create mode 100644 packages/graphql/src/transformer.ts create mode 100644 packages/graphql/src/transformers/rename-types.transform.ts create mode 100644 packages/graphql/test/transformers/rename-types.transform.test.ts create mode 100644 packages/graphql/tsconfig.json diff --git a/packages/graphql/package.json b/packages/graphql/package.json new file mode 100644 index 00000000000..1ddbcebbf06 --- /dev/null +++ b/packages/graphql/package.json @@ -0,0 +1,72 @@ +{ + "name": "@typespec/graphql", + "version": "0.1.0", + "author": "Microsoft Corporation", + "description": "TypeSpec library for emitting GraphQL", + "homepage": "https://typespec.io", + "readme": "https://github.com/microsoft/typespec/blob/main/README.md", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/typespec.git" + }, + "bugs": { + "url": "https://github.com/microsoft/typespec/issues" + }, + "keywords": [ + "typespec" + ], + "type": "module", + "main": "dist/src/index.js", + "exports": { + ".": { + "typespec": "./lib/main.tsp", + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + }, + "./transformer": { + "types": "./dist/src/transformers/index.d.ts", + "default": "./dist/src/transformers/index.js" + } + }, + "engines": { + "node": ">=18.0.0" + }, + "graphql": { + "documents": "test/**/*.{js,ts}" + }, + "dependencies": { + "@alloy-js/core": "^0.11.0", + "@alloy-js/typescript": "^0.11.0", + "change-case": "^5.4.4", + "graphql": "^16.9.0" + }, + "scripts": { + "clean": "rimraf ./dist ./temp", + "build": "tsc -p .", + "watch": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest -w", + "lint": "eslint src/ test/ --report-unused-disable-directives --max-warnings=0", + "lint:fix": "eslint . --report-unused-disable-directives --fix" + }, + "files": [ + "lib/*.tsp", + "dist/**", + "!dist/test/**" + ], + "peerDependencies": { + "@typespec/compiler": "workspace:~", + "@typespec/http": "workspace:~", + "@typespec/emitter-framework": "^0.5.0", + "@typespec/mutator-framework": "workspace:~" + }, + "devDependencies": { + "@types/node": "~22.13.13", + "@typespec/mutator-framework": "workspace:~", + "rimraf": "~6.0.1", + "source-map-support": "~0.5.21", + "typescript": "~5.8.2", + "vitest": "^3.0.9" + } +} diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts new file mode 100644 index 00000000000..4e18d433c82 --- /dev/null +++ b/packages/graphql/src/index.ts @@ -0,0 +1,4 @@ +export { $onEmit } from "./emitter.js"; +export { $lib } from "./lib.js"; +export { $transformer } from "./transformer.js"; +export { $decorators } from "./tsp-index.js"; diff --git a/packages/graphql/src/lib/type-utils.ts b/packages/graphql/src/lib/type-utils.ts new file mode 100644 index 00000000000..fb88ef617eb --- /dev/null +++ b/packages/graphql/src/lib/type-utils.ts @@ -0,0 +1,285 @@ +import { + type ArrayModelType, + type Enum, + getDoc, + getTypeName, + type IndeterminateEntity, + isNeverType, + isTemplateInstance, + type Model, + type Program, + type RecordModelType, + type Scalar, + type Type, + type Union, + type Value, + walkPropertiesInherited, +} from "@typespec/compiler"; +import { + type AliasStatementNode, + type IdentifierNode, + type ModelPropertyNode, + type ModelStatementNode, + type Node, + SyntaxKind, + type UnionStatementNode, +} from "@typespec/compiler/ast"; +import { camelCase, constantCase, pascalCase, split, splitSeparateNumbers } from "change-case"; +import { GraphQLScalarType } from "graphql"; + +export const ANY_SCALAR = new GraphQLScalarType({ + name: "Any", +}); + +export function getTemplatedModelName(model: Model): string { + const name = getTypeName(model, {}); + const baseName = toTypeName(name.replace(/<[^>]*>/g, "")); + const templateString = getTemplateString(model); + return templateString ? `${baseName}Of${templateString}` : baseName; +} + +function splitWithAcronyms( + split: (name: string) => string[], + skipStart: boolean, + name: string, +): string[] { + const result = split(name); + + if (name === name.toUpperCase()) { + return result; + } + // Preserve strings of capital letters, e.g. "API" should be treated as three words ["A", "P", "I"] instead of one word + return result.flatMap((part) => { + const result = !skipStart && part.match(/^[A-Z]+$/) ? part.split("") : part; + skipStart = false; + return result; + }); +} + +export function toTypeName(name: string): string { + return pascalCase(sanitizeNameForGraphQL(getNameWithoutNamespace(name)), { + split: splitWithAcronyms.bind(null, split, false), + }); +} + +export function sanitizeNameForGraphQL(name: string, prefix: string = ""): string { + name = name.replace("[]", "Array"); + name = name.replaceAll(/\W/g, "_"); + if (!name.match("^[_a-zA-Z]")) { + name = `${prefix}_${name}`; + } + return name; +} + +export function toEnumMemberName(enumName: string, name: string) { + return constantCase(sanitizeNameForGraphQL(name, enumName), { + split: splitSeparateNumbers, + prefixCharacters: "_", + }); +} + +export function toFieldName(name: string): string { + return camelCase(sanitizeNameForGraphQL(name), { + prefixCharacters: "_", + split: splitWithAcronyms.bind(null, split, true), + }); +} + +function getNameWithoutNamespace(name: string): string { + const parts = name.trim().split("."); + return parts[parts.length - 1]; +} + +export function getUnionName(union: Union, program: Program): string { + // SyntaxKind.UnionExpression: Foo | Bar + // SyntaxKind.UnionStatement: union FooBarUnion { Foo, Bar } + // SyntaxKind.TypeReference: FooBarUnion + + const templateString = getTemplateString(union) ? "Of" + getTemplateString(union) : ""; + + switch (true) { + case !!union.name: + // The union is not anonymous, use its name + return union.name; + + case isReturnType(union): + // The union is a return type, use the name of the operation + // e.g. op getBaz(): Foo | Bar => GetBazUnion + return `${getUnionNameForOperation(program, union)}${templateString}Union`; + + case isModelProperty(union): + // The union is a model property, name it based on the model + property + // e.g. model Foo { bar: Bar | Baz } => FooBarUnion + const modelProperty = getModelProperty(union); + const propName = toTypeName(getNameForNode(modelProperty!)); + const unionModel = union.node?.parent?.parent as ModelStatementNode; + const modelName = unionModel ? getNameForNode(unionModel) : ""; + return `${modelName}${propName}${templateString}Union`; + + case isAliased(union): + // The union is an alias, name it based on the alias name + // e.g. alias Baz = Foo | Bar => Baz + const alias = getAlias(union); + const aliasName = getNameForNode(alias!); + return `${aliasName}${templateString}`; + + default: + throw new Error("Unrecognized union construction."); + } +} + +function isNamedType(type: Type | Value | IndeterminateEntity): type is { name: string } & Type { + return "name" in type && typeof (type as { name: unknown }).name === "string"; +} + +function isAliased(union: Union): boolean { + return union.node?.parent?.kind === SyntaxKind.AliasStatement; +} + +function getAlias(union: Union): AliasStatementNode | undefined { + return isAliased(union) ? (union.node?.parent as AliasStatementNode) : undefined; +} + +function isModelProperty(union: Union): boolean { + return union.node?.parent?.kind === SyntaxKind.ModelProperty; +} + +function getModelProperty(union: Union): ModelPropertyNode | undefined { + return isModelProperty(union) ? (union.node?.parent as ModelPropertyNode) : undefined; +} + +function isReturnType(type: Type): boolean { + return !!( + type.node && + type.node.parent?.kind === SyntaxKind.OperationSignatureDeclaration && + type.node.parent?.parent?.kind === SyntaxKind.OperationStatement + ); +} + +type NamedNode = Node & { id: IdentifierNode }; + +function getNameForNode(node: NamedNode): string { + return "id" in node && node.id?.kind === SyntaxKind.Identifier ? node.id.sv : ""; +} + +function getUnionNameForOperation(program: Program, union: Union): string { + const operationNode = (union.node as UnionStatementNode).parent?.parent; + const operation = program.checker.getTypeForNode(operationNode!); + + return toTypeName(getTypeName(operation)); +} + +export function getSingleNameWithNamespace(name: string): string { + return name.trim().replace(/\./g, "_"); +} + +// TODO: To replace this with the type-utils isArrayModelType function +export function isArray(model: Model): model is ArrayModelType { + return Boolean(model.indexer && model.indexer.key.name === "integer"); +} + +// TODO: To replace this with the type-utils isRecordModelType function +// The type-utils function takes an used program as an argument +// and this function is used in the selector which does not have access to +// the program +export function isRecordType(type: Model): type is RecordModelType { + return Boolean(type.indexer && type.indexer.key.name === "string"); +} + +export function isScalarOrEnumArray(type: Model): type is ArrayModelType { + return ( + isArray(type) && (type.indexer?.value.kind === "Scalar" || type.indexer?.value.kind === "Enum") + ); +} + +export function isUnionArray(type: Model): type is ArrayModelType { + return isArray(type) && type.indexer?.value.kind === "Union"; +} + +export function unwrapModel(model: ArrayModelType): Model | Scalar | Enum | Union; +export function unwrapModel(model: Exclude): Model; +export function unwrapModel(model: Model): Model | Scalar | Enum | Union { + if (!isArray(model)) { + return model; + } + + if (model.indexer?.value.kind) { + if (["Model", "Scalar", "Enum", "Union"].includes(model.indexer.value.kind)) { + return model.indexer.value as Model | Scalar | Enum | Union; + } + throw new Error(`Unexpected array type: ${model.indexer.value.kind}`); + } + return model; +} + +export function unwrapType(type: Model): Model | Scalar | Enum | Union; +export function unwrapType(type: Type): Type; +export function unwrapType(type: Type): Type { + if (type.kind === "Model") { + return unwrapModel(type); + } + return type; +} + +export function getGraphQLDoc(program: Program, type: Type): string | undefined { + // GraphQL uses CommonMark for descriptions + // https://spec.graphql.org/October2021/#sec-Descriptions + let doc = getDoc(program, type); + if (!program.compilerOptions.miscOptions?.isTest) { + doc = + (doc || "") + + ` + +Created from ${type.kind} +\`\`\` +${getTypeName(type)} +\`\`\` + `; + } + + if (doc) { + doc = doc.trim(); + doc.replaceAll("\\n", "\n"); + } + return doc; +} + +export function getTemplateString( + type: Type, + options: { conjunction: string; prefix: string } = { conjunction: "And", prefix: "" }, +): string { + if (isTemplateInstance(type)) { + const args = type.templateMapper.args.filter(isNamedType).map((arg) => getTypeName(arg)); + return getTemplateStringInternal(args, options); + } + return ""; +} + +function getTemplateStringInternal( + args: string[], + options: { conjunction: string; prefix: string } = { conjunction: "And", prefix: "" }, +): string { + return args.length > 0 + ? options.prefix + toTypeName(args.map(toTypeName).join(options.conjunction)) + : ""; +} + +export function isTrueModel(model: Model): boolean { + /* eslint-disable no-fallthrough */ + switch (true) { + // A scalar array is represented as a model with an indexer + // and a scalar type. We don't want to emit this as a model. + case isScalarOrEnumArray(model): + // A union array is represented as a model with an indexer + // and a union type. We don't want to emit this as a model. + case isUnionArray(model): + case isNeverType(model): + // If the model is purely a record, we don't want to emit it as a model. + // Instead, we will need to create a scalar + case isRecordType(model) && [...walkPropertiesInherited(model)].length === 0: + return false; + default: + return true; + } + /* eslint-enable no-fallthrough */ +} diff --git a/packages/graphql/src/transformer.ts b/packages/graphql/src/transformer.ts new file mode 100644 index 00000000000..ab44ea07223 --- /dev/null +++ b/packages/graphql/src/transformer.ts @@ -0,0 +1,13 @@ +import { defineTransformer } from "@typespec/compiler"; +import { renameTypesTransform } from "./transformers/rename-types.transform.js"; + +export const $transformer = defineTransformer({ + transforms: [renameTypesTransform], + transformSets: { + graphql_naming: { + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }, + }, +}); diff --git a/packages/graphql/src/transformers/rename-types.transform.ts b/packages/graphql/src/transformers/rename-types.transform.ts new file mode 100644 index 00000000000..29ed795491f --- /dev/null +++ b/packages/graphql/src/transformers/rename-types.transform.ts @@ -0,0 +1,85 @@ +import type { Program } from "@typespec/compiler"; +import { createTransform } from "@typespec/compiler"; +import { $ } from "@typespec/compiler/typekit"; +import { + EnumMemberMutation, + EnumMutation, + ModelMutation, + ModelPropertyMutation, + OperationMutation, + ScalarMutation, + SimpleMutationEngine, +} from "@typespec/mutator-framework"; +import { sanitizeNameForGraphQL } from "../lib/type-utils.js"; + +// Custom mutation classes for renaming types to valid GraphQL names + +class RenameEnumMutation extends EnumMutation { + mutate() { + this.mutateType((enumType) => { + enumType.name = sanitizeNameForGraphQL(enumType.name); + }); + super.mutate(); + } +} + +class RenameEnumMemberMutation extends EnumMemberMutation { + mutate() { + this.mutateType((member) => { + member.name = sanitizeNameForGraphQL(member.name); + }); + super.mutate(); + } +} + +class RenameModelMutation extends ModelMutation { + mutate() { + this.mutateType((model) => { + model.name = sanitizeNameForGraphQL(model.name); + }); + super.mutate(); + } +} + +class RenameModelPropertyMutation extends ModelPropertyMutation { + mutate() { + this.mutateType((property) => { + property.name = sanitizeNameForGraphQL(property.name); + }); + super.mutate(); + } +} + +class RenameOperationMutation extends OperationMutation { + mutate() { + this.mutateType((operation) => { + operation.name = sanitizeNameForGraphQL(operation.name); + }); + super.mutate(); + } +} + +class RenameScalarMutation extends ScalarMutation { + mutate() { + this.mutateType((scalar) => { + scalar.name = sanitizeNameForGraphQL(scalar.name); + }); + super.mutate(); + } +} + +export const renameTypesTransform = createTransform({ + name: "rename-types", + description: "Rename types to be valid GraphQL names.", + createEngine: (program: Program) => { + const tk = $(program); + return new SimpleMutationEngine(tk, { + Enum: RenameEnumMutation, + EnumMember: RenameEnumMemberMutation, + Model: RenameModelMutation, + ModelProperty: RenameModelPropertyMutation, + Operation: RenameOperationMutation, + Scalar: RenameScalarMutation, + }); + }, +}); diff --git a/packages/graphql/test/transformers/rename-types.transform.test.ts b/packages/graphql/test/transformers/rename-types.transform.test.ts new file mode 100644 index 00000000000..7fb906d1fc3 --- /dev/null +++ b/packages/graphql/test/transformers/rename-types.transform.test.ts @@ -0,0 +1,311 @@ +import { t, type TransformerTesterInstance } from "@typespec/compiler/testing"; +import { beforeEach, describe, expect, it } from "vitest"; +import { sanitizeNameForGraphQL } from "../../src/lib/type-utils.js"; +import { renameTypesTransform } from "../../src/transformers/rename-types.transform.js"; +import { Tester } from "../test-host.js"; + +// Unit tests for the sanitization function +describe("sanitizeNameForGraphQL", () => { + it("replaces special characters with underscores", () => { + expect(sanitizeNameForGraphQL("$Money$")).toBe("_Money_"); + expect(sanitizeNameForGraphQL("My-Name")).toBe("My_Name"); + expect(sanitizeNameForGraphQL("Hello.World")).toBe("Hello_World"); + }); + + it("replaces [] with Array", () => { + expect(sanitizeNameForGraphQL("Item[]")).toBe("ItemArray"); + }); + + it("leaves valid names unchanged", () => { + expect(sanitizeNameForGraphQL("ValidName")).toBe("ValidName"); + expect(sanitizeNameForGraphQL("_underscore")).toBe("_underscore"); + expect(sanitizeNameForGraphQL("name123")).toBe("name123"); + }); + + it("adds prefix for names starting with numbers", () => { + expect(sanitizeNameForGraphQL("123Name")).toBe("_123Name"); + expect(sanitizeNameForGraphQL("1")).toBe("_1"); + }); + + it("handles multiple special characters", () => { + expect(sanitizeNameForGraphQL("$My-Special.Name$")).toBe("_My_Special_Name_"); + }); + + it("handles empty prefix parameter", () => { + expect(sanitizeNameForGraphQL("123Name", "")).toBe("_123Name"); + }); + + it("uses custom prefix for invalid starting character", () => { + expect(sanitizeNameForGraphQL("123Name", "Num")).toBe("Num_123Name"); + }); +}); + +// Integration tests verifying the transformer applies sanitization +// +// NOTE: The mutator framework is designed for renaming CHILD elements (properties, +// members, operations) within their parent containers. Parent type renaming (e.g., +// renaming a Model or Enum's own name) only works when that type is directly marked +// as an entry point - it does NOT propagate through property type references. + +describe("Rename enums transform", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).createInstance(); + }); + + it("leaves valid enum names alone", async () => { + const { ValidEnum } = await tester.compile( + t.code`enum ${t.enum("ValidEnum")} { + Value + }`, + ); + + expect(ValidEnum.name).toBe("ValidEnum"); + }); + + it("processes enum members through sanitization", async () => { + const { MyEnum } = await tester.compile( + t.code`enum ${t.enum("MyEnum")} { + ValidMember + }`, + ); + + // Verify the enum is properly extracted and its members are accessible + expect(MyEnum.name).toBe("MyEnum"); + expect(MyEnum.members.has("ValidMember")).toBe(true); + }); +}); + +describe("Rename enum members transform", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).createInstance(); + }); + + it("leaves valid enum member names alone", async () => { + const { ValidMember } = await tester.compile( + t.code`enum MyEnum { + ${t.enumMember("ValidMember")} + }`, + ); + + expect(ValidMember.name).toBe("ValidMember"); + }); + + it("renames invalid enum member names", async () => { + // Extract the enum (parent) and check its members collection has the renamed key + const { MyEnum } = await tester.compile( + t.code`enum ${t.enum("MyEnum")} { + \`$Value$\` + }`, + ); + + expect(MyEnum.members.has("_Value_")).toBe(true); + expect(MyEnum.members.has("$Value$")).toBe(false); + }); +}); + +describe("Rename models transform", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).createInstance(); + }); + + it("leaves valid model names alone", async () => { + const { ValidModel } = await tester.compile(t.code`model ${t.model("ValidModel")} { }`); + + expect(ValidModel.name).toBe("ValidModel"); + }); + + it("processes model properties through sanitization", async () => { + const { TestModel } = await tester.compile( + t.code`model ${t.model("TestModel")} { validProp: string }`, + ); + + expect(TestModel.name).toBe("TestModel"); + expect(TestModel.properties.has("validProp")).toBe(true); + }); +}); + +describe("Rename model properties transform", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).createInstance(); + }); + + it("leaves valid property names alone", async () => { + const { prop } = await tester.compile(t.code`model M { ${t.modelProperty("prop")}: string }`); + + expect(prop.name).toBe("prop"); + }); + + it("renames invalid property names", async () => { + // Extract the model (parent) and check its properties collection has the renamed key + const { M } = await tester.compile(t.code`model ${t.model("M")} { \`$prop$\`: string }`); + + expect(M.properties.has("_prop_")).toBe(true); + expect(M.properties.has("$prop$")).toBe(false); + }); +}); + +describe("Rename operations transform", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).createInstance(); + }); + + it("leaves valid operation names alone", async () => { + const { ValidOp } = await tester.compile(t.code`op ${t.op("ValidOp")}(): void;`); + + expect(ValidOp.name).toBe("ValidOp"); + }); + + it("renames invalid operation names", async () => { + // Extract the interface (parent) and check its operations collection has the renamed key + const { Iface } = await tester.compile( + t.code`interface ${t.interface("Iface")} { \`$Do$\`(): void; }`, + ); + + expect(Iface.operations.has("_Do_")).toBe(true); + expect(Iface.operations.has("$Do$")).toBe(false); + }); + + it("renames operation names with hyphens", async () => { + const { Iface } = await tester.compile( + t.code`interface ${t.interface("Iface")} { \`get-data\`(): void; }`, + ); + + expect(Iface.operations.has("get_data")).toBe(true); + expect(Iface.operations.has("get-data")).toBe(false); + }); +}); + +describe("Rename scalars transform", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).createInstance(); + }); + + it("leaves valid scalar names alone", async () => { + const { ValidScalar } = await tester.compile( + t.code`scalar ${t.scalar("ValidScalar")} extends string;`, + ); + + expect(ValidScalar.name).toBe("ValidScalar"); + }); +}); + +describe("Edge cases", () => { + let tester: TransformerTesterInstance; + beforeEach(async () => { + tester = await Tester.transformer({ + enable: { + [`@typespec/graphql/${renameTypesTransform.name}`]: true, + }, + }).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; + }`, + ); + + expect(M.properties.has("_prop1_")).toBe(true); + expect(M.properties.has("prop_2")).toBe(true); + expect(M.properties.has("prop_3")).toBe(true); + expect(M.properties.has("$prop1$")).toBe(false); + expect(M.properties.has("prop-2")).toBe(false); + expect(M.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\` + }`, + ); + + expect(E.members.has("_val1_")).toBe(true); + expect(E.members.has("val_2")).toBe(true); + expect(E.members.has("val_3")).toBe(true); + }); + + it("handles interface with multiple invalid operations", async () => { + const { Api } = await tester.compile( + t.code`interface ${t.interface("Api")} { + \`get-user\`(): void; + \`create-user\`(): void; + \`delete.user\`(): void; + }`, + ); + + expect(Api.operations.has("get_user")).toBe(true); + expect(Api.operations.has("create_user")).toBe(true); + expect(Api.operations.has("delete_user")).toBe(true); + }); + + it("preserves valid underscore-prefixed names", async () => { + const { _ValidName } = await tester.compile(t.code`model ${t.model("_ValidName")} { }`); + + expect(_ValidName.name).toBe("_ValidName"); + }); + + it("preserves names with numbers in the middle", async () => { + const { Model123 } = await tester.compile(t.code`model ${t.model("Model123")} { }`); + + expect(Model123.name).toBe("Model123"); + }); + + it("handles property names starting with numbers", async () => { + const { M } = await tester.compile(t.code`model ${t.model("M")} { \`123prop\`: string; }`); + + expect(M.properties.has("_123prop")).toBe(true); + expect(M.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\` }`); + + expect(E.members.has("_123value")).toBe(true); + expect(E.members.has("123value")).toBe(false); + }); + + it("handles operation names starting with numbers", async () => { + const { Api } = await tester.compile( + t.code`interface ${t.interface("Api")} { \`123action\`(): void; }`, + ); + + expect(Api.operations.has("_123action")).toBe(true); + expect(Api.operations.has("123action")).toBe(false); + }); +}); diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json new file mode 100644 index 00000000000..b4bcf7d0623 --- /dev/null +++ b/packages/graphql/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [{ "path": "../compiler/tsconfig.json" }], + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "tsBuildInfoFile": "temp/tsconfig.tsbuildinfo", + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} From 0f0e56e5686d2ebc15e40e3aab5cee2246c792cf Mon Sep 17 00:00:00 2001 From: Fiona Date: Fri, 5 Dec 2025 13:09:09 -0500 Subject: [PATCH 2/2] Cleanup --- packages/graphql/test/test-host.ts | 7 +++++++ .../test/transformers/rename-types.transform.test.ts | 7 ------- packages/graphql/vitest.config.ts | 5 +++++ 3 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 packages/graphql/test/test-host.ts create mode 100644 packages/graphql/vitest.config.ts diff --git a/packages/graphql/test/test-host.ts b/packages/graphql/test/test-host.ts new file mode 100644 index 00000000000..dba9574cd2c --- /dev/null +++ b/packages/graphql/test/test-host.ts @@ -0,0 +1,7 @@ +import { resolvePath } from "@typespec/compiler"; +import { createTester } from "@typespec/compiler/testing"; + +export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { + libraries: ["@typespec/graphql"], +}); + diff --git a/packages/graphql/test/transformers/rename-types.transform.test.ts b/packages/graphql/test/transformers/rename-types.transform.test.ts index 7fb906d1fc3..3cc4620e96a 100644 --- a/packages/graphql/test/transformers/rename-types.transform.test.ts +++ b/packages/graphql/test/transformers/rename-types.transform.test.ts @@ -40,13 +40,6 @@ describe("sanitizeNameForGraphQL", () => { }); }); -// Integration tests verifying the transformer applies sanitization -// -// NOTE: The mutator framework is designed for renaming CHILD elements (properties, -// members, operations) within their parent containers. Parent type renaming (e.g., -// renaming a Model or Enum's own name) only works when that type is directly marked -// as an entry point - it does NOT propagate through property type references. - describe("Rename enums transform", () => { let tester: TransformerTesterInstance; beforeEach(async () => { diff --git a/packages/graphql/vitest.config.ts b/packages/graphql/vitest.config.ts new file mode 100644 index 00000000000..6a33ca850ba --- /dev/null +++ b/packages/graphql/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { defaultTypeSpecVitestConfig } from "../../vitest.config.js"; + +export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); +