@zero-server/orm 0.9.0 → 0.9.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/LICENSE +21 -21
- package/README.md +1 -1
- package/index.js +35 -35
- package/lib/debug.js +372 -0
- package/lib/orm/adapters/json.js +290 -0
- package/lib/orm/adapters/memory.js +764 -0
- package/lib/orm/adapters/mongo.js +764 -0
- package/lib/orm/adapters/mysql.js +933 -0
- package/lib/orm/adapters/postgres.js +1144 -0
- package/lib/orm/adapters/redis.js +1534 -0
- package/lib/orm/adapters/sql-base.js +212 -0
- package/lib/orm/adapters/sqlite.js +858 -0
- package/lib/orm/audit.js +649 -0
- package/lib/orm/cache.js +394 -0
- package/lib/orm/geo.js +387 -0
- package/lib/orm/index.js +784 -0
- package/lib/orm/migrate.js +432 -0
- package/lib/orm/model.js +1706 -0
- package/lib/orm/plugin.js +375 -0
- package/lib/orm/procedures.js +836 -0
- package/lib/orm/profiler.js +233 -0
- package/lib/orm/query.js +1772 -0
- package/lib/orm/replicas.js +241 -0
- package/lib/orm/schema.js +307 -0
- package/lib/orm/search.js +380 -0
- package/lib/orm/seed/data/commerce.js +136 -0
- package/lib/orm/seed/data/internet.js +111 -0
- package/lib/orm/seed/data/locations.js +204 -0
- package/lib/orm/seed/data/names.js +338 -0
- package/lib/orm/seed/data/person.js +128 -0
- package/lib/orm/seed/data/phone.js +211 -0
- package/lib/orm/seed/data/words.js +134 -0
- package/lib/orm/seed/factory.js +178 -0
- package/lib/orm/seed/fake.js +1186 -0
- package/lib/orm/seed/index.js +18 -0
- package/lib/orm/seed/rng.js +71 -0
- package/lib/orm/seed/seeder.js +125 -0
- package/lib/orm/seed/unique.js +68 -0
- package/lib/orm/snapshot.js +366 -0
- package/lib/orm/tenancy.js +605 -0
- package/lib/orm/views.js +350 -0
- package/package.json +12 -3
package/lib/orm/model.js
ADDED
|
@@ -0,0 +1,1706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module orm/model
|
|
3
|
+
* @description Base Model class for defining database-backed entities.
|
|
4
|
+
* Provides static CRUD methods, instance-level save/update/delete,
|
|
5
|
+
* lifecycle hooks, relationship definitions, computed/virtual columns,
|
|
6
|
+
* attribute casting, model events & observers, and advanced relationships.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const { Model, Database } = require('@zero-server/sdk');
|
|
10
|
+
*
|
|
11
|
+
* class User extends Model {
|
|
12
|
+
* static table = 'users';
|
|
13
|
+
* static schema = {
|
|
14
|
+
* id: { type: 'integer', primaryKey: true, autoIncrement: true },
|
|
15
|
+
* name: { type: 'string', required: true, maxLength: 100 },
|
|
16
|
+
* email: { type: 'string', required: true, unique: true },
|
|
17
|
+
* role: { type: 'string', enum: ['user','admin'], default: 'user' },
|
|
18
|
+
* };
|
|
19
|
+
* static timestamps = true; // auto createdAt/updatedAt
|
|
20
|
+
* static softDelete = true; // deletedAt instead of real delete
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* db.register(User);
|
|
24
|
+
*
|
|
25
|
+
* const user = await User.create({ name: 'Alice', email: 'a@b.com' });
|
|
26
|
+
* const users = await User.find({ role: 'admin' });
|
|
27
|
+
* const u = await User.findById(1);
|
|
28
|
+
* await u.update({ name: 'Alice2' });
|
|
29
|
+
* await u.delete();
|
|
30
|
+
*/
|
|
31
|
+
const { validate } = require('./schema');
|
|
32
|
+
const Query = require('./query');
|
|
33
|
+
const crypto = require('crypto');
|
|
34
|
+
const log = require('../debug')('zero:orm');
|
|
35
|
+
const { ValidationError, DatabaseError } = require('@zero-server/errors');
|
|
36
|
+
const { EventEmitter } = require('events');
|
|
37
|
+
|
|
38
|
+
class Model
|
|
39
|
+
{
|
|
40
|
+
/**
|
|
41
|
+
* Table name — override in subclass.
|
|
42
|
+
* @type {string}
|
|
43
|
+
*/
|
|
44
|
+
static table = '';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Column schema — override in subclass.
|
|
48
|
+
* @type {Object<string, object>}
|
|
49
|
+
*/
|
|
50
|
+
static schema = {};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Enable auto timestamps (createdAt, updatedAt).
|
|
54
|
+
* @type {boolean}
|
|
55
|
+
*/
|
|
56
|
+
static timestamps = false;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Enable soft deletes (deletedAt instead of real deletion).
|
|
60
|
+
* @type {boolean}
|
|
61
|
+
*/
|
|
62
|
+
static softDelete = false;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fields to hide from toJSON() serialization.
|
|
66
|
+
* Useful for excluding passwords, tokens, internal fields.
|
|
67
|
+
* @type {string[]}
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* class User extends Model {
|
|
71
|
+
* static hidden = ['password', 'resetToken'];
|
|
72
|
+
* }
|
|
73
|
+
*/
|
|
74
|
+
static hidden = [];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Named query scopes — reusable query conditions.
|
|
78
|
+
* Each scope is a function that receives a Query and returns it.
|
|
79
|
+
* @type {Object<string, Function>}
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* class User extends Model {
|
|
83
|
+
* static scopes = {
|
|
84
|
+
* active: q => q.where('active', true),
|
|
85
|
+
* admins: q => q.where('role', 'admin'),
|
|
86
|
+
* olderThan: (q, age) => q.where('age', '>', age),
|
|
87
|
+
* };
|
|
88
|
+
* }
|
|
89
|
+
*
|
|
90
|
+
* // Use:
|
|
91
|
+
* await User.scope('active').scope('admins').limit(5);
|
|
92
|
+
* await User.scope('olderThan', 30);
|
|
93
|
+
*/
|
|
94
|
+
static scopes = {};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Lifecycle hooks.
|
|
98
|
+
* Override these in subclasses: `static beforeCreate(data) { return data; }`
|
|
99
|
+
* @type {object}
|
|
100
|
+
*/
|
|
101
|
+
static hooks = {};
|
|
102
|
+
|
|
103
|
+
// -- Computed & Virtual Columns ---------------------
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Computed column definitions — virtual columns derived from other fields.
|
|
107
|
+
* Not stored in the database; calculated on the fly.
|
|
108
|
+
* Each entry maps a column name to a getter function `(instance) => value`.
|
|
109
|
+
* @type {Object<string, Function>}
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* class User extends Model {
|
|
113
|
+
* static computed = {
|
|
114
|
+
* fullName: (user) => `${user.firstName} ${user.lastName}`,
|
|
115
|
+
* isAdmin: (user) => user.role === 'admin',
|
|
116
|
+
* };
|
|
117
|
+
* }
|
|
118
|
+
*
|
|
119
|
+
* const user = await User.findById(1);
|
|
120
|
+
* user.fullName // => 'Alice Smith'
|
|
121
|
+
*/
|
|
122
|
+
static computed = {};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Attribute casts — automatic type transformations on get/set.
|
|
126
|
+
* Maps column names to cast types or custom cast objects.
|
|
127
|
+
*
|
|
128
|
+
* Built-in cast types:
|
|
129
|
+
* - `'json'` — JSON.parse on get, JSON.stringify on set
|
|
130
|
+
* - `'boolean'` — Cast to true/false
|
|
131
|
+
* - `'integer'` — parseInt
|
|
132
|
+
* - `'float'` — parseFloat
|
|
133
|
+
* - `'date'` — Cast to Date object
|
|
134
|
+
* - `'string'` — Cast to String
|
|
135
|
+
* - `'array'` — JSON parse/stringify for array data
|
|
136
|
+
*
|
|
137
|
+
* Custom casts:
|
|
138
|
+
* - `{ get: (v) => transformed, set: (v) => transformed }`
|
|
139
|
+
*
|
|
140
|
+
* @type {Object<string, string|{ get: Function, set: Function }>}
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
143
|
+
* class Settings extends Model {
|
|
144
|
+
* static casts = {
|
|
145
|
+
* preferences: 'json',
|
|
146
|
+
* isActive: 'boolean',
|
|
147
|
+
* loginCount: 'integer',
|
|
148
|
+
* tags: 'array',
|
|
149
|
+
* metadata: {
|
|
150
|
+
* get: (v) => v ? JSON.parse(v) : {},
|
|
151
|
+
* set: (v) => JSON.stringify(v),
|
|
152
|
+
* },
|
|
153
|
+
* };
|
|
154
|
+
* }
|
|
155
|
+
*/
|
|
156
|
+
static casts = {};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Custom attribute accessors (getters).
|
|
160
|
+
* These transform values when reading from the model instance.
|
|
161
|
+
* Each entry maps a column name to a function `(value, instance) => transformedValue`.
|
|
162
|
+
* @type {Object<string, Function>}
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* class User extends Model {
|
|
166
|
+
* static accessors = {
|
|
167
|
+
* email: (val) => val ? val.toLowerCase() : val,
|
|
168
|
+
* name: (val) => val ? val.trim() : val,
|
|
169
|
+
* };
|
|
170
|
+
* }
|
|
171
|
+
*/
|
|
172
|
+
static accessors = {};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Custom attribute mutators (setters).
|
|
176
|
+
* These transform values before writing to the model instance.
|
|
177
|
+
* Each entry maps a column name to a function `(value, instance) => transformedValue`.
|
|
178
|
+
* @type {Object<string, Function>}
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* class User extends Model {
|
|
182
|
+
* static mutators = {
|
|
183
|
+
* email: (val) => val ? val.toLowerCase().trim() : val,
|
|
184
|
+
* password: (val) => hashSync(val),
|
|
185
|
+
* };
|
|
186
|
+
* }
|
|
187
|
+
*/
|
|
188
|
+
static mutators = {};
|
|
189
|
+
|
|
190
|
+
// -- Model Events -----------------------------------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Internal event emitter for model-level events.
|
|
194
|
+
* @type {EventEmitter|null}
|
|
195
|
+
* @private
|
|
196
|
+
*/
|
|
197
|
+
static _emitter = null;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Registered observers for this model.
|
|
201
|
+
* @type {object[]}
|
|
202
|
+
* @private
|
|
203
|
+
*/
|
|
204
|
+
static _observers = [];
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Relationship definitions.
|
|
208
|
+
* @type {object}
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
static _relations = {};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Database adapter reference — set by Database.register().
|
|
215
|
+
* @type {object|null}
|
|
216
|
+
* @private
|
|
217
|
+
*/
|
|
218
|
+
static _adapter = null;
|
|
219
|
+
|
|
220
|
+
// -- Constructor ------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @constructor
|
|
224
|
+
* Create a model instance from a data row.
|
|
225
|
+
* Generally you won't call this directly — use static methods.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} data - Row data.
|
|
228
|
+
*/
|
|
229
|
+
constructor(data = {})
|
|
230
|
+
{
|
|
231
|
+
/** @type {boolean} Whether this instance exists in the database. */
|
|
232
|
+
this._persisted = false;
|
|
233
|
+
|
|
234
|
+
/** @type {object} The original data snapshot for dirty tracking. */
|
|
235
|
+
this._original = {};
|
|
236
|
+
|
|
237
|
+
// Assign data to instance (filter prototype pollution keys)
|
|
238
|
+
const mutators = this.constructor.mutators || {};
|
|
239
|
+
const casts = this.constructor.casts || {};
|
|
240
|
+
for (const key of Object.keys(data))
|
|
241
|
+
{
|
|
242
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
|
|
243
|
+
let val = data[key];
|
|
244
|
+
// Apply mutator if defined
|
|
245
|
+
if (typeof mutators[key] === 'function')
|
|
246
|
+
{
|
|
247
|
+
val = mutators[key](val, this);
|
|
248
|
+
}
|
|
249
|
+
// Apply cast set if defined
|
|
250
|
+
else if (casts[key])
|
|
251
|
+
{
|
|
252
|
+
val = Model._applyCastSet(val, casts[key]);
|
|
253
|
+
}
|
|
254
|
+
this[key] = val;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// -- Instance Methods -------------------------------
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Save this instance to the database. Insert if new, update if persisted.
|
|
262
|
+
* @returns {Promise<Model>} `this`
|
|
263
|
+
*/
|
|
264
|
+
async save()
|
|
265
|
+
{
|
|
266
|
+
const ctor = this.constructor;
|
|
267
|
+
if (this._persisted)
|
|
268
|
+
{
|
|
269
|
+
const pk = ctor._primaryKey();
|
|
270
|
+
const changes = this._dirtyFields();
|
|
271
|
+
if (Object.keys(changes).length === 0) return this;
|
|
272
|
+
|
|
273
|
+
if (ctor.timestamps && ctor._fullSchema().updatedAt)
|
|
274
|
+
{
|
|
275
|
+
changes.updatedAt = new Date();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await ctor._runHook('beforeUpdate', changes);
|
|
279
|
+
const { valid, errors, sanitized } = validate(changes, ctor._fullSchema(), { partial: true });
|
|
280
|
+
if (!valid) throw new ValidationError('Validation failed: ' + errors.join(', '), errors);
|
|
281
|
+
|
|
282
|
+
try { await ctor._adapter.update(ctor.table, pk, this[pk], sanitized); }
|
|
283
|
+
catch (e) { log.error('%s update failed: %s', ctor.table, e.message); throw e; }
|
|
284
|
+
log.debug('%s update id=%s', ctor.table, this[pk]);
|
|
285
|
+
Object.assign(this, sanitized);
|
|
286
|
+
await ctor._runHook('afterUpdate', this);
|
|
287
|
+
this._snapshot();
|
|
288
|
+
}
|
|
289
|
+
else
|
|
290
|
+
{
|
|
291
|
+
const data = this._toData();
|
|
292
|
+
|
|
293
|
+
if (ctor.timestamps)
|
|
294
|
+
{
|
|
295
|
+
const now = new Date();
|
|
296
|
+
if (ctor._fullSchema().createdAt && !data.createdAt) data.createdAt = now;
|
|
297
|
+
if (ctor._fullSchema().updatedAt && !data.updatedAt) data.updatedAt = now;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await ctor._runHook('beforeCreate', data);
|
|
301
|
+
const { valid, errors, sanitized } = validate(data, ctor._fullSchema());
|
|
302
|
+
if (!valid) throw new ValidationError('Validation failed: ' + errors.join(', '), errors);
|
|
303
|
+
|
|
304
|
+
let result;
|
|
305
|
+
try { result = await ctor._adapter.insert(ctor.table, sanitized); }
|
|
306
|
+
catch (e) { log.error('%s insert failed: %s', ctor.table, e.message); throw e; }
|
|
307
|
+
log.debug('%s insert', ctor.table);
|
|
308
|
+
const pk = ctor._primaryKey();
|
|
309
|
+
if (result && result[pk] !== undefined) this[pk] = result[pk];
|
|
310
|
+
Object.assign(this, sanitized);
|
|
311
|
+
this._persisted = true;
|
|
312
|
+
await ctor._runHook('afterCreate', this);
|
|
313
|
+
this._snapshot();
|
|
314
|
+
}
|
|
315
|
+
return this;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Update specific fields on this instance.
|
|
320
|
+
* @param {object} data - Fields to update.
|
|
321
|
+
* @returns {Promise<Model>} `this`
|
|
322
|
+
*/
|
|
323
|
+
async update(data)
|
|
324
|
+
{
|
|
325
|
+
Object.assign(this, this.constructor._stripGuarded(data));
|
|
326
|
+
return this.save();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Delete this instance from the database.
|
|
331
|
+
* If softDelete is enabled, sets deletedAt instead.
|
|
332
|
+
* @returns {Promise<void>}
|
|
333
|
+
*/
|
|
334
|
+
async delete()
|
|
335
|
+
{
|
|
336
|
+
const ctor = this.constructor;
|
|
337
|
+
const pk = ctor._primaryKey();
|
|
338
|
+
|
|
339
|
+
await ctor._runHook('beforeDelete', this);
|
|
340
|
+
|
|
341
|
+
if (ctor.softDelete)
|
|
342
|
+
{
|
|
343
|
+
this.deletedAt = new Date();
|
|
344
|
+
try { await ctor._adapter.update(ctor.table, pk, this[pk], { deletedAt: this.deletedAt }); }
|
|
345
|
+
catch (e) { log.error('%s soft-delete failed: %s', ctor.table, e.message); throw e; }
|
|
346
|
+
}
|
|
347
|
+
else
|
|
348
|
+
{
|
|
349
|
+
try { await ctor._adapter.remove(ctor.table, pk, this[pk]); }
|
|
350
|
+
catch (e) { log.error('%s delete failed: %s', ctor.table, e.message); throw e; }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
log.debug('%s delete id=%s', ctor.table, this[pk]);
|
|
354
|
+
|
|
355
|
+
await ctor._runHook('afterDelete', this);
|
|
356
|
+
this._persisted = false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Restore a soft-deleted record.
|
|
361
|
+
* @returns {Promise<Model>} `this`
|
|
362
|
+
*/
|
|
363
|
+
async restore()
|
|
364
|
+
{
|
|
365
|
+
const ctor = this.constructor;
|
|
366
|
+
if (!ctor.softDelete) throw new Error('Model does not use soft deletes');
|
|
367
|
+
const pk = ctor._primaryKey();
|
|
368
|
+
this.deletedAt = null;
|
|
369
|
+
try { await ctor._adapter.update(ctor.table, pk, this[pk], { deletedAt: null }); }
|
|
370
|
+
catch (e) { log.error('%s restore failed: %s', ctor.table, e.message); throw e; }
|
|
371
|
+
return this;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Increment a numeric field atomically.
|
|
376
|
+
*
|
|
377
|
+
* @param {string} field - Column name to increment.
|
|
378
|
+
* @param {number} [by=1] - Amount to increment by.
|
|
379
|
+
* @returns {Promise<Model>} `this`
|
|
380
|
+
*
|
|
381
|
+
* @example
|
|
382
|
+
* await post.increment('views');
|
|
383
|
+
* await product.increment('stock', 10);
|
|
384
|
+
*/
|
|
385
|
+
async increment(field, by = 1)
|
|
386
|
+
{
|
|
387
|
+
const ctor = this.constructor;
|
|
388
|
+
const pk = ctor._primaryKey();
|
|
389
|
+
this[field] = (Number(this[field]) || 0) + by;
|
|
390
|
+
const update = { [field]: this[field] };
|
|
391
|
+
if (ctor.timestamps && ctor._fullSchema().updatedAt)
|
|
392
|
+
{
|
|
393
|
+
update.updatedAt = new Date();
|
|
394
|
+
this.updatedAt = update.updatedAt;
|
|
395
|
+
}
|
|
396
|
+
await ctor._adapter.update(ctor.table, pk, this[pk], update);
|
|
397
|
+
log.debug('%s increment %s by %d', ctor.table, field, by);
|
|
398
|
+
this._snapshot();
|
|
399
|
+
return this;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Decrement a numeric field atomically.
|
|
404
|
+
*
|
|
405
|
+
* @param {string} field - Column name to decrement.
|
|
406
|
+
* @param {number} [by=1] - Amount to decrement by.
|
|
407
|
+
* @returns {Promise<Model>} `this`
|
|
408
|
+
*
|
|
409
|
+
* @example
|
|
410
|
+
* await product.decrement('stock');
|
|
411
|
+
* await account.decrement('balance', 50);
|
|
412
|
+
*/
|
|
413
|
+
async decrement(field, by = 1)
|
|
414
|
+
{
|
|
415
|
+
return this.increment(field, -by);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Reload this instance from the database.
|
|
420
|
+
* @returns {Promise<Model>} `this`
|
|
421
|
+
*/
|
|
422
|
+
async reload()
|
|
423
|
+
{
|
|
424
|
+
const ctor = this.constructor;
|
|
425
|
+
const pk = ctor._primaryKey();
|
|
426
|
+
const fresh = await ctor.findById(this[pk]);
|
|
427
|
+
if (!fresh) throw new Error('Record not found');
|
|
428
|
+
Object.assign(this, fresh);
|
|
429
|
+
this._snapshot();
|
|
430
|
+
return this;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Convert to plain object (for JSON serialization).
|
|
435
|
+
* Respects `static hidden = [...]` to exclude sensitive fields.
|
|
436
|
+
* Includes computed columns and applies accessor transformations.
|
|
437
|
+
* @returns {object} Plain data object with hidden fields excluded.
|
|
438
|
+
*/
|
|
439
|
+
toJSON()
|
|
440
|
+
{
|
|
441
|
+
const data = {};
|
|
442
|
+
const ctor = this.constructor;
|
|
443
|
+
const schema = ctor._fullSchema();
|
|
444
|
+
const hidden = ctor.hidden || [];
|
|
445
|
+
const accessors = ctor.accessors || {};
|
|
446
|
+
const casts = ctor.casts || {};
|
|
447
|
+
for (const key of Object.keys(schema))
|
|
448
|
+
{
|
|
449
|
+
if (this[key] !== undefined && !hidden.includes(key))
|
|
450
|
+
{
|
|
451
|
+
let val = this[key];
|
|
452
|
+
// Apply accessor if defined
|
|
453
|
+
if (typeof accessors[key] === 'function')
|
|
454
|
+
{
|
|
455
|
+
val = accessors[key](val, this);
|
|
456
|
+
}
|
|
457
|
+
// Apply cast get if defined (and no accessor)
|
|
458
|
+
else if (casts[key])
|
|
459
|
+
{
|
|
460
|
+
val = Model._applyCastGet(val, casts[key]);
|
|
461
|
+
}
|
|
462
|
+
data[key] = val;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Include computed columns
|
|
466
|
+
const computed = ctor.computed || {};
|
|
467
|
+
for (const [name, fn] of Object.entries(computed))
|
|
468
|
+
{
|
|
469
|
+
if (!hidden.includes(name) && typeof fn === 'function')
|
|
470
|
+
{
|
|
471
|
+
data[name] = fn(this);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return data;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// -- Internal Instance Helpers ----------------------
|
|
478
|
+
|
|
479
|
+
/** @private Snapshot current data for dirty tracking. */
|
|
480
|
+
_snapshot()
|
|
481
|
+
{
|
|
482
|
+
this._original = { ...this._toData() };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** @private Get only data columns (exclude internal props). */
|
|
486
|
+
_toData()
|
|
487
|
+
{
|
|
488
|
+
const data = {};
|
|
489
|
+
const schema = this.constructor._fullSchema();
|
|
490
|
+
for (const key of Object.keys(schema))
|
|
491
|
+
{
|
|
492
|
+
if (this[key] !== undefined) data[key] = this[key];
|
|
493
|
+
}
|
|
494
|
+
return data;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** @private Get fields that changed since last snapshot. */
|
|
498
|
+
_dirtyFields()
|
|
499
|
+
{
|
|
500
|
+
const data = this._toData();
|
|
501
|
+
const changes = {};
|
|
502
|
+
for (const [k, v] of Object.entries(data))
|
|
503
|
+
{
|
|
504
|
+
if (v !== this._original[k]) changes[k] = v;
|
|
505
|
+
}
|
|
506
|
+
return changes;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// -- Static CRUD ------------------------------------
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Create and persist a new record.
|
|
513
|
+
*
|
|
514
|
+
* @param {object} data - Record data.
|
|
515
|
+
* @returns {Promise<Model>} The created instance.
|
|
516
|
+
*/
|
|
517
|
+
static async create(data)
|
|
518
|
+
{
|
|
519
|
+
const instance = new this(this._stripGuarded(data));
|
|
520
|
+
return instance.save();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Create multiple records at once.
|
|
525
|
+
* Uses batch INSERT when the adapter supports it (much faster for SQL databases).
|
|
526
|
+
*
|
|
527
|
+
* @param {object[]} dataArray - Array of record data.
|
|
528
|
+
* @returns {Promise<Model[]>} Created model instances.
|
|
529
|
+
*/
|
|
530
|
+
static async createMany(dataArray)
|
|
531
|
+
{
|
|
532
|
+
if (!dataArray.length) return [];
|
|
533
|
+
|
|
534
|
+
// Validate, apply hooks & timestamps for each row
|
|
535
|
+
const fullSchema = this._fullSchema();
|
|
536
|
+
const sanitizedRows = [];
|
|
537
|
+
for (const data of dataArray)
|
|
538
|
+
{
|
|
539
|
+
const row = this._stripGuarded({ ...data });
|
|
540
|
+
if (this.timestamps)
|
|
541
|
+
{
|
|
542
|
+
const now = new Date();
|
|
543
|
+
if (fullSchema.createdAt && !row.createdAt) row.createdAt = now;
|
|
544
|
+
if (fullSchema.updatedAt && !row.updatedAt) row.updatedAt = now;
|
|
545
|
+
}
|
|
546
|
+
await this._runHook('beforeCreate', row);
|
|
547
|
+
const { valid, errors, sanitized } = validate(row, fullSchema);
|
|
548
|
+
if (!valid) throw new ValidationError('Validation failed: ' + errors.join(', '), errors);
|
|
549
|
+
sanitizedRows.push(sanitized);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Use batch insertMany if adapter supports it
|
|
553
|
+
if (typeof this._adapter.insertMany === 'function')
|
|
554
|
+
{
|
|
555
|
+
let results;
|
|
556
|
+
try { results = await this._adapter.insertMany(this.table, sanitizedRows); }
|
|
557
|
+
catch (e) { log.error('%s insertMany failed: %s', this.table, e.message); throw e; }
|
|
558
|
+
|
|
559
|
+
const instances = results.map(row => {
|
|
560
|
+
const inst = this._fromRow(row);
|
|
561
|
+
return inst;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
for (const inst of instances) await this._runHook('afterCreate', inst);
|
|
565
|
+
return instances;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Fallback: individual inserts
|
|
569
|
+
return Promise.all(dataArray.map(d => this.create(d)));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Find records matching conditions.
|
|
574
|
+
*
|
|
575
|
+
* @param {object} [conditions={}] - WHERE conditions `{ key: value }`.
|
|
576
|
+
* @returns {Promise<Model[]>} Matching records.
|
|
577
|
+
*/
|
|
578
|
+
static async find(conditions = {})
|
|
579
|
+
{
|
|
580
|
+
const q = this.query().where(conditions);
|
|
581
|
+
return q.exec();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Find a single record matching conditions.
|
|
586
|
+
*
|
|
587
|
+
* @param {object} conditions - WHERE conditions.
|
|
588
|
+
* @returns {Promise<Model|null>} First matching record, or null.
|
|
589
|
+
*/
|
|
590
|
+
static async findOne(conditions)
|
|
591
|
+
{
|
|
592
|
+
return this.query().where(conditions).first();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Find a record by primary key.
|
|
597
|
+
*
|
|
598
|
+
* @param {*} id - Primary key value.
|
|
599
|
+
* @returns {Promise<Model|null>} Matching record, or null.
|
|
600
|
+
*/
|
|
601
|
+
static async findById(id)
|
|
602
|
+
{
|
|
603
|
+
const pk = this._primaryKey();
|
|
604
|
+
return this.query().where(pk, id).first();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Find one or create if not found.
|
|
609
|
+
*
|
|
610
|
+
* @param {object} conditions - Search conditions.
|
|
611
|
+
* @param {object} [defaults={}] - Additional data for creation.
|
|
612
|
+
* @returns {Promise<{ instance: Model, created: boolean }>}
|
|
613
|
+
*/
|
|
614
|
+
static async findOrCreate(conditions, defaults = {})
|
|
615
|
+
{
|
|
616
|
+
const existing = await this.findOne(conditions);
|
|
617
|
+
if (existing) return { instance: existing, created: false };
|
|
618
|
+
const instance = await this.create({ ...conditions, ...defaults });
|
|
619
|
+
return { instance, created: true };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Update records matching conditions.
|
|
624
|
+
*
|
|
625
|
+
* @param {object} conditions - WHERE conditions.
|
|
626
|
+
* @param {object} data - Fields to update.
|
|
627
|
+
* @returns {Promise<number>} Number of updated records.
|
|
628
|
+
*/
|
|
629
|
+
static async updateWhere(conditions, data)
|
|
630
|
+
{
|
|
631
|
+
data = this._stripGuarded(data);
|
|
632
|
+
if (this.timestamps && this._fullSchema().updatedAt)
|
|
633
|
+
{
|
|
634
|
+
data.updatedAt = new Date();
|
|
635
|
+
}
|
|
636
|
+
await this._runHook('beforeUpdate', data);
|
|
637
|
+
try { return await this._adapter.updateWhere(this.table, conditions, data); }
|
|
638
|
+
catch (e) { log.error('%s updateWhere failed: %s', this.table, e.message); throw e; }
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Delete records matching conditions.
|
|
643
|
+
*
|
|
644
|
+
* @param {object} conditions - WHERE conditions.
|
|
645
|
+
* @returns {Promise<number>} Number of deleted records.
|
|
646
|
+
*/
|
|
647
|
+
static async deleteWhere(conditions)
|
|
648
|
+
{
|
|
649
|
+
if (this.softDelete)
|
|
650
|
+
{
|
|
651
|
+
try { return await this._adapter.updateWhere(this.table, conditions, { deletedAt: new Date() }); }
|
|
652
|
+
catch (e) { log.error('%s deleteWhere (soft) failed: %s', this.table, e.message); throw e; }
|
|
653
|
+
}
|
|
654
|
+
try { return await this._adapter.deleteWhere(this.table, conditions); }
|
|
655
|
+
catch (e) { log.error('%s deleteWhere failed: %s', this.table, e.message); throw e; }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Count records matching conditions.
|
|
660
|
+
*
|
|
661
|
+
* @param {object} [conditions={}] - WHERE conditions.
|
|
662
|
+
* @returns {Promise<number>} Number of matching records.
|
|
663
|
+
*/
|
|
664
|
+
static async count(conditions = {})
|
|
665
|
+
{
|
|
666
|
+
return this.query().where(conditions).count();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Check whether any records matching conditions exist.
|
|
671
|
+
*
|
|
672
|
+
* @param {object} [conditions={}] - WHERE conditions.
|
|
673
|
+
* @returns {Promise<boolean>} True if any matching records exist.
|
|
674
|
+
*
|
|
675
|
+
* @example
|
|
676
|
+
* if (await User.exists({ email: 'a@b.com' })) { ... }
|
|
677
|
+
*/
|
|
678
|
+
static async exists(conditions = {})
|
|
679
|
+
{
|
|
680
|
+
return this.query().where(conditions).exists();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Insert or update a record matching conditions.
|
|
685
|
+
* If a matching record exists, update it. Otherwise, create a new one.
|
|
686
|
+
*
|
|
687
|
+
* @param {object} conditions - Search conditions (unique fields).
|
|
688
|
+
* @param {object} data - Data to set (merged with conditions on create).
|
|
689
|
+
* @returns {Promise<{ instance: Model, created: boolean }>}
|
|
690
|
+
*
|
|
691
|
+
* @example
|
|
692
|
+
* const { instance, created } = await User.upsert(
|
|
693
|
+
* { email: 'a@b.com' },
|
|
694
|
+
* { name: 'Alice', role: 'admin' }
|
|
695
|
+
* );
|
|
696
|
+
*/
|
|
697
|
+
static async upsert(conditions, data = {})
|
|
698
|
+
{
|
|
699
|
+
const existing = await this.findOne(conditions);
|
|
700
|
+
if (existing)
|
|
701
|
+
{
|
|
702
|
+
await existing.update(data);
|
|
703
|
+
return { instance: existing, created: false };
|
|
704
|
+
}
|
|
705
|
+
const instance = await this.create({ ...conditions, ...data });
|
|
706
|
+
return { instance, created: true };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Start a query with a named scope applied.
|
|
711
|
+
*
|
|
712
|
+
* @param {string} name - Scope name (from `static scopes`).
|
|
713
|
+
* @param {...*} [args] - Additional arguments passed to the scope function.
|
|
714
|
+
* @returns {Query} Scoped query builder.
|
|
715
|
+
*
|
|
716
|
+
* @example
|
|
717
|
+
* await User.scope('active').where('role', 'admin');
|
|
718
|
+
* await User.scope('olderThan', 21).limit(10);
|
|
719
|
+
*/
|
|
720
|
+
static scope(name, ...args)
|
|
721
|
+
{
|
|
722
|
+
if (!this.scopes || typeof this.scopes[name] !== 'function')
|
|
723
|
+
{
|
|
724
|
+
throw new Error(`Unknown scope "${name}" on ${this.name}`);
|
|
725
|
+
}
|
|
726
|
+
const q = this.query();
|
|
727
|
+
this.scopes[name](q, ...args);
|
|
728
|
+
return q;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Start a fluent query builder.
|
|
733
|
+
*
|
|
734
|
+
* @returns {Query} New fluent query builder.
|
|
735
|
+
*
|
|
736
|
+
* @example
|
|
737
|
+
* const results = await User.query()
|
|
738
|
+
* .where('age', '>', 18)
|
|
739
|
+
* .orderBy('name')
|
|
740
|
+
* .limit(10);
|
|
741
|
+
*/
|
|
742
|
+
static query()
|
|
743
|
+
{
|
|
744
|
+
if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
|
|
745
|
+
const q = new Query(this, this._adapter);
|
|
746
|
+
|
|
747
|
+
// Auto-exclude soft-deleted records
|
|
748
|
+
if (this.softDelete)
|
|
749
|
+
{
|
|
750
|
+
q.whereNull('deletedAt');
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return q;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// -- LINQ-Inspired Static Shortcuts -----------------
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Find the first record matching optional conditions.
|
|
760
|
+
*
|
|
761
|
+
* @param {object} [conditions={}] - WHERE conditions.
|
|
762
|
+
* @returns {Promise<Model|null>} First matching record, or null.
|
|
763
|
+
*
|
|
764
|
+
* @example
|
|
765
|
+
* const admin = await User.first({ role: 'admin' });
|
|
766
|
+
* const oldest = await User.first(); // first by PK
|
|
767
|
+
*/
|
|
768
|
+
static async first(conditions = {})
|
|
769
|
+
{
|
|
770
|
+
return this.query().where(conditions).first();
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Find the last record matching optional conditions.
|
|
775
|
+
*
|
|
776
|
+
* @param {object} [conditions={}] - WHERE conditions.
|
|
777
|
+
* @returns {Promise<Model|null>} Last matching record, or null.
|
|
778
|
+
*
|
|
779
|
+
* @example
|
|
780
|
+
* const newest = await User.last();
|
|
781
|
+
* const lastAdmin = await User.last({ role: 'admin' });
|
|
782
|
+
*/
|
|
783
|
+
static async last(conditions = {})
|
|
784
|
+
{
|
|
785
|
+
return this.query().where(conditions).last();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Rich pagination with metadata.
|
|
790
|
+
* Returns `{ data, total, page, perPage, pages, hasNext, hasPrev }`.
|
|
791
|
+
*
|
|
792
|
+
* @param {number} page - 1-indexed page number.
|
|
793
|
+
* @param {number} [perPage=20] - Items per page.
|
|
794
|
+
* @param {object} [conditions={}] - Optional WHERE conditions.
|
|
795
|
+
* @returns {Promise<object>} Pagination result with data, total, page, perPage, pages, hasNext, hasPrev.
|
|
796
|
+
*
|
|
797
|
+
* @example
|
|
798
|
+
* const result = await User.paginate(2, 10, { role: 'admin' });
|
|
799
|
+
* // { data: [...], total: 53, page: 2, perPage: 10,
|
|
800
|
+
* // pages: 6, hasNext: true, hasPrev: true }
|
|
801
|
+
*/
|
|
802
|
+
static async paginate(page, perPage = 20, conditions = {})
|
|
803
|
+
{
|
|
804
|
+
return this.query().where(conditions).paginate(page, perPage);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Process all matching records in batches.
|
|
809
|
+
* Calls `fn(batch, batchIndex)` for each chunk.
|
|
810
|
+
*
|
|
811
|
+
* @param {number} size - Batch size.
|
|
812
|
+
* @param {Function} fn - Called with (batch: Model[], index: number).
|
|
813
|
+
* @param {object} [conditions={}] - Optional WHERE conditions.
|
|
814
|
+
* @returns {Promise<void>}
|
|
815
|
+
*
|
|
816
|
+
* @example
|
|
817
|
+
* await User.chunk(100, async (users, i) => {
|
|
818
|
+
* for (const u of users) await u.update({ migrated: true });
|
|
819
|
+
* }, { active: true });
|
|
820
|
+
*/
|
|
821
|
+
static async chunk(size, fn, conditions = {})
|
|
822
|
+
{
|
|
823
|
+
return this.query().where(conditions).chunk(size, fn);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Get all records, optionally filtered.
|
|
828
|
+
* Alias for find() — for LINQ-familiarity.
|
|
829
|
+
*
|
|
830
|
+
* @param {object} [conditions={}] - WHERE conditions.
|
|
831
|
+
* @returns {Promise<Model[]>} All matching records.
|
|
832
|
+
*/
|
|
833
|
+
static async all(conditions = {})
|
|
834
|
+
{
|
|
835
|
+
return this.find(conditions);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Get a random record.
|
|
840
|
+
*
|
|
841
|
+
* @param {object} [conditions={}] - Optional WHERE conditions.
|
|
842
|
+
* @returns {Promise<Model|null>} Random matching record, or null.
|
|
843
|
+
*
|
|
844
|
+
* @example
|
|
845
|
+
* const luckyUser = await User.random();
|
|
846
|
+
* const randomAdmin = await User.random({ role: 'admin' });
|
|
847
|
+
*/
|
|
848
|
+
static async random(conditions = {})
|
|
849
|
+
{
|
|
850
|
+
const total = await this.count(conditions);
|
|
851
|
+
if (total === 0) return null;
|
|
852
|
+
const idx = Math.floor(Math.random() * total);
|
|
853
|
+
return this.query().where(conditions).offset(idx).first();
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Pluck values for a single column across all matching records.
|
|
858
|
+
*
|
|
859
|
+
* @param {string} field - Column name to extract.
|
|
860
|
+
* @param {object} [conditions={}] - Optional WHERE conditions.
|
|
861
|
+
* @returns {Promise<Array>} Values for the specified column.
|
|
862
|
+
*
|
|
863
|
+
* @example
|
|
864
|
+
* const emails = await User.pluck('email');
|
|
865
|
+
* const adminNames = await User.pluck('name', { role: 'admin' });
|
|
866
|
+
*/
|
|
867
|
+
static async pluck(field, conditions = {})
|
|
868
|
+
{
|
|
869
|
+
return this.query().where(conditions).pluck(field);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// -- Relationships ----------------------------------
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Define a hasMany relationship.
|
|
876
|
+
* @param {Function} RelatedModel - The related Model class.
|
|
877
|
+
* @param {string} foreignKey - Foreign key column on the related table.
|
|
878
|
+
* @param {string} [localKey] - Local key (default: primary key).
|
|
879
|
+
*/
|
|
880
|
+
static hasMany(RelatedModel, foreignKey, localKey)
|
|
881
|
+
{
|
|
882
|
+
const pk = localKey || this._primaryKey();
|
|
883
|
+
if (!this._relations) this._relations = {};
|
|
884
|
+
this._relations[RelatedModel.name] = { type: 'hasMany', model: RelatedModel, foreignKey, localKey: pk };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Define a hasOne relationship.
|
|
889
|
+
* @param {Function} RelatedModel - The related Model class.
|
|
890
|
+
* @param {string} foreignKey - Foreign key column on the related table.
|
|
891
|
+
* @param {string} [localKey] - Local key (default: primary key).
|
|
892
|
+
*/
|
|
893
|
+
static hasOne(RelatedModel, foreignKey, localKey)
|
|
894
|
+
{
|
|
895
|
+
const pk = localKey || this._primaryKey();
|
|
896
|
+
if (!this._relations) this._relations = {};
|
|
897
|
+
this._relations[RelatedModel.name] = { type: 'hasOne', model: RelatedModel, foreignKey, localKey: pk };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/**
|
|
901
|
+
* Define a belongsTo relationship.
|
|
902
|
+
* @param {Function} RelatedModel - The related Model class.
|
|
903
|
+
* @param {string} foreignKey - Foreign key column on THIS table.
|
|
904
|
+
* @param {string} [otherKey] - Key on the related table (default: its primary key).
|
|
905
|
+
*/
|
|
906
|
+
static belongsTo(RelatedModel, foreignKey, otherKey)
|
|
907
|
+
{
|
|
908
|
+
const ok = otherKey || RelatedModel._primaryKey();
|
|
909
|
+
if (!this._relations) this._relations = {};
|
|
910
|
+
this._relations[RelatedModel.name] = { type: 'belongsTo', model: RelatedModel, foreignKey, localKey: ok };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Define a many-to-many relationship through a junction/pivot table.
|
|
915
|
+
*
|
|
916
|
+
* @param {Function} RelatedModel - The related Model class.
|
|
917
|
+
* @param {object} opts - Relationship options.
|
|
918
|
+
* @param {string} opts.through - Junction table name (e.g. 'user_roles').
|
|
919
|
+
* @param {string} opts.foreignKey - Column on the junction table referencing THIS model.
|
|
920
|
+
* @param {string} opts.otherKey - Column on the junction table referencing the related model.
|
|
921
|
+
* @param {string} [opts.localKey] - Local key (default: primary key).
|
|
922
|
+
* @param {string} [opts.relatedKey] - Related model key (default: its primary key).
|
|
923
|
+
*
|
|
924
|
+
* @example
|
|
925
|
+
* User.belongsToMany(Role, {
|
|
926
|
+
* through: 'user_roles',
|
|
927
|
+
* foreignKey: 'userId',
|
|
928
|
+
* otherKey: 'roleId'
|
|
929
|
+
* });
|
|
930
|
+
* const roles = await user.load('Role'); // returns Role[]
|
|
931
|
+
*/
|
|
932
|
+
static belongsToMany(RelatedModel, opts = {})
|
|
933
|
+
{
|
|
934
|
+
if (!opts.through || !opts.foreignKey || !opts.otherKey)
|
|
935
|
+
{
|
|
936
|
+
throw new Error('belongsToMany requires through, foreignKey, and otherKey');
|
|
937
|
+
}
|
|
938
|
+
const pk = opts.localKey || this._primaryKey();
|
|
939
|
+
const rpk = opts.relatedKey || RelatedModel._primaryKey();
|
|
940
|
+
if (!this._relations) this._relations = {};
|
|
941
|
+
this._relations[RelatedModel.name] = {
|
|
942
|
+
type: 'belongsToMany',
|
|
943
|
+
model: RelatedModel,
|
|
944
|
+
through: opts.through,
|
|
945
|
+
foreignKey: opts.foreignKey,
|
|
946
|
+
otherKey: opts.otherKey,
|
|
947
|
+
localKey: pk,
|
|
948
|
+
relatedKey: rpk,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Load a related model for this instance.
|
|
954
|
+
*
|
|
955
|
+
* @param {string} relationName - Name of the related Model class or relation alias.
|
|
956
|
+
* @returns {Promise<Model|Model[]|null>} The related model(s) or null.
|
|
957
|
+
*/
|
|
958
|
+
async load(relationName)
|
|
959
|
+
{
|
|
960
|
+
const ctor = this.constructor;
|
|
961
|
+
const rel = ctor._relations && ctor._relations[relationName];
|
|
962
|
+
if (!rel) throw new Error(`Unknown relation "${relationName}" on ${ctor.name}`);
|
|
963
|
+
|
|
964
|
+
switch (rel.type)
|
|
965
|
+
{
|
|
966
|
+
case 'hasMany':
|
|
967
|
+
return rel.model.find({ [rel.foreignKey]: this[rel.localKey] });
|
|
968
|
+
case 'hasOne':
|
|
969
|
+
return rel.model.findOne({ [rel.foreignKey]: this[rel.localKey] });
|
|
970
|
+
case 'belongsTo':
|
|
971
|
+
return rel.model.findOne({ [rel.localKey]: this[rel.foreignKey] });
|
|
972
|
+
case 'belongsToMany':
|
|
973
|
+
{
|
|
974
|
+
// Query the junction table to find related IDs
|
|
975
|
+
const junctionRows = await ctor._adapter.execute({
|
|
976
|
+
action: 'select',
|
|
977
|
+
table: rel.through,
|
|
978
|
+
fields: [rel.otherKey],
|
|
979
|
+
where: [{ field: rel.foreignKey, op: '=', value: this[rel.localKey], logic: 'AND' }],
|
|
980
|
+
orderBy: [], joins: [], groupBy: [], having: [],
|
|
981
|
+
limit: null, offset: null, distinct: false,
|
|
982
|
+
});
|
|
983
|
+
if (!junctionRows.length) return [];
|
|
984
|
+
const relatedIds = junctionRows.map(r => r[rel.otherKey]);
|
|
985
|
+
return rel.model.query().whereIn(rel.relatedKey, relatedIds).exec();
|
|
986
|
+
}
|
|
987
|
+
case 'morphOne':
|
|
988
|
+
{
|
|
989
|
+
const typeCol = `${rel.morphName}_type`;
|
|
990
|
+
const idCol = `${rel.morphName}_id`;
|
|
991
|
+
return rel.model.findOne({ [typeCol]: ctor.name, [idCol]: this[rel.localKey] });
|
|
992
|
+
}
|
|
993
|
+
case 'morphMany':
|
|
994
|
+
{
|
|
995
|
+
const typeCol = `${rel.morphName}_type`;
|
|
996
|
+
const idCol = `${rel.morphName}_id`;
|
|
997
|
+
return rel.model.find({ [typeCol]: ctor.name, [idCol]: this[rel.localKey] });
|
|
998
|
+
}
|
|
999
|
+
case 'hasManyThrough':
|
|
1000
|
+
{
|
|
1001
|
+
// Get intermediate records
|
|
1002
|
+
const throughRecords = await rel.through.find({ [rel.firstKey]: this[rel.localKey] });
|
|
1003
|
+
if (!throughRecords.length) return [];
|
|
1004
|
+
const throughIds = throughRecords.map(r => r[rel.secondLocalKey]);
|
|
1005
|
+
return rel.model.query().whereIn(rel.secondKey, throughIds).exec();
|
|
1006
|
+
}
|
|
1007
|
+
default:
|
|
1008
|
+
throw new Error(`Unknown relation type "${rel.type}"`);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// -- Internal Static Helpers ------------------------
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Strip guarded fields from a data object.
|
|
1016
|
+
* Guarded fields are defined in the schema with `guarded: true`.
|
|
1017
|
+
* They cannot be set via mass-assignment (create / update with object).
|
|
1018
|
+
*
|
|
1019
|
+
* @param {object} data - The input data.
|
|
1020
|
+
* @returns {object} A copy of data without guarded fields.
|
|
1021
|
+
* @private
|
|
1022
|
+
*/
|
|
1023
|
+
static _stripGuarded(data)
|
|
1024
|
+
{
|
|
1025
|
+
const schema = this.schema;
|
|
1026
|
+
const guardedKeys = Object.entries(schema)
|
|
1027
|
+
.filter(([, def]) => def.guarded)
|
|
1028
|
+
.map(([name]) => name);
|
|
1029
|
+
if (guardedKeys.length === 0) return data;
|
|
1030
|
+
const cleaned = { ...data };
|
|
1031
|
+
for (const key of guardedKeys) delete cleaned[key];
|
|
1032
|
+
return cleaned;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Get the full schema including auto-fields.
|
|
1037
|
+
* @returns {object} Schema with auto-generated timestamp and soft-delete columns.
|
|
1038
|
+
* @private
|
|
1039
|
+
*/
|
|
1040
|
+
static _fullSchema()
|
|
1041
|
+
{
|
|
1042
|
+
const s = { ...this.schema };
|
|
1043
|
+
if (this.timestamps)
|
|
1044
|
+
{
|
|
1045
|
+
if (!s.createdAt) s.createdAt = { type: 'datetime', default: () => new Date() };
|
|
1046
|
+
if (!s.updatedAt) s.updatedAt = { type: 'datetime', default: () => new Date() };
|
|
1047
|
+
}
|
|
1048
|
+
if (this.softDelete)
|
|
1049
|
+
{
|
|
1050
|
+
if (!s.deletedAt) s.deletedAt = { type: 'datetime', nullable: true };
|
|
1051
|
+
}
|
|
1052
|
+
return s;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Get the primary key column name(s).
|
|
1057
|
+
* Returns a single string for simple PKs, or an array for composite PKs.
|
|
1058
|
+
* @returns {string|string[]} Primary key column name(s).
|
|
1059
|
+
* @private
|
|
1060
|
+
*/
|
|
1061
|
+
static _primaryKey()
|
|
1062
|
+
{
|
|
1063
|
+
const pks = [];
|
|
1064
|
+
for (const [name, def] of Object.entries(this.schema))
|
|
1065
|
+
{
|
|
1066
|
+
if (def.primaryKey) pks.push(name);
|
|
1067
|
+
}
|
|
1068
|
+
if (pks.length === 0) return 'id'; // convention
|
|
1069
|
+
if (pks.length === 1) return pks[0];
|
|
1070
|
+
return pks; // composite PK
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Create a model instance from a raw database row.
|
|
1075
|
+
* @param {object} row - Data row object.
|
|
1076
|
+
* @returns {Model} Hydrated model instance.
|
|
1077
|
+
* @private
|
|
1078
|
+
*/
|
|
1079
|
+
static _fromRow(row)
|
|
1080
|
+
{
|
|
1081
|
+
const instance = new this(row);
|
|
1082
|
+
instance._persisted = true;
|
|
1083
|
+
instance._snapshot();
|
|
1084
|
+
return instance;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Run a lifecycle hook if defined.
|
|
1089
|
+
* Also emits model events and notifies observers.
|
|
1090
|
+
* @param {string} hookName - Lifecycle hook name.
|
|
1091
|
+
* @param {*} data - Record data object.
|
|
1092
|
+
* @returns {Promise<*>} Resolved value.
|
|
1093
|
+
* @private
|
|
1094
|
+
*/
|
|
1095
|
+
static async _runHook(hookName, data)
|
|
1096
|
+
{
|
|
1097
|
+
// Check for static hook on class
|
|
1098
|
+
if (typeof this[hookName] === 'function')
|
|
1099
|
+
{
|
|
1100
|
+
await this[hookName](data);
|
|
1101
|
+
}
|
|
1102
|
+
// Check hooks object
|
|
1103
|
+
else if (this.hooks && typeof this.hooks[hookName] === 'function')
|
|
1104
|
+
{
|
|
1105
|
+
await this.hooks[hookName](data);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Emit model event
|
|
1109
|
+
this._emit(hookName, data);
|
|
1110
|
+
|
|
1111
|
+
// Notify observers
|
|
1112
|
+
this._notifyObservers(hookName, data);
|
|
1113
|
+
|
|
1114
|
+
return data;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Sync the table schema with the database (create table if not exists).
|
|
1119
|
+
* @returns {Promise<void>}
|
|
1120
|
+
*/
|
|
1121
|
+
static async sync()
|
|
1122
|
+
{
|
|
1123
|
+
if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
|
|
1124
|
+
return this._adapter.createTable(this.table, this._fullSchema());
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Drop the table.
|
|
1129
|
+
* @returns {Promise<void>}
|
|
1130
|
+
*/
|
|
1131
|
+
static async drop()
|
|
1132
|
+
{
|
|
1133
|
+
if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
|
|
1134
|
+
return this._adapter.dropTable(this.table);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// -- Attribute Casting Helpers ----------------------
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Apply a cast transformation on get (reading from model).
|
|
1141
|
+
* @param {*} value - Raw stored value.
|
|
1142
|
+
* @param {string|object} cast - Cast type or custom cast object.
|
|
1143
|
+
* @returns {*} Transformed value.
|
|
1144
|
+
* @private
|
|
1145
|
+
*/
|
|
1146
|
+
static _applyCastGet(value, cast)
|
|
1147
|
+
{
|
|
1148
|
+
if (value === null || value === undefined) return value;
|
|
1149
|
+
if (typeof cast === 'object' && typeof cast.get === 'function')
|
|
1150
|
+
{
|
|
1151
|
+
return cast.get(value);
|
|
1152
|
+
}
|
|
1153
|
+
switch (cast)
|
|
1154
|
+
{
|
|
1155
|
+
case 'json':
|
|
1156
|
+
case 'array':
|
|
1157
|
+
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
1158
|
+
case 'boolean':
|
|
1159
|
+
if (typeof value === 'boolean') return value;
|
|
1160
|
+
if (typeof value === 'number') return value !== 0;
|
|
1161
|
+
if (typeof value === 'string') return ['true', '1', 'yes'].includes(value.toLowerCase());
|
|
1162
|
+
return Boolean(value);
|
|
1163
|
+
case 'integer':
|
|
1164
|
+
return parseInt(value, 10) || 0;
|
|
1165
|
+
case 'float':
|
|
1166
|
+
return parseFloat(value) || 0;
|
|
1167
|
+
case 'date':
|
|
1168
|
+
return value instanceof Date ? value : new Date(value);
|
|
1169
|
+
case 'string':
|
|
1170
|
+
return String(value);
|
|
1171
|
+
default:
|
|
1172
|
+
return value;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Apply a cast transformation on set (writing to model).
|
|
1178
|
+
* @param {*} value - Input value.
|
|
1179
|
+
* @param {string|object} cast - Cast type or custom cast object.
|
|
1180
|
+
* @returns {*} Transformed value for storage.
|
|
1181
|
+
* @private
|
|
1182
|
+
*/
|
|
1183
|
+
static _applyCastSet(value, cast)
|
|
1184
|
+
{
|
|
1185
|
+
if (value === null || value === undefined) return value;
|
|
1186
|
+
if (typeof cast === 'object' && typeof cast.set === 'function')
|
|
1187
|
+
{
|
|
1188
|
+
return cast.set(value);
|
|
1189
|
+
}
|
|
1190
|
+
switch (cast)
|
|
1191
|
+
{
|
|
1192
|
+
case 'json':
|
|
1193
|
+
case 'array':
|
|
1194
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
1195
|
+
case 'boolean':
|
|
1196
|
+
if (typeof value === 'boolean') return value;
|
|
1197
|
+
if (typeof value === 'number') return value !== 0;
|
|
1198
|
+
if (typeof value === 'string') return ['true', '1', 'yes'].includes(value.toLowerCase());
|
|
1199
|
+
return Boolean(value);
|
|
1200
|
+
case 'integer':
|
|
1201
|
+
return parseInt(value, 10) || 0;
|
|
1202
|
+
case 'float':
|
|
1203
|
+
return parseFloat(value) || 0;
|
|
1204
|
+
case 'date':
|
|
1205
|
+
return value instanceof Date ? value : new Date(value);
|
|
1206
|
+
case 'string':
|
|
1207
|
+
return String(value);
|
|
1208
|
+
default:
|
|
1209
|
+
return value;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Get an attribute value with accessor/cast applied.
|
|
1215
|
+
*
|
|
1216
|
+
* @param {string} key - Attribute name.
|
|
1217
|
+
* @returns {*} Transformed value.
|
|
1218
|
+
*
|
|
1219
|
+
* @example
|
|
1220
|
+
* const email = user.getAttribute('email');
|
|
1221
|
+
*/
|
|
1222
|
+
getAttribute(key)
|
|
1223
|
+
{
|
|
1224
|
+
const ctor = this.constructor;
|
|
1225
|
+
const accessors = ctor.accessors || {};
|
|
1226
|
+
const casts = ctor.casts || {};
|
|
1227
|
+
const computed = ctor.computed || {};
|
|
1228
|
+
|
|
1229
|
+
// Check computed first
|
|
1230
|
+
if (typeof computed[key] === 'function')
|
|
1231
|
+
{
|
|
1232
|
+
return computed[key](this);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
let val = this[key];
|
|
1236
|
+
|
|
1237
|
+
// Apply accessor
|
|
1238
|
+
if (typeof accessors[key] === 'function')
|
|
1239
|
+
{
|
|
1240
|
+
return accessors[key](val, this);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Apply cast get
|
|
1244
|
+
if (casts[key])
|
|
1245
|
+
{
|
|
1246
|
+
return Model._applyCastGet(val, casts[key]);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
return val;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Set an attribute value with mutator/cast applied.
|
|
1254
|
+
*
|
|
1255
|
+
* @param {string} key - Attribute name.
|
|
1256
|
+
* @param {*} value - Value to set.
|
|
1257
|
+
* @returns {Model} `this` for chaining.
|
|
1258
|
+
*
|
|
1259
|
+
* @example
|
|
1260
|
+
* user.setAttribute('email', 'ALICE@EXAMPLE.COM');
|
|
1261
|
+
* // If mutator lowercases: user.email => 'alice@example.com'
|
|
1262
|
+
*/
|
|
1263
|
+
setAttribute(key, value)
|
|
1264
|
+
{
|
|
1265
|
+
const ctor = this.constructor;
|
|
1266
|
+
const mutators = ctor.mutators || {};
|
|
1267
|
+
const casts = ctor.casts || {};
|
|
1268
|
+
|
|
1269
|
+
if (typeof mutators[key] === 'function')
|
|
1270
|
+
{
|
|
1271
|
+
this[key] = mutators[key](value, this);
|
|
1272
|
+
}
|
|
1273
|
+
else if (casts[key])
|
|
1274
|
+
{
|
|
1275
|
+
this[key] = Model._applyCastSet(value, casts[key]);
|
|
1276
|
+
}
|
|
1277
|
+
else
|
|
1278
|
+
{
|
|
1279
|
+
this[key] = value;
|
|
1280
|
+
}
|
|
1281
|
+
return this;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// -- Model Events -----------------------------------
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Get or create the EventEmitter for this model class.
|
|
1288
|
+
* @returns {EventEmitter} The model's event emitter.
|
|
1289
|
+
* @private
|
|
1290
|
+
*/
|
|
1291
|
+
static _getEmitter()
|
|
1292
|
+
{
|
|
1293
|
+
if (!this.hasOwnProperty('_emitter') || !this._emitter)
|
|
1294
|
+
{
|
|
1295
|
+
this._emitter = new EventEmitter();
|
|
1296
|
+
}
|
|
1297
|
+
return this._emitter;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Register an event listener on this model.
|
|
1302
|
+
* Supported events: `creating`, `created`, `updating`, `updated`,
|
|
1303
|
+
* `deleting`, `deleted`, `saving`, `saved`.
|
|
1304
|
+
*
|
|
1305
|
+
* @param {string} event - Event name.
|
|
1306
|
+
* @param {Function} listener - Callback `(data) => {}`.
|
|
1307
|
+
* @returns {typeof Model} The model class (for chaining).
|
|
1308
|
+
*
|
|
1309
|
+
* @example
|
|
1310
|
+
* User.on('created', (user) => {
|
|
1311
|
+
* console.log('New user:', user.name);
|
|
1312
|
+
* });
|
|
1313
|
+
*
|
|
1314
|
+
* User.on('updating', (changes) => {
|
|
1315
|
+
* console.log('Updating fields:', Object.keys(changes));
|
|
1316
|
+
* });
|
|
1317
|
+
*/
|
|
1318
|
+
static on(event, listener)
|
|
1319
|
+
{
|
|
1320
|
+
this._getEmitter().on(event, listener);
|
|
1321
|
+
return this;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Register a one-time event listener.
|
|
1326
|
+
*
|
|
1327
|
+
* @param {string} event - Event name.
|
|
1328
|
+
* @param {Function} listener - Callback function.
|
|
1329
|
+
* @returns {typeof Model} The model class (for chaining).
|
|
1330
|
+
*/
|
|
1331
|
+
static once(event, listener)
|
|
1332
|
+
{
|
|
1333
|
+
this._getEmitter().once(event, listener);
|
|
1334
|
+
return this;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Remove an event listener.
|
|
1339
|
+
*
|
|
1340
|
+
* @param {string} event - Event name.
|
|
1341
|
+
* @param {Function} listener - Callback to remove.
|
|
1342
|
+
* @returns {typeof Model} The model class (for chaining).
|
|
1343
|
+
*/
|
|
1344
|
+
static off(event, listener)
|
|
1345
|
+
{
|
|
1346
|
+
this._getEmitter().off(event, listener);
|
|
1347
|
+
return this;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Remove all listeners for an event, or all listeners entirely.
|
|
1352
|
+
*
|
|
1353
|
+
* @param {string} [event] - Event name. If omitted, removes all listeners.
|
|
1354
|
+
* @returns {typeof Model} The model class (for chaining).
|
|
1355
|
+
*/
|
|
1356
|
+
static removeAllListeners(event)
|
|
1357
|
+
{
|
|
1358
|
+
if (event !== undefined)
|
|
1359
|
+
{
|
|
1360
|
+
this._getEmitter().removeAllListeners(event);
|
|
1361
|
+
}
|
|
1362
|
+
else
|
|
1363
|
+
{
|
|
1364
|
+
this._getEmitter().removeAllListeners();
|
|
1365
|
+
}
|
|
1366
|
+
return this;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Emit a model event.
|
|
1371
|
+
* @param {string} event - Event name.
|
|
1372
|
+
* @param {*} data - Event data.
|
|
1373
|
+
* @private
|
|
1374
|
+
*/
|
|
1375
|
+
static _emit(event, data)
|
|
1376
|
+
{
|
|
1377
|
+
// Map hook names to event names
|
|
1378
|
+
const eventMap = {
|
|
1379
|
+
beforeCreate: 'creating',
|
|
1380
|
+
afterCreate: 'created',
|
|
1381
|
+
beforeUpdate: 'updating',
|
|
1382
|
+
afterUpdate: 'updated',
|
|
1383
|
+
beforeDelete: 'deleting',
|
|
1384
|
+
afterDelete: 'deleted',
|
|
1385
|
+
};
|
|
1386
|
+
const eventName = eventMap[event];
|
|
1387
|
+
if (eventName && this.hasOwnProperty('_emitter') && this._emitter)
|
|
1388
|
+
{
|
|
1389
|
+
this._emitter.emit(eventName, data);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// -- Observers --------------------------------------
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Register an observer for this model.
|
|
1397
|
+
* An observer is an object with methods named after lifecycle events:
|
|
1398
|
+
* `creating`, `created`, `updating`, `updated`, `deleting`, `deleted`.
|
|
1399
|
+
*
|
|
1400
|
+
* @param {object} observer - Observer object with event handler methods.
|
|
1401
|
+
* @returns {typeof Model} The model class (for chaining).
|
|
1402
|
+
*
|
|
1403
|
+
* @example
|
|
1404
|
+
* const UserObserver = {
|
|
1405
|
+
* created(user) { console.log('New user:', user.name); },
|
|
1406
|
+
* updating(changes) { console.log('Updating:', changes); },
|
|
1407
|
+
* deleted(user) { console.log('Deleted user:', user.id); },
|
|
1408
|
+
* };
|
|
1409
|
+
*
|
|
1410
|
+
* User.observe(UserObserver);
|
|
1411
|
+
*/
|
|
1412
|
+
static observe(observer)
|
|
1413
|
+
{
|
|
1414
|
+
if (!this.hasOwnProperty('_observers'))
|
|
1415
|
+
{
|
|
1416
|
+
this._observers = [];
|
|
1417
|
+
}
|
|
1418
|
+
this._observers.push(observer);
|
|
1419
|
+
return this;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Remove an observer from this model.
|
|
1424
|
+
*
|
|
1425
|
+
* @param {object} observer - Observer to remove.
|
|
1426
|
+
* @returns {typeof Model} The model class (for chaining).
|
|
1427
|
+
*/
|
|
1428
|
+
static unobserve(observer)
|
|
1429
|
+
{
|
|
1430
|
+
if (this.hasOwnProperty('_observers'))
|
|
1431
|
+
{
|
|
1432
|
+
this._observers = this._observers.filter(o => o !== observer);
|
|
1433
|
+
}
|
|
1434
|
+
return this;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Notify all registered observers of a lifecycle event.
|
|
1439
|
+
* @param {string} hookName - Hook name (e.g. 'beforeCreate').
|
|
1440
|
+
* @param {*} data - Event data.
|
|
1441
|
+
* @private
|
|
1442
|
+
*/
|
|
1443
|
+
static _notifyObservers(hookName, data)
|
|
1444
|
+
{
|
|
1445
|
+
const eventMap = {
|
|
1446
|
+
beforeCreate: 'creating',
|
|
1447
|
+
afterCreate: 'created',
|
|
1448
|
+
beforeUpdate: 'updating',
|
|
1449
|
+
afterUpdate: 'updated',
|
|
1450
|
+
beforeDelete: 'deleting',
|
|
1451
|
+
afterDelete: 'deleted',
|
|
1452
|
+
};
|
|
1453
|
+
const eventName = eventMap[hookName];
|
|
1454
|
+
if (!eventName) return;
|
|
1455
|
+
|
|
1456
|
+
const observers = this.hasOwnProperty('_observers') ? this._observers : [];
|
|
1457
|
+
for (const observer of observers)
|
|
1458
|
+
{
|
|
1459
|
+
if (typeof observer[eventName] === 'function')
|
|
1460
|
+
{
|
|
1461
|
+
observer[eventName](data);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// -- Advanced Relationships -------------------------
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Define a polymorphic one-to-one relationship (morphOne).
|
|
1470
|
+
* The related table uses two columns: a type column and an ID column.
|
|
1471
|
+
*
|
|
1472
|
+
* @param {Function} RelatedModel - The related Model class.
|
|
1473
|
+
* @param {string} morphName - Base name for the polymorphic columns (e.g. 'commentable').
|
|
1474
|
+
* @param {string} [localKey] - Local key (default: primary key).
|
|
1475
|
+
*
|
|
1476
|
+
* @example
|
|
1477
|
+
* // Image can belong to either User or Post
|
|
1478
|
+
* User.morphOne(Image, 'imageable');
|
|
1479
|
+
* // Related table has: imageable_type, imageable_id columns
|
|
1480
|
+
* const avatar = await user.load('Image'); // Image where imageable_type='User', imageable_id=user.id
|
|
1481
|
+
*/
|
|
1482
|
+
static morphOne(RelatedModel, morphName, localKey)
|
|
1483
|
+
{
|
|
1484
|
+
const pk = localKey || this._primaryKey();
|
|
1485
|
+
if (!this._relations) this._relations = {};
|
|
1486
|
+
this._relations[RelatedModel.name] = {
|
|
1487
|
+
type: 'morphOne',
|
|
1488
|
+
model: RelatedModel,
|
|
1489
|
+
morphName,
|
|
1490
|
+
localKey: pk,
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Define a polymorphic one-to-many relationship (morphMany).
|
|
1496
|
+
* The related table uses two columns: a type column and an ID column.
|
|
1497
|
+
*
|
|
1498
|
+
* @param {Function} RelatedModel - The related Model class.
|
|
1499
|
+
* @param {string} morphName - Base name for the polymorphic columns (e.g. 'commentable').
|
|
1500
|
+
* @param {string} [localKey] - Local key (default: primary key).
|
|
1501
|
+
*
|
|
1502
|
+
* @example
|
|
1503
|
+
* // Comments can belong to either Post or Video
|
|
1504
|
+
* Post.morphMany(Comment, 'commentable');
|
|
1505
|
+
* const comments = await post.load('Comment');
|
|
1506
|
+
*/
|
|
1507
|
+
static morphMany(RelatedModel, morphName, localKey)
|
|
1508
|
+
{
|
|
1509
|
+
const pk = localKey || this._primaryKey();
|
|
1510
|
+
if (!this._relations) this._relations = {};
|
|
1511
|
+
this._relations[RelatedModel.name] = {
|
|
1512
|
+
type: 'morphMany',
|
|
1513
|
+
model: RelatedModel,
|
|
1514
|
+
morphName,
|
|
1515
|
+
localKey: pk,
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
/**
|
|
1520
|
+
* Define a has-many-through relationship.
|
|
1521
|
+
* Accesses distant relations through an intermediate table.
|
|
1522
|
+
*
|
|
1523
|
+
* @param {Function} RelatedModel - The distant related Model class.
|
|
1524
|
+
* @param {Function} ThroughModel - The intermediate Model class.
|
|
1525
|
+
* @param {string} firstKey - FK on the through table referencing this model.
|
|
1526
|
+
* @param {string} secondKey - FK on the related table referencing the through table.
|
|
1527
|
+
* @param {string} [localKey] - Local key (default: primary key).
|
|
1528
|
+
* @param {string} [secondLocalKey] - Key on the through table matched by secondKey (default: through model PK).
|
|
1529
|
+
*
|
|
1530
|
+
* @example
|
|
1531
|
+
* // Country → User → Post
|
|
1532
|
+
* // A country has many posts through users
|
|
1533
|
+
* Country.hasManyThrough(Post, User, 'countryId', 'userId');
|
|
1534
|
+
* const posts = await country.load('Post');
|
|
1535
|
+
*/
|
|
1536
|
+
static hasManyThrough(RelatedModel, ThroughModel, firstKey, secondKey, localKey, secondLocalKey)
|
|
1537
|
+
{
|
|
1538
|
+
const pk = localKey || this._primaryKey();
|
|
1539
|
+
const throughPk = secondLocalKey || ThroughModel._primaryKey();
|
|
1540
|
+
if (!this._relations) this._relations = {};
|
|
1541
|
+
this._relations[RelatedModel.name] = {
|
|
1542
|
+
type: 'hasManyThrough',
|
|
1543
|
+
model: RelatedModel,
|
|
1544
|
+
through: ThroughModel,
|
|
1545
|
+
firstKey,
|
|
1546
|
+
secondKey,
|
|
1547
|
+
localKey: pk,
|
|
1548
|
+
secondLocalKey: throughPk,
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/**
|
|
1553
|
+
* Define a self-referential relationship for tree/graph structures.
|
|
1554
|
+
* Sets up both parent and children relationships.
|
|
1555
|
+
*
|
|
1556
|
+
* @param {object} opts - Relationship options.
|
|
1557
|
+
* @param {string} opts.foreignKey - FK column referencing self (e.g. 'parentId').
|
|
1558
|
+
* @param {string} [opts.localKey] - Local key (default: primary key).
|
|
1559
|
+
* @param {string} [opts.parentName='parent'] - Name for the parent relationship.
|
|
1560
|
+
* @param {string} [opts.childrenName='children'] - Name for the children relationship.
|
|
1561
|
+
*
|
|
1562
|
+
* @example
|
|
1563
|
+
* Category.selfReferential({
|
|
1564
|
+
* foreignKey: 'parentId',
|
|
1565
|
+
* parentName: 'parent',
|
|
1566
|
+
* childrenName: 'children',
|
|
1567
|
+
* });
|
|
1568
|
+
*
|
|
1569
|
+
* const parent = await category.load('parent');
|
|
1570
|
+
* const children = await category.load('children');
|
|
1571
|
+
* const tree = await Category.tree(); // full tree structure
|
|
1572
|
+
*/
|
|
1573
|
+
static selfReferential(opts = {})
|
|
1574
|
+
{
|
|
1575
|
+
if (!opts.foreignKey) throw new Error('selfReferential requires foreignKey');
|
|
1576
|
+
const pk = opts.localKey || this._primaryKey();
|
|
1577
|
+
const parentName = opts.parentName || 'parent';
|
|
1578
|
+
const childrenName = opts.childrenName || 'children';
|
|
1579
|
+
|
|
1580
|
+
if (!this._relations) this._relations = {};
|
|
1581
|
+
|
|
1582
|
+
// Parent relationship (belongsTo self)
|
|
1583
|
+
this._relations[parentName] = {
|
|
1584
|
+
type: 'belongsTo',
|
|
1585
|
+
model: this,
|
|
1586
|
+
foreignKey: opts.foreignKey,
|
|
1587
|
+
localKey: pk,
|
|
1588
|
+
};
|
|
1589
|
+
|
|
1590
|
+
// Children relationship (hasMany self)
|
|
1591
|
+
this._relations[childrenName] = {
|
|
1592
|
+
type: 'hasMany',
|
|
1593
|
+
model: this,
|
|
1594
|
+
foreignKey: opts.foreignKey,
|
|
1595
|
+
localKey: pk,
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Build a full tree structure from self-referential records.
|
|
1601
|
+
* Returns nested objects with a `children` array property.
|
|
1602
|
+
*
|
|
1603
|
+
* @param {object} [options] - Configuration options.
|
|
1604
|
+
* @param {string} [options.foreignKey='parentId'] - FK column for the parent reference.
|
|
1605
|
+
* @param {string} [options.childrenKey='children'] - Property name for nested children.
|
|
1606
|
+
* @param {*} [options.rootValue=null] - Value of foreignKey that indicates a root node.
|
|
1607
|
+
* @returns {Promise<object[]>} Array of root nodes with nested children.
|
|
1608
|
+
*
|
|
1609
|
+
* @example
|
|
1610
|
+
* const tree = await Category.tree({ foreignKey: 'parentId' });
|
|
1611
|
+
* // [{ id: 1, name: 'Root', children: [{ id: 2, name: 'Child', children: [] }] }]
|
|
1612
|
+
*/
|
|
1613
|
+
static async tree(options = {})
|
|
1614
|
+
{
|
|
1615
|
+
const { foreignKey = 'parentId', childrenKey = 'children', rootValue = null } = options;
|
|
1616
|
+
const all = await this.find();
|
|
1617
|
+
const pk = this._primaryKey();
|
|
1618
|
+
const map = new Map();
|
|
1619
|
+
const roots = [];
|
|
1620
|
+
|
|
1621
|
+
for (const node of all) { node[childrenKey] = []; map.set(node[pk], node); }
|
|
1622
|
+
|
|
1623
|
+
for (const node of all)
|
|
1624
|
+
{
|
|
1625
|
+
const parentId = node[foreignKey];
|
|
1626
|
+
if (parentId === rootValue || parentId === null || parentId === undefined)
|
|
1627
|
+
{
|
|
1628
|
+
roots.push(node);
|
|
1629
|
+
}
|
|
1630
|
+
else
|
|
1631
|
+
{
|
|
1632
|
+
const parent = map.get(parentId);
|
|
1633
|
+
if (parent) parent[childrenKey].push(node);
|
|
1634
|
+
else roots.push(node); // orphan → treat as root
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
return roots;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* Get all ancestors of this instance in a self-referential tree.
|
|
1643
|
+
*
|
|
1644
|
+
* @param {string} [foreignKey='parentId'] - FK column for the parent reference.
|
|
1645
|
+
* @returns {Promise<Model[]>} Array of ancestors from immediate parent to root.
|
|
1646
|
+
*
|
|
1647
|
+
* @example
|
|
1648
|
+
* const ancestors = await category.ancestors('parentId');
|
|
1649
|
+
* // [parentCategory, grandparentCategory, rootCategory]
|
|
1650
|
+
*/
|
|
1651
|
+
async ancestors(foreignKey = 'parentId')
|
|
1652
|
+
{
|
|
1653
|
+
const ctor = this.constructor;
|
|
1654
|
+
const pk = ctor._primaryKey();
|
|
1655
|
+
const result = [];
|
|
1656
|
+
let currentId = this[foreignKey];
|
|
1657
|
+
const seen = new Set();
|
|
1658
|
+
|
|
1659
|
+
while (currentId !== null && currentId !== undefined)
|
|
1660
|
+
{
|
|
1661
|
+
if (seen.has(currentId)) break; // circular reference guard
|
|
1662
|
+
seen.add(currentId);
|
|
1663
|
+
const parent = await ctor.findById(currentId);
|
|
1664
|
+
if (!parent) break;
|
|
1665
|
+
result.push(parent);
|
|
1666
|
+
currentId = parent[foreignKey];
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
return result;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* Get all descendants of this instance in a self-referential tree.
|
|
1674
|
+
*
|
|
1675
|
+
* @param {string} [foreignKey='parentId'] - FK column for the parent reference.
|
|
1676
|
+
* @returns {Promise<Model[]>} Flat array of all descendants (breadth-first).
|
|
1677
|
+
*
|
|
1678
|
+
* @example
|
|
1679
|
+
* const descendants = await category.descendants('parentId');
|
|
1680
|
+
*/
|
|
1681
|
+
async descendants(foreignKey = 'parentId')
|
|
1682
|
+
{
|
|
1683
|
+
const ctor = this.constructor;
|
|
1684
|
+
const pk = ctor._primaryKey();
|
|
1685
|
+
const result = [];
|
|
1686
|
+
const queue = [this[pk]];
|
|
1687
|
+
const seen = new Set([this[pk]]);
|
|
1688
|
+
|
|
1689
|
+
while (queue.length)
|
|
1690
|
+
{
|
|
1691
|
+
const parentId = queue.shift();
|
|
1692
|
+
const children = await ctor.find({ [foreignKey]: parentId });
|
|
1693
|
+
for (const child of children)
|
|
1694
|
+
{
|
|
1695
|
+
if (seen.has(child[pk])) continue; // circular reference guard
|
|
1696
|
+
seen.add(child[pk]);
|
|
1697
|
+
result.push(child);
|
|
1698
|
+
queue.push(child[pk]);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
return result;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
module.exports = Model;
|