@getstrata/core 0.5.91 → 0.5.98

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.
@@ -2,6 +2,26 @@
2
2
  // ../../src/core/database/model.ts
3
3
  import { NotFoundError } from "@getstrata/core/errors/http";
4
4
 
5
+ // ../../src/core/database/inflection.ts
6
+ function singularize(word) {
7
+ if (word.endsWith("ies") && word.length > 3) {
8
+ return `${word.slice(0, -3)}y`;
9
+ }
10
+ if (/(ses|xes|zes|ches|shes)$/i.test(word)) {
11
+ return word.slice(0, -2);
12
+ }
13
+ if (word.endsWith("s") && !word.endsWith("ss")) {
14
+ return word.slice(0, -1);
15
+ }
16
+ return word;
17
+ }
18
+ function foreignKeyFromTable(tableName) {
19
+ return `${singularize(tableName)}_id`;
20
+ }
21
+ function pivotTableName(leftTable, rightTable) {
22
+ return [singularize(leftTable), singularize(rightTable)].sort().join("_");
23
+ }
24
+
5
25
  // ../../src/core/database/query.ts
6
26
  function quoteIdentifier(identifier) {
7
27
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
@@ -115,7 +135,16 @@ function buildWhereClause(tableName, where = {}) {
115
135
  params
116
136
  };
117
137
  }
138
+ function remapExistsSql(sql, existsParams, params) {
139
+ const offset = params.length;
140
+ params.push(...existsParams);
141
+ return sql.replace(/\$(\d+)/g, (_match, index) => `$${offset + Number(index)}`);
142
+ }
118
143
  function buildWhereNodeClause(tableName, node, params) {
144
+ if ("exists" in node) {
145
+ const body = remapExistsSql(node.exists.sql, node.exists.params, params);
146
+ return `${node.exists.not ? "NOT " : ""}EXISTS (${body})`;
147
+ }
119
148
  if ("where" in node) {
120
149
  return appendWhereParts(tableName, node.where, params);
121
150
  }
@@ -416,6 +445,428 @@ function buildDeleteByIdQuery(table, id) {
416
445
  };
417
446
  }
418
447
 
