millas 0.1.8 → 0.1.9

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.
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const QueryBuilder = require('../query/QueryBuilder');
3
+ const QueryBuilder = require('../query/QueryBuilder');
4
+ const LookupParser = require('../query/LookupParser');
4
5
  const DatabaseManager = require('../drivers/DatabaseManager');
5
6
 
6
7
  /**
@@ -8,80 +9,176 @@ const DatabaseManager = require('../drivers/DatabaseManager');
8
9
  *
9
10
  * Base class for all Millas ORM models.
10
11
  *
11
- * Define your schema with static fields and interact via static
12
- * query methods — no instantiation needed for reads.
12
+ * ── Schema ───────────────────────────────────────────────────────────────────
13
13
  *
14
- * Usage:
14
+ * class Post extends Model {
15
+ * static table = 'posts';
16
+ * static fields = {
17
+ * id: fields.id(),
18
+ * title: fields.string(),
19
+ * body: fields.text({ nullable: true }),
20
+ * user_id: fields.foreignId('user_id'),
21
+ * published: fields.boolean({ default: false }),
22
+ * published_at: fields.timestamp({ nullable: true }),
23
+ * created_at: fields.timestamp(),
24
+ * updated_at: fields.timestamp(),
25
+ * };
15
26
  *
16
- * const { Model, fields } = require('millas/src/orm');
27
+ * ── Relations ────────────────────────────────────────────────────────────────
17
28
  *
18
- * class User extends Model {
19
- * static table = 'users';
20
- * static fields = {
21
- * id: fields.id(),
22
- * name: fields.string(),
23
- * email: fields.string({ unique: true }),
24
- * active: fields.boolean({ default: true }),
25
- * created_at: fields.timestamp(),
26
- * updated_at: fields.timestamp(),
29
+ * static relations = {
30
+ * author: new BelongsTo(() => User, 'user_id'),
31
+ * comments: new HasMany(() => Comment, 'post_id'),
32
+ * tags: new BelongsToMany(() => Tag, 'post_tag', 'post_id', 'tag_id'),
33
+ * };
34
+ *
35
+ * ── Scopes ───────────────────────────────────────────────────────────────────
36
+ *
37
+ * static scopes = {
38
+ * published: qb => qb.where('published', true),
39
+ * recent: qb => qb.latest().limit(10),
40
+ * byUser: (qb, userId) => qb.where('user_id', userId),
27
41
  * };
42
+ *
43
+ * ── Soft deletes ─────────────────────────────────────────────────────────────
44
+ *
45
+ * static softDeletes = true;
46
+ * // Now delete() sets deleted_at instead of removing the row.
47
+ * // All queries automatically exclude deleted rows.
48
+ *
49
+ * ── Validation ───────────────────────────────────────────────────────────────
50
+ *
51
+ * static validate(data) {
52
+ * if (!data.title) throw new Error('title is required');
53
+ * }
54
+ *
55
+ * ── Lifecycle hooks (signals) ────────────────────────────────────────────────
56
+ *
57
+ * static beforeCreate(data) { return data; } // can modify data
58
+ * static afterCreate(instance) {}
59
+ * static beforeUpdate(data) { return data; }
60
+ * static afterUpdate(instance) {}
61
+ * static beforeDelete(instance) {}
62
+ * static afterDelete(instance) {}
28
63
  * }
29
64
  *
65
+ * ── Usage ─────────────────────────────────────────────────────────────────────
66
+ *
30
67
  * // CRUD
31
- * const user = await User.create({ name: 'Alice', email: 'a@b.com' });
32
- * const users = await User.all();
33
- * const found = await User.find(1);
34
- * const alice = await User.findBy('email', 'a@b.com');
35
- * await user.update({ name: 'Alice Smith' });
36
- * await user.delete();
68
+ * const post = await Post.create({ title: 'Hello' });
69
+ * const posts = await Post.all();
70
+ * const found = await Post.find(1);
71
+ * await post.update({ title: 'New Title' });
72
+ * await post.delete(); // soft-delete if enabled
73
+ *
74
+ * // Lookups
75
+ * Post.where('title__icontains', 'world').get()
76
+ * Post.where('published_at__year', 2024).get()
77
+ *
78
+ * // Q objects
79
+ * Post.filter(Q({ published: true }).or({ user_id: 5 })).get()
80
+ *
81
+ * // Scopes
82
+ * Post.scope('published').scope('recent').get()
83
+ *
84
+ * // Relations
85
+ * Post.with('author', 'tags').get()
86
+ * post.author() // lazy-load
87
+ * post.tags() // lazy-load
88
+ * post.tags.attach(5)
89
+ * post.tags.sync([1, 2, 3])
90
+ *
91
+ * // Aggregates
92
+ * Post.aggregate({ total: Count('id'), avg: Avg('views') })
93
+ * Post.annotate({ comment_count: Count('comments.id') }).get()
94
+ *
95
+ * // Transactions
96
+ * await Post.transaction(async (trx) => {
97
+ * await Post.create({ title: 'Hello' }, { trx });
98
+ * await Tag.create({ name: 'news' }, { trx });
99
+ * });
100
+ *
101
+ * // Soft deletes
102
+ * await post.delete() // sets deleted_at
103
+ * await post.restore() // clears deleted_at
104
+ * Post.withTrashed().get() // includes deleted
105
+ * Post.onlyTrashed().get() // only deleted
37
106
  *
38
- * // Query builder
39
- * const active = await User.where('active', true).orderBy('name').get();
40
- * const admins = await User.where('role', 'admin').limit(10).get();
41
- * const page = await User.where('active', true).paginate(1, 15);
107
+ * // Bulk update
108
+ * await Post.bulkUpdate([
109
+ * { id: 1, title: 'One' },
110
+ * { id: 2, title: 'Two' },
111
+ * ], 'id');
112
+ *
113
+ * // only() / defer()
114
+ * Post.only('id', 'title').get()
115
+ * Post.defer('body').get()
116
+ *
117
+ * // Raw
118
+ * Post.raw('SELECT * FROM posts WHERE YEAR(created_at) = ?', [2024])
42
119
  */
