outlet-orm 2.5.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.
@@ -0,0 +1,710 @@
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;