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.
- package/dist/index.cjs +795 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +174 -2
- package/dist/index.d.ts +174 -2
- package/dist/index.js +788 -5
- package/dist/index.js.map +1 -1
- package/package.json +10 -2
- package/src/core/dialect/postgres/index.ts +9 -5
- package/src/core/execution/db-executor.ts +5 -4
- package/src/core/execution/executors/mysql-executor.ts +9 -7
- package/src/index.ts +6 -4
- package/src/openapi/index.ts +4 -0
- package/src/openapi/query-parameters.ts +206 -0
- package/src/openapi/schema-extractor.ts +586 -0
- package/src/openapi/schema-types.ts +158 -0
- package/src/openapi/type-mappers.ts +227 -0
- package/src/orm/unit-of-work.ts +25 -13
- package/src/query-builder/select.ts +35 -11
|
@@ -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
|
+
};
|