43
120
  class Model {
44
121
  // ─── Static schema config ─────────────────────────────────────────────────
45
122
 
46
- /** @type {string} Table name — defaults to pluralised lowercase class name */
47
123
  static get table() {
48
124
  return this._table || (this._table = this._defaultTable());
49
125
  }
50
126
  static set table(v) { this._table = v; }
51
127
 
52
- /** @type {string} Primary key column name */
53
- static primaryKey = 'id';
128
+ static primaryKey = 'id';
129
+ static timestamps = true;
130
+ static softDeletes = false;
131
+ static fields = {};
132
+ static connection = null;
133
+
134
+ /** Define named scopes: static scopes = { published: qb => qb.where('published', true) } */
135
+ static scopes = {};
136
+
137
+ /** Define relations: static relations = { author: new BelongsTo(...) } */
138
+ static relations = {};
139
+
140
+ // ─── Lifecycle hooks (override in subclass) ───────────────────────────────
54
141
 
55
- /** @type {boolean} Whether to auto-manage created_at / updated_at */
56
- static timestamps = true;
142
+ static async beforeCreate(data) { return data; }
143
+ static async afterCreate(instance) {}
144
+ static async beforeUpdate(data) { return data; }
145
+ static async afterUpdate(instance) {}
146
+ static async beforeDelete(instance){}
147
+ static async afterDelete(instance) {}
57
148
 
58
- /** @type {object} Field definitions */
59
- static fields = {};
149
+ // ─── Validation (override in subclass) ───────────────────────────────────
60
150
 
61
- /** @type {string|null} Named DB connection to use */
62
- static connection = null;
151
+ /** Override to throw validation errors before create/update. */
152
+ static validate(data) {}
63
153
 
64
- // ─── Static CRUD methods ──────────────────────────────────────────────────
154
+ // ─── Transactions ─────────────────────────────────────────────────────────
65
155
 
66
156
  /**
67
- * Return all rows.
157
+ * Run a callback inside a database transaction.
158
+ * If the callback throws, the transaction is rolled back automatically.
159
+ *
160
+ * await Post.transaction(async (trx) => {
161
+ * const post = await Post.create({ title: 'Hi' }, { trx });
162
+ * await post.update({ published: true }, { trx });
163
+ * });
68
164
  */
165
+ static async transaction(callback) {
166
+ const db = DatabaseManager.connection(this.connection || null);
167
+ return db.transaction(callback);
168
+ }
169
+
170
+ // ─── Static CRUD ──────────────────────────────────────────────────────────
171
+
69
172
  static async all() {
70
173
  const rows = await this._db();
71
174
  return rows.map(r => this._hydrate(r));
72
175
  }
73
176
 
74
- /**
75
- * Find by primary key. Returns null if not found.
76
- */
77
177
  static async find(id) {
78
178
  const row = await this._db().where(this.primaryKey, id).first();
79
179
  return row ? this._hydrate(row) : null;
80
180
  }
81
181
 
82
- /**
83
- * Find by primary key. Throws 404 HttpError if not found.
84
- */
85
182
  static async findOrFail(id) {
86
183
  const result = await this.find(id);
87
184
  if (!result) {
@@ -91,17 +188,12 @@ class Model {
91
188
  return result;
92
189
  }
93
190
 
94
- /**
95
- * Find the first row matching column = value.
96
- */
97
191
  static async findBy(column, value) {
98
- const row = await this._db().where(column, value).first();
99
- return row ? this._hydrate(row) : null;
192
+ const qb = new QueryBuilder(this._db(), this);
193
+ qb.where(column, value);
194
+ return qb.first();
100
195
  }
101
196
 
102
- /**
103
- * Find the first row matching column = value or throw 404.
104
- */
105
197
  static async findByOrFail(column, value) {
106
198
  const result = await this.findBy(column, value);
107
199
  if (!result) {
@@ -112,31 +204,37 @@ class Model {
112
204
  }
113
205
 
114
206
  /**
115
- * Create a new row and return the model instance.
207
+ * Create a new row.
208
+ * @param {object} data
209
+ * @param {object} options — { trx } for transaction support
116
210
  */
117
- static async create(data) {
118
- const now = new Date().toISOString();
119
- const payload = {
211
+ static async create(data, { trx } = {}) {
212
+ this.validate(data);
213
+
214
+ const now = new Date().toISOString();
215
+ let payload = {
120
216
  ...this._applyDefaults(data),
121
217
  ...(this.timestamps ? { created_at: now, updated_at: now } : {}),
122
218
  };
123
219
 
124
- const [id] = await this._db().insert(payload);
125
- return this.find(id);
220
+ payload = await this.beforeCreate(payload) ?? payload;
221
+
222
+ const q = trx ? trx(this.table) : this._db();
223
+ const [id] = await q.insert(payload);
224
+ const instance = await (trx
225
+ ? this._hydrateFromTrx(id, trx)
226
+ : this.find(id));
227
+
228
+ await this.afterCreate(instance);
229
+ return instance;
126
230
  }
127
231
 
128
- /**
129
- * Find by column or create if not found.
130
- */
131
232
  static async firstOrCreate(search, extra = {}) {
132
- const existing = await this.findBy(Object.keys(search)[0], Object.values(search)[0]);
233
+ const existing = await this.where(search).first();
133
234
  if (existing) return existing;
134
235
  return this.create({ ...search, ...extra });
135
236
  }
136
237
 
137
- /**
138
- * Update or create based on search criteria.
139
- */
140
238
  static async updateOrCreate(search, data) {
141
239
  const existing = await this.where(search).first();
142
240
  if (existing) {
@@ -146,31 +244,19 @@ class Model {
146
244
  return this.create({ ...search, ...data });
147
245
  }
148
246
 
149
- /**
150
- * Count rows.
151
- */
152
247
  static async count(column = '*') {
153
- const result = await this._db().count(`${column} as count`);
154
- const row = Array.isArray(result) ? result[0] : result;
155
- return Number(row?.count ?? 0);
248
+ const result = await this._db().count(`${column} as count`).first();
249
+ return Number(result?.count ?? 0);
156
250
  }
157
251
 
158
- /**
159
- * Check whether any row matches the optional conditions.
160
- */
161
252
  static async exists(conditions = {}) {
162
- let q = this._db();
163
- if (Object.keys(conditions).length) q = q.where(conditions);
164
- const result = await q.count('* as count');
165
- const row = Array.isArray(result) ? result[0] : result;
166
- return Number(row?.count ?? 0) > 0;
253
+ const qb = new QueryBuilder(this._db(), this);
254
+ for (const [k, v] of Object.entries(conditions)) qb.where(k, v);
255
+ return (await qb.count()) > 0;
167
256
  }
168
257
 
169
- /**
170
- * Bulk insert — returns number of rows inserted.
171
- */
172
258
  static async insert(rows) {
173
- const now = new Date().toISOString();
259
+ const now = new Date().toISOString();
174
260
  const payload = rows.map(r => ({
175
261
  ...this._applyDefaults(r),
176
262
  ...(this.timestamps ? { created_at: now, updated_at: now } : {}),
@@ -178,25 +264,116 @@ class Model {
178
264
  return this._db().insert(payload);
179
265
  }
180
266
 
181
- /**
182
- * Delete rows by primary key.
183
- */
184
267
  static async destroy(...ids) {
185
268
  return this._db().whereIn(this.primaryKey, ids.flat()).delete();
186
269
  }
187
270
 
188
- /**
189
- * Truncate the table.
190
- */
191
271
  static async truncate() {
192
272
  return this._db().truncate();
193
273
  }
194
274
 
275
+ // ─── Aggregation ──────────────────────────────────────────────────────────
276
+
277
+ /**
278
+ * Compute one or more aggregate values over the whole table.
279
+ *
280
+ * await Order.aggregate({ total: Sum('amount'), avg: Avg('amount') })
281
+ * // → { total: 9430.5, avg: 94.3 }
282
+ */
283
+ static async aggregate(expressions) {
284
+ const { AggregateExpression } = require('../query/Aggregates');
285
+ let q = this._db();
286
+
287
+ const selects = [];
288
+ for (const [alias, expr] of Object.entries(expressions)) {
289
+ if (!(expr instanceof AggregateExpression)) {
290
+ throw new Error(`aggregate() values must be Aggregate expressions (Sum, Count, …)`);
291
+ }
292
+ selects.push(q.client.raw(expr.toSQL(alias)));
293
+ }
294
+
295
+ const row = await q.select(selects).first();
296
+ // Parse numeric strings returned by some drivers
297
+ const result = {};
298
+ for (const key of Object.keys(expressions)) {
299
+ result[key] = row[key] == null ? null : Number(row[key]);
300
+ }
301
+ return result;
302
+ }
303
+
304
+ // ─── Bulk update ──────────────────────────────────────────────────────────
305
+
306
+ /**
307
+ * Update many rows with different values in a single transaction.
308
+ *
309
+ * await Post.bulkUpdate([
310
+ * { id: 1, title: 'One', published: true },
311
+ * { id: 2, title: 'Two', published: false },
312
+ * ], 'id');
313
+ */
314
+ static async bulkUpdate(rows, keyColumn = null) {
315
+ const pk = keyColumn || this.primaryKey;
316
+ return this.transaction(async (trx) => {
317
+ for (const row of rows) {
318
+ const { [pk]: keyValue, ...data } = row;
319
+ if (keyValue == null) continue;
320
+ const now = new Date().toISOString();
321
+ await trx(this.table)
322
+ .where(pk, keyValue)
323
+ .update({ ...data, ...(this.timestamps ? { updated_at: now } : {}) });
324
+ }
325
+ });
326
+ }
327
+
328
+ // ─── only() / defer() ────────────────────────────────────────────────────
329
+
330
+ /**
331
+ * Select only these columns (like Django's .only()).
332
+ * Post.only('id', 'title').get()
333
+ */
334
+ static only(...columns) {
335
+ return new QueryBuilder(this._db(), this).select(...columns);
336
+ }
337
+
338
+ /**
339
+ * Select all columns EXCEPT the given ones (like Django's .defer()).
340
+ * Post.defer('body', 'metadata').get()
341
+ */
342
+ static defer(...columns) {
343
+ const all = Object.keys(this.fields);
344
+ const exclude = new Set(columns);
345
+ const keep = all.filter(c => !exclude.has(c));
346
+ return new QueryBuilder(this._db(), this).select(...keep.map(c => `${this.table}.${c}`));
347
+ }
348
+
349
+ // ─── Raw ──────────────────────────────────────────────────────────────────
350
+
351
+ /**
352
+ * Execute a raw SQL query and return hydrated model instances.
353
+ * Post.raw('SELECT * FROM posts WHERE YEAR(created_at) = ?', [2024])
354
+ */
355
+ static async raw(sql, bindings = []) {
356
+ const db = DatabaseManager.connection(this.connection || null);
357
+ const rows = await db.raw(sql, bindings);
358
+ // knex wraps raw results differently per driver
359
+ const data = rows.rows ?? rows[0] ?? rows;
360
+ return Array.isArray(data) ? data.map(r => this._hydrate(r)) : data;
361
+ }
362
+
195
363
  // ─── Query Builder entry points ───────────────────────────────────────────
196
364
 
197
365
  static where(column, operatorOrValue, value) {
198
- const qb = new QueryBuilder(this._db(), this);
199
- return qb.where(column, operatorOrValue, value);
366
+ return new QueryBuilder(this._db(), this).where(column, operatorOrValue, value);
367
+ }
368
+
369
+ /** filter() — Django alias for where() */
370
+ static filter(column, operatorOrValue, value) {
371
+ return this.where(column, operatorOrValue, value);
372
+ }
373
+
374
+ /** exclude() — Django alias for whereNot() */
375
+ static exclude(column, value) {
376
+ return new QueryBuilder(this._db(), this).whereNot(column, value);
200
377
  }
201
378
 
202
379
  static whereIn(column, values) {
@@ -231,58 +408,104 @@ class Model {
231
408
  return new QueryBuilder(this._db(), this).select(...cols);
232
409
  }
233
410
 
234
- /**
235
- * Raw query builder — escape hatch.
236
- */
411
+ static distinct(...cols) {
412
+ return new QueryBuilder(this._db(), this).distinct(...cols);
413
+ }
414
+
415
+ /** Start an eager-load chain. */
416
+ static with(...relations) {
417
+ return new QueryBuilder(this._db(), this).with(...relations);
418
+ }
419
+
420
+ /** Apply a named scope. */
421
+ static scope(name, ...args) {
422
+ return new QueryBuilder(this._db(), this).scope(name, ...args);
423
+ }
424
+
425
+ /** Annotate rows with aggregate expressions. */
426
+ static annotate(expressions) {
427
+ return new QueryBuilder(this._db(), this).annotate(expressions);
428
+ }
429
+
430
+ /** Return plain objects instead of model instances. */
431
+ static values(...columns) {
432
+ return new QueryBuilder(this._db(), this).values(...columns);
433
+ }
434
+
435
+ /** Return a flat array of a single column. */
436
+ static valuesList(column) {
437
+ return new QueryBuilder(this._db(), this).valuesList(column);
438
+ }
439
+
440
+ /** Include soft-deleted rows. */
441
+ static withTrashed() {
442
+ return new QueryBuilder(this._db(), this).withTrashed();
443
+ }
444
+
445
+ /** Return only soft-deleted rows. */
446
+ static onlyTrashed() {
447
+ return new QueryBuilder(this._db(), this).onlyTrashed();
448
+ }
449
+
237
450
  static query() {
238
451
  return new QueryBuilder(this._db(), this);
239
452
  }
240
453
 
241
- /**
242
- * Paginate static shorthand.
243
- */
244
454
  static async paginate(page = 1, perPage = 15) {
245
- const offset = (page - 1) * perPage;
246
- const total = await this.count();
247
- const rows = await this._db().limit(perPage).offset(offset);
248
- return {
249
- data: rows.map(r => this._hydrate(r)),
250
- total,
251
- page: Number(page),
252
- perPage,
253
- lastPage: Math.ceil(total / perPage),
254
- };
455
+ return new QueryBuilder(this._db(), this).paginate(page, perPage);
255
456
  }
256
457
 
257
458
  // ─── Instance methods ─────────────────────────────────────────────────────
258
459
 
259
460
  constructor(attributes = {}) {
260
461
  Object.assign(this, attributes);
261
- this._original = { ...attributes };
262
- this._isDirty = false;
462
+ this._original = { ...attributes };
463
+
464
+ // Attach lazy relation loaders as callable methods
465
+ const relations = this.constructor.relations || {};
466
+ for (const [name, rel] of Object.entries(relations)) {
467
+ // Skip if already set by eager load
468
+ if (!(name in this)) {
469
+ this[name] = () => rel.load(this);
470
+ }
471
+
472
+ // Attach pivot manager for BelongsToMany
473
+ const BelongsToMany = require('../relations/BelongsToMany');
474
+ if (rel instanceof BelongsToMany) {
475
+ Object.assign(this[name], rel._pivotManager(this));
476
+ }
477
+ }
263
478
  }
264
479
 
265
480
  /**
266
- * Persist changes to this instance back to the database.
481
+ * Persist changes to this instance.
482
+ * @param {object} data
483
+ * @param {object} options — { trx }
267
484
  */
268
- async update(data = {}) {
485
+ async update(data = {}, { trx } = {}) {
486
+ this.constructor.validate(data);
487
+
269
488
  const now = new Date().toISOString();
270
- const payload = {
489
+ let payload = {
271
490
  ...data,
272
491
  ...(this.constructor.timestamps ? { updated_at: now } : {}),
273
492
  };
274
493
 
275
- await this.constructor._db()
494
+ payload = await this.constructor.beforeUpdate(payload) ?? payload;
495
+
496
+ const q = trx
497
+ ? trx(this.constructor.table)
498
+ : this.constructor._db();
499
+
500
+ await q
276
501
  .where(this.constructor.primaryKey, this[this.constructor.primaryKey])
277
502
  .update(payload);
278
503
 
279
504
  Object.assign(this, payload);
505
+ await this.constructor.afterUpdate(this);
280
506
  return this;
281
507
  }
282
508
 
283
- /**
284
- * Save any changes made directly to this instance's properties.
285
- */
286
509
  async save() {
287
510
  const dirty = this._getDirty();
288
511
  if (!Object.keys(dirty).length) return this;
@@ -290,53 +513,95 @@ class Model {
290
513
  }
291
514
 
292
515
  /**
293
- * Delete this row from the database.
516
+ * Delete this row.
517
+ * If softDeletes = true, sets deleted_at instead of removing.
294
518
  */
295
- async delete() {
519
+ async delete({ trx } = {}) {
520
+ await this.constructor.beforeDelete(this);
521
+
522
+ const q = trx
523
+ ? trx(this.constructor.table)
524
+ : this.constructor._db();
525
+
526
+ if (this.constructor.softDeletes) {
527
+ const now = new Date().toISOString();
528
+ await q
529
+ .where(this.constructor.primaryKey, this[this.constructor.primaryKey])
530
+ .update({ deleted_at: now });
531
+ this.deleted_at = now;
532
+ } else {
533
+ await q
534
+ .where(this.constructor.primaryKey, this[this.constructor.primaryKey])
535
+ .delete();
536
+ }
537
+
538
+ await this.constructor.afterDelete(this);
539
+ return this;
540
+ }
541
+
542
+ /** Restore a soft-deleted row. */
543
+ async restore() {
544
+ if (!this.constructor.softDeletes) {
545
+ throw new Error(`${this.constructor.name} does not use soft deletes.`);
546
+ }
296
547
  await this.constructor._db()
297
548
  .where(this.constructor.primaryKey, this[this.constructor.primaryKey])
298
- .delete();
549
+ .update({ deleted_at: null });
550
+ this.deleted_at = null;
551
+ return this;
552
+ }
553
+
554
+ /** Force-delete even if softDeletes is enabled. */
555
+ async forceDelete({ trx } = {}) {
556
+ await this.constructor.beforeDelete(this);
557
+ const q = trx ? trx(this.constructor.table) : this.constructor._db();
558
+ await q.where(this.constructor.primaryKey, this[this.constructor.primaryKey]).delete();
559
+ await this.constructor.afterDelete(this);
299
560
  return this;
300
561
  }
301
562
 
302
- /**
303
- * Reload this instance's attributes from the database.
304
- */
305
563
  async refresh() {
306
564
  const fresh = await this.constructor.find(this[this.constructor.primaryKey]);
307
565
  if (fresh) Object.assign(this, fresh);
308
566
  return this;
309
567
  }
310
568
 
311
- /**
312
- * Return a plain object representation.
313
- */
569
+ get isNew() { return !this[this.constructor.primaryKey]; }
570
+ get isTrashed() { return !!this.deleted_at; }
571
+
314
572
  toJSON() {
315
573
  const obj = {};
316
574
  for (const key of Object.keys(this)) {
317
- if (!key.startsWith('_')) obj[key] = this[key];
575
+ if (!key.startsWith('_') && typeof this[key] !== 'function') {
576
+ obj[key] = this[key];
577
+ }
318
578
  }
319
579
  return obj;
320
580
  }
321
581
 
322
- /**
323
- * Check whether this instance has been persisted.
324
- */
325
- get isNew() {
326
- return !this[this.constructor.primaryKey];
327
- }
328
-
329
582
  // ─── Internal ─────────────────────────────────────────────────────────────
330
583
 
331
584
  static _db() {
332
- const db = DatabaseManager.connection(this.connection || null);
333
- return db(this.table);
585
+ const db = DatabaseManager.connection(this.connection || null);
586
+ let q = db(this.table);
587
+
588
+ // Auto-exclude soft-deleted rows unless caller opts in
589
+ if (this.softDeletes) {
590
+ q = q.whereNull(`${this.table}.deleted_at`);
591
+ }
592
+
593
+ return q;
334
594
  }
335
595
 
336
596
  static _hydrate(row) {
337
597
  return new this(row);
338
598
  }
339
599
 
600
+ static async _hydrateFromTrx(id, trx) {
601
+ const row = await trx(this.table).where(this.primaryKey, id).first();
602
+ return row ? this._hydrate(row) : null;
603
+ }
604
+
340
605
  static _applyDefaults(data) {
341
606
  const result = { ...data };
342
607
  for (const [key, field] of Object.entries(this.fields)) {
@@ -360,7 +625,7 @@ class Model {
360
625
  _getDirty() {
361
626
  const dirty = {};
362
627
  for (const key of Object.keys(this)) {
363
- if (!key.startsWith('_') && this[key] !== this._original[key]) {
628
+ if (!key.startsWith('_') && typeof this[key] !== 'function' && this[key] !== this._original[key]) {
364
629
  dirty[key] = this[key];
365
630
  }
366
631
  }