@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/model.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { DatabaseManager as Db, setDefaultConnection, connections, LengthAwarePaginator, CursorPaginator, Paginator, dateTimeForStorage, withoutQueryWriteHook, wrapSqlName, } from "@bunyad/database";
2
- import { Collection } from "@bunyad/common";
2
+ import { Collection, LazyCollection } from "@bunyad/common";
3
3
  import { OrmCollection } from "./orm-collection.js";
4
- import { Attribute, castFromStorage, castToStorage, hashCastValue, isAlreadyHashed, enumFromStorage, enumToStorage, isAttribute, isCastType, isEnumCast, } from "./casts.js";
4
+ import { Attribute, castFromStorage, castToStorage, hashCastValue, isAlreadyHashed, enumFromStorage, enumToStorage, isAttribute, isCastType, parseCast, formatPhpDate, isEnumCast, } from "./casts.js";
5
5
  import { bootIfNotBooted, fireModelEvent, hasAnyModelEventListeners, hasModelEventListeners, observeModel, registerModelEvent, withoutModelEvents, } from "./model-events.js";
6
6
  import { resolveModelBuilder } from "./decorators.js";
7
7
  import { addGlobalScope as registerGlobalScope, getGlobalScopes, hasGlobalScopes, installLocalScopeCallStatic, removeGlobalScope as unregisterGlobalScope, } from "./scopes.js";
8
8
  import { deletedAtColumn, normalizeWithRelations, nowForConnection, parseRelationAlias, singular, usesSoftDeletes, } from "./model-helpers.js";
9
- import { BelongsTo, BelongsToMany, HasMany, HasManyThrough, HasOne, HasOneThrough, MorphMany, MorphOne, MorphTo, MorphToMany, aggregateRelation, clearMorphMap, morphMap, morphTypeFor, } from "./relations.js";
9
+ import { BelongsTo, BelongsToMany, HasMany, HasManyThrough, HasOne, HasOneThrough, MorphMany, MorphOne, MorphTo, MorphToMany, MorphedByMany, aggregateRelation, clearMorphMap, getMorphedModel, enforceMorphMap, morphMap, registerMorphClass, requireMorphMap, morphTypeFor, } from "./relations.js";
10
10
  import { eagerLoadAggregates, eagerLoadModels, } from "./eager.js";
11
11
  import { ModelQuery, MODEL_QUERY_TARGET } from "./model-query.js";
12
12
  import { LazyLoadingViolationException, MassAssignmentException, MissingAttributeException, isIgnoringTouch, isInsideEagerLoad, modelsShouldPreventAccessingMissingAttributes, modelsShouldPreventLazyLoading, modelsShouldPreventSilentlyDiscardingAttributes, preventAccessingMissingAttributes as setPreventAccessingMissingAttributes, preventLazyLoading as setPreventLazyLoading, preventSilentlyDiscardingAttributes as setPreventSilentlyDiscardingAttributes, shouldBeStrict as setShouldBeStrict, withoutTouching as runWithoutTouching, } from "./model-strictness.js";
@@ -69,7 +69,23 @@ function cachedSyncInsertColumns(ctor) {
69
69
  });
70
70
  return cols;
71
71
  }
