outlet-orm 5.0.0 → 5.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,76 +1,88 @@
1
- {
2
- "name": "outlet-orm",
3
- "version": "5.0.0",
4
- "description": "A Laravel Eloquent-inspired ORM for Node.js with support for MySQL, PostgreSQL, and SQLite",
5
- "main": "src/index.js",
6
- "types": "types/index.d.ts",
7
- "bin": {
8
- "outlet-init": "./bin/init.js",
9
- "outlet-convert": "./bin/convert.js",
10
- "outlet-migrate": "./bin/migrate.js"
11
- },
12
- "files": [
13
- "src/**",
14
- "bin/**",
15
- "types/**",
16
- "README.md",
17
- "LICENSE"
18
- ],
19
- "scripts": {
20
- "test": "jest",
21
- "test:watch": "jest --watch",
22
- "test:coverage": "jest --coverage",
23
- "lint": "eslint \"{src,bin}/**/*.js\"",
24
- "lint:fix": "eslint \"{src,bin}/**/*.js\" --fix",
25
- "migrate": "node bin/migrate.js migrate",
26
- "migrate:make": "node bin/migrate.js make",
27
- "migrate:rollback": "node bin/migrate.js rollback --steps 1",
28
- "migrate:status": "node bin/migrate.js status",
29
- "migrate:reset": "node bin/migrate.js reset --yes",
30
- "migrate:refresh": "node bin/migrate.js refresh --yes",
31
- "migrate:fresh": "node bin/migrate.js fresh --yes"
32
- },
33
- "keywords": [
34
- "orm",
35
- "eloquent",
36
- "laravel",
37
- "database",
38
- "mysql",
39
- "postgresql",
40
- "sqlite",
41
- "query-builder",
42
- "active-record"
43
- ],
44
- "author": "omgbwa-yasse",
45
- "license": "MIT",
46
- "repository": {
47
- "type": "git",
48
- "url": "https://github.com/omgbwa-yasse/outlet-orm.git"
49
- },
50
- "bugs": {
51
- "url": "https://github.com/omgbwa-yasse/outlet-orm/issues"
52
- },
53
- "homepage": "https://github.com/omgbwa-yasse/outlet-orm#readme",
54
- "dependencies": {
55
- "dotenv": "^16.4.5"
56
- },
57
- "peerDependencies": {
58
- "mysql2": "^3.15.2",
59
- "pg": "^8.11.0",
60
- "sqlite3": "^5.1.6"
61
- },
62
- "peerDependenciesMeta": {
63
- "mysql2": { "optional": true },
64
- "pg": { "optional": true },
65
- "sqlite3": { "optional": true }
66
- },
67
- "devDependencies": {
68
- "@types/node": "^20.10.0",
69
- "eslint": "^8.50.0",
70
- "jest": "^29.7.0",
71
- "typescript": "^5.3.0"
72
- },
73
- "engines": {
74
- "node": ">=18.0.0"
75
- }
76
- }
1
+ {
2
+ "name": "outlet-orm",
3
+ "version": "5.5.2",
4
+ "description": "A Laravel Eloquent-inspired ORM for Node.js with support for MySQL, PostgreSQL, and SQLite",
5
+ "main": "src/index.js",
6
+ "types": "types/index.d.ts",
7
+ "bin": {
8
+ "outlet-init": "bin/init.js",
9
+ "outlet-convert": "bin/convert.js",
10
+ "outlet-migrate": "bin/migrate.js",
11
+ "outlet-reverse": "bin/reverse.js"
12
+ },
13
+ "files": [
14
+ "src/**",
15
+ "bin/**",
16
+ "types/**",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "jest",
22
+ "test:watch": "jest --watch",
23
+ "test:coverage": "jest --coverage",
24
+ "check:version": "node scripts/check-changelog-version.js",
25
+ "prepublishOnly": "npm run check:version",
26
+ "lint": "eslint \"{src,bin}/**/*.js\"",
27
+ "lint:fix": "eslint \"{src,bin}/**/*.js\" --fix",
28
+ "migrate": "node bin/migrate.js migrate",
29
+ "migrate:make": "node bin/migrate.js make",
30
+ "migrate:rollback": "node bin/migrate.js rollback --steps 1",
31
+ "migrate:status": "node bin/migrate.js status",
32
+ "migrate:reset": "node bin/migrate.js reset --yes",
33
+ "migrate:refresh": "node bin/migrate.js refresh --yes",
34
+ "migrate:fresh": "node bin/migrate.js fresh --yes",
35
+ "seed": "node bin/migrate.js seed",
36
+ "seed:make": "node bin/migrate.js make:seed"
37
+ },
38
+ "keywords": [
39
+ "orm",
40
+ "eloquent",
41
+ "laravel",
42
+ "database",
43
+ "mysql",
44
+ "postgresql",
45
+ "sqlite",
46
+ "query-builder",
47
+ "active-record"
48
+ ],
49
+ "author": "omgbwa-yasse",
50
+ "license": "MIT",
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/omgbwa-yasse/outlet-orm.git"
54
+ },
55
+ "bugs": {
56
+ "url": "https://github.com/omgbwa-yasse/outlet-orm/issues"
57
+ },
58
+ "homepage": "https://github.com/omgbwa-yasse/outlet-orm#readme",
59
+ "dependencies": {
60
+ "dotenv": "^16.4.5",
61
+ "pluralize": "^8.0.0"
62
+ },
63
+ "peerDependencies": {
64
+ "mysql2": "^3.15.2",
65
+ "pg": "^8.11.0",
66
+ "sqlite3": "^5.1.6"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "mysql2": {
70
+ "optional": true
71
+ },
72
+ "pg": {
73
+ "optional": true
74
+ },
75
+ "sqlite3": {
76
+ "optional": true
77
+ }
78
+ },
79
+ "devDependencies": {
80
+ "@types/node": "^20.10.0",
81
+ "eslint": "^8.50.0",
82
+ "jest": "^29.7.0",
83
+ "typescript": "^5.3.0"
84
+ },
85
+ "engines": {
86
+ "node": ">=18.0.0"
87
+ }
88
+ }
@@ -9,23 +9,27 @@ let sqlite3;
9
9
  // Query log storage
