Skip to content

Commit 398ae95

Browse files
committed
refactor(graphql): use EnumTypeMap in registry
1 parent df743a3 commit 398ae95

4 files changed

Lines changed: 147 additions & 98 deletions

File tree

packages/graphql/src/lib/type-utils.ts

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,12 @@ function splitWithAcronyms(
5454

5555
/** Convert a name to PascalCase for GraphQL type names. */
5656
export function toTypeName(name: string): string {
57-
return pascalCase(sanitizeNameForGraphQL(getNameWithoutNamespace(name)), {
57+
const sanitized = sanitizeNameForGraphQL(getNameWithoutNamespace(name));
58+
// Preserve all-caps names (acronyms like API, HTTP, URL)
59+
if (/^[A-Z]+$/.test(sanitized)) {
60+
return sanitized;
61+
}
62+
return pascalCase(sanitized, {
5863
split: splitWithAcronyms.bind(null, split, false),
5964
});
6065
}
@@ -232,30 +237,13 @@ export function unwrapType(type: Type): Type {
232237
export function getGraphQLDoc(program: Program, type: Type): string | undefined {
233238
// GraphQL uses CommonMark for descriptions
234239
// https://spec.graphql.org/October2021/#sec-Descriptions
235-
let doc = getDoc(program, type);
236-
if (!program.compilerOptions.miscOptions?.isTest) {
237-
doc =
238-
(doc || "") +
239-
`
240-
241-
Created from ${type.kind}
242-
\`\`\`
243-
${getTypeName(type)}
244-
\`\`\`
245-
`;
246-
}
247-
248-
if (doc) {
249-
doc = doc.trim();
250-
doc = doc.replaceAll("\\n", "\n");
251-
}
252-
return doc;
240+
return getDoc(program, type);
253241
}
254242

255243
/** Generate a string representation of template arguments (e.g., `StringAndInt`). */
256244
export function getTemplateString(
257245
type: Type,
258-
options: { conjunction: string; prefix: string } = { conjunction: "And", prefix: "" },
246+
options: { conjunction: string } = { conjunction: "And" },
259247
): string {
260248
if (isTemplateInstance(type)) {
261249
const args = type.templateMapper.args.filter(isNamedType).map((arg) => getTypeName(arg));
@@ -266,11 +254,9 @@ export function getTemplateString(
266254

267255
function getTemplateStringInternal(
268256
args: string[],
269-
options: { conjunction: string; prefix: string } = { conjunction: "And", prefix: "" },
257+
options: { conjunction: string } = { conjunction: "And" },
270258
): string {
271-
return args.length > 0
272-
? options.prefix + toTypeName(args.map(toTypeName).join(options.conjunction))
273-
: "";
259+
return args.length > 0 ? toTypeName(args.map(toTypeName).join(options.conjunction)) : "";
274260
}
275261

276262
/** Check if a model should be emitted as a GraphQL object type (not an array, record, or never). */

packages/graphql/src/registry.ts

Lines changed: 21 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
1-
import { UsageFlags, type Enum, type Model } from "@typespec/compiler";
1+
import { UsageFlags, type Enum } from "@typespec/compiler";
22
import {
33
GraphQLBoolean,
44
GraphQLEnumType,
55
GraphQLObjectType,
66
type GraphQLNamedType,
77
type GraphQLSchemaConfig,
88
} from "graphql";
9-
10-
// The TSPTypeContext interface represents the intermediate TSP type information before materialization.
11-
// It stores the raw TSP type and any extracted metadata relevant for GraphQL generation.
12-
interface TSPTypeContext {
13-
tspType: Enum | Model; // Extend with other TSP types like Operation, Interface, TSP Union, etc.
14-
name: string;
15-
usageFlags?: Set<UsageFlags>;
16-
// TODO: Add any other TSP-specific metadata here.
17-
}
9+
import { type TypeKey } from "./type-maps.js";
10+
import { EnumTypeMap } from "./type-maps/index.js";
1811
/**
1912
* GraphQLTypeRegistry manages the registration and materialization of TypeSpec (TSP)
2013
* types into their corresponding GraphQL type definitions.
@@ -39,61 +32,39 @@ interface TSPTypeContext {
3932
* by using thunks for fields/arguments.
4033
*/
4134
export class GraphQLTypeRegistry {
42-
// Stores intermediate TSP type information, keyed by TSP type name.
43-
// TODO: make this more of a seen set
44-
private TSPTypeContextRegistry: Map<string, TSPTypeContext> = new Map();
35+
// TypeMap for enum types
36+
private enumTypeMap = new EnumTypeMap();
4537

46-
// Stores materialized GraphQL types, keyed by their GraphQL name.
47-
private materializedGraphQLTypes: Map<string, GraphQLNamedType> = new Map();
38+
// Track all registered names to detect cross-TypeMap name collisions
39+
private allRegisteredNames = new Set<string>();
4840

4941
addEnum(tspEnum: Enum): void {
5042
const enumName = tspEnum.name;
51-
if (this.TSPTypeContextRegistry.has(enumName)) {
52-
// Optionally, log a warning or update if new information is more complete.
43+
44+
// Check for duplicate names across all type maps
45+
if (this.allRegisteredNames.has(enumName)) {
46+
// Already registered (could be same enum or name collision)
47+
// TODO: Add a warning to the diagnostics
5348
return;
5449
}
5550

56-
this.TSPTypeContextRegistry.set(enumName, {
57-
tspType: tspEnum,
58-
name: enumName,
59-
// TODO: Populate usageFlags based on TSP context and other decorator context.
51+
this.enumTypeMap.register({
52+
type: tspEnum,
53+
usageFlag: UsageFlags.Output, // Enums are same for input/output
6054
});
55+
this.allRegisteredNames.add(enumName);
6156
}
6257

6358
// Materializes a TSP Enum into a GraphQLEnumType.
6459
materializeEnum(enumName: string): GraphQLEnumType | undefined {
65-
// Check if the GraphQL type is already materialized.
66-
if (this.materializedGraphQLTypes.has(enumName)) {
67-
return this.materializedGraphQLTypes.get(enumName) as GraphQLEnumType;
68-
}
69-
70-
const context = this.TSPTypeContextRegistry.get(enumName);
71-
if (!context || context.tspType.kind !== "Enum") {
72-
// TODO: Handle error or warning for missing context.
73-
return undefined;
74-
}
75-
76-
const tspEnum = context.tspType as Enum;
77-
78-
const gqlEnum = new GraphQLEnumType({
79-
name: context.name,
80-
values: Object.fromEntries(
81-
Array.from(tspEnum.members.values()).map((member) => [
82-
member.name,
83-
{
84-
value: member.value ?? member.name,
85-
},
86-
]),
87-
),
88-
});
89-
90-
this.materializedGraphQLTypes.set(enumName, gqlEnum);
91-
return gqlEnum;
60+
return this.enumTypeMap.get(enumName as TypeKey);
9261
}
9362

9463
materializeSchemaConfig(): GraphQLSchemaConfig {
95-
const allMaterializedGqlTypes = Array.from(this.materializedGraphQLTypes.values());
96-
let queryType = this.materializedGraphQLTypes.get("Query") as GraphQLObjectType | undefined;
64+
// Collect all materialized types from all TypeMaps
65+
const allMaterializedGqlTypes: GraphQLNamedType[] = [...this.enumTypeMap.getAllMaterialized()];
66+
// TODO: Query type will come from operations
67+
let queryType: GraphQLObjectType | undefined = undefined;
9768
if (!queryType) {
9869
queryType = new GraphQLObjectType({
9970
name: "Query",

packages/graphql/src/type-maps.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { GraphQLType } from "graphql";
66
* @template T - The TypeSpec type
77
*/
88
export interface TSPContext<T extends Type> {
9-
type: T; // The TypeSpec type
9+
type: T; // The TypeSpec type (mutations should have already been applied)
1010
usageFlag: UsageFlags; // How the type is being used (input, output, etc.)
1111
metadata?: Record<string, any>; // Optional additional metadata
1212
}
Lines changed: 115 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,129 @@
11
import { describe, expect, it } from "vitest";
2-
import { sanitizeNameForGraphQL } from "../../src/lib/type-utils.js";
2+
import {
3+
getSingleNameWithNamespace,
4+
sanitizeNameForGraphQL,
5+
toEnumMemberName,
6+
toFieldName,
7+
toTypeName,
8+
} from "../../src/lib/type-utils.js";
39

4-
describe("sanitizeNameForGraphQL", () => {
5-
it("replaces special characters with underscores", () => {
6-
expect(sanitizeNameForGraphQL("$Money$")).toBe("_Money_");
7-
expect(sanitizeNameForGraphQL("My-Name")).toBe("My_Name");
8-
expect(sanitizeNameForGraphQL("Hello.World")).toBe("Hello_World");
9-
});
10+
describe("type-utils", () => {
11+
describe("sanitizeNameForGraphQL", () => {
12+
it("replaces special characters with underscores", () => {
13+
expect(sanitizeNameForGraphQL("$Money$")).toBe("_Money_");
14+
expect(sanitizeNameForGraphQL("My-Name")).toBe("My_Name");
15+
expect(sanitizeNameForGraphQL("Hello.World")).toBe("Hello_World");
16+
});
1017

11-
it("replaces [] with Array", () => {
12-
expect(sanitizeNameForGraphQL("Item[]")).toBe("ItemArray");
13-
});
18+
it("replaces [] with Array", () => {
19+
expect(sanitizeNameForGraphQL("Item[]")).toBe("ItemArray");
20+
});
21+
22+
it("leaves valid names unchanged", () => {
23+
expect(sanitizeNameForGraphQL("ValidName")).toBe("ValidName");
24+
expect(sanitizeNameForGraphQL("_underscore")).toBe("_underscore");
25+
expect(sanitizeNameForGraphQL("name123")).toBe("name123");
26+
});
27+
28+
it("adds prefix for names starting with numbers", () => {
29+
expect(sanitizeNameForGraphQL("123Name")).toBe("_123Name");
30+
expect(sanitizeNameForGraphQL("1")).toBe("_1");
31+
});
32+
33+
it("handles multiple special characters", () => {
34+
expect(sanitizeNameForGraphQL("$My-Special.Name$")).toBe("_My_Special_Name_");
35+
});
1436

15-
it("leaves valid names unchanged", () => {
16-
expect(sanitizeNameForGraphQL("ValidName")).toBe("ValidName");
17-
expect(sanitizeNameForGraphQL("_underscore")).toBe("_underscore");
18-
expect(sanitizeNameForGraphQL("name123")).toBe("name123");
37+
it("handles empty prefix parameter", () => {
38+
expect(sanitizeNameForGraphQL("123Name", "")).toBe("_123Name");
39+
});
40+
41+
it("uses custom prefix for invalid starting character", () => {
42+
expect(sanitizeNameForGraphQL("123Name", "Num")).toBe("Num_123Name");
43+
});
1944
});
2045

21-
it("adds prefix for names starting with numbers", () => {
22-
expect(sanitizeNameForGraphQL("123Name")).toBe("_123Name");
23-
expect(sanitizeNameForGraphQL("1")).toBe("_1");
46+
describe("toTypeName", () => {
47+
it("converts to PascalCase", () => {
48+
expect(toTypeName("my_name")).toBe("MyName");
49+
expect(toTypeName("some-value")).toBe("SomeValue");
50+
expect(toTypeName("hello_world")).toBe("HelloWorld");
51+
});
52+
53+
it("preserves all-caps acronyms", () => {
54+
expect(toTypeName("API")).toBe("API");
55+
expect(toTypeName("APIResponse")).toBe("APIResponse");
56+
expect(toTypeName("myAPIKey")).toBe("MyAPIKey");
57+
expect(toTypeName("HTTPResponse")).toBe("HTTPResponse");
58+
});
59+
60+
it("handles namespaced names by using only the last part", () => {
61+
expect(toTypeName("MyNamespace.MyType")).toBe("MyType");
62+
expect(toTypeName("A.B.C.MyType")).toBe("MyType");
63+
});
64+
65+
it("sanitizes and converts special characters", () => {
66+
// Special chars become underscores, then PascalCase removes them
67+
expect(toTypeName("my-special$name")).toBe("MySpecialName");
68+
expect(toTypeName("$invalid")).toBe("Invalid");
69+
});
2470
});
2571

26-
it("handles multiple special characters", () => {
27-
expect(sanitizeNameForGraphQL("$My-Special.Name$")).toBe("_My_Special_Name_");
72+
describe("toEnumMemberName", () => {
73+
it("converts to CONSTANT_CASE", () => {
74+
expect(toEnumMemberName("MyEnum", "myValue")).toBe("MY_VALUE");
75+
expect(toEnumMemberName("Status", "inProgress")).toBe("IN_PROGRESS");
76+
});
77+
78+
it("handles already uppercase names", () => {
79+
expect(toEnumMemberName("MyEnum", "ACTIVE")).toBe("ACTIVE");
80+
});
81+
82+
it("uses enum name as prefix for invalid starting characters", () => {
83+
expect(toEnumMemberName("Priority", "1High")).toBe("PRIORITY_1_HIGH");
84+
});
85+
86+
it("handles special characters", () => {
87+
expect(toEnumMemberName("MyEnum", "value-with-dashes")).toBe("VALUE_WITH_DASHES");
88+
});
89+
90+
it("separates numbers", () => {
91+
expect(toEnumMemberName("MyEnum", "value123")).toBe("VALUE_123");
92+
});
2893
});
2994

30-
it("handles empty prefix parameter", () => {
31-
expect(sanitizeNameForGraphQL("123Name", "")).toBe("_123Name");
95+
describe("toFieldName", () => {
96+
it("converts to camelCase", () => {
97+
expect(toFieldName("MyField")).toBe("myField");
98+
expect(toFieldName("SOME_VALUE")).toBe("someValue");
99+
});
100+
101+
it("handles snake_case", () => {
102+
expect(toFieldName("my_field_name")).toBe("myFieldName");
103+
});
104+
105+
it("handles special characters", () => {
106+
expect(toFieldName("my-field")).toBe("myField");
107+
expect(toFieldName("$special")).toBe("_special");
108+
});
109+
110+
it("preserves leading underscores", () => {
111+
expect(toFieldName("_private")).toBe("_private");
112+
expect(toFieldName("__internal")).toBe("__internal");
113+
});
32114
});
33115

34-
it("uses custom prefix for invalid starting character", () => {
35-
expect(sanitizeNameForGraphQL("123Name", "Num")).toBe("Num_123Name");
116+
describe("getSingleNameWithNamespace", () => {
117+
it("replaces dots with underscores", () => {
118+
expect(getSingleNameWithNamespace("My.Namespace.Type")).toBe("My_Namespace_Type");
119+
});
120+
121+
it("trims whitespace", () => {
122+
expect(getSingleNameWithNamespace(" My.Type ")).toBe("My_Type");
123+
});
124+
125+
it("handles names without namespace", () => {
126+
expect(getSingleNameWithNamespace("MyType")).toBe("MyType");
127+
});
36128
});
37129
});

0 commit comments

Comments
 (0)