outlet-orm 2.5.0 → 3.1.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.
@@ -1,710 +1,794 @@
1
- /**
2
- * Query Builder for constructing and executing database queries
3
- */
4
- class QueryBuilder {
5
- constructor(model) {
6
- this.model = model;
7
- this.wheres = [];
8
- this.orders = [];
9
- this.limitValue = null;
10
- this.offsetValue = null;
11
- this.selectedColumns = ['*'];
12
- this.withRelations = [];
13
- this.withConstraints = {};
14
- this.joins = [];
15
- this.distinctFlag = false;
16
- this.groupBys = [];
17
- this.havings = [];
18
- }
19
-
20
- /**
21
- * Select specific columns
22
- * @param {...string} columns
23
- * @returns {this}
24
- */
25
- select(...columns) {
26
- this.selectedColumns = columns;
27
- return this;
28
- }
29
-
30
- /**
31
- * Convenience alias to pass an array of columns
32
- * @param {string[]} cols
33
- * @returns {this}
34
- */
35
- columns(cols) {
36
- if (Array.isArray(cols)) {
37
- this.selectedColumns = cols;
38
- }
39
- return this;
40
- }
41
-
42
- /**
43
- * Select distinct
44
- * @returns {this}
45
- */
46
- distinct() {
47
- this.distinctFlag = true;
48
- return this;
49
- }
50
-
51
- /**
52
- * Add a basic where clause
53
- * @param {string} column
54
- * @param {string|any} operator
55
- * @param {any} value
56
- * @returns {this}
57
- */
58
- where(column, operator, value) {
59
- if (arguments.length === 2) {
60
- value = operator;
61
- operator = '=';
62
- }
63
- this.wheres.push({ column, operator, value, type: 'basic', boolean: 'and' });
64
- return this;
65
- }
66
-
67
- /**
68
- * Add a where in clause
69
- * @param {string} column
70
- * @param {Array} values
71
- * @returns {this}
72
- */
73
- whereIn(column, values) {
74
- this.wheres.push({ column, values, type: 'in', boolean: 'and' });
75
- return this;
76
- }
77
-
78
- /**
79
- * Add a where not in clause
80
- * @param {string} column
81
- * @param {Array} values
82
- * @returns {this}
83
- */
84
- whereNotIn(column, values) {
85
- this.wheres.push({ column, values, type: 'notIn', boolean: 'and' });
86
- return this;
87
- }
88
-
89
- /**
90
- * Add a where null clause
91
- * @param {string} column
92
- * @returns {this}
93
- */
94
- whereNull(column) {
95
- this.wheres.push({ column, type: 'null', boolean: 'and' });
96
- return this;
97
- }
98
-
99
- /**
100
- * Add a where not null clause
101
- * @param {string} column
102
- * @returns {this}
103
- */
104
- whereNotNull(column) {
105
- this.wheres.push({ column, type: 'notNull', boolean: 'and' });
106
- return this;
107
- }
108
-
109
- /**
110
- * Add an or where clause
111
- * @param {string} column
112
- * @param {string|any} operator
113
- * @param {any} value
114
- * @returns {this}
115
- */
116
- orWhere(column, operator, value) {
117
- if (arguments.length === 2) {
118
- value = operator;
119
- operator = '=';
120
- }
121
- this.wheres.push({ column, operator, value, type: 'basic', boolean: 'or' });
122
- return this;
123
- }
124
-
125
- /**
126
- * Add a where between clause
127
- * @param {string} column
128
- * @param {Array} values
129
- * @returns {this}
130
- */
131
- whereBetween(column, values) {
132
- this.wheres.push({ column, values, type: 'between', boolean: 'and' });
133
- return this;
134
- }
135
-
136
- /**
137
- * Add a where like clause
138
- * @param {string} column
139
- * @param {string} value
140
- * @returns {this}
141
- */
142
- whereLike(column, value) {
143
- this.wheres.push({ column, value, type: 'like', boolean: 'and' });
144
- return this;
145
- }
146
-
147
- /**
148
- * Filter parents where the given relation has at least one matching record.
149
- * Implements via INNER JOIN and applying the related where clauses.
150
- * @param {string} relationName
151
- * @param {(qb: QueryBuilder) => void} [callback]
152
- * @returns {this}
153
- */
154
- whereHas(relationName, callback) {
155
- // Create a dummy parent instance to construct the relation
156
- const parent = new this.model();
157
- const fn = parent[relationName];
158
- if (typeof fn !== 'function') {
159
- throw new Error(`Relation '${relationName}' is not defined on ${this.model.name}`);
160
- }
161
- const relation = fn.call(parent);
162
- if (!relation?.related || !relation?.foreignKey || !relation?.localKey) {
163
- throw new Error(`Invalid relation '${relationName}' on ${this.model.name}`);
164
- }
165
-
166
- const parentTable = this.model.table;
167
- const relatedClass = relation.related;
168
- const relatedTable = relatedClass.table;
169
-
170
- // Heuristic to detect relation direction
171
- const relatedDerivedFK = `${relatedTable.replace(/s$/, '')}_id`;
172
-
173
- // Build ON condition depending on relation type
174
- let onLeft, onRight;
175
- if (relation.foreignKey === relatedDerivedFK) {
176
- // belongsTo: parent has FK to related
177
- onLeft = `${relatedTable}.${relation.localKey}`; // related.ownerKey
178
- onRight = `${parentTable}.${relation.foreignKey}`; // parent.foreignKey
179
- } else {
180
- // hasOne/hasMany: related has FK to parent
181
- onLeft = `${relatedTable}.${relation.foreignKey}`; // related.foreignKey -> parent
182
- onRight = `${parentTable}.${relation.localKey}`; // parent.localKey (usually PK)
183
- }
184
-
185
- // Ensure the join exists
186
- this.join(relatedTable, onLeft, '=', onRight);
187
-
188
- if (typeof callback === 'function') {
189
- const relatedQB = new QueryBuilder(relatedClass);
190
- callback(relatedQB);
191
-
192
- // Prefix related wheres with table name when necessary
193
- for (const w of relatedQB.wheres) {
194
- const clone = { ...w };
195
- if (clone.column && !/\./.test(clone.column)) {
196
- clone.column = `${relatedTable}.${clone.column}`;
197
- }
198
- this.wheres.push(clone);
199
- }
200
- }
201
-
202
- return this;
203
- }
204
-
205
- /**
206
- * Filter parents that have related rows count matching operator and count
207
- * @param {string} relationName
208
- * @param {string|number} operatorOrCount
209
- * @param {number} [count]
210
- * @returns {this}
211
- */
212
- has(relationName, operatorOrCount = '>=', count = 1) {
213
- let operator = operatorOrCount;
214
- if (typeof operatorOrCount === 'number') {
215
- operator = '>=';
216
- count = operatorOrCount;
217
- }
218
-
219
- // Reuse whereHas join logic without extra wheres
220
- this.whereHas(relationName);
221
-
222
- const parentTable = this.model.table;
223
- const parentPk = this.model.primaryKey || 'id';
224
-
225
- // Group by parent primary key and having count
226
- if (!this.groupBys.includes(`${parentTable}.${parentPk}`)) {
227
- this.groupBys.push(`${parentTable}.${parentPk}`);
228
- }
229
- this.havings.push({ type: 'count', column: '*', operator, value: count });
230
- return this;
231
- }
232
-
233
- /**
234
- * Filter parents that do not have related rows (no callback support for now)
235
- * @param {string} relationName
236
- * @returns {this}
237
- */
238
- whereDoesntHave(relationName) {
239
- const parent = new this.model();
240
- const fn = parent[relationName];
241
- if (typeof fn !== 'function') {
242
- throw new Error(`Relation '${relationName}' is not defined on ${this.model.name}`);
243
- }
244
- const relation = fn.call(parent);
245
- const relatedClass = relation.related;
246
- const relatedTable = relatedClass.table;
247
- const parentTable = this.model.table;
248
-
249
- // Heuristic to detect direction as above
250
- const relatedDerivedFK = `${relatedTable.replace(/s$/, '')}_id`;
251
- let onLeft, onRight;
252
- if (relation.foreignKey === relatedDerivedFK) {
253
- onLeft = `${relatedTable}.${relation.localKey}`;
254
- onRight = `${parentTable}.${relation.foreignKey}`;
255
- } else {
256
- onLeft = `${relatedTable}.${relation.foreignKey}`;
257
- onRight = `${parentTable}.${relation.localKey}`;
258
- }
259
-
260
- // LEFT JOIN and ensure null on related PK
261
- this.leftJoin(relatedTable, onLeft, '=', onRight);
262
- const relatedPk = relatedClass.primaryKey || 'id';
263
- this.whereNull(`${relatedTable}.${relatedPk}`);
264
- return this;
265
- }
266
-
267
- /**
268
- * Add an order by clause
269
- * @param {string} column
270
- * @param {string} direction
271
- * @returns {this}
272
- */
273
- orderBy(column, direction = 'asc') {
274
- this.orders.push({ column, direction: direction.toLowerCase() });
275
- return this;
276
- }
277
-
278
- /**
279
- * Typo-friendly alias for orderBy
280
- * @param {string} column
281
- * @param {string} direction
282
- * @returns {this}
283
- */
284
- ordrer(column, direction = 'asc') {
285
- return this.orderBy(column, direction);
286
- }
287
-
288
- /**
289
- * Set the limit
290
- * @param {number} value
291
- * @returns {this}
292
- */
293
- limit(value) {
294
- this.limitValue = value;
295
- return this;
296
- }
297
-
298
- /**
299
- * Set the offset
300
- * @param {number} value
301
- * @returns {this}
302
- */
303
- offset(value) {
304
- this.offsetValue = value;
305
- return this;
306
- }
307
-
308
- /**
309
- * Group by columns
310
- * @param {...string} columns
311
- * @returns {this}
312
- */
313
- groupBy(...columns) {
314
- this.groupBys.push(...columns);
315
- return this;
316
- }
317
-
318
- /**
319
- * Having clause (basic)
320
- * @param {string} column
321
- * @param {string} operator
322
- * @param {any} value
323
- * @returns {this}
324
- */
325
- having(column, operator, value) {
326
- this.havings.push({ type: 'basic', column, operator, value });
327
- return this;
328
- }
329
-
330
- /**
331
- * Set the number of records to skip
332
- * @param {number} value
333
- * @returns {this}
334
- */
335
- skip(value) {
336
- return this.offset(value);
337
- }
338
-
339
- /**
340
- * Set the number of records to take
341
- * @param {number} value
342
- * @returns {this}
343
- */
344
- take(value) {
345
- return this.limit(value);
346
- }
347
-
348
- /**
349
- * Eager load relations
350
- * @param {...string} relations
351
- * @returns {this}
352
- */
353
- with(...relations) {
354
- // Support forms: with('a', 'b') | with(['a','b']) | with({ a: cb })
355
- if (relations.length === 1 && Array.isArray(relations[0])) {
356
- this.withRelations.push(...relations[0]);
357
- } else if (relations.length === 1 && typeof relations[0] === 'object' && !Array.isArray(relations[0])) {
358
- const obj = relations[0];
359
- for (const [name, cb] of Object.entries(obj)) {
360
- this.withRelations.push(name);
361
- if (typeof cb === 'function') this.withConstraints[name] = cb;
362
- }
363
- } else {
364
- this.withRelations.push(...relations);
365
- }
366
- return this;
367
- }
368
-
369
- /**
370
- * withCount helper: adds subquery count columns
371
- * Supports: withCount('rel') or withCount(['a','b'])
372
- * @param {string|string[]} rels
373
- * @returns {this}
374
- */
375
- withCount(rels) {
376
- const list = Array.isArray(rels) ? rels : [rels];
377
- for (const name of list) {
378
- // Build simple subquery for hasOne/hasMany/belongsTo/belongsToMany
379
- const parent = new this.model();
380
- const fn = parent[name];
381
- if (typeof fn !== 'function') continue;
382
- const relation = fn.call(parent);
383
- const parentTable = this.model.table;
384
- const relatedClass = relation.related;
385
- const relatedTable = relatedClass.table;
386
-
387
- let sub = '';
388
- if (relation instanceof require('./Relations/BelongsToManyRelation')) {
389
- // belongsToMany: count from pivot
390
- sub = `(SELECT COUNT(*) FROM ${relation.pivot} WHERE ${relation.pivot}.${relation.foreignPivotKey} = ${parentTable}.${relation.parentKey}) AS ${name}_count`;
391
- } else if (relation.child) {
392
- // belongsTo
393
- const ownerKey = relation.ownerKey || relatedClass.primaryKey || 'id';
394
- sub = `(SELECT COUNT(*) FROM ${relatedTable} WHERE ${relatedTable}.${ownerKey} = ${parentTable}.${relation.foreignKey}) AS ${name}_count`;
395
- } else {
396
- // hasOne/hasMany
397
- sub = `(SELECT COUNT(*) FROM ${relatedTable} WHERE ${relatedTable}.${relation.foreignKey} = ${parentTable}.${relation.localKey}) AS ${name}_count`;
398
- }
399
- this.selectedColumns.push(sub);
400
- }
401
- return this;
402
- }
403
-
404
- /**
405
- * Add a join clause
406
- * @param {string} table
407
- * @param {string} first
408
- * @param {string} operator
409
- * @param {string} second
410
- * @returns {this}
411
- */
412
- join(table, first, operator, second) {
413
- if (arguments.length === 3) {
414
- second = operator;
415
- operator = '=';
416
- }
417
- this.joins.push({ table, first, operator, second, type: 'inner' });
418
- return this;
419
- }
420
-
421
- /**
422
- * Add a left join clause
423
- * @param {string} table
424
- * @param {string} first
425
- * @param {string} operator
426
- * @param {string} second
427
- * @returns {this}
428
- */
429
- leftJoin(table, first, operator, second) {
430
- if (arguments.length === 3) {
431
- second = operator;
432
- operator = '=';
433
- }
434
- this.joins.push({ table, first, operator, second, type: 'left' });
435
- return this;
436
- }
437
-
438
- /**
439
- * Execute the query and get all results
440
- * @returns {Promise<Array>}
441
- */
442
- async get() {
443
- const rows = await this.model.connection.select(
444
- this.model.table,
445
- this.buildQuery()
446
- );
447
-
448
- const instances = rows.map(row => this.hydrate(row));
449
-
450
- if (this.withRelations.length > 0) {
451
- await this.eagerLoadRelations(instances);
452
- }
453
-
454
- return instances;
455
- }
456
-
457
- /**
458
- * Get the first result
459
- * @returns {Promise<Model|null>}
460
- */
461
- async first() {
462
- this.limit(1);
463
- const results = await this.get();
464
- return results[0] || null;
465
- }
466
-
467
- /**
468
- * Get the first result or throw an exception
469
- * @returns {Promise<Model>}
470
- */
471
- async firstOrFail() {
472
- const result = await this.first();
473
- if (!result) {
474
- throw new Error(`Model not found in table ${this.model.table}`);
475
- }
476
- return result;
477
- }
478
-
479
- /**
480
- * Paginate the results
481
- * @param {number} page
482
- * @param {number} perPage
483
- * @returns {Promise<Object>}
484
- */
485
- async paginate(page = 1, perPage = 15) {
486
- const offset = (page - 1) * perPage;
487
-
488
- const total = await this.count();
489
- const data = await this.offset(offset).limit(perPage).get();
490
-
491
- return {
492
- data,
493
- total,
494
- per_page: perPage,
495
- current_page: page,
496
- last_page: Math.ceil(total / perPage),
497
- from: total > 0 ? offset + 1 : null,
498
- to: offset + data.length
499
- };
500
- }
501
-
502
- /**
503
- * Get the count of records
504
- * @returns {Promise<number>}
505
- */
506
- async count() {
507
- const result = await this.model.connection.count(
508
- this.model.table,
509
- this.buildQuery()
510
- );
511
- return result;
512
- }
513
-
514
- /**
515
- * Check if any records exist
516
- * @returns {Promise<boolean>}
517
- */
518
- async exists() {
519
- const count = await this.count();
520
- return count > 0;
521
- }
522
-
523
- /**
524
- * Insert records
525
- * @param {Object|Array<Object>} data
526
- * @returns {Promise<any>}
527
- */
528
- async insert(data) {
529
- if (Array.isArray(data)) {
530
- return this.model.connection.insertMany(this.model.table, data);
531
- }
532
- return this.model.connection.insert(this.model.table, data);
533
- }
534
-
535
- /**
536
- * Update records
537
- * @param {Object} attributes
538
- * @returns {Promise<any>}
539
- */
540
- async update(attributes) {
541
- if (this.model.timestamps) {
542
- attributes.updated_at = new Date();
543
- }
544
-
545
- return this.model.connection.update(
546
- this.model.table,
547
- attributes,
548
- this.buildQuery()
549
- );
550
- }
551
-
552
- /**
553
- * Update records and fetch the first updated model, optionally eager loading relations
554
- * @param {Object} attributes
555
- * @param {string[]} [relations]
556
- * @returns {Promise<Model|null>}
557
- */
558
- async updateAndFetch(attributes, relations = []) {
559
- await this.update(attributes);
560
- const qb = this.clone();
561
- if (relations?.length) {
562
- qb.with(...relations);
563
- }
564
- return qb.first();
565
- }
566
-
567
- /**
568
- * Delete records
569
- * @returns {Promise<any>}
570
- */
571
- async delete() {
572
- return this.model.connection.delete(
573
- this.model.table,
574
- this.buildQuery()
575
- );
576
- }
577
-
578
- /**
579
- * Increment a column's value
580
- * @param {string} column
581
- * @param {number} amount
582
- * @returns {Promise<any>}
583
- */
584
- async increment(column, amount = 1) {
585
- return this.model.connection.increment(
586
- this.model.table,
587
- column,
588
- this.buildQuery(),
589
- amount
590
- );
591
- }
592
-
593
- /**
594
- * Decrement a column's value
595
- * @param {string} column
596
- * @param {number} amount
597
- * @returns {Promise<any>}
598
- */
599
- async decrement(column, amount = 1) {
600
- return this.model.connection.decrement(
601
- this.model.table,
602
- column,
603
- this.buildQuery(),
604
- amount
605
- );
606
- }
607
-
608
- /**
609
- * Create a model instance from a row
610
- * @param {Object} row
611
- * @returns {Model}
612
- */
613
- hydrate(row) {
614
- const instance = new this.model();
615
- instance.attributes = row;
616
- instance.original = { ...row };
617
- instance.exists = true;
618
- return instance;
619
- }
620
-
621
- /**
622
- * Eager load relations for a collection of models
623
- * @param {Array<Model>} instances
624
- * @returns {Promise<void>}
625
- */
626
- async eagerLoadRelations(instances) {
627
- if (instances.length === 0) return;
628
-
629
- for (const relationName of this.withRelations) {
630
- await this.loadRelationPath(instances, relationName, this.withConstraints[relationName]);
631
- }
632
- }
633
-
634
- /**
635
- * Load a relation path with support for nested relations (dot notation)
636
- * @param {Array<Model>} models
637
- * @param {string} path
638
- * @param {*} constraint
639
- * @returns {Promise<void>}
640
- */
641
- async loadRelationPath(models, path, constraint) {
642
- if (models.length === 0) return;
643
-
644
- const segments = path.split('.');
645
- const head = segments[0];
646
- const tail = segments.slice(1).join('.');
647
-
648
- // Load head relation eagerly
649
- const relationInstance = models[0][head];
650
- if (typeof relationInstance === 'function') {
651
- const relation = relationInstance.call(models[0]);
652
- if (relation && typeof relation.eagerLoad === 'function') {
653
- await relation.eagerLoad(models, head, constraint);
654
- }
655
- }
656
-
657
- if (tail) {
658
- // Collect all related models from the loaded relations
659
- const relatedModels = models.flatMap(model => {
660
- const rel = model.relations[head];
661
- return Array.isArray(rel) ? rel : (rel ? [rel] : []);
662
- }).filter(Boolean);
663
-
664
- if (relatedModels.length > 0) {
665
- // Recursively load the remaining path on related models
666
- await this.loadRelationPath(relatedModels, tail, null);
667
- }
668
- }
669
- }
670
-
671
- /**
672
- * Build the query object
673
- * @returns {Object}
674
- */
675
- buildQuery() {
676
- return {
677
- columns: this.selectedColumns,
678
- wheres: this.wheres,
679
- orders: this.orders,
680
- joins: this.joins,
681
- distinct: this.distinctFlag,
682
- groupBys: this.groupBys,
683
- havings: this.havings,
684
- limit: this.limitValue,
685
- offset: this.offsetValue
686
- };
687
- }
688
-
689
- /**
690
- * Clone the query builder
691
- * @returns {QueryBuilder}
692
- */
693
- clone() {
694
- const cloned = new QueryBuilder(this.model);
695
- cloned.wheres = [...this.wheres];
696
- cloned.orders = [...this.orders];
697
- cloned.limitValue = this.limitValue;
698
- cloned.offsetValue = this.offsetValue;
699
- cloned.selectedColumns = [...this.selectedColumns];
700
- cloned.withRelations = [...this.withRelations];
701
- cloned.withConstraints = { ...this.withConstraints };
702
- cloned.joins = [...this.joins];
703
- cloned.distinctFlag = this.distinctFlag;
704
- cloned.groupBys = [...this.groupBys];
705
- cloned.havings = [...this.havings];
706
- return cloned;
707
- }
708
- }
709
-
710
- module.exports = QueryBuilder;
1
+ /**
2
+ * Query Builder for constructing and executing database queries
3
+ */
4
+ class QueryBuilder {
5
+ constructor(model) {
6
+ this.model = model;
7
+ this.wheres = [];
8
+ this.orders = [];
9
+ this.limitValue = null;
10
+ this.offsetValue = null;
11
+ this.selectedColumns = ['*'];
12
+ this.withRelations = [];
13
+ this.withConstraints = {};
14
+ this.joins = [];
15
+ this.distinctFlag = false;
16
+ this.groupBys = [];
17
+ this.havings = [];
18
+ this._showHidden = false;
19
+ this._withTrashed = false;
20
+ this._onlyTrashed = false;
21
+ this._excludedScopes = [];
22
+ this._excludeAllScopes = false;
23
+ }
24
+
25
+ /**
26
+ * Apply global scopes to the query
27
+ * @private
28
+ */
29
+ _applyGlobalScopes() {
30
+ if (this._excludeAllScopes) return;
31
+
32
+ const scopes = this.model.globalScopes || {};
33
+ for (const [name, scopeFn] of Object.entries(scopes)) {
34
+ if (!this._excludedScopes.includes(name)) {
35
+ scopeFn(this);
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Apply soft delete constraints
42
+ * @private
43
+ */
44
+ _applySoftDeleteConstraints() {
45
+ if (!this.model.softDeletes) return;
46
+
47
+ if (this._onlyTrashed) {
48
+ this.whereNotNull(this.model.DELETED_AT);
49
+ } else if (!this._withTrashed) {
50
+ this.whereNull(this.model.DELETED_AT);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Include soft deleted records
56
+ * @returns {this}
57
+ */
58
+ withTrashed() {
59
+ this._withTrashed = true;
60
+ return this;
61
+ }
62
+
63
+ /**
64
+ * Only get soft deleted records
65
+ * @returns {this}
66
+ */
67
+ onlyTrashed() {
68
+ this._onlyTrashed = true;
69
+ return this;
70
+ }
71
+
72
+ /**
73
+ * Query without a specific global scope
74
+ * @param {string} name
75
+ * @returns {this}
76
+ */
77
+ withoutGlobalScope(name) {
78
+ this._excludedScopes.push(name);
79
+ return this;
80
+ }
81
+
82
+ /**
83
+ * Query without all global scopes
84
+ * @returns {this}
85
+ */
86
+ withoutGlobalScopes() {
87
+ this._excludeAllScopes = true;
88
+ return this;
89
+ }
90
+
91
+ /**
92
+ * Select specific columns
93
+ * @param {...string} columns
94
+ * @returns {this}
95
+ */
96
+ select(...columns) {
97
+ this.selectedColumns = columns;
98
+ return this;
99
+ }
100
+
101
+ /**
102
+ * Convenience alias to pass an array of columns
103
+ * @param {string[]} cols
104
+ * @returns {this}
105
+ */
106
+ columns(cols) {
107
+ if (Array.isArray(cols)) {
108
+ this.selectedColumns = cols;
109
+ }
110
+ return this;
111
+ }
112
+
113
+ /**
114
+ * Select distinct
115
+ * @returns {this}
116
+ */
117
+ distinct() {
118
+ this.distinctFlag = true;
119
+ return this;
120
+ }
121
+
122
+ /**
123
+ * Add a basic where clause
124
+ * @param {string} column
125
+ * @param {string|any} operator
126
+ * @param {any} value
127
+ * @returns {this}
128
+ */
129
+ where(column, operator, value) {
130
+ if (arguments.length === 2) {
131
+ value = operator;
132
+ operator = '=';
133
+ }
134
+ this.wheres.push({ column, operator, value, type: 'basic', boolean: 'and' });
135
+ return this;
136
+ }
137
+
138
+ /**
139
+ * Add a where in clause
140
+ * @param {string} column
141
+ * @param {Array} values
142
+ * @returns {this}
143
+ */
144
+ whereIn(column, values) {
145
+ this.wheres.push({ column, values, type: 'in', boolean: 'and' });
146
+ return this;
147
+ }
148
+
149
+ /**
150
+ * Add a where not in clause
151
+ * @param {string} column
152
+ * @param {Array} values
153
+ * @returns {this}
154
+ */
155
+ whereNotIn(column, values) {
156
+ this.wheres.push({ column, values, type: 'notIn', boolean: 'and' });
157
+ return this;
158
+ }
159
+
160
+ /**
161
+ * Add a where null clause
162
+ * @param {string} column
163
+ * @returns {this}
164
+ */
165
+ whereNull(column) {
166
+ this.wheres.push({ column, type: 'null', boolean: 'and' });
167
+ return this;
168
+ }
169
+
170
+ /**
171
+ * Add a where not null clause
172
+ * @param {string} column
173
+ * @returns {this}
174
+ */
175
+ whereNotNull(column) {
176
+ this.wheres.push({ column, type: 'notNull', boolean: 'and' });
177
+ return this;
178
+ }
179
+
180
+ /**
181
+ * Add an or where clause
182
+ * @param {string} column
183
+ * @param {string|any} operator
184
+ * @param {any} value
185
+ * @returns {this}
186
+ */
187
+ orWhere(column, operator, value) {
188
+ if (arguments.length === 2) {
189
+ value = operator;
190
+ operator = '=';
191
+ }
192
+ this.wheres.push({ column, operator, value, type: 'basic', boolean: 'or' });
193
+ return this;
194
+ }
195
+
196
+ /**
197
+ * Add a where between clause
198
+ * @param {string} column
199
+ * @param {Array} values
200
+ * @returns {this}
201
+ */
202
+ whereBetween(column, values) {
203
+ this.wheres.push({ column, values, type: 'between', boolean: 'and' });
204
+ return this;
205
+ }
206
+
207
+ /**
208
+ * Add a where like clause
209
+ * @param {string} column
210
+ * @param {string} value
211
+ * @returns {this}
212
+ */
213
+ whereLike(column, value) {
214
+ this.wheres.push({ column, value, type: 'like', boolean: 'and' });
215
+ return this;
216
+ }
217
+
218
+ /**
219
+ * Filter parents where the given relation has at least one matching record.
220
+ * Implements via INNER JOIN and applying the related where clauses.
221
+ * @param {string} relationName
222
+ * @param {(qb: QueryBuilder) => void} [callback]
223
+ * @returns {this}
224
+ */
225
+ whereHas(relationName, callback) {
226
+ // Create a dummy parent instance to construct the relation
227
+ const parent = new this.model();
228
+ const fn = parent[relationName];
229
+ if (typeof fn !== 'function') {
230
+ throw new Error(`Relation '${relationName}' is not defined on ${this.model.name}`);
231
+ }
232
+ const relation = fn.call(parent);
233
+ if (!relation?.related || !relation?.foreignKey || !relation?.localKey) {
234
+ throw new Error(`Invalid relation '${relationName}' on ${this.model.name}`);
235
+ }
236
+
237
+ const parentTable = this.model.table;
238
+ const relatedClass = relation.related;
239
+ const relatedTable = relatedClass.table;
240
+
241
+ // Heuristic to detect relation direction
242
+ const relatedDerivedFK = `${relatedTable.replace(/s$/, '')}_id`;
243
+
244
+ // Build ON condition depending on relation type
245
+ 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
250
+ } 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)
254
+ }
255
+
256
+ // Ensure the join exists
257
+ this.join(relatedTable, onLeft, '=', onRight);
258
+
259
+ if (typeof callback === 'function') {
260
+ const relatedQB = new QueryBuilder(relatedClass);
261
+ callback(relatedQB);
262
+
263
+ // Prefix related wheres with table name when necessary
264
+ for (const w of relatedQB.wheres) {
265
+ const clone = { ...w };
266
+ if (clone.column && !/\./.test(clone.column)) {
267
+ clone.column = `${relatedTable}.${clone.column}`;
268
+ }
269
+ this.wheres.push(clone);
270
+ }
271
+ }
272
+
273
+ return this;
274
+ }
275
+
276
+ /**
277
+ * Filter parents that have related rows count matching operator and count
278
+ * @param {string} relationName
279
+ * @param {string|number} operatorOrCount
280
+ * @param {number} [count]
281
+ * @returns {this}
282
+ */
283
+ has(relationName, operatorOrCount = '>=', count = 1) {
284
+ let operator = operatorOrCount;
285
+ if (typeof operatorOrCount === 'number') {
286
+ operator = '>=';
287
+ count = operatorOrCount;
288
+ }
289
+
290
+ // Reuse whereHas join logic without extra wheres
291
+ this.whereHas(relationName);
292
+
293
+ const parentTable = this.model.table;
294
+ const parentPk = this.model.primaryKey || 'id';
295
+
296
+ // Group by parent primary key and having count
297
+ if (!this.groupBys.includes(`${parentTable}.${parentPk}`)) {
298
+ this.groupBys.push(`${parentTable}.${parentPk}`);
299
+ }
300
+ this.havings.push({ type: 'count', column: '*', operator, value: count });
301
+ return this;
302
+ }
303
+
304
+ /**
305
+ * Filter parents that do not have related rows (no callback support for now)
306
+ * @param {string} relationName
307
+ * @returns {this}
308
+ */
309
+ whereDoesntHave(relationName) {
310
+ const parent = new this.model();
311
+ const fn = parent[relationName];
312
+ if (typeof fn !== 'function') {
313
+ throw new Error(`Relation '${relationName}' is not defined on ${this.model.name}`);
314
+ }
315
+ const relation = fn.call(parent);
316
+ const relatedClass = relation.related;
317
+ const relatedTable = relatedClass.table;
318
+ const parentTable = this.model.table;
319
+
320
+ // Heuristic to detect direction as above
321
+ const relatedDerivedFK = `${relatedTable.replace(/s$/, '')}_id`;
322
+ let onLeft, onRight;
323
+ if (relation.foreignKey === relatedDerivedFK) {
324
+ onLeft = `${relatedTable}.${relation.localKey}`;
325
+ onRight = `${parentTable}.${relation.foreignKey}`;
326
+ } else {
327
+ onLeft = `${relatedTable}.${relation.foreignKey}`;
328
+ onRight = `${parentTable}.${relation.localKey}`;
329
+ }
330
+
331
+ // LEFT JOIN and ensure null on related PK
332
+ this.leftJoin(relatedTable, onLeft, '=', onRight);
333
+ const relatedPk = relatedClass.primaryKey || 'id';
334
+ this.whereNull(`${relatedTable}.${relatedPk}`);
335
+ return this;
336
+ }
337
+
338
+ /**
339
+ * Add an order by clause
340
+ * @param {string} column
341
+ * @param {string} direction
342
+ * @returns {this}
343
+ */
344
+ orderBy(column, direction = 'asc') {
345
+ this.orders.push({ column, direction: direction.toLowerCase() });
346
+ return this;
347
+ }
348
+
349
+ /**
350
+ * Typo-friendly alias for orderBy
351
+ * @param {string} column
352
+ * @param {string} direction
353
+ * @returns {this}
354
+ */
355
+ ordrer(column, direction = 'asc') {
356
+ return this.orderBy(column, direction);
357
+ }
358
+
359
+ /**
360
+ * Set the limit
361
+ * @param {number} value
362
+ * @returns {this}
363
+ */
364
+ limit(value) {
365
+ this.limitValue = value;
366
+ return this;
367
+ }
368
+
369
+ /**
370
+ * Set the offset
371
+ * @param {number} value
372
+ * @returns {this}
373
+ */
374
+ offset(value) {
375
+ this.offsetValue = value;
376
+ return this;
377
+ }
378
+
379
+ /**
380
+ * Group by columns
381
+ * @param {...string} columns
382
+ * @returns {this}
383
+ */
384
+ groupBy(...columns) {
385
+ this.groupBys.push(...columns);
386
+ return this;
387
+ }
388
+
389
+ /**
390
+ * Having clause (basic)
391
+ * @param {string} column
392
+ * @param {string} operator
393
+ * @param {any} value
394
+ * @returns {this}
395
+ */
396
+ having(column, operator, value) {
397
+ this.havings.push({ type: 'basic', column, operator, value });
398
+ return this;
399
+ }
400
+
401
+ /**
402
+ * Set the number of records to skip
403
+ * @param {number} value
404
+ * @returns {this}
405
+ */
406
+ skip(value) {
407
+ return this.offset(value);
408
+ }
409
+
410
+ /**
411
+ * Set the number of records to take
412
+ * @param {number} value
413
+ * @returns {this}
414
+ */
415
+ take(value) {
416
+ return this.limit(value);
417
+ }
418
+
419
+ /**
420
+ * Eager load relations
421
+ * @param {...string} relations
422
+ * @returns {this}
423
+ */
424
+ with(...relations) {
425
+ // Support forms: with('a', 'b') | with(['a','b']) | with({ a: cb })
426
+ if (relations.length === 1 && Array.isArray(relations[0])) {
427
+ this.withRelations.push(...relations[0]);
428
+ } else if (relations.length === 1 && typeof relations[0] === 'object' && !Array.isArray(relations[0])) {
429
+ const obj = relations[0];
430
+ for (const [name, cb] of Object.entries(obj)) {
431
+ this.withRelations.push(name);
432
+ if (typeof cb === 'function') this.withConstraints[name] = cb;
433
+ }
434
+ } else {
435
+ this.withRelations.push(...relations);
436
+ }
437
+ return this;
438
+ }
439
+
440
+ /**
441
+ * withCount helper: adds subquery count columns
442
+ * Supports: withCount('rel') or withCount(['a','b'])
443
+ * @param {string|string[]} rels
444
+ * @returns {this}
445
+ */
446
+ withCount(rels) {
447
+ const list = Array.isArray(rels) ? rels : [rels];
448
+ for (const name of list) {
449
+ // Build simple subquery for hasOne/hasMany/belongsTo/belongsToMany
450
+ const parent = new this.model();
451
+ const fn = parent[name];
452
+ if (typeof fn !== 'function') continue;
453
+ const relation = fn.call(parent);
454
+ const parentTable = this.model.table;
455
+ const relatedClass = relation.related;
456
+ const relatedTable = relatedClass.table;
457
+
458
+ let sub = '';
459
+ if (relation instanceof require('./Relations/BelongsToManyRelation')) {
460
+ // belongsToMany: count from pivot
461
+ sub = `(SELECT COUNT(*) FROM ${relation.pivot} WHERE ${relation.pivot}.${relation.foreignPivotKey} = ${parentTable}.${relation.parentKey}) AS ${name}_count`;
462
+ } else if (relation.child) {
463
+ // belongsTo
464
+ const ownerKey = relation.ownerKey || relatedClass.primaryKey || 'id';
465
+ sub = `(SELECT COUNT(*) FROM ${relatedTable} WHERE ${relatedTable}.${ownerKey} = ${parentTable}.${relation.foreignKey}) AS ${name}_count`;
466
+ } else {
467
+ // hasOne/hasMany
468
+ sub = `(SELECT COUNT(*) FROM ${relatedTable} WHERE ${relatedTable}.${relation.foreignKey} = ${parentTable}.${relation.localKey}) AS ${name}_count`;
469
+ }
470
+ this.selectedColumns.push(sub);
471
+ }
472
+ return this;
473
+ }
474
+
475
+ /**
476
+ * Add a join clause
477
+ * @param {string} table
478
+ * @param {string} first
479
+ * @param {string} operator
480
+ * @param {string} second
481
+ * @returns {this}
482
+ */
483
+ join(table, first, operator, second) {
484
+ if (arguments.length === 3) {
485
+ second = operator;
486
+ operator = '=';
487
+ }
488
+ this.joins.push({ table, first, operator, second, type: 'inner' });
489
+ return this;
490
+ }
491
+
492
+ /**
493
+ * Add a left join clause
494
+ * @param {string} table
495
+ * @param {string} first
496
+ * @param {string} operator
497
+ * @param {string} second
498
+ * @returns {this}
499
+ */
500
+ leftJoin(table, first, operator, second) {
501
+ if (arguments.length === 3) {
502
+ second = operator;
503
+ operator = '=';
504
+ }
505
+ this.joins.push({ table, first, operator, second, type: 'left' });
506
+ return this;
507
+ }
508
+
509
+ /**
510
+ * Execute the query and get all results
511
+ * @returns {Promise<Array>}
512
+ */
513
+ async get() {
514
+ // Apply global scopes and soft delete constraints
515
+ this._applyGlobalScopes();
516
+ this._applySoftDeleteConstraints();
517
+
518
+ const rows = await this.model.connection.select(
519
+ this.model.table,
520
+ this.buildQuery()
521
+ );
522
+
523
+ const instances = rows.map(row => this.hydrate(row));
524
+
525
+ if (this.withRelations.length > 0) {
526
+ await this.eagerLoadRelations(instances);
527
+ }
528
+
529
+ return instances;
530
+ }
531
+
532
+ /**
533
+ * Get the first result
534
+ * @returns {Promise<Model|null>}
535
+ */
536
+ async first() {
537
+ this.limit(1);
538
+ const results = await this.get();
539
+ return results[0] || null;
540
+ }
541
+
542
+ /**
543
+ * Get the first result or throw an exception
544
+ * @returns {Promise<Model>}
545
+ */
546
+ async firstOrFail() {
547
+ const result = await this.first();
548
+ if (!result) {
549
+ throw new Error(`Model not found in table ${this.model.table}`);
550
+ }
551
+ return result;
552
+ }
553
+
554
+ /**
555
+ * Paginate the results
556
+ * @param {number} page
557
+ * @param {number} perPage
558
+ * @returns {Promise<Object>}
559
+ */
560
+ async paginate(page = 1, perPage = 15) {
561
+ const offset = (page - 1) * perPage;
562
+
563
+ // Apply scopes for count
564
+ this._applyGlobalScopes();
565
+ this._applySoftDeleteConstraints();
566
+
567
+ const total = await this.count();
568
+ const data = await this.offset(offset).limit(perPage).get();
569
+
570
+ return {
571
+ data,
572
+ total,
573
+ per_page: perPage,
574
+ current_page: page,
575
+ last_page: Math.ceil(total / perPage),
576
+ from: total > 0 ? offset + 1 : null,
577
+ to: offset + data.length
578
+ };
579
+ }
580
+
581
+ /**
582
+ * Get the count of records
583
+ * @returns {Promise<number>}
584
+ */
585
+ async count() {
586
+ // Apply scopes for count
587
+ this._applyGlobalScopes();
588
+ this._applySoftDeleteConstraints();
589
+
590
+ const result = await this.model.connection.count(
591
+ this.model.table,
592
+ this.buildQuery()
593
+ );
594
+ return result;
595
+ }
596
+
597
+ /**
598
+ * Check if any records exist
599
+ * @returns {Promise<boolean>}
600
+ */
601
+ async exists() {
602
+ const count = await this.count();
603
+ return count > 0;
604
+ }
605
+
606
+ /**
607
+ * Insert records
608
+ * @param {Object|Array<Object>} data
609
+ * @returns {Promise<any>}
610
+ */
611
+ async insert(data) {
612
+ if (Array.isArray(data)) {
613
+ return this.model.connection.insertMany(this.model.table, data);
614
+ }
615
+ return this.model.connection.insert(this.model.table, data);
616
+ }
617
+
618
+ /**
619
+ * Update records
620
+ * @param {Object} attributes
621
+ * @returns {Promise<any>}
622
+ */
623
+ async update(attributes) {
624
+ if (this.model.timestamps) {
625
+ attributes.updated_at = new Date();
626
+ }
627
+
628
+ return this.model.connection.update(
629
+ this.model.table,
630
+ attributes,
631
+ this.buildQuery()
632
+ );
633
+ }
634
+
635
+ /**
636
+ * Update records and fetch the first updated model, optionally eager loading relations
637
+ * @param {Object} attributes
638
+ * @param {string[]} [relations]
639
+ * @returns {Promise<Model|null>}
640
+ */
641
+ async updateAndFetch(attributes, relations = []) {
642
+ await this.update(attributes);
643
+ const qb = this.clone();
644
+ if (relations?.length) {
645
+ qb.with(...relations);
646
+ }
647
+ return qb.first();
648
+ }
649
+
650
+ /**
651
+ * Delete records
652
+ * @returns {Promise<any>}
653
+ */
654
+ async delete() {
655
+ return this.model.connection.delete(
656
+ this.model.table,
657
+ this.buildQuery()
658
+ );
659
+ }
660
+
661
+ /**
662
+ * Increment a column's value
663
+ * @param {string} column
664
+ * @param {number} amount
665
+ * @returns {Promise<any>}
666
+ */
667
+ async increment(column, amount = 1) {
668
+ return this.model.connection.increment(
669
+ this.model.table,
670
+ column,
671
+ this.buildQuery(),
672
+ amount
673
+ );
674
+ }
675
+
676
+ /**
677
+ * Decrement a column's value
678
+ * @param {string} column
679
+ * @param {number} amount
680
+ * @returns {Promise<any>}
681
+ */
682
+ async decrement(column, amount = 1) {
683
+ return this.model.connection.decrement(
684
+ this.model.table,
685
+ column,
686
+ this.buildQuery(),
687
+ amount
688
+ );
689
+ }
690
+
691
+ /**
692
+ * Create a model instance from a database row
693
+ * @param {Object} row
694
+ * @returns {Model}
695
+ */
696
+ hydrate(row) {
697
+ const instance = new this.model();
698
+ instance.attributes = row;
699
+ instance.original = { ...row };
700
+ instance.exists = true;
701
+ instance._showHidden = this._showHidden;
702
+ return instance;
703
+ }
704
+
705
+ /**
706
+ * Eager load relations for a collection of models
707
+ * @param {Array<Model>} instances
708
+ * @returns {Promise<void>}
709
+ */
710
+ async eagerLoadRelations(instances) {
711
+ if (instances.length === 0) return;
712
+
713
+ for (const relationName of this.withRelations) {
714
+ await this.loadRelationPath(instances, relationName, this.withConstraints[relationName]);
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Load a relation path with support for nested relations (dot notation)
720
+ * @param {Array<Model>} models
721
+ * @param {string} path
722
+ * @param {*} constraint
723
+ * @returns {Promise<void>}
724
+ */
725
+ async loadRelationPath(models, path, constraint) {
726
+ if (models.length === 0) return;
727
+
728
+ const segments = path.split('.');
729
+ const head = segments[0];
730
+ const tail = segments.slice(1).join('.');
731
+
732
+ // Load head relation eagerly
733
+ const relationInstance = models[0][head];
734
+ if (typeof relationInstance === 'function') {
735
+ const relation = relationInstance.call(models[0]);
736
+ if (relation && typeof relation.eagerLoad === 'function') {
737
+ await relation.eagerLoad(models, head, constraint);
738
+ }
739
+ }
740
+
741
+ if (tail) {
742
+ // Collect all related models from the loaded relations
743
+ const relatedModels = models.flatMap(model => {
744
+ const rel = model.relations[head];
745
+ return Array.isArray(rel) ? rel : (rel ? [rel] : []);
746
+ }).filter(Boolean);
747
+
748
+ if (relatedModels.length > 0) {
749
+ // Recursively load the remaining path on related models
750
+ await this.loadRelationPath(relatedModels, tail, null);
751
+ }
752
+ }
753
+ }
754
+
755
+ /**
756
+ * Build the query object
757
+ * @returns {Object}
758
+ */
759
+ buildQuery() {
760
+ return {
761
+ columns: this.selectedColumns,
762
+ wheres: this.wheres,
763
+ orders: this.orders,
764
+ joins: this.joins,
765
+ distinct: this.distinctFlag,
766
+ groupBys: this.groupBys,
767
+ havings: this.havings,
768
+ limit: this.limitValue,
769
+ offset: this.offsetValue
770
+ };
771
+ }
772
+
773
+ /**
774
+ * Clone the query builder
775
+ * @returns {QueryBuilder}
776
+ */
777
+ clone() {
778
+ const cloned = new QueryBuilder(this.model);
779
+ cloned.wheres = [...this.wheres];
780
+ cloned.orders = [...this.orders];
781
+ cloned.limitValue = this.limitValue;
782
+ cloned.offsetValue = this.offsetValue;
783
+ cloned.selectedColumns = [...this.selectedColumns];
784
+ cloned.withRelations = [...this.withRelations];
785
+ cloned.withConstraints = { ...this.withConstraints };
786
+ cloned.joins = [...this.joins];
787
+ cloned.distinctFlag = this.distinctFlag;
788
+ cloned.groupBys = [...this.groupBys];
789
+ cloned.havings = [...this.havings];
790
+ return cloned;
791
+ }
792
+ }
793
+
794
+ module.exports = QueryBuilder;