@bunyad/orm 0.1.0-alpha.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,1164 @@
1
+ /**
2
+ * Relation classes and morph map.
3
+ */
4
+ import { wrapSqlName } from "@bunyad/database";
5
+ import { OrmCollection } from "./orm-collection.js";
6
+ import { softDeleteAliasSql, nowForConnection, } from "./model-helpers.js";
7
+ import { Model } from "./model.js";
8
+ import { isIgnoringTouch } from "./model-strictness.js";
9
+ /** Register morph type aliases (`Relation::morphMap`). */
10
+ const morphAliases = new Map();
11
+ /** ModelQuery methods that mutate the builder and return it — forwarded onto relations. */
12
+ const RELATION_QUERY_CHAIN = [
13
+ "where",
14
+ "orWhere",
15
+ "whereIn",
16
+ "whereNotIn",
17
+ "whereNull",
18
+ "whereNotNull",
19
+ "whereBetween",
20
+ "whereNotBetween",
21
+ "whereColumn",
22
+ "whereRaw",
23
+ "orWhereRaw",
24
+ "whereLike",
25
+ "whereDate",
26
+ "orderBy",
27
+ "orderByDesc",
28
+ "orderByRaw",
29
+ "latest",
30
+ "oldest",
31
+ "limit",
32
+ "offset",
33
+ "take",
34
+ "skip",
35
+ "forPage",
36
+ "select",
37
+ "addSelect",
38
+ "selectRaw",
39
+ "groupBy",
40
+ "having",
41
+ "havingRaw",
42
+ "when",
43
+ "unless",
44
+ "tap",
45
+ ];
46
+ /** ModelQuery methods that return a value — forwarded onto relations. */
47
+ const RELATION_QUERY_TERMINAL = [
48
+ "count",
49
+ "exists",
50
+ "sum",
51
+ "avg",
52
+ "min",
53
+ "max",
54
+ "toSql",
55
+ "toRawSql",
56
+ "getBindings",
57
+ ];
58
+ /** Write terminals, forwarded only onto relations that filter one table by a foreign key. */
59
+ const RELATION_QUERY_WRITE = ["update", "delete", "forceDelete"];
60
+ function installRelationQueryForwarders(ctors, writes = []) {
61
+ for (const Ctor of writes) {
62
+ for (const method of RELATION_QUERY_WRITE) {
63
+ Object.defineProperty(Ctor.prototype, method, {
64
+ value: function (...args) {
65
+ const q = this.getQuery();
66
+ return q[method](...args);
67
+ },
68
+ configurable: true,
69
+ writable: true,
70
+ });
71
+ }
72
+ }
73
+ for (const Ctor of ctors) {
74
+ for (const method of RELATION_QUERY_CHAIN) {
75
+ Object.defineProperty(Ctor.prototype, method, {
76
+ value: function (...args) {
77
+ const q = this.getQuery();
78
+ q[method](...args);
79
+ return this;
80
+ },
81
+ configurable: true,
82
+ writable: true,
83
+ });
84
+ }
85
+ for (const method of RELATION_QUERY_TERMINAL) {
86
+ Object.defineProperty(Ctor.prototype, method, {
87
+ value: function (...args) {
88
+ const q = this.getQuery();
89
+ return q[method](...args);
90
+ },
91
+ configurable: true,
92
+ writable: true,
93
+ });
94
+ }
95
+ }
96
+ }
97
+ /** Strip `__pivot_*` columns onto `model.pivot`. */
98
+ export function applyPivotAttributes(model, attrs, pivotKeys) {
99
+ if (pivotKeys.length === 0)
100
+ return model;
101
+ const pivot = {};
102
+ const self = model;
103
+ for (const key of pivotKeys) {
104
+ const alias = `__pivot_${key}`;
105
+ if (Object.prototype.hasOwnProperty.call(attrs, alias)) {
106
+ pivot[key] = attrs[alias];
107
+ delete self[alias];
108
+ }
109
+ else if (Object.prototype.hasOwnProperty.call(self, alias)) {
110
+ pivot[key] = self[alias];
111
+ delete self[alias];
112
+ }
113
+ }
114
+ if (Object.keys(pivot).length > 0) {
115
+ self.pivot = pivot;
116
+ }
117
+ return model;
118
+ }
119
+ function pivotSelectKeys(foreignPivotKey, relatedPivotKey, extra) {
120
+ const keys = [foreignPivotKey, relatedPivotKey];
121
+ for (const col of extra) {
122
+ if (!keys.includes(col))
123
+ keys.push(col);
124
+ }
125
+ return keys;
126
+ }
127
+ export function morphMap(map) {
128
+ for (const [alias, cls] of Object.entries(map)) {
129
+ morphAliases.set(alias, cls);
130
+ }
131
+ }
132
+ export function clearMorphMap() {
133
+ morphAliases.clear();
134
+ }
135
+ export function morphTypeFor(model) {
136
+ for (const [alias, cls] of morphAliases) {
137
+ if (cls === model)
138
+ return alias;
139
+ }
140
+ return model.name;
141
+ }
142
+ export function resolveMorphType(type) {
143
+ const mapped = morphAliases.get(type);
144
+ if (mapped)
145
+ return mapped;
146
+ throw new Error(`No morph map entry for [${type}]. Call morphMap({ ${type}: Model }) first.`);
147
+ }
148
+ /** MorphMany relation. */
149
+ export class MorphMany {
150
+ parent;
151
+ related;
152
+ typeColumn;
153
+ idColumn;
154
+ morphType;
155
+ localKey;
156
+ #baseQuery = null;
157
+ constructor(parent, related, typeColumn, idColumn, morphType, localKey) {
158
+ this.parent = parent;
159
+ this.related = related;
160
+ this.typeColumn = typeColumn;
161
+ this.idColumn = idColumn;
162
+ this.morphType = morphType;
163
+ this.localKey = localKey;
164
+ }
165
+ getRelated() {
166
+ return this.related;
167
+ }
168
+ getTypeColumn() {
169
+ return this.typeColumn;
170
+ }
171
+ getIdColumn() {
172
+ return this.idColumn;
173
+ }
174
+ getMorphType() {
175
+ return this.morphType;
176
+ }
177
+ getLocalKeyName() {
178
+ return (this.localKey ?? this.parent.constructor.primaryKey);
179
+ }
180
+ #parentId() {
181
+ const parentKey = this.localKey ?? this.parent.constructor.primaryKey;
182
+ return this.parent[parentKey];
183
+ }
184
+ /** Constrained related query (`morphMany().where(…).get()`). */
185
+ getQuery() {
186
+ if (!this.#baseQuery) {
187
+ this.#baseQuery = this.related
188
+ .where(this.typeColumn, this.morphType)
189
+ .where(this.idColumn, this.#parentId());
190
+ }
191
+ return this.#baseQuery;
192
+ }
193
+ async get() {
194
+ const rows = await this.getQuery().get();
195
+ return new OrmCollection(rows.all(), { owned: true });
196
+ }
197
+ async first() {
198
+ return this.getQuery().first();
199
+ }
200
+ async create(attributes) {
201
+ return this.related.create({
202
+ ...attributes,
203
+ [this.typeColumn]: this.morphType,
204
+ [this.idColumn]: this.#parentId(),
205
+ });
206
+ }
207
+ }
208
+ /** MorphOne relation. */
209
+ export class MorphOne {
210
+ parent;
211
+ related;
212
+ typeColumn;
213
+ idColumn;
214
+ morphType;
215
+ localKey;
216
+ #ofManyColumn;
217
+ #ofManyAggregate;
218
+ #baseQuery = null;
219
+ constructor(parent, related, typeColumn, idColumn, morphType, localKey) {
220
+ this.parent = parent;
221
+ this.related = related;
222
+ this.typeColumn = typeColumn;
223
+ this.idColumn = idColumn;
224
+ this.morphType = morphType;
225
+ this.localKey = localKey;
226
+ }
227
+ getRelated() {
228
+ return this.related;
229
+ }
230
+ getTypeColumn() {
231
+ return this.typeColumn;
232
+ }
233
+ getIdColumn() {
234
+ return this.idColumn;
235
+ }
236
+ getMorphType() {
237
+ return this.morphType;
238
+ }
239
+ getLocalKeyName() {
240
+ return (this.localKey ?? this.parent.constructor.primaryKey);
241
+ }
242
+ isOfMany() {
243
+ return this.#ofManyColumn != null;
244
+ }
245
+ getOfManyColumn() {
246
+ return this.#ofManyColumn;
247
+ }
248
+ getOfManyAggregate() {
249
+ return this.#ofManyAggregate;
250
+ }
251
+ #parentId() {
252
+ const parentKey = this.localKey ?? this.parent.constructor.primaryKey;
253
+ return this.parent[parentKey];
254
+ }
255
+ latestOfMany(column) {
256
+ this.#baseQuery = null;
257
+ return this.ofMany(column ?? this.related.primaryKey, "max");
258
+ }
259
+ oldestOfMany(column) {
260
+ this.#baseQuery = null;
261
+ return this.ofMany(column ?? this.related.primaryKey, "min");
262
+ }
263
+ ofMany(column, aggregate) {
264
+ this.#baseQuery = null;
265
+ this.#ofManyColumn = column;
266
+ this.#ofManyAggregate = aggregate;
267
+ return this;
268
+ }
269
+ getQuery() {
270
+ if (!this.#baseQuery) {
271
+ let q = this.related
272
+ .where(this.typeColumn, this.morphType)
273
+ .where(this.idColumn, this.#parentId());
274
+ if (this.#ofManyAggregate && this.#ofManyColumn) {
275
+ const dialect = this.related.getConnection().dialect;
276
+ const table = wrapSqlName(dialect, this.related.table);
277
+ const col = wrapSqlName(dialect, this.#ofManyColumn);
278
+ const typeCol = wrapSqlName(dialect, this.typeColumn);
279
+ const idCol = wrapSqlName(dialect, this.idColumn);
280
+ const agg = this.#ofManyAggregate.toUpperCase();
281
+ const soft = softDeleteAliasSql(this.related, table);
282
+ q = q.whereRaw(`${table}.${col} = (SELECT ${agg}(${col}) FROM ${table} WHERE ${typeCol} = ? AND ${idCol} = ?${soft})`, [this.morphType, this.#parentId()]);
283
+ }
284
+ this.#baseQuery = q;
285
+ }
286
+ return this.#baseQuery;
287
+ }
288
+ async get() {
289
+ return this.first();
290
+ }
291
+ async first() {
292
+ return this.getQuery().first();
293
+ }
294
+ async create(attributes) {
295
+ return this.related.create({
296
+ ...attributes,
297
+ [this.typeColumn]: this.morphType,
298
+ [this.idColumn]: this.#parentId(),
299
+ });
300
+ }
301
+ }
302
+ /** Laravel MorphTo relation. */
303
+ export class MorphTo {
304
+ parent;
305
+ typeColumn;
306
+ idColumn;
307
+ ownerKey;
308
+ constructor(parent, typeColumn, idColumn, ownerKey) {
309
+ this.parent = parent;
310
+ this.typeColumn = typeColumn;
311
+ this.idColumn = idColumn;
312
+ this.ownerKey = ownerKey;
313
+ }
314
+ getTypeColumn() {
315
+ return this.typeColumn;
316
+ }
317
+ getIdColumn() {
318
+ return this.idColumn;
319
+ }
320
+ getOwnerKeyName() {
321
+ return this.ownerKey;
322
+ }
323
+ async get() {
324
+ return this.first();
325
+ }
326
+ async first() {
327
+ const row = this.parent;
328
+ const type = row[this.typeColumn];
329
+ const id = row[this.idColumn];
330
+ if (type == null || id == null)
331
+ return null;
332
+ const Related = resolveMorphType(String(type));
333
+ if (this.ownerKey && this.ownerKey !== Related.primaryKey) {
334
+ return Related.where(this.ownerKey, id).first();
335
+ }
336
+ return Related.find(id);
337
+ }
338
+ /** Laravel `associate` for morphTo. */
339
+ associate(model) {
340
+ const row = this.parent;
341
+ const Related = model.constructor;
342
+ const key = this.ownerKey ?? Related.primaryKey;
343
+ row[this.typeColumn] = morphTypeFor(Related);
344
+ row[this.idColumn] = model[key];
345
+ return this;
346
+ }
347
+ /** Laravel `dissociate` for morphTo. */
348
+ dissociate() {
349
+ const row = this.parent;
350
+ row[this.typeColumn] = null;
351
+ row[this.idColumn] = null;
352
+ return this;
353
+ }
354
+ }
355
+ /** HasMany relation. */
356
+ export class HasMany {
357
+ parent;
358
+ related;
359
+ foreignKey;
360
+ localKey;
361
+ #baseQuery = null;
362
+ constructor(parent, related, foreignKey, localKey) {
363
+ this.parent = parent;
364
+ this.related = related;
365
+ this.foreignKey = foreignKey;
366
+ this.localKey = localKey;
367
+ }
368
+ getRelated() {
369
+ return this.related;
370
+ }
371
+ getForeignKeyName() {
372
+ return this.foreignKey;
373
+ }
374
+ getLocalKeyName() {
375
+ return (this.localKey ?? this.parent.constructor.primaryKey);
376
+ }
377
+ #parentId() {
378
+ const parentKey = this.localKey ?? this.parent.constructor.primaryKey;
379
+ return this.parent[parentKey];
380
+ }
381
+ /** Constrained related query (`hasMany().where(…).orderBy(…).get()`). */
382
+ getQuery() {
383
+ if (!this.#baseQuery) {
384
+ this.#baseQuery = this.related.where(this.foreignKey, this.#parentId());
385
+ }
386
+ return this.#baseQuery;
387
+ }
388
+ async get() {
389
+ const rows = await this.getQuery().get();
390
+ return new OrmCollection(rows.all(), { owned: true });
391
+ }
392
+ async first() {
393
+ return this.getQuery().first();
394
+ }
395
+ async create(attributes) {
396
+ return this.related.create({
397
+ ...attributes,
398
+ [this.foreignKey]: this.#parentId(),
399
+ });
400
+ }
401
+ /** Bump `updated_at` on matching related rows. */
402
+ async touch() {
403
+ if (isIgnoringTouch(this.related))
404
+ return false;
405
+ if (this.related.timestamps === false)
406
+ return false;
407
+ const parentId = this.#parentId();
408
+ if (parentId === undefined || parentId === null)
409
+ return false;
410
+ const now = nowForConnection(this.related.getConnection());
411
+ await this.related
412
+ .newQuery()
413
+ .where(this.foreignKey, parentId)
414
+ .update({ updated_at: now });
415
+ return true;
416
+ }
417
+ }
418
+ /** HasOne relation. */
419
+ export class HasOne {
420
+ parent;
421
+ related;
422
+ foreignKey;
423
+ localKey;
424
+ #ofManyColumn;
425
+ #ofManyAggregate;
426
+ #baseQuery = null;
427
+ constructor(parent, related, foreignKey, localKey) {
428
+ this.parent = parent;
429
+ this.related = related;
430
+ this.foreignKey = foreignKey;
431
+ this.localKey = localKey;
432
+ }
433
+ getRelated() {
434
+ return this.related;
435
+ }
436
+ getForeignKeyName() {
437
+ return this.foreignKey;
438
+ }
439
+ getLocalKeyName() {
440
+ return (this.localKey ?? this.parent.constructor.primaryKey);
441
+ }
442
+ isOfMany() {
443
+ return this.#ofManyColumn != null;
444
+ }
445
+ getOfManyColumn() {
446
+ return this.#ofManyColumn;
447
+ }
448
+ getOfManyAggregate() {
449
+ return this.#ofManyAggregate;
450
+ }
451
+ #parentId() {
452
+ const parentKey = this.localKey ?? this.parent.constructor.primaryKey;
453
+ return this.parent[parentKey];
454
+ }
455
+ /** Latest related row by column (default primary key). */
456
+ latestOfMany(column) {
457
+ this.#baseQuery = null;
458
+ return this.ofMany(column ?? this.related.primaryKey, "max");
459
+ }
460
+ /** Oldest related row by column (default primary key). */
461
+ oldestOfMany(column) {
462
+ this.#baseQuery = null;
463
+ return this.ofMany(column ?? this.related.primaryKey, "min");
464
+ }
465
+ /** Constrain to the related row with min/max of `column`. */
466
+ ofMany(column, aggregate) {
467
+ this.#baseQuery = null;
468
+ this.#ofManyColumn = column;
469
+ this.#ofManyAggregate = aggregate;
470
+ return this;
471
+ }
472
+ getQuery() {
473
+ if (!this.#baseQuery) {
474
+ let q = this.related.where(this.foreignKey, this.#parentId());
475
+ if (this.#ofManyAggregate && this.#ofManyColumn) {
476
+ const dialect = this.related.getConnection().dialect;
477
+ const table = wrapSqlName(dialect, this.related.table);
478
+ const col = wrapSqlName(dialect, this.#ofManyColumn);
479
+ const fk = wrapSqlName(dialect, this.foreignKey);
480
+ const agg = this.#ofManyAggregate.toUpperCase();
481
+ const soft = softDeleteAliasSql(this.related, table);
482
+ q = q.whereRaw(`${table}.${col} = (SELECT ${agg}(${col}) FROM ${table} WHERE ${fk} = ?${soft})`, [this.#parentId()]);
483
+ }
484
+ this.#baseQuery = q;
485
+ }
486
+ return this.#baseQuery;
487
+ }
488
+ async get() {
489
+ return this.first();
490
+ }
491
+ async first() {
492
+ return this.getQuery().first();
493
+ }
494
+ async create(attributes) {
495
+ return this.related.create({
496
+ ...attributes,
497
+ [this.foreignKey]: this.#parentId(),
498
+ });
499
+ }
500
+ /** Bump `updated_at` on matching related rows. */
501
+ async touch() {
502
+ if (isIgnoringTouch(this.related))
503
+ return false;
504
+ if (this.related.timestamps === false)
505
+ return false;
506
+ const parentKey = this.localKey ?? this.parent.constructor.primaryKey;
507
+ const parentId = this.parent[parentKey];
508
+ if (parentId === undefined || parentId === null)
509
+ return false;
510
+ const now = nowForConnection(this.related.getConnection());
511
+ await this.related
512
+ .newQuery()
513
+ .where(this.foreignKey, parentId)
514
+ .update({ updated_at: now });
515
+ return true;
516
+ }
517
+ }
518
+ /** HasManyThrough relation (Parent → Through → Related). */
519
+ export class HasManyThrough {
520
+ parent;
521
+ related;
522
+ through;
523
+ firstKey;
524
+ secondKey;
525
+ localKey;
526
+ secondLocalKey;
527
+ #baseQuery = null;
528
+ constructor(parent, related, through, firstKey, secondKey, localKey, secondLocalKey) {
529
+ this.parent = parent;
530
+ this.related = related;
531
+ this.through = through;
532
+ this.firstKey = firstKey;
533
+ this.secondKey = secondKey;
534
+ this.localKey = localKey;
535
+ this.secondLocalKey = secondLocalKey;
536
+ }
537
+ getRelated() {
538
+ return this.related;
539
+ }
540
+ getThrough() {
541
+ return this.through;
542
+ }
543
+ getFirstKeyName() {
544
+ return this.firstKey;
545
+ }
546
+ getSecondKeyName() {
547
+ return this.secondKey;
548
+ }
549
+ getLocalKeyName() {
550
+ return this.localKey;
551
+ }
552
+ getSecondLocalKeyName() {
553
+ return this.secondLocalKey;
554
+ }
555
+ #parentId() {
556
+ return this.parent[this.localKey];
557
+ }
558
+ getQuery() {
559
+ if (!this.#baseQuery) {
560
+ this.#baseQuery = this.related
561
+ .newQuery()
562
+ .join(this.through.table, `${this.related.table}.${this.secondKey}`, "=", `${this.through.table}.${this.secondLocalKey}`)
563
+ .where(`${this.through.table}.${this.firstKey}`, this.#parentId())
564
+ .select(`${this.related.table}.*`);
565
+ }
566
+ return this.#baseQuery;
567
+ }
568
+ async get() {
569
+ const parentId = this.#parentId();
570
+ if (parentId == null)
571
+ return new OrmCollection([], { owned: true });
572
+ const rows = await this.getQuery().get();
573
+ return new OrmCollection(rows.all(), { owned: true });
574
+ }
575
+ async first() {
576
+ const parentId = this.#parentId();
577
+ if (parentId == null)
578
+ return null;
579
+ return this.getQuery().first();
580
+ }
581
+ }
582
+ /**
583
+ * HasOneThrough — same join as HasManyThrough, returns a single related model.
584
+ */
585
+ export class HasOneThrough {
586
+ #many;
587
+ constructor(parent, related, through, firstKey, secondKey, localKey, secondLocalKey) {
588
+ this.#many = new HasManyThrough(parent, related, through, firstKey, secondKey, localKey, secondLocalKey);
589
+ }
590
+ getRelated() {
591
+ return this.#many.getRelated();
592
+ }
593
+ getThrough() {
594
+ return this.#many.getThrough();
595
+ }
596
+ getFirstKeyName() {
597
+ return this.#many.getFirstKeyName();
598
+ }
599
+ getSecondKeyName() {
600
+ return this.#many.getSecondKeyName();
601
+ }
602
+ getLocalKeyName() {
603
+ return this.#many.getLocalKeyName();
604
+ }
605
+ getSecondLocalKeyName() {
606
+ return this.#many.getSecondLocalKeyName();
607
+ }
608
+ /** Underlying many-through relation (for eager load reuse). */
609
+ asHasManyThrough() {
610
+ return this.#many;
611
+ }
612
+ getQuery() {
613
+ return this.#many.getQuery().limit(1);
614
+ }
615
+ async get() {
616
+ return this.first();
617
+ }
618
+ async first() {
619
+ return this.#many.first();
620
+ }
621
+ }
622
+ /** BelongsTo relation. */
623
+ export class BelongsTo {
624
+ child;
625
+ related;
626
+ foreignKey;
627
+ ownerKey;
628
+ #baseQuery = null;
629
+ constructor(child, related, foreignKey, ownerKey) {
630
+ this.child = child;
631
+ this.related = related;
632
+ this.foreignKey = foreignKey;
633
+ this.ownerKey = ownerKey;
634
+ }
635
+ getRelated() {
636
+ return this.related;
637
+ }
638
+ getForeignKeyName() {
639
+ return this.foreignKey;
640
+ }
641
+ getOwnerKeyName() {
642
+ return this.ownerKey ?? this.related.primaryKey;
643
+ }
644
+ getQuery() {
645
+ if (!this.#baseQuery) {
646
+ const id = this.child[this.foreignKey];
647
+ if (id === undefined || id === null) {
648
+ this.#baseQuery = this.related
649
+ .newQuery()
650
+ .whereRaw("0 = 1");
651
+ }
652
+ else {
653
+ this.#baseQuery = this.related.where(this.getOwnerKeyName(), id);
654
+ }
655
+ }
656
+ return this.#baseQuery;
657
+ }
658
+ async get() {
659
+ return this.first();
660
+ }
661
+ async first() {
662
+ return this.getQuery().first();
663
+ }
664
+ /** Set the foreign key from a related model (or null). */
665
+ associate(model) {
666
+ const key = this.getOwnerKeyName();
667
+ this.child[this.foreignKey] = model
668
+ ? model[key]
669
+ : null;
670
+ return this;
671
+ }
672
+ /** Clear the foreign key. */
673
+ dissociate() {
674
+ this.child[this.foreignKey] = null;
675
+ return this;
676
+ }
677
+ /**
678
+ * Bump related parent's `updated_at` (query update).
679
+ * Honors `Model.withoutTouching` / `isIgnoringTouch` on the related class.
680
+ */
681
+ async touch() {
682
+ return this.#touchImpl(false);
683
+ }
684
+ /** Sync variant for Model sync-persist path. */
685
+ touchSync() {
686
+ return this.#touchImpl(true);
687
+ }
688
+ #touchImpl(sync) {
689
+ if (isIgnoringTouch(this.related))
690
+ return false;
691
+ if (this.related.timestamps === false)
692
+ return false;
693
+ const id = this.child[this.foreignKey];
694
+ if (id === undefined || id === null)
695
+ return false;
696
+ const conn = this.related.getConnection();
697
+ const now = nowForConnection(conn);
698
+ const ownerKey = this.getOwnerKeyName();
699
+ if (sync) {
700
+ if (typeof conn.runSync !== "function") {
701
+ throw new Error("BelongsTo.touchSync requires a sync connection.");
702
+ }
703
+ const dialect = conn.dialect;
704
+ const table = wrapSqlName(dialect, this.related.table);
705
+ const setCol = wrapSqlName(dialect, "updated_at");
706
+ const whereCol = wrapSqlName(dialect, ownerKey);
707
+ conn.runSync(`UPDATE ${table} SET ${setCol} = ? WHERE ${whereCol} = ?`, [now, id]);
708
+ return true;
709
+ }
710
+ return this.related
711
+ .newQuery()
712
+ .where(ownerKey, id)
713
+ .update({ updated_at: now })
714
+ .then(() => true);
715
+ }
716
+ }
717
+ /** BelongsToMany relation. */
718
+ export class BelongsToMany {
719
+ parent;
720
+ related;
721
+ pivotTable;
722
+ foreignPivotKey;
723
+ relatedPivotKey;
724
+ #baseQuery = null;
725
+ #pivotColumns = [];
726
+ constructor(parent, related, pivotTable, foreignPivotKey, relatedPivotKey) {
727
+ this.parent = parent;
728
+ this.related = related;
729
+ this.pivotTable = pivotTable;
730
+ this.foreignPivotKey = foreignPivotKey;
731
+ this.relatedPivotKey = relatedPivotKey;
732
+ }
733
+ getRelated() {
734
+ return this.related;
735
+ }
736
+ getPivotTable() {
737
+ return this.pivotTable;
738
+ }
739
+ getForeignPivotKeyName() {
740
+ return this.foreignPivotKey;
741
+ }
742
+ getRelatedPivotKeyName() {
743
+ return this.relatedPivotKey;
744
+ }
745
+ getPivotColumns() {
746
+ return [...this.#pivotColumns];
747
+ }
748
+ /** Include extra pivot table columns on related models as `model.pivot`. */
749
+ withPivot(...columns) {
750
+ this.#baseQuery = null;
751
+ for (const col of columns.flat()) {
752
+ if (typeof col === "string" && col.trim() && !this.#pivotColumns.includes(col)) {
753
+ this.#pivotColumns.push(col);
754
+ }
755
+ }
756
+ return this;
757
+ }
758
+ #parentId() {
759
+ const parentKey = this.parent.constructor.primaryKey;
760
+ return this.parent[parentKey];
761
+ }
762
+ #pivotKeys() {
763
+ if (this.#pivotColumns.length === 0)
764
+ return [];
765
+ return pivotSelectKeys(this.foreignPivotKey, this.relatedPivotKey, this.#pivotColumns);
766
+ }
767
+ getQuery() {
768
+ if (!this.#baseQuery) {
769
+ const related = this.related;
770
+ let q = related
771
+ .newQuery()
772
+ .join(this.pivotTable, `${this.pivotTable}.${this.relatedPivotKey}`, "=", `${related.table}.${related.primaryKey}`)
773
+ .where(`${this.pivotTable}.${this.foreignPivotKey}`, this.#parentId())
774
+ .select(`${related.table}.*`);
775
+ for (const col of this.#pivotKeys()) {
776
+ q = q.selectRaw(`${this.pivotTable}.${col} as __pivot_${col}`);
777
+ }
778
+ this.#baseQuery = q;
779
+ }
780
+ return this.#baseQuery;
781
+ }
782
+ #hydratePivot(models) {
783
+ const keys = this.#pivotKeys();
784
+ if (keys.length === 0)
785
+ return;
786
+ for (const model of models) {
787
+ applyPivotAttributes(model, model, keys);
788
+ }
789
+ }
790
+ async get() {
791
+ const rows = await this.getQuery().get();
792
+ const models = rows.all();
793
+ this.#hydratePivot(models);
794
+ return new OrmCollection(models, { owned: true });
795
+ }
796
+ async first() {
797
+ const model = (await this.getQuery().first());
798
+ if (model)
799
+ this.#hydratePivot([model]);
800
+ return model;
801
+ }
802
+ async attach(ids) {
803
+ const list = Array.isArray(ids) ? ids : [ids];
804
+ const parent = this.parent.constructor;
805
+ for (const id of list) {
806
+ await parent.getConnection().run(`INSERT INTO ${this.pivotTable} (${this.foreignPivotKey}, ${this.relatedPivotKey}) VALUES (?, ?)`, [this.#parentId(), id]);
807
+ }
808
+ }
809
+ async detach(ids) {
810
+ const parent = this.parent.constructor;
811
+ if (ids === undefined) {
812
+ await parent.getConnection().run(`DELETE FROM ${this.pivotTable} WHERE ${this.foreignPivotKey} = ?`, [this.#parentId()]);
813
+ return;
814
+ }
815
+ const list = Array.isArray(ids) ? ids : [ids];
816
+ for (const id of list) {
817
+ await parent.getConnection().run(`DELETE FROM ${this.pivotTable}
818
+ WHERE ${this.foreignPivotKey} = ? AND ${this.relatedPivotKey} = ?`, [this.#parentId(), id]);
819
+ }
820
+ }
821
+ async sync(ids) {
822
+ await this.detach();
823
+ await this.attach(ids);
824
+ }
825
+ /** Attach missing ids only. */
826
+ async syncWithoutDetaching(ids) {
827
+ const existing = new Set((await this.get()).map((m) => String(m[this.related.primaryKey])));
828
+ const missing = ids.filter((id) => !existing.has(String(id)));
829
+ if (missing.length > 0)
830
+ await this.attach(missing);
831
+ }
832
+ /** Attach missing, detach present. */
833
+ async toggle(ids) {
834
+ const list = Array.isArray(ids) ? ids : [ids];
835
+ const existing = new Set((await this.get()).map((m) => String(m[this.related.primaryKey])));
836
+ const attachIds = [];
837
+ const detachIds = [];
838
+ for (const id of list) {
839
+ if (existing.has(String(id)))
840
+ detachIds.push(id);
841
+ else
842
+ attachIds.push(id);
843
+ }
844
+ if (detachIds.length > 0)
845
+ await this.detach(detachIds);
846
+ if (attachIds.length > 0)
847
+ await this.attach(attachIds);
848
+ }
849
+ }
850
+ /** Polymorphic many-to-many (e.g. Contact → Role via `model_has_roles`). */
851
+ export class MorphToMany {
852
+ parent;
853
+ related;
854
+ pivotTable;
855
+ foreignPivotKey;
856
+ relatedPivotKey;
857
+ morphTypeColumn;
858
+ morphTypes;
859
+ pivotTenantKey;
860
+ parentTenantKey;
861
+ constructor(parent, related, pivotTable, foreignPivotKey, relatedPivotKey, morphTypeColumn, morphTypes, pivotTenantKey, parentTenantKey) {
862
+ this.parent = parent;
863
+ this.related = related;
864
+ this.pivotTable = pivotTable;
865
+ this.foreignPivotKey = foreignPivotKey;
866
+ this.relatedPivotKey = relatedPivotKey;
867
+ this.morphTypeColumn = morphTypeColumn;
868
+ this.morphTypes = morphTypes;
869
+ this.pivotTenantKey = pivotTenantKey;
870
+ this.parentTenantKey = parentTenantKey;
871
+ }
872
+ getRelated() {
873
+ return this.related;
874
+ }
875
+ getPivotTable() {
876
+ return this.pivotTable;
877
+ }
878
+ getForeignPivotKeyName() {
879
+ return this.foreignPivotKey;
880
+ }
881
+ getRelatedPivotKeyName() {
882
+ return this.relatedPivotKey;
883
+ }
884
+ getMorphTypeColumn() {
885
+ return this.morphTypeColumn;
886
+ }
887
+ getMorphTypes() {
888
+ return [...this.morphTypes];
889
+ }
890
+ getPivotTenantKey() {
891
+ return this.pivotTenantKey;
892
+ }
893
+ getParentTenantKey() {
894
+ return this.parentTenantKey ?? this.pivotTenantKey;
895
+ }
896
+ /** Primary morph type written on attach (first configured type). */
897
+ getMorphType() {
898
+ return this.morphTypes[0] ?? morphTypeFor(this.parent.constructor);
899
+ }
900
+ #parentId() {
901
+ const parentKey = this.parent.constructor.primaryKey;
902
+ return this.parent[parentKey];
903
+ }
904
+ #tenantId() {
905
+ const key = this.getParentTenantKey();
906
+ if (!this.pivotTenantKey || !key)
907
+ return undefined;
908
+ return this.parent[key];
909
+ }
910
+ #morphTypePlaceholders() {
911
+ return this.morphTypes.map(() => "?").join(", ");
912
+ }
913
+ async get() {
914
+ const parent = this.parent.constructor;
915
+ const related = this.related;
916
+ const params = [this.#parentId(), ...this.morphTypes];
917
+ let tenantSql = "";
918
+ const tenantId = this.#tenantId();
919
+ if (this.pivotTenantKey && tenantId != null && tenantId !== "") {
920
+ tenantSql = ` AND ${this.pivotTable}.${this.pivotTenantKey} = ?`;
921
+ params.push(tenantId);
922
+ }
923
+ const rows = await parent.getConnection().all(`SELECT ${related.table}.* FROM ${related.table}
924
+ INNER JOIN ${this.pivotTable}
925
+ ON ${this.pivotTable}.${this.relatedPivotKey} = ${related.table}.${related.primaryKey}
926
+ WHERE ${this.pivotTable}.${this.foreignPivotKey} = ?
927
+ AND ${this.pivotTable}.${this.morphTypeColumn} IN (${this.#morphTypePlaceholders()})${tenantSql}`, params);
928
+ return new OrmCollection(rows.map((row) => new related(row)), { owned: true });
929
+ }
930
+ async attach(ids) {
931
+ const list = Array.isArray(ids) ? ids : [ids];
932
+ const parent = this.parent.constructor;
933
+ const morphType = this.getMorphType();
934
+ const tenantId = this.#tenantId();
935
+ const useTenant = Boolean(this.pivotTenantKey && tenantId != null && tenantId !== "");
936
+ for (const id of list) {
937
+ if (useTenant) {
938
+ await parent.getConnection().run(`INSERT INTO ${this.pivotTable} (${this.foreignPivotKey}, ${this.relatedPivotKey}, ${this.morphTypeColumn}, ${this.pivotTenantKey}) VALUES (?, ?, ?, ?)`, [this.#parentId(), id, morphType, tenantId]);
939
+ }
940
+ else {
941
+ await parent.getConnection().run(`INSERT INTO ${this.pivotTable} (${this.foreignPivotKey}, ${this.relatedPivotKey}, ${this.morphTypeColumn}) VALUES (?, ?, ?)`, [this.#parentId(), id, morphType]);
942
+ }
943
+ }
944
+ }
945
+ async detach(ids) {
946
+ const parent = this.parent.constructor;
947
+ const tenantId = this.#tenantId();
948
+ const useTenant = Boolean(this.pivotTenantKey && tenantId != null && tenantId !== "");
949
+ const tenantSql = useTenant
950
+ ? ` AND ${this.pivotTenantKey} = ?`
951
+ : "";
952
+ const tenantParams = useTenant ? [tenantId] : [];
953
+ if (ids === undefined) {
954
+ await parent.getConnection().run(`DELETE FROM ${this.pivotTable}
955
+ WHERE ${this.foreignPivotKey} = ?
956
+ AND ${this.morphTypeColumn} IN (${this.#morphTypePlaceholders()})${tenantSql}`, [this.#parentId(), ...this.morphTypes, ...tenantParams]);
957
+ return;
958
+ }
959
+ const list = Array.isArray(ids) ? ids : [ids];
960
+ for (const id of list) {
961
+ await parent.getConnection().run(`DELETE FROM ${this.pivotTable}
962
+ WHERE ${this.foreignPivotKey} = ?
963
+ AND ${this.relatedPivotKey} = ?
964
+ AND ${this.morphTypeColumn} IN (${this.#morphTypePlaceholders()})${tenantSql}`, [this.#parentId(), id, ...this.morphTypes, ...tenantParams]);
965
+ }
966
+ }
967
+ async sync(ids) {
968
+ await this.detach();
969
+ await this.attach(ids);
970
+ }
971
+ async syncWithoutDetaching(ids) {
972
+ const existing = new Set((await this.get()).map((m) => String(m[this.related.primaryKey])));
973
+ const missing = ids.filter((id) => !existing.has(String(id)));
974
+ if (missing.length > 0)
975
+ await this.attach(missing);
976
+ }
977
+ async toggle(ids) {
978
+ const list = Array.isArray(ids) ? ids : [ids];
979
+ const existing = new Set((await this.get()).map((m) => String(m[this.related.primaryKey])));
980
+ const attachIds = [];
981
+ const detachIds = [];
982
+ for (const id of list) {
983
+ if (existing.has(String(id)))
984
+ detachIds.push(id);
985
+ else
986
+ attachIds.push(id);
987
+ }
988
+ if (detachIds.length > 0)
989
+ await this.detach(detachIds);
990
+ if (attachIds.length > 0)
991
+ await this.attach(attachIds);
992
+ }
993
+ }
994
+ /** Cached relation meta per model class (avoids `new model()` + related() per compile). */
995
+ const relationMetaCache = new WeakMap();
996
+ export function resolveRelation(model, relation) {
997
+ let byName = relationMetaCache.get(model);
998
+ if (!byName) {
999
+ byName = new Map();
1000
+ relationMetaCache.set(model, byName);
1001
+ }
1002
+ if (byName.has(relation))
1003
+ return byName.get(relation);
1004
+ const instance = new model();
1005
+ let rel;
1006
+ try {
1007
+ rel = instance.related(relation);
1008
+ }
1009
+ catch {
1010
+ byName.set(relation, null);
1011
+ return null;
1012
+ }
1013
+ let meta = null;
1014
+ if (rel instanceof HasMany || rel instanceof HasOne) {
1015
+ meta = {
1016
+ kind: "has",
1017
+ related: rel.getRelated(),
1018
+ foreignKey: rel.getForeignKeyName(),
1019
+ localKey: rel.getLocalKeyName(),
1020
+ };
1021
+ }
1022
+ else if (rel instanceof BelongsTo) {
1023
+ meta = {
1024
+ kind: "belongsTo",
1025
+ related: rel.getRelated(),
1026
+ foreignKey: rel.getForeignKeyName(),
1027
+ ownerKey: rel.getOwnerKeyName(),
1028
+ };
1029
+ }
1030
+ else if (rel instanceof BelongsToMany) {
1031
+ meta = {
1032
+ kind: "belongsToMany",
1033
+ related: rel.getRelated(),
1034
+ pivotTable: rel.getPivotTable(),
1035
+ foreignPivotKey: rel.getForeignPivotKeyName(),
1036
+ relatedPivotKey: rel.getRelatedPivotKeyName(),
1037
+ localKey: model.primaryKey,
1038
+ pivotColumns: rel.getPivotColumns(),
1039
+ };
1040
+ }
1041
+ else if (rel instanceof MorphToMany) {
1042
+ meta = {
1043
+ kind: "morphToMany",
1044
+ related: rel.getRelated(),
1045
+ pivotTable: rel.getPivotTable(),
1046
+ foreignPivotKey: rel.getForeignPivotKeyName(),
1047
+ relatedPivotKey: rel.getRelatedPivotKeyName(),
1048
+ localKey: model.primaryKey,
1049
+ morphTypeColumn: rel.getMorphTypeColumn(),
1050
+ morphTypes: rel.getMorphTypes(),
1051
+ pivotTenantKey: rel.getPivotTenantKey(),
1052
+ parentTenantKey: rel.getParentTenantKey(),
1053
+ };
1054
+ }
1055
+ else if (rel instanceof HasManyThrough) {
1056
+ meta = {
1057
+ kind: "hasManyThrough",
1058
+ related: rel.getRelated(),
1059
+ through: rel.getThrough(),
1060
+ firstKey: rel.getFirstKeyName(),
1061
+ secondKey: rel.getSecondKeyName(),
1062
+ localKey: rel.getLocalKeyName(),
1063
+ secondLocalKey: rel.getSecondLocalKeyName(),
1064
+ };
1065
+ }
1066
+ else if (rel instanceof HasOneThrough) {
1067
+ meta = {
1068
+ kind: "hasOneThrough",
1069
+ related: rel.getRelated(),
1070
+ through: rel.getThrough(),
1071
+ firstKey: rel.getFirstKeyName(),
1072
+ secondKey: rel.getSecondKeyName(),
1073
+ localKey: rel.getLocalKeyName(),
1074
+ secondLocalKey: rel.getSecondLocalKeyName(),
1075
+ };
1076
+ }
1077
+ byName.set(relation, meta);
1078
+ return meta;
1079
+ }
1080
+ export async function countRelation(model, relation) {
1081
+ const value = await aggregateRelation(model, relation, "count");
1082
+ return Number(value ?? 0);
1083
+ }
1084
+ /** Run COUNT/SUM/AVG/MIN/MAX/EXISTS for a relation on one model instance. */
1085
+ export async function aggregateRelation(model, relation, fn, column) {
1086
+ const ctor = model.constructor;
1087
+ const meta = resolveRelation(ctor, relation);
1088
+ if (!meta) {
1089
+ throw new Error(`Unknown relation [${relation}] for load aggregate.`);
1090
+ }
1091
+ const id = model[ctor.primaryKey];
1092
+ if (meta.kind === "has") {
1093
+ let q = meta.related.where(meta.foreignKey, id);
1094
+ if (fn === "exists")
1095
+ return (await q.exists()) ? 1 : 0;
1096
+ if (fn === "count")
1097
+ return q.count();
1098
+ if (fn === "sum")
1099
+ return q.sum(column);
1100
+ if (fn === "avg")
1101
+ return q.avg(column);
1102
+ if (fn === "min")
1103
+ return q.min(column);
1104
+ return q.max(column);
1105
+ }
1106
+ if (meta.kind === "belongsTo") {
1107
+ const fk = model[meta.foreignKey];
1108
+ if (fk == null)
1109
+ return fn === "exists" ? 0 : fn === "count" || fn === "sum" ? 0 : null;
1110
+ let q = meta.related.where(meta.ownerKey, fk);
1111
+ if (fn === "exists")
1112
+ return (await q.exists()) ? 1 : 0;
1113
+ if (fn === "count")
1114
+ return q.count();
1115
+ if (fn === "sum")
1116
+ return q.sum(column);
1117
+ if (fn === "avg")
1118
+ return q.avg(column);
1119
+ if (fn === "min")
1120
+ return q.min(column);
1121
+ return q.max(column);
1122
+ }
1123
+ if (meta.kind === "hasManyThrough" || meta.kind === "hasOneThrough") {
1124
+ // Through-relations have no pivot table — the SQL below only applies to
1125
+ // belongsToMany/morphToMany. Previously this fell through unguarded and
1126
+ // built a query with `undefined` in place of a pivot table/column.
1127
+ throw new Error(`aggregateRelation() does not support "${meta.kind}" relations yet (relation [${relation}]).`);
1128
+ }
1129
+ const morphSql = meta.kind === "morphToMany"
1130
+ ? ` AND ${meta.pivotTable}.${meta.morphTypeColumn} IN (${meta.morphTypes.map(() => "?").join(", ")})`
1131
+ : "";
1132
+ const morphParams = meta.kind === "morphToMany" ? [...meta.morphTypes] : [];
1133
+ let tenantSql = "";
1134
+ const tenantParams = [];
1135
+ if (meta.kind === "morphToMany" && meta.pivotTenantKey && meta.parentTenantKey) {
1136
+ const tenantId = model[meta.parentTenantKey];
1137
+ if (tenantId != null && tenantId !== "") {
1138
+ tenantSql = ` AND ${meta.pivotTable}.${meta.pivotTenantKey} = ?`;
1139
+ tenantParams.push(tenantId);
1140
+ }
1141
+ }
1142
+ const row = await ctor.getConnection().get(fn === "exists"
1143
+ ? `SELECT 1 as v FROM ${meta.related.table}
1144
+ INNER JOIN ${meta.pivotTable}
1145
+ ON ${meta.pivotTable}.${meta.relatedPivotKey} = ${meta.related.table}.${meta.related.primaryKey}
1146
+ WHERE ${meta.pivotTable}.${meta.foreignPivotKey} = ?${morphSql}${tenantSql} LIMIT 1`
1147
+ : `SELECT ${fn === "count" ? "COUNT(*)" : `${fn.toUpperCase()}(${column ?? "*"})`} as v FROM ${meta.related.table}
1148
+ INNER JOIN ${meta.pivotTable}
1149
+ ON ${meta.pivotTable}.${meta.relatedPivotKey} = ${meta.related.table}.${meta.related.primaryKey}
1150
+ WHERE ${meta.pivotTable}.${meta.foreignPivotKey} = ?${morphSql}${tenantSql}`, [id, ...morphParams, ...tenantParams]);
1151
+ if (fn === "exists")
1152
+ return row ? 1 : 0;
1153
+ return row?.v ?? (fn === "count" || fn === "sum" ? 0 : null);
1154
+ }
1155
+ installRelationQueryForwarders([
1156
+ MorphMany,
1157
+ MorphOne,
1158
+ HasMany,
1159
+ HasOne,
1160
+ HasManyThrough,
1161
+ HasOneThrough,
1162
+ BelongsTo,
1163
+ BelongsToMany,
1164
+ ], [MorphMany, MorphOne, HasMany, HasOne]);