metal-orm 1.0.87 → 1.0.89
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 +226 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -2
- package/dist/index.d.ts +27 -2
- package/dist/index.js +226 -62
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/openapi/schema-extractor-input.ts +193 -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 +51 -517
- package/src/openapi/schema-types.ts +30 -1
|
@@ -1,20 +1,25 @@
|
|
|
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
10
|
InputSchemaOptions,
|
|
14
|
-
JsonSchemaProperty
|
|
15
|
-
JsonSchemaType
|
|
11
|
+
JsonSchemaProperty
|
|
16
12
|
} from './schema-types.js';
|
|
17
|
-
import {
|
|
13
|
+
import { extractInputSchema } from './schema-extractor-input.js';
|
|
14
|
+
import { extractOutputSchema } from './schema-extractor-output.js';
|
|
15
|
+
import {
|
|
16
|
+
createContext,
|
|
17
|
+
hasComputedProjection,
|
|
18
|
+
registerComponentSchema,
|
|
19
|
+
resolveComponentName,
|
|
20
|
+
resolveSelectedComponentName,
|
|
21
|
+
shouldUseSelectedSchema
|
|
22
|
+
} from './schema-extractor-utils.js';
|
|
18
23
|
|
|
19
24
|
const DEFAULT_MAX_DEPTH = 5;
|
|
20
25
|
|
|
@@ -29,17 +34,45 @@ export const extractSchema = (
|
|
|
29
34
|
): OpenApiSchemaBundle => {
|
|
30
35
|
const outputOptions = resolveOutputOptions(options);
|
|
31
36
|
const outputContext = createContext(outputOptions.maxDepth ?? DEFAULT_MAX_DEPTH);
|
|
32
|
-
|
|
37
|
+
if (outputOptions.refMode === 'components') {
|
|
38
|
+
outputContext.components = { schemas: {} };
|
|
39
|
+
}
|
|
40
|
+
const outputSchema = extractOutputSchema(table, plan, projectionNodes, outputContext, outputOptions);
|
|
41
|
+
let output: OpenApiSchema | JsonSchemaProperty = outputSchema;
|
|
42
|
+
const useSelected = shouldUseSelectedSchema(outputOptions, plan, projectionNodes);
|
|
43
|
+
const hasComputedFields = hasComputedProjection(projectionNodes);
|
|
44
|
+
const canUseComponents = outputOptions.refMode === 'components' && outputContext.components && !hasComputedFields;
|
|
45
|
+
|
|
46
|
+
if (canUseComponents) {
|
|
47
|
+
const componentName = useSelected && plan
|
|
48
|
+
? resolveSelectedComponentName(table, plan, outputOptions)
|
|
49
|
+
: resolveComponentName(table, outputOptions);
|
|
50
|
+
registerComponentSchema(componentName, outputSchema, outputContext);
|
|
51
|
+
if (outputOptions.outputAsRef) {
|
|
52
|
+
output = { $ref: `#/components/schemas/${componentName}` };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
33
55
|
|
|
34
56
|
const inputOptions = resolveInputOptions(options);
|
|
35
57
|
if (!inputOptions) {
|
|
36
|
-
return {
|
|
58
|
+
return {
|
|
59
|
+
output,
|
|
60
|
+
components: outputContext.components && Object.keys(outputContext.components.schemas).length
|
|
61
|
+
? outputContext.components
|
|
62
|
+
: undefined
|
|
63
|
+
};
|
|
37
64
|
}
|
|
38
65
|
|
|
39
66
|
const inputContext = createContext(inputOptions.maxDepth ?? DEFAULT_MAX_DEPTH);
|
|
40
67
|
const input = extractInputSchema(table, inputContext, inputOptions);
|
|
41
68
|
|
|
42
|
-
return {
|
|
69
|
+
return {
|
|
70
|
+
output,
|
|
71
|
+
input,
|
|
72
|
+
components: outputContext.components && Object.keys(outputContext.components.schemas).length
|
|
73
|
+
? outputContext.components
|
|
74
|
+
: undefined
|
|
75
|
+
};
|
|
43
76
|
};
|
|
44
77
|
|
|
45
78
|
const resolveOutputOptions = (options: SchemaOptions): OutputSchemaOptions => ({
|
|
@@ -49,7 +82,11 @@ const resolveOutputOptions = (options: SchemaOptions): OutputSchemaOptions => ({
|
|
|
49
82
|
includeExamples: options.includeExamples,
|
|
50
83
|
includeDefaults: options.includeDefaults,
|
|
51
84
|
includeNullable: options.includeNullable,
|
|
52
|
-
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH
|
|
85
|
+
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
86
|
+
refMode: options.refMode ?? 'inline',
|
|
87
|
+
selectedRefMode: options.selectedRefMode ?? 'inline',
|
|
88
|
+
componentName: options.componentName,
|
|
89
|
+
outputAsRef: options.outputAsRef ?? false
|
|
53
90
|
});
|
|
54
91
|
|
|
55
92
|
const resolveInputOptions = (options: SchemaOptions): InputSchemaOptions | undefined => {
|
|
@@ -69,515 +106,12 @@ const resolveInputOptions = (options: SchemaOptions): InputSchemaOptions | undef
|
|
|
69
106
|
maxDepth: input.maxDepth ?? options.maxDepth ?? DEFAULT_MAX_DEPTH,
|
|
70
107
|
omitReadOnly: input.omitReadOnly ?? true,
|
|
71
108
|
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
|
|
109
|
+
requirePrimaryKey: input.requirePrimaryKey ?? (mode === 'update'),
|
|
110
|
+
excludeRelationForeignKeys: input.excludeRelationForeignKeys ?? false,
|
|
111
|
+
relationSelections: input.relationSelections
|
|
457
112
|
};
|
|
458
113
|
};
|
|
459
114
|
|
|
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
115
|
/**
|
|
582
116
|
* Converts a schema to a JSON string with optional pretty printing
|
|
583
117
|
*/
|
|
@@ -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,11 +110,24 @@ 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;
|
|
119
|
+
/** Emit output schema as a component $ref when refMode is components */
|
|
120
|
+
outputAsRef?: boolean;
|
|
104
121
|
}
|
|
105
122
|
|
|
106
123
|
export type InputRelationMode = 'ids' | 'objects' | 'mixed';
|
|
107
124
|
export type InputSchemaMode = 'create' | 'update';
|
|
108
125
|
|
|
126
|
+
export interface RelationSelection {
|
|
127
|
+
pick?: string[];
|
|
128
|
+
omit?: string[];
|
|
129
|
+
}
|
|
130
|
+
|
|
109
131
|
/**
|
|
110
132
|
* Input schema generation options (write payloads)
|
|
111
133
|
*/
|
|
@@ -124,6 +146,10 @@ export interface InputSchemaOptions extends ColumnSchemaOptions {
|
|
|
124
146
|
excludePrimaryKey?: boolean;
|
|
125
147
|
/** Require primary key columns on update payloads */
|
|
126
148
|
requirePrimaryKey?: boolean;
|
|
149
|
+
/** Remove relation foreign keys pointing to the parent from nested inputs */
|
|
150
|
+
excludeRelationForeignKeys?: boolean;
|
|
151
|
+
/** Per-relation field selection for nested inputs */
|
|
152
|
+
relationSelections?: Record<string, RelationSelection>;
|
|
127
153
|
}
|
|
128
154
|
|
|
129
155
|
/**
|
|
@@ -138,9 +164,10 @@ export interface SchemaOptions extends OutputSchemaOptions {
|
|
|
138
164
|
* Input + output schema bundle
|
|
139
165
|
*/
|
|
140
166
|
export interface OpenApiSchemaBundle {
|
|
141
|
-
output: OpenApiSchema;
|
|
167
|
+
output: OpenApiSchema | JsonSchemaProperty;
|
|
142
168
|
input?: OpenApiSchema;
|
|
143
169
|
parameters?: OpenApiParameter[];
|
|
170
|
+
components?: OpenApiComponents;
|
|
144
171
|
}
|
|
145
172
|
|
|
146
173
|
/**
|
|
@@ -155,4 +182,6 @@ export interface SchemaExtractionContext {
|
|
|
155
182
|
depth: number;
|
|
156
183
|
/** Maximum depth to recurse */
|
|
157
184
|
maxDepth: number;
|
|
185
|
+
/** Component registry when using refMode=components */
|
|
186
|
+
components?: OpenApiComponents;
|
|
158
187
|
}
|