@bunyad/orm 0.2.0-beta.1 → 0.2.0-beta.3
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/README.md +1 -2
- package/dist/casts.d.ts +16 -3
- package/dist/casts.js +150 -4
- package/dist/eager.js +130 -11
- package/dist/factory.d.ts +20 -1
- package/dist/factory.js +143 -37
- package/dist/index.d.ts +21 -1
- package/dist/index.js +3 -1
- package/dist/model-events.d.ts +8 -2
- package/dist/model-events.js +27 -4
- package/dist/model-query.d.ts +75 -29
- package/dist/model-query.js +252 -133
- package/dist/model.d.ts +164 -34
- package/dist/model.js +708 -44
- package/dist/relations.d.ts +187 -11
- package/dist/relations.js +815 -89
- package/dist/schema-types.d.ts +27 -0
- package/dist/schema-types.js +145 -0
- package/dist/test-drivers.d.ts +10 -0
- package/dist/test-drivers.js +40 -0
- package/dist/typed-names.d.ts +53 -0
- package/dist/typed-names.js +4 -0
- package/package.json +3 -3
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
|
-
/**
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
+
const models = rows.all();
|
|
291
|
+
this.#hydrateInverse(models);
|
|
292
|
+
return new OrmCollection(models, { owned: true });
|
|
196
293
|
}
|
|
197
294
|
async first() {
|
|
198
|
-
|
|
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
|
-
|
|
532
|
+
const models = rows.all();
|
|
533
|
+
this.#hydrateInverse(models);
|
|
534
|
+
return new OrmCollection(models, { owned: true });
|
|
391
535
|
}
|
|
392
536
|
async first() {
|
|
393
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
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
|
|
813
|
-
return;
|
|
1311
|
+
const result = await this.#conn().run(base, [this.#parentId(), ...pw.params]);
|
|
1312
|
+
return Number(result ?? 0);
|
|
814
1313
|
}
|
|
815
|
-
const list =
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
/**
|
|
1386
|
+
/** `syncWithoutDetaching`. */
|
|
826
1387
|
async syncWithoutDetaching(ids) {
|
|
827
|
-
|
|
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
|
-
/**
|
|
1390
|
+
/** `toggle` — attach missing, detach present. */
|
|
833
1391
|
async toggle(ids) {
|
|
834
|
-
const
|
|
835
|
-
const existing =
|
|
836
|
-
const
|
|
837
|
-
const
|
|
838
|
-
for (const id of
|
|
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
|
-
|
|
1398
|
+
detached.push(id);
|
|
841
1399
|
else
|
|
842
|
-
|
|
1400
|
+
attach.push([id, attrs]);
|
|
843
1401
|
}
|
|
844
|
-
if (
|
|
845
|
-
await this.detach(
|
|
846
|
-
if (
|
|
847
|
-
await this.attach(
|
|
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
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
|
|
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
|
|
947
|
-
const
|
|
948
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
969
|
-
|
|
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 =
|
|
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 =
|
|
980
|
-
const
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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")
|