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/README.md +1325 -1312
- package/bin/init.js +397 -379
- package/bin/migrate.js +544 -440
- package/bin/reverse.js +602 -0
- package/package.json +88 -76
- package/src/DatabaseConnection.js +98 -46
- package/src/Migrations/MigrationManager.js +329 -326
- package/src/Model.js +1141 -1118
- package/src/QueryBuilder.js +134 -35
- package/src/RawExpression.js +11 -0
- package/src/Relations/BelongsToManyRelation.js +466 -466
- package/src/Schema/Schema.js +830 -790
- package/src/Seeders/Seeder.js +60 -0
- package/src/Seeders/SeederManager.js +105 -0
- package/src/index.js +55 -49
- package/types/index.d.ts +674 -660
package/package.json
CHANGED
|
@@ -1,76 +1,88 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "outlet-orm",
|
|
3
|
-
"version": "5.
|
|
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": "
|
|
9
|
-
"outlet-convert": "
|
|
10
|
-
"outlet-migrate": "
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"test
|
|
22
|
-
"test:
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"migrate
|
|
29
|
-
"migrate:
|
|
30
|
-
"migrate:
|
|
31
|
-
"migrate:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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
|
-
|
|
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} ${
|
|
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}) ${
|
|
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 =>
|
|
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 +=
|
|
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 +=
|
|
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 '
|
|
949
|
-
clauses.push(`${boolean} ${where.
|
|
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} ${
|
|
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} ${
|
|
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} ${
|
|
1020
|
+
clauses.push(`${boolean} ${col} IS NULL`);
|
|
969
1021
|
break;
|
|
970
1022
|
|
|
971
1023
|
case 'notNull':
|
|
972
|
-
clauses.push(`${boolean} ${
|
|
1024
|
+
clauses.push(`${boolean} ${col} IS NOT NULL`);
|
|
973
1025
|
break;
|
|
974
1026
|
|
|
975
1027
|
case 'between':
|
|
976
|
-
clauses.push(`${boolean} ${
|
|
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} ${
|
|
1033
|
+
clauses.push(`${boolean} ${col} LIKE ?`);
|
|
982
1034
|
params.push(where.value);
|
|
983
1035
|
break;
|
|
984
1036
|
}
|