outlet-orm 2.5.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.
@@ -0,0 +1,697 @@
1
+ // Load environment variables from .env if present
2
+ require('dotenv').config();
3
+
4
+ // Lazy driver holders
5
+ let mysql;
6
+ let PgClient;
7
+ let sqlite3;
8
+
9
+ function ensureDriver(driverName) {
10
+ let pkg;
11
+ try {
12
+ switch (driverName) {
13
+ case 'mysql':
14
+ pkg = 'mysql2';
15
+ if (!mysql) mysql = require('mysql2/promise');
16
+ return true;
17
+ case 'postgres':
18
+ case 'postgresql':
19
+ pkg = 'pg';
20
+ if (!PgClient) ({ Client: PgClient } = require('pg'));
21
+ return true;
22
+ case 'sqlite':
23
+ pkg = 'sqlite3';
24
+ if (!sqlite3) sqlite3 = require('sqlite3').verbose();
25
+ return true;
26
+ default:
27
+ return false;
28
+ }
29
+ } catch (e) {
30
+ const msg = `Database driver not installed: ${pkg}.\nInstall it with: npm i ${pkg} --save\nOr select a different driver via config/.env.`;
31
+ throw new Error(msg);
32
+ }
33
+ }
34
+
35
+ function coerceNumber(val) {
36
+ const n = Number(val);
37
+ return Number.isFinite(n) ? n : undefined;
38
+ }
39
+
40
+ /**
41
+ * Database Connection Manager
42
+ * Supports MySQL, PostgreSQL, and SQLite
43
+ */
44
+ class DatabaseConnection {
45
+ constructor(config) {
46
+ const cfg = config || {};
47
+ const env = process.env || {};
48
+ let driver = (cfg.driver || env.DB_DRIVER || env.DATABASE_DRIVER || 'mysql').toLowerCase();
49
+ if (driver === 'postgresql') driver = 'postgres';
50
+ if (driver === 'sqlite3') driver = 'sqlite';
51
+
52
+ const resolved = {
53
+ driver,
54
+ host: cfg.host || env.DB_HOST || 'localhost',
55
+ port: cfg.port || coerceNumber(env.DB_PORT),
56
+ user: cfg.user || env.DB_USER || env.DB_USERNAME,
57
+ password: cfg.password || env.DB_PASSWORD,
58
+ database: cfg.database || env.DB_DATABASE || env.DB_NAME,
59
+ connectionLimit: cfg.connectionLimit || coerceNumber(env.DB_POOL_MAX)
60
+ };
61
+
62
+ if (driver === 'sqlite' && !resolved.database) {
63
+ resolved.database = env.DB_FILE || env.SQLITE_DB || env.SQLITE_FILENAME || ':memory:';
64
+ }
65
+
66
+ this.config = resolved;
67
+ this.driver = driver || 'mysql';
68
+ this.connection = null;
69
+ this.pool = null;
70
+ }
71
+
72
+ /**
73
+ * Connect to the database
74
+ * @returns {Promise<void>}
75
+ */
76
+ async connect() {
77
+ if (this.connection) return;
78
+
79
+ switch (this.driver) {
80
+ case 'mysql':
81
+ ensureDriver('mysql');
82
+ await this.connectMySQL();
83
+ break;
84
+ case 'postgres':
85
+ case 'postgresql':
86
+ ensureDriver('postgres');
87
+ await this.connectPostgreSQL();
88
+ break;
89
+ case 'sqlite':
90
+ ensureDriver('sqlite');
91
+ await this.connectSQLite();
92
+ break;
93
+ default:
94
+ throw new Error(`Unsupported database driver: ${this.driver}`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Connect to MySQL database
100
+ * @private
101
+ */
102
+ async connectMySQL() {
103
+ this.pool = mysql.createPool({
104
+ host: this.config.host || 'localhost',
105
+ port: this.config.port || 3306,
106
+ user: this.config.user,
107
+ password: this.config.password,
108
+ database: this.config.database,
109
+ waitForConnections: true,
110
+ connectionLimit: this.config.connectionLimit || 10,
111
+ queueLimit: 0
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Connect to PostgreSQL database
117
+ * @private
118
+ */
119
+ async connectPostgreSQL() {
120
+ this.connection = new PgClient({
121
+ host: this.config.host || 'localhost',
122
+ port: this.config.port || 5432,
123
+ user: this.config.user,
124
+ password: this.config.password,
125
+ database: this.config.database
126
+ });
127
+ await this.connection.connect();
128
+ }
129
+
130
+ /**
131
+ * Connect to SQLite database
132
+ * @private
133
+ */
134
+ async connectSQLite() {
135
+ return new Promise((resolve, reject) => {
136
+ this.connection = new sqlite3.Database(
137
+ this.config.database || ':memory:',
138
+ (err) => {
139
+ if (err) reject(new Error(err.message || String(err)));
140
+ else resolve();
141
+ }
142
+ );
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Execute a SELECT query
148
+ * @param {string} table
149
+ * @param {Object} query
150
+ * @returns {Promise<Array>}
151
+ */
152
+ async select(table, query) {
153
+ await this.connect();
154
+
155
+ const { sql, params } = this.buildSelectQuery(table, query);
156
+
157
+ switch (this.driver) {
158
+ case 'mysql':
159
+ return this.executeMySQLQuery(sql, params);
160
+ case 'postgres':
161
+ case 'postgresql':
162
+ return this.executePostgreSQLQuery(sql, params);
163
+ case 'sqlite':
164
+ return this.executeSQLiteQuery(sql, params);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Insert a record
170
+ * @param {string} table
171
+ * @param {Object} data
172
+ * @returns {Promise<Object>}
173
+ */
174
+ async insert(table, data) {
175
+ await this.connect();
176
+
177
+ const columns = Object.keys(data);
178
+ const values = Object.values(data);
179
+ const placeholders = this.getPlaceholders(values.length);
180
+
181
+ const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`;
182
+
183
+ switch (this.driver) {
184
+ case 'mysql': {
185
+ const [result] = await this.pool.execute(sql, values);
186
+ return { insertId: result.insertId, affectedRows: result.affectedRows };
187
+ }
188
+
189
+ case 'postgres':
190
+ case 'postgresql': {
191
+ const pgResult = await this.connection.query(
192
+ `${sql} RETURNING *`,
193
+ values
194
+ );
195
+ return { insertId: pgResult.rows[0].id, affectedRows: pgResult.rowCount };
196
+ }
197
+
198
+ case 'sqlite':
199
+ return new Promise((resolve, reject) => {
200
+ this.connection.run(sql, values, function(err) {
201
+ if (err) reject(new Error(err.message || String(err)));
202
+ else resolve({ insertId: this.lastID, affectedRows: this.changes });
203
+ });
204
+ });
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Insert multiple records
210
+ * @param {string} table
211
+ * @param {Array<Object>} data
212
+ * @returns {Promise<Object>}
213
+ */
214
+ async insertMany(table, data) {
215
+ if (data.length === 0) return { affectedRows: 0 };
216
+
217
+ await this.connect();
218
+
219
+ const columns = Object.keys(data[0]);
220
+ const valuesSets = data.map(row => Object.values(row));
221
+
222
+ const placeholderSet = `(${this.getPlaceholders(columns.length)})`;
223
+ const allPlaceholders = valuesSets.map(() => placeholderSet).join(', ');
224
+ const allValues = valuesSets.flat();
225
+
226
+ const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES ${allPlaceholders}`;
227
+
228
+ switch (this.driver) {
229
+ case 'mysql': {
230
+ const [result] = await this.pool.execute(sql, allValues);
231
+ return { affectedRows: result.affectedRows };
232
+ }
233
+
234
+ case 'postgres':
235
+ case 'postgresql': {
236
+ const pgResult = await this.connection.query(sql, allValues);
237
+ return { affectedRows: pgResult.rowCount };
238
+ }
239
+
240
+ case 'sqlite':
241
+ return new Promise((resolve, reject) => {
242
+ this.connection.run(sql, allValues, function(err) {
243
+ if (err) reject(new Error(err.message || String(err)));
244
+ else resolve({ affectedRows: this.changes });
245
+ });
246
+ });
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Update records
252
+ * @param {string} table
253
+ * @param {Object} data
254
+ * @param {Object} query
255
+ * @returns {Promise<Object>}
256
+ */
257
+ async update(table, data, query) {
258
+ await this.connect();
259
+
260
+ const setClauses = Object.keys(data).map(key => `${key} = ?`);
261
+ const { whereClause, params: whereParams } = this.buildWhereClause(query.wheres || []);
262
+
263
+ const sql = `UPDATE ${table} SET ${setClauses.join(', ')}${whereClause}`;
264
+ const params = [...Object.values(data), ...whereParams];
265
+
266
+ switch (this.driver) {
267
+ case 'mysql': {
268
+ const [result] = await this.pool.execute(
269
+ this.convertToDriverPlaceholder(sql),
270
+ params
271
+ );
272
+ return { affectedRows: result.affectedRows };
273
+ }
274
+
275
+ case 'postgres':
276
+ case 'postgresql': {
277
+ const pgResult = await this.connection.query(
278
+ this.convertToDriverPlaceholder(sql, 'postgres'),
279
+ params
280
+ );
281
+ return { affectedRows: pgResult.rowCount };
282
+ }
283
+
284
+ case 'sqlite':
285
+ return new Promise((resolve, reject) => {
286
+ this.connection.run(sql, params, function(err) {
287
+ if (err) reject(new Error(err.message || String(err)));
288
+ else resolve({ affectedRows: this.changes });
289
+ });
290
+ });
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Delete records
296
+ * @param {string} table
297
+ * @param {Object} query
298
+ * @returns {Promise<Object>}
299
+ */
300
+ async delete(table, query) {
301
+ await this.connect();
302
+
303
+ const { whereClause, params } = this.buildWhereClause(query.wheres || []);
304
+ const sql = `DELETE FROM ${table}${whereClause}`;
305
+
306
+ switch (this.driver) {
307
+ case 'mysql': {
308
+ const [result] = await this.pool.execute(
309
+ this.convertToDriverPlaceholder(sql),
310
+ params
311
+ );
312
+ return { affectedRows: result.affectedRows };
313
+ }
314
+
315
+ case 'postgres':
316
+ case 'postgresql': {
317
+ const pgResult = await this.connection.query(
318
+ this.convertToDriverPlaceholder(sql, 'postgres'),
319
+ params
320
+ );
321
+ return { affectedRows: pgResult.rowCount };
322
+ }
323
+
324
+ case 'sqlite':
325
+ return new Promise((resolve, reject) => {
326
+ this.connection.run(sql, params, function(err) {
327
+ if (err) reject(new Error(err.message || String(err)));
328
+ else resolve({ affectedRows: this.changes });
329
+ });
330
+ });
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Atomically increment a column
336
+ * @param {string} table
337
+ * @param {string} column
338
+ * @param {number} amount
339
+ * @param {Object} query
340
+ * @returns {Promise<{affectedRows: number}>}
341
+ */
342
+ async increment(table, column, query, amount = 1) {
343
+ await this.connect();
344
+
345
+ const { whereClause, params: whereParams } = this.buildWhereClause(query?.wheres || []);
346
+ const sql = `UPDATE ${table} SET ${column} = ${column} + ?${whereClause}`;
347
+ const params = [amount, ...whereParams];
348
+
349
+ switch (this.driver) {
350
+ case 'mysql': {
351
+ const [result] = await this.pool.execute(this.convertToDriverPlaceholder(sql), params);
352
+ return { affectedRows: result.affectedRows };
353
+ }
354
+ case 'postgres':
355
+ case 'postgresql': {
356
+ const res = await this.connection.query(this.convertToDriverPlaceholder(sql, 'postgres'), params);
357
+ return { affectedRows: res.rowCount };
358
+ }
359
+ case 'sqlite':
360
+ return new Promise((resolve, reject) => {
361
+ this.connection.run(sql, params, function(err) {
362
+ if (err) reject(new Error(err.message || String(err)));
363
+ else resolve({ affectedRows: this.changes });
364
+ });
365
+ });
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Atomically decrement a column
371
+ * @param {string} table
372
+ * @param {string} column
373
+ * @param {number} amount
374
+ * @param {Object} query
375
+ * @returns {Promise<{affectedRows: number}>}
376
+ */
377
+ async decrement(table, column, query, amount = 1) {
378
+ await this.connect();
379
+
380
+ const { whereClause, params: whereParams } = this.buildWhereClause(query?.wheres || []);
381
+ const sql = `UPDATE ${table} SET ${column} = ${column} - ?${whereClause}`;
382
+ const params = [amount, ...whereParams];
383
+
384
+ switch (this.driver) {
385
+ case 'mysql': {
386
+ const [result] = await this.pool.execute(this.convertToDriverPlaceholder(sql), params);
387
+ return { affectedRows: result.affectedRows };
388
+ }
389
+ case 'postgres':
390
+ case 'postgresql': {
391
+ const res = await this.connection.query(this.convertToDriverPlaceholder(sql, 'postgres'), params);
392
+ return { affectedRows: res.rowCount };
393
+ }
394
+ case 'sqlite':
395
+ return new Promise((resolve, reject) => {
396
+ this.connection.run(sql, params, function(err) {
397
+ if (err) reject(new Error(err.message || String(err)));
398
+ else resolve({ affectedRows: this.changes });
399
+ });
400
+ });
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Count records
406
+ * @param {string} table
407
+ * @param {Object} query
408
+ * @returns {Promise<number>}
409
+ */
410
+ async count(table, query) {
411
+ await this.connect();
412
+
413
+ const { whereClause, params } = this.buildWhereClause(query.wheres || []);
414
+ const sql = `SELECT COUNT(*) as count FROM ${table}${whereClause}`;
415
+
416
+ const rows = await this.executeRawQuery(sql, params);
417
+ return rows[0].count || rows[0].COUNT || 0;
418
+ }
419
+
420
+ /**
421
+ * Execute a raw query
422
+ * @param {string} sql
423
+ * @param {Array} params
424
+ * @returns {Promise<Array>}
425
+ */
426
+ async executeRawQuery(sql, params = []) {
427
+ await this.connect();
428
+
429
+ switch (this.driver) {
430
+ case 'mysql':
431
+ return this.executeMySQLQuery(sql, params);
432
+ case 'postgres':
433
+ case 'postgresql':
434
+ return this.executePostgreSQLQuery(sql, params);
435
+ case 'sqlite':
436
+ return this.executeSQLiteQuery(sql, params);
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Execute raw SQL and return driver-native results (used by migrations)
442
+ * @param {string} sql
443
+ * @param {Array} params
444
+ * @returns {Promise<any>}
445
+ */
446
+ async execute(sql, params = []) {
447
+ await this.connect();
448
+ switch (this.driver) {
449
+ case 'mysql': {
450
+ const [result] = await this.pool.execute(sql, params);
451
+ return result;
452
+ }
453
+ case 'postgres':
454
+ case 'postgresql': {
455
+ const res = await this.connection.query(this.convertToDriverPlaceholder(sql, 'postgres'), params);
456
+ return res.rows ?? res;
457
+ }
458
+ 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
+ }
473
+ });
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Execute MySQL query
479
+ * @private
480
+ */
481
+ async executeMySQLQuery(sql, params) {
482
+ const [rows] = await this.pool.execute(sql, params);
483
+ return rows;
484
+ }
485
+
486
+ /**
487
+ * Execute PostgreSQL query
488
+ * @private
489
+ */
490
+ async executePostgreSQLQuery(sql, params) {
491
+ const result = await this.connection.query(
492
+ this.convertToDriverPlaceholder(sql, 'postgres'),
493
+ params
494
+ );
495
+ return result.rows;
496
+ }
497
+
498
+ /**
499
+ * Execute SQLite query
500
+ * @private
501
+ */
502
+ async executeSQLiteQuery(sql, params) {
503
+ return new Promise((resolve, reject) => {
504
+ this.connection.all(sql, params, (err, rows) => {
505
+ if (err) reject(new Error(err.message || String(err)));
506
+ else resolve(rows);
507
+ });
508
+ });
509
+ }
510
+
511
+ /**
512
+ * Build SELECT query
513
+ * @private
514
+ */
515
+ buildSelectQuery(table, query) {
516
+ const distinct = query.distinct ? 'DISTINCT ' : '';
517
+ const columns = query.columns && query.columns.length > 0
518
+ ? query.columns.join(', ')
519
+ : '*';
520
+
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;
530
+ }
531
+ let params = [];
532
+
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];
538
+ }
539
+
540
+ // GROUP BY
541
+ if (query.groupBys && query.groupBys.length > 0) {
542
+ sql += ` GROUP BY ${query.groupBys.join(', ')}`;
543
+ }
544
+
545
+ // HAVING
546
+ if (query.havings && query.havings.length > 0) {
547
+ const havingClauses = [];
548
+ for (const h of query.havings) {
549
+ if (h.type === 'basic') {
550
+ havingClauses.push(`${h.column} ${h.operator} ?`);
551
+ params.push(h.value);
552
+ } else if (h.type === 'count') {
553
+ const col = h.column && h.column !== '*' ? h.column : '*';
554
+ havingClauses.push(`COUNT(${col}) ${h.operator} ?`);
555
+ params.push(h.value);
556
+ }
557
+ }
558
+ if (havingClauses.length) {
559
+ sql += ` HAVING ${havingClauses.join(' AND ')}`;
560
+ }
561
+ }
562
+
563
+ // ORDER BY
564
+ if (query.orders && query.orders.length > 0) {
565
+ const orderClauses = query.orders.map(
566
+ order => `${order.column} ${order.direction.toUpperCase()}`
567
+ );
568
+ sql += ` ORDER BY ${orderClauses.join(', ')}`;
569
+ }
570
+
571
+ // LIMIT
572
+ if (query.limit !== null && query.limit !== undefined) {
573
+ sql += ` LIMIT ${query.limit}`;
574
+ }
575
+
576
+ // OFFSET
577
+ if (query.offset !== null && query.offset !== undefined) {
578
+ sql += ` OFFSET ${query.offset}`;
579
+ }
580
+
581
+ return { sql, params };
582
+ }
583
+
584
+ /**
585
+ * Build WHERE clause
586
+ * @private
587
+ */
588
+ buildWhereClause(wheres) {
589
+ if (!wheres || wheres.length === 0) {
590
+ return { whereClause: '', params: [] };
591
+ }
592
+
593
+ const clauses = [];
594
+ const params = [];
595
+
596
+ wheres.forEach((where, index) => {
597
+ const boolean = index === 0 ? 'WHERE' : (where.boolean || 'AND').toUpperCase();
598
+
599
+ switch (where.type) {
600
+ case 'basic':
601
+ clauses.push(`${boolean} ${where.column} ${where.operator} ?`);
602
+ params.push(where.value);
603
+ break;
604
+
605
+ case 'in': {
606
+ const inPlaceholders = where.values.map(() => '?').join(', ');
607
+ clauses.push(`${boolean} ${where.column} IN (${inPlaceholders})`);
608
+ params.push(...where.values);
609
+ break;
610
+ }
611
+
612
+ case 'notIn': {
613
+ const notInPlaceholders = where.values.map(() => '?').join(', ');
614
+ clauses.push(`${boolean} ${where.column} NOT IN (${notInPlaceholders})`);
615
+ params.push(...where.values);
616
+ break;
617
+ }
618
+
619
+ case 'null':
620
+ clauses.push(`${boolean} ${where.column} IS NULL`);
621
+ break;
622
+
623
+ case 'notNull':
624
+ clauses.push(`${boolean} ${where.column} IS NOT NULL`);
625
+ break;
626
+
627
+ case 'between':
628
+ clauses.push(`${boolean} ${where.column} BETWEEN ? AND ?`);
629
+ params.push(...where.values);
630
+ break;
631
+
632
+ case 'like':
633
+ clauses.push(`${boolean} ${where.column} LIKE ?`);
634
+ params.push(where.value);
635
+ break;
636
+ }
637
+ });
638
+
639
+ return {
640
+ whereClause: ' ' + clauses.join(' '),
641
+ params
642
+ };
643
+ }
644
+
645
+ /**
646
+ * Get placeholders for SQL
647
+ * @private
648
+ */
649
+ getPlaceholders(count) {
650
+ return Array(count).fill('?').join(', ');
651
+ }
652
+
653
+ /**
654
+ * Convert placeholders for specific driver
655
+ * @private
656
+ */
657
+ convertToDriverPlaceholder(sql, driver = this.driver) {
658
+ if (driver === 'postgres' || driver === 'postgresql') {
659
+ let index = 1;
660
+ return sql.replace(/\?/g, () => `$${index++}`);
661
+ }
662
+ return sql;
663
+ }
664
+
665
+ /**
666
+ * Close the database connection
667
+ * @returns {Promise<void>}
668
+ */
669
+ async close() {
670
+ if (this.pool) {
671
+ await this.pool.end();
672
+ this.pool = null;
673
+ }
674
+ if (this.connection) {
675
+ if (this.driver === 'postgres' || this.driver === 'postgresql') {
676
+ await this.connection.end();
677
+ } else if (this.driver === 'sqlite') {
678
+ await new Promise((resolve, reject) => {
679
+ this.connection.close((err) => {
680
+ if (err) reject(new Error(err.message || String(err)));
681
+ else resolve();
682
+ });
683
+ });
684
+ }
685
+ this.connection = null;
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Backwards-compatible alias used by CLI
691
+ */
692
+ async disconnect() {
693
+ return this.close();
694
+ }
695
+ }
696
+
697
+ module.exports = DatabaseConnection;