@bunyad/orm 0.2.0-beta.1 → 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
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() {
@@ -1160,12 +1724,20 @@ export class Model {
1160
1724
  return this.forceCreate(filterFillable(this, attributes));
1161
1725
  }
1162
1726
  /** `Model::forceCreate` — insert every given attribute, ignoring `$fillable`. */
1163
- static forceCreate(useAttrs) {
1727
+ static forceCreate(useAttrs, beforeSave) {
1164
1728
  this.bootIfNotBooted();
1165
1729
  // Assign raw attributes (skip get-casts) so mutators like Attribute.set can run on save.
1166
1730
  const model = classHasNoCasts(this)
1167
1731
  ? new this(useAttrs)
1168
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) {
1169
1741
  const saved = model.save();
1170
1742
  const after = (row) => {
1171
1743
  if (classHasNoCasts(this)) {
@@ -1250,6 +1822,20 @@ export class Model {
1250
1822
  assignOwn(this, ctor.castAttributes(finalAttrs, "get"));
1251
1823
  return this;
1252
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
+ }
1253
1839
  /** `$model->update($attributes)`. */
1254
1840
  async update(attributes) {
1255
1841
  this.fill(attributes);
@@ -1294,6 +1880,20 @@ export class Model {
1294
1880
  });
1295
1881
  return this.save();
1296
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
+ }
1297
1897
  #persistSync(conn) {
1298
1898
  const ctor = this.constructor;
1299
1899
  const key = ctor.primaryKey;
@@ -1306,16 +1906,39 @@ export class Model {
1306
1906
  const usesTimestamps = ctor.timestamps !== false;
1307
1907
  if (usesTimestamps) {
1308
1908
  const now = nowForConnection(conn);
1309
- row.updated_at = now;
1310
1909
  if (!exists) {
1910
+ row.updated_at = now;
1311
1911
  row.created_at = row.created_at ?? now;
1312
1912
  }
1913
+ else {
1914
+ this.#stampUpdatedAt(now);
1915
+ }
1313
1916
  }
1314
1917
  if (!exists) {
1315
1918
  const noCasts = classHasNoCasts(ctor);
1316
- const cachedCols = noCasts
1919
+ let cachedCols = noCasts
1317
1920
  ? cachedSyncInsertColumns(ctor)
1318
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
+ }
1319
1942
  let writeColumns;
1320
1943
  let writeValues;
1321
1944
  if (cachedCols) {
@@ -1380,6 +2003,7 @@ export class Model {
1380
2003
  }
1381
2004
  this.#original = original;
1382
2005
  this.#changes = original;
2006
+ this.#previous = undefined;
1383
2007
  this.#touchOwnersSync();
1384
2008
  return this;
1385
2009
  }
@@ -1395,6 +2019,7 @@ export class Model {
1395
2019
  }
1396
2020
  this.#wasRecentlyCreated = false;
1397
2021
  this.#changes = dirty;
2022
+ this.#capturePrevious();
1398
2023
  this.syncOriginal();
1399
2024
  this.#touchOwnersSync();
1400
2025
  return this;
@@ -1408,19 +2033,19 @@ export class Model {
1408
2033
  if ((await fireModelEvent(this, "saving")) === false)
1409
2034
  return this;
1410
2035
  const usesTimestamps = ctor.timestamps !== false;
1411
- if (usesTimestamps) {
1412
- const now = nowForConnection(this.getConnection());
1413
- row.updated_at = now;
1414
- if (!exists) {
1415
- row.created_at = row.created_at ?? now;
1416
- }
1417
- }
1418
- const dirty = this.getDirty();
1419
2036
  const persistCtor = ctor;
1420
2037
  const table = new Db(this.getConnection()).table(ctor.table);
2038
+ // Order: saving → creating/updating → timestamps → write → created/updated → saved.
2039
+ let dirty;
1421
2040
  if (!exists) {
1422
2041
  if ((await fireModelEvent(this, "creating")) === false)
1423
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();
1424
2049
  const attributes = persistableAttributes(persistCtor, row, ctor.incrementing !== false || row[key] == null ? key : undefined);
1425
2050
  if (ctor.incrementing !== false) {
1426
2051
  const newId = await withoutQueryWriteHook(() => table.insertGetId(ctor.castAttributes(attributes, "set")));
@@ -1435,28 +2060,34 @@ export class Model {
1435
2060
  }
1436
2061
  this.#exists = true;
1437
2062
  this.#wasRecentlyCreated = true;
2063
+ this.#recordChanges(dirty, false, usesTimestamps);
1438
2064
  await fireModelEvent(this, "created");
1439
2065
  }
1440
2066
  else {
1441
2067
  if ((await fireModelEvent(this, "updating")) === false)
1442
2068
  return this;
1443
- const attributes = persistableAttributes(persistCtor, dirty, key);
1444
- if (Object.keys(attributes).length > 0) {
1445
- 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");
1446
2081
  }
1447
- this.#wasRecentlyCreated = false;
1448
- await fireModelEvent(this, "updated");
1449
- }
1450
- this.#changes = { ...dirty };
1451
- if (usesTimestamps) {
1452
- if ("updated_at" in row)
1453
- this.#changes.updated_at = row.updated_at;
1454
- if (!exists && "created_at" in row) {
1455
- this.#changes.created_at = row.created_at;
2082
+ else {
2083
+ this.#wasRecentlyCreated = false;
2084
+ this.#recordChanges(dirty, true, usesTimestamps);
1456
2085
  }
1457
2086
  }
1458
- this.syncOriginal();
2087
+ // `saved` fires first, then the original set is synced,
2088
+ // so `isDirty()` / `getOriginal()` still describe the save inside `updated` and `saved`.
1459
2089
  await fireModelEvent(this, "saved");
2090
+ this.syncOriginal();
1460
2091
  // finishSave → touchOwners (Relation.touch honors withoutTouching on related).
1461
2092
  await this.touchOwners();
1462
2093
  return this;
@@ -1475,6 +2106,8 @@ export class Model {
1475
2106
  if (fresh) {
1476
2107
  Object.assign(this, fresh.getAttributes());
1477
2108
  this.#original = { ...fresh.#original };
2109
+ this.#rawRow = fresh.#rawRow;
2110
+ this.#snapshots = fresh.#snapshots;
1478
2111
  this.#exists = true;
1479
2112
  }
1480
2113
  return this;
@@ -1494,6 +2127,8 @@ export class Model {
1494
2127
  if (fresh) {
1495
2128
  Object.assign(this, fresh.getAttributes());
1496
2129
  this.#original = { ...fresh.#original };
2130
+ this.#rawRow = fresh.#rawRow;
2131
+ this.#snapshots = fresh.#snapshots;
1497
2132
  this.#exists = true;
1498
2133
  }
1499
2134
  return this;
@@ -1645,8 +2280,8 @@ export class Model {
1645
2280
  return;
1646
2281
  if (usesSoftDeletes(ctor)) {
1647
2282
  await this.#performSoftDelete();
1648
- await fireModelEvent(this, "deleted");
1649
2283
  await fireModelEvent(this, "trashed");
2284
+ await fireModelEvent(this, "deleted");
1650
2285
  return;
1651
2286
  }
1652
2287
  // delete(): touchOwners before performDeleteOnModel.
@@ -1676,9 +2311,12 @@ export class Model {
1676
2311
  ctor.bootIfNotBooted();
1677
2312
  if ((await fireModelEvent(this, "forceDeleting")) === false)
1678
2313
  return;
2314
+ if ((await fireModelEvent(this, "deleting")) === false)
2315
+ return;
2316
+ await this.touchOwners();
1679
2317
  await this.#performForceDelete();
1680
- await fireModelEvent(this, "forceDeleted");
1681
2318
  await fireModelEvent(this, "deleted");
2319
+ await fireModelEvent(this, "forceDeleted");
1682
2320
  }
1683
2321
  /** `$model->deleteQuietly()`. */
1684
2322
  async deleteQuietly() {
@@ -1739,7 +2377,8 @@ export class Model {
1739
2377
  continue;
1740
2378
  if (visible && !visible.has(key))
1741
2379
  continue;
1742
- out[key] = value instanceof Date ? value.toISOString() : value;
2380
+ out[key] =
2381
+ value instanceof Date ? serializeDate(ctor, key, value) : value;
1743
2382
  }
1744
2383
  for (const key of appends) {
1745
2384
  if (hidden.has(key))
@@ -1808,8 +2447,14 @@ export class Model {
1808
2447
  }
1809
2448
  return this;
1810
2449
  }
2450
+ /** `toArray()` with `bigint` values as decimal strings, since JSON has no `bigint`. */
1811
2451
  toJSON() {
1812
- 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;
1813
2458
  }
1814
2459
  /** Alias of {@link toJSON}. */
1815
2460
  toJson() {
@@ -1919,6 +2564,14 @@ export class Model {
1919
2564
  : [name, primaryMorph].filter((value, index, all) => all.indexOf(value) === index);
1920
2565
  return new MorphToMany(this, related, table, foreign, relatedKey, morphTypeColumn, morphTypes, options.pivotTenantKey, options.parentTenantKey ?? options.pivotTenantKey);
1921
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
+ }
1922
2575
  /** `$this->morphMany(Related::class, name)`. */
1923
2576
  morphMany(related, name, type, id, localKey) {
1924
2577
  return new MorphMany(this, related, type ?? `${name}_type`, id ?? `${name}_id`, morphTypeFor(this.constructor), localKey);
@@ -1932,6 +2585,17 @@ export class Model {
1932
2585
  return new MorphTo(this, type ?? `${name}_type`, id ?? `${name}_id`, ownerKey);
1933
2586
  }
1934
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
+ }
1935
2599
  installLocalScopeCallStatic(Model);
1936
2600
  /** True when `value` is a Model subclass constructor (O(1) prototype check). */
1937
2601
  export function isModelCtor(value) {
@@ -1941,7 +2605,7 @@ export function isModelCtor(value) {
1941
2605
  value.prototype instanceof Model);
1942
2606
  }
1943
2607
  // Re-exports — public API stays on ./model.ts for index.ts
1944
- 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";
1945
2609
  export { eagerLoadAggregates, eagerLoadModels, } from "./eager.js";
1946
2610
  export { ModelQuery } from "./model-query.js";
1947
2611
  export { LazyLoadingViolationException, MassAssignmentException, MissingAttributeException, resetModelStrictnessForTests, } from "./model-strictness.js";