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.
- package/dist/index.cjs +210 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -17
- package/dist/index.d.ts +29 -17
- package/dist/index.js +210 -97
- 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
- package/src/orm/execute.ts +12 -26
- package/src/query-builder/select/select-operations.ts +4 -12
- package/src/query-builder/select.ts +23 -25
package/package.json
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { TableDef } from '../schema/table.js';
|
|
2
|
+
import type { RelationDef } from '../schema/relation.js';
|
|
3
|
+
import { findPrimaryKey } from '../query-builder/hydration-planner.js';
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
OpenApiSchema,
|
|
7
|
+
SchemaExtractionContext,
|
|
8
|
+
InputSchemaOptions,
|
|
9
|
+
JsonSchemaProperty,
|
|
10
|
+
JsonSchemaType
|
|
11
|
+
} from './schema-types.js';
|
|
12
|
+
import { mapColumnType, mapRelationType } from './type-mappers.js';
|
|
13
|
+
import { buildCircularReferenceSchema } from './schema-extractor-utils.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Input schema extraction (write payloads)
|
|
17
|
+
*/
|
|
18
|
+
export const extractInputSchema = (
|
|
19
|
+
table: TableDef,
|
|
20
|
+
context: SchemaExtractionContext,
|
|
21
|
+
options: InputSchemaOptions
|
|
22
|
+
): OpenApiSchema => {
|
|
23
|
+
const cacheKey = `${table.name}:${options.mode ?? 'create'}`;
|
|
24
|
+
|
|
25
|
+
if (context.schemaCache.has(cacheKey)) {
|
|
26
|
+
return context.schemaCache.get(cacheKey)!;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (context.visitedTables.has(cacheKey) && context.depth > 0) {
|
|
30
|
+
return buildCircularReferenceSchema(table.name, 'input');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
context.visitedTables.add(cacheKey);
|
|
34
|
+
|
|
35
|
+
const properties: Record<string, JsonSchemaProperty> = {};
|
|
36
|
+
const required: string[] = [];
|
|
37
|
+
const primaryKey = findPrimaryKey(table);
|
|
38
|
+
|
|
39
|
+
for (const [columnName, column] of Object.entries(table.columns)) {
|
|
40
|
+
const isPrimary = columnName === primaryKey || column.primary;
|
|
41
|
+
if (options.excludePrimaryKey && isPrimary) continue;
|
|
42
|
+
if (options.omitReadOnly && isReadOnlyColumn(column)) continue;
|
|
43
|
+
|
|
44
|
+
properties[columnName] = mapColumnType(column, options);
|
|
45
|
+
|
|
46
|
+
if (options.mode === 'create' && isRequiredForCreate(column)) {
|
|
47
|
+
required.push(columnName);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (options.mode === 'update' && options.requirePrimaryKey && isPrimary) {
|
|
51
|
+
required.push(columnName);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (options.includeRelations && context.depth < context.maxDepth) {
|
|
56
|
+
for (const [relationName, relation] of Object.entries(table.relations)) {
|
|
57
|
+
properties[relationName] = extractInputRelationSchema(
|
|
58
|
+
relation,
|
|
59
|
+
{ ...context, depth: context.depth + 1 },
|
|
60
|
+
options
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const schema: OpenApiSchema = {
|
|
66
|
+
type: 'object',
|
|
67
|
+
properties,
|
|
68
|
+
required
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
context.schemaCache.set(cacheKey, schema);
|
|
72
|
+
return schema;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const isReadOnlyColumn = (column: { autoIncrement?: boolean; generated?: string }): boolean =>
|
|
76
|
+
Boolean(column.autoIncrement || column.generated === 'always');
|
|
77
|
+
|
|
78
|
+
const isRequiredForCreate = (column: { notNull?: boolean; primary?: boolean; default?: unknown; autoIncrement?: boolean; generated?: string }): boolean => {
|
|
79
|
+
if (isReadOnlyColumn(column)) return false;
|
|
80
|
+
if (column.default !== undefined) return false;
|
|
81
|
+
return Boolean(column.notNull || column.primary);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const buildPrimaryKeySchema = (
|
|
85
|
+
table: TableDef,
|
|
86
|
+
options: InputSchemaOptions
|
|
87
|
+
): JsonSchemaProperty => {
|
|
88
|
+
const primaryKey = findPrimaryKey(table);
|
|
89
|
+
const column = table.columns[primaryKey];
|
|
90
|
+
if (!column) {
|
|
91
|
+
return {
|
|
92
|
+
anyOf: [
|
|
93
|
+
{ type: 'string' as JsonSchemaType },
|
|
94
|
+
{ type: 'number' as JsonSchemaType },
|
|
95
|
+
{ type: 'integer' as JsonSchemaType }
|
|
96
|
+
]
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return mapColumnType(column, options);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const extractInputRelationSchema = (
|
|
104
|
+
relation: RelationDef,
|
|
105
|
+
context: SchemaExtractionContext,
|
|
106
|
+
options: InputSchemaOptions
|
|
107
|
+
): JsonSchemaProperty => {
|
|
108
|
+
const { type: relationType, isNullable } = mapRelationType(relation.type);
|
|
109
|
+
const relationMode = options.relationMode ?? 'mixed';
|
|
110
|
+
const allowIds = relationMode !== 'objects';
|
|
111
|
+
const allowObjects = relationMode !== 'ids';
|
|
112
|
+
|
|
113
|
+
const variants: JsonSchemaProperty[] = [];
|
|
114
|
+
|
|
115
|
+
if (allowIds) {
|
|
116
|
+
variants.push(buildPrimaryKeySchema(relation.target, options));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (allowObjects) {
|
|
120
|
+
const targetSchema = extractInputSchema(relation.target, context, options);
|
|
121
|
+
variants.push(targetSchema as JsonSchemaProperty);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const itemSchema: JsonSchemaProperty =
|
|
125
|
+
variants.length === 1 ? variants[0] : { anyOf: variants };
|
|
126
|
+
|
|
127
|
+
if (relationType === 'array') {
|
|
128
|
+
return {
|
|
129
|
+
type: 'array',
|
|
130
|
+
items: itemSchema,
|
|
131
|
+
nullable: isNullable
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
...itemSchema,
|
|
137
|
+
nullable: isNullable
|
|
138
|
+
};
|
|
139
|
+
};
|
|
@@ -0,0 +1,427 @@
|
|
|
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
|
+
|
|
6
|
+
import type {
|
|
7
|
+
OpenApiSchema,
|
|
8
|
+
SchemaExtractionContext,
|
|
9
|
+
OutputSchemaOptions,
|
|
10
|
+
JsonSchemaProperty,
|
|
11
|
+
JsonSchemaType
|
|
12
|
+
} from './schema-types.js';
|
|
13
|
+
import { mapColumnType, mapRelationType } from './type-mappers.js';
|
|
14
|
+
import {
|
|
15
|
+
buildCircularReferenceSchema,
|
|
16
|
+
ensureComponentRef,
|
|
17
|
+
hasComputedProjection,
|
|
18
|
+
resolveComponentName,
|
|
19
|
+
resolveSelectedComponentName,
|
|
20
|
+
shouldUseSelectedSchema
|
|
21
|
+
} from './schema-extractor-utils.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Output schema extraction (query results)
|
|
25
|
+
*/
|
|
26
|
+
export const extractOutputSchema = (
|
|
27
|
+
table: TableDef,
|
|
28
|
+
plan: HydrationPlan | undefined,
|
|
29
|
+
projectionNodes: ProjectionNode[] | undefined,
|
|
30
|
+
context: SchemaExtractionContext,
|
|
31
|
+
options: OutputSchemaOptions
|
|
32
|
+
): OpenApiSchema => {
|
|
33
|
+
const hasComputedFields = hasComputedProjection(projectionNodes);
|
|
34
|
+
|
|
35
|
+
if (hasComputedFields) {
|
|
36
|
+
return extractFromProjectionNodes(table, projectionNodes!, context, options);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (shouldUseSelectedSchema(options, plan, projectionNodes)) {
|
|
40
|
+
return extractSelectedSchema(table, plan, context, options);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return extractFullTableSchema(table, context, options);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extracts schema from projection nodes (handles computed fields)
|
|
48
|
+
*/
|
|
49
|
+
const extractFromProjectionNodes = (
|
|
50
|
+
table: TableDef,
|
|
51
|
+
projectionNodes: ProjectionNode[],
|
|
52
|
+
context: SchemaExtractionContext,
|
|
53
|
+
options: OutputSchemaOptions
|
|
54
|
+
): OpenApiSchema => {
|
|
55
|
+
const properties: Record<string, JsonSchemaProperty> = {};
|
|
56
|
+
const required: string[] = [];
|
|
57
|
+
const includeDescriptions = Boolean(options.includeDescriptions);
|
|
58
|
+
|
|
59
|
+
for (const node of projectionNodes) {
|
|
60
|
+
if (!node || typeof node !== 'object') continue;
|
|
61
|
+
|
|
62
|
+
const projection = node as { type: string; alias?: string; fn?: string; value?: unknown };
|
|
63
|
+
const propertyName = projection.alias ?? '';
|
|
64
|
+
|
|
65
|
+
if (!propertyName) continue;
|
|
66
|
+
|
|
67
|
+
if (projection.type === 'Column') {
|
|
68
|
+
const columnNode = node as { table: string; name: string };
|
|
69
|
+
const column = table.columns[columnNode.name];
|
|
70
|
+
if (!column) continue;
|
|
71
|
+
|
|
72
|
+
const property = mapColumnType(column, options);
|
|
73
|
+
properties[propertyName] = property;
|
|
74
|
+
|
|
75
|
+
if (column.notNull || column.primary) {
|
|
76
|
+
required.push(propertyName);
|
|
77
|
+
}
|
|
78
|
+
} else if (projection.type === 'Function' || projection.type === 'WindowFunction') {
|
|
79
|
+
const fnNode = node as { fn?: string; name?: string };
|
|
80
|
+
const functionName = fnNode.fn?.toUpperCase() ?? fnNode.name?.toUpperCase() ?? '';
|
|
81
|
+
const propertySchema = projection.type === 'Function'
|
|
82
|
+
? mapFunctionNodeToSchema(functionName, includeDescriptions)
|
|
83
|
+
: mapWindowFunctionToSchema(functionName, includeDescriptions);
|
|
84
|
+
|
|
85
|
+
properties[propertyName] = propertySchema;
|
|
86
|
+
|
|
87
|
+
const isCountFunction = functionName === 'COUNT';
|
|
88
|
+
const isWindowRankFunction = functionName === 'ROW_NUMBER' || functionName === 'RANK';
|
|
89
|
+
|
|
90
|
+
if (isCountFunction || isWindowRankFunction) {
|
|
91
|
+
required.push(propertyName);
|
|
92
|
+
}
|
|
93
|
+
} else if (projection.type === 'CaseExpression') {
|
|
94
|
+
const propertySchema: JsonSchemaProperty = {
|
|
95
|
+
type: 'string' as JsonSchemaType,
|
|
96
|
+
nullable: true
|
|
97
|
+
};
|
|
98
|
+
if (includeDescriptions) {
|
|
99
|
+
propertySchema.description = 'Computed CASE expression';
|
|
100
|
+
}
|
|
101
|
+
properties[propertyName] = propertySchema;
|
|
102
|
+
} else if (projection.type === 'ScalarSubquery') {
|
|
103
|
+
const propertySchema: JsonSchemaProperty = {
|
|
104
|
+
type: 'object' as JsonSchemaType,
|
|
105
|
+
nullable: true
|
|
106
|
+
};
|
|
107
|
+
if (includeDescriptions) {
|
|
108
|
+
propertySchema.description = 'Subquery result';
|
|
109
|
+
}
|
|
110
|
+
properties[propertyName] = propertySchema;
|
|
111
|
+
} else if (projection.type === 'CastExpression') {
|
|
112
|
+
const propertySchema: JsonSchemaProperty = {
|
|
113
|
+
type: 'string' as JsonSchemaType,
|
|
114
|
+
nullable: true
|
|
115
|
+
};
|
|
116
|
+
if (includeDescriptions) {
|
|
117
|
+
propertySchema.description = 'CAST expression result';
|
|
118
|
+
}
|
|
119
|
+
properties[propertyName] = propertySchema;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
type: 'object',
|
|
125
|
+
properties,
|
|
126
|
+
required
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Maps SQL aggregate functions to OpenAPI types
|
|
132
|
+
*/
|
|
133
|
+
const mapFunctionNodeToSchema = (
|
|
134
|
+
functionName: string,
|
|
135
|
+
includeDescriptions: boolean
|
|
136
|
+
): JsonSchemaProperty => {
|
|
137
|
+
const upperName = functionName.toUpperCase();
|
|
138
|
+
|
|
139
|
+
switch (upperName) {
|
|
140
|
+
case 'COUNT':
|
|
141
|
+
case 'SUM':
|
|
142
|
+
case 'AVG':
|
|
143
|
+
case 'MIN':
|
|
144
|
+
case 'MAX':
|
|
145
|
+
return withOptionalDescription({
|
|
146
|
+
type: 'number' as JsonSchemaType,
|
|
147
|
+
nullable: false
|
|
148
|
+
}, includeDescriptions, `${upperName} aggregate function result`);
|
|
149
|
+
|
|
150
|
+
case 'GROUP_CONCAT':
|
|
151
|
+
case 'STRING_AGG':
|
|
152
|
+
case 'ARRAY_AGG':
|
|
153
|
+
return withOptionalDescription({
|
|
154
|
+
type: 'string' as JsonSchemaType,
|
|
155
|
+
nullable: true
|
|
156
|
+
}, includeDescriptions, `${upperName} aggregate function result`);
|
|
157
|
+
|
|
158
|
+
case 'JSON_ARRAYAGG':
|
|
159
|
+
case 'JSON_OBJECTAGG':
|
|
160
|
+
return withOptionalDescription({
|
|
161
|
+
type: 'object' as JsonSchemaType,
|
|
162
|
+
nullable: true
|
|
163
|
+
}, includeDescriptions, `${upperName} aggregate function result`);
|
|
164
|
+
|
|
165
|
+
default:
|
|
166
|
+
return withOptionalDescription({
|
|
167
|
+
type: 'string' as JsonSchemaType,
|
|
168
|
+
nullable: true
|
|
169
|
+
}, includeDescriptions, `Unknown function: ${functionName}`);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Maps SQL window functions to OpenAPI types
|
|
175
|
+
*/
|
|
176
|
+
const mapWindowFunctionToSchema = (
|
|
177
|
+
functionName: string,
|
|
178
|
+
includeDescriptions: boolean
|
|
179
|
+
): JsonSchemaProperty => {
|
|
180
|
+
const upperName = functionName.toUpperCase();
|
|
181
|
+
|
|
182
|
+
switch (upperName) {
|
|
183
|
+
case 'ROW_NUMBER':
|
|
184
|
+
case 'RANK':
|
|
185
|
+
case 'DENSE_RANK':
|
|
186
|
+
case 'NTILE':
|
|
187
|
+
return withOptionalDescription({
|
|
188
|
+
type: 'integer' as JsonSchemaType,
|
|
189
|
+
nullable: false
|
|
190
|
+
}, includeDescriptions, `${upperName} window function result`);
|
|
191
|
+
|
|
192
|
+
case 'LAG':
|
|
193
|
+
case 'LEAD':
|
|
194
|
+
case 'FIRST_VALUE':
|
|
195
|
+
case 'LAST_VALUE':
|
|
196
|
+
return withOptionalDescription({
|
|
197
|
+
type: 'string' as JsonSchemaType,
|
|
198
|
+
nullable: true
|
|
199
|
+
}, includeDescriptions, `${upperName} window function result`);
|
|
200
|
+
|
|
201
|
+
default:
|
|
202
|
+
return withOptionalDescription({
|
|
203
|
+
type: 'string' as JsonSchemaType,
|
|
204
|
+
nullable: true
|
|
205
|
+
}, includeDescriptions, `Unknown window function: ${functionName}`);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const withOptionalDescription = (
|
|
210
|
+
schema: JsonSchemaProperty,
|
|
211
|
+
includeDescriptions: boolean,
|
|
212
|
+
description: string
|
|
213
|
+
): JsonSchemaProperty => {
|
|
214
|
+
if (includeDescriptions) {
|
|
215
|
+
return { ...schema, description };
|
|
216
|
+
}
|
|
217
|
+
return schema;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Extracts schema with only selected columns and relations
|
|
222
|
+
*/
|
|
223
|
+
const extractSelectedSchema = (
|
|
224
|
+
table: TableDef,
|
|
225
|
+
plan: HydrationPlan,
|
|
226
|
+
context: SchemaExtractionContext,
|
|
227
|
+
options: OutputSchemaOptions
|
|
228
|
+
): OpenApiSchema => {
|
|
229
|
+
const properties: Record<string, JsonSchemaProperty> = {};
|
|
230
|
+
const required: string[] = [];
|
|
231
|
+
|
|
232
|
+
plan.rootColumns.forEach(columnName => {
|
|
233
|
+
const column = table.columns[columnName];
|
|
234
|
+
if (!column) return;
|
|
235
|
+
|
|
236
|
+
properties[columnName] = mapColumnType(column, options);
|
|
237
|
+
|
|
238
|
+
if (column.notNull || column.primary) {
|
|
239
|
+
required.push(columnName);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
plan.relations.forEach(relationPlan => {
|
|
244
|
+
const relation = table.relations[relationPlan.name];
|
|
245
|
+
if (!relation) return;
|
|
246
|
+
|
|
247
|
+
const relationSchema = extractRelationSchema(
|
|
248
|
+
relation,
|
|
249
|
+
relationPlan,
|
|
250
|
+
relationPlan.columns,
|
|
251
|
+
context,
|
|
252
|
+
options
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
properties[relationPlan.name] = relationSchema;
|
|
256
|
+
|
|
257
|
+
const { isNullable } = mapRelationType(relation.type);
|
|
258
|
+
if (!isNullable && relationPlan.name) {
|
|
259
|
+
required.push(relationPlan.name);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties,
|
|
266
|
+
required
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Extracts full table schema (all columns, all relations)
|
|
272
|
+
*/
|
|
273
|
+
const extractFullTableSchema = (
|
|
274
|
+
table: TableDef,
|
|
275
|
+
context: SchemaExtractionContext,
|
|
276
|
+
options: OutputSchemaOptions
|
|
277
|
+
): OpenApiSchema => {
|
|
278
|
+
const cacheKey = table.name;
|
|
279
|
+
|
|
280
|
+
if (context.schemaCache.has(cacheKey)) {
|
|
281
|
+
return context.schemaCache.get(cacheKey)!;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (context.visitedTables.has(cacheKey) && context.depth > 0) {
|
|
285
|
+
return buildCircularReferenceSchema(table.name, 'output');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
context.visitedTables.add(cacheKey);
|
|
289
|
+
|
|
290
|
+
const properties: Record<string, JsonSchemaProperty> = {};
|
|
291
|
+
const required: string[] = [];
|
|
292
|
+
|
|
293
|
+
Object.entries(table.columns).forEach(([columnName, column]) => {
|
|
294
|
+
properties[columnName] = mapColumnType(column, options);
|
|
295
|
+
|
|
296
|
+
if (column.notNull || column.primary) {
|
|
297
|
+
required.push(columnName);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
Object.entries(table.relations).forEach(([relationName, relation]) => {
|
|
302
|
+
if (context.depth >= context.maxDepth) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const relationSchema = extractRelationSchema(
|
|
307
|
+
relation,
|
|
308
|
+
undefined,
|
|
309
|
+
[],
|
|
310
|
+
{ ...context, depth: context.depth + 1 },
|
|
311
|
+
options
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
properties[relationName] = relationSchema;
|
|
315
|
+
|
|
316
|
+
const { isNullable } = mapRelationType(relation.type);
|
|
317
|
+
if (!isNullable) {
|
|
318
|
+
required.push(relationName);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const schema: OpenApiSchema = {
|
|
323
|
+
type: 'object',
|
|
324
|
+
properties,
|
|
325
|
+
required
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
context.schemaCache.set(cacheKey, schema);
|
|
329
|
+
return schema;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Extracts schema for a single relation
|
|
334
|
+
*/
|
|
335
|
+
const extractRelationSchema = (
|
|
336
|
+
relation: RelationDef,
|
|
337
|
+
relationPlan: HydrationRelationPlan | undefined,
|
|
338
|
+
selectedColumns: string[],
|
|
339
|
+
context: SchemaExtractionContext,
|
|
340
|
+
options: OutputSchemaOptions
|
|
341
|
+
): JsonSchemaProperty => {
|
|
342
|
+
const targetTable = relation.target;
|
|
343
|
+
const { type: relationType, isNullable } = mapRelationType(relation.type);
|
|
344
|
+
|
|
345
|
+
if (options.refMode === 'components' && context.components) {
|
|
346
|
+
if (relationPlan && selectedColumns.length > 0 && options.selectedRefMode === 'components') {
|
|
347
|
+
const plan: HydrationPlan = {
|
|
348
|
+
rootTable: targetTable.name,
|
|
349
|
+
rootPrimaryKey: relationPlan.targetPrimaryKey,
|
|
350
|
+
rootColumns: selectedColumns,
|
|
351
|
+
relations: []
|
|
352
|
+
};
|
|
353
|
+
const componentName = resolveSelectedComponentName(targetTable, plan, options);
|
|
354
|
+
const ref = ensureComponentRef(
|
|
355
|
+
targetTable,
|
|
356
|
+
componentName,
|
|
357
|
+
context,
|
|
358
|
+
() => extractSelectedSchema(targetTable, plan, context, options)
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
if (relationType === 'array') {
|
|
362
|
+
return {
|
|
363
|
+
type: 'array',
|
|
364
|
+
items: ref,
|
|
365
|
+
nullable: isNullable
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
...ref,
|
|
371
|
+
nullable: isNullable
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const componentName = resolveComponentName(targetTable, options);
|
|
376
|
+
const ref = ensureComponentRef(
|
|
377
|
+
targetTable,
|
|
378
|
+
componentName,
|
|
379
|
+
context,
|
|
380
|
+
() => extractFullTableSchema(targetTable, context, options)
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
if (relationType === 'array') {
|
|
384
|
+
return {
|
|
385
|
+
type: 'array',
|
|
386
|
+
items: ref,
|
|
387
|
+
nullable: isNullable
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
...ref,
|
|
393
|
+
nullable: isNullable
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let targetSchema: OpenApiSchema;
|
|
398
|
+
|
|
399
|
+
if (relationPlan && selectedColumns.length > 0) {
|
|
400
|
+
const plan: HydrationPlan = {
|
|
401
|
+
rootTable: targetTable.name,
|
|
402
|
+
rootPrimaryKey: relationPlan.targetPrimaryKey,
|
|
403
|
+
rootColumns: selectedColumns,
|
|
404
|
+
relations: []
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
targetSchema = extractSelectedSchema(targetTable, plan, context, options);
|
|
408
|
+
} else {
|
|
409
|
+
targetSchema = extractFullTableSchema(targetTable, context, options);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (relationType === 'array') {
|
|
413
|
+
return {
|
|
414
|
+
type: 'array',
|
|
415
|
+
items: targetSchema as JsonSchemaProperty,
|
|
416
|
+
nullable: isNullable
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
type: 'object' as JsonSchemaType,
|
|
422
|
+
properties: targetSchema.properties,
|
|
423
|
+
required: targetSchema.required,
|
|
424
|
+
nullable: isNullable,
|
|
425
|
+
description: targetSchema.description
|
|
426
|
+
};
|
|
427
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { TableDef } from '../schema/table.js';
|
|
2
|
+
import type { HydrationPlan } from '../core/hydration/types.js';
|
|
3
|
+
import type { ProjectionNode } from '../query-builder/select-query-state.js';
|
|
4
|
+
import type {
|
|
5
|
+
OpenApiSchema,
|
|
6
|
+
SchemaExtractionContext,
|
|
7
|
+
OutputSchemaOptions,
|
|
8
|
+
JsonSchemaProperty,
|
|
9
|
+
JsonSchemaType
|
|
10
|
+
} from './schema-types.js';
|
|
11
|
+
|
|
12
|
+
export const hasComputedProjection = (projectionNodes?: ProjectionNode[]): boolean =>
|
|
13
|
+
Boolean(projectionNodes && projectionNodes.some(node => node.type !== 'Column'));
|
|
14
|
+
|
|
15
|
+
export const shouldUseSelectedSchema = (
|
|
16
|
+
options: OutputSchemaOptions,
|
|
17
|
+
plan: HydrationPlan | undefined,
|
|
18
|
+
projectionNodes: ProjectionNode[] | undefined
|
|
19
|
+
): boolean => {
|
|
20
|
+
if (!plan || options.mode !== 'selected') return false;
|
|
21
|
+
if (hasComputedProjection(projectionNodes)) return false;
|
|
22
|
+
if (options.refMode === 'components' && options.selectedRefMode !== 'components') return false;
|
|
23
|
+
return true;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const resolveComponentName = (table: TableDef, options: OutputSchemaOptions): string =>
|
|
27
|
+
options.componentName ? options.componentName(table) : table.name;
|
|
28
|
+
|
|
29
|
+
const normalizeColumns = (columns: string[]): string[] =>
|
|
30
|
+
Array.from(new Set(columns)).sort((a, b) => a.localeCompare(b));
|
|
31
|
+
|
|
32
|
+
const buildSelectionSignature = (plan: HydrationPlan): string => {
|
|
33
|
+
const relations = plan.relations
|
|
34
|
+
.map(relation => ({
|
|
35
|
+
name: relation.name,
|
|
36
|
+
columns: normalizeColumns(relation.columns)
|
|
37
|
+
}))
|
|
38
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
39
|
+
|
|
40
|
+
return JSON.stringify({
|
|
41
|
+
root: normalizeColumns(plan.rootColumns),
|
|
42
|
+
relations
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const hashString = (value: string): string => {
|
|
47
|
+
let hash = 2166136261;
|
|
48
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
49
|
+
hash ^= value.charCodeAt(i);
|
|
50
|
+
hash = (hash * 16777619) >>> 0;
|
|
51
|
+
}
|
|
52
|
+
return hash.toString(16).padStart(8, '0');
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const resolveSelectedComponentName = (
|
|
56
|
+
table: TableDef,
|
|
57
|
+
plan: HydrationPlan,
|
|
58
|
+
options: OutputSchemaOptions
|
|
59
|
+
): string => {
|
|
60
|
+
const base = resolveComponentName(table, options);
|
|
61
|
+
const signature = buildSelectionSignature(plan);
|
|
62
|
+
return `${base}__sel_${hashString(signature)}`;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const ensureComponentRef = (
|
|
66
|
+
table: TableDef,
|
|
67
|
+
componentName: string,
|
|
68
|
+
context: SchemaExtractionContext,
|
|
69
|
+
schemaFactory: () => OpenApiSchema
|
|
70
|
+
): JsonSchemaProperty => {
|
|
71
|
+
if (context.components && !context.components.schemas[componentName]) {
|
|
72
|
+
if (!context.visitedTables.has(table.name)) {
|
|
73
|
+
context.components.schemas[componentName] = schemaFactory();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { $ref: `#/components/schemas/${componentName}` };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const registerComponentSchema = (
|
|
81
|
+
name: string,
|
|
82
|
+
schema: OpenApiSchema,
|
|
83
|
+
context: SchemaExtractionContext
|
|
84
|
+
): void => {
|
|
85
|
+
if (!context.components) return;
|
|
86
|
+
if (!context.components.schemas[name]) {
|
|
87
|
+
context.components.schemas[name] = schema;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const createContext = (maxDepth: number): SchemaExtractionContext => ({
|
|
92
|
+
visitedTables: new Set(),
|
|
93
|
+
schemaCache: new Map(),
|
|
94
|
+
depth: 0,
|
|
95
|
+
maxDepth
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export const buildCircularReferenceSchema = (
|
|
99
|
+
tableName: string,
|
|
100
|
+
kind: 'input' | 'output'
|
|
101
|
+
): OpenApiSchema => ({
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: {
|
|
104
|
+
_ref: {
|
|
105
|
+
type: 'string' as JsonSchemaType,
|
|
106
|
+
description: `Circular ${kind} reference to ${tableName}`
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
required: []
|
|
110
|
+
});
|