metal-orm 1.0.87 → 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.
- package/dist/index.cjs +175 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +175 -61
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/openapi/schema-extractor-input.ts +139 -0
- package/src/openapi/schema-extractor-output.ts +427 -0
- package/src/openapi/schema-extractor-utils.ts +110 -0
- package/src/openapi/schema-extractor.ts +41 -516
- package/src/openapi/schema-types.ts +18 -0
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import type { TableDef } from '../schema/table.js';
|
|
2
|
-
import type {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
}
|