metal-orm 1.1.8 → 1.1.10

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.
Files changed (75) hide show
  1. package/README.md +769 -764
  2. package/dist/index.cjs +2352 -226
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +605 -40
  5. package/dist/index.d.ts +605 -40
  6. package/dist/index.js +2324 -226
  7. package/dist/index.js.map +1 -1
  8. package/package.json +22 -17
  9. package/src/bulk/bulk-context.ts +83 -0
  10. package/src/bulk/bulk-delete-executor.ts +89 -0
  11. package/src/bulk/bulk-executor.base.ts +73 -0
  12. package/src/bulk/bulk-insert-executor.ts +74 -0
  13. package/src/bulk/bulk-types.ts +70 -0
  14. package/src/bulk/bulk-update-executor.ts +192 -0
  15. package/src/bulk/bulk-upsert-executor.ts +95 -0
  16. package/src/bulk/bulk-utils.ts +91 -0
  17. package/src/bulk/index.ts +18 -0
  18. package/src/codegen/typescript.ts +30 -21
  19. package/src/core/ast/expression-builders.ts +107 -10
  20. package/src/core/ast/expression-nodes.ts +52 -22
  21. package/src/core/ast/expression-visitor.ts +23 -13
  22. package/src/core/dialect/abstract.ts +30 -17
  23. package/src/core/dialect/mysql/index.ts +20 -5
  24. package/src/core/execution/db-executor.ts +96 -64
  25. package/src/core/execution/executors/better-sqlite3-executor.ts +94 -0
  26. package/src/core/execution/executors/mssql-executor.ts +66 -34
  27. package/src/core/execution/executors/mysql-executor.ts +98 -66
  28. package/src/core/execution/executors/postgres-executor.ts +33 -11
  29. package/src/core/execution/executors/sqlite-executor.ts +86 -30
  30. package/src/decorators/bootstrap.ts +482 -398
  31. package/src/decorators/column-decorator.ts +87 -96
  32. package/src/decorators/decorator-metadata.ts +100 -24
  33. package/src/decorators/entity.ts +27 -24
  34. package/src/decorators/relations.ts +231 -149
  35. package/src/decorators/transformers/transformer-decorators.ts +26 -29
  36. package/src/decorators/validators/country-validators-decorators.ts +9 -15
  37. package/src/dto/apply-filter.ts +568 -551
  38. package/src/index.ts +16 -9
  39. package/src/orm/entity-hydration.ts +116 -72
  40. package/src/orm/entity-metadata.ts +347 -301
  41. package/src/orm/entity-relations.ts +264 -207
  42. package/src/orm/entity.ts +199 -199
  43. package/src/orm/execute.ts +13 -13
  44. package/src/orm/lazy-batch/morph-many.ts +70 -0
  45. package/src/orm/lazy-batch/morph-one.ts +69 -0
  46. package/src/orm/lazy-batch/morph-to.ts +59 -0
  47. package/src/orm/lazy-batch.ts +4 -1
  48. package/src/orm/orm-session.ts +170 -104
  49. package/src/orm/pooled-executor-factory.ts +99 -58
  50. package/src/orm/query-logger.ts +49 -40
  51. package/src/orm/relation-change-processor.ts +198 -96
  52. package/src/orm/relations/belongs-to.ts +143 -143
  53. package/src/orm/relations/has-many.ts +204 -204
  54. package/src/orm/relations/has-one.ts +174 -174
  55. package/src/orm/relations/many-to-many.ts +288 -288
  56. package/src/orm/relations/morph-many.ts +156 -0
  57. package/src/orm/relations/morph-one.ts +151 -0
  58. package/src/orm/relations/morph-to.ts +162 -0
  59. package/src/orm/save-graph.ts +116 -1
  60. package/src/query-builder/expression-table-mapper.ts +5 -0
  61. package/src/query-builder/hydration-manager.ts +345 -345
  62. package/src/query-builder/hydration-planner.ts +178 -148
  63. package/src/query-builder/relation-conditions.ts +171 -151
  64. package/src/query-builder/relation-cte-builder.ts +5 -1
  65. package/src/query-builder/relation-filter-utils.ts +9 -6
  66. package/src/query-builder/relation-include-strategies.ts +44 -2
  67. package/src/query-builder/relation-join-strategies.ts +8 -1
  68. package/src/query-builder/relation-service.ts +250 -241
  69. package/src/query-builder/select/cursor-pagination.ts +323 -0
  70. package/src/query-builder/select/select-operations.ts +110 -105
  71. package/src/query-builder/select.ts +42 -1
  72. package/src/query-builder/update-include.ts +4 -0
  73. package/src/schema/relation.ts +296 -188
  74. package/src/schema/types.ts +138 -123
  75. package/src/tree/tree-decorator.ts +127 -137
