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.
@@ -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
- // Heuristic to detect relation direction
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.foreignKey === relatedDerivedFK) {
247
- // belongsTo: parent has FK to related
248
- onLeft = `${relatedTable}.${relation.localKey}`; // related.ownerKey
249
- onRight = `${parentTable}.${relation.foreignKey}`; // parent.foreignKey
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}.${relation.foreignKey}`; // related.foreignKey -> parent
253
- onRight = `${parentTable}.${relation.localKey}`; // parent.localKey (usually PK)
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
- // Heuristic to detect direction as above
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.foreignKey === relatedDerivedFK) {
324
- onLeft = `${relatedTable}.${relation.localKey}`;
325
- onRight = `${parentTable}.${relation.foreignKey}`;
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
- onLeft = `${relatedTable}.${relation.foreignKey}`;
328
- onRight = `${parentTable}.${relation.localKey}`;
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
- sub = `(SELECT COUNT(*) FROM ${relation.pivot} WHERE ${relation.pivot}.${relation.foreignPivotKey} = ${parentTable}.${relation.parentKey}) AS ${name}_count`;
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
- sub = `(SELECT COUNT(*) FROM ${relatedTable} WHERE ${relatedTable}.${ownerKey} = ${parentTable}.${relation.foreignKey}) AS ${name}_count`;
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
- sub = `(SELECT COUNT(*) FROM ${relatedTable} WHERE ${relatedTable}.${relation.foreignKey} = ${parentTable}.${relation.localKey}) AS ${name}_count`;
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
- if (Array.isArray(data)) {
613
- return this.model.connection.insertMany(this.model.table, data);
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, data);
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
- attributes.updated_at = new Date();
711
+ safeAttributes.updated_at = new Date();
626
712
  }
627
713
 
628
714
  return this.model.connection.update(
629
715
  this.model.table,
630
- attributes,
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
  }
@@ -0,0 +1,11 @@
1
+ class RawExpression {
2
+ constructor(value) {
3
+ this.value = value;
4
+ }
5
+
6
+ toString() {
7
+ return this.value;
8
+ }
9
+ }
10
+
11
+ module.exports = RawExpression;