metal-orm 1.0.78 → 1.0.80

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.78",
3
+ "version": "1.0.80",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "engines": {
@@ -29,6 +29,10 @@
29
29
  "gen:entities": "node scripts/generate-entities.mjs",
30
30
  "test": "vitest",
31
31
  "test:ui": "vitest --ui",
32
+ "test:mysql": "vitest --run tests/e2e/mysql-memory.test.ts tests/e2e/decorators-mysql-memory.test.ts tests/e2e/save-graph-mysql-memory.test.ts",
33
+ "test:sqlite": "vitest --run tests/e2e/sqlite-memory.test.ts tests/e2e/decorators-sqlite-memory.test.ts tests/e2e/save-graph-sqlite-memory.test.ts",
34
+ "test:pglite": "vitest --run tests/e2e/pglite-memory.test.ts",
35
+ "test:e2e": "vitest tests/e2e",
32
36
  "show-sql": "node scripts/show-sql.mjs",
33
37
  "lint": "node scripts/run-eslint.mjs",
34
38
  "lint:fix": "node scripts/run-eslint.mjs --fix"
@@ -54,11 +58,13 @@
54
58
  }
55
59
  },
56
60
  "devDependencies": {
57
- "@vitest/ui": "^4.0.14",
61
+ "@electric-sql/pglite": "^0.3.14",
58
62
  "@typescript-eslint/eslint-plugin": "^8.20.0",
59
63
  "@typescript-eslint/parser": "^8.20.0",
64
+ "@vitest/ui": "^4.0.14",
60
65
  "eslint": "^8.57.0",
61
66
  "eslint-plugin-deprecation": "^3.0.0",
67
+ "mysql-memory-server": "^1.13.0",
62
68
  "mysql2": "^3.15.3",
63
69
  "pg": "^8.16.3",
64
70
  "sqlite3": "^5.1.7",
@@ -33,11 +33,15 @@ export class PostgresDialect extends SqlDialectBase {
33
33
  * @param id - Identifier to quote
34
34
  * @returns Quoted identifier
35
35
  */
36
- quoteIdentifier(id: string): string {
37
- return `"${id}"`;
38
- }
39
-
40
- /**
36
+ quoteIdentifier(id: string): string {
37
+ return `"${id}"`;
38
+ }
39
+
40
+ protected formatPlaceholder(index: number): string {
41
+ return `$${index}`;
42
+ }
43
+
44
+ /**
41
45
  * Compiles JSON path expression using PostgreSQL syntax
42
46
  * @param node - JSON path node
43
47
  * @returns PostgreSQL JSON path expression
package/src/index.ts CHANGED
@@ -41,16 +41,18 @@ export * from './orm/relations/has-many.js';
41
41
  export * from './orm/relations/belongs-to.js';
42
42
  export * from './orm/relations/many-to-many.js';
43
43
  export * from './orm/execute.js';
44
- export type { EntityContext } from './orm/entity-context.js';
45
- export type { PrimaryKey as EntityPrimaryKey } from './orm/entity-context.js';
44
+ export type { EntityContext } from './orm/entity-context.js';
45
+ export type { PrimaryKey as EntityPrimaryKey } from './orm/entity-context.js';
46
46
  export * from './orm/execution-context.js';
47
47
  export * from './orm/hydration-context.js';
48
48
  export * from './orm/domain-event-bus.js';
49
49
  export * from './orm/runtime-types.js';
50
50
  export * from './orm/query-logger.js';
51
+ export * from './orm/interceptor-pipeline.js';
51
52
  export * from './orm/jsonify.js';
52
- export * from './orm/save-graph-types.js';
53
- export * from './decorators/index.js';
53
+ export * from './orm/save-graph-types.js';
54
+ export * from './decorators/index.js';
55
+ export * from './openapi/index.js';
54
56
 
55
57
  // NEW: execution abstraction + helpers
56
58
  export * from './core/execution/db-executor.js';
@@ -0,0 +1,3 @@
1
+ export * from './schema-types.js';
2
+ export * from './type-mappers.js';
3
+ export * from './schema-extractor.js';
@@ -0,0 +1,418 @@
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 { OpenApiSchema, SchemaExtractionContext, SchemaOptions, JsonSchemaProperty, JsonSchemaType } from './schema-types.js';
7
+ import { mapColumnType, mapRelationType } from './type-mappers.js';
8
+
9
+ /**
10
+ * Extracts OpenAPI 3.1 schema from a query builder's hydration plan
11
+ * @param table - Table definition
12
+ * @param plan - Hydration plan from query builder
13
+ * @param projectionNodes - Projection AST nodes (for computed fields)
14
+ * @param options - Schema generation options
15
+ * @returns OpenAPI 3.1 JSON Schema
16
+ */
17
+ export const extractSchema = (
18
+ table: TableDef,
19
+ plan: HydrationPlan | undefined,
20
+ projectionNodes: ProjectionNode[] | undefined,
21
+ options: SchemaOptions = {}
22
+ ): OpenApiSchema => {
23
+ const mode = options.mode ?? 'full';
24
+
25
+ const context: SchemaExtractionContext = {
26
+ visitedTables: new Set(),
27
+ schemaCache: new Map(),
28
+ depth: 0,
29
+ maxDepth: options.maxDepth ?? 5,
30
+ };
31
+
32
+ // Detect if query contains computed fields (non-Column nodes)
33
+ const hasComputedFields = projectionNodes && projectionNodes.some(
34
+ node => node.type !== 'Column'
35
+ );
36
+
37
+ if (hasComputedFields) {
38
+ // Use projection-based extraction for computed fields + relations
39
+ return extractFromProjectionNodes(table, projectionNodes!, context, options);
40
+ }
41
+
42
+ if (mode === 'selected' && plan) {
43
+ return extractSelectedSchema(table, plan, context, options);
44
+ }
45
+
46
+ return extractFullTableSchema(table, context, options);
47
+ };
48
+
49
+ /**
50
+ * Extracts schema from projection nodes (handles computed fields)
51
+ * @param table - Table definition
52
+ * @param projectionNodes - Projection AST nodes
53
+ * @param context - Schema extraction context
54
+ * @param options - Schema generation options
55
+ * @returns OpenAPI 3.1 JSON Schema
56
+ */
57
+ const extractFromProjectionNodes = (
58
+ table: TableDef,
59
+ projectionNodes: ProjectionNode[],
60
+ context: SchemaExtractionContext,
61
+ options: SchemaOptions
62
+ ): OpenApiSchema => {
63
+ const properties: Record<string, JsonSchemaProperty> = {};
64
+ const required: string[] = [];
65
+
66
+ for (const node of projectionNodes) {
67
+ if (!node || typeof node !== 'object') continue;
68
+
69
+ const projection = node as { type: string; alias?: string; fn?: string; value?: unknown };
70
+ const propertyName = projection.alias ?? '';
71
+
72
+ if (!propertyName) continue;
73
+
74
+ if (projection.type === 'Column') {
75
+ const columnNode = node as { table: string; name: string };
76
+ const column = table.columns[columnNode.name];
77
+ if (!column) continue;
78
+
79
+ const property = mapColumnType(column);
80
+ if (!property.description && options.includeDescriptions && column.comment) {
81
+ property.description = column.comment;
82
+ }
83
+
84
+ properties[propertyName] = property;
85
+
86
+ if (column.notNull || column.primary) {
87
+ required.push(propertyName);
88
+ }
89
+ } else if (projection.type === 'Function' || projection.type === 'WindowFunction') {
90
+ const fnNode = node as { fn?: string; name?: string };
91
+ const functionName = fnNode.fn?.toUpperCase() ?? fnNode.name?.toUpperCase() ?? '';
92
+ const propertySchema = projection.type === 'Function'
93
+ ? mapFunctionNodeToSchema(functionName)
94
+ : mapWindowFunctionToSchema(functionName);
95
+
96
+ properties[propertyName] = propertySchema;
97
+
98
+ const isCountFunction = functionName === 'COUNT';
99
+ const isWindowRankFunction = functionName === 'ROW_NUMBER' || functionName === 'RANK';
100
+
101
+ if (isCountFunction || isWindowRankFunction) {
102
+ required.push(propertyName);
103
+ }
104
+ } else if (projection.type === 'CaseExpression') {
105
+ const propertySchema: JsonSchemaProperty = {
106
+ type: 'string' as JsonSchemaType,
107
+ description: 'Computed CASE expression',
108
+ nullable: true,
109
+ };
110
+ properties[propertyName] = propertySchema;
111
+ } else if (projection.type === 'ScalarSubquery') {
112
+ const propertySchema: JsonSchemaProperty = {
113
+ type: 'object' as JsonSchemaType,
114
+ description: 'Subquery result',
115
+ nullable: true,
116
+ };
117
+ properties[propertyName] = propertySchema;
118
+ } else if (projection.type === 'CastExpression') {
119
+ const propertySchema: JsonSchemaProperty = {
120
+ type: 'string' as JsonSchemaType,
121
+ description: 'CAST expression result',
122
+ nullable: true,
123
+ };
124
+ properties[propertyName] = propertySchema;
125
+ }
126
+ }
127
+
128
+ return {
129
+ type: 'object',
130
+ properties,
131
+ required,
132
+ };
133
+ };
134
+
135
+ /**
136
+ * Maps SQL aggregate functions to OpenAPI types
137
+ * @param functionName - SQL function name
138
+ * @returns OpenAPI JSON Schema property
139
+ */
140
+ const mapFunctionNodeToSchema = (functionName: string): JsonSchemaProperty => {
141
+ const upperName = functionName.toUpperCase();
142
+
143
+ switch (upperName) {
144
+ case 'COUNT':
145
+ case 'SUM':
146
+ case 'AVG':
147
+ case 'MIN':
148
+ case 'MAX':
149
+ return {
150
+ type: 'number' as JsonSchemaType,
151
+ description: `${upperName} aggregate function result`,
152
+ nullable: false,
153
+ };
154
+
155
+ case 'GROUP_CONCAT':
156
+ case 'STRING_AGG':
157
+ case 'ARRAY_AGG':
158
+ return {
159
+ type: 'string' as JsonSchemaType,
160
+ description: `${upperName} aggregate function result`,
161
+ nullable: true,
162
+ };
163
+
164
+ case 'JSON_ARRAYAGG':
165
+ case 'JSON_OBJECTAGG':
166
+ return {
167
+ type: 'object' as JsonSchemaType,
168
+ description: `${upperName} aggregate function result`,
169
+ nullable: true,
170
+ };
171
+
172
+ default:
173
+ return {
174
+ type: 'string' as JsonSchemaType,
175
+ description: `Unknown function: ${functionName}`,
176
+ nullable: true,
177
+ };
178
+ }
179
+ };
180
+
181
+ /**
182
+ * Maps SQL window functions to OpenAPI types
183
+ * @param functionName - SQL function name
184
+ * @returns OpenAPI JSON Schema property
185
+ */
186
+ const mapWindowFunctionToSchema = (functionName: string): JsonSchemaProperty => {
187
+ const upperName = functionName.toUpperCase();
188
+
189
+ switch (upperName) {
190
+ case 'ROW_NUMBER':
191
+ case 'RANK':
192
+ case 'DENSE_RANK':
193
+ case 'NTILE':
194
+ return {
195
+ type: 'integer' as JsonSchemaType,
196
+ description: `${upperName} window function result`,
197
+ nullable: false,
198
+ };
199
+
200
+ case 'LAG':
201
+ case 'LEAD':
202
+ case 'FIRST_VALUE':
203
+ case 'LAST_VALUE':
204
+ return {
205
+ type: 'string' as JsonSchemaType,
206
+ description: `${upperName} window function result`,
207
+ nullable: true,
208
+ };
209
+
210
+ default:
211
+ return {
212
+ type: 'string' as JsonSchemaType,
213
+ description: `Unknown window function: ${functionName}`,
214
+ nullable: true,
215
+ };
216
+ }
217
+ };
218
+
219
+ /**
220
+ * Extracts schema with only selected columns and relations
221
+ * @param table - Table definition
222
+ * @param plan - Hydration plan
223
+ * @param context - Schema extraction context
224
+ * @param options - Schema generation options
225
+ * @returns OpenAPI 3.1 JSON Schema
226
+ */
227
+ const extractSelectedSchema = (
228
+ table: TableDef,
229
+ plan: HydrationPlan,
230
+ context: SchemaExtractionContext,
231
+ options: SchemaOptions
232
+ ): OpenApiSchema => {
233
+ const properties: Record<string, JsonSchemaProperty> = {};
234
+ const required: string[] = [];
235
+
236
+ plan.rootColumns.forEach(columnName => {
237
+ const column = table.columns[columnName];
238
+ if (!column) return;
239
+
240
+ const property = mapColumnType(column);
241
+ if (!property.description && options.includeDescriptions && column.comment) {
242
+ property.description = column.comment;
243
+ }
244
+
245
+ properties[columnName] = property;
246
+
247
+ if (column.notNull || column.primary) {
248
+ required.push(columnName);
249
+ }
250
+ });
251
+
252
+ plan.relations.forEach(relationPlan => {
253
+ const relation = table.relations[relationPlan.name];
254
+ if (!relation) return;
255
+
256
+ const relationSchema = extractRelationSchema(
257
+ relation,
258
+ relationPlan,
259
+ relationPlan.columns,
260
+ context,
261
+ options
262
+ );
263
+
264
+ properties[relationPlan.name] = relationSchema;
265
+
266
+ const { isNullable } = mapRelationType(relation.type);
267
+ if (!isNullable && relationPlan.name) {
268
+ required.push(relationPlan.name);
269
+ }
270
+ });
271
+
272
+ return {
273
+ type: 'object',
274
+ properties,
275
+ required,
276
+ };
277
+ };
278
+
279
+ /**
280
+ * Extracts full table schema (all columns, all relations)
281
+ * @param table - Table definition
282
+ * @param context - Schema extraction context
283
+ * @param options - Schema generation options
284
+ * @returns OpenAPI 3.1 JSON Schema
285
+ */
286
+ const extractFullTableSchema = (
287
+ table: TableDef,
288
+ context: SchemaExtractionContext,
289
+ options: SchemaOptions
290
+ ): OpenApiSchema => {
291
+ const cacheKey = table.name;
292
+
293
+ if (context.schemaCache.has(cacheKey)) {
294
+ return context.schemaCache.get(cacheKey)!;
295
+ }
296
+
297
+ if (context.visitedTables.has(cacheKey) && context.depth > 0) {
298
+ return {
299
+ type: 'object',
300
+ properties: {
301
+ _ref: {
302
+ type: 'string' as JsonSchemaType,
303
+ description: `Circular reference to ${table.name}`,
304
+ },
305
+ },
306
+ required: [],
307
+ };
308
+ }
309
+
310
+ context.visitedTables.add(cacheKey);
311
+
312
+ const properties: Record<string, JsonSchemaProperty> = {};
313
+ const required: string[] = [];
314
+
315
+ Object.entries(table.columns).forEach(([columnName, column]) => {
316
+ const property = mapColumnType(column);
317
+ if (!property.description && options.includeDescriptions && column.comment) {
318
+ property.description = column.comment;
319
+ }
320
+
321
+ properties[columnName] = property;
322
+
323
+ if (column.notNull || column.primary) {
324
+ required.push(columnName);
325
+ }
326
+ });
327
+
328
+ Object.entries(table.relations).forEach(([relationName, relation]) => {
329
+ if (context.depth >= context.maxDepth) {
330
+ return;
331
+ }
332
+
333
+ const relationSchema = extractRelationSchema(
334
+ relation,
335
+ undefined,
336
+ [],
337
+ { ...context, depth: context.depth + 1 },
338
+ options
339
+ );
340
+
341
+ properties[relationName] = relationSchema;
342
+
343
+ const { isNullable } = mapRelationType(relation.type);
344
+ if (!isNullable) {
345
+ required.push(relationName);
346
+ }
347
+ });
348
+
349
+ const schema: OpenApiSchema = {
350
+ type: 'object',
351
+ properties,
352
+ required,
353
+ };
354
+
355
+ context.schemaCache.set(cacheKey, schema);
356
+ return schema;
357
+ };
358
+
359
+ /**
360
+ * Extracts schema for a single relation
361
+ * @param relation - Relation definition
362
+ * @param relationPlan - Hydration plan for relation
363
+ * @param selectedColumns - Selected columns from relation
364
+ * @param context - Schema extraction context
365
+ * @param options - Schema generation options
366
+ * @returns OpenAPI JSON Schema property for relation
367
+ */
368
+ const extractRelationSchema = (
369
+ relation: RelationDef,
370
+ relationPlan: HydrationRelationPlan | undefined,
371
+ selectedColumns: string[],
372
+ context: SchemaExtractionContext,
373
+ options: SchemaOptions
374
+ ): JsonSchemaProperty => {
375
+ const targetTable = relation.target;
376
+ const { type: relationType, isNullable } = mapRelationType(relation.type);
377
+
378
+ let targetSchema: OpenApiSchema;
379
+
380
+ if (relationPlan && selectedColumns.length > 0) {
381
+ const plan: HydrationPlan = {
382
+ rootTable: targetTable.name,
383
+ rootPrimaryKey: relationPlan.targetPrimaryKey,
384
+ rootColumns: selectedColumns,
385
+ relations: [],
386
+ };
387
+
388
+ targetSchema = extractSelectedSchema(targetTable, plan, context, options);
389
+ } else {
390
+ targetSchema = extractFullTableSchema(targetTable, context, options);
391
+ }
392
+
393
+ if (relationType === 'array') {
394
+ return {
395
+ type: 'array',
396
+ items: targetSchema as JsonSchemaProperty,
397
+ nullable: isNullable,
398
+ };
399
+ }
400
+
401
+ return {
402
+ type: 'object' as JsonSchemaType,
403
+ properties: targetSchema.properties,
404
+ required: targetSchema.required,
405
+ nullable: isNullable,
406
+ description: targetSchema.description,
407
+ };
408
+ };
409
+
410
+ /**
411
+ * Converts a schema to a JSON string with optional pretty printing
412
+ * @param schema - OpenAPI schema
413
+ * @param pretty - Whether to pretty print
414
+ * @returns JSON string
415
+ */
416
+ export const schemaToJson = (schema: OpenApiSchema, pretty = false): string => {
417
+ return JSON.stringify(schema, null, pretty ? 2 : 0);
418
+ };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * OpenAPI 3.1 JSON Schema type representation
3
+ */
4
+ export type JsonSchemaType =
5
+ | 'string'
6
+ | 'number'
7
+ | 'integer'
8
+ | 'boolean'
9
+ | 'object'
10
+ | 'array'
11
+ | 'null';
12
+
13
+ /**
14
+ * Common OpenAPI 3.1 JSON Schema formats
15
+ */
16
+ export type JsonSchemaFormat =
17
+ | 'date-time'
18
+ | 'date'
19
+ | 'time'
20
+ | 'email'
21
+ | 'uuid'
22
+ | 'uri'
23
+ | 'binary'
24
+ | 'base64';
25
+
26
+ /**
27
+ * OpenAPI 3.1 JSON Schema property definition
28
+ */
29
+ export interface JsonSchemaProperty {
30
+ type?: JsonSchemaType | JsonSchemaType[];
31
+ format?: JsonSchemaFormat;
32
+ description?: string;
33
+ nullable?: boolean;
34
+ minimum?: number;
35
+ maximum?: number;
36
+ minLength?: number;
37
+ maxLength?: number;
38
+ pattern?: string;
39
+ enum?: (string | number | boolean)[];
40
+ default?: unknown;
41
+ example?: unknown;
42
+ properties?: Record<string, JsonSchemaProperty>;
43
+ required?: string[];
44
+ items?: JsonSchemaProperty;
45
+ $ref?: string;
46
+ anyOf?: JsonSchemaProperty[];
47
+ allOf?: JsonSchemaProperty[];
48
+ oneOf?: JsonSchemaProperty[];
49
+ [key: string]: unknown;
50
+ }
51
+
52
+ /**
53
+ * Complete OpenAPI 3.1 Schema for an entity or query result
54
+ */
55
+ export interface OpenApiSchema {
56
+ type: 'object';
57
+ properties: Record<string, JsonSchemaProperty>;
58
+ required: string[];
59
+ description?: string;
60
+ }
61
+
62
+ /**
63
+ * Schema generation options
64
+ */
65
+ export interface SchemaOptions {
66
+ /** Use selected columns only (from select/include) vs full entity */
67
+ mode?: 'selected' | 'full';
68
+ /** Include description from column comments */
69
+ includeDescriptions?: boolean;
70
+ /** Include enum values for enum columns */
71
+ includeEnums?: boolean;
72
+ /** Include column examples if available */
73
+ includeExamples?: boolean;
74
+ /** Format output for pretty printing (debugging) */
75
+ pretty?: boolean;
76
+ /** Maximum depth for relation recursion */
77
+ maxDepth?: number;
78
+ }
79
+
80
+ /**
81
+ * Schema extraction context for handling circular references
82
+ */
83
+ export interface SchemaExtractionContext {
84
+ /** Set of already visited tables to detect cycles */
85
+ visitedTables: Set<string>;
86
+ /** Map of table names to their generated schemas */
87
+ schemaCache: Map<string, OpenApiSchema>;
88
+ /** Current extraction depth */
89
+ depth: number;
90
+ /** Maximum depth to recurse */
91
+ maxDepth: number;
92
+ }