448
+ // ../../src/core/database/relationQuery.ts
449
+ function asWhere(where) {
450
+ return where;
451
+ }
452
+ function ownerId(owner, ownerKey) {
453
+ if (typeof owner.get === "function") {
454
+ return owner.get(ownerKey);
455
+ }
456
+ if (owner && typeof owner === "object" && "id" in owner && owner.id !== undefined) {
457
+ return owner.id;
458
+ }
459
+ throw new Error("belongsTo.associate() requires a related model or { id }.");
460
+ }
461
+
462
+ class HasManyRelationQuery {
463
+ parent;
464
+ related;
465
+ relation;
466
+ kind = "hasMany";
467
+ extraWhere = {};
468
+ extraOptions = {};
469
+ constructor(parent, related, relation) {
470
+ this.parent = parent;
471
+ this.related = related;
472
+ this.relation = relation;
473
+ }
474
+ where(where) {
475
+ this.extraWhere = { ...this.extraWhere, ...where };
476
+ return this;
477
+ }
478
+ orderBy(orderBy) {
479
+ this.extraOptions = { ...this.extraOptions, orderBy };
480
+ return this;
481
+ }
482
+ limit(limit) {
483
+ this.extraOptions = { ...this.extraOptions, limit };
484
+ return this;
485
+ }
486
+ applyEagerLoad(query, alias) {
487
+ query.withHasMany(alias, this.relation, this.related.repository(), this.extraOptions);
488
+ }
489
+ hydrateEager(row, alias) {
490
+ const value = row[alias] ?? [];
491
+ const rows = Array.isArray(value) ? value : [];
492
+ return rows.map((item) => this.related.newFromRecord(item));
493
+ }
494
+ toExistsClause(parentTable) {
495
+ const childTable = this.related.repository().getTable().name;
496
+ const extra = buildAdvancedWhereClause(childTable, this.extraWhere, [], []);
497
+ const extraSql = extra.clause.replace(/^ WHERE /, "");
498
+ const sql = `SELECT 1 FROM ${quoteIdentifier(childTable)} WHERE ${qualifyColumn(childTable, this.relation.foreignKey)} = ${qualifyColumn(parentTable, this.relation.localKey)}${extraSql ? ` AND ${extraSql}` : ""}`;
499
+ return { sql, params: extra.params };
500
+ }
501
+ scopedQuery() {
502
+ const repository = this.related.repository().withConnection(this.parent.getRepository().getConnection());
503
+ let query = repository.query(asWhere({
504
+ [this.relation.foreignKey]: this.parent.get(this.relation.localKey),
505
+ ...this.extraWhere
506
+ }));
507
+ if (this.extraOptions.orderBy) {
508
+ query = query.orderBy(this.extraOptions.orderBy);
509
+ }
510
+ if (this.extraOptions.limit !== undefined) {
511
+ query = query.limit(this.extraOptions.limit);
512
+ }
513
+ return query;
514
+ }
515
+ async get() {
516
+ const rows = await this.scopedQuery().get();
517
+ return rows.map((row) => this.related.newFromRecord(row));
518
+ }
519
+ async first() {
520
+ const rows = await this.limit(1).get();
521
+ return rows[0] ?? null;
522
+ }
523
+ async count() {
524
+ return (await this.get()).length;
525
+ }
526
+ async create(attributes = {}) {
527
+ return this.related.create(attributes, {
528
+ [this.relation.foreignKey]: this.parent.get(this.relation.localKey)
529
+ });
530
+ }
531
+ async createMany(records) {
532
+ const created = [];
533
+ for (const attributes of records) {
534
+ created.push(await this.create(attributes));
535
+ }
536
+ return created;
537
+ }
538
+ }
539
+
540
+ class HasOneRelationQuery {
541
+ relation;
542
+ kind = "hasOne";
543
+ inner;
544
+ constructor(parent, related, relation) {
545
+ this.relation = relation;
546
+ this.inner = new HasManyRelationQuery(parent, related, {
547
+ type: "hasMany",
548
+ name: relation.name,
549
+ localKey: relation.localKey,
550
+ foreignKey: relation.foreignKey
551
+ });
552
+ }
553
+ where(where) {
554
+ this.inner.where(where);
555
+ return this;
556
+ }
557
+ orderBy(orderBy) {
558
+ this.inner.orderBy(orderBy);
559
+ return this;
560
+ }
561
+ applyEagerLoad(query, alias) {
562
+ this.inner.limit(1).applyEagerLoad(query, alias);
563
+ }
564
+ hydrateEager(row, alias) {
565
+ const hydrated = this.inner.hydrateEager(row, alias);
566
+ return hydrated[0];
567
+ }
568
+ toExistsClause(parentTable) {
569
+ return this.inner.toExistsClause(parentTable);
570
+ }
571
+ async get() {
572
+ return this.inner.limit(1).first();
573
+ }
574
+ async first() {
575
+ return this.get();
576
+ }
577
+ async count() {
578
+ return await this.get() ? 1 : 0;
579
+ }
580
+ async create(attributes = {}) {
581
+ return this.inner.create(attributes);
582
+ }
583
+ }
584
+
585
+ class BelongsToRelationQuery {
586
+ parent;
587
+ related;
588
+ relation;
589
+ kind = "belongsTo";
590
+ extraOptions = {};
591
+ constructor(parent, related, relation) {
592
+ this.parent = parent;
593
+ this.related = related;
594
+ this.relation = relation;
595
+ }
596
+ orderBy(orderBy) {
597
+ this.extraOptions = { ...this.extraOptions, orderBy };
598
+ return this;
599
+ }
600
+ applyEagerLoad(query, alias) {
601
+ query.withBelongsTo(alias, this.relation, this.related.repository(), this.extraOptions);
602
+ }
603
+ hydrateEager(row, alias) {
604
+ const value = row[alias];
605
+ return value ? this.related.newFromRecord(value) : value;
606
+ }
607
+ toExistsClause(parentTable) {
608
+ const relatedTable = this.related.repository().getTable().name;
609
+ return {
610
+ sql: `SELECT 1 FROM ${quoteIdentifier(relatedTable)} WHERE ${qualifyColumn(relatedTable, this.relation.ownerKey)} = ${qualifyColumn(parentTable, this.relation.foreignKey)}`,
611
+ params: []
612
+ };
613
+ }
614
+ async get() {
615
+ const foreign = this.parent.get(this.relation.foreignKey);
616
+ if (foreign === null || foreign === undefined) {
617
+ return null;
618
+ }
619
+ const repository = this.related.repository().withConnection(this.parent.getRepository().getConnection());
620
+ let query = repository.query(asWhere({ [this.relation.ownerKey]: foreign }));
621
+ if (this.extraOptions.orderBy) {
622
+ query = query.orderBy(this.extraOptions.orderBy);
623
+ }
624
+ const row = await query.first();
625
+ return row ? this.related.newFromRecord(row) : null;
626
+ }
627
+ async first() {
628
+ return this.get();
629
+ }
630
+ async associate(owner) {
631
+ await this.parent.getRepository().updateById(this.parent.id, {
632
+ [this.relation.foreignKey]: ownerId(owner, this.relation.ownerKey)
633
+ });
634
+ }
635
+ async dissociate() {
636
+ await this.parent.getRepository().updateById(this.parent.id, {
637
+ [this.relation.foreignKey]: null
638
+ });
639
+ }
640
+ }
641
+
642
+ class BelongsToManyRelationQuery {
643
+ parent;
644
+ related;
645
+ relation;
646
+ kind = "belongsToMany";
647
+ extraWhere = {};
648
+ extraOptions = {};
649
+ constructor(parent, related, relation) {
650
+ this.parent = parent;
651
+ this.related = related;
652
+ this.relation = relation;
653
+ }
654
+ where(where) {
655
+ this.extraWhere = { ...this.extraWhere, ...where };
656
+ return this;
657
+ }
658
+ orderBy(orderBy) {
659
+ this.extraOptions = { ...this.extraOptions, orderBy };
660
+ return this;
661
+ }
662
+ applyEagerLoad(_query, _alias) {}
663
+ hydrateEager(row, alias) {
664
+ const value = row[alias] ?? [];
665
+ const rows = Array.isArray(value) ? value : [];
666
+ return rows.map((item) => this.related.newFromRecord(item));
667
+ }
668
+ toExistsClause(parentTable) {
669
+ const relatedTable = this.related.repository().getTable().name;
670
+ const extra = buildAdvancedWhereClause(relatedTable, this.extraWhere, [], []);
671
+ const extraSql = extra.clause.replace(/^ WHERE /, "");
672
+ const sql = `SELECT 1 FROM ${quoteIdentifier(relatedTable)} INNER JOIN ${quoteIdentifier(this.relation.pivotTable)} ON ${qualifyColumn(this.relation.pivotTable, this.relation.relatedPivotKey)} = ${qualifyColumn(relatedTable, this.relation.relatedKey)} WHERE ${qualifyColumn(this.relation.pivotTable, this.relation.foreignPivotKey)} = ${qualifyColumn(parentTable, this.relation.parentKey)}${extraSql ? ` AND ${extraSql}` : ""}`;
673
+ return { sql, params: extra.params };
674
+ }
675
+ connection() {
676
+ return this.parent.getRepository().getConnection();
677
+ }
678
+ async get() {
679
+ const parentId = this.parent.get(this.relation.parentKey);
680
+ const pivotRows = await this.connection().unsafe(`SELECT * FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
681
+ if (pivotRows.length === 0) {
682
+ return [];
683
+ }
684
+ const relatedIds = [
685
+ ...new Set(pivotRows.map((row) => row[this.relation.relatedPivotKey]))
686
+ ];
687
+ const repository = this.related.repository().withConnection(this.connection());
688
+ const rows = await repository.findAll({
689
+ ...this.extraOptions,
690
+ where: asWhere({
691
+ [this.relation.relatedKey]: relatedIds,
692
+ ...this.extraWhere
693
+ })
694
+ });
695
+ return rows.map((row) => this.related.newFromRecord(row));
696
+ }
697
+ async first() {
698
+ const rows = await this.get();
699
+ return rows[0] ?? null;
700
+ }
701
+ async count() {
702
+ return (await this.get()).length;
703
+ }
704
+ async attach(ids) {
705
+ const list = Array.isArray(ids) ? ids : [ids];
706
+ const parentId = this.parent.get(this.relation.parentKey);
707
+ for (const id of list) {
708
+ await this.connection().unsafe(`INSERT INTO ${this.relation.pivotTable} (${String(this.relation.foreignPivotKey)}, ${String(this.relation.relatedPivotKey)}) VALUES ($1, $2)`, [parentId, id]);
709
+ }
710
+ }
711
+ async detach(ids) {
712
+ const parentId = this.parent.get(this.relation.parentKey);
713
+ if (ids === undefined) {
714
+ await this.connection().unsafe(`DELETE FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1`, [parentId]);
715
+ return;
716
+ }
717
+ const list = Array.isArray(ids) ? ids : [ids];
718
+ await this.connection().unsafe(`DELETE FROM ${this.relation.pivotTable} WHERE ${String(this.relation.foreignPivotKey)} = $1 AND ${String(this.relation.relatedPivotKey)} = ANY($2)`, [parentId, list]);
719
+ }
720
+ async sync(ids) {
721
+ await this.detach();
722
+ if (ids.length > 0) {
723
+ await this.attach(ids);
724
+ }
725
+ }
726
+ async create(attributes = {}) {
727
+ const related = await this.related.create(attributes);
728
+ await this.attach(related.id);
729
+ return related;
730
+ }
731
+ }
732
+
733
+ class MorphManyRelationQuery {
734
+ parent;
735
+ related;
736
+ relation;
737
+ kind = "morphMany";
738
+ extraWhere = {};
739
+ extraOptions = {};
740
+ constructor(parent, related, relation) {
741
+ this.parent = parent;
742
+ this.related = related;
743
+ this.relation = relation;
744
+ }
745
+ where(where) {
746
+ this.extraWhere = { ...this.extraWhere, ...where };
747
+ return this;
748
+ }
749
+ applyEagerLoad(query, alias) {
750
+ query.withMorphMany(alias, this.relation, this.related.repository(), this.extraOptions);
751
+ }
752
+ hydrateEager(row, alias) {
753
+ const value = row[alias] ?? [];
754
+ const rows = Array.isArray(value) ? value : [];
755
+ return rows.map((item) => this.related.newFromRecord(item));
756
+ }
757
+ toExistsClause(parentTable) {
758
+ const childTable = this.related.repository().getTable().name;
759
+ const extra = buildAdvancedWhereClause(childTable, {
760
+ [this.relation.morphTypeKey]: this.relation.morphType,
761
+ ...this.extraWhere
762
+ }, [], []);
763
+ const extraSql = extra.clause.replace(/^ WHERE /, "");
764
+ const sql = `SELECT 1 FROM ${quoteIdentifier(childTable)} WHERE ${qualifyColumn(childTable, this.relation.morphIdKey)} = ${qualifyColumn(parentTable, this.relation.localKey)}${extraSql ? ` AND ${extraSql}` : ""}`;
765
+ return { sql, params: extra.params };
766
+ }
767
+ async get() {
768
+ const repository = this.related.repository().withConnection(this.parent.getRepository().getConnection());
769
+ const rows = await repository.query(asWhere({
770
+ [this.relation.morphTypeKey]: this.relation.morphType,
771
+ [this.relation.morphIdKey]: this.parent.get(this.relation.localKey),
772
+ ...this.extraWhere
773
+ })).get();
774
+ return rows.map((row) => this.related.newFromRecord(row));
775
+ }
776
+ async first() {
777
+ const rows = await this.get();
778
+ return rows[0] ?? null;
779
+ }
780
+ async create(attributes = {}) {
781
+ return this.related.create(attributes, {
782
+ [this.relation.morphTypeKey]: this.relation.morphType,
783
+ [this.relation.morphIdKey]: this.parent.get(this.relation.localKey)
784
+ });
785
+ }
786
+ }
787
+
788
+ class MorphOneRelationQuery {
789
+ relation;
790
+ kind = "morphOne";
791
+ inner;
792
+ constructor(parent, related, relation) {
793
+ this.relation = relation;
794
+ this.inner = new MorphManyRelationQuery(parent, related, {
795
+ type: "morphMany",
796
+ name: relation.name,
797
+ localKey: relation.localKey,
798
+ morphTypeKey: relation.morphTypeKey,
799
+ morphIdKey: relation.morphIdKey,
800
+ morphType: relation.morphType
801
+ });
802
+ }
803
+ where(where) {
804
+ this.inner.where(where);
805
+ return this;
806
+ }
807
+ applyEagerLoad(query, alias) {
808
+ this.inner.applyEagerLoad(query, alias);
809
+ }
810
+ hydrateEager(row, alias) {
811
+ const hydrated = this.inner.hydrateEager(row, alias);
812
+ return hydrated[0];
813
+ }
814
+ toExistsClause(parentTable) {
815
+ return this.inner.toExistsClause(parentTable);
816
+ }
817
+ async get() {
818
+ return this.inner.first();
819
+ }
820
+ async create(attributes = {}) {
821
+ return this.inner.create(attributes);
822
+ }
823
+ }
824
+
825
+ class MorphToRelationQuery {
826
+ parent;
827
+ relatedByType;
828
+ relation;
829
+ kind = "morphTo";
830
+ constructor(parent, relatedByType, relation) {
831
+ this.parent = parent;
832
+ this.relatedByType = relatedByType;
833
+ this.relation = relation;
834
+ }
835
+ applyEagerLoad(query, alias) {
836
+ const repositories = new Map(Object.entries(this.relatedByType).map(([type, model]) => [
837
+ type,
838
+ model.repository()
839
+ ]));
840
+ query.withMorphTo(alias, this.relation, repositories);
841
+ }
842
+ hydrateEager(row, alias) {
843
+ return row[alias];
844
+ }
845
+ toExistsClause(parentTable) {
846
+ const type = String(this.parent.get(this.relation.morphTypeKey) ?? "");
847
+ const related = this.relatedByType[type];
848
+ if (!related) {
849
+ return { sql: "SELECT 1 WHERE 1 = 0", params: [] };
850
+ }
851
+ const relatedTable = related.repository().getTable();
852
+ return {
853
+ sql: `SELECT 1 FROM ${quoteIdentifier(relatedTable.name)} WHERE ${qualifyColumn(relatedTable.name, relatedTable.primaryKey)} = ${qualifyColumn(parentTable, this.relation.morphIdKey)}`,
854
+ params: []
855
+ };
856
+ }
857
+ async get() {
858
+ const type = String(this.parent.get(this.relation.morphTypeKey) ?? "");
859
+ const id = this.parent.get(this.relation.morphIdKey);
860
+ const related = this.relatedByType[type];
861
+ if (!related || id === null || id === undefined) {
862
+ return null;
863
+ }
864
+ const table = related.repository().getTable();
865
+ const row = await related.repository().withConnection(this.parent.getRepository().getConnection()).query(asWhere({ [table.primaryKey]: id })).first();
866
+ return row ? related.newFromRecord(row) : null;
867
+ }
868
+ }
869
+
419
870
  // ../../src/core/database/relationships.ts
420
871
  function hasMany(definition) {
421
872
  return {
@@ -565,7 +1016,54 @@ function indexMorphToRelation(children, parentsByType, relation) {
565
1016
  // ../../src/core/database/model.ts
566
1017
  var modelRepositories = new WeakMap;
567
1018
  var modelGlobalScopes = new WeakMap;
1019
+ var modelObservers = new WeakMap;
568
1020
  var modelBooted = new WeakSet;
1021
+ async function runObservers(model, hook) {
1022
+ const observers = modelObservers.get(model.constructor) ?? [];
1023
+ for (const observer of observers) {
1024
+ const handler = observer[hook];
1025
+ if (handler && await handler(model) === false) {
1026
+ return false;
1027
+ }
1028
+ }
1029
+ return true;
1030
+ }
1031
+ function accessorName(key) {
1032
+ const pascal = key.replace(/_([a-z])/g, (_match, letter) => letter.toUpperCase());
1033
+ return `get${pascal.charAt(0).toUpperCase()}${pascal.slice(1)}Attribute`;
1034
+ }
1035
+ function constrainRelationExists(model, name, constrain, not) {
1036
+ const statics = modelStatics(model);
1037
+ ensureBooted(model);
1038
+ const repository = resolveModelRepository(model);
1039
+ const dummy = statics.newFromRecord({});
1040
+ const method = dummy[name];
1041
+ if (typeof method !== "function") {
1042
+ throw new Error(`${model.name} has no relation method ${name}().`);
1043
+ }
1044
+ const relationQuery = method.call(dummy);
1045
+ constrain?.(relationQuery);
1046
+ const exists = relationQuery.toExistsClause(repository.getTable().name);
1047
+ const query = Model.query.call(model);
1048
+ return not ? query.whereNotExists(exists.sql, exists.params) : query.whereExists(exists.sql, exists.params);
1049
+ }
1050
+ async function loadNested(model, path) {
1051
+ const [head, ...rest] = path.split(".");
1052
+ if (!head) {
1053
+ return;
1054
+ }
1055
+ await model.load(head);
1056
+ if (rest.length === 0) {
1057
+ return;
1058
+ }
1059
+ const loaded = model.loaded(head);
1060
+ const children = Array.isArray(loaded) ? loaded : loaded ? [loaded] : [];
1061
+ for (const child of children) {
1062
+ if (child && typeof child === "object" && typeof child.load === "function") {
1063
+ await loadNested(child, rest.join("."));
1064
+ }
1065
+ }
1066
+ }
569
1067
  function resolveModelRepository(model) {
570
1068
  const repository = modelRepositories.get(model);
571
1069
  if (!repository) {
@@ -680,12 +1178,22 @@ class Model {
680
1178
  static $guarded;
681
1179
  static $casts = {};
682
1180
  static $timestamps = true;
1181
+ static $hidden;
1182
+ static $visible;
1183
+ static $appends;
683
1184
  _exists;
1185
+ loadedRelations = {};
1186
+ hiddenOverrides = [];
1187
+ visibleOverrides = [];
1188
+ appended = [];
684
1189
  constructor(attributes, repository, exists = true) {
685
1190
  this.attributes = attributes;
686
1191
  this.repository = repository;
687
1192
  this._exists = exists;
688
1193
  }
1194
+ getRepository() {
1195
+ return this.repository;
1196
+ }
689
1197
  get $exists() {
690
1198
  return this._exists;
691
1199
  }
@@ -698,6 +1206,49 @@ class Model {
698
1206
  toObject() {
699
1207
  return { ...this.attributes };
700
1208
  }
1209
+ toArray() {
1210
+ const ModelClass = modelStatics(this.constructor);
1211
+ const hidden = new Set([...ModelClass.$hidden ?? [], ...this.hiddenOverrides]);
1212
+ const visible = this.visibleOverrides.length > 0 ? this.visibleOverrides : ModelClass.$visible;
1213
+ const data = { ...this.attributes };
1214
+ if (visible && visible.length > 0) {
1215
+ for (const key of Object.keys(data)) {
1216
+ if (!visible.includes(key)) {
1217
+ delete data[key];
1218
+ }
1219
+ }
1220
+ }
1221
+ for (const key of hidden) {
1222
+ delete data[key];
1223
+ }
1224
+ for (const key of [...ModelClass.$appends ?? [], ...this.appended]) {
1225
+ const accessor = this[accessorName(key)];
1226
+ if (typeof accessor === "function") {
1227
+ data[key] = accessor.call(this);
1228
+ }
1229
+ }
1230
+ for (const [name, value] of Object.entries(this.loadedRelations)) {
1231
+ if (!hidden.has(name) && (!visible || visible.includes(name) || this.appended.includes(name))) {
1232
+ data[name] = value;
1233
+ }
1234
+ }
1235
+ return data;
1236
+ }
1237
+ toJSON() {
1238
+ return this.toArray();
1239
+ }
1240
+ makeHidden(...keys) {
1241
+ this.hiddenOverrides.push(...keys);
1242
+ return this;
1243
+ }
1244
+ makeVisible(...keys) {
1245
+ this.visibleOverrides.push(...keys);
1246
+ return this;
1247
+ }
1248
+ append(...keys) {
1249
+ this.appended.push(...keys);
1250
+ return this;
1251
+ }
701
1252
  primaryKey() {
702
1253
  throw new Error(`${this.constructor.name}.primaryKey() is not implemented.`);
703
1254
  }
@@ -718,6 +1269,10 @@ class Model {
718
1269
  return new statics(hydrated, repository, exists);
719
1270
  }
720
1271
  static boot() {}
1272
+ static observe(observer) {
1273
+ const existing = modelObservers.get(this) ?? [];
1274
+ modelObservers.set(this, [...existing, observer]);
1275
+ }
721
1276
  static addGlobalScope(_name, scope) {
722
1277
  ensureBooted(this);
723
1278
  const existing = modelGlobalScopes.get(this) ?? [];
@@ -738,17 +1293,89 @@ class Model {
738
1293
  }
739
1294
  return query;
740
1295
  }
741
- static async create(attributes) {
1296
+ static newFromRecord(record, exists = true) {
1297
+ const repository = resolveModelRepository(this);
1298
+ return modelStatics(this).fromRecord(record, repository, exists);
1299
+ }
1300
+ static async create(attributes, forced = {}) {
742
1301
  const statics = modelStatics(this);
743
1302
  ensureBooted(this);
744
1303
  const repository = resolveModelRepository(this);
745
1304
  const table = repository.getTable();
746
1305
  const timestamps = statics.$timestamps ?? true;
747
- const assignable = filterMassAssignable(statics.$fillable, statics.$guarded, attributes);
1306
+ const assignable = {
1307
+ ...filterMassAssignable(statics.$fillable, statics.$guarded, attributes),
1308
+ ...forced
1309
+ };
748
1310
  const withTimestamps = applyTimestampsOnCreate(table.columns, assignable, timestamps);
749
1311
  const payload = statics.dehydrateAttributes(withTimestamps);
1312
+ const pending = statics.newFromRecord({ ...payload }, false);
1313
+ if (await runObservers(pending, "creating") === false) {
1314
+ throw new Error(`${this.name}.create() was cancelled by an observer.`);
1315
+ }
750
1316
  const record = await repository.create(payload);
751
- return statics.fromRecord(record, repository, true);
1317
+ const created = statics.fromRecord(record, repository, true);
1318
+ await runObservers(created, "created");
1319
+ return created;
1320
+ }
1321
+ static with(...relations) {
1322
+ const statics = modelStatics(this);
1323
+ ensureBooted(this);
1324
+ const repository = resolveModelRepository(this);
1325
+ const dummy = statics.fromRecord({}, repository, false);
1326
+ const resolved = relations.map((path) => {
1327
+ const name = path.split(".")[0] ?? path;
1328
+ const method = dummy[name];
1329
+ if (typeof method !== "function") {
1330
+ throw new Error(`${this.name} has no relation method ${name}().`);
1331
+ }
1332
+ const relationQuery = method.call(dummy);
1333
+ return { name, path, relationQuery };
1334
+ });
1335
+ const query = Model.query.call(this);
1336
+ for (const { name, relationQuery } of resolved) {
1337
+ if (relationQuery.kind !== "belongsToMany") {
1338
+ relationQuery.applyEagerLoad(query, name);
1339
+ }
1340
+ }
1341
+ return {
1342
+ async get() {
1343
+ const rows = await query.get();
1344
+ const models = [];
1345
+ for (const row of rows) {
1346
+ const model = statics.fromRecord(row, repository, true);
1347
+ for (const { name, path, relationQuery } of resolved) {
1348
+ if (relationQuery.kind === "belongsToMany") {
1349
+ await model.load(path.includes(".") ? path : name);
1350
+ continue;
1351
+ }
1352
+ model.setLoaded(name, relationQuery.hydrateEager(row, name));
1353
+ const nested = path.split(".").slice(1).join(".");
1354
+ if (nested) {
1355
+ await loadNested(model, path);
1356
+ }
1357
+ }
1358
+ models.push(model);
1359
+ }
1360
+ return models;
1361
+ },
1362
+ async first() {
1363
+ const [model] = await this.get();
1364
+ return model ?? null;
1365
+ }
1366
+ };
1367
+ }
1368
+ static whereHas(name, constrain) {
1369
+ return constrainRelationExists(this, name, constrain, false);
1370
+ }
1371
+ static has(name) {
1372
+ return constrainRelationExists(this, name, undefined, false);
1373
+ }
1374
+ static doesntHave(name) {
1375
+ return constrainRelationExists(this, name, undefined, true);
1376
+ }
1377
+ static whereDoesntHave(name, constrain) {
1378
+ return constrainRelationExists(this, name, constrain, true);
752
1379
  }
753
1380
  static async find(id) {
754
1381
  const statics = modelStatics(this);
@@ -777,6 +1404,9 @@ class Model {
777
1404
  const rows = await query.get();
778
1405
  return rows.map((row) => statics.fromRecord(row, repository, true));
779
1406
  }
1407
+ static where(where) {
1408
+ return Model.query.call(this).where(where);
1409
+ }
780
1410
  static async firstWhere(where, options = {}) {
781
1411
  const statics = modelStatics(this);
782
1412
  const repository = resolveModelRepository(this);
@@ -787,15 +1417,41 @@ class Model {
787
1417
  const record = await query.first();
788
1418
  return record ? statics.fromRecord(record, repository, true) : null;
789
1419
  }
1420
+ static async firstOrNew(where, values = {}) {
1421
+ const existing = await Model.firstWhere.call(this, where);
1422
+ if (existing) {
1423
+ return existing;
1424
+ }
1425
+ return modelStatics(this).newFromRecord({ ...where, ...values }, false);
1426
+ }
1427
+ static async firstOrCreate(where, values = {}) {
1428
+ const existing = await Model.firstWhere.call(this, where);
1429
+ if (existing) {
1430
+ return existing;
1431
+ }
1432
+ return Model.create.call(this, { ...where, ...values });
1433
+ }
1434
+ static async updateOrCreate(where, values = {}) {
1435
+ const existing = await Model.firstWhere.call(this, where);
1436
+ if (existing) {
1437
+ return existing.update(values);
1438
+ }
1439
+ return Model.create.call(this, { ...where, ...values });
1440
+ }
790
1441
  async save() {
791
1442
  const ModelClass = modelStatics(this.constructor);
792
1443
  const timestamps = ModelClass.$timestamps ?? true;
793
1444
  const casts = ModelClass.$casts ?? {};
794
1445
  const table = this.repository.getTable();
795
- if (this.$exists) {
1446
+ const updating = this.$exists;
1447
+ if (await runObservers(this, updating ? "updating" : "creating") === false) {
1448
+ return this;
1449
+ }
1450
+ if (updating) {
796
1451
  const changes = applyTimestampsOnUpdate(table.columns, applyCasts(this.attributes, casts, "dehydrate"), timestamps);
797
1452
  const record2 = await this.repository.updateByIdOrThrow(this.id, changes);
798
1453
  this.attributes = ModelClass.hydrateAttributes(record2);
1454
+ await runObservers(this, "updated");
799
1455
  return this;
800
1456
  }
801
1457
  const assignable = filterMassAssignable(ModelClass.$fillable, ModelClass.$guarded, this.attributes);
@@ -804,6 +1460,7 @@ class Model {
804
1460
  const record = await this.repository.create(payload);
805
1461
  this.attributes = ModelClass.hydrateAttributes(record);
806
1462
  this._exists = true;
1463
+ await runObservers(this, "created");
807
1464
  return this;
808
1465
  }
809
1466
  async update(changes) {
@@ -813,10 +1470,14 @@ class Model {
813
1470
  return await this.save();
814
1471
  }
815
1472
  async delete() {
816
- if (resolveSoftDeleteColumn(this.repository.getTable())) {
817
- return await this.repository.deleteById(this.id);
1473
+ if (await runObservers(this, "deleting") === false) {
1474
+ return false;
818
1475
  }
819
- return await this.repository.forceDeleteById(this.id);
1476
+ const deleted = resolveSoftDeleteColumn(this.repository.getTable()) ? await this.repository.deleteById(this.id) : await this.repository.forceDeleteById(this.id);
1477
+ if (deleted) {
1478
+ await runObservers(this, "deleted");
1479
+ }
1480
+ return deleted;
820
1481
  }
821
1482
  async forceDelete() {
822
1483
  return await this.repository.forceDeleteById(this.id);
@@ -865,6 +1526,91 @@ class Model {
865
1526
  const loaded = grouped.get(parentId) ?? [];
866
1527
  return Object.assign(this, { [as]: loaded });
867
1528
  }
1529
+ hasMany(related, foreignKey, localKey) {
1530
+ const table = this.repository.getTable();
1531
+ return new HasManyRelationQuery(this, related, hasMany({
1532
+ name: related.repository().getTable().name,
1533
+ localKey: localKey ?? table.primaryKey,
1534
+ foreignKey: foreignKey ?? foreignKeyFromTable(table.name)
1535
+ }));
1536
+ }
1537
+ hasOne(related, foreignKey, localKey) {
1538
+ const table = this.repository.getTable();
1539
+ return new HasOneRelationQuery(this, related, hasOne({
1540
+ name: related.repository().getTable().name,
1541
+ localKey: localKey ?? table.primaryKey,
1542
+ foreignKey: foreignKey ?? foreignKeyFromTable(table.name)
1543
+ }));
1544
+ }
1545
+ belongsTo(related, foreignKey, ownerKey) {
1546
+ const relatedTable = related.repository().getTable();
1547
+ return new BelongsToRelationQuery(this, related, belongsTo({
1548
+ name: relatedTable.name,
1549
+ foreignKey: foreignKey ?? foreignKeyFromTable(relatedTable.name),
1550
+ ownerKey: ownerKey ?? relatedTable.primaryKey
1551
+ }));
1552
+ }
1553
+ belongsToMany(related, pivotTable, foreignPivotKey, relatedPivotKey) {
1554
+ const table = this.repository.getTable();
1555
+ const relatedTable = related.repository().getTable();
1556
+ return new BelongsToManyRelationQuery(this, related, belongsToMany({
1557
+ name: relatedTable.name,
1558
+ pivotTable: pivotTable ?? pivotTableName(table.name, relatedTable.name),
1559
+ parentKey: table.primaryKey,
1560
+ relatedKey: relatedTable.primaryKey,
1561
+ foreignPivotKey: foreignPivotKey ?? foreignKeyFromTable(table.name),
1562
+ relatedPivotKey: relatedPivotKey ?? foreignKeyFromTable(relatedTable.name)
1563
+ }));
1564
+ }
1565
+ morphMany(related, morphName, typeKey, idKey) {
1566
+ const table = this.repository.getTable();
1567
+ return new MorphManyRelationQuery(this, related, morphMany({
1568
+ name: morphName,
1569
+ localKey: table.primaryKey,
1570
+ morphTypeKey: typeKey ?? `${morphName}_type`,
1571
+ morphIdKey: idKey ?? `${morphName}_id`,
1572
+ morphType: table.name
1573
+ }));
1574
+ }
1575
+ morphOne(related, morphName, typeKey, idKey) {
1576
+ const table = this.repository.getTable();
1577
+ return new MorphOneRelationQuery(this, related, morphOne({
1578
+ name: morphName,
1579
+ localKey: table.primaryKey,
1580
+ morphTypeKey: typeKey ?? `${morphName}_type`,
1581
+ morphIdKey: idKey ?? `${morphName}_id`,
1582
+ morphType: table.name
1583
+ }));
1584
+ }
1585
+ morphTo(relatedByType, morphName = "imageable", typeKey, idKey) {
1586
+ return new MorphToRelationQuery(this, relatedByType, morphTo({
1587
+ name: morphName,
1588
+ morphTypeKey: typeKey ?? `${morphName}_type`,
1589
+ morphIdKey: idKey ?? `${morphName}_id`
1590
+ }));
1591
+ }
1592
+ async load(...names) {
1593
+ for (const name of names) {
1594
+ if (name.includes(".")) {
1595
+ await loadNested(this, name);
1596
+ continue;
1597
+ }
1598
+ const method = this[name];
1599
+ if (typeof method !== "function") {
1600
+ throw new Error(`${this.constructor.name} has no relation method ${name}().`);
1601
+ }
1602
+ const relationQuery = method.call(this);
1603
+ this.loadedRelations[name] = await relationQuery.get();
1604
+ }
1605
+ return this;
1606
+ }
1607
+ loaded(name) {
1608
+ return this.loadedRelations[name];
1609
+ }
1610
+ setLoaded(name, value) {
1611
+ this.loadedRelations[name] = value;
1612
+ return this;
1613
+ }
868
1614
  mergeAttributes(patch) {
869
1615
  Object.assign(this.attributes, patch);
870
1616
  return this;
@@ -876,7 +1622,14 @@ function registerModelRepository(model, repository) {
876
1622
  return model;
877
1623
  }
878
1624
  export {
1625
+ BelongsToManyRelationQuery,
1626
+ BelongsToRelationQuery,
1627
+ HasManyRelationQuery,
1628
+ HasOneRelationQuery,
879
1629
  Model,
1630
+ MorphManyRelationQuery,
1631
+ MorphOneRelationQuery,
1632
+ MorphToRelationQuery,
880
1633
  applyCasts,
881
1634
  dehydrateValue,
882
1635
  filterMassAssignable,