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.
- package/package.json +1 -1
- package/src/index.js +10 -2
- package/src/orm/index.js +19 -0
- package/src/orm/model/Model.js +398 -133
- package/src/orm/query/Aggregates.js +56 -0
- package/src/orm/query/LookupParser.js +308 -0
- package/src/orm/query/Q.js +123 -0
- package/src/orm/query/QueryBuilder.js +266 -82
- package/src/orm/relations/BelongsTo.js +68 -0
- package/src/orm/relations/BelongsToMany.js +188 -0
- package/src/orm/relations/HasMany.js +72 -0
- package/src/orm/relations/HasOne.js +67 -0
- package/src/orm/relations/index.js +8 -0
- package/src/scaffold/maker.js +39 -4
package/src/orm/model/Model.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const 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
|
-
*
|
|
12
|
-
* query methods — no instantiation needed for reads.
|
|
12
|
+
* ── Schema ───────────────────────────────────────────────────────────────────
|
|
13
13
|
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
27
|
+
* ── Relations ────────────────────────────────────────────────────────────────
|
|
17
28
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
32
|
-
* const
|
|
33
|
-
* const found = await
|
|
34
|
-
*
|
|
35
|
-
* await
|
|
36
|
-
*
|
|
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
|
-
* //
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
53
|
-
static
|
|
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
|
-
|
|
56
|
-
static
|
|
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
|
-
|
|
59
|
-
static fields = {};
|
|
149
|
+
// ─── Validation (override in subclass) ───────────────────────────────────
|
|
60
150
|
|
|
61
|
-
/**
|
|
62
|
-
static
|
|
151
|
+
/** Override to throw validation errors before create/update. */
|
|
152
|
+
static validate(data) {}
|
|
63
153
|
|
|
64
|
-
// ───
|
|
154
|
+
// ─── Transactions ─────────────────────────────────────────────────────────
|
|
65
155
|
|
|
66
156
|
/**
|
|
67
|
-
*
|
|
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
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
262
|
-
|
|
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
|
|
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
|
-
|
|
489
|
+
let payload = {
|
|
271
490
|
...data,
|
|
272
491
|
...(this.constructor.timestamps ? { updated_at: now } : {}),
|
|
273
492
|
};
|
|
274
493
|
|
|
275
|
-
await this.constructor.
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
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('_')
|
|
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
|
|
333
|
-
|
|
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
|
}
|