10
10
  let queryLog = [];
11
11
  let queryLoggingEnabled = false;
12
+ // Maximum number of entries retained in the query log to prevent unbounded memory growth
13
+ const MAX_QUERY_LOG_SIZE = 1000;
14
+
15
+ const RawExpression = require('./RawExpression');
12
16
 
13
17
  /**
14
18
  * Sanitize SQL identifier (table/column name) to prevent SQL injection
15
- * @param {string} identifier
19
+ * @param {string|RawExpression} identifier
16
20
  * @returns {string}
17
21
  */
18
22
  function sanitizeIdentifier(identifier) {
23
+ if (identifier instanceof RawExpression) {
24
+ return identifier.value;
25
+ }
19
26
  if (!identifier || typeof identifier !== 'string') {
20
27
  throw new Error('Invalid SQL identifier');
21
28
  }
22
- // Allow only alphanumeric, underscore, dot (for table.column)
29
+ // Strict allowlist: only alphanumeric, underscore, dot (for table.column)
30
+ // Any identifier not matching this pattern is rejected outright — no fallback.
23
31
  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
- }
32
+ throw new Error('Invalid SQL identifier');
29
33
  }
30
34
  return identifier;
31
35
  }
@@ -44,6 +48,10 @@ function logQuery(sql, params, duration) {
44
48
  duration,
45
49
  timestamp: new Date()
46
50
  });
51
+ // Enforce size cap to prevent unbounded memory growth
52
+ if (queryLog.length > MAX_QUERY_LOG_SIZE) {
53
+ queryLog.shift();
54
+ }
47
55
  }
