|
| 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 | +} |
0 commit comments