metal-orm 1.0.79 → 1.0.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,586 @@
1
+ import type { TableDef } from '../schema/table.js';
2
+ import type { RelationDef } from '../schema/relation.js';
3
+ import type { HydrationPlan, HydrationRelationPlan } from '../core/hydration/types.js';
4
+ import type { ProjectionNode } from '../query-builder/select-query-state.js';
5
+ import { findPrimaryKey } from '../query-builder/hydration-planner.js';
6
+
7
+ import type {
8
+ OpenApiSchema,
9
+ OpenApiSchemaBundle,
10
+ SchemaExtractionContext,
11
+ SchemaOptions,
12
+ OutputSchemaOptions,
13
+ InputSchemaOptions,
14
+ JsonSchemaProperty,
15
+ JsonSchemaType
16
+ } from './schema-types.js';
17
+ import { mapColumnType, mapRelationType } from './type-mappers.js';
18
+
19
+ const DEFAULT_MAX_DEPTH = 5;
20
+
21
+ /**
22
+ * Extracts OpenAPI 3.1 schemas for output and optional input payloads.
23
+ */
24
+ export const extractSchema = (
25
+ table: TableDef,
26
+ plan: HydrationPlan | undefined,
27
+ projectionNodes: ProjectionNode[] | undefined,
28
+ options: SchemaOptions = {}
29
+ ): OpenApiSchemaBundle => {
30
+ const outputOptions = resolveOutputOptions(options);
31
+ const outputContext = createContext(outputOptions.maxDepth ?? DEFAULT_MAX_DEPTH);
32
+ const output = extractOutputSchema(table, plan, projectionNodes, outputContext, outputOptions);
33
+
34
+ const inputOptions = resolveInputOptions(options);
35
+ if (!inputOptions) {
36
+ return { output };
37
+ }
38
+
39
+ const inputContext = createContext(inputOptions.maxDepth ?? DEFAULT_MAX_DEPTH);
40
+ const input = extractInputSchema(table, inputContext, inputOptions);
41
+
42
+ return { output, input };
43
+ };
44
+
45
+ const resolveOutputOptions = (options: SchemaOptions): OutputSchemaOptions => ({
46
+ mode: options.mode ?? 'full',
47
+ includeDescriptions: options.includeDescriptions,
48
+ includeEnums: options.includeEnums,
49
+ includeExamples: options.includeExamples,
50
+ includeDefaults: options.includeDefaults,
51
+ includeNullable: options.includeNullable,
52
+ maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH
53
+ });
54
+
55
+ const resolveInputOptions = (options: SchemaOptions): InputSchemaOptions | undefined => {
56
+ if (options.input === false) return undefined;
57
+ const input = options.input ?? {};
58
+ const mode = input.mode ?? 'create';
59
+
60
+ return {
61
+ mode,
62
+ includeRelations: input.includeRelations ?? true,
63
+ relationMode: input.relationMode ?? 'mixed',
64
+ includeDescriptions: input.includeDescriptions ?? options.includeDescriptions,
65
+ includeEnums: input.includeEnums ?? options.includeEnums,
66
+ includeExamples: input.includeExamples ?? options.includeExamples,
67
+ includeDefaults: input.includeDefaults ?? options.includeDefaults,
68
+ includeNullable: input.includeNullable ?? options.includeNullable,
69
+ maxDepth: input.maxDepth ?? options.maxDepth ?? DEFAULT_MAX_DEPTH,
70
+ omitReadOnly: input.omitReadOnly ?? true,
71
+ excludePrimaryKey: input.excludePrimaryKey ?? false,
72
+ requirePrimaryKey: input.requirePrimaryKey ?? (mode === 'update')
73
+ };
74
+ };
75
+
76
+ const createContext = (maxDepth: number): SchemaExtractionContext => ({
77
+ visitedTables: new Set(),
78
+ schemaCache: new Map(),
79
+ depth: 0,
80
+ maxDepth
81
+ });
82
+
83
+ /**
84
+ * Output schema extraction (query results)
85
+ */
86
+ const extractOutputSchema = (
87
+ table: TableDef,
88
+ plan: HydrationPlan | undefined,
89
+ projectionNodes: ProjectionNode[] | undefined,
90
+ context: SchemaExtractionContext,
91
+ options: OutputSchemaOptions
92
+ ): OpenApiSchema => {
93
+ const mode = options.mode ?? 'full';
94
+
95
+ const hasComputedFields = projectionNodes && projectionNodes.some(
96
+ node => node.type !== 'Column'
97
+ );
98
+
99
+ if (hasComputedFields) {
100
+ return extractFromProjectionNodes(table, projectionNodes!, context, options);
101
+ }
102
+
103
+ if (mode === 'selected' && plan) {
104
+ return extractSelectedSchema(table, plan, context, options);
105
+ }
106
+
107
+ return extractFullTableSchema(table, context, options);
108
+ };
109
+
110
+ /**
111
+ * Input schema extraction (write payloads)
112
+ */
113
+ const extractInputSchema = (
114
+ table: TableDef,
115
+ context: SchemaExtractionContext,
116
+ options: InputSchemaOptions
117
+ ): OpenApiSchema => {
118
+ const cacheKey = `${table.name}:${options.mode ?? 'create'}`;
119
+
120
+ if (context.schemaCache.has(cacheKey)) {
121
+ return context.schemaCache.get(cacheKey)!;
122
+ }
123
+
124
+ if (context.visitedTables.has(cacheKey) && context.depth > 0) {
125
+ return buildCircularReferenceSchema(table.name, 'input');
126
+ }
127
+
128
+ context.visitedTables.add(cacheKey);
129
+
130
+ const properties: Record<string, JsonSchemaProperty> = {};
131
+ const required: string[] = [];
132
+ const primaryKey = findPrimaryKey(table);
133
+
134
+ for (const [columnName, column] of Object.entries(table.columns)) {
135
+ const isPrimary = columnName === primaryKey || column.primary;
136
+ if (options.excludePrimaryKey && isPrimary) continue;
137
+ if (options.omitReadOnly && isReadOnlyColumn(column)) continue;
138
+
139
+ properties[columnName] = mapColumnType(column, options);
140
+
141
+ if (options.mode === 'create' && isRequiredForCreate(column)) {
142
+ required.push(columnName);
143
+ }
144
+
145
+ if (options.mode === 'update' && options.requirePrimaryKey && isPrimary) {
146
+ required.push(columnName);
147
+ }
148
+ }
149
+
150
+ if (options.includeRelations && context.depth < context.maxDepth) {
151
+ for (const [relationName, relation] of Object.entries(table.relations)) {
152
+ properties[relationName] = extractInputRelationSchema(
153
+ relation,
154
+ { ...context, depth: context.depth + 1 },
155
+ options
156
+ );
157
+ }
158
+ }
159
+
160
+ const schema: OpenApiSchema = {
161
+ type: 'object',
162
+ properties,
163
+ required
164
+ };
165
+
166
+ context.schemaCache.set(cacheKey, schema);
167
+ return schema;
168
+ };
169
+
170
+ const isReadOnlyColumn = (column: { autoIncrement?: boolean; generated?: string }): boolean =>
171
+ Boolean(column.autoIncrement || column.generated === 'always');
172
+
173
+ const isRequiredForCreate = (column: { notNull?: boolean; primary?: boolean; default?: unknown; autoIncrement?: boolean; generated?: string }): boolean => {
174
+ if (isReadOnlyColumn(column)) return false;
175
+ if (column.default !== undefined) return false;
176
+ return Boolean(column.notNull || column.primary);
177
+ };
178
+
179
+ const buildPrimaryKeySchema = (
180
+ table: TableDef,
181
+ options: InputSchemaOptions
182
+ ): JsonSchemaProperty => {
183
+ const primaryKey = findPrimaryKey(table);
184
+ const column = table.columns[primaryKey];
185
+ if (!column) {
186
+ return {
187
+ anyOf: [
188
+ { type: 'string' as JsonSchemaType },
189
+ { type: 'number' as JsonSchemaType },
190
+ { type: 'integer' as JsonSchemaType }
191
+ ]
192
+ };
193
+ }
194
+
195
+ return mapColumnType(column, options);
196
+ };
197
+
198
+ const extractInputRelationSchema = (
199
+ relation: RelationDef,
200
+ context: SchemaExtractionContext,
201
+ options: InputSchemaOptions
202
+ ): JsonSchemaProperty => {
203
+ const { type: relationType, isNullable } = mapRelationType(relation.type);
204
+ const relationMode = options.relationMode ?? 'mixed';
205
+ const allowIds = relationMode !== 'objects';
206
+ const allowObjects = relationMode !== 'ids';
207
+
208
+ const variants: JsonSchemaProperty[] = [];
209
+
210
+ if (allowIds) {
211
+ variants.push(buildPrimaryKeySchema(relation.target, options));
212
+ }
213
+
214
+ if (allowObjects) {
215
+ const targetSchema = extractInputSchema(relation.target, context, options);
216
+ variants.push(targetSchema as JsonSchemaProperty);
217
+ }
218
+
219
+ const itemSchema: JsonSchemaProperty =
220
+ variants.length === 1 ? variants[0] : { anyOf: variants };
221
+
222
+ if (relationType === 'array') {
223
+ return {
224
+ type: 'array',
225
+ items: itemSchema,
226
+ nullable: isNullable
227
+ };
228
+ }
229
+
230
+ return {
231
+ ...itemSchema,
232
+ nullable: isNullable
233
+ };
234
+ };
235
+
236
+ /**
237
+ * Extracts schema from projection nodes (handles computed fields)
238
+ */
239
+ const extractFromProjectionNodes = (
240
+ table: TableDef,
241
+ projectionNodes: ProjectionNode[],
242
+ context: SchemaExtractionContext,
243
+ options: OutputSchemaOptions
244
+ ): OpenApiSchema => {
245
+ const properties: Record<string, JsonSchemaProperty> = {};
246
+ const required: string[] = [];
247
+ const includeDescriptions = Boolean(options.includeDescriptions);
248
+
249
+ for (const node of projectionNodes) {
250
+ if (!node || typeof node !== 'object') continue;
251
+
252
+ const projection = node as { type: string; alias?: string; fn?: string; value?: unknown };
253
+ const propertyName = projection.alias ?? '';
254
+
255
+ if (!propertyName) continue;
256
+
257
+ if (projection.type === 'Column') {
258
+ const columnNode = node as { table: string; name: string };
259
+ const column = table.columns[columnNode.name];
260
+ if (!column) continue;
261
+
262
+ const property = mapColumnType(column, options);
263
+ properties[propertyName] = property;
264
+
265
+ if (column.notNull || column.primary) {
266
+ required.push(propertyName);
267
+ }
268
+ } else if (projection.type === 'Function' || projection.type === 'WindowFunction') {
269
+ const fnNode = node as { fn?: string; name?: string };
270
+ const functionName = fnNode.fn?.toUpperCase() ?? fnNode.name?.toUpperCase() ?? '';
271
+ const propertySchema = projection.type === 'Function'
272
+ ? mapFunctionNodeToSchema(functionName, includeDescriptions)
273
+ : mapWindowFunctionToSchema(functionName, includeDescriptions);
274
+
275
+ properties[propertyName] = propertySchema;
276
+
277
+ const isCountFunction = functionName === 'COUNT';
278
+ const isWindowRankFunction = functionName === 'ROW_NUMBER' || functionName === 'RANK';
279
+
280
+ if (isCountFunction || isWindowRankFunction) {
281
+ required.push(propertyName);
282
+ }
283
+ } else if (projection.type === 'CaseExpression') {
284
+ const propertySchema: JsonSchemaProperty = {
285
+ type: 'string' as JsonSchemaType,
286
+ nullable: true
287
+ };
288
+ if (includeDescriptions) {
289
+ propertySchema.description = 'Computed CASE expression';
290
+ }
291
+ properties[propertyName] = propertySchema;
292
+ } else if (projection.type === 'ScalarSubquery') {
293
+ const propertySchema: JsonSchemaProperty = {
294
+ type: 'object' as JsonSchemaType,
295
+ nullable: true
296
+ };
297
+ if (includeDescriptions) {
298
+ propertySchema.description = 'Subquery result';
299
+ }
300
+ properties[propertyName] = propertySchema;
301
+ } else if (projection.type === 'CastExpression') {
302
+ const propertySchema: JsonSchemaProperty = {
303
+ type: 'string' as JsonSchemaType,
304
+ nullable: true
305
+ };
306
+ if (includeDescriptions) {
307
+ propertySchema.description = 'CAST expression result';
308
+ }
309
+ properties[propertyName] = propertySchema;
310
+ }
311
+ }
312
+
313
+ return {
314
+ type: 'object',
315
+ properties,
316
+ required
317
+ };
318
+ };
319
+
320
+ /**
321
+ * Maps SQL aggregate functions to OpenAPI types
322
+ */
323
+ const mapFunctionNodeToSchema = (
324
+ functionName: string,
325
+ includeDescriptions: boolean
326
+ ): JsonSchemaProperty => {
327
+ const upperName = functionName.toUpperCase();
328
+
329
+ switch (upperName) {
330
+ case 'COUNT':
331
+ case 'SUM':
332
+ case 'AVG':
333
+ case 'MIN':
334
+ case 'MAX':
335
+ return withOptionalDescription({
336
+ type: 'number' as JsonSchemaType,
337
+ nullable: false
338
+ }, includeDescriptions, `${upperName} aggregate function result`);
339
+
340
+ case 'GROUP_CONCAT':
341
+ case 'STRING_AGG':
342
+ case 'ARRAY_AGG':
343
+ return withOptionalDescription({
344
+ type: 'string' as JsonSchemaType,
345
+ nullable: true
346
+ }, includeDescriptions, `${upperName} aggregate function result`);
347
+
348
+ case 'JSON_ARRAYAGG':
349
+ case 'JSON_OBJECTAGG':
350
+ return withOptionalDescription({
351
+ type: 'object' as JsonSchemaType,
352
+ nullable: true
353
+ }, includeDescriptions, `${upperName} aggregate function result`);
354
+
355
+ default:
356
+ return withOptionalDescription({
357
+ type: 'string' as JsonSchemaType,
358
+ nullable: true
359
+ }, includeDescriptions, `Unknown function: ${functionName}`);
360
+ }
361
+ };
362
+
363
+ /**
364
+ * Maps SQL window functions to OpenAPI types
365
+ */
366
+ const mapWindowFunctionToSchema = (
367
+ functionName: string,
368
+ includeDescriptions: boolean
369
+ ): JsonSchemaProperty => {
370
+ const upperName = functionName.toUpperCase();
371
+
372
+ switch (upperName) {
373
+ case 'ROW_NUMBER':
374
+ case 'RANK':
375
+ case 'DENSE_RANK':
376
+ case 'NTILE':
377
+ return withOptionalDescription({
378
+ type: 'integer' as JsonSchemaType,
379
+ nullable: false
380
+ }, includeDescriptions, `${upperName} window function result`);
381
+
382
+ case 'LAG':
383
+ case 'LEAD':
384
+ case 'FIRST_VALUE':
385
+ case 'LAST_VALUE':
386
+ return withOptionalDescription({
387
+ type: 'string' as JsonSchemaType,
388
+ nullable: true
389
+ }, includeDescriptions, `${upperName} window function result`);
390
+
391
+ default:
392
+ return withOptionalDescription({
393
+ type: 'string' as JsonSchemaType,
394
+ nullable: true
395
+ }, includeDescriptions, `Unknown window function: ${functionName}`);
396
+ }
397
+ };
398
+
399
+ const withOptionalDescription = (
400
+ schema: JsonSchemaProperty,
401
+ includeDescriptions: boolean,
402
+ description: string
403
+ ): JsonSchemaProperty => {
404
+ if (includeDescriptions) {
405
+ return { ...schema, description };
406
+ }
407
+ return schema;
408
+ };
409
+
410
+ /**
411
+ * Extracts schema with only selected columns and relations
412
+ */
413
+ const extractSelectedSchema = (
414
+ table: TableDef,
415
+ plan: HydrationPlan,
416
+ context: SchemaExtractionContext,
417
+ options: OutputSchemaOptions
418
+ ): OpenApiSchema => {
419
+ const properties: Record<string, JsonSchemaProperty> = {};
420
+ const required: string[] = [];
421
+
422
+ plan.rootColumns.forEach(columnName => {
423
+ const column = table.columns[columnName];
424
+ if (!column) return;
425
+
426
+ properties[columnName] = mapColumnType(column, options);
427
+
428
+ if (column.notNull || column.primary) {
429
+ required.push(columnName);
430
+ }
431
+ });
432
+
433
+ plan.relations.forEach(relationPlan => {
434
+ const relation = table.relations[relationPlan.name];
435
+ if (!relation) return;
436
+
437
+ const relationSchema = extractRelationSchema(
438
+ relation,
439
+ relationPlan,
440
+ relationPlan.columns,
441
+ context,
442
+ options
443
+ );
444
+
445
+ properties[relationPlan.name] = relationSchema;
446
+
447
+ const { isNullable } = mapRelationType(relation.type);
448
+ if (!isNullable && relationPlan.name) {
449
+ required.push(relationPlan.name);
450
+ }
451
+ });
452
+
453
+ return {
454
+ type: 'object',
455
+ properties,
456
+ required
457
+ };
458
+ };
459
+
460
+ /**
461
+ * Extracts full table schema (all columns, all relations)
462
+ */
463
+ const extractFullTableSchema = (
464
+ table: TableDef,
465
+ context: SchemaExtractionContext,
466
+ options: OutputSchemaOptions
467
+ ): OpenApiSchema => {
468
+ const cacheKey = table.name;
469
+
470
+ if (context.schemaCache.has(cacheKey)) {
471
+ return context.schemaCache.get(cacheKey)!;
472
+ }
473
+
474
+ if (context.visitedTables.has(cacheKey) && context.depth > 0) {
475
+ return buildCircularReferenceSchema(table.name, 'output');
476
+ }
477
+
478
+ context.visitedTables.add(cacheKey);
479
+
480
+ const properties: Record<string, JsonSchemaProperty> = {};
481
+ const required: string[] = [];
482
+
483
+ Object.entries(table.columns).forEach(([columnName, column]) => {
484
+ properties[columnName] = mapColumnType(column, options);
485
+
486
+ if (column.notNull || column.primary) {
487
+ required.push(columnName);
488
+ }
489
+ });
490
+
491
+ Object.entries(table.relations).forEach(([relationName, relation]) => {
492
+ if (context.depth >= context.maxDepth) {
493
+ return;
494
+ }
495
+
496
+ const relationSchema = extractRelationSchema(
497
+ relation,
498
+ undefined,
499
+ [],
500
+ { ...context, depth: context.depth + 1 },
501
+ options
502
+ );
503
+
504
+ properties[relationName] = relationSchema;
505
+
506
+ const { isNullable } = mapRelationType(relation.type);
507
+ if (!isNullable) {
508
+ required.push(relationName);
509
+ }
510
+ });
511
+
512
+ const schema: OpenApiSchema = {
513
+ type: 'object',
514
+ properties,
515
+ required
516
+ };
517
+
518
+ context.schemaCache.set(cacheKey, schema);
519
+ return schema;
520
+ };
521
+
522
+ /**
523
+ * Extracts schema for a single relation
524
+ */
525
+ const extractRelationSchema = (
526
+ relation: RelationDef,
527
+ relationPlan: HydrationRelationPlan | undefined,
528
+ selectedColumns: string[],
529
+ context: SchemaExtractionContext,
530
+ options: OutputSchemaOptions
531
+ ): JsonSchemaProperty => {
532
+ const targetTable = relation.target;
533
+ const { type: relationType, isNullable } = mapRelationType(relation.type);
534
+
535
+ let targetSchema: OpenApiSchema;
536
+
537
+ if (relationPlan && selectedColumns.length > 0) {
538
+ const plan: HydrationPlan = {
539
+ rootTable: targetTable.name,
540
+ rootPrimaryKey: relationPlan.targetPrimaryKey,
541
+ rootColumns: selectedColumns,
542
+ relations: []
543
+ };
544
+
545
+ targetSchema = extractSelectedSchema(targetTable, plan, context, options);
546
+ } else {
547
+ targetSchema = extractFullTableSchema(targetTable, context, options);
548
+ }
549
+
550
+ if (relationType === 'array') {
551
+ return {
552
+ type: 'array',
553
+ items: targetSchema as JsonSchemaProperty,
554
+ nullable: isNullable
555
+ };
556
+ }
557
+
558
+ return {
559
+ type: 'object' as JsonSchemaType,
560
+ properties: targetSchema.properties,
561
+ required: targetSchema.required,
562
+ nullable: isNullable,
563
+ description: targetSchema.description
564
+ };
565
+ };
566
+
567
+ const buildCircularReferenceSchema = (
568
+ tableName: string,
569
+ kind: 'input' | 'output'
570
+ ): OpenApiSchema => ({
571
+ type: 'object',
572
+ properties: {
573
+ _ref: {
574
+ type: 'string' as JsonSchemaType,
575
+ description: `Circular ${kind} reference to ${tableName}`
576
+ }
577
+ },
578
+ required: []
579
+ });
580
+
581
+ /**
582
+ * Converts a schema to a JSON string with optional pretty printing
583
+ */
584
+ export const schemaToJson = (schema: OpenApiSchema, pretty = false): string => {
585
+ return JSON.stringify(schema, null, pretty ? 2 : 0);
586
+ };