Skip to content

Commit 42df8a9

Browse files
authored
Add GraphQL name sanitization utilities (#55)
## Summary Adds utility functions for transforming TypeSpec names into valid GraphQL identifiers. These utilities form the foundation for name handling throughout the GraphQL emitter. ## Changes - **`src/lib/type-utils.ts`** - Core utility functions for GraphQL name transformations - **`test/lib/type-utils.test.ts`** - Unit tests for `sanitizeNameForGraphQL` ## Utilities Added | Function | Purpose | |----------|---------| | `sanitizeNameForGraphQL` | Sanitize names to be valid GraphQL identifiers | | `toTypeName` | Convert to PascalCase for type names | | `toFieldName` | Convert to camelCase for field names | | `toEnumMemberName` | Convert to CONSTANT_CASE for enum members | | `getUnionName` | Generate names for anonymous unions | | `getTemplatedModelName` | Generate names for templated models (e.g., `ListOfString`) | | `isArray`, `isRecordType` | Type guards for array/record models | | `unwrapModel`, `unwrapType` | Extract element types from arrays | | `isTrueModel` | Check if a model should emit as GraphQL object type | | `getGraphQLDoc` | Extract doc comments for GraphQL descriptions |
1 parent c2fe95c commit 42df8a9

2 files changed

Lines changed: 410 additions & 0 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import {
2+
type ArrayModelType,
3+
type Enum,
4+
getDoc,
5+
getTypeName,
6+
type IndeterminateEntity,
7+
isNeverType,
8+
isTemplateInstance,
9+
type Model,
10+
type Program,
11+
type RecordModelType,
12+
type Scalar,
13+
type Type,
14+
type Union,
15+
type Value,
16+
walkPropertiesInherited,
17+
} from "@typespec/compiler";
18+
import {
19+
type AliasStatementNode,
20+
type IdentifierNode,
21+
type ModelPropertyNode,
22+
type ModelStatementNode,
23+
type Node,
24+
SyntaxKind,
25+
type UnionStatementNode,
26+
} from "@typespec/compiler/ast";
27+
import { camelCase, constantCase, pascalCase, split, splitSeparateNumbers } from "change-case";
28+
29+
/** Generate a GraphQL type name for a templated model (e.g., `ListOfString`). */
30+
export function getTemplatedModelName(model: Model): string {
31+
const name = getTypeName(model, {});
32+
const baseName = toTypeName(name.replace(/<[^>]*>/g, ""));
33+
const templateString = getTemplateString(model);
34+
return templateString ? `${baseName}Of${templateString}` : baseName;
35+
}
36+
37+
function splitWithAcronyms(
38+
split: (name: string) => string[],
39+
skipStart: boolean,
40+
name: string,
41+
): string[] {
42+
const result = split(name);
43+
44+
if (name === name.toUpperCase()) {
45+
return result;
46+
}
47+
// Preserve strings of capital letters, e.g. "API" should be treated as three words ["A", "P", "I"] instead of one word
48+
return result.flatMap((part) => {
49+
const result = !skipStart && part.match(/^[A-Z]+$/) ? part.split("") : part;
50+
skipStart = false;
51+
return result;
52+
});
53+
}
54+
55+
/** Convert a name to PascalCase for GraphQL type names. */
56+
export function toTypeName(name: string): string {
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, {
63+
split: splitWithAcronyms.bind(null, split, false),
64+
});
65+
}
66+
67+
/** Sanitize a name to be a valid GraphQL identifier. */
68+
export function sanitizeNameForGraphQL(name: string, prefix: string = ""): string {
69+
name = name.replace("[]", "Array");
70+
name = name.replaceAll(/\W/g, "_");
71+
if (!name.match("^[_a-zA-Z]")) {
72+
name = `${prefix}_${name}`;
73+
}
74+
return name;
75+
}
76+
77+
/** Convert a name to CONSTANT_CASE for GraphQL enum members. */
78+
export function toEnumMemberName(enumName: string, name: string) {
79+
return constantCase(sanitizeNameForGraphQL(name, enumName), {
80+
split: splitSeparateNumbers,
81+
prefixCharacters: "_",
82+
});
83+
}
84+
85+
/** Convert a name to camelCase for GraphQL field names. */
86+
export function toFieldName(name: string): string {
87+
return camelCase(sanitizeNameForGraphQL(name), {
88+
prefixCharacters: "_",
89+
split: splitWithAcronyms.bind(null, split, true),
90+
});
91+
}
92+
93+
function getNameWithoutNamespace(name: string): string {
94+
const parts = name.trim().split(".");
95+
return parts[parts.length - 1];
96+
}
97+
98+
/** Generate a GraphQL type name for a union, including anonymous unions. */
99+
export function getUnionName(union: Union, program: Program): string {
100+
// SyntaxKind.UnionExpression: Foo | Bar
101+
// SyntaxKind.UnionStatement: union FooBarUnion { Foo, Bar }
102+
// SyntaxKind.TypeReference: FooBarUnion
103+
104+
const templateString = getTemplateString(union) ? "Of" + getTemplateString(union) : "";
105+
106+
switch (true) {
107+
case !!union.name:
108+
// The union is not anonymous, use its name
109+
return union.name;
110+
111+
case isReturnType(union):
112+
// The union is a return type, use the name of the operation
113+
// e.g. op getBaz(): Foo | Bar => GetBazUnion
114+
return `${getUnionNameForOperation(program, union)}${templateString}Union`;
115+
116+
case isModelProperty(union):
117+
// The union is a model property, name it based on the model + property
118+
// e.g. model Foo { bar: Bar | Baz } => FooBarUnion
119+
const modelProperty = getModelProperty(union);
120+
const propName = toTypeName(getNameForNode(modelProperty!));
121+
const unionModel = union.node?.parent?.parent as ModelStatementNode;
122+
const modelName = unionModel ? getNameForNode(unionModel) : "";
123+
return `${modelName}${propName}${templateString}Union`;
124+
125+
case isAliased(union):
126+
// The union is an alias, name it based on the alias name
127+
// e.g. alias Baz = Foo<string> | Bar => Baz
128+
const alias = getAlias(union);
129+
const aliasName = getNameForNode(alias!);
130+
return `${aliasName}${templateString}`;
131+
132+
default:
133+
throw new Error("Unrecognized union construction.");
134+
}
135+
}
136+
137+
function isNamedType(type: Type | Value | IndeterminateEntity): type is { name: string } & Type {
138+
return "name" in type && typeof (type as { name: unknown }).name === "string";
139+
}
140+
141+
function isAliased(union: Union): boolean {
142+
return union.node?.parent?.kind === SyntaxKind.AliasStatement;
143+
}
144+
145+
function getAlias(union: Union): AliasStatementNode | undefined {
146+
return isAliased(union) ? (union.node?.parent as AliasStatementNode) : undefined;
147+
}
148+
149+
function isModelProperty(union: Union): boolean {
150+
return union.node?.parent?.kind === SyntaxKind.ModelProperty;
151+
}
152+
153+
function getModelProperty(union: Union): ModelPropertyNode | undefined {
154+
return isModelProperty(union) ? (union.node?.parent as ModelPropertyNode) : undefined;
155+
}
156+
157+
function isReturnType(type: Type): boolean {
158+
return !!(
159+
type.node &&
160+
type.node.parent?.kind === SyntaxKind.OperationSignatureDeclaration &&
161+
type.node.parent?.parent?.kind === SyntaxKind.OperationStatement
162+
);
163+
}
164+
165+
type NamedNode = Node & { id: IdentifierNode };
166+
167+
function getNameForNode(node: NamedNode): string {
168+
return "id" in node && node.id?.kind === SyntaxKind.Identifier ? node.id.sv : "";
169+
}
170+
171+
function getUnionNameForOperation(program: Program, union: Union): string {
172+
const operationNode = (union.node as UnionStatementNode).parent?.parent;
173+
const operation = program.checker.getTypeForNode(operationNode!);
174+
175+
return toTypeName(getTypeName(operation));
176+
}
177+
178+
/** Convert a namespaced name to a single name by replacing dots with underscores. */
179+
export function getSingleNameWithNamespace(name: string): string {
180+
return name.trim().replace(/\./g, "_");
181+
}
182+
183+
/**
184+
* Check if a model is an array type.
185+
*/
186+
export function isArray(model: Model): model is ArrayModelType {
187+
return Boolean(model.indexer && model.indexer.key.name === "integer");
188+
}
189+
190+
/**
191+
* Check if a model is a record/map type.
192+
*/
193+
export function isRecordType(type: Model): type is RecordModelType {
194+
return Boolean(type.indexer && type.indexer.key.name === "string");
195+
}
196+
197+
/** Check if a model is an array of scalars or enums. */
198+
export function isScalarOrEnumArray(type: Model): type is ArrayModelType {
199+
return (
200+
isArray(type) && (type.indexer?.value.kind === "Scalar" || type.indexer?.value.kind === "Enum")
201+
);
202+
}
203+
204+
/** Check if a model is an array of unions. */
205+
export function isUnionArray(type: Model): type is ArrayModelType {
206+
return isArray(type) && type.indexer?.value.kind === "Union";
207+
}
208+
209+
/** Extract the element type from an array model, or return the model itself. */
210+
export function unwrapModel(model: ArrayModelType): Model | Scalar | Enum | Union;
211+
export function unwrapModel(model: Exclude<Model, ArrayModelType>): Model;
212+
export function unwrapModel(model: Model): Model | Scalar | Enum | Union {
213+
if (!isArray(model)) {
214+
return model;
215+
}
216+
217+
if (model.indexer?.value.kind) {
218+
if (["Model", "Scalar", "Enum", "Union"].includes(model.indexer.value.kind)) {
219+
return model.indexer.value as Model | Scalar | Enum | Union;
220+
}
221+
throw new Error(`Unexpected array type: ${model.indexer.value.kind}`);
222+
}
223+
return model;
224+
}
225+
226+
/** Unwrap array types to get the inner element type. */
227+
export function unwrapType(type: Model): Model | Scalar | Enum | Union;
228+
export function unwrapType(type: Type): Type;
229+
export function unwrapType(type: Type): Type {
230+
if (type.kind === "Model") {
231+
return unwrapModel(type);
232+
}
233+
return type;
234+
}
235+
236+
/** Get the GraphQL description for a type from its doc comments. */
237+
export function getGraphQLDoc(program: Program, type: Type): string | undefined {
238+
// GraphQL uses CommonMark for descriptions
239+
// https://spec.graphql.org/October2021/#sec-Descriptions
240+
return getDoc(program, type);
241+
}
242+
243+
/** Generate a string representation of template arguments (e.g., `StringAndInt`). */
244+
export function getTemplateString(
245+
type: Type,
246+
options: { conjunction: string } = { conjunction: "And" },
247+
): string {
248+
if (isTemplateInstance(type)) {
249+
const args = type.templateMapper.args.filter(isNamedType).map((arg) => getTypeName(arg));
250+
return getTemplateStringInternal(args, options);
251+
}
252+
return "";
253+
}
254+
255+
function getTemplateStringInternal(
256+
args: string[],
257+
options: { conjunction: string } = { conjunction: "And" },
258+
): string {
259+
return args.length > 0 ? toTypeName(args.map(toTypeName).join(options.conjunction)) : "";
260+
}
261+
262+
/** Check if a model should be emitted as a GraphQL object type (not an array, record, or never). */
263+
export function isTrueModel(model: Model): boolean {
264+
/* eslint-disable no-fallthrough */
265+
switch (true) {
266+
// A scalar array is represented as a model with an indexer
267+
// and a scalar type. We don't want to emit this as a model.
268+
case isScalarOrEnumArray(model):
269+
// A union array is represented as a model with an indexer
270+
// and a union type. We don't want to emit this as a model.
271+
case isUnionArray(model):
272+
case isNeverType(model):
273+
// If the model is purely a record, we don't want to emit it as a model.
274+
// Instead, we will need to create a scalar
275+
case isRecordType(model) && [...walkPropertiesInherited(model)].length === 0:
276+
return false;
277+
default:
278+
return true;
279+
}
280+
/* eslint-enable no-fallthrough */
281+
}

0 commit comments

Comments
 (0)