outlet-orm 4.2.1 → 5.5.1
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 +166 -53
- package/bin/init.js +18 -0
- package/bin/migrate.js +109 -7
- package/bin/reverse.js +602 -0
- package/package.json +22 -13
- package/src/Database/DatabaseConnection.js +4 -0
- package/src/DatabaseConnection.js +98 -46
- package/{lib → src}/Migrations/Migration.js +48 -48
- package/{lib → src}/Migrations/MigrationManager.js +22 -19
- package/src/Model.js +30 -7
- package/src/QueryBuilder.js +134 -35
- package/src/RawExpression.js +11 -0
- package/src/Relations/BelongsToManyRelation.js +466 -466
- package/{lib → src}/Schema/Schema.js +157 -117
- package/src/Seeders/Seeder.js +60 -0
- package/src/Seeders/SeederManager.js +105 -0
- package/src/index.js +25 -1
- package/types/index.d.ts +14 -0
- package/lib/Database/DatabaseConnection.js +0 -4
package/src/QueryBuilder.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Query Builder for constructing and executing database queries
|
|
3
3
|
*/
|
|
4
|
+
const RawExpression = require('./RawExpression');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validate a SQL identifier used internally in subquery construction.
|
|
8
|
+
* Throws if the value is not a safe alphanumeric/underscore/dot string.
|
|
9
|
+
* @param {string} value
|
|
10
|
+
* @param {string} context - human-readable label for error messages
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function assertIdentifier(value, context = 'identifier') {
|
|
14
|
+
if (typeof value !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/.test(value)) {
|
|
15
|
+
throw new Error(`Invalid SQL ${context}`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
4
20
|
class QueryBuilder {
|
|
5
21
|
constructor(model) {
|
|
6
22
|
this.model = model;
|
|
@@ -28,6 +44,7 @@ class QueryBuilder {
|
|
|
28
44
|
*/
|
|
29
45
|
_applyGlobalScopes() {
|
|
30
46
|
if (this._excludeAllScopes) return;
|
|
47
|
+
if (this._scopesApplied) return;
|
|
31
48
|
|
|
32
49
|
const scopes = this.model.globalScopes || {};
|
|
33
50
|
for (const [name, scopeFn] of Object.entries(scopes)) {
|
|
@@ -35,6 +52,7 @@ class QueryBuilder {
|
|
|
35
52
|
scopeFn(this);
|
|
36
53
|
}
|
|
37
54
|
}
|
|
55
|
+
this._scopesApplied = true;
|
|
38
56
|
}
|
|
39
57
|
|
|
40
58
|
/**
|
|
@@ -43,12 +61,14 @@ class QueryBuilder {
|
|
|
43
61
|
*/
|
|
44
62
|
_applySoftDeleteConstraints() {
|
|
45
63
|
if (!this.model.softDeletes) return;
|
|
64
|
+
if (this._softDeleteApplied) return;
|
|
46
65
|
|
|
47
66
|
if (this._onlyTrashed) {
|
|
48
67
|
this.whereNotNull(this.model.DELETED_AT);
|
|
49
68
|
} else if (!this._withTrashed) {
|
|
50
69
|
this.whereNull(this.model.DELETED_AT);
|
|
51
70
|
}
|
|
71
|
+
this._softDeleteApplied = true;
|
|
52
72
|
}
|
|
53
73
|
|
|
54
74
|
/**
|
|
@@ -98,6 +118,16 @@ class QueryBuilder {
|
|
|
98
118
|
return this;
|
|
99
119
|
}
|
|
100
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Add a raw select expression
|
|
123
|
+
* @param {string} expression
|
|
124
|
+
* @returns {this}
|
|
125
|
+
*/
|
|
126
|
+
selectRaw(expression) {
|
|
127
|
+
this.selectedColumns.push(new RawExpression(expression));
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
|
|
101
131
|
/**
|
|
102
132
|
* Convenience alias to pass an array of columns
|
|
103
133
|
* @param {string[]} cols
|
|
@@ -215,6 +245,28 @@ class QueryBuilder {
|
|
|
215
245
|
return this;
|
|
216
246
|
}
|
|
217
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Add a raw where clause
|
|
250
|
+
* @param {string} sql
|
|
251
|
+
* @param {Array} bindings
|
|
252
|
+
* @returns {this}
|
|
253
|
+
*/
|
|
254
|
+
whereRaw(sql, bindings = []) {
|
|
255
|
+
this.wheres.push({ type: 'raw', sql, bindings, boolean: 'and' });
|
|
256
|
+
return this;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Add a raw or where clause
|
|
261
|
+
* @param {string} sql
|
|
262
|
+
* @param {Array} bindings
|
|
263
|
+
* @returns {this}
|
|
264
|
+
*/
|
|
265
|
+
orWhereRaw(sql, bindings = []) {
|
|
266
|
+
this.wheres.push({ type: 'raw', sql, bindings, boolean: 'or' });
|
|
267
|
+
return this;
|
|
268
|
+
}
|
|
269
|
+
|
|
218
270
|
/**
|
|
219
271
|
* Filter parents where the given relation has at least one matching record.
|
|
220
272
|
* Implements via INNER JOIN and applying the related where clauses.
|
|
@@ -234,23 +286,23 @@ class QueryBuilder {
|
|
|
234
286
|
throw new Error(`Invalid relation '${relationName}' on ${this.model.name}`);
|
|
235
287
|
}
|
|
236
288
|
|
|
237
|
-
const parentTable = this.model.table;
|
|
289
|
+
const parentTable = assertIdentifier(this.model.table, 'parent table');
|
|
238
290
|
const relatedClass = relation.related;
|
|
239
|
-
const relatedTable = relatedClass.table;
|
|
291
|
+
const relatedTable = assertIdentifier(relatedClass.table, 'related table');
|
|
292
|
+
const foreignKey = assertIdentifier(relation.foreignKey, 'foreignKey');
|
|
293
|
+
const localKey = assertIdentifier(relation.localKey, 'localKey');
|
|
240
294
|
|
|
241
|
-
//
|
|
242
|
-
const relatedDerivedFK = `${relatedTable.replace(/s$/, '')}_id`;
|
|
243
|
-
|
|
244
|
-
// Build ON condition depending on relation type
|
|
295
|
+
// Determine relation direction using relation type (relation.child is set on BelongsTo)
|
|
245
296
|
let onLeft, onRight;
|
|
246
|
-
if (relation.
|
|
247
|
-
// belongsTo: parent has FK to related
|
|
248
|
-
|
|
249
|
-
|
|
297
|
+
if (relation.child) {
|
|
298
|
+
// belongsTo: parent has FK pointing to related
|
|
299
|
+
const ownerKey = assertIdentifier(relation.ownerKey || relatedClass.primaryKey || 'id', 'ownerKey');
|
|
300
|
+
onLeft = `${relatedTable}.${ownerKey}`;
|
|
301
|
+
onRight = `${parentTable}.${foreignKey}`;
|
|
250
302
|
} else {
|
|
251
|
-
// hasOne/hasMany: related has FK to parent
|
|
252
|
-
onLeft = `${relatedTable}.${
|
|
253
|
-
onRight = `${parentTable}.${
|
|
303
|
+
// hasOne/hasMany: related has FK pointing to parent
|
|
304
|
+
onLeft = `${relatedTable}.${foreignKey}`;
|
|
305
|
+
onRight = `${parentTable}.${localKey}`;
|
|
254
306
|
}
|
|
255
307
|
|
|
256
308
|
// Ensure the join exists
|
|
@@ -314,23 +366,27 @@ class QueryBuilder {
|
|
|
314
366
|
}
|
|
315
367
|
const relation = fn.call(parent);
|
|
316
368
|
const relatedClass = relation.related;
|
|
317
|
-
const relatedTable = relatedClass.table;
|
|
318
|
-
const parentTable = this.model.table;
|
|
369
|
+
const relatedTable = assertIdentifier(relatedClass.table, 'related table');
|
|
370
|
+
const parentTable = assertIdentifier(this.model.table, 'parent table');
|
|
371
|
+
const foreignKey = assertIdentifier(relation.foreignKey, 'foreignKey');
|
|
372
|
+
const localKey = assertIdentifier(relation.localKey, 'localKey');
|
|
319
373
|
|
|
320
|
-
//
|
|
321
|
-
const relatedDerivedFK = `${relatedTable.replace(/s$/, '')}_id`;
|
|
374
|
+
// Determine direction using relation type (same logic as whereHas)
|
|
322
375
|
let onLeft, onRight;
|
|
323
|
-
if (relation.
|
|
324
|
-
|
|
325
|
-
|
|
376
|
+
if (relation.child) {
|
|
377
|
+
// belongsTo: parent has FK pointing to related
|
|
378
|
+
const ownerKey = assertIdentifier(relation.ownerKey || relatedClass.primaryKey || 'id', 'ownerKey');
|
|
379
|
+
onLeft = `${relatedTable}.${ownerKey}`;
|
|
380
|
+
onRight = `${parentTable}.${foreignKey}`;
|
|
326
381
|
} else {
|
|
327
|
-
|
|
328
|
-
|
|
382
|
+
// hasOne/hasMany: related has FK pointing to parent
|
|
383
|
+
onLeft = `${relatedTable}.${foreignKey}`;
|
|
384
|
+
onRight = `${parentTable}.${localKey}`;
|
|
329
385
|
}
|
|
330
386
|
|
|
331
387
|
// LEFT JOIN and ensure null on related PK
|
|
332
388
|
this.leftJoin(relatedTable, onLeft, '=', onRight);
|
|
333
|
-
const relatedPk = relatedClass.primaryKey || 'id';
|
|
389
|
+
const relatedPk = assertIdentifier(relatedClass.primaryKey || 'id', 'relatedPrimaryKey');
|
|
334
390
|
this.whereNull(`${relatedTable}.${relatedPk}`);
|
|
335
391
|
return this;
|
|
336
392
|
}
|
|
@@ -346,6 +402,16 @@ class QueryBuilder {
|
|
|
346
402
|
return this;
|
|
347
403
|
}
|
|
348
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Add a raw order by clause
|
|
407
|
+
* @param {string} sql
|
|
408
|
+
* @returns {this}
|
|
409
|
+
*/
|
|
410
|
+
orderByRaw(sql) {
|
|
411
|
+
this.orders.push({ type: 'raw', sql });
|
|
412
|
+
return this;
|
|
413
|
+
}
|
|
414
|
+
|
|
349
415
|
/**
|
|
350
416
|
* Typo-friendly alias for orderBy
|
|
351
417
|
* @param {string} column
|
|
@@ -451,23 +517,29 @@ class QueryBuilder {
|
|
|
451
517
|
const fn = parent[name];
|
|
452
518
|
if (typeof fn !== 'function') continue;
|
|
453
519
|
const relation = fn.call(parent);
|
|
454
|
-
const parentTable = this.model.table;
|
|
520
|
+
const parentTable = assertIdentifier(this.model.table, 'parent table');
|
|
455
521
|
const relatedClass = relation.related;
|
|
456
|
-
const relatedTable = relatedClass.table;
|
|
522
|
+
const relatedTable = assertIdentifier(relatedClass.table, 'related table');
|
|
457
523
|
|
|
458
524
|
let sub = '';
|
|
459
525
|
if (relation instanceof require('./Relations/BelongsToManyRelation')) {
|
|
460
526
|
// belongsToMany: count from pivot
|
|
461
|
-
|
|
527
|
+
const pivot = assertIdentifier(relation.pivot, 'pivot table');
|
|
528
|
+
const fpk = assertIdentifier(relation.foreignPivotKey, 'foreignPivotKey');
|
|
529
|
+
const pk = assertIdentifier(relation.parentKey, 'parentKey');
|
|
530
|
+
sub = `(SELECT COUNT(*) FROM \`${pivot}\` WHERE \`${pivot}\`.\`${fpk}\` = \`${parentTable}\`.\`${pk}\`) AS \`${name}_count\``;
|
|
462
531
|
} else if (relation.child) {
|
|
463
532
|
// belongsTo
|
|
464
|
-
const ownerKey = relation.ownerKey || relatedClass.primaryKey || 'id';
|
|
465
|
-
|
|
533
|
+
const ownerKey = assertIdentifier(relation.ownerKey || relatedClass.primaryKey || 'id', 'ownerKey');
|
|
534
|
+
const fk = assertIdentifier(relation.foreignKey, 'foreignKey');
|
|
535
|
+
sub = `(SELECT COUNT(*) FROM \`${relatedTable}\` WHERE \`${relatedTable}\`.\`${ownerKey}\` = \`${parentTable}\`.\`${fk}\`) AS \`${name}_count\``;
|
|
466
536
|
} else {
|
|
467
537
|
// hasOne/hasMany
|
|
468
|
-
|
|
538
|
+
const fk = assertIdentifier(relation.foreignKey, 'foreignKey');
|
|
539
|
+
const lk = assertIdentifier(relation.localKey, 'localKey');
|
|
540
|
+
sub = `(SELECT COUNT(*) FROM \`${relatedTable}\` WHERE \`${relatedTable}\`.\`${fk}\` = \`${parentTable}\`.\`${lk}\`) AS \`${name}_count\``;
|
|
469
541
|
}
|
|
470
|
-
this.selectedColumns.push(sub);
|
|
542
|
+
this.selectedColumns.push(new RawExpression(sub));
|
|
471
543
|
}
|
|
472
544
|
return this;
|
|
473
545
|
}
|
|
@@ -609,10 +681,18 @@ class QueryBuilder {
|
|
|
609
681
|
* @returns {Promise<any>}
|
|
610
682
|
*/
|
|
611
683
|
async insert(data) {
|
|
612
|
-
|
|
613
|
-
|
|
684
|
+
// Apply fillable guard: only allow fields listed in model.fillable (if defined)
|
|
685
|
+
const fillable = this.model.fillable || [];
|
|
686
|
+
const applyFillable = (obj) => fillable.length > 0
|
|
687
|
+
? Object.fromEntries(Object.entries(obj).filter(([k]) => fillable.includes(k)))
|
|
688
|
+
: { ...obj };
|
|
689
|
+
|
|
690
|
+
const safeData = Array.isArray(data) ? data.map(applyFillable) : applyFillable(data);
|
|
691
|
+
|
|
692
|
+
if (Array.isArray(safeData)) {
|
|
693
|
+
return this.model.connection.insertMany(this.model.table, safeData);
|
|
614
694
|
}
|
|
615
|
-
return this.model.connection.insert(this.model.table,
|
|
695
|
+
return this.model.connection.insert(this.model.table, safeData);
|
|
616
696
|
}
|
|
617
697
|
|
|
618
698
|
/**
|
|
@@ -621,13 +701,19 @@ class QueryBuilder {
|
|
|
621
701
|
* @returns {Promise<any>}
|
|
622
702
|
*/
|
|
623
703
|
async update(attributes) {
|
|
704
|
+
// Apply fillable guard: only allow fields listed in model.fillable (if defined)
|
|
705
|
+
const fillable = this.model.fillable || [];
|
|
706
|
+
const safeAttributes = fillable.length > 0
|
|
707
|
+
? Object.fromEntries(Object.entries(attributes).filter(([k]) => fillable.includes(k)))
|
|
708
|
+
: { ...attributes };
|
|
709
|
+
|
|
624
710
|
if (this.model.timestamps) {
|
|
625
|
-
|
|
711
|
+
safeAttributes.updated_at = new Date();
|
|
626
712
|
}
|
|
627
713
|
|
|
628
714
|
return this.model.connection.update(
|
|
629
715
|
this.model.table,
|
|
630
|
-
|
|
716
|
+
safeAttributes,
|
|
631
717
|
this.buildQuery()
|
|
632
718
|
);
|
|
633
719
|
}
|
|
@@ -729,6 +815,10 @@ class QueryBuilder {
|
|
|
729
815
|
const head = segments[0];
|
|
730
816
|
const tail = segments.slice(1).join('.');
|
|
731
817
|
|
|
818
|
+
// Prevent prototype pollution and calling built-in methods
|
|
819
|
+
const builtIns = ['constructor', 'load', 'save', 'delete', 'update', 'query', 'with', 'withCount', 'hasOne', 'hasMany', 'belongsTo', 'belongsToMany', 'morphTo', 'morphOne', 'morphMany', 'hasOneThrough', 'hasManyThrough'];
|
|
820
|
+
if (builtIns.includes(head) || head.startsWith('__')) return;
|
|
821
|
+
|
|
732
822
|
// Load head relation eagerly
|
|
733
823
|
const relationInstance = models[0][head];
|
|
734
824
|
if (typeof relationInstance === 'function') {
|
|
@@ -787,6 +877,15 @@ class QueryBuilder {
|
|
|
787
877
|
cloned.distinctFlag = this.distinctFlag;
|
|
788
878
|
cloned.groupBys = [...this.groupBys];
|
|
789
879
|
cloned.havings = [...this.havings];
|
|
880
|
+
|
|
881
|
+
cloned._showHidden = this._showHidden;
|
|
882
|
+
cloned._withTrashed = this._withTrashed;
|
|
883
|
+
cloned._onlyTrashed = this._onlyTrashed;
|
|
884
|
+
cloned._excludedScopes = [...this._excludedScopes];
|
|
885
|
+
cloned._excludeAllScopes = this._excludeAllScopes;
|
|
886
|
+
cloned._scopesApplied = this._scopesApplied;
|
|
887
|
+
cloned._softDeleteApplied = this._softDeleteApplied;
|
|
888
|
+
|
|
790
889
|
return cloned;
|
|
791
890
|
}
|
|
792
891
|
}
|