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.
- package/README.md +674 -313
- package/package.json +1 -1
- package/src/DatabaseConnection.js +464 -110
- package/src/Model.js +1118 -659
- package/src/QueryBuilder.js +794 -710
- package/src/index.js +9 -1
- package/types/index.d.ts +126 -16
|
@@ -3,9 +3,50 @@ require('dotenv').config();
|
|
|
3
3
|
|
|
4
4
|
// Lazy driver holders
|
|
5
5
|
let mysql;
|
|
6
|
-
let
|
|
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 (!
|
|
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
|
|
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.
|
|
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
|
-
|
|
156
|
-
|
|
374
|
+
let result;
|
|
157
375
|
switch (this.driver) {
|
|
158
376
|
case 'mysql':
|
|
159
|
-
|
|
377
|
+
result = await this.executeMySQLQuery(sql, params);
|
|
378
|
+
break;
|
|
160
379
|
case 'postgres':
|
|
161
380
|
case 'postgresql':
|
|
162
|
-
|
|
381
|
+
result = await this.executePostgreSQLQuery(sql, params);
|
|
382
|
+
break;
|
|
163
383
|
case 'sqlite':
|
|
164
|
-
|
|
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 ${
|
|
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
|
|
186
|
-
|
|
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
|
|
192
|
-
|
|
420
|
+
const conn = this._getConnection();
|
|
421
|
+
const pgResult = await conn.query(
|
|
422
|
+
`${this.convertToDriverPlaceholder(sql)} RETURNING *`,
|
|
193
423
|
values
|
|
194
424
|
);
|
|
195
|
-
|
|
425
|
+
result = { insertId: pgResult.rows[0]?.id, affectedRows: pgResult.rowCount };
|
|
426
|
+
break;
|
|
196
427
|
}
|
|
197
428
|
|
|
198
429
|
case 'sqlite':
|
|
199
|
-
|
|
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 ${
|
|
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
|
|
231
|
-
|
|
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
|
|
237
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
517
|
+
const conn = this._getConnection();
|
|
518
|
+
const [res] = await conn.execute(
|
|
269
519
|
this.convertToDriverPlaceholder(sql),
|
|
270
520
|
params
|
|
271
521
|
);
|
|
272
|
-
|
|
522
|
+
result = { affectedRows: res.affectedRows };
|
|
523
|
+
break;
|
|
273
524
|
}
|
|
274
525
|
|
|
275
526
|
case 'postgres':
|
|
276
527
|
case 'postgresql': {
|
|
277
|
-
const
|
|
528
|
+
const conn = this._getConnection();
|
|
529
|
+
const pgResult = await conn.query(
|
|
278
530
|
this.convertToDriverPlaceholder(sql, 'postgres'),
|
|
279
531
|
params
|
|
280
532
|
);
|
|
281
|
-
|
|
533
|
+
result = { affectedRows: pgResult.rowCount };
|
|
534
|
+
break;
|
|
282
535
|
}
|
|
283
536
|
|
|
284
537
|
case 'sqlite':
|
|
285
|
-
|
|
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 ${
|
|
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
|
|
568
|
+
const conn = this._getConnection();
|
|
569
|
+
const [res] = await conn.execute(
|
|
309
570
|
this.convertToDriverPlaceholder(sql),
|
|
310
571
|
params
|
|
311
572
|
);
|
|
312
|
-
|
|
573
|
+
result = { affectedRows: res.affectedRows };
|
|
574
|
+
break;
|
|
313
575
|
}
|
|
314
576
|
|
|
315
577
|
case 'postgres':
|
|
316
578
|
case 'postgresql': {
|
|
317
|
-
const
|
|
579
|
+
const conn = this._getConnection();
|
|
580
|
+
const pgResult = await conn.query(
|
|
318
581
|
this.convertToDriverPlaceholder(sql, 'postgres'),
|
|
319
582
|
params
|
|
320
583
|
);
|
|
321
|
-
|
|
584
|
+
result = { affectedRows: pgResult.rowCount };
|
|
585
|
+
break;
|
|
322
586
|
}
|
|
323
587
|
|
|
324
588
|
case 'sqlite':
|
|
325
|
-
|
|
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 ${
|
|
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
|
|
352
|
-
|
|
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
|
|
357
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
387
|
-
|
|
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
|
|
392
|
-
|
|
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
|
-
|
|
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
|
|
414
|
-
const sql = `SELECT COUNT(*) as count FROM ${
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
451
|
-
|
|
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
|
|
456
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
|
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
|
|
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
|
|
517
|
-
const columns = query.columns && query.columns.length > 0
|
|
518
|
-
? query.columns.join(', ')
|
|
519
|
-
: '*';
|
|
862
|
+
const params = [];
|
|
520
863
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
if (query.
|
|
524
|
-
|
|
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
|
-
//
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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 === '
|
|
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)));
|