@rudderjs/orm 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +225 -0
- package/dist/aggregate.d.ts +95 -0
- package/dist/aggregate.d.ts.map +1 -0
- package/dist/aggregate.js +390 -0
- package/dist/aggregate.js.map +1 -0
- package/dist/commands/prune.d.ts +18 -0
- package/dist/commands/prune.d.ts.map +1 -0
- package/dist/commands/prune.js +43 -0
- package/dist/commands/prune.js.map +1 -0
- package/dist/index.d.ts +220 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +620 -5
- package/dist/index.js.map +1 -1
- package/dist/prune.d.ts +27 -0
- package/dist/prune.d.ts.map +1 -0
- package/dist/prune.js +65 -0
- package/dist/prune.js.map +1 -0
- package/package.json +7 -3
package/dist/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { castGet, castSet } from './cast.js';
|
|
2
|
+
import { AGGREGATES_SYMBOL, aggregateKeysOf, loadCountOrExists, loadMissingRelations, loadNumericAggregate, normalizeWithCount, normalizeWithExists, normalizeWithNumericAggregate, } from './aggregate.js';
|
|
2
3
|
export { Attribute } from './attribute.js';
|
|
3
4
|
export { JsonResource, ResourceCollection } from './resource.js';
|
|
4
5
|
export { ModelCollection } from './collection.js';
|
|
5
6
|
export { ModelFactory, sequence } from './factory.js';
|
|
6
7
|
export { Seeder } from './seeder.js';
|
|
8
|
+
export { AggregateConstraintBuilder, AGGREGATES_SYMBOL } from './aggregate.js';
|
|
9
|
+
export { pruneModels } from './prune.js';
|
|
7
10
|
// ─── Global ORM Registry ───────────────────────────────────
|
|
8
11
|
export class ModelRegistry {
|
|
9
12
|
static adapter = null;
|
|
@@ -180,6 +183,13 @@ export class Model {
|
|
|
180
183
|
* }
|
|
181
184
|
*/
|
|
182
185
|
static morphAlias;
|
|
186
|
+
/**
|
|
187
|
+
* Pruning mode for `pnpm rudder model:prune`. Override to `'mass'` for
|
|
188
|
+
* {@link MassPrunable}. The runner only considers models that also define
|
|
189
|
+
* `static prunable()`; this static just disambiguates instance- vs
|
|
190
|
+
* bulk-mode for those that do.
|
|
191
|
+
*/
|
|
192
|
+
static pruneMode = 'instance';
|
|
183
193
|
/**
|
|
184
194
|
* Column used to resolve a route parameter into a Model instance via
|
|
185
195
|
* {@link Model.findForRoute}. Defaults to the primary key. Override to
|
|
@@ -338,6 +348,16 @@ export class Model {
|
|
|
338
348
|
#instanceHidden;
|
|
339
349
|
/** @internal */
|
|
340
350
|
#instanceVisible;
|
|
351
|
+
// ── Dirty Tracking ─────────────────────────────────────
|
|
352
|
+
//
|
|
353
|
+
// Snapshot of attribute values as of the last load / save / refresh.
|
|
354
|
+
// Used by isDirty / isClean / wasChanged / getOriginal / getChanges /
|
|
355
|
+
// getDirty. Captured by hydrate(), save(), refresh(), and increment/
|
|
356
|
+
// decrement so the baseline always matches the persisted state.
|
|
357
|
+
/** @internal — own enumerable column values as of last load/save/refresh. */
|
|
358
|
+
#original = {};
|
|
359
|
+
/** @internal — diff of attributes that changed during the most recent save. */
|
|
360
|
+
#changes = {};
|
|
341
361
|
// ── Scopes ─────────────────────────────────────────────
|
|
342
362
|
static globalScopes = {};
|
|
343
363
|
static scopes = {};
|
|
@@ -428,16 +448,80 @@ export class Model {
|
|
|
428
448
|
const Ctor = this;
|
|
429
449
|
const instance = new Ctor();
|
|
430
450
|
Object.assign(instance, record);
|
|
451
|
+
instance._syncOriginal();
|
|
431
452
|
return instance;
|
|
432
453
|
}
|
|
433
454
|
/** @internal — wrap a QueryBuilder so its read methods return Model instances. */
|
|
434
455
|
static _hydratingQb(self, qb) {
|
|
435
456
|
const ModelClass = self;
|
|
436
|
-
|
|
457
|
+
/** Aliases stamped onto rows by the adapter for any aggregates registered
|
|
458
|
+
* on this QB. Tagged on each hydrated instance via `aggregateKeysOf` so
|
|
459
|
+
* `_toData()` excludes them on writes. */
|
|
460
|
+
const aggregateAliases = new Set();
|
|
461
|
+
const wrap = (r) => {
|
|
462
|
+
const inst = ModelClass.hydrate.call(self, r);
|
|
463
|
+
if (inst && aggregateAliases.size > 0) {
|
|
464
|
+
const set = aggregateKeysOf(inst);
|
|
465
|
+
for (const a of aggregateAliases)
|
|
466
|
+
set.add(a);
|
|
467
|
+
}
|
|
468
|
+
return inst;
|
|
469
|
+
};
|
|
437
470
|
const wrapMaybe = (r) => r == null ? null : wrap(r);
|
|
438
471
|
const wrapMany = (rs) => rs.map(wrap);
|
|
472
|
+
const dispatchAggregates = (reqs) => {
|
|
473
|
+
for (const r of reqs)
|
|
474
|
+
aggregateAliases.add(r.alias);
|
|
475
|
+
qb.withAggregate(reqs);
|
|
476
|
+
};
|
|
439
477
|
const proxy = new Proxy(qb, {
|
|
440
478
|
get(target, prop, receiver) {
|
|
479
|
+
// ORM-side chainables that don't exist on the adapter QB itself —
|
|
480
|
+
// intercept before the existence check below, since `whereHas` etc.
|
|
481
|
+
// are added by this proxy, not by the adapter.
|
|
482
|
+
if (prop === 'whereHas') {
|
|
483
|
+
return (relation, constrain) => {
|
|
484
|
+
_attachWhereHas(ModelClass, target, relation, true, constrain);
|
|
485
|
+
return proxy;
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
if (prop === 'whereDoesntHave') {
|
|
489
|
+
return (relation, constrain) => {
|
|
490
|
+
_attachWhereHas(ModelClass, target, relation, false, constrain);
|
|
491
|
+
return proxy;
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (prop === 'withWhereHas') {
|
|
495
|
+
return (relation, constrain) => {
|
|
496
|
+
_attachWithWhereHas(ModelClass, target, relation, constrain);
|
|
497
|
+
return proxy;
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (prop === 'whereBelongsTo') {
|
|
501
|
+
return (parent, relation) => {
|
|
502
|
+
_attachWhereBelongsTo(ModelClass, target, parent, relation);
|
|
503
|
+
return proxy;
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
if (prop === 'withCount') {
|
|
507
|
+
return (arg) => {
|
|
508
|
+
dispatchAggregates(normalizeWithCount(ModelClass, arg));
|
|
509
|
+
return proxy;
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
if (prop === 'withExists') {
|
|
513
|
+
return (arg) => {
|
|
514
|
+
dispatchAggregates(normalizeWithExists(ModelClass, arg));
|
|
515
|
+
return proxy;
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
if (prop === 'withSum' || prop === 'withMin' || prop === 'withMax' || prop === 'withAvg') {
|
|
519
|
+
const fn = prop.slice(4).toLowerCase();
|
|
520
|
+
return (arg1, arg2) => {
|
|
521
|
+
dispatchAggregates(normalizeWithNumericAggregate(ModelClass, fn, arg1, arg2));
|
|
522
|
+
return proxy;
|
|
523
|
+
};
|
|
524
|
+
}
|
|
441
525
|
const value = Reflect.get(target, prop, receiver);
|
|
442
526
|
if (typeof value !== 'function')
|
|
443
527
|
return value;
|
|
@@ -576,6 +660,120 @@ export class Model {
|
|
|
576
660
|
static where(column, value) {
|
|
577
661
|
return Model._q(this).where(column, value);
|
|
578
662
|
}
|
|
663
|
+
/**
|
|
664
|
+
* Filter rows where the named relation has at least one matching child.
|
|
665
|
+
* The optional callback receives a sub-QueryBuilder scoped to the related
|
|
666
|
+
* model — chain `.where()` etc. on it to narrow the relation predicate
|
|
667
|
+
* further.
|
|
668
|
+
*
|
|
669
|
+
* Resolves the relation declaration on `static relations`, builds a
|
|
670
|
+
* {@link RelationExistencePredicate}, and dispatches it to the adapter via
|
|
671
|
+
* `whereRelationExists`. `morphTo` relations are not supported (the related
|
|
672
|
+
* table is dynamic) — call sites should filter on the discriminator
|
|
673
|
+
* columns directly.
|
|
674
|
+
*
|
|
675
|
+
* @example
|
|
676
|
+
* await User.whereHas('posts').get() // users with at least one post
|
|
677
|
+
* await User.whereHas('posts', q => q.where('published', true)).get() // users with at least one published post
|
|
678
|
+
* await Post.whereHas('tags', q => q.where('name', 'featured')).get() // belongsToMany pivot path
|
|
679
|
+
* await Post.whereHas('comments').get() // morphMany — adds the {morph}Type filter automatically
|
|
680
|
+
*/
|
|
681
|
+
static whereHas(relation, constrain) {
|
|
682
|
+
return _attachWhereHas(this, Model._q(this), relation, true, constrain);
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Inverse of {@link Model.whereHas} — rows whose named relation has zero
|
|
686
|
+
* matching children. Same constrain-callback semantics: when present,
|
|
687
|
+
* narrows the "matching" set so `whereDoesntHave` matches "no children
|
|
688
|
+
* matching the constraint" rather than "no children at all".
|
|
689
|
+
*/
|
|
690
|
+
static whereDoesntHave(relation, constrain) {
|
|
691
|
+
return _attachWhereHas(this, Model._q(this), relation, false, constrain);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* `whereHas` + `with` — filter by the relation predicate AND eager-load the
|
|
695
|
+
* matching rows under the same constraint when the adapter supports it
|
|
696
|
+
* (`withConstrained`). Adapters without constrained eager-load fall back to
|
|
697
|
+
* unconstrained `with(relation)` — every related row is returned even if
|
|
698
|
+
* the parent was matched on a narrower predicate.
|
|
699
|
+
*/
|
|
700
|
+
static withWhereHas(relation, constrain) {
|
|
701
|
+
return _attachWithWhereHas(this, Model._q(this), relation, constrain);
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Filter rows whose `belongsTo` relation points at `parent`. Sugar for
|
|
705
|
+
* `where(fk, parent.primaryKeyValue)` with the FK column resolved from the
|
|
706
|
+
* relation declaration. When `relation` is omitted, looks up the single
|
|
707
|
+
* `belongsTo` relation pointing at `parent.constructor` and throws if zero
|
|
708
|
+
* or more-than-one match.
|
|
709
|
+
*
|
|
710
|
+
* @example
|
|
711
|
+
* await Post.whereBelongsTo(user).get() // posts whose author belongsTo this user (single FK)
|
|
712
|
+
* await Comment.whereBelongsTo(post, 'post').get() // explicit relation name when ambiguous
|
|
713
|
+
*/
|
|
714
|
+
static whereBelongsTo(parent, relation) {
|
|
715
|
+
return _attachWhereBelongsTo(this, Model._q(this), parent, relation);
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Aggregate eager-loading — count related rows alongside the parent in a
|
|
719
|
+
* single query. The result is stamped onto each parent under
|
|
720
|
+
* `<relation>Count` (`postsCount` for `withCount('posts')`) without dropping
|
|
721
|
+
* into the adapter.
|
|
722
|
+
*
|
|
723
|
+
* Three call shapes:
|
|
724
|
+
* - `withCount('posts')` — single relation, no constraint.
|
|
725
|
+
* - `withCount(['posts', 'comments'])` — multiple, no constraints.
|
|
726
|
+
* - `withCount({ posts: q => q.where('published', true).as('publishedPosts') })`
|
|
727
|
+
* — map form with `where`/`orWhere` constraints + optional alias override.
|
|
728
|
+
*
|
|
729
|
+
* Closes the N+1 footgun for hot list pages. For a single instance use
|
|
730
|
+
* `instance.loadCount('posts')` instead.
|
|
731
|
+
*
|
|
732
|
+
* @example
|
|
733
|
+
* await User.query().withCount('posts').get() // user.postsCount
|
|
734
|
+
* await User.query().withCount({ posts: q => q.where('published', true) }).get()
|
|
735
|
+
* await Post.query().withCount(['comments', 'tags']).paginate(1)
|
|
736
|
+
*/
|
|
737
|
+
static withCount(arg) {
|
|
738
|
+
return Model._q(this).withCount(arg);
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Boolean aggregate — stamps `<relation>Exists` (true/false) onto each
|
|
742
|
+
* parent. Cheap on Prisma (translates to `_count > 0`) and Drizzle
|
|
743
|
+
* (`EXISTS (...)` correlated subquery). Use this instead of `withCount`
|
|
744
|
+
* when you only need presence, not the count.
|
|
745
|
+
*/
|
|
746
|
+
static withExists(arg) {
|
|
747
|
+
return Model._q(this).withExists(arg);
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Aggregate eager-loading — sum a column across the related rows.
|
|
751
|
+
* Stamps `<relation>Sum<Column>` onto each parent (e.g.
|
|
752
|
+
* `withSum('orders', 'total')` → `ordersSumTotal`).
|
|
753
|
+
*
|
|
754
|
+
* Map form supports per-relation constraints + alias overrides:
|
|
755
|
+
*
|
|
756
|
+
* ```ts
|
|
757
|
+
* await User.query().withSum({
|
|
758
|
+
* orders: { column: 'total', constraint: q => q.where('status', 'paid') },
|
|
759
|
+
* }).get()
|
|
760
|
+
* ```
|
|
761
|
+
*/
|
|
762
|
+
static withSum(arg1, column) {
|
|
763
|
+
return Model._q(this).withSum(arg1, column);
|
|
764
|
+
}
|
|
765
|
+
/** Min of a column across the related rows. Stamps `<relation>Min<Column>`. */
|
|
766
|
+
static withMin(arg1, column) {
|
|
767
|
+
return Model._q(this).withMin(arg1, column);
|
|
768
|
+
}
|
|
769
|
+
/** Max of a column across the related rows. Stamps `<relation>Max<Column>`. */
|
|
770
|
+
static withMax(arg1, column) {
|
|
771
|
+
return Model._q(this).withMax(arg1, column);
|
|
772
|
+
}
|
|
773
|
+
/** Average of a column across the related rows. Stamps `<relation>Avg<Column>`. */
|
|
774
|
+
static withAvg(arg1, column) {
|
|
775
|
+
return Model._q(this).withAvg(arg1, column);
|
|
776
|
+
}
|
|
579
777
|
/**
|
|
580
778
|
* Find a record by attributes; if none exists, create one with `attrs` merged with `values`.
|
|
581
779
|
* Returns the existing or newly-created record.
|
|
@@ -765,21 +963,39 @@ export class Model {
|
|
|
765
963
|
return value;
|
|
766
964
|
}
|
|
767
965
|
/**
|
|
768
|
-
* @internal — own
|
|
769
|
-
*
|
|
770
|
-
*
|
|
966
|
+
* @internal — current own-property column attributes, with framework `_`
|
|
967
|
+
* keys + `undefined` placeholders dropped. Aggregate-injected keys (stamped
|
|
968
|
+
* by `withCount` / `loadCount` etc.) are excluded — they're not real schema
|
|
969
|
+
* columns and would be rejected by Prisma writes / Drizzle inserts.
|
|
970
|
+
*
|
|
971
|
+
* Single source of truth shared by `_toData()` and dirty-tracking baselines.
|
|
771
972
|
*/
|
|
772
|
-
|
|
973
|
+
_currentAttrs() {
|
|
773
974
|
const out = {};
|
|
975
|
+
const aggregates = this[AGGREGATES_SYMBOL];
|
|
774
976
|
for (const [k, v] of Object.entries(this)) {
|
|
775
977
|
if (k.startsWith('_'))
|
|
776
978
|
continue;
|
|
777
979
|
if (v === undefined)
|
|
778
980
|
continue;
|
|
981
|
+
if (aggregates && aggregates.has(k))
|
|
982
|
+
continue;
|
|
779
983
|
out[k] = v;
|
|
780
984
|
}
|
|
781
985
|
return out;
|
|
782
986
|
}
|
|
987
|
+
/**
|
|
988
|
+
* @internal — own enumerable data fields, with framework-internal `_` keys
|
|
989
|
+
* stripped and `undefined` values dropped so a class-declared but never-set
|
|
990
|
+
* field (`id!: number`) doesn't leak into a create/update payload.
|
|
991
|
+
*/
|
|
992
|
+
_toData() {
|
|
993
|
+
return this._currentAttrs();
|
|
994
|
+
}
|
|
995
|
+
/** @internal — capture current attrs as the new dirty-tracking baseline. */
|
|
996
|
+
_syncOriginal() {
|
|
997
|
+
this.#original = this._currentAttrs();
|
|
998
|
+
}
|
|
783
999
|
/**
|
|
784
1000
|
* Persist this instance. Inserts when the primary key is unset; otherwise updates.
|
|
785
1001
|
*
|
|
@@ -797,6 +1013,14 @@ export class Model {
|
|
|
797
1013
|
? await Model._doCreate.call(ctor, data)
|
|
798
1014
|
: await Model._doUpdate.call(ctor, id, data);
|
|
799
1015
|
Object.assign(this, persisted);
|
|
1016
|
+
const next = this._currentAttrs();
|
|
1017
|
+
const diff = {};
|
|
1018
|
+
for (const k of new Set([...Object.keys(next), ...Object.keys(this.#original)])) {
|
|
1019
|
+
if (!_attrEqual(next[k], this.#original[k]))
|
|
1020
|
+
diff[k] = next[k];
|
|
1021
|
+
}
|
|
1022
|
+
this.#changes = diff;
|
|
1023
|
+
this.#original = next;
|
|
800
1024
|
return this;
|
|
801
1025
|
}
|
|
802
1026
|
/**
|
|
@@ -840,6 +1064,8 @@ export class Model {
|
|
|
840
1064
|
delete this[k];
|
|
841
1065
|
}
|
|
842
1066
|
Object.assign(this, fresh);
|
|
1067
|
+
this.#changes = {};
|
|
1068
|
+
this._syncOriginal();
|
|
843
1069
|
return this;
|
|
844
1070
|
}
|
|
845
1071
|
/**
|
|
@@ -854,6 +1080,42 @@ export class Model {
|
|
|
854
1080
|
}
|
|
855
1081
|
await ctor.delete(id);
|
|
856
1082
|
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Restore this soft-deleted instance — clears `deletedAt` and routes through
|
|
1085
|
+
* the static `restore()` so observers fire. Refreshes in-place with the
|
|
1086
|
+
* canonical row returned from the database.
|
|
1087
|
+
*/
|
|
1088
|
+
async restore() {
|
|
1089
|
+
const ctor = this.constructor;
|
|
1090
|
+
const id = this._getKey();
|
|
1091
|
+
if (id === undefined) {
|
|
1092
|
+
throw new Error(`[RudderJS ORM] Cannot restore a ${ctor.name} without a primary key.`);
|
|
1093
|
+
}
|
|
1094
|
+
const restored = await ctor.restore(id);
|
|
1095
|
+
Object.assign(this, restored);
|
|
1096
|
+
this._syncOriginal();
|
|
1097
|
+
return this;
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Persist this instance without firing observer / listener events.
|
|
1101
|
+
* Equivalent to `await Model.withoutEvents(() => instance.save())`.
|
|
1102
|
+
*
|
|
1103
|
+
* Per-class — observers cascading into child classes still fire normally.
|
|
1104
|
+
*/
|
|
1105
|
+
async saveQuietly() {
|
|
1106
|
+
const ctor = this.constructor;
|
|
1107
|
+
return ctor.withoutEvents(() => this.save());
|
|
1108
|
+
}
|
|
1109
|
+
/** Delete this instance without firing observer / listener events. */
|
|
1110
|
+
async deleteQuietly() {
|
|
1111
|
+
const ctor = this.constructor;
|
|
1112
|
+
await ctor.withoutEvents(() => this.delete());
|
|
1113
|
+
}
|
|
1114
|
+
/** Restore this soft-deleted instance without firing observer / listener events. */
|
|
1115
|
+
async restoreQuietly() {
|
|
1116
|
+
const ctor = this.constructor;
|
|
1117
|
+
return ctor.withoutEvents(() => this.restore());
|
|
1118
|
+
}
|
|
857
1119
|
/**
|
|
858
1120
|
* Atomically add `amount` to `column` on this instance. The row is updated
|
|
859
1121
|
* via SQL `UPDATE col = col + amount` and the new value is merged back into
|
|
@@ -870,6 +1132,67 @@ export class Model {
|
|
|
870
1132
|
}
|
|
871
1133
|
const updated = await ctor.increment(id, column, amount, extra);
|
|
872
1134
|
Object.assign(this, updated);
|
|
1135
|
+
this._syncOriginal();
|
|
1136
|
+
return this;
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Aggregate-load related rows for this single instance. Mutates in place
|
|
1140
|
+
* by setting `this[<relation>Count]` (or the `.as(name)` override) and
|
|
1141
|
+
* returns `this` for chaining.
|
|
1142
|
+
*
|
|
1143
|
+
* One round-trip per call. For batched loads on a list, prefer
|
|
1144
|
+
* `Model.query().withCount(...)` on the parent query.
|
|
1145
|
+
*
|
|
1146
|
+
* @example
|
|
1147
|
+
* const user = await User.find(1)
|
|
1148
|
+
* await user!.loadCount('posts')
|
|
1149
|
+
* console.log(user!.postsCount)
|
|
1150
|
+
*
|
|
1151
|
+
* await user!.loadCount({ posts: q => q.where('published', true).as('publishedPosts') })
|
|
1152
|
+
* console.log(user!.publishedPostsCount)
|
|
1153
|
+
*/
|
|
1154
|
+
async loadCount(arg) {
|
|
1155
|
+
await loadCountOrExists(this, 'count', arg);
|
|
1156
|
+
return this;
|
|
1157
|
+
}
|
|
1158
|
+
/** Boolean aggregate — stamps `<relation>Exists` on the instance. */
|
|
1159
|
+
async loadExists(arg) {
|
|
1160
|
+
await loadCountOrExists(this, 'exists', arg);
|
|
1161
|
+
return this;
|
|
1162
|
+
}
|
|
1163
|
+
/** Sum of `column` across the related rows. Stamps `<relation>Sum<Column>`. */
|
|
1164
|
+
async loadSum(arg1, column) {
|
|
1165
|
+
await loadNumericAggregate(this, 'sum', arg1, column);
|
|
1166
|
+
return this;
|
|
1167
|
+
}
|
|
1168
|
+
/** Min of `column` across the related rows. Stamps `<relation>Min<Column>`. */
|
|
1169
|
+
async loadMin(arg1, column) {
|
|
1170
|
+
await loadNumericAggregate(this, 'min', arg1, column);
|
|
1171
|
+
return this;
|
|
1172
|
+
}
|
|
1173
|
+
/** Max of `column` across the related rows. Stamps `<relation>Max<Column>`. */
|
|
1174
|
+
async loadMax(arg1, column) {
|
|
1175
|
+
await loadNumericAggregate(this, 'max', arg1, column);
|
|
1176
|
+
return this;
|
|
1177
|
+
}
|
|
1178
|
+
/** Average of `column` across the related rows. Stamps `<relation>Avg<Column>`. */
|
|
1179
|
+
async loadAvg(arg1, column) {
|
|
1180
|
+
await loadNumericAggregate(this, 'avg', arg1, column);
|
|
1181
|
+
return this;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Eager-load each named relation onto the instance only when the property
|
|
1185
|
+
* is currently `null` / `undefined`. Truthy properties are skipped — useful
|
|
1186
|
+
* after partial hydration to backfill the relations a downstream consumer
|
|
1187
|
+
* needs without refetching what's already there.
|
|
1188
|
+
*
|
|
1189
|
+
* @example
|
|
1190
|
+
* const user = await User.query().with('profile').first()
|
|
1191
|
+
* // profile already populated; only `posts` issues a query.
|
|
1192
|
+
* await user!.loadMissing('profile', 'posts')
|
|
1193
|
+
*/
|
|
1194
|
+
async loadMissing(...relations) {
|
|
1195
|
+
await loadMissingRelations(this, relations);
|
|
873
1196
|
return this;
|
|
874
1197
|
}
|
|
875
1198
|
/**
|
|
@@ -884,6 +1207,7 @@ export class Model {
|
|
|
884
1207
|
}
|
|
885
1208
|
const updated = await ctor.decrement(id, column, amount, extra);
|
|
886
1209
|
Object.assign(this, updated);
|
|
1210
|
+
this._syncOriginal();
|
|
887
1211
|
return this;
|
|
888
1212
|
}
|
|
889
1213
|
/**
|
|
@@ -917,6 +1241,47 @@ export class Model {
|
|
|
917
1241
|
}
|
|
918
1242
|
return clone;
|
|
919
1243
|
}
|
|
1244
|
+
// ── Dirty Tracking ─────────────────────────────────────
|
|
1245
|
+
/**
|
|
1246
|
+
* Whether any attribute (or the named attribute) has been changed since
|
|
1247
|
+
* the last save / load / refresh.
|
|
1248
|
+
*/
|
|
1249
|
+
isDirty(key) {
|
|
1250
|
+
const dirty = this.getDirty();
|
|
1251
|
+
return key === undefined ? Object.keys(dirty).length > 0 : key in dirty;
|
|
1252
|
+
}
|
|
1253
|
+
/** Inverse of {@link isDirty}. */
|
|
1254
|
+
isClean(key) {
|
|
1255
|
+
return !this.isDirty(key);
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Whether the named attribute (or any attribute) was actually changed on
|
|
1259
|
+
* the most recent {@link save}. Stays true until the next save or refresh.
|
|
1260
|
+
*/
|
|
1261
|
+
wasChanged(key) {
|
|
1262
|
+
return key === undefined
|
|
1263
|
+
? Object.keys(this.#changes).length > 0
|
|
1264
|
+
: key in this.#changes;
|
|
1265
|
+
}
|
|
1266
|
+
getOriginal(key) {
|
|
1267
|
+
if (key === undefined)
|
|
1268
|
+
return { ...this.#original };
|
|
1269
|
+
return this.#original[key];
|
|
1270
|
+
}
|
|
1271
|
+
/** Diff map of attributes that changed during the most recent {@link save}. */
|
|
1272
|
+
getChanges() {
|
|
1273
|
+
return { ...this.#changes };
|
|
1274
|
+
}
|
|
1275
|
+
/** Diff map of attributes currently dirty (unsaved). */
|
|
1276
|
+
getDirty() {
|
|
1277
|
+
const out = {};
|
|
1278
|
+
const current = this._currentAttrs();
|
|
1279
|
+
for (const k of new Set([...Object.keys(current), ...Object.keys(this.#original)])) {
|
|
1280
|
+
if (!_attrEqual(current[k], this.#original[k]))
|
|
1281
|
+
out[k] = current[k];
|
|
1282
|
+
}
|
|
1283
|
+
return out;
|
|
1284
|
+
}
|
|
920
1285
|
/**
|
|
921
1286
|
* True when `other` represents the same record — same model class (by table)
|
|
922
1287
|
* and same primary key.
|
|
@@ -1293,6 +1658,34 @@ export class Model {
|
|
|
1293
1658
|
return Object.fromEntries(Object.entries(result).filter(([k]) => !effectiveHidden.includes(k)));
|
|
1294
1659
|
}
|
|
1295
1660
|
}
|
|
1661
|
+
// ─── Dirty tracking equality ───────────────────────────────
|
|
1662
|
+
/**
|
|
1663
|
+
* @internal — value equality used by dirty tracking. Mirrors Eloquent's
|
|
1664
|
+
* `originalIsEquivalent`: strict for primitives, `getTime()` for Date,
|
|
1665
|
+
* structural-by-JSON for arrays / plain objects (covers `json` / `array`
|
|
1666
|
+
* casts), null/undefined collapsed.
|
|
1667
|
+
*
|
|
1668
|
+
* Caveat: JSON.stringify is key-order sensitive, so `{a:1,b:2}` vs
|
|
1669
|
+
* `{b:2,a:1}` compares unequal. Same posture Laravel takes — documented
|
|
1670
|
+
* in the Dirty Tracking section of the orm README.
|
|
1671
|
+
*/
|
|
1672
|
+
function _attrEqual(a, b) {
|
|
1673
|
+
if (a === b)
|
|
1674
|
+
return true;
|
|
1675
|
+
if (a == null && b == null)
|
|
1676
|
+
return true;
|
|
1677
|
+
if (a instanceof Date && b instanceof Date)
|
|
1678
|
+
return a.getTime() === b.getTime();
|
|
1679
|
+
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
|
|
1680
|
+
try {
|
|
1681
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1682
|
+
}
|
|
1683
|
+
catch {
|
|
1684
|
+
return false;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return false;
|
|
1688
|
+
}
|
|
1296
1689
|
function _camelHead(s) {
|
|
1297
1690
|
return s.charAt(0).toLowerCase() + s.slice(1);
|
|
1298
1691
|
}
|
|
@@ -1339,6 +1732,228 @@ function _resolveMorphedByManyMeta(Parent, Related, def) {
|
|
|
1339
1732
|
relatedKey: def.relatedKey ?? Related.primaryKey,
|
|
1340
1733
|
};
|
|
1341
1734
|
}
|
|
1735
|
+
// ─── whereHas internals ────────────────────────────────────
|
|
1736
|
+
/**
|
|
1737
|
+
* Run the constrain callback against a recording-only QueryBuilder that
|
|
1738
|
+
* captures `.where()` calls into a flat `WhereClause[]` and treats every
|
|
1739
|
+
* other chainable method as a no-op. Nested `whereHas` inside the callback
|
|
1740
|
+
* throws — recursive predicates are deferred to v2.
|
|
1741
|
+
*/
|
|
1742
|
+
function _captureConstraintWheres(constrain) {
|
|
1743
|
+
const wheres = [];
|
|
1744
|
+
const recorder = new Proxy({}, {
|
|
1745
|
+
get(_t, prop) {
|
|
1746
|
+
const name = String(prop);
|
|
1747
|
+
if (name === 'where') {
|
|
1748
|
+
return (col, opOrVal, maybeVal) => {
|
|
1749
|
+
if (maybeVal === undefined) {
|
|
1750
|
+
wheres.push({ column: col, operator: '=', value: opOrVal });
|
|
1751
|
+
}
|
|
1752
|
+
else {
|
|
1753
|
+
wheres.push({ column: col, operator: opOrVal, value: maybeVal });
|
|
1754
|
+
}
|
|
1755
|
+
return recorder;
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
if (name === 'whereHas' || name === 'whereDoesntHave' || name === 'withWhereHas') {
|
|
1759
|
+
return () => {
|
|
1760
|
+
throw new Error(`[RudderJS ORM] Nested ${name} inside a whereHas constrain callback is deferred to v2. ` +
|
|
1761
|
+
`Filter on flat columns inside the callback for now.`);
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
// All other chainable methods record nothing and return the recorder so
|
|
1765
|
+
// `q.orderBy('x').limit(1)` chains through silently. Terminal methods
|
|
1766
|
+
// (find/get/etc.) don't make sense in a constrain callback — they'd
|
|
1767
|
+
// execute mid-build — but we don't intercept them here; they'd just
|
|
1768
|
+
// return the recorder which then fails downstream. Keep the contract
|
|
1769
|
+
// simple.
|
|
1770
|
+
return () => recorder;
|
|
1771
|
+
},
|
|
1772
|
+
});
|
|
1773
|
+
constrain(recorder);
|
|
1774
|
+
return wheres;
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Resolve the `belongsTo` relation declaration on `Self` that points at
|
|
1778
|
+
* `ParentCtor`. When `relation` is given, looks it up directly. Otherwise
|
|
1779
|
+
* scans `Self.relations` for a single `belongsTo` whose `model()` resolves
|
|
1780
|
+
* to `ParentCtor` — throws on zero or multiple candidates.
|
|
1781
|
+
*/
|
|
1782
|
+
function _resolveBelongsToFor(Self, ParentCtor, relation) {
|
|
1783
|
+
if (relation !== undefined) {
|
|
1784
|
+
const def = Self.relations[relation];
|
|
1785
|
+
if (!def)
|
|
1786
|
+
throw new Error(`[RudderJS ORM] Relation "${relation}" is not defined on ${Self.name}.`);
|
|
1787
|
+
if (def.type !== 'belongsTo') {
|
|
1788
|
+
throw new Error(`[RudderJS ORM] Relation "${relation}" on ${Self.name} is "${def.type}", not "belongsTo".`);
|
|
1789
|
+
}
|
|
1790
|
+
return def;
|
|
1791
|
+
}
|
|
1792
|
+
const candidates = [];
|
|
1793
|
+
for (const [name, def] of Object.entries(Self.relations)) {
|
|
1794
|
+
if (def.type !== 'belongsTo')
|
|
1795
|
+
continue;
|
|
1796
|
+
const btDef = def;
|
|
1797
|
+
if (btDef.model() === ParentCtor)
|
|
1798
|
+
candidates.push([name, btDef]);
|
|
1799
|
+
}
|
|
1800
|
+
if (candidates.length === 0) {
|
|
1801
|
+
throw new Error(`[RudderJS ORM] whereBelongsTo: ${Self.name} has no belongsTo relation pointing at ${ParentCtor.name}. ` +
|
|
1802
|
+
`Pass a relation name explicitly.`);
|
|
1803
|
+
}
|
|
1804
|
+
if (candidates.length > 1) {
|
|
1805
|
+
const names = candidates.map(([n]) => n).join(', ');
|
|
1806
|
+
throw new Error(`[RudderJS ORM] whereBelongsTo: ${Self.name} has multiple belongsTo relations pointing at ${ParentCtor.name} (${names}). ` +
|
|
1807
|
+
`Pass the relation name explicitly.`);
|
|
1808
|
+
}
|
|
1809
|
+
return candidates[0][1];
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Build the {@link RelationExistencePredicate} for a relation declared on
|
|
1813
|
+
* `Parent` by `relation` and dispatch it to the adapter via
|
|
1814
|
+
* `q.whereRelationExists(predicate)`. Returns the same QueryBuilder for
|
|
1815
|
+
* chaining (the adapter mutates in place).
|
|
1816
|
+
*
|
|
1817
|
+
* `morphTo` throws — the related table isn't statically known so a single
|
|
1818
|
+
* EXISTS subquery can't represent it. Filter on the discriminator + id
|
|
1819
|
+
* columns directly when you need that semantic.
|
|
1820
|
+
*/
|
|
1821
|
+
function _attachWhereHas(Parent, q, relation, exists, constrain) {
|
|
1822
|
+
const def = Parent.relations[relation];
|
|
1823
|
+
if (!def) {
|
|
1824
|
+
throw new Error(`[RudderJS ORM] Relation "${relation}" is not defined on ${Parent.name}.`);
|
|
1825
|
+
}
|
|
1826
|
+
if (def.type === 'morphTo') {
|
|
1827
|
+
throw new Error(`[RudderJS ORM] morphTo "${relation}" cannot be used with whereHas — the related table is dynamic. ` +
|
|
1828
|
+
`Filter on ${def.morphName}Id / ${def.morphName}Type directly instead.`);
|
|
1829
|
+
}
|
|
1830
|
+
const constraintWheres = constrain ? _captureConstraintWheres(constrain) : [];
|
|
1831
|
+
const predicate = _buildRelationPredicate(Parent, relation, def, exists, constraintWheres);
|
|
1832
|
+
return q.whereRelationExists(predicate);
|
|
1833
|
+
}
|
|
1834
|
+
function _buildRelationPredicate(Parent, relation, def, exists, constraintWheres) {
|
|
1835
|
+
const Related = def.model();
|
|
1836
|
+
if (def.type === 'belongsToMany') {
|
|
1837
|
+
const meta = _resolveBelongsToManyMeta(Parent, Related, def);
|
|
1838
|
+
return {
|
|
1839
|
+
relation,
|
|
1840
|
+
exists,
|
|
1841
|
+
relatedTable: Related.getTable(),
|
|
1842
|
+
parentColumn: meta.parentKey,
|
|
1843
|
+
relatedColumn: meta.relatedKey,
|
|
1844
|
+
constraintWheres,
|
|
1845
|
+
through: {
|
|
1846
|
+
pivotTable: meta.pivotTable,
|
|
1847
|
+
foreignPivotKey: meta.foreignPivotKey,
|
|
1848
|
+
relatedPivotKey: meta.relatedPivotKey,
|
|
1849
|
+
},
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
if (def.type === 'morphToMany') {
|
|
1853
|
+
const meta = _resolveMorphToManyMeta(Parent, Related, def);
|
|
1854
|
+
return {
|
|
1855
|
+
relation,
|
|
1856
|
+
exists,
|
|
1857
|
+
relatedTable: Related.getTable(),
|
|
1858
|
+
parentColumn: meta.parentKey,
|
|
1859
|
+
relatedColumn: meta.relatedKey,
|
|
1860
|
+
constraintWheres,
|
|
1861
|
+
extraEquals: { [meta.morphTypeKey]: meta.morphTypeValue },
|
|
1862
|
+
through: {
|
|
1863
|
+
pivotTable: meta.pivotTable,
|
|
1864
|
+
foreignPivotKey: meta.foreignPivotKey,
|
|
1865
|
+
relatedPivotKey: meta.relatedPivotKey,
|
|
1866
|
+
},
|
|
1867
|
+
};
|
|
1868
|
+
}
|
|
1869
|
+
if (def.type === 'morphedByMany') {
|
|
1870
|
+
const meta = _resolveMorphedByManyMeta(Parent, Related, def);
|
|
1871
|
+
return {
|
|
1872
|
+
relation,
|
|
1873
|
+
exists,
|
|
1874
|
+
relatedTable: Related.getTable(),
|
|
1875
|
+
parentColumn: meta.parentKey,
|
|
1876
|
+
relatedColumn: meta.relatedKey,
|
|
1877
|
+
constraintWheres,
|
|
1878
|
+
extraEquals: { [meta.morphTypeKey]: meta.morphTypeValue },
|
|
1879
|
+
through: {
|
|
1880
|
+
pivotTable: meta.pivotTable,
|
|
1881
|
+
foreignPivotKey: meta.foreignPivotKey,
|
|
1882
|
+
relatedPivotKey: meta.relatedPivotKey,
|
|
1883
|
+
},
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
if (def.type === 'morphMany' || def.type === 'morphOne') {
|
|
1887
|
+
const idCol = `${def.morphName}Id`;
|
|
1888
|
+
const typeCol = `${def.morphName}Type`;
|
|
1889
|
+
const localCol = def.localKey ?? Parent.primaryKey;
|
|
1890
|
+
const typeVal = def.morphType ?? Parent.morphAlias ?? Parent.name;
|
|
1891
|
+
return {
|
|
1892
|
+
relation,
|
|
1893
|
+
exists,
|
|
1894
|
+
relatedTable: Related.getTable(),
|
|
1895
|
+
parentColumn: localCol,
|
|
1896
|
+
relatedColumn: idCol,
|
|
1897
|
+
constraintWheres,
|
|
1898
|
+
extraEquals: { [typeCol]: typeVal },
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
if (def.type === 'belongsTo') {
|
|
1902
|
+
const fk = def.foreignKey ?? `${_camelHead(Related.name)}Id`;
|
|
1903
|
+
const localCol = def.localKey ?? fk;
|
|
1904
|
+
return {
|
|
1905
|
+
relation,
|
|
1906
|
+
exists,
|
|
1907
|
+
relatedTable: Related.getTable(),
|
|
1908
|
+
parentColumn: localCol,
|
|
1909
|
+
relatedColumn: Related.primaryKey,
|
|
1910
|
+
constraintWheres,
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
// hasOne / hasMany — related table holds the FK pointing back to Parent.
|
|
1914
|
+
const fk = def.foreignKey ?? `${_camelHead(Parent.name)}Id`;
|
|
1915
|
+
const localCol = def.localKey ?? Parent.primaryKey;
|
|
1916
|
+
return {
|
|
1917
|
+
relation,
|
|
1918
|
+
exists,
|
|
1919
|
+
relatedTable: Related.getTable(),
|
|
1920
|
+
parentColumn: localCol,
|
|
1921
|
+
relatedColumn: fk,
|
|
1922
|
+
constraintWheres,
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* `withWhereHas` — run `whereHas` AND eager-load the relation under the
|
|
1927
|
+
* same constraint when the adapter implements `withConstrained`. Adapters
|
|
1928
|
+
* without it fall back to plain `with(relation)` (constraint applies only
|
|
1929
|
+
* to the parent filter, not the eagerly loaded children).
|
|
1930
|
+
*/
|
|
1931
|
+
function _attachWithWhereHas(Parent, q, relation, constrain) {
|
|
1932
|
+
const constraintWheres = constrain ? _captureConstraintWheres(constrain) : [];
|
|
1933
|
+
// Reuse _attachWhereHas for the parent-side filter — it re-runs the
|
|
1934
|
+
// constrain callback against a fresh recorder, so the WhereClause[] we
|
|
1935
|
+
// capture above and the one captured inside _attachWhereHas come from
|
|
1936
|
+
// distinct recorder instances. That's intentional: each captures the
|
|
1937
|
+
// same constraint independently and neither is mutated by the adapter.
|
|
1938
|
+
_attachWhereHas(Parent, q, relation, true, constrain);
|
|
1939
|
+
const withConstrained = q.withConstrained;
|
|
1940
|
+
if (constraintWheres.length > 0 && typeof withConstrained === 'function') {
|
|
1941
|
+
return withConstrained.call(q, relation, constraintWheres);
|
|
1942
|
+
}
|
|
1943
|
+
return q.with(relation);
|
|
1944
|
+
}
|
|
1945
|
+
function _attachWhereBelongsTo(Self, q, parent, relation) {
|
|
1946
|
+
const ParentCtor = parent.constructor;
|
|
1947
|
+
const def = _resolveBelongsToFor(Self, ParentCtor, relation);
|
|
1948
|
+
const Related = def.model();
|
|
1949
|
+
const fk = def.foreignKey ?? `${_camelHead(Related.name)}Id`;
|
|
1950
|
+
const localCol = def.localKey ?? fk;
|
|
1951
|
+
const parentVal = parent[ParentCtor.primaryKey];
|
|
1952
|
+
if (parentVal === undefined || parentVal === null) {
|
|
1953
|
+
throw new Error(`[RudderJS ORM] whereBelongsTo: parent.${ParentCtor.primaryKey} is unset on ${ParentCtor.name}.`);
|
|
1954
|
+
}
|
|
1955
|
+
return q.where(localCol, parentVal);
|
|
1956
|
+
}
|
|
1342
1957
|
const _CHAIN_METHODS = new Set([
|
|
1343
1958
|
'where', 'orWhere', 'orderBy', 'limit', 'offset', 'with', 'withTrashed', 'onlyTrashed',
|
|
1344
1959
|
]);
|