@bunyad/orm 0.2.0-beta.0 → 0.2.0-beta.2

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.
package/dist/relations.js CHANGED
@@ -5,7 +5,9 @@ import { wrapSqlName } from "@bunyad/database";
5
5
  import { OrmCollection } from "./orm-collection.js";
6
6
  import { softDeleteAliasSql, nowForConnection, } from "./model-helpers.js";
7
7
  import { Model } from "./model.js";
8
+ import { hasGlobalScopes } from "./scopes.js";
8
9
  import { isIgnoringTouch } from "./model-strictness.js";
10
+ import { fireModelEvent, hasAnyModelEventListeners } from "./model-events.js";
9
11
  /** Register morph type aliases (`Relation::morphMap`). */
10
12
  const morphAliases = new Map();
11
13
  /** ModelQuery methods that mutate the builder and return it — forwarded onto relations. */
@@ -94,8 +96,12 @@ function installRelationQueryForwarders(ctors, writes = []) {
94
96
  }
95
97
  }
96
98
  }
97
- /** Strip `__pivot_*` columns onto `model.pivot`. */
98
- export function applyPivotAttributes(model, attrs, pivotKeys) {
99
+ /**
100
+ * Strip `__pivot_*` columns onto `model.pivot` (or the `as()` accessor). With
101
+ * `using(PivotModel)` the pivot is an instance of that model, so its casts and
102
+ * methods apply.
103
+ */
104
+ export function applyPivotAttributes(model, attrs, pivotKeys, options = {}) {
99
105
  if (pivotKeys.length === 0)
100
106
  return model;
101
107
  const pivot = {};
@@ -112,7 +118,15 @@ export function applyPivotAttributes(model, attrs, pivotKeys) {
112
118
  }
113
119
  }
114
120
  if (Object.keys(pivot).length > 0) {
115
- self.pivot = pivot;
121
+ const accessor = options.accessor ?? "pivot";
122
+ if (options.pivotClass) {
123
+ const instance = new options.pivotClass(pivot);
124
+ instance.exists = true;
125
+ self[accessor] = instance;
126
+ }
127
+ else {
128
+ self[accessor] = pivot;
129
+ }
116
130
  }
117
131
  return model;
118
132
  }
@@ -124,26 +138,82 @@ function pivotSelectKeys(foreignPivotKey, relatedPivotKey, extra) {
124
138
  }
125
139
  return keys;
126
140
  }