@@ -1,45 +1,50 @@
1
1
  // src/core/execution/db-executor.ts
2
2
 
3
3
  // low-level canonical shape
4
- export type QueryResult = {
5
- columns: string[];
6
- values: unknown[][];
7
- meta?: {
8
- insertId?: number | string;
9
- rowsAffected?: number;
10
- };
11
- };
12
-
13
- /**
14
- * Canonical execution payload.
15
- * It remains array-compatible for a gradual migration but always exposes
16
- * `resultSets` for explicit multi-result handling.
17
- */
18
- export type ExecutionPayload = QueryResult[] & {
19
- resultSets?: QueryResult[];
20
- };
21
-
22
- export const toExecutionPayload = (resultSets: QueryResult[]): ExecutionPayload => {
23
- const payload = resultSets as ExecutionPayload;
24
- payload.resultSets = resultSets;
25
- return payload;
26
- };
27
-
28
- export const payloadResultSets = (payload: ExecutionPayload): QueryResult[] =>
29
- payload.resultSets ?? payload;
30
-
31
- export interface DbExecutor {
32
- /** Capability flags so the runtime can make correct decisions without relying on optional methods. */
33
- readonly capabilities: {
34
- /** True if begin/commit/rollback are real and should be used to provide atomicity. */
35
- transactions: boolean;
4
+ export type QueryResult = {
5
+ columns: string[];
6
+ values: unknown[][];
7
+ meta?: {
8
+ insertId?: number | string;
9
+ rowsAffected?: number;
36
10
  };
11
+ };
12
+
13
+ /**
14
+ * Canonical execution payload.
15
+ * It remains array-compatible for a gradual migration but always exposes
16
+ * `resultSets` for explicit multi-result handling.
17
+ */
18
+ export type ExecutionPayload = QueryResult[] & {
19
+ resultSets?: QueryResult[];
20
+ };
37
21
 
38
- executeSql(sql: string, params?: unknown[]): Promise<ExecutionPayload>;
22
+ export const toExecutionPayload = (resultSets: QueryResult[]): ExecutionPayload => {
23
+ const payload = resultSets as ExecutionPayload;
24
+ payload.resultSets = resultSets;
25
+ return payload;
26
+ };
39
27
 
40
- beginTransaction(): Promise<void>;
41
- commitTransaction(): Promise<void>;
42
- rollbackTransaction(): Promise<void>;
28
+ export const payloadResultSets = (payload: ExecutionPayload): QueryResult[] =>
29
+ payload.resultSets ?? payload;
30
+
31
+ export interface DbExecutor {
32
+ /** Capability flags so the runtime can make correct decisions without relying on optional methods. */
33
+ readonly capabilities: {
34
+ /** True if begin/commit/rollback are real and should be used to provide atomicity. */
35
+ transactions: boolean;
36
+ /** True if savepoint/release/rollback-to-savepoint are implemented. */
37
+ savepoints?: boolean;
38
+ };
39
+
40
+ executeSql(sql: string, params?: unknown[]): Promise<ExecutionPayload>;
41
+
42
+ beginTransaction(): Promise<void>;
43
+ commitTransaction(): Promise<void>;
44
+ rollbackTransaction(): Promise<void>;
45
+ savepoint?(name: string): Promise<void>;
46
+ releaseSavepoint?(name: string): Promise<void>;
47
+ rollbackToSavepoint?(name: string): Promise<void>;
43
48
 
44
49
  /** Release any underlying resources (connections, pool leases, etc). Must be idempotent. */
45
50
  dispose(): Promise<void>;
@@ -71,10 +76,13 @@ export interface SimpleQueryRunner {
71
76
  params?: unknown[]
72
77
  ): Promise<Array<Record<string, unknown>>>;
73
78
 
74
- /** Optional: used to support real transactions. */
75
- beginTransaction?(): Promise<void>;
76
- commitTransaction?(): Promise<void>;
77
- rollbackTransaction?(): Promise<void>;
79
+ /** Optional: used to support real transactions. */
80
+ beginTransaction?(): Promise<void>;
81
+ commitTransaction?(): Promise<void>;
82
+ rollbackTransaction?(): Promise<void>;
83
+ savepoint?(name: string): Promise<void>;
84
+ releaseSavepoint?(name: string): Promise<void>;
85
+ rollbackToSavepoint?(name: string): Promise<void>;
78
86
 
79
87
  /** Optional: release resources (connection close, pool lease release, etc). */
80
88
  dispose?(): Promise<void>;
@@ -86,20 +94,26 @@ export interface SimpleQueryRunner {
86
94
  export function createExecutorFromQueryRunner(
87
95
  runner: SimpleQueryRunner
88
96
  ): DbExecutor {
89
- const supportsTransactions =
90
- typeof runner.beginTransaction === 'function' &&
91
- typeof runner.commitTransaction === 'function' &&
92
- typeof runner.rollbackTransaction === 'function';
93
-
94
- return {
95
- capabilities: {
96
- transactions: supportsTransactions,
97
- },
98
- async executeSql(sql, params) {
99
- const rows = await runner.query(sql, params);
100
- const result = rowsToQueryResult(rows);
101
- return toExecutionPayload([result]);
97
+ const supportsTransactions =
98
+ typeof runner.beginTransaction === 'function' &&
99
+ typeof runner.commitTransaction === 'function' &&
100
+ typeof runner.rollbackTransaction === 'function';
101
+ const supportsSavepoints =
102
+ supportsTransactions &&
103
+ typeof runner.savepoint === 'function' &&
104
+ typeof runner.releaseSavepoint === 'function' &&
105
+ typeof runner.rollbackToSavepoint === 'function';
106
+
107
+ return {
108
+ capabilities: {
109
+ transactions: supportsTransactions,
110
+ ...(supportsSavepoints ? { savepoints: true } : {}),
102
111
  },
112
+ async executeSql(sql, params) {
113
+ const rows = await runner.query(sql, params);
114
+ const result = rowsToQueryResult(rows);
115
+ return toExecutionPayload([result]);
116
+ },
103
117
  async beginTransaction() {
104
118
  if (!supportsTransactions) {
105
119
  throw new Error('Transactions are not supported by this executor');
@@ -112,14 +126,32 @@ export function createExecutorFromQueryRunner(
112
126
  }
113
127
  await runner.commitTransaction!.call(runner);
114
128
  },
115
- async rollbackTransaction() {
116
- if (!supportsTransactions) {
117
- throw new Error('Transactions are not supported by this executor');
118
- }
119
- await runner.rollbackTransaction!.call(runner);
120
- },
121
- async dispose() {
122
- await runner.dispose?.call(runner);
123
- },
124
- };
125
- }
129
+ async rollbackTransaction() {
130
+ if (!supportsTransactions) {
131
+ throw new Error('Transactions are not supported by this executor');
132
+ }
133
+ await runner.rollbackTransaction!.call(runner);
134
+ },
135
+ async savepoint(name: string) {
136
+ if (!supportsSavepoints) {
137
+ throw new Error('Savepoints are not supported by this executor');
138
+ }
139
+ await runner.savepoint!.call(runner, name);
140
+ },
141
+ async releaseSavepoint(name: string) {
142
+ if (!supportsSavepoints) {
143
+ throw new Error('Savepoints are not supported by this executor');
144
+ }
145
+ await runner.releaseSavepoint!.call(runner, name);
146
+ },
147
+ async rollbackToSavepoint(name: string) {
148
+ if (!supportsSavepoints) {
149
+ throw new Error('Savepoints are not supported by this executor');
150
+ }
151
+ await runner.rollbackToSavepoint!.call(runner, name);
152
+ },
153
+ async dispose() {
154
+ await runner.dispose?.call(runner);
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,94 @@
1
+ // src/core/execution/executors/better-sqlite3-executor.ts
2
+ import {
3
+ DbExecutor,
4
+ toExecutionPayload,
5
+ rowsToQueryResult,
6
+ QueryResult
7
+ } from '../db-executor.js';
8
+
9
+ export interface BetterSqlite3Statement {
10
+ reader: boolean;
11
+ all(...params: unknown[]): unknown[];
12
+ run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint };
13
+ }
14
+
15
+ export interface BetterSqlite3ClientLike {
16
+ prepare(sql: string): BetterSqlite3Statement;
17
+ transaction<T extends (...args: any[]) => any>(fn: T): T;
18
+ }
19
+
20
+ const SAVEPOINT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
21
+
22
+ const sanitizeSavepointName = (name: string): string => {
23
+ const trimmed = name.trim();
24
+ if (!SAVEPOINT_NAME_PATTERN.test(trimmed)) {
25
+ throw new Error(`Invalid savepoint name: "${name}"`);
26
+ }
27
+ return trimmed;
28
+ };
29
+
30
+ /**
31
+ * Creates a database executor for better-sqlite3.
32
+ * @param client A better-sqlite3 database instance.
33
+ * @returns A DbExecutor implementation for better-sqlite3.
34
+ */
35
+ export function createBetterSqlite3Executor(
36
+ client: BetterSqlite3ClientLike
37
+ ): DbExecutor {
38
+ // better-sqlite3 handles nested transactions using savepoints automatically
39
+ // when using .transaction(), but DbExecutor needs explicit control.
40
+
41
+ return {
42
+ capabilities: {
43
+ transactions: true,
44
+ savepoints: true,
45
+ },
46
+ async executeSql(sql, params) {
47
+ const stmt = client.prepare(sql);
48
+ let result: QueryResult;
49
+
50
+ if (stmt.reader) {
51
+ const rows = stmt.all(...(params ?? [])) as Record<string, unknown>[];
52
+ result = rowsToQueryResult(rows);
53
+ } else {
54
+ const info = stmt.run(...(params ?? []));
55
+ result = {
56
+ columns: [],
57
+ values: [],
58
+ meta: {
59
+ rowsAffected: info.changes,
60
+ insertId: typeof info.lastInsertRowid === 'bigint'
61
+ ? info.lastInsertRowid.toString()
62
+ : info.lastInsertRowid
63
+ }
64
+ };
65
+ }
66
+
67
+ return toExecutionPayload([result]);
68
+ },
69
+ async beginTransaction() {
70
+ client.prepare('BEGIN').run();
71
+ },
72
+ async commitTransaction() {
73
+ client.prepare('COMMIT').run();
74
+ },
75
+ async rollbackTransaction() {
76
+ client.prepare('ROLLBACK').run();
77
+ },
78
+ async savepoint(name: string) {
79
+ const savepoint = sanitizeSavepointName(name);
80
+ client.prepare(`SAVEPOINT ${savepoint}`).run();
81
+ },
82
+ async releaseSavepoint(name: string) {
83
+ const savepoint = sanitizeSavepointName(name);
84
+ client.prepare(`RELEASE SAVEPOINT ${savepoint}`).run();
85
+ },
86
+ async rollbackToSavepoint(name: string) {
87
+ const savepoint = sanitizeSavepointName(name);
88
+ client.prepare(`ROLLBACK TO SAVEPOINT ${savepoint}`).run();
89
+ },
90
+ async dispose() {
91
+ // Connection lifecycle is owned by the caller.
92
+ },
93
+ };
94
+ }
@@ -1,22 +1,32 @@
1
1
  // src/core/execution/executors/mssql-executor.ts
2
- import {
3
- DbExecutor,
4
- toExecutionPayload,
5
- rowsToQueryResult
6
- } from '../db-executor.js';
2
+ import {
3
+ DbExecutor,
4
+ toExecutionPayload,
5
+ rowsToQueryResult
6
+ } from '../db-executor.js';
7
7
 
8
8
  export interface MssqlClientLike {
9
- query(
10
- sql: string,
11
- params?: unknown[]
12
- ): Promise<{
13
- recordset?: Array<Record<string, unknown>>;
14
- recordsets?: Array<Array<Record<string, unknown>>>;
15
- }>;
16
- beginTransaction?(): Promise<void>;
17
- commit?(): Promise<void>;
9
+ query(
10
+ sql: string,
11
+ params?: unknown[]
12
+ ): Promise<{
13
+ recordset?: Array<Record<string, unknown>>;
14
+ recordsets?: Array<Array<Record<string, unknown>>>;
15
+ }>;
16
+ beginTransaction?(): Promise<void>;
17
+ commit?(): Promise<void>;
18
18
  rollback?(): Promise<void>;
19
19
  }
20
+
21
+ const SAVEPOINT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
22
+
23
+ const sanitizeSavepointName = (name: string): string => {
24
+ const trimmed = name.trim();
25
+ if (!SAVEPOINT_NAME_PATTERN.test(trimmed)) {
26
+ throw new Error(`Invalid savepoint name: "${name}"`);
27
+ }
28
+ return trimmed;
29
+ };
20
30
 
21
31
  /**
22
32
  * Creates a database executor for Microsoft SQL Server.
@@ -26,20 +36,22 @@ export interface MssqlClientLike {
26
36
  export function createMssqlExecutor(
27
37
  client: MssqlClientLike
28
38
  ): DbExecutor {
29
- const supportsTransactions =
30
- typeof client.beginTransaction === 'function' &&
31
- typeof client.commit === 'function' &&
32
- typeof client.rollback === 'function';
39
+ const supportsTransactions =
40
+ typeof client.beginTransaction === 'function' &&
41
+ typeof client.commit === 'function' &&
42
+ typeof client.rollback === 'function';
43
+ const supportsSavepoints = supportsTransactions;
33
44
 
34
45
  return {
35
46
  capabilities: {
36
47
  transactions: supportsTransactions,
48
+ ...(supportsSavepoints ? { savepoints: true } : {}),
37
49
  },
38
- async executeSql(sql, params) {
39
- const { recordset, recordsets } = await client.query(sql, params);
40
- const sets = Array.isArray(recordsets) ? recordsets : [recordset ?? []];
41
- return toExecutionPayload(sets.map(set => rowsToQueryResult(set ?? [])));
42
- },
50
+ async executeSql(sql, params) {
51
+ const { recordset, recordsets } = await client.query(sql, params);
52
+ const sets = Array.isArray(recordsets) ? recordsets : [recordset ?? []];
53
+ return toExecutionPayload(sets.map(set => rowsToQueryResult(set ?? [])));
54
+ },
43
55
  async beginTransaction() {
44
56
  if (!supportsTransactions) {
45
57
  throw new Error('Transactions are not supported by this executor');
@@ -52,15 +64,35 @@ export function createMssqlExecutor(
52
64
  }
53
65
  await client.commit!();
54
66
  },
55
- async rollbackTransaction() {
56
- if (!supportsTransactions) {
57
- throw new Error('Transactions are not supported by this executor');
58
- }
59
- await client.rollback!();
60
- },
61
- async dispose() {
62
- // Connection lifecycle is owned by the caller/driver. Pool lease executors should implement dispose.
63
- },
67
+ async rollbackTransaction() {
68
+ if (!supportsTransactions) {
69
+ throw new Error('Transactions are not supported by this executor');
70
+ }
71
+ await client.rollback!();
72
+ },
73
+ async savepoint(name: string) {
74
+ if (!supportsSavepoints) {
75
+ throw new Error('Savepoints are not supported by this executor');
76
+ }
77
+ const savepoint = sanitizeSavepointName(name);
78
+ await client.query(`SAVE TRANSACTION ${savepoint}`);
79
+ },
80
+ async releaseSavepoint(_name: string) {
81
+ if (!supportsSavepoints) {
82
+ throw new Error('Savepoints are not supported by this executor');
83
+ }
84
+ // SQL Server does not expose a RELEASE SAVEPOINT statement.
85
+ },
86
+ async rollbackToSavepoint(name: string) {
87
+ if (!supportsSavepoints) {
88
+ throw new Error('Savepoints are not supported by this executor');
89
+ }
90
+ const savepoint = sanitizeSavepointName(name);
91
+ await client.query(`ROLLBACK TRANSACTION ${savepoint}`);
92
+ },
93
+ async dispose() {
94
+ // Connection lifecycle is owned by the caller/driver. Pool lease executors should implement dispose.
95
+ },
64
96
  };
65
97
  }
66
98
 
@@ -161,8 +193,8 @@ export function createTediousMssqlClient(
161
193
  }
162
194
  );
163
195
 
164
- return { recordset: rows, recordsets: [rows] };
165
- },
196
+ return { recordset: rows, recordsets: [rows] };
197
+ },
166
198
 
167
199
  beginTransaction: connection.beginTransaction
168
200
  ? () =>
@@ -1,12 +1,12 @@
1
1
  // src/core/execution/executors/mysql-executor.ts
2
- import {
3
- DbExecutor,
4
- QueryResult,
5
- toExecutionPayload,
6
- rowsToQueryResult
7
- } from '../db-executor.js';
2
+ import {
3
+ DbExecutor,
4
+ QueryResult,
5
+ toExecutionPayload,
6
+ rowsToQueryResult
7
+ } from '../db-executor.js';
8
8
 
9
- export interface MysqlClientLike {
9
+ export interface MysqlClientLike {
10
10
  query(
11
11
  sql: string,
12
12
  params?: unknown[]
@@ -14,57 +14,66 @@ export interface MysqlClientLike {
14
14
  beginTransaction?(): Promise<void>;
15
15
  commit?(): Promise<void>;
16
16
  rollback?(): Promise<void>;
17
- }
18
-
17
+ }
18
+
19
19
  type RowObject = Record<string, unknown>;
20
+ const SAVEPOINT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
20
21
 
21
22
  const isRowObject = (value: unknown): value is RowObject =>
22
23
  typeof value === 'object' && value !== null && !Array.isArray(value);
23
-
24
- const isRowObjectArray = (value: unknown): value is RowObject[] =>
25
- Array.isArray(value) && value.every(isRowObject);
26
-
27
- const isMysqlResultHeader = (value: unknown): value is Record<string, unknown> =>
28
- isRowObject(value) &&
29
- ('affectedRows' in value ||
30
- 'insertId' in value ||
31
- 'warningStatus' in value ||
32
- 'serverStatus' in value);
33
-
24
+
25
+ const isRowObjectArray = (value: unknown): value is RowObject[] =>
26
+ Array.isArray(value) && value.every(isRowObject);
27
+
28
+ const isMysqlResultHeader = (value: unknown): value is Record<string, unknown> =>
29
+ isRowObject(value) &&
30
+ ('affectedRows' in value ||
31
+ 'insertId' in value ||
32
+ 'warningStatus' in value ||
33
+ 'serverStatus' in value);
34
+
34
35
  const headerToQueryResult = (header: Record<string, unknown>): QueryResult => ({
35
- columns: [],
36
- values: [],
37
- meta: {
38
- insertId: header.insertId as number | string | undefined,
39
- rowsAffected: header.affectedRows as number | undefined,
36
+ columns: [],
37
+ values: [],
38
+ meta: {
39
+ insertId: header.insertId as number | string | undefined,
40
+ rowsAffected: header.affectedRows as number | undefined,
40
41
  }
41
42
  });
42
43
 
43
- const normalizeMysqlResults = (rows: unknown): QueryResult[] => {
44
- if (!Array.isArray(rows)) {
45
- return isMysqlResultHeader(rows)
46
- ? [headerToQueryResult(rows)]
47
- : [rowsToQueryResult([])];
48
- }
49
-
50
- if (isRowObjectArray(rows)) {
51
- return [rowsToQueryResult(rows)];
44
+ const sanitizeSavepointName = (name: string): string => {
45
+ const trimmed = name.trim();
46
+ if (!SAVEPOINT_NAME_PATTERN.test(trimmed)) {
47
+ throw new Error(`Invalid savepoint name: "${name}"`);
52
48
  }
53
-
54
- const normalized: QueryResult[] = [];
55
- for (const chunk of rows) {
56
- if (isRowObjectArray(chunk)) {
57
- normalized.push(rowsToQueryResult(chunk));
58
- continue;
59
- }
60
- if (isMysqlResultHeader(chunk)) {
61
- normalized.push(headerToQueryResult(chunk));
62
- }
63
- }
64
-
65
- return normalized.length ? normalized : [rowsToQueryResult([])];
49
+ return trimmed;
66
50
  };
67
51
 
52
+ const normalizeMysqlResults = (rows: unknown): QueryResult[] => {
53
+ if (!Array.isArray(rows)) {
54
+ return isMysqlResultHeader(rows)
55
+ ? [headerToQueryResult(rows)]
56
+ : [rowsToQueryResult([])];
57
+ }
58
+
59
+ if (isRowObjectArray(rows)) {
60
+ return [rowsToQueryResult(rows)];
61
+ }
62
+
63
+ const normalized: QueryResult[] = [];
64
+ for (const chunk of rows) {
65
+ if (isRowObjectArray(chunk)) {
66
+ normalized.push(rowsToQueryResult(chunk));
67
+ continue;
68
+ }
69
+ if (isMysqlResultHeader(chunk)) {
70
+ normalized.push(headerToQueryResult(chunk));
71
+ }
72
+ }
73
+
74
+ return normalized.length ? normalized : [rowsToQueryResult([])];
75
+ };
76
+
68
77
  /**
69
78
  * Creates a database executor for MySQL.
70
79
  * @param client A MySQL client instance.
@@ -73,19 +82,21 @@ const normalizeMysqlResults = (rows: unknown): QueryResult[] => {
73
82
  export function createMysqlExecutor(
74
83
  client: MysqlClientLike
75
84
  ): DbExecutor {
76
- const supportsTransactions =
77
- typeof client.beginTransaction === 'function' &&
78
- typeof client.commit === 'function' &&
79
- typeof client.rollback === 'function';
85
+ const supportsTransactions =
86
+ typeof client.beginTransaction === 'function' &&
87
+ typeof client.commit === 'function' &&
88
+ typeof client.rollback === 'function';
89
+ const supportsSavepoints = supportsTransactions;
80
90
 
81
91
  return {
82
- capabilities: {
83
- transactions: supportsTransactions,
84
- },
85
- async executeSql(sql, params) {
86
- const [rows] = await client.query(sql, params);
87
- return toExecutionPayload(normalizeMysqlResults(rows));
92
+ capabilities: {
93
+ transactions: supportsTransactions,
94
+ ...(supportsSavepoints ? { savepoints: true } : {}),
88
95
  },
96
+ async executeSql(sql, params) {
97
+ const [rows] = await client.query(sql, params);
98
+ return toExecutionPayload(normalizeMysqlResults(rows));
99
+ },
89
100
  async beginTransaction() {
90
101
  if (!supportsTransactions) {
91
102
  throw new Error('Transactions are not supported by this executor');
@@ -98,14 +109,35 @@ export function createMysqlExecutor(
98
109
  }
99
110
  await client.commit!();
100
111
  },
101
- async rollbackTransaction() {
102
- if (!supportsTransactions) {
103
- throw new Error('Transactions are not supported by this executor');
104
- }
105
- await client.rollback!();
106
- },
107
- async dispose() {
108
- // Connection lifecycle is owned by the caller/driver. Pool lease executors should implement dispose.
109
- },
112
+ async rollbackTransaction() {
113
+ if (!supportsTransactions) {
114
+ throw new Error('Transactions are not supported by this executor');
115
+ }
116
+ await client.rollback!();
117
+ },
118
+ async savepoint(name: string) {
119
+ if (!supportsSavepoints) {
120
+ throw new Error('Savepoints are not supported by this executor');
121
+ }
122
+ const savepoint = sanitizeSavepointName(name);
123
+ await client.query(`SAVEPOINT ${savepoint}`);
124
+ },
125
+ async releaseSavepoint(name: string) {
126
+ if (!supportsSavepoints) {
127
+ throw new Error('Savepoints are not supported by this executor');
128
+ }
129
+ const savepoint = sanitizeSavepointName(name);
130
+ await client.query(`RELEASE SAVEPOINT ${savepoint}`);
131
+ },
132
+ async rollbackToSavepoint(name: string) {
133
+ if (!supportsSavepoints) {
134
+ throw new Error('Savepoints are not supported by this executor');
135
+ }
136
+ const savepoint = sanitizeSavepointName(name);
137
+ await client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
138
+ },
139
+ async dispose() {
140
+ // Connection lifecycle is owned by the caller/driver. Pool lease executors should implement dispose.
141
+ },
110
142
  };
111
143
  }
@@ -1,15 +1,25 @@
1
1
  // src/core/execution/executors/postgres-executor.ts
2
- import {
3
- DbExecutor,
4
- createExecutorFromQueryRunner
5
- } from '../db-executor.js';
2
+ import {
3
+ DbExecutor,
4
+ createExecutorFromQueryRunner
5
+ } from '../db-executor.js';
6
6
 
7
- export interface PostgresClientLike {
7
+ export interface PostgresClientLike {
8
8
  query(
9
9
  text: string,
10
10
  params?: unknown[]
11
11
  ): Promise<{ rows: Array<Record<string, unknown>> }>;
12
- }
12
+ }
13
+
14
+ const SAVEPOINT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
+
16
+ const sanitizeSavepointName = (name: string): string => {
17
+ const trimmed = name.trim();
18
+ if (!SAVEPOINT_NAME_PATTERN.test(trimmed)) {
19
+ throw new Error(`Invalid savepoint name: "${name}"`);
20
+ }
21
+ return trimmed;
22
+ };
13
23
 
14
24
  /**
15
25
  * Creates a database executor for PostgreSQL.
@@ -30,8 +40,20 @@ export function createPostgresExecutor(
30
40
  async commitTransaction() {
31
41
  await client.query('COMMIT');
32
42
  },
33
- async rollbackTransaction() {
34
- await client.query('ROLLBACK');
35
- },
36
- });
37
- }
43
+ async rollbackTransaction() {
44
+ await client.query('ROLLBACK');
45
+ },
46
+ async savepoint(name: string) {
47
+ const savepoint = sanitizeSavepointName(name);
48
+ await client.query(`SAVEPOINT ${savepoint}`);
49
+ },
50
+ async releaseSavepoint(name: string) {
51
+ const savepoint = sanitizeSavepointName(name);
52
+ await client.query(`RELEASE SAVEPOINT ${savepoint}`);
53
+ },
54
+ async rollbackToSavepoint(name: string) {
55
+ const savepoint = sanitizeSavepointName(name);
56
+ await client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`);
57
+ },
58
+ });
59
+ }