48
56
  }
49
57
 
@@ -272,18 +280,24 @@ class DatabaseConnection {
272
280
  switch (this.driver) {
273
281
  case 'mysql':
274
282
  if (this._transactionConnection) {
275
- await this._transactionConnection.commit();
276
- this._transactionConnection.release();
277
- this._transactionConnection = null;
283
+ try {
284
+ await this._transactionConnection.commit();
285
+ } finally {
286
+ this._transactionConnection.release();
287
+ this._transactionConnection = null;
288
+ }
278
289
  }
279
290
  break;
280
291
 
281
292
  case 'postgres':
282
293
  case 'postgresql':
283
294
  if (this._transactionConnection) {
284
- await this._transactionConnection.query('COMMIT');
285
- this._transactionConnection.release();
286
- this._transactionConnection = null;
295
+ try {
296
+ await this._transactionConnection.query('COMMIT');
297
+ } finally {
298
+ this._transactionConnection.release();
299
+ this._transactionConnection = null;
300
+ }
287
301
  }
288
302
  break;
289
303
 
@@ -306,18 +320,24 @@ class DatabaseConnection {
306
320
  switch (this.driver) {
307
321
  case 'mysql':
308
322
  if (this._transactionConnection) {
309
- await this._transactionConnection.rollback();
310
- this._transactionConnection.release();
311
- this._transactionConnection = null;
323
+ try {
324
+ await this._transactionConnection.rollback();
325
+ } finally {
326
+ this._transactionConnection.release();
327
+ this._transactionConnection = null;
328
+ }
312
329
  }
313
330
  break;
314
331
 
315
332
  case 'postgres':
316
333
  case 'postgresql':
317
334
  if (this._transactionConnection) {
318
- await this._transactionConnection.query('ROLLBACK');
319
- this._transactionConnection.release();
320
- this._transactionConnection = null;
335
+ try {
336
+ await this._transactionConnection.query('ROLLBACK');
337
+ } finally {
338
+ this._transactionConnection.release();
339
+ this._transactionConnection = null;
340
+ }
321
341
  }
322
342
  break;
323
343
 
@@ -515,10 +535,7 @@ class DatabaseConnection {
515
535
  switch (this.driver) {
516
536
  case 'mysql': {
517
537
  const conn = this._getConnection();
518
- const [res] = await conn.execute(
519
- this.convertToDriverPlaceholder(sql),
520
- params
521
- );
538
+ const [res] = await conn.execute(sql, params);
522
539
  result = { affectedRows: res.affectedRows };
523
540
  break;
524
541
  }
@@ -566,10 +583,7 @@ class DatabaseConnection {
566
583
  switch (this.driver) {
567
584
  case 'mysql': {
568
585
  const conn = this._getConnection();
569
- const [res] = await conn.execute(
570
- this.convertToDriverPlaceholder(sql),
571
- params
572
- );
586
+ const [res] = await conn.execute(sql, params);
573
587
  result = { affectedRows: res.affectedRows };
574
588
  break;
575
589
  }
@@ -737,7 +751,10 @@ class DatabaseConnection {
737
751
  }
738
752
 
739
753
  /**
740
- * Execute a raw query and return normalized results
754
+ * Execute a raw query and return normalized results.
755
+ * ⚠️ SECURITY WARNING: The `sql` parameter is passed to the database driver without
756
+ * any sanitization. NEVER pass user-controlled data in `sql`. User-controlled values
757
+ * must be supplied exclusively via the `params` array (parameterized queries).
741
758
  * @param {string} sql
742
759
  * @param {Array} params
743
760
  * @returns {Promise<Array>}
@@ -776,7 +793,10 @@ class DatabaseConnection {
776
793
  }
777
794
 
778
795
  /**
779
- * Execute raw SQL (driver-native results - for migrations)
796
+ * Execute raw SQL driver-native results (intended for migrations).
797
+ * ⚠️ SECURITY WARNING: The `sql` parameter is passed to the database driver without
798
+ * any sanitization. NEVER pass user-controlled data in `sql`. User-controlled values
799
+ * must be supplied exclusively via the `params` array (parameterized queries).
780
800
  * @param {string} sql
781
801
  * @param {Array} params
782
802
  * @returns {Promise<any>}
@@ -864,7 +884,7 @@ class DatabaseConnection {
864
884
  // SELECT clause
865
885
  let selectClause = '*';
866
886
  if (query.columns && query.columns.length > 0 && query.columns[0] !== '*') {
867
- selectClause = query.columns.join(', ');
887
+ selectClause = query.columns.map(col => sanitizeIdentifier(col)).join(', ');
868
888
  }
869
889
 
870
890
  // DISTINCT
@@ -876,7 +896,12 @@ class DatabaseConnection {
876
896
  if (query.joins && query.joins.length > 0) {
877
897
  for (const join of query.joins) {
878
898
  const joinType = (join.type || 'inner').toUpperCase();
879
- sql += ` ${joinType} JOIN ${join.table} ON ${join.first} ${join.operator} ${join.second}`;
899
+ const ALLOWED_OPERATORS = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IS', 'IS NOT'];
900
+ const op = join.operator.toUpperCase();
901
+ if (!ALLOWED_OPERATORS.includes(op)) {
902
+ throw new Error(`Invalid operator: ${join.operator}`);
903
+ }
904
+ sql += ` ${joinType} JOIN ${sanitizeIdentifier(join.table)} ON ${sanitizeIdentifier(join.first)} ${op} ${sanitizeIdentifier(join.second)}`;
880
905
  }
881
906
  }
882
907
 
@@ -887,19 +912,24 @@ class DatabaseConnection {
887
912
 
888
913
  // GROUP BY
889
914
  if (query.groupBys && query.groupBys.length > 0) {
890
- sql += ` GROUP BY ${query.groupBys.join(', ')}`;
915
+ sql += ` GROUP BY ${query.groupBys.map(col => sanitizeIdentifier(col)).join(', ')}`;
891
916
  }
892
917
 
893
918
  // HAVING
894
919
  if (query.havings && query.havings.length > 0) {
895
920
  const havingClauses = [];
896
921
  for (const h of query.havings) {
922
+ const ALLOWED_OPERATORS = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IS', 'IS NOT'];
923
+ const op = h.operator.toUpperCase();
924
+ if (!ALLOWED_OPERATORS.includes(op)) {
925
+ throw new Error(`Invalid operator: ${h.operator}`);
926
+ }
897
927
  if (h.type === 'basic') {
898
- havingClauses.push(`${h.column} ${h.operator} ?`);
928
+ havingClauses.push(`${sanitizeIdentifier(h.column)} ${op} ?`);
899
929
  params.push(h.value);
900
930
  } else if (h.type === 'count') {
901
- const col = h.column && h.column !== '*' ? h.column : '*';
902
- havingClauses.push(`COUNT(${col}) ${h.operator} ?`);
931
+ const col = h.column && h.column !== '*' ? sanitizeIdentifier(h.column) : '*';
932
+ havingClauses.push(`COUNT(${col}) ${op} ?`);
903
933
  params.push(h.value);
904
934
  }
905
935
  }
@@ -911,19 +941,28 @@ class DatabaseConnection {
911
941
  // ORDER BY
912
942
  if (query.orders && query.orders.length > 0) {
913
943
  const orderClauses = query.orders.map(
914
- order => `${order.column} ${order.direction.toUpperCase()}`
944
+ order => {
945
+ if (order.type === 'raw') {
946
+ return order.sql;
947
+ }
948
+ const dir = order.direction.toUpperCase();
949
+ if (dir !== 'ASC' && dir !== 'DESC') throw new Error(`Invalid direction: ${dir}`);
950
+ return `${sanitizeIdentifier(order.column)} ${dir}`;
951
+ }
915
952
  );
916
953
  sql += ` ORDER BY ${orderClauses.join(', ')}`;
917
954
  }
918
955
 
919
956
  // LIMIT
920
957
  if (query.limit !== null && query.limit !== undefined) {
921
- sql += ` LIMIT ${query.limit}`;
958
+ sql += ' LIMIT ?';
959
+ params.push(query.limit);
922
960
  }
923
961
 
924
962
  // OFFSET
925
963
  if (query.offset !== null && query.offset !== undefined) {
926
- sql += ` OFFSET ${query.offset}`;
964
+ sql += ' OFFSET ?';
965
+ params.push(query.offset);
927
966
  }
928
967
 
929
968
  return { sql, params };
@@ -940,45 +979,58 @@ class DatabaseConnection {
940
979
 
941
980
  const clauses = [];
942
981
  const params = [];
982
+ const ALLOWED_OPERATORS = ['=', '!=', '<>', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IS', 'IS NOT'];
943
983
 
944
984
  wheres.forEach((where, index) => {
945
985
  const boolean = index === 0 ? 'WHERE' : (where.boolean || 'AND').toUpperCase();
986
+ const col = where.type !== 'raw' ? sanitizeIdentifier(where.column) : null;
946
987
 
947
988
  switch (where.type) {
948
- case 'basic':
949
- clauses.push(`${boolean} ${where.column} ${where.operator} ?`);
989
+ case 'raw': {
990
+ clauses.push(`${boolean} ${where.sql}`);
991
+ if (where.bindings) {
992
+ params.push(...where.bindings);
993
+ }
994
+ break;
995
+ }
996
+
997
+ case 'basic': {
998
+ const op = where.operator.toUpperCase();
999
+ if (!ALLOWED_OPERATORS.includes(op)) throw new Error(`Invalid operator: ${where.operator}`);
1000
+ clauses.push(`${boolean} ${col} ${op} ?`);
950
1001
  params.push(where.value);
951
1002
  break;
1003
+ }
952
1004
 
953
1005
  case 'in': {
954
1006
  const inPlaceholders = where.values.map(() => '?').join(', ');
955
- clauses.push(`${boolean} ${where.column} IN (${inPlaceholders})`);
1007
+ clauses.push(`${boolean} ${col} IN (${inPlaceholders})`);
956
1008
  params.push(...where.values);
957
1009
  break;
958
1010
  }
959
1011
 
960
1012
  case 'notIn': {
961
1013
  const notInPlaceholders = where.values.map(() => '?').join(', ');
962
- clauses.push(`${boolean} ${where.column} NOT IN (${notInPlaceholders})`);
1014
+ clauses.push(`${boolean} ${col} NOT IN (${notInPlaceholders})`);
963
1015
  params.push(...where.values);
964
1016
  break;
965
1017
  }
966
1018
 
967
1019
  case 'null':
968
- clauses.push(`${boolean} ${where.column} IS NULL`);
1020
+ clauses.push(`${boolean} ${col} IS NULL`);
969
1021
  break;
970
1022
 
971
1023
  case 'notNull':
972
- clauses.push(`${boolean} ${where.column} IS NOT NULL`);
1024
+ clauses.push(`${boolean} ${col} IS NOT NULL`);
973
1025
  break;
974
1026
 
975
1027
  case 'between':
976
- clauses.push(`${boolean} ${where.column} BETWEEN ? AND ?`);
1028
+ clauses.push(`${boolean} ${col} BETWEEN ? AND ?`);
977
1029
  params.push(...where.values);
978
1030
  break;
979
1031
 
980
1032
  case 'like':
981
- clauses.push(`${boolean} ${where.column} LIKE ?`);
1033
+ clauses.push(`${boolean} ${col} LIKE ?`);
982
1034
  params.push(where.value);
983
1035
  break;
984
1036
  }