127
- export function morphMap(map) {
128
- for (const [alias, cls] of Object.entries(map)) {
129
- morphAliases.set(alias, cls);
141
+ /** Classes seen by the ORM (booted or used as a morph target), keyed by class name — fallback for unmapped types. */
142
+ const knownClasses = new Map();
143
+ let morphMapRequired = false;
144
+ /** Class names used by two different model classes — never resolved by name alone. */
145
+ const ambiguousClassNames = new Set();
146
+ export function registerMorphClass(model) {
147
+ if (!model.name)
148
+ return;
149
+ const existing = knownClasses.get(model.name);
150
+ if (!existing)
151
+ knownClasses.set(model.name, model);
152
+ else if (existing !== model)
153
+ ambiguousClassNames.add(model.name);
154
+ }
155
+ export function morphMap(map, merge = true) {
156
+ if (map) {
157
+ if (!merge)
158
+ morphAliases.clear();
159
+ for (const [alias, cls] of Object.entries(map)) {
160
+ morphAliases.set(alias, cls);
161
+ }
130
162
  }
163
+ return Object.fromEntries(morphAliases);
164
+ }
165
+ /** `Relation::enforceMorphMap` — register the map and refuse unmapped classes. */
166
+ export function enforceMorphMap(map, merge = true) {
167
+ requireMorphMap();
168
+ return morphMap(map, merge);
169
+ }
170
+ /** `Relation::requireMorphMap` — throw when a model without an alias is used polymorphically. */
171
+ export function requireMorphMap(required = true) {
172
+ morphMapRequired = required;
173
+ }
174
+ export function requiresMorphMap() {
175
+ return morphMapRequired;
131
176
  }
132
177
  export function clearMorphMap() {
133
178
  morphAliases.clear();
179
+ morphMapRequired = false;
134
180
  }
135
181
  export function morphTypeFor(model) {
136
182
  for (const [alias, cls] of morphAliases) {
137
183
  if (cls === model)
138
184
  return alias;
139
185
  }
186
+ if (morphMapRequired) {
187
+ throw new Error(`No morph map defined for [${model.name}]. Add it to morphMap()/enforceMorphMap() before using it in a polymorphic relation.`);
188
+ }
189
+ registerMorphClass(model);
140
190
  return model.name;
141
191
  }
142
- export function resolveMorphType(type) {
192
+ /** `Relation::getMorphedModel` — class for an alias, or undefined. */
193
+ export function getMorphedModel(type) {
143
194
  const mapped = morphAliases.get(type);
144
195
  if (mapped)
145
196
  return mapped;
146
- throw new Error(`No morph map entry for [${type}]. Call morphMap({ ${type}: Model }) first.`);
197
+ if (morphMapRequired || ambiguousClassNames.has(type))
198
+ return undefined;
199
+ return knownClasses.get(type);
200
+ }
201
+ export function resolveMorphType(type) {
202
+ const mapped = getMorphedModel(type);
203
+ if (mapped)
204
+ return mapped;
205
+ const hint = ambiguousClassNames.has(type)
206
+ ? ` Two model classes are named [${type}], so it cannot be resolved by name.`
207
+ : "";
208
+ throw new Error(`No morph map entry for [${type}].${hint} Call morphMap({ ${type}: Model }) first.`);
209
+ }
210
+ function buildDefault(Related, spec, parent, keys = {}) {
211
+ const model = new Related({ ...keys });
212
+ if (typeof spec === "function")
213
+ spec(model, parent);
214
+ else if (spec !== true)
215
+ Object.assign(model, spec);
216
+ return model;
147
217
  }
148
218
  /** MorphMany relation. */
149
219
  export class MorphMany {
@@ -154,6 +224,7 @@ export class MorphMany {
154
224
  morphType;
155
225
  localKey;
156
226
  #baseQuery = null;
227
+ #inverse;
157
228
  constructor(parent, related, typeColumn, idColumn, morphType, localKey) {
158
229
  this.parent = parent;
159
230
  this.related = related;
@@ -182,6 +253,30 @@ export class MorphMany {
182
253
  return this.parent[parentKey];
183
254
  }
184
255
  /** Constrained related query (`morphMany().where(…).get()`). */
256
+ /**
257
+ * `chaperone($inverse)` — set the parent on every loaded child (`post.user` without a query).
258
+ * The inverse name defaults to the parent class name in camelCase.
259
+ */
260
+ chaperone(inverse) {
261
+ this.#inverse = inverse ?? true;
262
+ return this;
263
+ }
264
+ /** Property the parent is set on, or `undefined` when `chaperone()` was not called. */
265
+ getInverse() {
266
+ if (this.#inverse === undefined)
267
+ return undefined;
268
+ if (typeof this.#inverse === "string")
269
+ return this.#inverse;
270
+ const name = this.parent.constructor.name;
271
+ return name.charAt(0).toLowerCase() + name.slice(1);
272
+ }
273
+ #hydrateInverse(rows) {
274
+ const inverse = this.getInverse();
275
+ if (inverse === undefined)
276
+ return;
277
+ for (const row of rows)
278
+ setInverse(row, inverse, this.parent);
279
+ }
185
280
  getQuery() {
186
281
  if (!this.#baseQuery) {
187
282
  this.#baseQuery = this.related
@@ -192,10 +287,15 @@ export class MorphMany {
192
287
  }
193
288
  async get() {
194
289
  const rows = await this.getQuery().get();
195
- return new OrmCollection(rows.all(), { owned: true });
290
+ const models = rows.all();
291
+ this.#hydrateInverse(models);
292
+ return new OrmCollection(models, { owned: true });
196
293
  }
197
294
  async first() {
198
- return this.getQuery().first();
295
+ const row = (await this.getQuery().first());
296
+ if (row)
297
+ this.#hydrateInverse([row]);
298
+ return row;
199
299
  }
200
300
  async create(attributes) {
201
301
  return this.related.create({
@@ -216,6 +316,7 @@ export class MorphOne {
216
316
  #ofManyColumn;
217
317
  #ofManyAggregate;
218
318
  #baseQuery = null;
319
+ #default;
219
320
  constructor(parent, related, typeColumn, idColumn, morphType, localKey) {
220
321
  this.parent = parent;
221
322
  this.related = related;
@@ -266,6 +367,18 @@ export class MorphOne {
266
367
  this.#ofManyAggregate = aggregate;
267
368
  return this;
268
369
  }
370
+ /** `withDefault` — return an empty related model instead of `null` when nothing matches. */
371
+ withDefault(spec = true) {
372
+ this.#default = spec;
373
+ return this;
374
+ }
375
+ hasDefault() {
376
+ return this.#default !== undefined;
377
+ }
378
+ /** The default model for the owner this relation was built from. */
379
+ makeDefault() {
380
+ return buildDefault(this.related, this.#default ?? true, this.parent, { [this.idColumn]: this.parent[this.getLocalKeyName()], [this.typeColumn]: this.morphType });
381
+ }
269
382
  getQuery() {
270
383
  if (!this.#baseQuery) {
271
384
  let q = this.related
@@ -289,6 +402,10 @@ export class MorphOne {
289
402
  return this.first();
290
403
  }
291
404
  async first() {
405
+ const found = await this.#firstRow();
406
+ return found ?? (this.#default !== undefined ? this.makeDefault() : null);
407
+ }
408
+ async #firstRow() {
292
409
  return this.getQuery().first();
293
410
  }
294
411
  async create(attributes) {
@@ -359,6 +476,7 @@ export class HasMany {
359
476
  foreignKey;
360
477
  localKey;
361
478
  #baseQuery = null;
479
+ #inverse;
362
480
  constructor(parent, related, foreignKey, localKey) {
363
481
  this.parent = parent;
364
482
  this.related = related;
@@ -379,6 +497,30 @@ export class HasMany {
379
497
  return this.parent[parentKey];
380
498
  }
381
499
  /** Constrained related query (`hasMany().where(…).orderBy(…).get()`). */
500
+ /**
501
+ * `chaperone($inverse)` — set the parent on every loaded child (`post.user` without a query).
502
+ * The inverse name defaults to the parent class name in camelCase.
503
+ */
504
+ chaperone(inverse) {
505
+ this.#inverse = inverse ?? true;
506
+ return this;
507
+ }
508
+ /** Property the parent is set on, or `undefined` when `chaperone()` was not called. */
509
+ getInverse() {
510
+ if (this.#inverse === undefined)
511
+ return undefined;
512
+ if (typeof this.#inverse === "string")
513
+ return this.#inverse;
514
+ const name = this.parent.constructor.name;
515
+ return name.charAt(0).toLowerCase() + name.slice(1);
516
+ }
517
+ #hydrateInverse(rows) {
518
+ const inverse = this.getInverse();
519
+ if (inverse === undefined)
520
+ return;
521
+ for (const row of rows)
522
+ setInverse(row, inverse, this.parent);
523
+ }
382
524
  getQuery() {
383
525
  if (!this.#baseQuery) {
384
526
  this.#baseQuery = this.related.where(this.foreignKey, this.#parentId());
@@ -387,10 +529,15 @@ export class HasMany {
387
529
  }
388
530
  async get() {
389
531
  const rows = await this.getQuery().get();
390
- return new OrmCollection(rows.all(), { owned: true });
532
+ const models = rows.all();
533
+ this.#hydrateInverse(models);
534
+ return new OrmCollection(models, { owned: true });
391
535
  }
392
536
  async first() {
393
- return this.getQuery().first();
537
+ const row = (await this.getQuery().first());
538
+ if (row)
539
+ this.#hydrateInverse([row]);
540
+ return row;
394
541
  }
395
542
  async create(attributes) {
396
543
  return this.related.create({
@@ -424,6 +571,8 @@ export class HasOne {
424
571
  #ofManyColumn;
425
572
  #ofManyAggregate;
426
573
  #baseQuery = null;
574
+ #inverse;
575
+ #default;
427
576
  constructor(parent, related, foreignKey, localKey) {
428
577
  this.parent = parent;
429
578
  this.related = related;
@@ -469,6 +618,42 @@ export class HasOne {
469
618
  this.#ofManyAggregate = aggregate;
470
619
  return this;
471
620
  }
621
+ /** `withDefault` — return an empty related model instead of `null` when nothing matches. */
622
+ withDefault(spec = true) {
623
+ this.#default = spec;
624
+ return this;
625
+ }
626
+ hasDefault() {
627
+ return this.#default !== undefined;
628
+ }
629
+ /** The default model for the owner this relation was built from. */
630
+ makeDefault() {
631
+ return buildDefault(this.related, this.#default ?? true, this.parent, { [this.foreignKey]: this.parent[this.getLocalKeyName()] });
632
+ }
633
+ /**
634
+ * `chaperone($inverse)` — set the parent on every loaded child (`post.user` without a query).
635
+ * The inverse name defaults to the parent class name in camelCase.
636
+ */
637
+ chaperone(inverse) {
638
+ this.#inverse = inverse ?? true;
639
+ return this;
640
+ }
641
+ /** Property the parent is set on, or `undefined` when `chaperone()` was not called. */
642
+ getInverse() {
643
+ if (this.#inverse === undefined)
644
+ return undefined;
645
+ if (typeof this.#inverse === "string")
646
+ return this.#inverse;
647
+ const name = this.parent.constructor.name;
648
+ return name.charAt(0).toLowerCase() + name.slice(1);
649
+ }
650
+ #hydrateInverse(rows) {
651
+ const inverse = this.getInverse();
652
+ if (inverse === undefined)
653
+ return;
654
+ for (const row of rows)
655
+ setInverse(row, inverse, this.parent);
656
+ }
472
657
  getQuery() {
473
658
  if (!this.#baseQuery) {
474
659
  let q = this.related.where(this.foreignKey, this.#parentId());
@@ -489,6 +674,16 @@ export class HasOne {
489
674
  return this.first();
490
675
  }
491
676
  async first() {
677
+ const found = await this.#firstRow();
678
+ return found ?? (this.#default !== undefined ? this.makeDefault() : null);
679
+ }
680
+ async #firstRow() {
681
+ const row = await this.#firstRowRaw();
682
+ if (row)
683
+ this.#hydrateInverse([row]);
684
+ return row;
685
+ }
686
+ async #firstRowRaw() {
492
687
  return this.getQuery().first();
493
688
  }
494
689
  async create(attributes) {
@@ -626,6 +821,7 @@ export class BelongsTo {
626
821
  foreignKey;
627
822
  ownerKey;
628
823
  #baseQuery = null;
824
+ #default;
629
825
  constructor(child, related, foreignKey, ownerKey) {
630
826
  this.child = child;
631
827
  this.related = related;
@@ -641,6 +837,18 @@ export class BelongsTo {
641
837
  getOwnerKeyName() {
642
838
  return this.ownerKey ?? this.related.primaryKey;
643
839
  }
840
+ /** `withDefault` — return an empty related model instead of `null` when nothing matches. */
841
+ withDefault(spec = true) {
842
+ this.#default = spec;
843
+ return this;
844
+ }
845
+ hasDefault() {
846
+ return this.#default !== undefined;
847
+ }
848
+ /** The default model for the owner this relation was built from. */
849
+ makeDefault() {
850
+ return buildDefault(this.related, this.#default ?? true, this.child, {});
851
+ }
644
852
  getQuery() {
645
853
  if (!this.#baseQuery) {
646
854
  const id = this.child[this.foreignKey];
@@ -659,6 +867,10 @@ export class BelongsTo {
659
867
  return this.first();
660
868
  }
661
869
  async first() {
870
+ const found = await this.#firstRow();
871
+ return found ?? (this.#default !== undefined ? this.makeDefault() : null);
872
+ }
873
+ async #firstRow() {
662
874
  return this.getQuery().first();
663
875
  }
664
876
  /** Set the foreign key from a related model (or null). */
@@ -715,6 +927,97 @@ export class BelongsTo {
715
927
  }
716
928
  }
717
929
  /** BelongsToMany relation. */
930
+ /**
931
+ * Point `child[inverse]` at its parent without making it enumerable: a back-reference
932
+ * must not be serialized (`toJSON`) or treated as an attribute, or parent → child → parent recurses.
933
+ */
934
+ export function setInverse(child, inverse, parent) {
935
+ Object.defineProperty(child, inverse, {
936
+ value: parent,
937
+ enumerable: false,
938
+ writable: true,
939
+ configurable: true,
940
+ });
941
+ }
942
+ /** Apply pivot wheres to a ModelQuery / QueryBuilder, qualified with the pivot table. */
943
+ export function applyPivotWheres(q, pivotTable, wheres) {
944
+ const t = q;
945
+ for (const w of wheres) {
946
+ const col = `${pivotTable}.${w.column}`;
947
+ if (w.kind === "basic")
948
+ t.where(col, w.op, w.value);
949
+ else if (w.kind === "in")
950
+ (w.not ? t.whereNotIn(col, w.values) : t.whereIn(col, w.values));
951
+ else if (w.kind === "null")
952
+ (w.not ? t.whereNotNull(col) : t.whereNull(col));
953
+ else
954
+ (w.not ? t.whereNotBetween(col, w.values) : t.whereBetween(col, w.values));
955
+ }
956
+ }
957
+ /** Raw SQL for pivot wheres (` AND "col" = ?`), for the pivot-only queries (sync / detach / update). */
958
+ function pivotWhereSql(dialect, wheres) {
959
+ let sql = "";
960
+ const params = [];
961
+ for (const w of wheres) {
962
+ const col = wrapSqlName(dialect, w.column);
963
+ if (w.kind === "basic") {
964
+ sql += ` AND ${col} ${w.op} ?`;
965
+ params.push(w.value);
966
+ }
967
+ else if (w.kind === "in") {
968
+ if (w.values.length === 0) {
969
+ sql += w.not ? "" : " AND 1 = 0";
970
+ }
971
+ else {
972
+ sql += ` AND ${col} ${w.not ? "NOT IN" : "IN"} (${w.values.map(() => "?").join(", ")})`;
973
+ params.push(...w.values);
974
+ }
975
+ }
976
+ else if (w.kind === "null") {
977
+ sql += ` AND ${col} IS ${w.not ? "NOT " : ""}NULL`;
978
+ }
979
+ else {
980
+ sql += ` AND ${col} ${w.not ? "NOT BETWEEN" : "BETWEEN"} ? AND ?`;
981
+ params.push(w.values[0], w.values[1]);
982
+ }
983
+ }
984
+ return { sql, params };
985
+ }
986
+ /** Multi-row INSERT into a pivot table, grouped by column set and chunked under parameter limits. */
987
+ async function insertPivotRows(conn, table, rows) {
988
+ if (rows.length === 0)
989
+ return;
990
+ const d = conn.dialect;
991
+ const groups = new Map();
992
+ for (const row of rows) {
993
+ const cols = Object.keys(row).sort().join("\u0000");
994
+ const group = groups.get(cols);
995
+ if (group)
996
+ group.push(row);
997
+ else
998
+ groups.set(cols, [row]);
999
+ }
1000
+ for (const [colKey, group] of groups) {
1001
+ const cols = colKey.split("\u0000");
1002
+ const colSql = cols.map((c) => wrapSqlName(d, c)).join(", ");
1003
+ const rowSql = `(${cols.map(() => "?").join(", ")})`;
1004
+ const perChunk = Math.max(1, Math.floor(900 / cols.length));
1005
+ for (let i = 0; i < group.length; i += perChunk) {
1006
+ const chunk = group.slice(i, i + perChunk);
1007
+ await conn.run(`INSERT INTO ${wrapSqlName(d, table)} (${colSql}) VALUES ${chunk.map(() => rowSql).join(", ")}`, chunk.flatMap((row) => cols.map((c) => row[c])));
1008
+ }
1009
+ }
1010
+ }
1011
+ /** Chunked `DELETE … WHERE fk = ? AND col IN (…)`; returns deleted row count. */
1012
+ async function deletePivotIn(conn, sqlBase, baseParams, inColumn, ids) {
1013
+ const d = conn.dialect;
1014
+ let deleted = 0;
1015
+ for (let i = 0; i < ids.length; i += 800) {
1016
+ const chunk = ids.slice(i, i + 800);
1017
+ deleted += Number((await conn.run(`${sqlBase} AND ${wrapSqlName(d, inColumn)} IN (${chunk.map(() => "?").join(", ")})`, [...baseParams, ...chunk])) ?? 0);
1018
+ }
1019
+ return deleted;
1020
+ }
718
1021
  export class BelongsToMany {
719
1022
  parent;
720
1023
  related;
@@ -723,6 +1026,11 @@ export class BelongsToMany {
723
1026
  relatedPivotKey;
724
1027
  #baseQuery = null;
725
1028
  #pivotColumns = [];
1029
+ #pivotWheres = [];
1030
+ #pivotOrders = [];
1031
+ #pivotTimestamps = false;
1032
+ #pivotAccessor = "pivot";
1033
+ #pivotClass;
726
1034
  constructor(parent, related, pivotTable, foreignPivotKey, relatedPivotKey) {
727
1035
  this.parent = parent;
728
1036
  this.related = related;
@@ -760,7 +1068,7 @@ export class BelongsToMany {
760
1068
  return this.parent[parentKey];
761
1069
  }
762
1070
  #pivotKeys() {
763
- if (this.#pivotColumns.length === 0)
1071
+ if (this.#pivotColumns.length === 0 && !this.#pivotClass && this.#pivotAccessor === "pivot")
764
1072
  return [];
765
1073
  return pivotSelectKeys(this.foreignPivotKey, this.relatedPivotKey, this.#pivotColumns);
766
1074
  }
@@ -772,6 +1080,10 @@ export class BelongsToMany {
772
1080
  .join(this.pivotTable, `${this.pivotTable}.${this.relatedPivotKey}`, "=", `${related.table}.${related.primaryKey}`)
773
1081
  .where(`${this.pivotTable}.${this.foreignPivotKey}`, this.#parentId())
774
1082
  .select(`${related.table}.*`);
1083
+ applyPivotWheres(q, this.pivotTable, this.#pivotWheres);
1084
+ for (const o of this.#pivotOrders) {
1085
+ q = q.orderBy(`${this.pivotTable}.${o.column}`, o.direction);
1086
+ }
775
1087
  for (const col of this.#pivotKeys()) {
776
1088
  q = q.selectRaw(`${this.pivotTable}.${col} as __pivot_${col}`);
777
1089
  }
@@ -784,7 +1096,7 @@ export class BelongsToMany {
784
1096
  if (keys.length === 0)
785
1097
  return;
786
1098
  for (const model of models) {
787
- applyPivotAttributes(model, model, keys);
1099
+ applyPivotAttributes(model, model, keys, { accessor: this.#pivotAccessor, pivotClass: this.#pivotClass });
788
1100
  }
789
1101
  }
790
1102
  async get() {
@@ -799,52 +1111,299 @@ export class BelongsToMany {
799
1111
  this.#hydratePivot([model]);
800
1112
  return model;
801
1113
  }
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]);
1114
+ /** `withTimestamps` — stamp `created_at` / `updated_at` on pivot writes. */
1115
+ withTimestamps() {
1116
+ this.#baseQuery = null;
1117
+ this.#pivotTimestamps = true;
1118
+ // The timestamp columns are also read onto the pivot.
1119
+ for (const col of ["created_at", "updated_at"]) {
1120
+ if (!this.#pivotColumns.includes(col))
1121
+ this.#pivotColumns.push(col);
1122
+ }
1123
+ return this;
1124
+ }
1125
+ /** `as('membership')` — expose the pivot under another property name than `pivot`. */
1126
+ as(accessor) {
1127
+ this.#pivotAccessor = accessor;
1128
+ return this;
1129
+ }
1130
+ /**
1131
+ * `using(MembershipPivot)` — hydrate the pivot as a model (casts and methods apply)
1132
+ * and run pivot writes (`attach`, `sync`, `updateExistingPivot`) through its "set" casts.
1133
+ */
1134
+ using(pivotClass) {
1135
+ this.#baseQuery = null;
1136
+ this.#pivotClass = pivotClass;
1137
+ return this;
1138
+ }
1139
+ getPivotAccessor() {
1140
+ return this.#pivotAccessor;
1141
+ }
1142
+ getPivotClass() {
1143
+ return this.#pivotClass;
1144
+ }
1145
+ /** Pivot attributes for a write, passed through the custom pivot model's set-casts. */
1146
+ #castPivotWrite(attrs) {
1147
+ return this.#pivotClass ? this.#pivotClass.castAttributes(attrs, "set") : attrs;
1148
+ }
1149
+ /** True when the custom pivot model has listeners, so pivot writes must fire its events. */
1150
+ #pivotEvents() {
1151
+ return this.#pivotClass !== undefined && hasAnyModelEventListeners(this.#pivotClass);
1152
+ }
1153
+ #pivotInstance(attrs, exists) {
1154
+ const instance = new this.#pivotClass();
1155
+ instance.forceFill(attrs);
1156
+ if (exists) {
1157
+ instance.syncOriginal();
1158
+ instance.exists = true;
1159
+ }
1160
+ return instance;
1161
+ }
1162
+ getPivotWheres() {
1163
+ return [...this.#pivotWheres];
1164
+ }
1165
+ getPivotOrders() {
1166
+ return [...this.#pivotOrders];
1167
+ }
1168
+ #addPivotWhere(where) {
1169
+ this.#baseQuery = null;
1170
+ this.#pivotWheres.push(where);
1171
+ return this;
1172
+ }
1173
+ wherePivot(column, opOrValue, value) {
1174
+ return value === undefined
1175
+ ? this.#addPivotWhere({ kind: "basic", column, op: "=", value: opOrValue })
1176
+ : this.#addPivotWhere({ kind: "basic", column, op: String(opOrValue), value });
1177
+ }
1178
+ wherePivotIn(column, values) {
1179
+ return this.#addPivotWhere({ kind: "in", column, values, not: false });
1180
+ }
1181
+ wherePivotNotIn(column, values) {
1182
+ return this.#addPivotWhere({ kind: "in", column, values, not: true });
1183
+ }
1184
+ wherePivotNull(column) {
1185
+ return this.#addPivotWhere({ kind: "null", column, not: false });
1186
+ }
1187
+ wherePivotNotNull(column) {
1188
+ return this.#addPivotWhere({ kind: "null", column, not: true });
1189
+ }
1190
+ wherePivotBetween(column, values) {
1191
+ return this.#addPivotWhere({ kind: "between", column, values, not: false });
1192
+ }
1193
+ wherePivotNotBetween(column, values) {
1194
+ return this.#addPivotWhere({ kind: "between", column, values, not: true });
1195
+ }
1196
+ /** `orderByPivot` — order related rows by a pivot column. */
1197
+ orderByPivot(column, direction = "asc") {
1198
+ this.#baseQuery = null;
1199
+ this.#pivotOrders.push({ column, direction });
1200
+ return this;
1201
+ }
1202
+ #conn() {
1203
+ return this.parent.constructor.getConnection();
1204
+ }
1205
+ /** Normalize `1`, `[1, 2]` or `{ 1: { role: "x" } }` into `[id, attributes]` pairs. */
1206
+ #normalize(ids, attributes = {}) {
1207
+ if (ids instanceof Model) {
1208
+ return [[ids[this.related.primaryKey], attributes]];
1209
+ }
1210
+ if (Array.isArray(ids)) {
1211
+ return ids.map((id) => [
1212
+ id instanceof Model
1213
+ ? id[this.related.primaryKey]
1214
+ : id,
1215
+ attributes,
1216
+ ]);
1217
+ }
1218
+ if (ids !== null && typeof ids === "object") {
1219
+ return Object.entries(ids).map(([id, attrs]) => [id, { ...attributes, ...attrs }]);
1220
+ }
1221
+ return [[ids, attributes]];
1222
+ }
1223
+ #stamp(row, created) {
1224
+ if (!this.#pivotTimestamps)
1225
+ return row;
1226
+ const now = nowForConnection(this.#conn());
1227
+ if (created && row.created_at === undefined)
1228
+ row.created_at = now;
1229
+ if (row.updated_at === undefined)
1230
+ row.updated_at = now;
1231
+ return row;
1232
+ }
1233
+ async #currentIds() {
1234
+ const d = this.#conn().dialect;
1235
+ const pw = pivotWhereSql(d, this.#pivotWheres);
1236
+ const rows = await this.#conn().all(`SELECT ${wrapSqlName(d, this.relatedPivotKey)} AS id FROM ${wrapSqlName(d, this.pivotTable)} WHERE ${wrapSqlName(d, this.foreignPivotKey)} = ?${pw.sql}`, [this.#parentId(), ...pw.params]);
1237
+ return new Set(rows.map((r) => String(r.id)));
1238
+ }
1239
+ /** `attach` — one multi-row INSERT per chunk; optional pivot attributes. */
1240
+ async attach(ids, attributes = {}) {
1241
+ const pairs = this.#normalize(ids, attributes);
1242
+ if (pairs.length === 0)
1243
+ return;
1244
+ const d = this.#conn().dialect;
1245
+ const parentId = this.#parentId();
1246
+ // `wherePivot(col, value)` equality constraints become defaults for new pivot rows.
1247
+ const defaults = {};
1248
+ for (const w of this.#pivotWheres) {
1249
+ if (w.kind === "basic" && w.op === "=")
1250
+ defaults[w.column] = w.value;
1251
+ }
1252
+ const rows = pairs.map(([id, attrs]) => this.#castPivotWrite(this.#stamp({ ...defaults, ...attrs, [this.foreignPivotKey]: parentId, [this.relatedPivotKey]: id }, true)));
1253
+ if (!this.#pivotEvents()) {
1254
+ await insertPivotRows(this.#conn(), this.pivotTable, rows);
1255
+ return;
1256
+ }
1257
+ // Custom pivot model with listeners: `saving` / `creating` may change or cancel each row.
1258
+ const pending = [];
1259
+ for (const row of rows) {
1260
+ const pivot = this.#pivotInstance(row, false);
1261
+ if ((await fireModelEvent(pivot, "saving")) === false)
1262
+ continue;
1263
+ if ((await fireModelEvent(pivot, "creating")) === false)
1264
+ continue;
1265
+ pending.push([pivot, pivot.getAttributes()]);
1266
+ }
1267
+ if (pending.length === 0)
1268
+ return;
1269
+ await insertPivotRows(this.#conn(), this.pivotTable, pending.map(([, attrs]) => attrs));
1270
+ for (const [pivot] of pending) {
1271
+ pivot.exists = true;
1272
+ await fireModelEvent(pivot, "created");
1273
+ await fireModelEvent(pivot, "saved");
807
1274
  }
808
1275
  }
1276
+ /** `detach` — returns the number of deleted pivot rows. */
809
1277
  async detach(ids) {
810
- const parent = this.parent.constructor;
1278
+ if (this.#pivotEvents())
1279
+ return this.#detachWithEvents(ids);
1280
+ return this.#detachRows(ids);
1281
+ }
1282
+ /** Load the matching pivot rows, fire `deleting`, delete the survivors, fire `deleted`. */
1283
+ async #detachWithEvents(ids) {
1284
+ const d = this.#conn().dialect;
1285
+ const pw = pivotWhereSql(d, this.#pivotWheres);
1286
+ const wanted = ids === undefined ? null : new Set(this.#normalize(ids).map(([id]) => String(id)));
1287
+ const rows = await this.#conn().all(`SELECT * FROM ${wrapSqlName(d, this.pivotTable)} WHERE ${wrapSqlName(d, this.foreignPivotKey)} = ?${pw.sql}`, [this.#parentId(), ...pw.params]);
1288
+ const doomed = [];
1289
+ for (const row of rows) {
1290
+ if (wanted && !wanted.has(String(row[this.relatedPivotKey])))
1291
+ continue;
1292
+ const pivot = this.#pivotInstance(row, true);
1293
+ if ((await fireModelEvent(pivot, "deleting")) === false)
1294
+ continue;
1295
+ doomed.push(pivot);
1296
+ }
1297
+ if (doomed.length === 0)
1298
+ return 0;
1299
+ const deleted = await this.#detachRows(doomed.map((p) => p[this.relatedPivotKey]));
1300
+ for (const pivot of doomed) {
1301
+ pivot.exists = false;
1302
+ await fireModelEvent(pivot, "deleted");
1303
+ }
1304
+ return deleted;
1305
+ }
1306
+ async #detachRows(ids) {
1307
+ const d = this.#conn().dialect;
1308
+ const pw = pivotWhereSql(d, this.#pivotWheres);
1309
+ const base = `DELETE FROM ${wrapSqlName(d, this.pivotTable)} WHERE ${wrapSqlName(d, this.foreignPivotKey)} = ?${pw.sql}`;
811
1310
  if (ids === undefined) {
812
- await parent.getConnection().run(`DELETE FROM ${this.pivotTable} WHERE ${this.foreignPivotKey} = ?`, [this.#parentId()]);
813
- return;
1311
+ const result = await this.#conn().run(base, [this.#parentId(), ...pw.params]);
1312
+ return Number(result ?? 0);
814
1313
  }
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]);
1314
+ const list = this.#normalize(ids).map(([id]) => id);
1315
+ let deleted = 0;
1316
+ for (let i = 0; i < list.length; i += 900) {
1317
+ const chunk = list.slice(i, i + 900);
1318
+ const result = await this.#conn().run(`${base} AND ${wrapSqlName(d, this.relatedPivotKey)} IN (${chunk.map(() => "?").join(", ")})`, [this.#parentId(), ...pw.params, ...chunk]);
1319
+ deleted += Number(result ?? 0);
1320
+ }
1321
+ return deleted;
1322
+ }
1323
+ /** `updateExistingPivot` — update pivot columns for one related id. Returns affected rows. */
1324
+ async updateExistingPivot(id, attributes) {
1325
+ const relatedId = this.#normalize(id)[0][0];
1326
+ let data = this.#castPivotWrite(this.#stamp({ ...attributes }, false));
1327
+ if (Object.keys(data).length === 0)
1328
+ return 0;
1329
+ const d = this.#conn().dialect;
1330
+ const pw = pivotWhereSql(d, this.#pivotWheres);
1331
+ let pivot = null;
1332
+ if (this.#pivotEvents()) {
1333
+ const [current] = await this.#conn().all(`SELECT * FROM ${wrapSqlName(d, this.pivotTable)} WHERE ${wrapSqlName(d, this.foreignPivotKey)} = ? AND ${wrapSqlName(d, this.relatedPivotKey)} = ?${pw.sql}`, [this.#parentId(), relatedId, ...pw.params]);
1334
+ if (!current)
1335
+ return 0;
1336
+ pivot = this.#pivotInstance(current, true);
1337
+ pivot.forceFill(data);
1338
+ if ((await fireModelEvent(pivot, "saving")) === false)
1339
+ return 0;
1340
+ if ((await fireModelEvent(pivot, "updating")) === false)
1341
+ return 0;
1342
+ data = pivot.getDirty();
819
1343
  }
1344
+ const cols = Object.keys(data);
1345
+ if (cols.length === 0)
1346
+ return 0;
1347
+ const result = await this.#conn().run(`UPDATE ${wrapSqlName(d, this.pivotTable)} SET ${cols.map((c) => `${wrapSqlName(d, c)} = ?`).join(", ")} WHERE ${wrapSqlName(d, this.foreignPivotKey)} = ? AND ${wrapSqlName(d, this.relatedPivotKey)} = ?${pw.sql}`, [...cols.map((c) => data[c]), this.#parentId(), relatedId, ...pw.params]);
1348
+ if (pivot) {
1349
+ await fireModelEvent(pivot, "updated");
1350
+ await fireModelEvent(pivot, "saved");
1351
+ }
1352
+ return Number(result ?? 0);
820
1353
  }
821
- async sync(ids) {
822
- await this.detach();
823
- await this.attach(ids);
1354
+ /**
1355
+ * `sync` — diff against existing rows: attach new, detach removed, and update
1356
+ * pivot attributes of kept rows when the map form supplies them.
1357
+ */
1358
+ async sync(ids, detaching = true) {
1359
+ const pairs = this.#normalize(ids);
1360
+ const existing = await this.#currentIds();
1361
+ const result = { attached: [], detached: [], updated: [] };
1362
+ const wanted = new Set(pairs.map(([id]) => String(id)));
1363
+ if (detaching) {
1364
+ const gone = [...existing].filter((id) => !wanted.has(id));
1365
+ if (gone.length > 0) {
1366
+ await this.detach(gone);
1367
+ result.detached = gone;
1368
+ }
1369
+ }
1370
+ const toAttach = [];
1371
+ for (const [id, attrs] of pairs) {
1372
+ if (!existing.has(String(id))) {
1373
+ toAttach.push([id, attrs]);
1374
+ result.attached.push(id);
1375
+ }
1376
+ else if (Object.keys(attrs).length > 0) {
1377
+ if ((await this.updateExistingPivot(id, attrs)) > 0)
1378
+ result.updated.push(id);
1379
+ }
1380
+ }
1381
+ if (toAttach.length > 0) {
1382
+ await this.attach(Object.fromEntries(toAttach));
1383
+ }
1384
+ return result;
824
1385
  }
825
- /** Attach missing ids only. */
1386
+ /** `syncWithoutDetaching`. */
826
1387
  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);
1388
+ return this.sync(ids, false);
831
1389
  }
832
- /** Attach missing, detach present. */
1390
+ /** `toggle` — attach missing, detach present. */
833
1391
  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) {
1392
+ const pairs = this.#normalize(ids);
1393
+ const existing = await this.#currentIds();
1394
+ const attach = [];
1395
+ const detached = [];
1396
+ for (const [id, attrs] of pairs) {
839
1397
  if (existing.has(String(id)))
840
- detachIds.push(id);
1398
+ detached.push(id);
841
1399
  else
842
- attachIds.push(id);
1400
+ attach.push([id, attrs]);
843
1401
  }
844
- if (detachIds.length > 0)
845
- await this.detach(detachIds);
846
- if (attachIds.length > 0)
847
- await this.attach(attachIds);
1402
+ if (detached.length > 0)
1403
+ await this.detach(detached);
1404
+ if (attach.length > 0)
1405
+ await this.attach(Object.fromEntries(attach));
1406
+ return { attached: attach.map(([id]) => id), detached };
848
1407
  }
849
1408
  }
850
1409
  /** Polymorphic many-to-many (e.g. Contact → Role via `model_has_roles`). */
@@ -920,6 +1479,19 @@ export class MorphToMany {
920
1479
  tenantSql = ` AND ${this.pivotTable}.${this.pivotTenantKey} = ?`;
921
1480
  params.push(tenantId);
922
1481
  }
1482
+ if (hasGlobalScopes(related)) {
1483
+ // Query-builder path so the related model's global scopes apply.
1484
+ let q = related
1485
+ .newQuery()
1486
+ .join(this.pivotTable, `${this.pivotTable}.${this.relatedPivotKey}`, "=", `${related.table}.${related.primaryKey}`)
1487
+ .where(`${this.pivotTable}.${this.foreignPivotKey}`, this.#parentId())
1488
+ .whereIn(`${this.pivotTable}.${this.morphTypeColumn}`, this.morphTypes)
1489
+ .select(`${related.table}.*`);
1490
+ if (this.pivotTenantKey && tenantSql) {
1491
+ q = q.where(`${this.pivotTable}.${this.pivotTenantKey}`, tenantId);
1492
+ }
1493
+ return new OrmCollection((await q.get()).all(), { owned: true });
1494
+ }
923
1495
  const rows = await parent.getConnection().all(`SELECT ${related.table}.* FROM ${related.table}
924
1496
  INNER JOIN ${this.pivotTable}
925
1497
  ON ${this.pivotTable}.${this.relatedPivotKey} = ${related.table}.${related.primaryKey}
@@ -927,68 +1499,192 @@ export class MorphToMany {
927
1499
  AND ${this.pivotTable}.${this.morphTypeColumn} IN (${this.#morphTypePlaceholders()})${tenantSql}`, params);
928
1500
  return new OrmCollection(rows.map((row) => new related(row)), { owned: true });
929
1501
  }
1502
+ #conn() {
1503
+ return this.parent.constructor.getConnection();
1504
+ }
1505
+ #useTenant() {
1506
+ const tenantId = this.#tenantId();
1507
+ return Boolean(this.pivotTenantKey && tenantId != null && tenantId !== "");
1508
+ }
1509
+ async #currentIds() {
1510
+ const d = this.#conn().dialect;
1511
+ const params = [this.#parentId(), ...this.morphTypes];
1512
+ let tenantSql = "";
1513
+ if (this.#useTenant()) {
1514
+ tenantSql = ` AND ${wrapSqlName(d, this.pivotTenantKey)} = ?`;
1515
+ params.push(this.#tenantId());
1516
+ }
1517
+ const rows = await this.#conn().all(`SELECT ${wrapSqlName(d, this.relatedPivotKey)} AS id FROM ${wrapSqlName(d, this.pivotTable)} WHERE ${wrapSqlName(d, this.foreignPivotKey)} = ? AND ${wrapSqlName(d, this.morphTypeColumn)} IN (${this.#morphTypePlaceholders()})${tenantSql}`, params);
1518
+ return new Set(rows.map((r) => String(r.id)));
1519
+ }
1520
+ /** One multi-row INSERT per chunk (was one INSERT per id). */
930
1521
  async attach(ids) {
931
1522
  const list = Array.isArray(ids) ? ids : [ids];
932
- const parent = this.parent.constructor;
933
1523
  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
- }
1524
+ const useTenant = this.#useTenant();
1525
+ await insertPivotRows(this.#conn(), this.pivotTable, list.map((id) => ({
1526
+ [this.foreignPivotKey]: this.#parentId(),
1527
+ [this.relatedPivotKey]: id,
1528
+ [this.morphTypeColumn]: morphType,
1529
+ ...(useTenant ? { [this.pivotTenantKey]: this.#tenantId() } : {}),
1530
+ })));
1531
+ }
1532
+ /** Returns the number of deleted pivot rows. */
1533
+ async detach(ids) {
1534
+ const d = this.#conn().dialect;
1535
+ const useTenant = this.#useTenant();
1536
+ const tenantSql = useTenant ? ` AND ${wrapSqlName(d, this.pivotTenantKey)} = ?` : "";
1537
+ const tenantParams = useTenant ? [this.#tenantId()] : [];
1538
+ const base = `DELETE FROM ${wrapSqlName(d, this.pivotTable)}
1539
+ WHERE ${wrapSqlName(d, this.foreignPivotKey)} = ?
1540
+ AND ${wrapSqlName(d, this.morphTypeColumn)} IN (${this.#morphTypePlaceholders()})${tenantSql}`;
1541
+ const baseParams = [this.#parentId(), ...this.morphTypes, ...tenantParams];
1542
+ if (ids === undefined) {
1543
+ return Number((await this.#conn().run(base, baseParams)) ?? 0);
943
1544
  }
1545
+ const list = Array.isArray(ids) ? ids : [ids];
1546
+ return deletePivotIn(this.#conn(), base, baseParams, this.relatedPivotKey, list);
1547
+ }
1548
+ /** Diff-based: keeps existing rows, attaches new ids, detaches removed ones. */
1549
+ async sync(ids) {
1550
+ const existing = await this.#currentIds();
1551
+ const wanted = new Set(ids.map(String));
1552
+ const detached = [...existing].filter((id) => !wanted.has(id));
1553
+ const attached = ids.filter((id) => !existing.has(String(id)));
1554
+ if (detached.length > 0)
1555
+ await this.detach(detached);
1556
+ if (attached.length > 0)
1557
+ await this.attach(attached);
1558
+ return { attached, detached, updated: [] };
1559
+ }
1560
+ async syncWithoutDetaching(ids) {
1561
+ const existing = await this.#currentIds();
1562
+ const attached = ids.filter((id) => !existing.has(String(id)));
1563
+ if (attached.length > 0)
1564
+ await this.attach(attached);
1565
+ return { attached, detached: [], updated: [] };
1566
+ }
1567
+ async toggle(ids) {
1568
+ const list = Array.isArray(ids) ? ids : [ids];
1569
+ const existing = await this.#currentIds();
1570
+ const detached = list.filter((id) => existing.has(String(id)));
1571
+ const attached = list.filter((id) => !existing.has(String(id)));
1572
+ if (detached.length > 0)
1573
+ await this.detach(detached);
1574
+ if (attached.length > 0)
1575
+ await this.attach(attached);
1576
+ return { attached, detached };
1577
+ }
1578
+ }
1579
+ /**
1580
+ * `morphedByMany` — inverse of a `morphToMany` (`taggables` pivot).
1581
+ * Parent is the "tag" side; the pivot stores the related model's id and morph type.
1582
+ */
1583
+ export class MorphedByMany {
1584
+ parent;
1585
+ related;
1586
+ pivotTable;
1587
+ foreignPivotKey;
1588
+ relatedPivotKey;
1589
+ morphTypeColumn;
1590
+ morphType;
1591
+ constructor(parent, related, pivotTable, foreignPivotKey, relatedPivotKey, morphTypeColumn, morphType) {
1592
+ this.parent = parent;
1593
+ this.related = related;
1594
+ this.pivotTable = pivotTable;
1595
+ this.foreignPivotKey = foreignPivotKey;
1596
+ this.relatedPivotKey = relatedPivotKey;
1597
+ this.morphTypeColumn = morphTypeColumn;
1598
+ this.morphType = morphType;
1599
+ }
1600
+ getRelated() {
1601
+ return this.related;
1602
+ }
1603
+ getPivotTable() {
1604
+ return this.pivotTable;
1605
+ }
1606
+ getForeignPivotKeyName() {
1607
+ return this.foreignPivotKey;
1608
+ }
1609
+ getRelatedPivotKeyName() {
1610
+ return this.relatedPivotKey;
1611
+ }
1612
+ getMorphTypeColumn() {
1613
+ return this.morphTypeColumn;
1614
+ }
1615
+ getMorphType() {
1616
+ return this.morphType;
1617
+ }
1618
+ #parentId() {
1619
+ const parentKey = this.parent.constructor.primaryKey;
1620
+ return this.parent[parentKey];
1621
+ }
1622
+ #conn() {
1623
+ return this.parent.constructor.getConnection();
1624
+ }
1625
+ async get() {
1626
+ const related = this.related;
1627
+ const d = this.#conn().dialect;
1628
+ const qRelated = wrapSqlName(d, related.table);
1629
+ const qPivot = wrapSqlName(d, this.pivotTable);
1630
+ const rows = await this.#conn().all(`SELECT ${qRelated}.* FROM ${qRelated}
1631
+ INNER JOIN ${qPivot}
1632
+ ON ${qPivot}.${wrapSqlName(d, this.relatedPivotKey)} = ${qRelated}.${wrapSqlName(d, related.primaryKey)}
1633
+ WHERE ${qPivot}.${wrapSqlName(d, this.foreignPivotKey)} = ?
1634
+ AND ${qPivot}.${wrapSqlName(d, this.morphTypeColumn)} = ?${softDeleteAliasSql(related, qRelated)}`, [this.#parentId(), this.morphType]);
1635
+ return new OrmCollection(rows.map((row) => new related(row)), {
1636
+ owned: true,
1637
+ });
1638
+ }
1639
+ async attach(ids) {
1640
+ const list = Array.isArray(ids) ? ids : [ids];
1641
+ await insertPivotRows(this.#conn(), this.pivotTable, list.map((id) => ({
1642
+ [this.foreignPivotKey]: this.#parentId(),
1643
+ [this.relatedPivotKey]: id,
1644
+ [this.morphTypeColumn]: this.morphType,
1645
+ })));
944
1646
  }
945
1647
  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] : [];
1648
+ const d = this.#conn().dialect;
1649
+ const base = `DELETE FROM ${wrapSqlName(d, this.pivotTable)} WHERE ${wrapSqlName(d, this.foreignPivotKey)} = ? AND ${wrapSqlName(d, this.morphTypeColumn)} = ?`;
1650
+ const baseParams = [this.#parentId(), this.morphType];
953
1651
  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;
1652
+ return Number((await this.#conn().run(base, baseParams)) ?? 0);
958
1653
  }
959
1654
  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
- }
1655
+ return deletePivotIn(this.#conn(), base, baseParams, this.relatedPivotKey, list);
966
1656
  }
1657
+ async #currentIds() {
1658
+ const d = this.#conn().dialect;
1659
+ const rows = await this.#conn().all(`SELECT ${wrapSqlName(d, this.relatedPivotKey)} AS id FROM ${wrapSqlName(d, this.pivotTable)} WHERE ${wrapSqlName(d, this.foreignPivotKey)} = ? AND ${wrapSqlName(d, this.morphTypeColumn)} = ?`, [this.#parentId(), this.morphType]);
1660
+ return new Set(rows.map((r) => String(r.id)));
1661
+ }
1662
+ /** Detach missing ids, attach new ones; keeps rows that stay */
967
1663
  async sync(ids) {
968
- await this.detach();
969
- await this.attach(ids);
1664
+ const existing = await this.#currentIds();
1665
+ const wanted = new Set(ids.map(String));
1666
+ const detach = [...existing].filter((id) => !wanted.has(id));
1667
+ const attach = ids.filter((id) => !existing.has(String(id)));
1668
+ if (detach.length > 0)
1669
+ await this.detach(detach);
1670
+ if (attach.length > 0)
1671
+ await this.attach(attach);
970
1672
  }
971
1673
  async syncWithoutDetaching(ids) {
972
- const existing = new Set((await this.get()).map((m) => String(m[this.related.primaryKey])));
1674
+ const existing = await this.#currentIds();
973
1675
  const missing = ids.filter((id) => !existing.has(String(id)));
974
1676
  if (missing.length > 0)
975
1677
  await this.attach(missing);
976
1678
  }
977
1679
  async toggle(ids) {
978
1680
  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);
1681
+ const existing = await this.#currentIds();
1682
+ const detach = list.filter((id) => existing.has(String(id)));
1683
+ const attach = list.filter((id) => !existing.has(String(id)));
1684
+ if (detach.length > 0)
1685
+ await this.detach(detach);
1686
+ if (attach.length > 0)
1687
+ await this.attach(attach);
992
1688
  }
993
1689
  }
994
1690
  /** Cached relation meta per model class (avoids `new model()` + related() per compile). */
@@ -1019,6 +1715,17 @@ export function resolveRelation(model, relation) {
1019
1715
  localKey: rel.getLocalKeyName(),
1020
1716
  };
1021
1717
  }
1718
+ else if (rel instanceof MorphMany || rel instanceof MorphOne) {
1719
+ // Same shape as hasMany plus the morph type column.
1720
+ meta = {
1721
+ kind: "has",
1722
+ related: rel.getRelated(),
1723
+ foreignKey: rel.getIdColumn(),
1724
+ localKey: rel.getLocalKeyName(),
1725
+ typeColumn: rel.getTypeColumn(),
1726
+ morphType: rel.getMorphType(),
1727
+ };
1728
+ }
1022
1729
  else if (rel instanceof BelongsTo) {
1023
1730
  meta = {
1024
1731
  kind: "belongsTo",
@@ -1036,6 +1743,7 @@ export function resolveRelation(model, relation) {
1036
1743
  relatedPivotKey: rel.getRelatedPivotKeyName(),
1037
1744
  localKey: model.primaryKey,
1038
1745
  pivotColumns: rel.getPivotColumns(),
1746
+ pivotWheres: rel.getPivotWheres(),
1039
1747
  };
1040
1748
  }
1041
1749
  else if (rel instanceof MorphToMany) {
@@ -1052,6 +1760,19 @@ export function resolveRelation(model, relation) {
1052
1760
  parentTenantKey: rel.getParentTenantKey(),
1053
1761
  };
1054
1762
  }
1763
+ else if (rel instanceof MorphedByMany) {
1764
+ // Same shape as morphToMany: pivot filtered by morph type, parent key + related key columns.
1765
+ meta = {
1766
+ kind: "morphToMany",
1767
+ related: rel.getRelated(),
1768
+ pivotTable: rel.getPivotTable(),
1769
+ foreignPivotKey: rel.getForeignPivotKeyName(),
1770
+ relatedPivotKey: rel.getRelatedPivotKeyName(),
1771
+ localKey: model.primaryKey,
1772
+ morphTypeColumn: rel.getMorphTypeColumn(),
1773
+ morphTypes: [rel.getMorphType()],
1774
+ };
1775
+ }
1055
1776
  else if (rel instanceof HasManyThrough) {
1056
1777
  meta = {
1057
1778
  kind: "hasManyThrough",
@@ -1077,6 +1798,9 @@ export function resolveRelation(model, relation) {
1077
1798
  byName.set(relation, meta);
1078
1799
  return meta;
1079
1800
  }
1801
+ export function resolveAggregateRelation(model, relation) {
1802
+ return resolveRelation(model, relation);
1803
+ }
1080
1804
  export async function countRelation(model, relation) {
1081
1805
  const value = await aggregateRelation(model, relation, "count");
1082
1806
  return Number(value ?? 0);
@@ -1091,6 +1815,8 @@ export async function aggregateRelation(model, relation, fn, column) {
1091
1815
  const id = model[ctor.primaryKey];
1092
1816
  if (meta.kind === "has") {
1093
1817
  let q = meta.related.where(meta.foreignKey, id);
1818
+ if (meta.typeColumn)
1819
+ q = q.where(meta.typeColumn, meta.morphType);
1094
1820
  if (fn === "exists")
1095
1821
  return (await q.exists()) ? 1 : 0;
1096
1822
  if (fn === "count")