Skip to content

Commit 65f55e7

Browse files
committed
feat(graphql): add GraphQL name sanitization utilities
1 parent c2fe95c commit 65f55e7

File tree

2 files changed

+338
-0
lines changed

2 files changed

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

0 commit comments

Comments
 (0)