outlet-orm 2.5.0 → 3.1.0

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.
@@ -3,9 +3,50 @@ require('dotenv').config();
3
3
 
4
4
  // Lazy driver holders
5
5
  let mysql;
6
- let PgClient;
6
+ let PgPool;
7
7
  let sqlite3;
8
8
 
9
+ // Query log storage
10
+ let queryLog = [];
11
+ let queryLoggingEnabled = false;
12
+
13
+ /**
14
+ * Sanitize SQL identifier (table/column name) to prevent SQL injection
15
+ * @param {string} identifier
16
+ * @returns {string}
17
+ */
18
+ function sanitizeIdentifier(identifier) {
19
+ if (!identifier || typeof identifier !== 'string') {
20
+ throw new Error('Invalid SQL identifier');
21
+ }
22
+ // Allow only alphanumeric, underscore, dot (for table.column)
23
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/.test(identifier)) {
24
+ // Check for common SQL injection patterns
25
+ // Note: Escape hyphen in character class to avoid range interpretation
26
+ if (/['";]|--|\/*|\*\/|xp_|sp_|0x/i.test(identifier)) {
27
+ throw new Error(`Potentially dangerous SQL identifier: ${identifier}`);
28
+ }
29
+ }
30
+ return identifier;
31
+ }
32
+
33
+ /**
34
+ * Log a query if logging is enabled
35
+ * @param {string} sql
36
+ * @param {Array} params
37
+ * @param {number} duration
38
+ */
39
+ function logQuery(sql, params, duration) {
40
+ if (queryLoggingEnabled) {
41
+ queryLog.push({
42
+ sql,
43
+ params: params || [],
44
+ duration,
45
+ timestamp: new Date()
46
+ });
47
+ }
48
+ }
49
+
9
50
  function ensureDriver(driverName) {
10
51
  let pkg;
11
52
  try {
@@ -17,7 +58,7 @@ function ensureDriver(driverName) {
17
58
  case 'postgres':
18
59
  case 'postgresql':
19
60
  pkg = 'pg';
20
- if (!PgClient) ({ Client: PgClient } = require('pg'));
61
+ if (!PgPool) ({ Pool: PgPool } = require('pg'));
21
62
  return true;
22
63
  case 'sqlite':
23
64
  pkg = 'sqlite3';
@@ -40,6 +81,7 @@ function coerceNumber(val) {
40
81
  /**
41
82
  * Database Connection Manager
42
83
  * Supports MySQL, PostgreSQL, and SQLite
84
+ * Features: Connection pooling, transactions, query logging
43
85
  */
44
86
  class DatabaseConnection {
45
87
  constructor(config) {
@@ -56,7 +98,7 @@ class DatabaseConnection {
56
98
  user: cfg.user || env.DB_USER || env.DB_USERNAME,
57
99
  password: cfg.password || env.DB_PASSWORD,
58
100
  database: cfg.database || env.DB_DATABASE || env.DB_NAME,
59
- connectionLimit: cfg.connectionLimit || coerceNumber(env.DB_POOL_MAX)
101
+ connectionLimit: cfg.connectionLimit || coerceNumber(env.DB_POOL_MAX) || 10
60
102
  };
61
103
 
62
104
  if (driver === 'sqlite' && !resolved.database) {
@@ -67,14 +109,61 @@ class DatabaseConnection {
67
109
  this.driver = driver || 'mysql';
68
110
  this.connection = null;
69
111
  this.pool = null;
112
+ this._transactionConnection = null;
70
113
  }
71
114
 
115
+ // ==================== Query Logging ====================
116
+
117
+ /**
118
+ * Enable query logging
119
+ * @static
120
+ */
121
+ static enableQueryLog() {
122
+ queryLoggingEnabled = true;
123
+ }
124
+
125
+ /**
126
+ * Disable query logging
127
+ * @static
128
+ */
129
+ static disableQueryLog() {
130
+ queryLoggingEnabled = false;
131
+ }
132
+
133
+ /**
134
+ * Get the query log
135
+ * @static
136
+ * @returns {Array}
137
+ */
138
+ static getQueryLog() {
139
+ return [...queryLog];
140
+ }
141
+
142
+ /**
143
+ * Clear the query log
144
+ * @static
145
+ */
146
+ static flushQueryLog() {
147
+ queryLog = [];
148
+ }
149
+
150
+ /**
151
+ * Check if query logging is enabled
152
+ * @static
153
+ * @returns {boolean}
154
+ */
155
+ static isLogging() {
156
+ return queryLoggingEnabled;
157
+ }
158
+
159
+ // ==================== Connection ====================
160
+
72
161
  /**
73
162
  * Connect to the database
74
163
  * @returns {Promise<void>}
75
164
  */
76
165
  async connect() {
77
- if (this.connection) return;
166
+ if (this.pool || this.connection) return;
78
167
 
79
168
  switch (this.driver) {
80
169
  case 'mysql':
@@ -96,7 +185,7 @@ class DatabaseConnection {
96
185
  }
97
186
 
98
187
  /**
99
- * Connect to MySQL database
188
+ * Connect to MySQL database with connection pool
100
189
  * @private
101
190
  */
102
191
  async connectMySQL() {
@@ -107,24 +196,24 @@ class DatabaseConnection {
107
196
  password: this.config.password,
108
197
  database: this.config.database,
109
198
  waitForConnections: true,
110
- connectionLimit: this.config.connectionLimit || 10,
199
+ connectionLimit: this.config.connectionLimit,
111
200
  queueLimit: 0
112
201
  });
113
202
  }
114
203
 
115
204
  /**
116
- * Connect to PostgreSQL database
205
+ * Connect to PostgreSQL database with connection pool
117
206
  * @private
118
207
  */
119
208
  async connectPostgreSQL() {
120
- this.connection = new PgClient({
209
+ this.pool = new PgPool({
121
210
  host: this.config.host || 'localhost',
122
211
  port: this.config.port || 5432,
123
212
  user: this.config.user,
124
213
  password: this.config.password,
125
- database: this.config.database
214
+ database: this.config.database,
215
+ max: this.config.connectionLimit
126
216
  });
127
- await this.connection.connect();
128
217
  }
129
218
 
130
219
  /**
@@ -143,6 +232,133 @@ class DatabaseConnection {
143
232
  });
144
233
  }
145
234
 
235
+ // ==================== Transactions ====================
236
+
237
+ /**
238
+ * Begin a transaction
239
+ * @returns {Promise<void>}
240
+ */
241
+ async beginTransaction() {
242
+ await this.connect();
243
+
244
+ switch (this.driver) {
245
+ case 'mysql':
246
+ this._transactionConnection = await this.pool.getConnection();
247
+ await this._transactionConnection.beginTransaction();
248
+ break;
249
+
250
+ case 'postgres':
251
+ case 'postgresql':
252
+ this._transactionConnection = await this.pool.connect();
253
+ await this._transactionConnection.query('BEGIN');
254
+ break;
255
+
256
+ case 'sqlite':
257
+ await new Promise((resolve, reject) => {
258
+ this.connection.run('BEGIN TRANSACTION', (err) => {
259
+ if (err) reject(new Error(err.message || String(err)));
260
+ else resolve();
261
+ });
262
+ });
263
+ break;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Commit the current transaction
269
+ * @returns {Promise<void>}
270
+ */
271
+ async commit() {
272
+ switch (this.driver) {
273
+ case 'mysql':
274
+ if (this._transactionConnection) {
275
+ await this._transactionConnection.commit();
276
+ this._transactionConnection.release();
277
+ this._transactionConnection = null;
278
+ }
279
+ break;
280
+
281
+ case 'postgres':
282
+ case 'postgresql':
283
+ if (this._transactionConnection) {
284
+ await this._transactionConnection.query('COMMIT');
285
+ this._transactionConnection.release();
286
+ this._transactionConnection = null;
287
+ }
288
+ break;
289
+
290
+ case 'sqlite':
291
+ await new Promise((resolve, reject) => {
292
+ this.connection.run('COMMIT', (err) => {
293
+ if (err) reject(new Error(err.message || String(err)));
294
+ else resolve();
295
+ });
296
+ });
297
+ break;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Rollback the current transaction
303
+ * @returns {Promise<void>}
304
+ */
305
+ async rollback() {
306
+ switch (this.driver) {
307
+ case 'mysql':
308
+ if (this._transactionConnection) {
309
+ await this._transactionConnection.rollback();
310
+ this._transactionConnection.release();
311
+ this._transactionConnection = null;
312
+ }
313
+ break;
314
+
315
+ case 'postgres':
316
+ case 'postgresql':
317
+ if (this._transactionConnection) {
318
+ await this._transactionConnection.query('ROLLBACK');
319
+ this._transactionConnection.release();
320
+ this._transactionConnection = null;
321
+ }
322
+ break;
323
+
324
+ case 'sqlite':
325
+ await new Promise((resolve, reject) => {
326
+ this.connection.run('ROLLBACK', (err) => {
327
+ if (err) reject(new Error(err.message || String(err)));
328
+ else resolve();
329
+ });
330
+ });
331
+ break;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Execute a callback within a transaction
337
+ * @param {Function} callback - Async function to execute
338
+ * @returns {Promise<any>} - Result of the callback
339
+ */
340
+ async transaction(callback) {
341
+ await this.beginTransaction();
342
+ try {
343
+ const result = await callback(this);
344
+ await this.commit();
345
+ return result;
346
+ } catch (error) {
347
+ await this.rollback();
348
+ throw error;
349
+ }
350
+ }
351
+
352
+ // ==================== Query Methods ====================
353
+
354
+ /**
355
+ * Get the connection to use (transaction connection or pool)
356
+ * @private
357
+ */
358
+ _getConnection() {
359
+ return this._transactionConnection || this.pool || this.connection;
360
+ }
361
+
146
362
  /**
147
363
  * Execute a SELECT query
148
364
  * @param {string} table
@@ -151,18 +367,26 @@ class DatabaseConnection {
151
367
  */
152
368
  async select(table, query) {
153
369
  await this.connect();
370
+ const safeTable = sanitizeIdentifier(table);
371
+ const { sql, params } = this.buildSelectQuery(safeTable, query);
372
+ const start = Date.now();
154
373
 
155
- const { sql, params } = this.buildSelectQuery(table, query);
156
-
374
+ let result;
157
375
  switch (this.driver) {
158
376
  case 'mysql':
159
- return this.executeMySQLQuery(sql, params);
377
+ result = await this.executeMySQLQuery(sql, params);
378
+ break;
160
379
  case 'postgres':
161
380
  case 'postgresql':
162
- return this.executePostgreSQLQuery(sql, params);
381
+ result = await this.executePostgreSQLQuery(sql, params);
382
+ break;
163
383
  case 'sqlite':
164
- return this.executeSQLiteQuery(sql, params);
384
+ result = await this.executeSQLiteQuery(sql, params);
385
+ break;
165
386
  }
387
+
388
+ logQuery(sql, params, Date.now() - start);
389
+ return result;
166
390
  }
167
391
 
168
392
  /**
@@ -173,36 +397,47 @@ class DatabaseConnection {
173
397
  */
174
398
  async insert(table, data) {
175
399
  await this.connect();
400
+ const safeTable = sanitizeIdentifier(table);
176
401
 
177
- const columns = Object.keys(data);
402
+ const columns = Object.keys(data).map(col => sanitizeIdentifier(col));
178
403
  const values = Object.values(data);
179
404
  const placeholders = this.getPlaceholders(values.length);
180
405
 
181
- const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`;
406
+ const sql = `INSERT INTO ${safeTable} (${columns.join(', ')}) VALUES (${placeholders})`;
407
+ const start = Date.now();
182
408
 
409
+ let result;
183
410
  switch (this.driver) {
184
411
  case 'mysql': {
185
- const [result] = await this.pool.execute(sql, values);
186
- return { insertId: result.insertId, affectedRows: result.affectedRows };
412
+ const conn = this._getConnection();
413
+ const [res] = await conn.execute(sql, values);
414
+ result = { insertId: res.insertId, affectedRows: res.affectedRows };
415
+ break;
187
416
  }
188
417
 
189
418
  case 'postgres':
190
419
  case 'postgresql': {
191
- const pgResult = await this.connection.query(
192
- `${sql} RETURNING *`,
420
+ const conn = this._getConnection();
421
+ const pgResult = await conn.query(
422
+ `${this.convertToDriverPlaceholder(sql)} RETURNING *`,
193
423
  values
194
424
  );
195
- return { insertId: pgResult.rows[0].id, affectedRows: pgResult.rowCount };
425
+ result = { insertId: pgResult.rows[0]?.id, affectedRows: pgResult.rowCount };
426
+ break;
196
427
  }
197
428
 
198
429
  case 'sqlite':
199
- return new Promise((resolve, reject) => {
430
+ result = await new Promise((resolve, reject) => {
200
431
  this.connection.run(sql, values, function(err) {
201
432
  if (err) reject(new Error(err.message || String(err)));
202
433
  else resolve({ insertId: this.lastID, affectedRows: this.changes });
203
434
  });
204
435
  });
436
+ break;
205
437
  }
438
+
439
+ logQuery(sql, values, Date.now() - start);
440
+ return result;
206
441
  }
207
442
 
208
443
  /**
@@ -215,36 +450,47 @@ class DatabaseConnection {
215
450
  if (data.length === 0) return { affectedRows: 0 };
216
451
 
217
452
  await this.connect();
453
+ const safeTable = sanitizeIdentifier(table);
218
454
 
219
- const columns = Object.keys(data[0]);
455
+ const columns = Object.keys(data[0]).map(col => sanitizeIdentifier(col));
220
456
  const valuesSets = data.map(row => Object.values(row));
221
457
 
222
458
  const placeholderSet = `(${this.getPlaceholders(columns.length)})`;
223
459
  const allPlaceholders = valuesSets.map(() => placeholderSet).join(', ');
224
460
  const allValues = valuesSets.flat();
225
461
 
226
- const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES ${allPlaceholders}`;
462
+ const sql = `INSERT INTO ${safeTable} (${columns.join(', ')}) VALUES ${allPlaceholders}`;
463
+ const start = Date.now();
227
464
 
465
+ let result;
228
466
  switch (this.driver) {
229
467
  case 'mysql': {
230
- const [result] = await this.pool.execute(sql, allValues);
231
- return { affectedRows: result.affectedRows };
468
+ const conn = this._getConnection();
469
+ const [res] = await conn.execute(sql, allValues);
470
+ result = { affectedRows: res.affectedRows };
471
+ break;
232
472
  }
233
473
 
234
474
  case 'postgres':
235
475
  case 'postgresql': {
236
- const pgResult = await this.connection.query(sql, allValues);
237
- return { affectedRows: pgResult.rowCount };
476
+ const conn = this._getConnection();
477
+ const pgResult = await conn.query(this.convertToDriverPlaceholder(sql), allValues);
478
+ result = { affectedRows: pgResult.rowCount };
479
+ break;
238
480
  }
239
481
 
240
482
  case 'sqlite':
241
- return new Promise((resolve, reject) => {
483
+ result = await new Promise((resolve, reject) => {
242
484
  this.connection.run(sql, allValues, function(err) {
243
485
  if (err) reject(new Error(err.message || String(err)));
244
486
  else resolve({ affectedRows: this.changes });
245
487
  });
246
488
  });
489
+ break;
247
490
  }
491
+
492
+ logQuery(sql, allValues, Date.now() - start);
493
+ return result;
248
494
  }
249
495
 
250
496
  /**
@@ -256,39 +502,50 @@ class DatabaseConnection {
256
502
  */
257
503
  async update(table, data, query) {
258
504
  await this.connect();
505
+ const safeTable = sanitizeIdentifier(table);
259
506
 
260
- const setClauses = Object.keys(data).map(key => `${key} = ?`);
507
+ const setClauses = Object.keys(data).map(key => `${sanitizeIdentifier(key)} = ?`);
261
508
  const { whereClause, params: whereParams } = this.buildWhereClause(query.wheres || []);
262
509
 
263
- const sql = `UPDATE ${table} SET ${setClauses.join(', ')}${whereClause}`;
510
+ const sql = `UPDATE ${safeTable} SET ${setClauses.join(', ')}${whereClause}`;
264
511
  const params = [...Object.values(data), ...whereParams];
512
+ const start = Date.now();
265
513
 
514
+ let result;
266
515
  switch (this.driver) {
267
516
  case 'mysql': {
268
- const [result] = await this.pool.execute(
517
+ const conn = this._getConnection();
518
+ const [res] = await conn.execute(
269
519
  this.convertToDriverPlaceholder(sql),
270
520
  params
271
521
  );
272
- return { affectedRows: result.affectedRows };
522
+ result = { affectedRows: res.affectedRows };
523
+ break;
273
524
  }
274
525
 
275
526
  case 'postgres':
276
527
  case 'postgresql': {
277
- const pgResult = await this.connection.query(
528
+ const conn = this._getConnection();
529
+ const pgResult = await conn.query(
278
530
  this.convertToDriverPlaceholder(sql, 'postgres'),
279
531
  params
280
532
  );
281
- return { affectedRows: pgResult.rowCount };
533
+ result = { affectedRows: pgResult.rowCount };
534
+ break;
282
535
  }
283
536
 
284
537
  case 'sqlite':
285
- return new Promise((resolve, reject) => {
538
+ result = await new Promise((resolve, reject) => {
286
539
  this.connection.run(sql, params, function(err) {
287
540
  if (err) reject(new Error(err.message || String(err)));
288
541
  else resolve({ affectedRows: this.changes });
289
542
  });
290
543
  });
544
+ break;
291
545
  }
546
+
547
+ logQuery(sql, params, Date.now() - start);
548
+ return result;
292
549
  }
293
550
 
294
551
  /**
@@ -299,106 +556,141 @@ class DatabaseConnection {
299
556
  */
300
557
  async delete(table, query) {
301
558
  await this.connect();
559
+ const safeTable = sanitizeIdentifier(table);
302
560
 
303
561
  const { whereClause, params } = this.buildWhereClause(query.wheres || []);
304
- const sql = `DELETE FROM ${table}${whereClause}`;
562
+ const sql = `DELETE FROM ${safeTable}${whereClause}`;
563
+ const start = Date.now();
305
564
 
565
+ let result;
306
566
  switch (this.driver) {
307
567
  case 'mysql': {
308
- const [result] = await this.pool.execute(
568
+ const conn = this._getConnection();
569
+ const [res] = await conn.execute(
309
570
  this.convertToDriverPlaceholder(sql),
310
571
  params
311
572
  );
312
- return { affectedRows: result.affectedRows };
573
+ result = { affectedRows: res.affectedRows };
574
+ break;
313
575
  }
314
576
 
315
577
  case 'postgres':
316
578
  case 'postgresql': {
317
- const pgResult = await this.connection.query(
579
+ const conn = this._getConnection();
580
+ const pgResult = await conn.query(
318
581
  this.convertToDriverPlaceholder(sql, 'postgres'),
319
582
  params
320
583
  );
321
- return { affectedRows: pgResult.rowCount };
584
+ result = { affectedRows: pgResult.rowCount };
585
+ break;
322
586
  }
323
587
 
324
588
  case 'sqlite':
325
- return new Promise((resolve, reject) => {
589
+ result = await new Promise((resolve, reject) => {
326
590
  this.connection.run(sql, params, function(err) {
327
591
  if (err) reject(new Error(err.message || String(err)));
328
592
  else resolve({ affectedRows: this.changes });
329
593
  });
330
594
  });
595
+ break;
331
596
  }
597
+
598
+ logQuery(sql, params, Date.now() - start);
599
+ return result;
332
600
  }
333
601
 
334
602
  /**
335
603
  * Atomically increment a column
336
604
  * @param {string} table
337
605
  * @param {string} column
338
- * @param {number} amount
339
606
  * @param {Object} query
607
+ * @param {number} amount
340
608
  * @returns {Promise<{affectedRows: number}>}
341
609
  */
342
610
  async increment(table, column, query, amount = 1) {
343
611
  await this.connect();
612
+ const safeTable = sanitizeIdentifier(table);
613
+ const safeColumn = sanitizeIdentifier(column);
344
614
 
345
615
  const { whereClause, params: whereParams } = this.buildWhereClause(query?.wheres || []);
346
- const sql = `UPDATE ${table} SET ${column} = ${column} + ?${whereClause}`;
616
+ const sql = `UPDATE ${safeTable} SET ${safeColumn} = ${safeColumn} + ?${whereClause}`;
347
617
  const params = [amount, ...whereParams];
618
+ const start = Date.now();
348
619
 
620
+ let result;
349
621
  switch (this.driver) {
350
622
  case 'mysql': {
351
- const [result] = await this.pool.execute(this.convertToDriverPlaceholder(sql), params);
352
- return { affectedRows: result.affectedRows };
623
+ const conn = this._getConnection();
624
+ const [res] = await conn.execute(this.convertToDriverPlaceholder(sql), params);
625
+ result = { affectedRows: res.affectedRows };
626
+ break;
353
627
  }
354
628
  case 'postgres':
355
629
  case 'postgresql': {
356
- const res = await this.connection.query(this.convertToDriverPlaceholder(sql, 'postgres'), params);
357
- return { affectedRows: res.rowCount };
630
+ const conn = this._getConnection();
631
+ const res = await conn.query(this.convertToDriverPlaceholder(sql, 'postgres'), params);
632
+ result = { affectedRows: res.rowCount };
633
+ break;
358
634
  }
359
635
  case 'sqlite':
360
- return new Promise((resolve, reject) => {
636
+ result = await new Promise((resolve, reject) => {
361
637
  this.connection.run(sql, params, function(err) {
362
638
  if (err) reject(new Error(err.message || String(err)));
363
639
  else resolve({ affectedRows: this.changes });
364
640
  });
365
641
  });
642
+ break;
366
643
  }
644
+
645
+ logQuery(sql, params, Date.now() - start);
646
+ return result;
367
647
  }
368
648
 
369
649
  /**
370
650
  * Atomically decrement a column
371
651
  * @param {string} table
372
652
  * @param {string} column
373
- * @param {number} amount
374
653
  * @param {Object} query
654
+ * @param {number} amount
375
655
  * @returns {Promise<{affectedRows: number}>}
376
656
  */
377
657
  async decrement(table, column, query, amount = 1) {
378
658
  await this.connect();
659
+ const safeTable = sanitizeIdentifier(table);
660
+ const safeColumn = sanitizeIdentifier(column);
379
661
 
380
662
  const { whereClause, params: whereParams } = this.buildWhereClause(query?.wheres || []);
381
- const sql = `UPDATE ${table} SET ${column} = ${column} - ?${whereClause}`;
663
+ const sql = `UPDATE ${safeTable} SET ${safeColumn} = ${safeColumn} - ?${whereClause}`;
382
664
  const params = [amount, ...whereParams];
665
+ const start = Date.now();
383
666
 
667
+ let result;
384
668
  switch (this.driver) {
385
669
  case 'mysql': {
386
- const [result] = await this.pool.execute(this.convertToDriverPlaceholder(sql), params);
387
- return { affectedRows: result.affectedRows };
670
+ const conn = this._getConnection();
671
+ const [res] = await conn.execute(this.convertToDriverPlaceholder(sql), params);
672
+ result = { affectedRows: res.affectedRows };
673
+ break;
388
674
  }
389
675
  case 'postgres':
390
676
  case 'postgresql': {
391
- const res = await this.connection.query(this.convertToDriverPlaceholder(sql, 'postgres'), params);
392
- return { affectedRows: res.rowCount };
677
+ const conn = this._getConnection();
678
+ const res = await conn.query(this.convertToDriverPlaceholder(sql, 'postgres'), params);
679
+ result = { affectedRows: res.rowCount };
680
+ break;
393
681
  }
394
682
  case 'sqlite':
395
- return new Promise((resolve, reject) => {
683
+ result = await new Promise((resolve, reject) => {
396
684
  this.connection.run(sql, params, function(err) {
397
685
  if (err) reject(new Error(err.message || String(err)));
398
686
  else resolve({ affectedRows: this.changes });
399
687
  });
400
688
  });
689
+ break;
401
690
  }
691
+
692
+ logQuery(sql, params, Date.now() - start);
693
+ return result;
402
694
  }
403
695
 
404
696
  /**
@@ -409,77 +701,128 @@ class DatabaseConnection {
409
701
  */
410
702
  async count(table, query) {
411
703
  await this.connect();
704
+ const safeTable = sanitizeIdentifier(table);
412
705
 
413
- const { whereClause, params } = this.buildWhereClause(query.wheres || []);
414
- const sql = `SELECT COUNT(*) as count FROM ${table}${whereClause}`;
706
+ const { whereClause, params } = this.buildWhereClause(query?.wheres || []);
707
+ const sql = `SELECT COUNT(*) as count FROM ${safeTable}${whereClause}`;
708
+ const start = Date.now();
415
709
 
416
- const rows = await this.executeRawQuery(sql, params);
417
- return rows[0].count || rows[0].COUNT || 0;
710
+ let result;
711
+ switch (this.driver) {
712
+ case 'mysql': {
713
+ const conn = this._getConnection();
714
+ const [rows] = await conn.execute(this.convertToDriverPlaceholder(sql), params);
715
+ result = rows[0].count;
716
+ break;
717
+ }
718
+ case 'postgres':
719
+ case 'postgresql': {
720
+ const conn = this._getConnection();
721
+ const pgResult = await conn.query(this.convertToDriverPlaceholder(sql, 'postgres'), params);
722
+ result = parseInt(pgResult.rows[0].count, 10);
723
+ break;
724
+ }
725
+ case 'sqlite':
726
+ result = await new Promise((resolve, reject) => {
727
+ this.connection.get(sql, params, (err, row) => {
728
+ if (err) reject(new Error(err.message || String(err)));
729
+ else resolve(row.count);
730
+ });
731
+ });
732
+ break;
733
+ }
734
+
735
+ logQuery(sql, params, Date.now() - start);
736
+ return result;
418
737
  }
419
738
 
420
739
  /**
421
- * Execute a raw query
740
+ * Execute a raw query and return normalized results
422
741
  * @param {string} sql
423
742
  * @param {Array} params
424
743
  * @returns {Promise<Array>}
425
744
  */
426
745
  async executeRawQuery(sql, params = []) {
427
746
  await this.connect();
747
+ const start = Date.now();
428
748
 
749
+ let result;
429
750
  switch (this.driver) {
430
- case 'mysql':
431
- return this.executeMySQLQuery(sql, params);
751
+ case 'mysql': {
752
+ const conn = this._getConnection();
753
+ const [rows] = await conn.execute(sql, params);
754
+ result = rows;
755
+ break;
756
+ }
432
757
  case 'postgres':
433
- case 'postgresql':
434
- return this.executePostgreSQLQuery(sql, params);
758
+ case 'postgresql': {
759
+ const conn = this._getConnection();
760
+ const pgResult = await conn.query(sql, params);
761
+ result = pgResult.rows;
762
+ break;
763
+ }
435
764
  case 'sqlite':
436
- return this.executeSQLiteQuery(sql, params);
765
+ result = await new Promise((resolve, reject) => {
766
+ this.connection.all(sql, params, (err, rows) => {
767
+ if (err) reject(new Error(err.message || String(err)));
768
+ else resolve(rows);
769
+ });
770
+ });
771
+ break;
437
772
  }
773
+
774
+ logQuery(sql, params, Date.now() - start);
775
+ return result;
438
776
  }
439
777
 
440
778
  /**
441
- * Execute raw SQL and return driver-native results (used by migrations)
779
+ * Execute raw SQL (driver-native results - for migrations)
442
780
  * @param {string} sql
443
781
  * @param {Array} params
444
782
  * @returns {Promise<any>}
445
783
  */
446
784
  async execute(sql, params = []) {
447
785
  await this.connect();
786
+ const start = Date.now();
787
+
788
+ let result;
448
789
  switch (this.driver) {
449
790
  case 'mysql': {
450
- const [result] = await this.pool.execute(sql, params);
451
- return result;
791
+ const conn = this._getConnection();
792
+ const [rows] = await conn.execute(sql, params);
793
+ result = rows;
794
+ break;
452
795
  }
453
796
  case 'postgres':
454
797
  case 'postgresql': {
455
- const res = await this.connection.query(this.convertToDriverPlaceholder(sql, 'postgres'), params);
456
- return res.rows ?? res;
798
+ const conn = this._getConnection();
799
+ const pgResult = await conn.query(sql, params);
800
+ result = pgResult.rows;
801
+ break;
457
802
  }
458
803
  case 'sqlite':
459
- return new Promise((resolve, reject) => {
460
- // Choose all/run based on query type
461
- const isSelect = /^\s*select/i.test(sql);
462
- if (isSelect) {
463
- this.connection.all(sql, params, (err, rows) => {
464
- if (err) reject(new Error(err.message || String(err)));
465
- else resolve(rows);
466
- });
467
- } else {
468
- this.connection.run(sql, params, function(err) {
469
- if (err) reject(new Error(err.message || String(err)));
470
- else resolve({ changes: this.changes, lastID: this.lastID });
471
- });
472
- }
804
+ result = await new Promise((resolve, reject) => {
805
+ this.connection.all(sql, params, (err, rows) => {
806
+ if (err) reject(new Error(err.message || String(err)));
807
+ else resolve(rows || []);
808
+ });
473
809
  });
810
+ break;
474
811
  }
812
+
813
+ logQuery(sql, params, Date.now() - start);
814
+ return result;
475
815
  }
476
816
 
817
+ // ==================== Driver-Specific Query Execution ====================
818
+
477
819
  /**
478
820
  * Execute MySQL query
479
821
  * @private
480
822
  */
481
823
  async executeMySQLQuery(sql, params) {
482
- const [rows] = await this.pool.execute(sql, params);
824
+ const conn = this._getConnection();
825
+ const [rows] = await conn.execute(sql, params);
483
826
  return rows;
484
827
  }
485
828
 
@@ -488,7 +831,8 @@ class DatabaseConnection {
488
831
  * @private
489
832
  */
490
833
  async executePostgreSQLQuery(sql, params) {
491
- const result = await this.connection.query(
834
+ const conn = this._getConnection();
835
+ const result = await conn.query(
492
836
  this.convertToDriverPlaceholder(sql, 'postgres'),
493
837
  params
494
838
  );
@@ -508,35 +852,39 @@ class DatabaseConnection {
508
852
  });
509
853
  }
510
854
 
855
+ // ==================== Query Building ====================
856
+
511
857
  /**
512
858
  * Build SELECT query
513
859
  * @private
514
860
  */
515
861
  buildSelectQuery(table, query) {
516
- const distinct = query.distinct ? 'DISTINCT ' : '';
517
- const columns = query.columns && query.columns.length > 0
518
- ? query.columns.join(', ')
519
- : '*';
862
+ const params = [];
520
863
 
521
- let sql = `SELECT ${distinct}${columns} FROM ${table}`;
522
- // JOINS
523
- if (query.joins && query.joins.length > 0) {
524
- const joinClauses = query.joins.map(j => {
525
- const type = (j.type === 'left' ? 'LEFT JOIN' : 'INNER JOIN');
526
- const op = j.operator || '=';
527
- return ` ${type} ${j.table} ON ${j.first} ${op} ${j.second}`;
528
- }).join('');
529
- sql += joinClauses;
864
+ // SELECT clause
865
+ let selectClause = '*';
866
+ if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') {
867
+ selectClause = query.columns.join(', ');
530
868
  }
531
- let params = [];
532
869
 
533
- // WHERE clauses
534
- if (query.wheres && query.wheres.length > 0) {
535
- const { whereClause, params: whereParams } = this.buildWhereClause(query.wheres);
536
- sql += whereClause;
537
- params = [...params, ...whereParams];
870
+ // DISTINCT
871
+ const distinctClause = query.distinct ? 'DISTINCT ' : '';
872
+
873
+ let sql = `SELECT ${distinctClause}${selectClause} FROM ${table}`;
874
+
875
+ // JOINs
876
+ if (query.joins && query.joins.length > 0) {
877
+ for (const join of query.joins) {
878
+ const joinType = (join.type || 'inner').toUpperCase();
879
+ sql += ` ${joinType} JOIN ${join.table} ON ${join.first} ${join.operator} ${join.second}`;
880
+ }
538
881
  }
539
882
 
883
+ // WHERE
884
+ const { whereClause, params: whereParams } = this.buildWhereClause(query.wheres);
885
+ sql += whereClause;
886
+ params.push(...whereParams);
887
+
540
888
  // GROUP BY
541
889
  if (query.groupBys && query.groupBys.length > 0) {
542
890
  sql += ` GROUP BY ${query.groupBys.join(', ')}`;
@@ -667,14 +1015,20 @@ class DatabaseConnection {
667
1015
  * @returns {Promise<void>}
668
1016
  */
669
1017
  async close() {
1018
+ if (this._transactionConnection) {
1019
+ try {
1020
+ await this.rollback();
1021
+ } catch (e) {
1022
+ // Ignore rollback errors during close
1023
+ }
1024
+ }
1025
+
670
1026
  if (this.pool) {
671
1027
  await this.pool.end();
672
1028
  this.pool = null;
673
1029
  }
674
1030
  if (this.connection) {
675
- if (this.driver === 'postgres' || this.driver === 'postgresql') {
676
- await this.connection.end();
677
- } else if (this.driver === 'sqlite') {
1031
+ if (this.driver === 'sqlite') {
678
1032
  await new Promise((resolve, reject) => {
679
1033
  this.connection.close((err) => {
680
1034
  if (err) reject(new Error(err.message || String(err)));