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