|
1 | | -import type { EmitContext, NewLine } from "@typespec/compiler"; |
2 | | -import { resolvePath } from "@typespec/compiler"; |
3 | | -import { createGraphQLEmitter } from "./graphql-emitter.js"; |
| 1 | +import { |
| 2 | + getEncode, |
| 3 | + isArrayModelType, |
| 4 | + isUnknownType, |
| 5 | + navigateTypesInNamespace, |
| 6 | + type EmitContext, |
| 7 | + type Enum, |
| 8 | + type Model, |
| 9 | + type ModelProperty, |
| 10 | + type Namespace, |
| 11 | + type Operation, |
| 12 | + type Program, |
| 13 | + type Scalar, |
| 14 | + type Type, |
| 15 | + type Union, |
| 16 | +} from "@typespec/compiler"; |
| 17 | +import { isInterface } from "./lib/interface.js"; |
| 18 | +import { getOperationKind } from "./lib/operation-kind.js"; |
4 | 19 | import type { GraphQLEmitterOptions } from "./lib.js"; |
| 20 | +import { resolveTypeUsage, GraphQLTypeUsage, type TypeUsageResolver } from "./type-usage.js"; |
| 21 | +import { listSchemas } from "./lib/schema.js"; |
| 22 | +import { createGraphQLMutationEngine, GraphQLTypeContext } from "./mutation-engine/index.js"; |
| 23 | +import type { ClassifiedTypes, ModelVariants, ScalarVariant } from "./context/index.js"; |
| 24 | +import { unwrapNullableUnion } from "./lib/type-utils.js"; |
| 25 | +import { getGraphQLBuiltinName, getScalarMapping } from "./lib/scalar-mappings.js"; |
| 26 | +import { getSpecifiedBy } from "./lib/specified-by.js"; |
5 | 27 |
|
6 | | -const defaultOptions = { |
7 | | - "new-line": "lf", |
8 | | - "omit-unreachable-types": false, |
9 | | - strict: false, |
10 | | -} as const; |
11 | | - |
| 28 | +/** |
| 29 | + * Main emitter entry point for GraphQL SDL generation. |
| 30 | + * |
| 31 | + * Runs the full data pipeline (type usage → mutation → classification) but |
| 32 | + * does not yet render output. Component-based SDL rendering will be added |
| 33 | + * in follow-up PRs. |
| 34 | + */ |
12 | 35 | export async function $onEmit(context: EmitContext<GraphQLEmitterOptions>) { |
13 | | - const options = resolveOptions(context); |
14 | | - const emitter = createGraphQLEmitter(context, options); |
15 | | - await emitter.emitGraphQL(); |
| 36 | + const schemas = listSchemas(context.program); |
| 37 | + if (schemas.length === 0) { |
| 38 | + schemas.push({ type: context.program.getGlobalNamespaceType() }); |
| 39 | + } |
| 40 | + |
| 41 | + for (const schema of schemas) { |
| 42 | + await emitSchema(context, schema); |
| 43 | + } |
16 | 44 | } |
17 | 45 |
|
18 | | -export interface ResolvedGraphQLEmitterOptions { |
19 | | - outputFile: string; |
20 | | - newLine: NewLine; |
21 | | - omitUnreachableTypes: boolean; |
22 | | - strict: boolean; |
| 46 | +/** |
| 47 | + * Process a single GraphQL schema through the data pipeline. |
| 48 | + */ |
| 49 | +async function emitSchema( |
| 50 | + context: EmitContext<GraphQLEmitterOptions>, |
| 51 | + schema: { type: Namespace; name?: string }, |
| 52 | +) { |
| 53 | + // Phase 1: Type usage tracking — determine which types are reachable from operations. |
| 54 | + // Must run before mutation so we can filter on original (pre-clone) type objects. |
| 55 | + const omitUnreachable = context.options["omit-unreachable-types"] ?? false; |
| 56 | + const typeUsage = resolveTypeUsage(schema.type, omitUnreachable); |
| 57 | + |
| 58 | + // Phase 2: Mutation — transform TypeSpec types with GraphQL naming conventions. |
| 59 | + // Unreachable enums/unions are skipped during mutation (avoids identity mismatch with cloned objects). |
| 60 | + const { mutatedTypes, scalarSpecifications, originalToMutated } = mutateTypes( |
| 61 | + context, |
| 62 | + schema, |
| 63 | + typeUsage, |
| 64 | + ); |
| 65 | + |
| 66 | + // Phase 3: Classification — separate types by category. |
| 67 | + const classifiedTypes = classifyTypes( |
| 68 | + context.program, |
| 69 | + mutatedTypes, |
| 70 | + originalToMutated, |
| 71 | + typeUsage, |
| 72 | + ); |
| 73 | + |
| 74 | + // Phase 4: Build model variant lookups. |
| 75 | + const _modelVariants = buildModelVariants(classifiedTypes); |
| 76 | + |
| 77 | + // Phase 5: Component-based SDL rendering. |
| 78 | + // TODO: Render using Alloy JSX components (added in follow-up PRs). |
| 79 | + void _modelVariants; |
| 80 | + void scalarSpecifications; |
23 | 81 | } |
24 | 82 |
|
25 | | -export function resolveOptions( |
| 83 | +// --------------------------------------------------------------------------- |
| 84 | +// Phase 2: Mutation |
| 85 | +// --------------------------------------------------------------------------- |
| 86 | + |
| 87 | +/** |
| 88 | + * Mutate all types in a schema namespace using the mutation engine. |
| 89 | + * Unreachable enums/unions are skipped based on the type usage resolver. |
| 90 | + */ |
| 91 | +function mutateTypes( |
26 | 92 | context: EmitContext<GraphQLEmitterOptions>, |
27 | | -): ResolvedGraphQLEmitterOptions { |
28 | | - const resolvedOptions = { ...defaultOptions, ...context.options }; |
29 | | - const outputFile = resolvedOptions["output-file"] ?? "{schema-name}.graphql"; |
| 93 | + schema: { type: Namespace }, |
| 94 | + typeUsage: TypeUsageResolver, |
| 95 | +) { |
| 96 | + const engine = createGraphQLMutationEngine(context.program); |
| 97 | + const mutatedModels: Model[] = []; |
| 98 | + const mutatedEnums: Enum[] = []; |
| 99 | + const mutatedScalars: Scalar[] = []; |
| 100 | + const mutatedUnions: Union[] = []; |
| 101 | + const mutatedOperations: Operation[] = []; |
| 102 | + const wrapperModels: Model[] = []; |
| 103 | + const scalarSpecifications = new Map<string, string>(); |
| 104 | + const originalToMutated = new Map<Model, Model>(); |
| 105 | + const processedScalars = new Set<string>(); |
| 106 | + const scalarVariantsMap = new Map<string, ScalarVariant>(); |
| 107 | + |
| 108 | + const processScalar = (node: Scalar): void => { |
| 109 | + const isDirectGraphQLBuiltin = |
| 110 | + context.program.checker.isStdType(node, "string") || |
| 111 | + context.program.checker.isStdType(node, "int32") || |
| 112 | + context.program.checker.isStdType(node, "float32") || |
| 113 | + context.program.checker.isStdType(node, "float64") || |
| 114 | + context.program.checker.isStdType(node, "boolean"); |
| 115 | + |
| 116 | + if (isDirectGraphQLBuiltin) return; |
| 117 | + |
| 118 | + const mutation = engine.mutateScalar(node); |
| 119 | + const graphqlName = mutation.mutatedType.name; |
| 120 | + |
| 121 | + if (!processedScalars.has(graphqlName)) { |
| 122 | + processedScalars.add(graphqlName); |
| 123 | + mutatedScalars.push(mutation.mutatedType); |
| 124 | + |
| 125 | + const specUrl = getSpecifiedBy(context.program, mutation.mutatedType); |
| 126 | + if (specUrl) { |
| 127 | + scalarSpecifications.set(graphqlName, specUrl); |
| 128 | + } |
| 129 | + } |
| 130 | + }; |
| 131 | + |
| 132 | + const processScalarVariant = (target: ModelProperty): void => { |
| 133 | + if (isUnknownType(target.type)) { |
| 134 | + if (!scalarVariantsMap.has("Unknown")) { |
| 135 | + scalarVariantsMap.set("Unknown", { |
| 136 | + sourceScalar: target.type, |
| 137 | + encoding: "default", |
| 138 | + graphqlName: "Unknown", |
| 139 | + specificationUrl: undefined, |
| 140 | + }); |
| 141 | + } |
| 142 | + return; |
| 143 | + } |
| 144 | + if ( |
| 145 | + target.type.kind === "Scalar" && |
| 146 | + context.program.checker.isStdType(target.type) && |
| 147 | + !getGraphQLBuiltinName(context.program, target.type) |
| 148 | + ) { |
| 149 | + const encodeData = getEncode(context.program, target); |
| 150 | + const encoding = encodeData?.encoding; |
| 151 | + const mapping = getScalarMapping(context.program, target.type, encoding); |
| 152 | + if (mapping && !scalarVariantsMap.has(mapping.graphqlName)) { |
| 153 | + scalarVariantsMap.set(mapping.graphqlName, { |
| 154 | + sourceScalar: target.type, |
| 155 | + encoding: encoding || "default", |
| 156 | + graphqlName: mapping.graphqlName, |
| 157 | + specificationUrl: mapping.specificationUrl, |
| 158 | + }); |
| 159 | + } |
| 160 | + } |
| 161 | + }; |
| 162 | + |
| 163 | + navigateTypesInNamespace(schema.type, { |
| 164 | + model: (node: Model) => { |
| 165 | + if (isArrayModelType(context.program, node)) return; |
| 166 | + const mutation = engine.mutateModel(node, GraphQLTypeContext.Output); |
| 167 | + mutatedModels.push(mutation.mutatedType); |
| 168 | + originalToMutated.set(node, mutation.mutatedType); |
| 169 | + }, |
| 170 | + enum: (node: Enum) => { |
| 171 | + if (typeUsage.isUnreachable(node)) return; |
| 172 | + const mutation = engine.mutateEnum(node); |
| 173 | + mutatedEnums.push(mutation.mutatedType); |
| 174 | + }, |
| 175 | + scalar: (node: Scalar) => { |
| 176 | + processScalar(node); |
| 177 | + }, |
| 178 | + union: (node: Union) => { |
| 179 | + // Skip nullable unions (e.g., string | null) — they're not union declarations. |
| 180 | + // Nullability for these is detected at render time in GraphQLTypeExpression. |
| 181 | + if (unwrapNullableUnion(node) !== undefined) { |
| 182 | + return; |
| 183 | + } |
| 184 | + if (typeUsage.isUnreachable(node)) return; |
| 185 | + const mutation = engine.mutateUnion(node, GraphQLTypeContext.Output); |
| 186 | + mutatedUnions.push(mutation.mutatedType as Union); |
| 187 | + wrapperModels.push(...mutation.wrapperModels); |
| 188 | + }, |
| 189 | + operation: (node: Operation) => { |
| 190 | + // Operations are passed through unmutated. This is load-bearing: |
| 191 | + // typeUsage walked operation params/returns on original types to mark input/output. |
| 192 | + // classifyTypes reverse-maps mutated models via originalToMutated. |
| 193 | + mutatedOperations.push(node); |
| 194 | + }, |
| 195 | + }); |
| 196 | + |
| 197 | + // Collect referenced scalars from model properties and operations. |
| 198 | + // Standard library scalars like int64, utcDateTime are not declared in the schema, |
| 199 | + // but are referenced in model properties — we need to collect and mutate them. |
| 200 | + const visitedTypes = new Set<Type>(); |
| 201 | + |
| 202 | + const collectReferencedScalars = (type: Type): void => { |
| 203 | + if (visitedTypes.has(type)) return; |
| 204 | + visitedTypes.add(type); |
| 205 | + |
| 206 | + if (type.kind === "Scalar") { |
| 207 | + processScalar(type); |
| 208 | + } else if (type.kind === "Model" && isArrayModelType(context.program, type)) { |
| 209 | + if (type.indexer?.value) { |
| 210 | + collectReferencedScalars(type.indexer.value); |
| 211 | + } |
| 212 | + } else if (type.kind === "Model") { |
| 213 | + for (const prop of type.properties.values()) { |
| 214 | + collectReferencedScalars(prop.type); |
| 215 | + } |
| 216 | + } else if (type.kind === "Union") { |
| 217 | + for (const variant of type.variants.values()) { |
| 218 | + collectReferencedScalars(variant.type); |
| 219 | + } |
| 220 | + } |
| 221 | + }; |
| 222 | + |
| 223 | + // Uses original (pre-mutation) models because mutated type refs won't match processedScalars. |
| 224 | + const originalModels = Array.from(originalToMutated.keys()); |
| 225 | + for (const model of originalModels) { |
| 226 | + for (const prop of model.properties.values()) { |
| 227 | + collectReferencedScalars(prop.type); |
| 228 | + processScalarVariant(prop); |
| 229 | + } |
| 230 | + } |
| 231 | + |
| 232 | + for (const op of mutatedOperations) { |
| 233 | + for (const param of op.parameters.properties.values()) { |
| 234 | + collectReferencedScalars(param.type); |
| 235 | + processScalarVariant(param); |
| 236 | + } |
| 237 | + collectReferencedScalars(op.returnType); |
| 238 | + } |
| 239 | + |
| 240 | + return { |
| 241 | + mutatedTypes: { |
| 242 | + models: mutatedModels, |
| 243 | + enums: mutatedEnums, |
| 244 | + scalars: mutatedScalars, |
| 245 | + unions: mutatedUnions, |
| 246 | + operations: mutatedOperations, |
| 247 | + wrapperModels, |
| 248 | + scalarVariants: Array.from(scalarVariantsMap.values()), |
| 249 | + }, |
| 250 | + scalarSpecifications, |
| 251 | + originalToMutated, |
| 252 | + }; |
| 253 | +} |
| 254 | + |
| 255 | +// --------------------------------------------------------------------------- |
| 256 | +// Phase 3: Classification |
| 257 | +// --------------------------------------------------------------------------- |
| 258 | + |
| 259 | +/** |
| 260 | + * Classify types into categories (interfaces, output types, input types, operations). |
| 261 | + */ |
| 262 | +function classifyTypes( |
| 263 | + program: Program, |
| 264 | + mutatedTypes: ReturnType<typeof mutateTypes>["mutatedTypes"], |
| 265 | + originalToMutated: Map<Model, Model>, |
| 266 | + typeUsage: TypeUsageResolver, |
| 267 | +): ClassifiedTypes { |
| 268 | + const interfaces: Model[] = []; |
| 269 | + const outputModels: Model[] = []; |
| 270 | + const inputModels: Model[] = []; |
| 271 | + const queries: Operation[] = []; |
| 272 | + const mutations: Operation[] = []; |
| 273 | + const subscriptions: Operation[] = []; |
| 274 | + |
| 275 | + // Create reverse mapping |
| 276 | + const mutatedToOriginal = new Map<Model, Model>(); |
| 277 | + for (const [orig, mut] of originalToMutated) { |
| 278 | + mutatedToOriginal.set(mut, orig); |
| 279 | + } |
| 280 | + |
| 281 | + for (const model of mutatedTypes.models) { |
| 282 | + const originalModel = mutatedToOriginal.get(model) || model; |
| 283 | + if (typeUsage.isUnreachable(originalModel)) { |
| 284 | + continue; |
| 285 | + } |
| 286 | + |
| 287 | + // Check @Interface on the original (pre-clone) model, since decorator state |
| 288 | + // is stored against original type identity, not mutated clones. |
| 289 | + if (isInterface(program, originalModel)) { |
| 290 | + interfaces.push(model); |
| 291 | + } else { |
| 292 | + const usage = typeUsage.getUsage(originalModel); |
| 293 | + const usedAsInput = usage?.has(GraphQLTypeUsage.Input) ?? false; |
| 294 | + const usedAsOutput = usage?.has(GraphQLTypeUsage.Output) ?? false; |
| 295 | + |
| 296 | + if (!usedAsInput && !usedAsOutput) { |
| 297 | + // Reachable but not referenced by any operation — default to Output |
| 298 | + outputModels.push(model); |
| 299 | + } else { |
| 300 | + if (usedAsOutput) outputModels.push(model); |
| 301 | + if (usedAsInput) inputModels.push(model); |
| 302 | + } |
| 303 | + } |
| 304 | + } |
| 305 | + |
| 306 | + // Add wrapper models created by union mutations (always used as output) |
| 307 | + outputModels.push(...mutatedTypes.wrapperModels); |
| 308 | + |
| 309 | + // Classify operations by kind |
| 310 | + for (const op of mutatedTypes.operations) { |
| 311 | + const kind = getOperationKind(program, op); |
| 312 | + if (kind === "Query") queries.push(op); |
| 313 | + else if (kind === "Mutation") mutations.push(op); |
| 314 | + else if (kind === "Subscription") subscriptions.push(op); |
| 315 | + } |
30 | 316 |
|
31 | 317 | return { |
32 | | - outputFile: resolvePath(context.emitterOutputDir, outputFile), |
33 | | - newLine: resolvedOptions["new-line"], |
34 | | - omitUnreachableTypes: resolvedOptions["omit-unreachable-types"], |
35 | | - strict: resolvedOptions["strict"], |
| 318 | + interfaces, |
| 319 | + outputModels, |
| 320 | + inputModels, |
| 321 | + enums: mutatedTypes.enums, |
| 322 | + scalars: mutatedTypes.scalars, |
| 323 | + scalarVariants: mutatedTypes.scalarVariants, |
| 324 | + unions: mutatedTypes.unions, |
| 325 | + queries, |
| 326 | + mutations, |
| 327 | + subscriptions, |
| 328 | + }; |
| 329 | +} |
| 330 | + |
| 331 | +// --------------------------------------------------------------------------- |
| 332 | +// Phase 4: Model variant lookups |
| 333 | +// --------------------------------------------------------------------------- |
| 334 | + |
| 335 | +/** |
| 336 | + * Build model variant lookups for checking which variants exist. |
| 337 | + */ |
| 338 | +function buildModelVariants(classifiedTypes: ClassifiedTypes): ModelVariants { |
| 339 | + const modelVariants: ModelVariants = { |
| 340 | + outputModels: new Map(), |
| 341 | + inputModels: new Map(), |
36 | 342 | }; |
| 343 | + |
| 344 | + classifiedTypes.outputModels.forEach((m) => |
| 345 | + modelVariants.outputModels.set(m.name, m), |
| 346 | + ); |
| 347 | + classifiedTypes.inputModels.forEach((m) => |
| 348 | + modelVariants.inputModels.set(m.name, m), |
| 349 | + ); |
| 350 | + |
| 351 | + return modelVariants; |
37 | 352 | } |
0 commit comments