ilana-orm 1.0.14 → 1.0.16

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,5 +1,6 @@
1
1
  const Database = require('../database/connection');
2
2
  const Collection = require('./Collection');
3
+ const ModelRegistry = require('./ModelRegistry');
3
4
 
4
5
  class QueryBuilder {
5
6
  constructor(tableName, modelClass, connectionName) {
@@ -75,12 +76,18 @@ class QueryBuilder {
75
76
  return this;
76
77
  }
77
78
 
79
+ static _validOp(op) {
80
+ const allowed = new Set(['=', '!=', '<>', '<', '>', '<=', '>=']);
81
+ if (!allowed.has(op)) throw new Error(`Invalid SQL operator: "${op}"`);
82
+ return op;
83
+ }
84
+
78
85
  whereJsonContains(column, value) {
79
86
  const client = this.query.client.config.client;
80
87
  if (client === 'pg') {
81
- this.query.whereRaw(`${column} @> ?`, [JSON.stringify(value)]);
88
+ this.query.whereRaw(`?? @> ?`, [column, JSON.stringify(value)]);
82
89
  } else if (client === 'mysql2') {
83
- this.query.whereRaw(`JSON_CONTAINS(${column}, ?)`, [JSON.stringify(value)]);
90
+ this.query.whereRaw(`JSON_CONTAINS(??, ?)`, [column, JSON.stringify(value)]);
84
91
  } else {
85
92
  this.query.whereJsonObject(column, value);
86
93
  }
@@ -88,11 +95,12 @@ class QueryBuilder {
88
95
  }
89
96
 
90
97
  whereJsonLength(column, operator, value) {
98
+ QueryBuilder._validOp(operator);
91
99
  const client = this.query.client.config.client;
92
100
  if (client === 'pg') {
93
- this.query.whereRaw(`jsonb_array_length(${column}) ${operator} ?`, [value]);
101
+ this.query.whereRaw(`jsonb_array_length(??) ${operator} ?`, [column, value]);
94
102
  } else if (client === 'mysql2') {
95
- this.query.whereRaw(`JSON_LENGTH(${column}) ${operator} ?`, [value]);
103
+ this.query.whereRaw(`JSON_LENGTH(??) ${operator} ?`, [column, value]);
96
104
  } else {
97
105
  this.query.whereJsonPath(column, '$.length()', operator, value);
98
106
  }
@@ -101,23 +109,137 @@ class QueryBuilder {
101
109
 
102
110
  whereDate(column, operatorOrValue, value) {
103
111
  if (value === undefined) {
104
- this.query.whereRaw(`DATE(${column}) = ?`, [operatorOrValue]);
112
+ this.query.whereRaw(`DATE(??) = ?`, [column, operatorOrValue]);
105
113
  } else {
106
- this.query.whereRaw(`DATE(${column}) ${operatorOrValue} ?`, [value]);
114
+ QueryBuilder._validOp(operatorOrValue);
115
+ this.query.whereRaw(`DATE(??) ${operatorOrValue} ?`, [column, value]);
107
116
  }
108
117
  return this;
109
118
  }
110
119
 
111
120
  whereMonth(column, month) {
112
- this.query.whereRaw(`MONTH(${column}) = ?`, [month]);
121
+ const client = this.query.client?.config?.client;
122
+ if (client === 'pg' || client === 'postgres') {
123
+ this.query.whereRaw(`EXTRACT(MONTH FROM ??) = ?`, [column, month]);
124
+ } else {
125
+ this.query.whereRaw(`MONTH(??) = ?`, [column, month]);
126
+ }
113
127
  return this;
114
128
  }
115
129
 
116
130
  whereYear(column, year) {
117
- this.query.whereRaw(`YEAR(${column}) = ?`, [year]);
131
+ const client = this.query.client?.config?.client;
132
+ if (client === 'pg' || client === 'postgres') {
133
+ this.query.whereRaw(`EXTRACT(YEAR FROM ??) = ?`, [column, year]);
134
+ } else {
135
+ this.query.whereRaw(`YEAR(??) = ?`, [column, year]);
136
+ }
137
+ return this;
138
+ }
139
+
140
+ whereDay(column, operatorOrValue, value) {
141
+ const client = this.query.client?.config?.client;
142
+ const isPg = client === 'pg' || client === 'postgres';
143
+ const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
144
+ if (isPg) {
145
+ if (value === undefined) {
146
+ this.query.whereRaw(`EXTRACT(DAY FROM ??) = ?`, [column, operatorOrValue]);
147
+ } else {
148
+ QueryBuilder._validOp(operatorOrValue);
149
+ this.query.whereRaw(`EXTRACT(DAY FROM ??) ${operatorOrValue} ?`, [column, value]);
150
+ }
151
+ } else if (isSqlite) {
152
+ if (value === undefined) {
153
+ this.query.whereRaw(`CAST(strftime('%d', ??) AS INTEGER) = ?`, [column, operatorOrValue]);
154
+ } else {
155
+ QueryBuilder._validOp(operatorOrValue);
156
+ this.query.whereRaw(`CAST(strftime('%d', ??) AS INTEGER) ${operatorOrValue} ?`, [column, value]);
157
+ }
158
+ } else {
159
+ if (value === undefined) {
160
+ this.query.whereRaw(`DAY(??) = ?`, [column, operatorOrValue]);
161
+ } else {
162
+ QueryBuilder._validOp(operatorOrValue);
163
+ this.query.whereRaw(`DAY(??) ${operatorOrValue} ?`, [column, value]);
164
+ }
165
+ }
166
+ return this;
167
+ }
168
+
169
+ whereTime(column, operatorOrValue, value) {
170
+ const client = this.query.client?.config?.client;
171
+ const isPg = client === 'pg' || client === 'postgres';
172
+ const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
173
+ if (isPg) {
174
+ if (value === undefined) {
175
+ this.query.whereRaw(`(??)::time = ?`, [column, operatorOrValue]);
176
+ } else {
177
+ QueryBuilder._validOp(operatorOrValue);
178
+ this.query.whereRaw(`(??)::time ${operatorOrValue} ?`, [column, value]);
179
+ }
180
+ } else if (isSqlite) {
181
+ if (value === undefined) {
182
+ this.query.whereRaw(`strftime('%H:%M:%S', ??) = ?`, [column, operatorOrValue]);
183
+ } else {
184
+ QueryBuilder._validOp(operatorOrValue);
185
+ this.query.whereRaw(`strftime('%H:%M:%S', ??) ${operatorOrValue} ?`, [column, value]);
186
+ }
187
+ } else {
188
+ if (value === undefined) {
189
+ this.query.whereRaw(`TIME(??) = ?`, [column, operatorOrValue]);
190
+ } else {
191
+ QueryBuilder._validOp(operatorOrValue);
192
+ this.query.whereRaw(`TIME(??) ${operatorOrValue} ?`, [column, value]);
193
+ }
194
+ }
195
+ return this;
196
+ }
197
+
198
+ whereNotBetween(column, range) {
199
+ this.query.whereNotBetween(column, range);
200
+ return this;
201
+ }
202
+
203
+ orWhereNull(column) {
204
+ this.query.orWhereNull(column);
205
+ return this;
206
+ }
207
+
208
+ orWhereNotNull(column) {
209
+ this.query.orWhereNotNull(column);
210
+ return this;
211
+ }
212
+
213
+ orWhereIn(column, values) {
214
+ this.query.orWhereIn(column, values);
215
+ return this;
216
+ }
217
+
218
+ orWhereNotIn(column, values) {
219
+ this.query.orWhereNotIn(column, values);
220
+ return this;
221
+ }
222
+
223
+ orWhereRaw(sql, bindings) {
224
+ this.query.orWhereRaw(sql, bindings);
225
+ return this;
226
+ }
227
+
228
+ addSelect(...columns) {
229
+ this.query.column(...columns);
118
230
  return this;
119
231
  }
120
232
 
233
+ inRandomOrder() {
234
+ const client = this.query.client?.config?.client;
235
+ this.query.orderByRaw(client === 'mysql2' ? 'RAND()' : 'RANDOM()');
236
+ return this;
237
+ }
238
+
239
+ forPage(page, perPage = 15) {
240
+ return this.offset((page - 1) * perPage).limit(perPage);
241
+ }
242
+
121
243
  whereExists(callback) {
122
244
  this.query.whereExists((builder) => {
123
245
  const subQuery = new QueryBuilder('', this.modelClass, this.connectionName);
@@ -136,6 +258,15 @@ class QueryBuilder {
136
258
  return this;
137
259
  }
138
260
 
261
+ unless(condition, callback, otherwise) {
262
+ if (!condition) {
263
+ callback(this);
264
+ } else if (otherwise) {
265
+ otherwise(this, condition);
266
+ }
267
+ return this;
268
+ }
269
+
139
270
  // Joins
140
271
  join(table, first, operator, second) {
141
272
  this.query.join(table, first, operator, second);
@@ -152,6 +283,16 @@ class QueryBuilder {
152
283
  return this;
153
284
  }
154
285
 
286
+ innerJoin(table, first, operator, second) {
287
+ this.query.join(table, first, operator, second);
288
+ return this;
289
+ }
290
+
291
+ crossJoin(table) {
292
+ this.query.crossJoin(table);
293
+ return this;
294
+ }
295
+
155
296
  // Ordering and limits
156
297
  orderBy(column, direction = 'asc') {
157
298
  this.query.orderBy(column, direction);
@@ -184,6 +325,11 @@ class QueryBuilder {
184
325
  return this.offset(count);
185
326
  }
186
327
 
328
+ from(table) {
329
+ this.query.from(table);
330
+ return this;
331
+ }
332
+
187
333
  // Grouping
188
334
  groupBy(...columns) {
189
335
  this.query.groupBy(...columns);
@@ -201,6 +347,20 @@ class QueryBuilder {
201
347
  return this;
202
348
  }
203
349
 
350
+ havingRaw(sql, bindings) {
351
+ this.query.havingRaw(sql, bindings);
352
+ return this;
353
+ }
354
+
355
+ whereNotExists(callback) {
356
+ this.query.whereNotExists((builder) => {
357
+ const subQb = new QueryBuilder('', null, this.connectionName);
358
+ subQb.query = builder;
359
+ callback(subQb);
360
+ });
361
+ return this;
362
+ }
363
+
204
364
  // Locking
205
365
  lockForUpdate() {
206
366
  this.query.forUpdate();
@@ -244,31 +404,40 @@ class QueryBuilder {
244
404
  return this;
245
405
  }
246
406
 
407
+ _softQuery() {
408
+ let q = this.query.clone();
409
+ if (this.modelClass && this.modelClass.softDeletes) {
410
+ const col = this.modelClass.deletedAt || 'deleted_at';
411
+ if (this._onlyTrashed) q = q.whereNotNull(col);
412
+ else if (!this._includeTrashed) q = q.whereNull(col);
413
+ }
414
+ return q;
415
+ }
416
+
247
417
  // Aggregates
248
418
  async count(column = '*') {
249
- const result = await this.query.count(column);
250
- // Handle different database result formats
419
+ const result = await this._softQuery().count(column);
251
420
  const countValue = result[0]['count(*)'] || result[0].count || result[0]['COUNT(*)'] || result[0]['COUNT'] || 0;
252
421
  return parseInt(countValue) || 0;
253
422
  }
254
423
 
255
424
  async sum(column) {
256
- const result = await this.query.sum(column);
425
+ const result = await this._softQuery().sum(column);
257
426
  return parseFloat(result[0][`sum(\`${column}\`)`] || result[0].sum);
258
427
  }
259
428
 
260
429
  async avg(column) {
261
- const result = await this.query.avg(column);
430
+ const result = await this._softQuery().avg(column);
262
431
  return parseFloat(result[0][`avg(\`${column}\`)`] || result[0].avg);
263
432
  }
264
433
 
265
434
  async min(column) {
266
- const result = await this.query.min(column);
435
+ const result = await this._softQuery().min(column);
267
436
  return result[0][`min(\`${column}\`)`] || result[0].min;
268
437
  }
269
438
 
270
439
  async max(column) {
271
- const result = await this.query.max(column);
440
+ const result = await this._softQuery().max(column);
272
441
  return result[0][`max(\`${column}\`)`] || result[0].max;
273
442
  }
274
443
 
@@ -292,33 +461,108 @@ class QueryBuilder {
292
461
  }
293
462
 
294
463
  withCount(...relations) {
464
+ // Ensure regular model columns are not dropped when we add count subqueries
465
+ const existing = this.query._single?.columns;
466
+ if (!existing || existing.length === 0) {
467
+ this.query.select(`${this.modelClass.getTableName()}.*`);
468
+ }
295
469
  for (const relation of relations) {
296
- this.eagerLoad.push(`${relation}_count`);
470
+ try {
471
+ const dummy = this._makeDummy();
472
+ if (!dummy) continue;
473
+ const relFn = this.modelClass.prototype[relation];
474
+ if (typeof relFn !== 'function') continue;
475
+ const rel = relFn.call(dummy);
476
+ const relatedClass = rel.getRelatedClass();
477
+ const relatedTable = relatedClass.getTableName();
478
+ const parentTable = this.modelClass.getTableName();
479
+ let countSql;
480
+ if (rel.constructor.name === 'BelongsTo') {
481
+ countSql = `(SELECT COUNT(*) FROM ${relatedTable} WHERE ${relatedTable}.${rel.localKey || relatedClass.getPrimaryKey()} = ${parentTable}.${rel.foreignKey})`;
482
+ } else {
483
+ countSql = `(SELECT COUNT(*) FROM ${relatedTable} WHERE ${relatedTable}.${rel.foreignKey} = ${parentTable}.${rel.localKey || this.modelClass.getPrimaryKey()})`;
484
+ }
485
+ this.query.column(Database.raw(`${countSql} as ${relation}_count`));
486
+ } catch (_) { /* skip unresolvable relations */ }
297
487
  }
298
488
  return this;
299
489
  }
300
490
 
301
491
  whereHas(relation, callback) {
302
- if (callback) {
303
- const subQuery = new QueryBuilder('', this.modelClass, this.connectionName);
304
- callback(subQuery);
305
- }
492
+ if (!this.modelClass) return this;
493
+ try {
494
+ const dummy = this._makeDummy();
495
+ const relFn = this.modelClass.prototype[relation];
496
+ if (typeof relFn !== 'function') return this;
497
+ const rel = relFn.call(dummy);
498
+ const relatedClass = rel.getRelatedClass();
499
+ const relatedTable = relatedClass.getTableName();
500
+ const parentTable = this.modelClass.getTableName();
501
+ const isBelongsTo = rel.constructor.name === 'BelongsTo';
502
+ this.query.whereExists((builder) => {
503
+ builder.from(relatedTable);
504
+ if (isBelongsTo) {
505
+ builder.whereRaw(`${relatedTable}.${rel.localKey || relatedClass.getPrimaryKey()} = ${parentTable}.${rel.foreignKey}`);
506
+ } else {
507
+ builder.whereRaw(`${relatedTable}.${rel.foreignKey} = ${parentTable}.${rel.localKey || this.modelClass.getPrimaryKey()}`);
508
+ }
509
+ if (callback) {
510
+ const subQb = new QueryBuilder(relatedTable, relatedClass, this.connectionName);
511
+ subQb.query = builder;
512
+ callback(subQb);
513
+ }
514
+ });
515
+ } catch (_) { /* skip if relation can't be resolved at query-build time */ }
306
516
  return this;
307
517
  }
308
518
 
519
+ whereDoesntHave(relation, callback) {
520
+ if (!this.modelClass) return this;
521
+ try {
522
+ const dummy = this._makeDummy();
523
+ const relFn = this.modelClass.prototype[relation];
524
+ if (typeof relFn !== 'function') return this;
525
+ const rel = relFn.call(dummy);
526
+ const relatedClass = rel.getRelatedClass();
527
+ const relatedTable = relatedClass.getTableName();
528
+ const parentTable = this.modelClass.getTableName();
529
+ const isBelongsTo = rel.constructor.name === 'BelongsTo';
530
+ this.query.whereNotExists((builder) => {
531
+ builder.from(relatedTable);
532
+ if (isBelongsTo) {
533
+ builder.whereRaw(`${relatedTable}.${rel.localKey || relatedClass.getPrimaryKey()} = ${parentTable}.${rel.foreignKey}`);
534
+ } else {
535
+ builder.whereRaw(`${relatedTable}.${rel.foreignKey} = ${parentTable}.${rel.localKey || this.modelClass.getPrimaryKey()}`);
536
+ }
537
+ if (typeof callback === 'function') {
538
+ const constraintQB = new QueryBuilder(relatedTable, relatedClass, this.connectionName);
539
+ constraintQB.query = builder;
540
+ callback(constraintQB);
541
+ }
542
+ });
543
+ } catch (_) {}
544
+ return this;
545
+ }
546
+
547
+ doesntHave(relation) {
548
+ return this.whereDoesntHave(relation);
549
+ }
550
+
551
+ _makeDummy() {
552
+ const dummy = Object.create(this.modelClass.prototype);
553
+ dummy.attributes = {};
554
+ dummy.relations = {};
555
+ dummy.casts = this.modelClass.casts || {};
556
+ dummy.fillable = this.modelClass.fillable || [];
557
+ dummy.guarded = this.modelClass.guarded || ['*'];
558
+ dummy.appends = this.modelClass.appends || [];
559
+ dummy.constructor = this.modelClass;
560
+ return dummy;
561
+ }
562
+
309
563
  // Execution methods
310
564
  async get() {
311
- let query = this.query;
312
-
313
- // Apply soft delete filtering if model has soft deletes
314
- if (this.modelClass && this.modelClass.softDeletes) {
315
- if (this._onlyTrashed) {
316
- query = query.whereNotNull('deleted_at');
317
- } else if (!this._includeTrashed) {
318
- query = query.whereNull('deleted_at');
319
- }
320
- }
321
-
565
+ const query = this._softQuery();
322
566
  const rows = await query;
323
567
  const models = rows.map(row => {
324
568
  const model = new this.modelClass(row);
@@ -333,18 +577,7 @@ class QueryBuilder {
333
577
  }
334
578
 
335
579
  async first() {
336
- let query = this.query;
337
-
338
- // Apply soft delete filtering if model has soft deletes
339
- if (this.modelClass && this.modelClass.softDeletes) {
340
- if (this._onlyTrashed) {
341
- query = query.whereNotNull('deleted_at');
342
- } else if (!this._includeTrashed) {
343
- query = query.whereNull('deleted_at');
344
- }
345
- }
346
-
347
- const row = await query.first();
580
+ const row = await this._softQuery().first();
348
581
  if (!row) return null;
349
582
  const model = new this.modelClass(row);
350
583
  model.exists = true;
@@ -357,18 +590,8 @@ class QueryBuilder {
357
590
 
358
591
 
359
592
  async find(id) {
360
- let query = this.query.where('id', id);
361
-
362
- // Apply soft delete filtering if model has soft deletes
363
- if (this.modelClass && this.modelClass.softDeletes) {
364
- if (this._onlyTrashed) {
365
- query = query.whereNotNull('deleted_at');
366
- } else if (!this._includeTrashed) {
367
- query = query.whereNull('deleted_at');
368
- }
369
- }
370
-
371
- const result = await query.first();
593
+ const pk = this.modelClass?.primaryKey || 'id';
594
+ const result = await this._softQuery().where(pk, id).first();
372
595
  if (!result) return null;
373
596
 
374
597
  const model = this.modelClass ? new this.modelClass(result) : result;
@@ -386,27 +609,37 @@ class QueryBuilder {
386
609
 
387
610
  async findOrFail(id) {
388
611
  const result = await this.find(id);
389
- if (!result) {
390
- throw new Error(`Model not found with id: ${id}`);
391
- }
612
+ if (!result) throw new Error(`Model not found with id: ${id}`);
392
613
  return result;
393
614
  }
394
615
 
616
+ async firstOrFail() {
617
+ const result = await this.first();
618
+ if (!result) throw new Error('No records found.');
619
+ return result;
620
+ }
621
+
622
+ async doesntExist() {
623
+ return !(await this.exists());
624
+ }
625
+
395
626
  async pluck(column) {
396
- return await this.query.pluck(column);
627
+ return await this._softQuery().pluck(column);
397
628
  }
398
629
 
399
630
  async exists() {
400
- const result = await this.query.select(Database.raw('1')).first();
631
+ const result = await this._softQuery().select(Database.raw('1')).first();
401
632
  return !!result;
402
633
  }
403
634
 
404
635
  // Pagination
405
636
  async paginate(page = 1, perPage = 15) {
406
- // Get total count first with a fresh query
637
+ // Get total count use _softQuery() to respect soft-delete scope
407
638
  const countQuery = new QueryBuilder(this.query._single.table, this.modelClass, this.connectionName);
408
- countQuery.query = this.query.clone();
409
- const total = await countQuery.count();
639
+ countQuery.query = this._softQuery();
640
+ countQuery._includeTrashed = this._includeTrashed;
641
+ countQuery._onlyTrashed = this._onlyTrashed;
642
+ const total = await countQuery.query.count('* as count').then(r => parseInt(r[0]?.count || r[0]?.['count(*)'] || 0));
410
643
 
411
644
  // Get the actual data with limit and offset
412
645
  const results = await this.clone().offset((page - 1) * perPage).limit(perPage).get();
@@ -472,7 +705,7 @@ class QueryBuilder {
472
705
  let results;
473
706
 
474
707
  do {
475
- results = await this.offset((page - 1) * size).limit(size).get();
708
+ results = await this.clone().offset((page - 1) * size).limit(size).get();
476
709
  if (results.length > 0) {
477
710
  await callback(results);
478
711
  }
@@ -570,6 +803,121 @@ class QueryBuilder {
570
803
  throw new Error(`Invalid related class for relation '${relation}'. Make sure the model is properly defined and registered.`);
571
804
  }
572
805
 
806
+ // HasManyThrough requires a JOIN through the intermediate table
807
+ if (relationInstance.constructor.name === 'HasManyThrough') {
808
+ const rel = relationInstance;
809
+ const throughClass = ModelRegistry.has(rel.through) ? ModelRegistry.get(rel.through) : null;
810
+ const throughTable = throughClass ? throughClass.getTableName() : (typeof rel.through === 'string' ? rel.through : rel.through.getTableName());
811
+ const relatedTable = relatedClass.getTableName();
812
+ const parentTable = models[0].constructor.getTableName();
813
+
814
+ let hmtQuery = new QueryBuilder(relatedTable, relatedClass, relatedClass.getConnectionName());
815
+ hmtQuery.query = hmtQuery.query
816
+ .select(`${relatedTable}.*`, `${throughTable}.${rel.firstKey} as _hmt_parent_id`)
817
+ .join(throughTable, `${relatedTable}.${rel.secondKey}`, `${throughTable}.${rel.secondLocalKey}`)
818
+ .whereIn(`${throughTable}.${rel.firstKey}`, localValues);
819
+
820
+ if (this.eagerLoadConstraints[relationName]) this.eagerLoadConstraints[relationName](hmtQuery);
821
+ if (nested) hmtQuery = hmtQuery.with(nested);
822
+
823
+ const hmtResults = await hmtQuery.get();
824
+ const hmtArray = Array.isArray(hmtResults) ? hmtResults : [...hmtResults];
825
+
826
+ const grouped = {};
827
+ for (const m of hmtArray) {
828
+ const pid = m.attributes['_hmt_parent_id'];
829
+ delete m.attributes['_hmt_parent_id'];
830
+ if (!grouped[pid]) grouped[pid] = [];
831
+ grouped[pid].push(m);
832
+ }
833
+ for (const model of models) {
834
+ model.relations[relation] = grouped[model.getAttribute(rel.localKey)] || [];
835
+ }
836
+ return;
837
+ }
838
+
839
+ // MorphTo: polymorphic parent — group by type and batch-load each model class
840
+ if (relationInstance.constructor.name === 'MorphTo') {
841
+ const rel = relationInstance;
842
+ const typeGroups = {};
843
+ for (const model of models) {
844
+ const morphType = model.getAttribute(rel.morphType);
845
+ const morphId = model.getAttribute(rel.morphId);
846
+ if (!morphType || morphId == null) continue;
847
+ if (!typeGroups[morphType]) typeGroups[morphType] = [];
848
+ typeGroups[morphType].push(morphId);
849
+ }
850
+ const resolved = {};
851
+ for (const [typeName, ids] of Object.entries(typeGroups)) {
852
+ const TypeClass = ModelRegistry.get(typeName);
853
+ if (!TypeClass) continue;
854
+ const rows = await new QueryBuilder(TypeClass.getTableName(), TypeClass, TypeClass.getConnectionName())
855
+ .whereIn(TypeClass.getPrimaryKey(), ids).get();
856
+ for (const row of (Array.isArray(rows) ? rows : [...rows])) {
857
+ resolved[`${typeName}:${row.getAttribute(TypeClass.getPrimaryKey())}`] = row;
858
+ }
859
+ }
860
+ for (const model of models) {
861
+ const morphType = model.getAttribute(rel.morphType);
862
+ const morphId = model.getAttribute(rel.morphId);
863
+ model.relations[relation] = resolved[`${morphType}:${morphId}`] || null;
864
+ }
865
+ return;
866
+ }
867
+
868
+ // BelongsToMany requires a join through the pivot table
869
+ if (relationInstance.constructor.name === 'BelongsToMany') {
870
+ const rel = relationInstance;
871
+ const parentPivotKey = rel.parentPivotKey;
872
+ const relatedPivotKey = rel.relatedPivotKey;
873
+ const pivotTable = rel.pivotTable;
874
+ const relatedTable = relatedClass.getTableName();
875
+ const parentKey = rel.parentKey || 'id';
876
+ const relatedKey = rel.relatedKey || relatedClass.getPrimaryKey();
877
+
878
+ const parentIds = models.map(m => m.getAttribute(parentKey)).filter(v => v != null);
879
+ const selectColumns = [
880
+ `${relatedTable}.*`,
881
+ `${pivotTable}.${parentPivotKey} as _pivot_parent_id`,
882
+ ...(rel.pivotColumns || []).map(c => `${pivotTable}.${c} as pivot_${c}`)
883
+ ];
884
+
885
+ let pivotQuery = new QueryBuilder(relatedTable, relatedClass, relatedClass.getConnectionName());
886
+ pivotQuery.query = pivotQuery.query
887
+ .select(selectColumns)
888
+ .join(pivotTable, `${relatedTable}.${relatedKey}`, `${pivotTable}.${relatedPivotKey}`)
889
+ .whereIn(`${pivotTable}.${parentPivotKey}`, parentIds);
890
+
891
+ if (this.eagerLoadConstraints[relationName]) {
892
+ this.eagerLoadConstraints[relationName](pivotQuery);
893
+ }
894
+ if (nested) pivotQuery = pivotQuery.with(nested);
895
+
896
+ const pivotResults = await pivotQuery.get();
897
+ const pivotArray = Array.isArray(pivotResults) ? pivotResults : [...pivotResults];
898
+
899
+ const grouped = {};
900
+ for (const m of pivotArray) {
901
+ const parentId = m.attributes['_pivot_parent_id'];
902
+ delete m.attributes['_pivot_parent_id'];
903
+ // Extract pivot columns
904
+ if (rel.pivotColumns && rel.pivotColumns.length) {
905
+ m.pivot = {};
906
+ for (const c of rel.pivotColumns) {
907
+ m.pivot[c] = m.attributes[`pivot_${c}`];
908
+ delete m.attributes[`pivot_${c}`];
909
+ }
910
+ }
911
+ if (!grouped[parentId]) grouped[parentId] = [];
912
+ grouped[parentId].push(m);
913
+ }
914
+
915
+ for (const model of models) {
916
+ model.relations[relation] = grouped[model.getAttribute(parentKey)] || [];
917
+ }
918
+ return;
919
+ }
920
+
573
921
  let relationQuery = new QueryBuilder(
574
922
  relatedClass.getTableName(),
575
923
  relatedClass,
@@ -580,6 +928,11 @@ class QueryBuilder {
580
928
  if (relationInstance.constructor.name === 'BelongsTo') {
581
929
  const foreignValues = models.map(model => model.getAttribute(foreignKey)).filter(Boolean);
582
930
  relationQuery = relationQuery.whereIn(relatedClass.getPrimaryKey(), foreignValues);
931
+ } else if (relationInstance.constructor.name === 'MorphMany' || relationInstance.constructor.name === 'MorphOne') {
932
+ const rel = relationInstance;
933
+ relationQuery = relationQuery
934
+ .where(rel.morphType, rel.morphClass)
935
+ .whereIn(rel.morphId, localValues);
583
936
  } else {
584
937
  relationQuery = relationQuery.whereIn(foreignKey, localValues);
585
938
  }
@@ -597,24 +950,31 @@ class QueryBuilder {
597
950
  // Convert Collection to array if needed
598
951
  const relatedArray = Array.isArray(relatedModels) ? relatedModels : (relatedModels && relatedModels.length !== undefined ? [...relatedModels] : []);
599
952
 
953
+ const isBelongsTo = relationInstance.constructor.name === 'BelongsTo';
954
+ const isMorphMany = relationInstance.constructor.name === 'MorphMany';
955
+ const isMorphOne = relationInstance.constructor.name === 'MorphOne';
956
+
600
957
  const grouped = {};
601
- for (let i = 0; i < relatedArray.length; i++) {
602
- const relatedModel = relatedArray[i];
603
- const key = relatedModel.getAttribute(relatedClass.getPrimaryKey());
604
- if (!grouped[key]) grouped[key] = [];
605
- grouped[key].push(relatedModel);
958
+ for (const relatedModel of relatedArray) {
959
+ let groupKey;
960
+ if (isBelongsTo) {
961
+ groupKey = relatedModel.getAttribute(relatedClass.getPrimaryKey());
962
+ } else if (isMorphMany || isMorphOne) {
963
+ groupKey = relatedModel.getAttribute(relationInstance.morphId);
964
+ } else {
965
+ groupKey = relatedModel.getAttribute(foreignKey);
966
+ }
967
+ if (!grouped[groupKey]) grouped[groupKey] = [];
968
+ grouped[groupKey].push(relatedModel);
606
969
  }
607
970
 
608
971
  for (const model of models) {
609
- let matchValue;
610
- if (relationInstance.constructor.name === 'BelongsTo') {
611
- matchValue = model.getAttribute(foreignKey);
612
- } else {
613
- matchValue = model.getAttribute(localKey);
614
- }
972
+ const matchValue = isBelongsTo
973
+ ? model.getAttribute(foreignKey)
974
+ : model.getAttribute(localKey);
615
975
  const related = grouped[matchValue] || [];
616
976
 
617
- if (relationInstance.constructor.name === 'HasOne' || relationInstance.constructor.name === 'BelongsTo') {
977
+ if (relationInstance.constructor.name === 'HasOne' || isBelongsTo || isMorphOne) {
618
978
  model.relations[relation] = related[0] || null;
619
979
  } else {
620
980
  model.relations[relation] = related;
@@ -647,6 +1007,8 @@ class QueryBuilder {
647
1007
  cloned.query = this.query.clone();
648
1008
  cloned.eagerLoad = [...this.eagerLoad];
649
1009
  cloned.eagerLoadConstraints = { ...this.eagerLoadConstraints };
1010
+ if (this._includeTrashed) cloned._includeTrashed = true;
1011
+ if (this._onlyTrashed) cloned._onlyTrashed = true;
650
1012
  return cloned;
651
1013
  }
652
1014