72
+ /** `Model::unguard()` — global switch. */
73
+ let massAssignmentUnguarded = false;
74
+ /** Whether `key` may be mass assigned (mirrors {@link filterFillable}'s rules). */
75
+ export function canMassAssign(model, key) {
76
+ if (massAssignmentUnguarded)
77
+ return true;
78
+ const fillables = model.fillable ?? [];
79
+ if (fillables.length > 0)
80
+ return fillables.includes(key);
81
+ const guardeds = model.guarded ?? ["*"];
82
+ if (!guardeds.includes("*"))
83
+ return !guardeds.includes(key);
84
+ return true; // empty fillable + guarded ["*"] → allow all (see filterFillable)
85
+ }
72
86
  export function filterFillable(model, attributes) {
87
+ if (massAssignmentUnguarded)
88
+ return { ...attributes };
73
89
  const fillables = model.fillable ?? [];
74
90
  const guardeds = model.guarded ?? ["*"];
75
91
  const strictDiscard = modelsShouldPreventSilentlyDiscardingAttributes();
@@ -187,6 +203,32 @@ const noCastsCache = new WeakMap();
187
203
  const castsResultCache = new WeakMap();
188
204
  const hashedColumnsCache = new WeakMap();
189
205
  const castShapeCache = new WeakMap();
206
+ /**
207
+ * Attributes whose cast value is a mutable object (`json`, `array`, `object`,
208
+ * `collection`). They are compared by content, so an in-place edit
209
+ * (`row.meta.theme = "dark"`) is dirty and an identical re-assignment is not.
210
+ */
211
+ const objectCastKeysCache = new WeakMap();
212
+ function objectCastKeys(ctor) {
213
+ let keys = objectCastKeysCache.get(ctor);
214
+ if (!keys) {
215
+ keys = [];
216
+ for (const [key, definition] of Object.entries(ctor.getCasts())) {
217
+ if (typeof definition !== "string")
218
+ continue;
219
+ const type = parseCast(definition)?.type;
220
+ if (type === "json" || type === "array" || type === "collection")
221
+ keys.push(key);
222
+ }
223
+ objectCastKeysCache.set(ctor, keys);
224
+ }
225
+ return keys;
226
+ }
227
+ function jsonSnapshot(value) {
228
+ if (value === undefined)
229
+ return undefined;
230
+ return JSON.stringify(value);
231
+ }
190
232
  /** Reuse DatabaseManager per Connection — avoids `new Db()` on every list build. */
191
233
  const dbManagerByConnection = new WeakMap();
192
234
  function classHasNoCasts(ctor) {
@@ -382,8 +424,13 @@ export class Model {
382
424
  static guarded = ["*"];
383
425
  /** Set in constructor / hydrate — avoid empty `{}` alloc before assign. */
384
426
  #original;
427
+ /** Raw (pre-cast) row this model was hydrated from; lets object casts be compared lazily. */
428
+ #rawRow;
429
+ /** JSON snapshots of object-cast attributes taken at the last `syncOriginal()`. */
430
+ #snapshots;
385
431
  /** Lazy: allocated on first mutation tracking. */
386
432
  #changes;
433
+ #previous;
387
434
  #wasRecentlyCreated = false;
388
435
  /**
389
436
  * Whether this instance is persisted (`$exists`).
@@ -418,6 +465,8 @@ export class Model {
418
465
  assignOwn(self, casted);
419
466
  // Avoid a second own-key scan via syncOriginal()/getAttributes() on hydrate.
420
467
  this.#original = { ...casted };
468
+ if (objectCastKeys(ctor).length > 0)
469
+ this.#rawRow = attributes;
421
470
  }
422
471
  /**
423
472
  * `newFromBuilder` — hydrate a persisted row (sets `$exists`).
@@ -431,8 +480,54 @@ export class Model {
431
480
  /** Snapshot current attributes as the original set. */
432
481
  syncOriginal() {
433
482
  this.#original = this.getAttributes();
483
+ this.#rawRow = undefined;
484
+ const keys = objectCastKeys(this.constructor);
485
+ if (keys.length > 0) {
486
+ const snapshots = new Map();
487
+ for (const key of keys) {
488
+ if (key in this.#original)
489
+ snapshots.set(key, jsonSnapshot(this.#original[key]));
490
+ }
491
+ this.#snapshots = snapshots;
492
+ }
493
+ else {
494
+ this.#snapshots = undefined;
495
+ }
434
496
  return this;
435
497
  }
498
+ /** JSON of an object-cast attribute as it was when hydrated / last synced. */
499
+ #objectSnapshot(key) {
500
+ if (this.#snapshots?.has(key))
501
+ return this.#snapshots.get(key);
502
+ const raw = this.#rawRow;
503
+ if (raw && key in raw) {
504
+ const definition = this.constructor.getCasts()[key];
505
+ const parsed = castFromStorage(raw[key], definition);
506
+ const snapshot = jsonSnapshot(parsed);
507
+ (this.#snapshots ??= new Map()).set(key, snapshot);
508
+ return snapshot;
509
+ }
510
+ return jsonSnapshot(this.#original?.[key]);
511
+ }
512
+ /** The pristine value of `key` (a fresh copy for object casts, so in-place edits don't leak in). */
513
+ #originalValue(key) {
514
+ const original = this.#original ?? {};
515
+ if (objectCastKeys(this.constructor).includes(key) && key in original) {
516
+ const snapshot = this.#objectSnapshot(key);
517
+ return snapshot === undefined ? undefined : JSON.parse(snapshot);
518
+ }
519
+ return original[key];
520
+ }
521
+ /** `$model->only($keys)` — a subset of the attributes (array or varargs; missing keys are skipped). */
522
+ only(...keys) {
523
+ const attributes = this.getAttributes();
524
+ const out = {};
525
+ for (const key of keys.flat()) {
526
+ if (Object.prototype.hasOwnProperty.call(attributes, key))
527
+ out[key] = attributes[key];
528
+ }
529
+ return out;
530
+ }
436
531
  /** Current attribute bag (non-function own properties). */
437
532
  getAttributes() {
438
533
  const out = {};
@@ -493,22 +588,73 @@ export class Model {
493
588
  getDirty() {
494
589
  const dirty = {};
495
590
  const original = this.#original ?? {};
591
+ const objectKeys = objectCastKeys(this.constructor);
496
592
  for (const [key, value] of Object.entries(this.getAttributes())) {
497
- if (!Object.is(value, original[key]))
593
+ if (objectKeys.length > 0 && objectKeys.includes(key) && key in original) {
594
+ if (jsonSnapshot(value) !== this.#objectSnapshot(key))
595
+ dirty[key] = value;
596
+ continue;
597
+ }
598
+ const before = original[key];
599
+ // Equal instants are not a change (`row.born = new Date(sameTime)`).
600
+ if (value instanceof Date && before instanceof Date) {
601
+ if (value.getTime() !== before.getTime())
602
+ dirty[key] = value;
603
+ continue;
604
+ }
605
+ if (!Object.is(value, before))
498
606
  dirty[key] = value;
499
607
  }
500
608
  return dirty;
501
609
  }
502
610
  getOriginal(key) {
503
611
  const original = this.#original ?? {};
504
- if (key === undefined)
505
- return { ...original };
506
- return original[key];
612
+ if (key === undefined) {
613
+ const out = { ...original };
614
+ for (const objectKey of objectCastKeys(this.constructor)) {
615
+ if (objectKey in out)
616
+ out[objectKey] = this.#originalValue(objectKey);
617
+ }
618
+ return out;
619
+ }
620
+ return this.#originalValue(key);
507
621
  }
508
622
  /** `getChanges` — attributes changed by the last save. */
509
623
  getChanges() {
510
624
  return this.#changes ? { ...this.#changes } : {};
511
625
  }
626
+ getPrevious(key) {
627
+ const previous = this.#previous ?? {};
628
+ if (key === undefined)
629
+ return { ...previous };
630
+ return previous[key];
631
+ }
632
+ /** `getChanges()` / `getPrevious()` for the write that just happened (set before `created` / `updated`). */
633
+ #recordChanges(dirty, exists, usesTimestamps) {
634
+ const row = this;
635
+ this.#changes = { ...dirty };
636
+ if (usesTimestamps) {
637
+ if ("updated_at" in row && (!exists || "updated_at" in dirty || Object.keys(dirty).length > 0)) {
638
+ this.#changes.updated_at = row.updated_at;
639
+ }
640
+ if (!exists && "created_at" in row) {
641
+ this.#changes.created_at = row.created_at;
642
+ }
643
+ }
644
+ this.#capturePrevious(!exists);
645
+ }
646
+ #capturePrevious(created = false) {
647
+ const original = this.#original ?? {};
648
+ const previous = {};
649
+ if (!created) {
650
+ for (const key of Object.keys(this.#changes ?? {})) {
651
+ if (Object.prototype.hasOwnProperty.call(original, key)) {
652
+ previous[key] = this.#originalValue(key);
653
+ }
654
+ }
655
+ }
656
+ this.#previous = previous;
657
+ }
512
658
  /** `wasChanged`. */
513
659
  wasChanged(...attributes) {
514
660
  const changes = this.#changes;
@@ -609,6 +755,39 @@ export class Model {
609
755
  static preventLazyLoading(prevent = true) {
610
756
  setPreventLazyLoading(prevent);
611
757
  }
758
+ /** `Model::unguard()` — disable mass-assignment protection (seeders, imports). */
759
+ static unguard(state = true) {
760
+ massAssignmentUnguarded = state;
761
+ }
762
+ /** `Model::reguard()`. */
763
+ static reguard() {
764
+ massAssignmentUnguarded = false;
765
+ }
766
+ /** `Model::isUnguarded()`. */
767
+ static isUnguarded() {
768
+ return massAssignmentUnguarded;
769
+ }
770
+ /** `Model::unguarded(callback)` — run with protection off, then restore it. */
771
+ static unguarded(callback) {
772
+ if (massAssignmentUnguarded)
773
+ return callback();
774
+ massAssignmentUnguarded = true;
775
+ let result;
776
+ try {
777
+ result = callback();
778
+ }
779
+ catch (error) {
780
+ massAssignmentUnguarded = false;
781
+ throw error;
782
+ }
783
+ if (result instanceof Promise) {
784
+ return result.finally(() => {
785
+ massAssignmentUnguarded = false;
786
+ });
787
+ }
788
+ massAssignmentUnguarded = false;
789
+ return result;
790
+ }
612
791
  /** `Model::preventSilentlyDiscardingAttributes`. */
613
792
  static preventSilentlyDiscardingAttributes(prevent = true) {
614
793
  setPreventSilentlyDiscardingAttributes(prevent);
@@ -646,6 +825,7 @@ export class Model {
646
825
  static booted() { }
647
826
  /** Ensure this model class has been booted. */
648
827
  static bootIfNotBooted() {
828
+ registerMorphClass(this);
649
829
  bootIfNotBooted(this);
650
830
  }
651
831
  /** `Model::observe`. */
@@ -654,7 +834,8 @@ export class Model {
654
834
  }
655
835
  /** `Model::withoutEvents`. */
656
836
  static withoutEvents(callback) {
657
- return withoutModelEvents(this, callback);
837
+ // Called on `Model` itself it mutes every model class.
838
+ return withoutModelEvents(this, callback, this === Model);
658
839
  }
659
840
  static retrieved(callback) {
660
841
  registerModelEvent(this, "retrieved", callback);
@@ -712,6 +893,13 @@ export class Model {
712
893
  return cached;
713
894
  const field = this.casts;
714
895
  const value = typeof field === "function" ? (field.call(this) ?? {}) : (field ?? {});
896
+ for (const [key, definition] of Object.entries(value)) {
897
+ if (typeof definition === "string" && !isCastType(definition)) {
898
+ throw new Error(`Unknown cast type "${definition}" for ${this.name ?? "Model"}.${key}. ` +
899
+ `Supported: boolean, integer, float, decimal[:N], string, bigint, json, array, object, collection, ` +
900
+ `date[:FORMAT], datetime[:FORMAT], immutable_date, immutable_datetime, encrypted[:json|:array|:collection], hashed.`);
901
+ }
902
+ }
715
903
  castsResultCache.set(this, value);
716
904
  return value;
717
905
  }
@@ -930,11 +1118,387 @@ export class Model {
930
1118
  return q.where(columnOrCallback, opOrValue);
931
1119
  return q.where(columnOrCallback, String(opOrValue), value);
932
1120
  }
933
- /** Karobar-style multi-column LIKE search. */
1121
+ /** Multi-column LIKE search. */
934
1122
  static search(columns, term) {
935
1123
  this.bootIfNotBooted();
936
1124
  return this.newQuery().search(columns, term);
937
1125
  }
1126
+ // Terminal forwarders take `any[]` on purpose: an app may define its own static with the same name
1127
+ // (`static exists(id)`), and a stricter base signature would make `typeof User` stop satisfying `typeof Model`.
1128
+ // ── Query-builder forwarding (`__callStatic`): `User.whereBetween(...)`, `User.has('posts')`, … ──
1129
+ static whereBetween(...args) {
1130
+ this.bootIfNotBooted();
1131
+ return this.newQuery()["whereBetween"](...args);
1132
+ }
1133
+ static whereNotBetween(...args) {
1134
+ this.bootIfNotBooted();
1135
+ return this.newQuery()["whereNotBetween"](...args);
1136
+ }
1137
+ static whereNotIn(...args) {
1138
+ this.bootIfNotBooted();
1139
+ return this.newQuery()["whereNotIn"](...args);
1140
+ }
1141
+ static whereRaw(...args) {
1142
+ this.bootIfNotBooted();
1143
+ return this.newQuery()["whereRaw"](...args);
1144
+ }
1145
+ static whereColumn(...args) {
1146
+ this.bootIfNotBooted();
1147
+ return this.newQuery()["whereColumn"](...args);
1148
+ }
1149
+ static whereLike(...args) {
1150
+ this.bootIfNotBooted();
1151
+ return this.newQuery()["whereLike"](...args);
1152
+ }
1153
+ static whereNotLike(...args) {
1154
+ this.bootIfNotBooted();
1155
+ return this.newQuery()["whereNotLike"](...args);
1156
+ }
1157
+ static whereDay(...args) {
1158
+ this.bootIfNotBooted();
1159
+ return this.newQuery()["whereDay"](...args);
1160
+ }
1161
+ static whereMonth(...args) {
1162
+ this.bootIfNotBooted();
1163
+ return this.newQuery()["whereMonth"](...args);
1164
+ }
1165
+ static whereYear(...args) {
1166
+ this.bootIfNotBooted();
1167
+ return this.newQuery()["whereYear"](...args);
1168
+ }
1169
+ static orWhereIn(...args) {
1170
+ this.bootIfNotBooted();
1171
+ return this.newQuery()["orWhereIn"](...args);
1172
+ }
1173
+ static orWhereNotIn(...args) {
1174
+ this.bootIfNotBooted();
1175
+ return this.newQuery()["orWhereNotIn"](...args);
1176
+ }
1177
+ static orWhereNull(...args) {
1178
+ this.bootIfNotBooted();
1179
+ return this.newQuery()["orWhereNull"](...args);
1180
+ }
1181
+ static orWhereNotNull(...args) {
1182
+ this.bootIfNotBooted();
1183
+ return this.newQuery()["orWhereNotNull"](...args);
1184
+ }
1185
+ static orWhereBetween(...args) {
1186
+ this.bootIfNotBooted();
1187
+ return this.newQuery()["orWhereBetween"](...args);
1188
+ }
1189
+ static orWhereNotBetween(...args) {
1190
+ this.bootIfNotBooted();
1191
+ return this.newQuery()["orWhereNotBetween"](...args);
1192
+ }
1193
+ static orWhereColumn(...args) {
1194
+ this.bootIfNotBooted();
1195
+ return this.newQuery()["orWhereColumn"](...args);
1196
+ }
1197
+ static orWhereRaw(...args) {
1198
+ this.bootIfNotBooted();
1199
+ return this.newQuery()["orWhereRaw"](...args);
1200
+ }
1201
+ static orWhereLike(...args) {
1202
+ this.bootIfNotBooted();
1203
+ return this.newQuery()["orWhereLike"](...args);
1204
+ }
1205
+ static orWhereNotLike(...args) {
1206
+ this.bootIfNotBooted();
1207
+ return this.newQuery()["orWhereNotLike"](...args);
1208
+ }
1209
+ static groupBy(...args) {
1210
+ this.bootIfNotBooted();
1211
+ return this.newQuery()["groupBy"](...args);
1212
+ }
1213
+ static having(...args) {
1214
+ this.bootIfNotBooted();
1215
+ return this.newQuery()["having"](...args);
1216
+ }
1217
+ static havingRaw(...args) {
1218
+ this.bootIfNotBooted();
1219
+ return this.newQuery()["havingRaw"](...args);
1220
+ }
1221
+ static selectRaw(...args) {
1222
+ this.bootIfNotBooted();
1223
+ return this.newQuery()["selectRaw"](...args);
1224
+ }
1225
+ static addSelect(...args) {
1226
+ this.bootIfNotBooted();
1227
+ return this.newQuery()["addSelect"](...args);
1228
+ }
1229
+ static distinct(...args) {
1230
+ this.bootIfNotBooted();
1231
+ return this.newQuery()["distinct"](...args);
1232
+ }
1233
+ static inRandomOrder(...args) {
1234
+ this.bootIfNotBooted();
1235
+ return this.newQuery()["inRandomOrder"](...args);
1236
+ }
1237
+ static reorder(...args) {
1238
+ this.bootIfNotBooted();
1239
+ return this.newQuery()["reorder"](...args);
1240
+ }
1241
+ static offset(...args) {
1242
+ this.bootIfNotBooted();
1243
+ return this.newQuery()["offset"](...args);
1244
+ }
1245
+ static skip(...args) {
1246
+ this.bootIfNotBooted();
1247
+ return this.newQuery()["skip"](...args);
1248
+ }
1249
+ static lockForUpdate(...args) {
1250
+ this.bootIfNotBooted();
1251
+ return this.newQuery()["lockForUpdate"](...args);
1252
+ }
1253
+ static sharedLock(...args) {
1254
+ this.bootIfNotBooted();
1255
+ return this.newQuery()["sharedLock"](...args);
1256
+ }
1257
+ static has(...args) {
1258
+ this.bootIfNotBooted();
1259
+ return this.newQuery()["has"](...args);
1260
+ }
1261
+ static doesntHave(...args) {
1262
+ this.bootIfNotBooted();
1263
+ return this.newQuery()["doesntHave"](...args);
1264
+ }
1265
+ static orHas(...args) {
1266
+ this.bootIfNotBooted();
1267
+ return this.newQuery()["orHas"](...args);
1268
+ }
1269
+ static orDoesntHave(...args) {
1270
+ this.bootIfNotBooted();
1271
+ return this.newQuery()["orDoesntHave"](...args);
1272
+ }
1273
+ static orWhereDoesntHave(...args) {
1274
+ this.bootIfNotBooted();
1275
+ return this.newQuery()["orWhereDoesntHave"](...args);
1276
+ }
1277
+ static whereHasMorph(...args) {
1278
+ this.bootIfNotBooted();
1279
+ return this.newQuery()["whereHasMorph"](...args);
1280
+ }
1281
+ static orWhereHasMorph(...args) {
1282
+ this.bootIfNotBooted();
1283
+ return this.newQuery()["orWhereHasMorph"](...args);
1284
+ }
1285
+ static whereDoesntHaveMorph(...args) {
1286
+ this.bootIfNotBooted();
1287
+ return this.newQuery()["whereDoesntHaveMorph"](...args);
1288
+ }
1289
+ static orWhereDoesntHaveMorph(...args) {
1290
+ this.bootIfNotBooted();
1291
+ return this.newQuery()["orWhereDoesntHaveMorph"](...args);
1292
+ }
1293
+ static hasMorph(...args) {
1294
+ this.bootIfNotBooted();
1295
+ return this.newQuery()["hasMorph"](...args);
1296
+ }
1297
+ static doesntHaveMorph(...args) {
1298
+ this.bootIfNotBooted();
1299
+ return this.newQuery()["doesntHaveMorph"](...args);
1300
+ }
1301
+ static whereMorphedTo(...args) {
1302
+ this.bootIfNotBooted();
1303
+ return this.newQuery()["whereMorphedTo"](...args);
1304
+ }
1305
+ static whereNotMorphedTo(...args) {
1306
+ this.bootIfNotBooted();
1307
+ return this.newQuery()["whereNotMorphedTo"](...args);
1308
+ }
1309
+ static orWhereMorphedTo(...args) {
1310
+ this.bootIfNotBooted();
1311
+ return this.newQuery()["orWhereMorphedTo"](...args);
1312
+ }
1313
+ static orWhereNotMorphedTo(...args) {
1314
+ this.bootIfNotBooted();
1315
+ return this.newQuery()["orWhereNotMorphedTo"](...args);
1316
+ }
1317
+ static whereMorphRelation(...args) {
1318
+ this.bootIfNotBooted();
1319
+ return this.newQuery()["whereMorphRelation"](...args);
1320
+ }
1321
+ static orWhereMorphRelation(...args) {
1322
+ this.bootIfNotBooted();
1323
+ return this.newQuery()["orWhereMorphRelation"](...args);
1324
+ }
1325
+ static whereKeyNot(...args) {
1326
+ this.bootIfNotBooted();
1327
+ return this.newQuery()["whereKeyNot"](...args);
1328
+ }
1329
+ static orWhereBelongsTo(...args) {
1330
+ this.bootIfNotBooted();
1331
+ return this.newQuery()["orWhereBelongsTo"](...args);
1332
+ }
1333
+ static orWhereAny(...args) {
1334
+ this.bootIfNotBooted();
1335
+ return this.newQuery()["orWhereAny"](...args);
1336
+ }
1337
+ static orWhereAll(...args) {
1338
+ this.bootIfNotBooted();
1339
+ return this.newQuery()["orWhereAll"](...args);
1340
+ }
1341
+ static orWhereNone(...args) {
1342
+ this.bootIfNotBooted();
1343
+ return this.newQuery()["orWhereNone"](...args);
1344
+ }
1345
+ static withAggregate(...args) {
1346
+ this.bootIfNotBooted();
1347
+ return this.newQuery()["withAggregate"](...args);
1348
+ }
1349
+ static withAttributes(...args) {
1350
+ this.bootIfNotBooted();
1351
+ return this.newQuery()["withAttributes"](...args);
1352
+ }
1353
+ static withCasts(...args) {
1354
+ this.bootIfNotBooted();
1355
+ return this.newQuery()["withCasts"](...args);
1356
+ }
1357
+ static rightJoin(...args) {
1358
+ this.bootIfNotBooted();
1359
+ return this.newQuery()["rightJoin"](...args);
1360
+ }
1361
+ static when(...args) {
1362
+ this.bootIfNotBooted();
1363
+ return this.newQuery()["when"](...args);
1364
+ }
1365
+ static unless(...args) {
1366
+ this.bootIfNotBooted();
1367
+ return this.newQuery()["unless"](...args);
1368
+ }
1369
+ static tap(...args) {
1370
+ this.bootIfNotBooted();
1371
+ return this.newQuery()["tap"](...args);
1372
+ }
1373
+ static forPage(...args) {
1374
+ this.bootIfNotBooted();
1375
+ return this.newQuery()["forPage"](...args);
1376
+ }
1377
+ static firstWhere(...args) {
1378
+ this.bootIfNotBooted();
1379
+ const q = this.newQuery();
1380
+ return q["firstWhere"].apply(q, args);
1381
+ }
1382
+ static findMany(...args) {
1383
+ this.bootIfNotBooted();
1384
+ const q = this.newQuery();
1385
+ return q["findMany"].apply(q, args);
1386
+ }
1387
+ static findOr(...args) {
1388
+ this.bootIfNotBooted();
1389
+ const q = this.newQuery();
1390
+ return q["findOr"].apply(q, args);
1391
+ }
1392
+ static findOrNew(...args) {
1393
+ this.bootIfNotBooted();
1394
+ const q = this.newQuery();
1395
+ return q["findOrNew"].apply(q, args);
1396
+ }
1397
+ static firstOr(...args) {
1398
+ this.bootIfNotBooted();
1399
+ const q = this.newQuery();
1400
+ return q["firstOr"].apply(q, args);
1401
+ }
1402
+ static chunk(...args) {
1403
+ this.bootIfNotBooted();
1404
+ const q = this.newQuery();
1405
+ return q["chunk"].apply(q, args);
1406
+ }
1407
+ static chunkById(...args) {
1408
+ this.bootIfNotBooted();
1409
+ const q = this.newQuery();
1410
+ return q["chunkById"].apply(q, args);
1411
+ }
1412
+ static chunkByIdDesc(...args) {
1413
+ this.bootIfNotBooted();
1414
+ const q = this.newQuery();
1415
+ return q["chunkByIdDesc"].apply(q, args);
1416
+ }
1417
+ static dd(...args) {
1418
+ this.bootIfNotBooted();
1419
+ const q = this.newQuery();
1420
+ return q["dd"].apply(q, args);
1421
+ }
1422
+ static dump(...args) {
1423
+ this.bootIfNotBooted();
1424
+ const q = this.newQuery();
1425
+ return q["dump"].apply(q, args);
1426
+ }
1427
+ static toBase(...args) {
1428
+ this.bootIfNotBooted();
1429
+ const q = this.newQuery();
1430
+ return q["toBase"].apply(q, args);
1431
+ }
1432
+ static count(...args) {
1433
+ this.bootIfNotBooted();
1434
+ const q = this.newQuery();
1435
+ return q["count"].apply(q, args);
1436
+ }
1437
+ static sum(...args) {
1438
+ this.bootIfNotBooted();
1439
+ const q = this.newQuery();
1440
+ return q["sum"].apply(q, args);
1441
+ }
1442
+ static avg(...args) {
1443
+ this.bootIfNotBooted();
1444
+ const q = this.newQuery();
1445
+ return q["avg"].apply(q, args);
1446
+ }
1447
+ static min(...args) {
1448
+ this.bootIfNotBooted();
1449
+ const q = this.newQuery();
1450
+ return q["min"].apply(q, args);
1451
+ }
1452
+ static max(...args) {
1453
+ this.bootIfNotBooted();
1454
+ const q = this.newQuery();
1455
+ return q["max"].apply(q, args);
1456
+ }
1457
+ static exists(...args) {
1458
+ this.bootIfNotBooted();
1459
+ const q = this.newQuery();
1460
+ return q["exists"].apply(q, args);
1461
+ }
1462
+ static doesntExist(...args) {
1463
+ this.bootIfNotBooted();
1464
+ const q = this.newQuery();
1465
+ return q["doesntExist"].apply(q, args);
1466
+ }
1467
+ static sole(...args) {
1468
+ this.bootIfNotBooted();
1469
+ const q = this.newQuery();
1470
+ return q["sole"].apply(q, args);
1471
+ }
1472
+ static soleValue(...args) {
1473
+ this.bootIfNotBooted();
1474
+ const q = this.newQuery();
1475
+ return q["soleValue"].apply(q, args);
1476
+ }
1477
+ static valueOrFail(...args) {
1478
+ this.bootIfNotBooted();
1479
+ const q = this.newQuery();
1480
+ return q["valueOrFail"].apply(q, args);
1481
+ }
1482
+ static toSql(...args) {
1483
+ this.bootIfNotBooted();
1484
+ const q = this.newQuery();
1485
+ return q["toSql"].apply(q, args);
1486
+ }
1487
+ static toRawSql(...args) {
1488
+ this.bootIfNotBooted();
1489
+ const q = this.newQuery();
1490
+ return q["toRawSql"].apply(q, args);
1491
+ }
1492
+ static getBindings(...args) {
1493
+ this.bootIfNotBooted();
1494
+ const q = this.newQuery();
1495
+ return q["getBindings"].apply(q, args);
1496
+ }
1497
+ static firstOrFail(...args) {
1498
+ this.bootIfNotBooted();
1499
+ const q = this.newQuery();
1500
+ return q["firstOrFail"].apply(q, args);
1501
+ }
938
1502
  static orWhere(column, opOrValue, value) {
939
1503
  this.bootIfNotBooted();
940
1504
  const q = this.newQuery();
@@ -965,7 +1529,7 @@ export class Model {
965
1529
  this.bootIfNotBooted();
966
1530
  return this.newQuery().orderByRaw(sql);
967
1531
  }
968
- /** Karobar alias for `orderByRaw`. */
1532
+ /** Alias for `orderByRaw`. */
969
1533
  static orderBySql(sql) {
970
1534
  return this.orderByRaw(sql);
971
1535
  }
@@ -995,15 +1559,15 @@ export class Model {
995
1559
  return q.whereDate(column, opOrValue);
996
1560
  return q.whereDate(column, String(opOrValue), value);
997
1561
  }
998
- /** `Model::whereNot(closure)`. */
999
- static whereNot(callback) {
1562
+ static whereNot(...args) {
1000
1563
  this.bootIfNotBooted();
1001
- return this.newQuery().whereNot(callback);
1564
+ const q = this.newQuery();
1565
+ return q.whereNot(...args);
1002
1566
  }
1003
- /** `Model::orWhereNot(closure)`. */
1004
- static orWhereNot(callback) {
1567
+ static orWhereNot(...args) {
1005
1568
  this.bootIfNotBooted();
1006
- return this.newQuery().orWhereNot(callback);
1569
+ const q = this.newQuery();
1570
+ return q.orWhereNot(...args);
1007
1571
  }
1008
1572
  /** `withTrashed()`. */
1009
1573
  static withTrashed() {
@@ -1138,18 +1702,42 @@ export class Model {
1138
1702
  static async cursorPaginate(perPage = 15, cursor = null, options = {}) {
1139
1703
  return this.newQuery().cursorPaginate(perPage, cursor, options);
1140
1704
  }
1705
+ /** `Model::cursor()`: stream models from a single query. */
1706
+ static cursor(chunkSize) {
1707
+ return this.newQuery().cursor(chunkSize);
1708
+ }
1709
+ /** `Model::lazy()`: chunked iteration, one query per chunk. */
1710
+ static lazy(chunkSize) {
1711
+ return this.newQuery().lazy(chunkSize);
1712
+ }
1713
+ /** `Model::lazyById()`: chunked iteration by primary key (safe while updating). */
1714
+ static lazyById(chunkSize, column) {
1715
+ return this.newQuery().lazyById(chunkSize, column);
1716
+ }
1717
+ /** `Model::lazyByIdDesc()`. */
1718
+ static lazyByIdDesc(chunkSize, column) {
1719
+ return this.newQuery().lazyByIdDesc(chunkSize, column);
1720
+ }
1141
1721
  /** `Model::create` (respects `$fillable` / `$guarded`). */
1142
1722
  static create(attributes) {
1143
1723
  this.bootIfNotBooted();
1144
1724
  return this.forceCreate(filterFillable(this, attributes));
1145
1725
  }
1146
1726
  /** `Model::forceCreate` — insert every given attribute, ignoring `$fillable`. */
1147
- static forceCreate(useAttrs) {
1727
+ static forceCreate(useAttrs, beforeSave) {
1148
1728
  this.bootIfNotBooted();
1149
1729
  // Assign raw attributes (skip get-casts) so mutators like Attribute.set can run on save.
1150
1730
  const model = classHasNoCasts(this)
1151
1731
  ? new this(useAttrs)
1152
1732
  : Object.assign(new this(), useAttrs);
1733
+ const pre = beforeSave?.(model);
1734
+ if (pre instanceof Promise) {
1735
+ return pre.then(() => this.forceCreateSaved(model));
1736
+ }
1737
+ return this.forceCreateSaved(model);
1738
+ }
1739
+ /** Persist a model built by {@link forceCreate} and re-hydrate get-casts. */
1740
+ static forceCreateSaved(model) {
1153
1741
  const saved = model.save();
1154
1742
  const after = (row) => {
1155
1743
  if (classHasNoCasts(this)) {
@@ -1234,6 +1822,20 @@ export class Model {
1234
1822
  assignOwn(this, ctor.castAttributes(finalAttrs, "get"));
1235
1823
  return this;
1236
1824
  }
1825
+ /** `$model->forceFill($attributes)` — assign without checking `fillable` / `guarded`. */
1826
+ forceFill(attributes) {
1827
+ const ctor = this.constructor;
1828
+ assignOwn(this, ctor.castAttributes({ ...attributes }, "get"));
1829
+ return this;
1830
+ }
1831
+ /** `$model->isFillable($key)`. */
1832
+ isFillable(key) {
1833
+ return canMassAssign(this.constructor, key);
1834
+ }
1835
+ /** `$model->isGuarded($key)`. */
1836
+ isGuarded(key) {
1837
+ return !this.isFillable(key);
1838
+ }
1237
1839
  /** `$model->update($attributes)`. */
1238
1840
  async update(attributes) {
1239
1841
  this.fill(attributes);
@@ -1278,6 +1880,20 @@ export class Model {
1278
1880
  });
1279
1881
  return this.save();
1280
1882
  }
1883
+ /**
1884
+ * Bump `updated_at` only when the model is dirty
1885
+ * and the caller did not set `updated_at` themselves. A no-op `save()` must
1886
+ * not issue an UPDATE.
1887
+ */
1888
+ #stampUpdatedAt(now) {
1889
+ const dirty = this.getDirty();
1890
+ for (const _ in dirty) {
1891
+ if (!("updated_at" in dirty)) {
1892
+ this.updated_at = now;
1893
+ }
1894
+ return;
1895
+ }
1896
+ }
1281
1897
  #persistSync(conn) {
1282
1898
  const ctor = this.constructor;
1283
1899
  const key = ctor.primaryKey;
@@ -1290,16 +1906,39 @@ export class Model {
1290
1906
  const usesTimestamps = ctor.timestamps !== false;
1291
1907
  if (usesTimestamps) {
1292
1908
  const now = nowForConnection(conn);
1293
- row.updated_at = now;
1294
1909
  if (!exists) {
1910
+ row.updated_at = now;
1295
1911
  row.created_at = row.created_at ?? now;
1296
1912
  }
1913
+ else {
1914
+ this.#stampUpdatedAt(now);
1915
+ }
1297
1916
  }
1298
1917
  if (!exists) {
1299
1918
  const noCasts = classHasNoCasts(ctor);
1300
- const cachedCols = noCasts
1919
+ let cachedCols = noCasts
1301
1920
  ? cachedSyncInsertColumns(ctor)
1302
1921
  : null;
1922
+ // The cached column list is only valid when the row sets exactly those columns:
1923
+ // an unset fillable column must stay absent (so the DB default applies) and an
1924
+ // attribute outside `fillable` (forceFill, direct assignment) must still be saved.
1925
+ if (cachedCols) {
1926
+ let present = 0;
1927
+ for (let i = 0; i < cachedCols.length; i++) {
1928
+ if (row[cachedCols[i]] === undefined) {
1929
+ cachedCols = null;
1930
+ break;
1931
+ }
1932
+ present++;
1933
+ }
1934
+ if (cachedCols) {
1935
+ let own = 0;
1936
+ for (const _ in row)
1937
+ own++;
1938
+ if (own !== present)
1939
+ cachedCols = null;
1940
+ }
1941
+ }
1303
1942
  let writeColumns;
1304
1943
  let writeValues;
1305
1944
  if (cachedCols) {
@@ -1364,6 +2003,7 @@ export class Model {
1364
2003
  }
1365
2004
  this.#original = original;
1366
2005
  this.#changes = original;
2006
+ this.#previous = undefined;
1367
2007
  this.#touchOwnersSync();
1368
2008
  return this;
1369
2009
  }
@@ -1379,6 +2019,7 @@ export class Model {
1379
2019
  }
1380
2020
  this.#wasRecentlyCreated = false;
1381
2021
  this.#changes = dirty;
2022
+ this.#capturePrevious();
1382
2023
  this.syncOriginal();
1383
2024
  this.#touchOwnersSync();
1384
2025
  return this;
@@ -1392,19 +2033,19 @@ export class Model {
1392
2033
  if ((await fireModelEvent(this, "saving")) === false)
1393
2034
  return this;
1394
2035
  const usesTimestamps = ctor.timestamps !== false;
1395
- if (usesTimestamps) {
1396
- const now = nowForConnection(this.getConnection());
1397
- row.updated_at = now;
1398
- if (!exists) {
1399
- row.created_at = row.created_at ?? now;
1400
- }
1401
- }
1402
- const dirty = this.getDirty();
1403
2036
  const persistCtor = ctor;
1404
2037
  const table = new Db(this.getConnection()).table(ctor.table);
2038
+ // Order: saving → creating/updating → timestamps → write → created/updated → saved.
2039
+ let dirty;
1405
2040
  if (!exists) {
1406
2041
  if ((await fireModelEvent(this, "creating")) === false)
1407
2042
  return this;
2043
+ if (usesTimestamps) {
2044
+ const now = nowForConnection(this.getConnection());
2045
+ row.updated_at = now;
2046
+ row.created_at = row.created_at ?? now;
2047
+ }
2048
+ dirty = this.getDirty();
1408
2049
  const attributes = persistableAttributes(persistCtor, row, ctor.incrementing !== false || row[key] == null ? key : undefined);
1409
2050
  if (ctor.incrementing !== false) {
1410
2051
  const newId = await withoutQueryWriteHook(() => table.insertGetId(ctor.castAttributes(attributes, "set")));
@@ -1419,28 +2060,34 @@ export class Model {
1419
2060
  }
1420
2061
  this.#exists = true;
1421
2062
  this.#wasRecentlyCreated = true;
2063
+ this.#recordChanges(dirty, false, usesTimestamps);
1422
2064
  await fireModelEvent(this, "created");
1423
2065
  }
1424
2066
  else {
1425
2067
  if ((await fireModelEvent(this, "updating")) === false)
1426
2068
  return this;
1427
- const attributes = persistableAttributes(persistCtor, dirty, key);
1428
- if (Object.keys(attributes).length > 0) {
1429
- await withoutQueryWriteHook(() => table.where(key, id).update(ctor.castAttributes(attributes, "set")));
2069
+ if (usesTimestamps)
2070
+ this.#stampUpdatedAt(nowForConnection(this.getConnection()));
2071
+ dirty = this.getDirty();
2072
+ // `updated` fires only when something was written.
2073
+ if (Object.keys(dirty).length > 0) {
2074
+ const attributes = persistableAttributes(persistCtor, dirty, key);
2075
+ if (Object.keys(attributes).length > 0) {
2076
+ await withoutQueryWriteHook(() => table.where(key, id).update(ctor.castAttributes(attributes, "set")));
2077
+ }
2078
+ this.#wasRecentlyCreated = false;
2079
+ this.#recordChanges(dirty, true, usesTimestamps);
2080
+ await fireModelEvent(this, "updated");
1430
2081
  }
1431
- this.#wasRecentlyCreated = false;
1432
- await fireModelEvent(this, "updated");
1433
- }
1434
- this.#changes = { ...dirty };
1435
- if (usesTimestamps) {
1436
- if ("updated_at" in row)
1437
- this.#changes.updated_at = row.updated_at;
1438
- if (!exists && "created_at" in row) {
1439
- this.#changes.created_at = row.created_at;
2082
+ else {
2083
+ this.#wasRecentlyCreated = false;
2084
+ this.#recordChanges(dirty, true, usesTimestamps);
1440
2085
  }
1441
2086
  }
1442
- this.syncOriginal();
2087
+ // `saved` fires first, then the original set is synced,
2088
+ // so `isDirty()` / `getOriginal()` still describe the save inside `updated` and `saved`.
1443
2089
  await fireModelEvent(this, "saved");
2090
+ this.syncOriginal();
1444
2091
  // finishSave → touchOwners (Relation.touch honors withoutTouching on related).
1445
2092
  await this.touchOwners();
1446
2093
  return this;
@@ -1459,6 +2106,8 @@ export class Model {
1459
2106
  if (fresh) {
1460
2107
  Object.assign(this, fresh.getAttributes());
1461
2108
  this.#original = { ...fresh.#original };
2109
+ this.#rawRow = fresh.#rawRow;
2110
+ this.#snapshots = fresh.#snapshots;
1462
2111
  this.#exists = true;
1463
2112
  }
1464
2113
  return this;
@@ -1478,6 +2127,8 @@ export class Model {
1478
2127
  if (fresh) {
1479
2128
  Object.assign(this, fresh.getAttributes());
1480
2129
  this.#original = { ...fresh.#original };
2130
+ this.#rawRow = fresh.#rawRow;
2131
+ this.#snapshots = fresh.#snapshots;
1481
2132
  this.#exists = true;
1482
2133
  }
1483
2134
  return this;
@@ -1629,8 +2280,8 @@ export class Model {
1629
2280
  return;
1630
2281
  if (usesSoftDeletes(ctor)) {
1631
2282
  await this.#performSoftDelete();
1632
- await fireModelEvent(this, "deleted");
1633
2283
  await fireModelEvent(this, "trashed");
2284
+ await fireModelEvent(this, "deleted");
1634
2285
  return;
1635
2286
  }
1636
2287
  // delete(): touchOwners before performDeleteOnModel.
@@ -1660,9 +2311,12 @@ export class Model {
1660
2311
  ctor.bootIfNotBooted();
1661
2312
  if ((await fireModelEvent(this, "forceDeleting")) === false)
1662
2313
  return;
2314
+ if ((await fireModelEvent(this, "deleting")) === false)
2315
+ return;
2316
+ await this.touchOwners();
1663
2317
  await this.#performForceDelete();
1664
- await fireModelEvent(this, "forceDeleted");
1665
2318
  await fireModelEvent(this, "deleted");
2319
+ await fireModelEvent(this, "forceDeleted");
1666
2320
  }
1667
2321
  /** `$model->deleteQuietly()`. */
1668
2322
  async deleteQuietly() {
@@ -1723,7 +2377,8 @@ export class Model {
1723
2377
  continue;
1724
2378
  if (visible && !visible.has(key))
1725
2379
  continue;
1726
- out[key] = value instanceof Date ? value.toISOString() : value;
2380
+ out[key] =
2381
+ value instanceof Date ? serializeDate(ctor, key, value) : value;
1727
2382
  }
1728
2383
  for (const key of appends) {
1729
2384
  if (hidden.has(key))
@@ -1792,8 +2447,14 @@ export class Model {
1792
2447
  }
1793
2448
  return this;
1794
2449
  }
2450
+ /** `toArray()` with `bigint` values as decimal strings, since JSON has no `bigint`. */
1795
2451
  toJSON() {
1796
- return this.toArray();
2452
+ const out = this.toArray();
2453
+ for (const key in out) {
2454
+ if (typeof out[key] === "bigint")
2455
+ out[key] = String(out[key]);
2456
+ }
2457
+ return out;
1797
2458
  }
1798
2459
  /** Alias of {@link toJSON}. */
1799
2460
  toJson() {
@@ -1903,6 +2564,14 @@ export class Model {
1903
2564
  : [name, primaryMorph].filter((value, index, all) => all.indexOf(value) === index);
1904
2565
  return new MorphToMany(this, related, table, foreign, relatedKey, morphTypeColumn, morphTypes, options.pivotTenantKey, options.parentTenantKey ?? options.pivotTenantKey);
1905
2566
  }
2567
+ /**
2568
+ * `$this->morphedByMany(Related::class, name)` — inverse of `morphToMany`.
2569
+ * Defaults: pivot `{name}s`, `{name}_id`, `{name}_type`, parent key `{parent}_id`.
2570
+ */
2571
+ morphedByMany(related, name, table, foreignPivotKey, relatedPivotKey) {
2572
+ const parent = this.constructor;
2573
+ return new MorphedByMany(this, related, table ?? `${name}s`, foreignPivotKey ?? `${singular(parent.table)}_id`, relatedPivotKey ?? `${name}_id`, `${name}_type`, morphTypeFor(related));
2574
+ }
1906
2575
  /** `$this->morphMany(Related::class, name)`. */
1907
2576
  morphMany(related, name, type, id, localKey) {
1908
2577
  return new MorphMany(this, related, type ?? `${name}_type`, id ?? `${name}_id`, morphTypeFor(this.constructor), localKey);
@@ -1916,6 +2585,17 @@ export class Model {
1916
2585
  return new MorphTo(this, type ?? `${name}_type`, id ?? `${name}_id`, ownerKey);
1917
2586
  }
1918
2587
  }
2588
+ /** ISO by default; `date:Y-m-d` / `datetime:FORMAT` casts format the serialized value. */
2589
+ function serializeDate(ctor, key, value) {
2590
+ const definition = ctor.getCasts()[key];
2591
+ if (typeof definition === "string") {
2592
+ const arg = parseCast(definition)?.arg;
2593
+ if (arg !== undefined && definition !== "decimal" && !definition.startsWith("decimal:")) {
2594
+ return formatPhpDate(value, arg);
2595
+ }
2596
+ }
2597
+ return value.toISOString();
2598
+ }
1919
2599
  installLocalScopeCallStatic(Model);
1920
2600
  /** True when `value` is a Model subclass constructor (O(1) prototype check). */
1921
2601
  export function isModelCtor(value) {
@@ -1925,7 +2605,7 @@ export function isModelCtor(value) {
1925
2605
  value.prototype instanceof Model);
1926
2606
  }
1927
2607
  // Re-exports — public API stays on ./model.ts for index.ts
1928
- export { BelongsTo, BelongsToMany, HasMany, HasManyThrough, HasOne, HasOneThrough, MorphMany, MorphOne, MorphTo, MorphToMany, clearMorphMap, morphMap, } from "./relations.js";
2608
+ export { BelongsTo, BelongsToMany, HasMany, HasManyThrough, HasOne, HasOneThrough, MorphMany, MorphOne, MorphTo, MorphToMany, MorphedByMany, clearMorphMap, morphMap, enforceMorphMap, requireMorphMap, getMorphedModel, } from "./relations.js";
1929
2609
  export { eagerLoadAggregates, eagerLoadModels, } from "./eager.js";
1930
2610
  export { ModelQuery } from "./model-query.js";
1931
2611
  export { LazyLoadingViolationException, MassAssignmentException, MissingAttributeException, resetModelStrictnessForTests, } from "./model-strictness.js";