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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metal-orm",
3
- "version": "1.0.86",
3
+ "version": "1.0.88",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "engines": {
@@ -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
+ });