metal-orm 1.0.86 → 1.0.88

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.
@@ -1,20 +1,24 @@
1
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';
2
+ import type { HydrationPlan } from '../core/hydration/types.js';
4
3
  import type { ProjectionNode } from '../query-builder/select-query-state.js';
5
- import { findPrimaryKey } from '../query-builder/hydration-planner.js';
6
4
 
7
5
  import type {
8
6
  OpenApiSchema,
9
7
  OpenApiSchemaBundle,
10
- SchemaExtractionContext,
11
8
  SchemaOptions,
12
9
  OutputSchemaOptions,
13
- InputSchemaOptions,
14
- JsonSchemaProperty,
15
- JsonSchemaType
10
+ InputSchemaOptions
16
11
  } from './schema-types.js';
17
- import { mapColumnType, mapRelationType } from './type-mappers.js';
12
+ import { extractInputSchema } from './schema-extractor-input.js';
13
+ import { extractOutputSchema } from './schema-extractor-output.js';
14
+ import {
15
+ createContext,
16
+ hasComputedProjection,
17
+ registerComponentSchema,
18
+ resolveComponentName,
19
+ resolveSelectedComponentName,
20
+ shouldUseSelectedSchema
21
+ } from './schema-extractor-utils.js';
18
22
 
19
23
  const DEFAULT_MAX_DEPTH = 5;
20
24
 
@@ -29,17 +33,40 @@ export const extractSchema = (
29
33
  ): OpenApiSchemaBundle => {
30
34
  const outputOptions = resolveOutputOptions(options);
31
35
  const outputContext = createContext(outputOptions.maxDepth ?? DEFAULT_MAX_DEPTH);
36
+ if (outputOptions.refMode === 'components') {
37
+ outputContext.components = { schemas: {} };
38
+ }
32
39
  const output = extractOutputSchema(table, plan, projectionNodes, outputContext, outputOptions);
40
+ const useSelected = shouldUseSelectedSchema(outputOptions, plan, projectionNodes);
41
+ const hasComputedFields = hasComputedProjection(projectionNodes);
42
+
43
+ if (outputOptions.refMode === 'components' && outputContext.components && !hasComputedFields) {
44
+ const componentName = useSelected && plan
45
+ ? resolveSelectedComponentName(table, plan, outputOptions)
46
+ : resolveComponentName(table, outputOptions);
47
+ registerComponentSchema(componentName, output, outputContext);
48
+ }
33
49
 
34
50
  const inputOptions = resolveInputOptions(options);
35
51
  if (!inputOptions) {
36
- return { output };
52
+ return {
53
+ output,
54
+ components: outputContext.components && Object.keys(outputContext.components.schemas).length
55
+ ? outputContext.components
56
+ : undefined
57
+ };
37
58
  }
38
59
 
39
60
  const inputContext = createContext(inputOptions.maxDepth ?? DEFAULT_MAX_DEPTH);
40
61
  const input = extractInputSchema(table, inputContext, inputOptions);
41
62
 
42
- return { output, input };
63
+ return {
64
+ output,
65
+ input,
66
+ components: outputContext.components && Object.keys(outputContext.components.schemas).length
67
+ ? outputContext.components
68
+ : undefined
69
+ };
43
70
  };
44
71
 
45
72
  const resolveOutputOptions = (options: SchemaOptions): OutputSchemaOptions => ({
@@ -49,7 +76,10 @@ const resolveOutputOptions = (options: SchemaOptions): OutputSchemaOptions => ({
49
76
  includeExamples: options.includeExamples,
50
77
  includeDefaults: options.includeDefaults,
51
78
  includeNullable: options.includeNullable,
52
- maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH
79
+ maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
80
+ refMode: options.refMode ?? 'inline',
81
+ selectedRefMode: options.selectedRefMode ?? 'inline',
82
+ componentName: options.componentName
53
83
  });
54
84
 
55
85
  const resolveInputOptions = (options: SchemaOptions): InputSchemaOptions | undefined => {
@@ -73,511 +103,6 @@ const resolveInputOptions = (options: SchemaOptions): InputSchemaOptions | undef
73
103
  };
74
104
  };
75
105
 
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
106
  /**
582
107
  * Converts a schema to a JSON string with optional pretty printing
583
108
  */
@@ -1,3 +1,5 @@
1
+ import type { TableDef } from '../schema/table.js';
2
+
1
3
  /**
2
4
  * OpenAPI 3.1 JSON Schema type representation
3
5
  */
@@ -77,6 +79,13 @@ export interface OpenApiSchema {
77
79
  description?: string;
78
80
  }
79
81
 
82
+ /**
83
+ * OpenAPI 3.1 components container
84
+ */
85
+ export interface OpenApiComponents {
86
+ schemas: Record<string, OpenApiSchema>;
87
+ }
88
+
80
89
  /**
81
90
  * Column-level schema flags
82
91
  */
@@ -101,6 +110,12 @@ export interface OutputSchemaOptions extends ColumnSchemaOptions {
101
110
  mode?: 'selected' | 'full';
102
111
  /** Maximum depth for relation recursion */
103
112
  maxDepth?: number;
113
+ /** Inline schemas vs $ref components */
114
+ refMode?: 'inline' | 'components';
115
+ /** Selected schemas inline vs components when refMode is components */
116
+ selectedRefMode?: 'inline' | 'components';
117
+ /** Customize component names */
118
+ componentName?: (table: TableDef) => string;
104
119
  }
105
120
 
106
121
  export type InputRelationMode = 'ids' | 'objects' | 'mixed';
@@ -141,6 +156,7 @@ export interface OpenApiSchemaBundle {
141
156
  output: OpenApiSchema;
142
157
  input?: OpenApiSchema;
143
158
  parameters?: OpenApiParameter[];
159
+ components?: OpenApiComponents;
144
160
  }
145
161
 
146
162
  /**
@@ -155,4 +171,6 @@ export interface SchemaExtractionContext {
155
171
  depth: number;
156
172
  /** Maximum depth to recurse */
157
173
  maxDepth: number;
174
+ /** Component registry when using refMode=components */
175
+ components?: OpenApiComponents;
158
176
  }