ilana-orm 1.0.0
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/README.md +4763 -0
- package/cli/ilana.js +928 -0
- package/database/DB.js +85 -0
- package/database/connection.js +114 -0
- package/database/schema-builder.js +219 -0
- package/ilana.config.js +61 -0
- package/ilana.png +0 -0
- package/index.js +34 -0
- package/orm/Collection.js +283 -0
- package/orm/CustomCasts.js +75 -0
- package/orm/Factory.js +372 -0
- package/orm/MigrationRunner.js +458 -0
- package/orm/Model.js +607 -0
- package/orm/ModelRegistry.js +26 -0
- package/orm/QueryBuilder.js +680 -0
- package/orm/Relation.js +299 -0
- package/orm/Seeder.js +153 -0
- package/package.json +71 -0
- package/test-role.js +26 -0
package/orm/Model.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
// Model.js
|
|
2
|
+
const QueryBuilder = require('./QueryBuilder');
|
|
3
|
+
const { HasOne, HasMany, BelongsTo, BelongsToMany, HasManyThrough, MorphTo, MorphMany } = require('./Relation');
|
|
4
|
+
const ModelRegistry = require('./ModelRegistry');
|
|
5
|
+
const Database = require('../database/connection');
|
|
6
|
+
|
|
7
|
+
// Auto-load configuration on first import
|
|
8
|
+
(function autoLoadConfig() {
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const configPath = path.join(process.cwd(), 'ilana.config.js');
|
|
12
|
+
if (fs.existsSync(configPath)) {
|
|
13
|
+
delete require.cache[configPath];
|
|
14
|
+
require(configPath);
|
|
15
|
+
}
|
|
16
|
+
})();
|
|
17
|
+
|
|
18
|
+
class Model {
|
|
19
|
+
// --- Static defaults ---
|
|
20
|
+
static table;
|
|
21
|
+
static connection;
|
|
22
|
+
static primaryKey = 'id';
|
|
23
|
+
static keyType = 'int';
|
|
24
|
+
static incrementing = true;
|
|
25
|
+
static timestamps = true;
|
|
26
|
+
static softDeletes = false;
|
|
27
|
+
static fillable = [];
|
|
28
|
+
static guarded = ['*'];
|
|
29
|
+
static casts = {};
|
|
30
|
+
static events = {};
|
|
31
|
+
static globalScopes = new Map();
|
|
32
|
+
static appends = [];
|
|
33
|
+
static timezone = 'UTC';
|
|
34
|
+
|
|
35
|
+
// --- Instance props ---
|
|
36
|
+
attributes = {};
|
|
37
|
+
original = {};
|
|
38
|
+
relations = {};
|
|
39
|
+
exists = false;
|
|
40
|
+
wasRecentlyCreated = false;
|
|
41
|
+
_dirty = new Set();
|
|
42
|
+
|
|
43
|
+
fillable;
|
|
44
|
+
guarded;
|
|
45
|
+
casts;
|
|
46
|
+
_deferred;
|
|
47
|
+
|
|
48
|
+
constructor(attrs = {}) {
|
|
49
|
+
// instance-level fillable/guarded/casts
|
|
50
|
+
this.fillable = Array.isArray(this.fillable) && this.fillable.length
|
|
51
|
+
? this.fillable
|
|
52
|
+
: this.constructor.fillable;
|
|
53
|
+
this.guarded = Array.isArray(this.guarded) && this.guarded.length
|
|
54
|
+
? this.guarded
|
|
55
|
+
: this.constructor.guarded;
|
|
56
|
+
this.casts = { ...this.constructor.casts };
|
|
57
|
+
this.appends = Array.isArray(this.appends) && this.appends.length
|
|
58
|
+
? this.appends
|
|
59
|
+
: this.constructor.appends;
|
|
60
|
+
|
|
61
|
+
// defer attribute setting
|
|
62
|
+
if (attrs && Object.keys(attrs).length) this._deferred = attrs;
|
|
63
|
+
|
|
64
|
+
// Create property getters for attributes
|
|
65
|
+
this._createAttributeGetters();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_initialize() {
|
|
69
|
+
if (!this._deferred) return;
|
|
70
|
+
// When initializing from database, bypass fillable/guarded restrictions
|
|
71
|
+
for (const [k, v] of Object.entries(this._deferred)) {
|
|
72
|
+
this.setAttribute(k, v);
|
|
73
|
+
}
|
|
74
|
+
this.syncOriginal();
|
|
75
|
+
this._deferred = null;
|
|
76
|
+
// Recreate getters after initialization
|
|
77
|
+
this._createAttributeGetters();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_createAttributeGetters() {
|
|
81
|
+
// Create getters for all potential attributes
|
|
82
|
+
const allKeys = new Set([
|
|
83
|
+
...Object.keys(this.attributes || {}),
|
|
84
|
+
...this.fillable,
|
|
85
|
+
...(this._deferred ? Object.keys(this._deferred) : [])
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
for (const key of allKeys) {
|
|
89
|
+
if (!this.hasOwnProperty(key)) {
|
|
90
|
+
Object.defineProperty(this, key, {
|
|
91
|
+
get() {
|
|
92
|
+
return this.getAttribute(key);
|
|
93
|
+
},
|
|
94
|
+
set(value) {
|
|
95
|
+
this.setAttribute(key, value);
|
|
96
|
+
},
|
|
97
|
+
enumerable: false,
|
|
98
|
+
configurable: true
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Static registry & resolving ---
|
|
105
|
+
static register() {
|
|
106
|
+
ModelRegistry.register(this.name, this);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
static resolveRelatedModel(related) {
|
|
110
|
+
if (typeof related === 'string') {
|
|
111
|
+
const cls = ModelRegistry.get(related);
|
|
112
|
+
if (!cls) throw new Error(`Model '${related}' not found. Make sure to call ${related}.register().`);
|
|
113
|
+
return cls;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If it's a class, ensure it's registered and return from registry if available
|
|
117
|
+
if (typeof related === 'function' && related.name) {
|
|
118
|
+
const registered = ModelRegistry.get(related.name);
|
|
119
|
+
if (registered) {
|
|
120
|
+
return registered;
|
|
121
|
+
}
|
|
122
|
+
// If not registered but is a valid model class, register it now
|
|
123
|
+
if (related.getTableName && typeof related.getTableName === 'function') {
|
|
124
|
+
ModelRegistry.register(related.name, related);
|
|
125
|
+
return related;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return related;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Query builder ---
|
|
133
|
+
static query() {
|
|
134
|
+
|
|
135
|
+
const qb = new QueryBuilder(this.getTableName(), this, this.getConnectionName());
|
|
136
|
+
|
|
137
|
+
// Use current transaction if available
|
|
138
|
+
const currentTrx = Database.getCurrentTransaction();
|
|
139
|
+
if (currentTrx) {
|
|
140
|
+
qb.query = currentTrx(this.getTableName());
|
|
141
|
+
qb._transaction = currentTrx;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.applyGlobalScopes(qb);
|
|
145
|
+
return qb;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
static with(...rels) { return this.query().with(...rels); }
|
|
149
|
+
static on(connectionOrTrx) {
|
|
150
|
+
if (connectionOrTrx && typeof connectionOrTrx.raw === 'function') {
|
|
151
|
+
// It's a transaction object
|
|
152
|
+
const qb = new QueryBuilder(this.getTableName(), this, null);
|
|
153
|
+
qb.query = connectionOrTrx(this.getTableName());
|
|
154
|
+
qb._transaction = connectionOrTrx;
|
|
155
|
+
return qb;
|
|
156
|
+
}
|
|
157
|
+
// It's a connection name
|
|
158
|
+
return new QueryBuilder(this.getTableName(), this, connectionOrTrx);
|
|
159
|
+
}
|
|
160
|
+
static async all() { return this.query().get(); }
|
|
161
|
+
static async find(id) { return this.query().find(id); }
|
|
162
|
+
static async findBy(column, value) { return this.query().where(column, value).first(); }
|
|
163
|
+
static async first() { return this.query().first(); }
|
|
164
|
+
static async firstOrFail() { return this.query().firstOrFail(); }
|
|
165
|
+
static latest(col) { return this.query().latest(col || 'created_at'); }
|
|
166
|
+
static oldest(col) { return this.query().oldest(col || 'created_at'); }
|
|
167
|
+
|
|
168
|
+
static make(attrs = {}) {
|
|
169
|
+
const inst = new this(attrs);
|
|
170
|
+
inst._initialize();
|
|
171
|
+
if (!this.incrementing && this.keyType === 'string' && !inst.getKey()) {
|
|
172
|
+
inst.setAttribute(this.primaryKey, this.generateUuid());
|
|
173
|
+
}
|
|
174
|
+
return inst;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
static async create(attrs = {}) {
|
|
178
|
+
const inst = this.make(attrs);
|
|
179
|
+
await inst.save();
|
|
180
|
+
return inst;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
static generateUuid() {
|
|
184
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
185
|
+
const r = Math.random() * 16 | 0;
|
|
186
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
static async insert(data) { return this.query().insert(data); }
|
|
191
|
+
static async destroy(ids) { return this.query().whereIn(this.primaryKey, Array.isArray(ids) ? ids : [ids]).delete(); }
|
|
192
|
+
static async firstOrCreate(a, v) { return (await this.query().where(a).first()) || this.create({ ...a, ...v }); }
|
|
193
|
+
static async firstOrNew(a, v) {
|
|
194
|
+
const existing = await this.query().where(a).first();
|
|
195
|
+
if (existing) return existing;
|
|
196
|
+
const inst = new this({ ...a, ...v });
|
|
197
|
+
inst._initialize();
|
|
198
|
+
return inst;
|
|
199
|
+
}
|
|
200
|
+
static async updateOrCreate(a, v) {
|
|
201
|
+
const existing = await this.query().where(a).first();
|
|
202
|
+
if (existing) {
|
|
203
|
+
await existing.update(v);
|
|
204
|
+
return existing;
|
|
205
|
+
}
|
|
206
|
+
return this.create({ ...a, ...v });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// scopes
|
|
210
|
+
static addGlobalScope(n, s) { this.globalScopes.set(n, s); }
|
|
211
|
+
static removeGlobalScope(n) { this.globalScopes.delete(n); }
|
|
212
|
+
static withoutGlobalScope(n) {
|
|
213
|
+
const qb = new QueryBuilder(this.getTableName(), this, this.getConnectionName());
|
|
214
|
+
const scopes = new Map(this.globalScopes);
|
|
215
|
+
scopes.delete(n);
|
|
216
|
+
scopes.forEach(s => s(qb));
|
|
217
|
+
return qb;
|
|
218
|
+
}
|
|
219
|
+
static applyGlobalScopes(qb) { this.globalScopes.forEach(s => s(qb)); }
|
|
220
|
+
|
|
221
|
+
// events
|
|
222
|
+
static _addEventHandler(evt, fn) { this.events[evt] = this.events[evt] || []; this.events[evt].push(fn); }
|
|
223
|
+
static creating(fn) { this._addEventHandler('creating', fn); }
|
|
224
|
+
static created(fn) { this._addEventHandler('created', fn); }
|
|
225
|
+
static updating(fn) { this._addEventHandler('updating', fn); }
|
|
226
|
+
static updated(fn) { this._addEventHandler('updated', fn); }
|
|
227
|
+
static saving(fn) { this._addEventHandler('saving', fn); }
|
|
228
|
+
static saved(fn) { this._addEventHandler('saved', fn); }
|
|
229
|
+
static deleting(fn) { this._addEventHandler('deleting', fn); }
|
|
230
|
+
static deleted(fn) { this._addEventHandler('deleted', fn); }
|
|
231
|
+
static restoring(fn) { this._addEventHandler('restoring', fn); }
|
|
232
|
+
static restored(fn) { this._addEventHandler('restored', fn); }
|
|
233
|
+
|
|
234
|
+
static observe(observer) {
|
|
235
|
+
if (typeof observer === 'function') {
|
|
236
|
+
const instance = new observer();
|
|
237
|
+
const events = ['creating', 'created', 'updating', 'updated', 'saving', 'saved', 'deleting', 'deleted', 'restoring', 'restored'];
|
|
238
|
+
for (const event of events) {
|
|
239
|
+
if (typeof instance[event] === 'function') {
|
|
240
|
+
this._addEventHandler(event, instance[event].bind(instance));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} else if (typeof observer === 'object') {
|
|
244
|
+
for (const [event, handler] of Object.entries(observer)) {
|
|
245
|
+
if (typeof handler === 'function') {
|
|
246
|
+
this._addEventHandler(event, handler);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// static async fireEvent(evt, mdl) { for (const h of this.events[evt] || []) if (await h(mdl) === false) return false; }
|
|
252
|
+
static async fireEvent(evt, mdl) {
|
|
253
|
+
const handlers = this.events[evt] || [];
|
|
254
|
+
for (const handler of handlers) {
|
|
255
|
+
if (await handler(mdl) === false) return false;
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// --- Instance methods ---
|
|
261
|
+
static getTableName() { return this.table || this.name.toLowerCase() + 's'; }
|
|
262
|
+
static getPrimaryKey() { return this.primaryKey; }
|
|
263
|
+
static getKeyType() { return this.keyType; }
|
|
264
|
+
static getIncrementing() { return this.incrementing; }
|
|
265
|
+
static getConnectionName() { return this.connection; }
|
|
266
|
+
|
|
267
|
+
getKey() { return this.attributes[this.constructor.primaryKey]; }
|
|
268
|
+
|
|
269
|
+
fill(attrs) {
|
|
270
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
271
|
+
if (!this.isFillable(k)) continue;
|
|
272
|
+
this.setAttribute(k, v);
|
|
273
|
+
}
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
isFillable(k) {
|
|
278
|
+
if (Array.isArray(this.fillable) && this.fillable.length) return this.fillable.includes(k);
|
|
279
|
+
if (this.guarded.includes('*')) return false;
|
|
280
|
+
return !this.guarded.includes(k);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
getAttribute(k) {
|
|
284
|
+
const val = this.attributes[k];
|
|
285
|
+
const cast = this.casts[k];
|
|
286
|
+
if (cast === 'json' || cast === 'array') {
|
|
287
|
+
try { return JSON.parse(val); } catch { return val; }
|
|
288
|
+
}
|
|
289
|
+
if (cast === 'date' && val != null) {
|
|
290
|
+
const config = this._getConfig();
|
|
291
|
+
const timezone = this.constructor.timezone || config?.timezone || 'UTC';
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
// Try moment-timezone first
|
|
295
|
+
const moment = require('moment-timezone');
|
|
296
|
+
// Parse the stored value as if it's in the configured timezone
|
|
297
|
+
return moment.tz(val, timezone).format('YYYY-MM-DD HH:mm:ss');
|
|
298
|
+
} catch (e) {
|
|
299
|
+
// Fallback: return the stored value as-is since it's already in the correct timezone
|
|
300
|
+
return val;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return val;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
setAttribute(k, v) {
|
|
307
|
+
const cast = this.casts[k];
|
|
308
|
+
let val = v;
|
|
309
|
+
if (cast === 'json' || cast === 'array') {
|
|
310
|
+
val = typeof v === 'string' ? v : JSON.stringify(v);
|
|
311
|
+
}
|
|
312
|
+
if (cast === 'date' && v instanceof Date) {
|
|
313
|
+
// Format date for database storage (YYYY-MM-DD HH:mm:ss)
|
|
314
|
+
const year = v.getFullYear();
|
|
315
|
+
const month = String(v.getMonth() + 1).padStart(2, '0');
|
|
316
|
+
const day = String(v.getDate()).padStart(2, '0');
|
|
317
|
+
const hours = String(v.getHours()).padStart(2, '0');
|
|
318
|
+
const minutes = String(v.getMinutes()).padStart(2, '0');
|
|
319
|
+
const seconds = String(v.getSeconds()).padStart(2, '0');
|
|
320
|
+
val = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
321
|
+
}
|
|
322
|
+
this.attributes[k] = val;
|
|
323
|
+
// Only mark as dirty if not during initialization and model exists
|
|
324
|
+
if (!this._deferred && this.exists) {
|
|
325
|
+
this._dirty.add(k);
|
|
326
|
+
}
|
|
327
|
+
return this;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
syncOriginal() {
|
|
331
|
+
this.original = { ...this.attributes };
|
|
332
|
+
this._dirty.clear();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
_getCurrentTimestamp() {
|
|
336
|
+
const config = this._getConfig();
|
|
337
|
+
const timezone = this.constructor.timezone || config?.timezone || 'UTC';
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
// Try to use moment-timezone if available
|
|
341
|
+
const moment = require('moment-timezone');
|
|
342
|
+
return moment().tz(timezone).toDate();
|
|
343
|
+
} catch (e) {
|
|
344
|
+
try {
|
|
345
|
+
// Try to use date-fns-tz if available
|
|
346
|
+
const { zonedTimeToUtc } = require('date-fns-tz');
|
|
347
|
+
return zonedTimeToUtc(new Date(), timezone);
|
|
348
|
+
} catch (e2) {
|
|
349
|
+
// Fallback to simple but accurate method
|
|
350
|
+
const now = new Date();
|
|
351
|
+
if (timezone === 'UTC') return now;
|
|
352
|
+
|
|
353
|
+
// Use toLocaleString for accurate timezone conversion
|
|
354
|
+
const utcTime = now.getTime() + (now.getTimezoneOffset() * 60000);
|
|
355
|
+
const targetTime = new Date(utcTime + (this._getTimezoneOffset(timezone, now) * 60000));
|
|
356
|
+
return targetTime;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
_getTimezoneOffset(timezone, date) {
|
|
362
|
+
try {
|
|
363
|
+
const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));
|
|
364
|
+
const targetDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }));
|
|
365
|
+
return (targetDate.getTime() - utcDate.getTime()) / 60000;
|
|
366
|
+
} catch (e) {
|
|
367
|
+
return 0; // Default to UTC
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
_getConfig() {
|
|
372
|
+
try {
|
|
373
|
+
const path = require('path');
|
|
374
|
+
const configPath = path.join(process.cwd(), 'ilana.config.js');
|
|
375
|
+
delete require.cache[configPath];
|
|
376
|
+
return require(configPath);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async save() {
|
|
383
|
+
this._initialize();
|
|
384
|
+
|
|
385
|
+
if (!this.exists) {
|
|
386
|
+
// Creating new record
|
|
387
|
+
if (await this.constructor.fireEvent('creating', this) === false) return false;
|
|
388
|
+
if (await this.constructor.fireEvent('saving', this) === false) return false;
|
|
389
|
+
|
|
390
|
+
if (this.constructor.timestamps) {
|
|
391
|
+
const now = this._getCurrentTimestamp();
|
|
392
|
+
this.setAttribute('created_at', now).setAttribute('updated_at', now);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Generate UUID if needed
|
|
396
|
+
if (!this.constructor.incrementing && this.constructor.keyType === 'string' && !this.getKey()) {
|
|
397
|
+
this.setAttribute(this.constructor.primaryKey, this.constructor.generateUuid());
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const qb = this.constructor.query();
|
|
401
|
+
if (this.constructor.incrementing) {
|
|
402
|
+
const id = await qb.insertGetId(this.attributes);
|
|
403
|
+
this.setAttribute(this.constructor.primaryKey, id);
|
|
404
|
+
} else {
|
|
405
|
+
await qb.insert(this.attributes);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.exists = true;
|
|
409
|
+
this.wasRecentlyCreated = true;
|
|
410
|
+
await this.constructor.fireEvent('created', this);
|
|
411
|
+
await this.constructor.fireEvent('saved', this);
|
|
412
|
+
this.syncOriginal();
|
|
413
|
+
} else if (this.isDirty()) {
|
|
414
|
+
// Updating existing record
|
|
415
|
+
if (await this.constructor.fireEvent('updating', this) === false) return false;
|
|
416
|
+
if (await this.constructor.fireEvent('saving', this) === false) return false;
|
|
417
|
+
|
|
418
|
+
if (this.constructor.timestamps) {
|
|
419
|
+
this.setAttribute('updated_at', this._getCurrentTimestamp());
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const updateData = this.getDirty();
|
|
423
|
+
delete updateData.created_at; // Never update created_at
|
|
424
|
+
|
|
425
|
+
if (Object.keys(updateData).length > 0) {
|
|
426
|
+
await this.constructor.query().where(this.constructor.primaryKey, this.getKey()).update(updateData);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await this.constructor.fireEvent('updated', this);
|
|
430
|
+
await this.constructor.fireEvent('saved', this);
|
|
431
|
+
this.syncOriginal();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async update(attributes = {}) {
|
|
438
|
+
this.fill(attributes);
|
|
439
|
+
return await this.save();
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
isDirty(key) {
|
|
443
|
+
return key ? this._dirty.has(key) : this._dirty.size > 0;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
getDirty() {
|
|
447
|
+
const dirty = {};
|
|
448
|
+
for (const key of this._dirty) {
|
|
449
|
+
dirty[key] = this.attributes[key];
|
|
450
|
+
}
|
|
451
|
+
return dirty;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async delete() {
|
|
455
|
+
if (!this.exists) return false;
|
|
456
|
+
|
|
457
|
+
await this.constructor.fireEvent('deleting', this);
|
|
458
|
+
|
|
459
|
+
if (this.constructor.softDeletes) {
|
|
460
|
+
this.setAttribute('deleted_at', new Date());
|
|
461
|
+
await this.save();
|
|
462
|
+
} else {
|
|
463
|
+
await this.constructor.query().where(this.constructor.primaryKey, this.getKey()).delete();
|
|
464
|
+
this.exists = false;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
await this.constructor.fireEvent('deleted', this);
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async restore() {
|
|
472
|
+
if (!this.constructor.softDeletes || !this.getAttribute('deleted_at')) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
await this.constructor.fireEvent('restoring', this);
|
|
477
|
+
|
|
478
|
+
this.setAttribute('deleted_at', null);
|
|
479
|
+
await this.save();
|
|
480
|
+
|
|
481
|
+
await this.constructor.fireEvent('restored', this);
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
trashed() {
|
|
486
|
+
return this.constructor.softDeletes && this.getAttribute('deleted_at') !== null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
only(keys) {
|
|
490
|
+
const result = {};
|
|
491
|
+
for (const key of keys) {
|
|
492
|
+
if (this.attributes.hasOwnProperty(key)) {
|
|
493
|
+
result[key] = this.getAttribute(key);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return result;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
except(keys) {
|
|
500
|
+
const result = {};
|
|
501
|
+
for (const [key, value] of Object.entries(this.attributes)) {
|
|
502
|
+
if (!keys.includes(key)) {
|
|
503
|
+
result[key] = this.getAttribute(key);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return result;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async forceDelete() {
|
|
510
|
+
if (!this.exists) return false;
|
|
511
|
+
|
|
512
|
+
await this.constructor.fireEvent('deleting', this);
|
|
513
|
+
await this.constructor.query().where(this.constructor.primaryKey, this.getKey()).delete();
|
|
514
|
+
this.exists = false;
|
|
515
|
+
await this.constructor.fireEvent('deleted', this);
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// JSON serialization
|
|
520
|
+
toJSON() {
|
|
521
|
+
this._initialize();
|
|
522
|
+
const data = { ...this.attributes };
|
|
523
|
+
|
|
524
|
+
// Apply casting and filter hidden attributes
|
|
525
|
+
const result = {};
|
|
526
|
+
const hidden = this.hidden || [];
|
|
527
|
+
for (const [key, value] of Object.entries(data)) {
|
|
528
|
+
if (!hidden.includes(key)) {
|
|
529
|
+
result[key] = this.getAttribute(key);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Add appends (computed attributes)
|
|
534
|
+
if (this.appends && this.appends.length) {
|
|
535
|
+
for (const appendKey of this.appends) {
|
|
536
|
+
const methodName = 'get' + appendKey.split('_').map(word =>
|
|
537
|
+
word.charAt(0).toUpperCase() + word.slice(1)
|
|
538
|
+
).join('') + 'Attribute';
|
|
539
|
+
|
|
540
|
+
if (typeof this[methodName] === 'function') {
|
|
541
|
+
try {
|
|
542
|
+
result[appendKey] = this[methodName]();
|
|
543
|
+
} catch (e) {
|
|
544
|
+
// Skip if accessor fails
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Add relations
|
|
551
|
+
for (const [key, relation] of Object.entries(this.relations)) {
|
|
552
|
+
if (Array.isArray(relation)) {
|
|
553
|
+
result[key] = relation.map(r => r.toJSON ? r.toJSON() : r);
|
|
554
|
+
} else if (relation && relation.toJSON) {
|
|
555
|
+
result[key] = relation.toJSON();
|
|
556
|
+
} else {
|
|
557
|
+
result[key] = relation;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return result;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// relations - convert classes to strings to avoid circular dependencies
|
|
565
|
+
hasOne(related, fk, lk) {
|
|
566
|
+
const relatedName = this._resolveRelatedName(related);
|
|
567
|
+
return new HasOne(this, relatedName, fk || `${this.constructor.table}_id`, lk || this.constructor.primaryKey);
|
|
568
|
+
}
|
|
569
|
+
hasMany(related, fk, lk) {
|
|
570
|
+
const relatedName = this._resolveRelatedName(related);
|
|
571
|
+
return new HasMany(this, relatedName, fk || `${this.constructor.table}_id`, lk || this.constructor.primaryKey);
|
|
572
|
+
}
|
|
573
|
+
belongsTo(related, fk, ok) {
|
|
574
|
+
const relatedName = this._resolveRelatedName(related);
|
|
575
|
+
return new BelongsTo(this, relatedName, fk, ok || this.constructor.primaryKey);
|
|
576
|
+
}
|
|
577
|
+
belongsToMany(related, pivot, fp, rp, pk, rk) {
|
|
578
|
+
const relatedName = this._resolveRelatedName(related);
|
|
579
|
+
return new BelongsToMany(this, relatedName, pivot, fp, rp, pk, rk);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
_resolveRelatedName(related) {
|
|
583
|
+
if (typeof related === 'string') {
|
|
584
|
+
return related;
|
|
585
|
+
}
|
|
586
|
+
if (typeof related === 'function' && related.name) {
|
|
587
|
+
return related.name;
|
|
588
|
+
}
|
|
589
|
+
// Handle circular dependency - empty object case
|
|
590
|
+
if (typeof related === 'object' && Object.keys(related).length === 0) {
|
|
591
|
+
throw new Error('Circular dependency detected. Use string reference instead:Ex. this.belongsToMany("User", ...)');
|
|
592
|
+
}
|
|
593
|
+
return related;
|
|
594
|
+
}
|
|
595
|
+
hasManyThrough(related, through, fk, sk, lk, slk) {
|
|
596
|
+
const relatedName = typeof related === 'function' && related.name ? related.name : related;
|
|
597
|
+
const throughName = typeof through === 'function' && through.name ? through.name : through;
|
|
598
|
+
return new HasManyThrough(this, relatedName, throughName, fk, sk, lk, slk);
|
|
599
|
+
}
|
|
600
|
+
morphTo(type, id) { return new MorphTo(this, type, id); }
|
|
601
|
+
morphMany(related, type, id) {
|
|
602
|
+
const relatedName = typeof related === 'function' && related.name ? related.name : related;
|
|
603
|
+
return new MorphMany(this, relatedName, type, id, this.constructor.name);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
module.exports = Model;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class ModelRegistry {
|
|
2
|
+
static models = new Map();
|
|
3
|
+
|
|
4
|
+
static register(name, model) {
|
|
5
|
+
if (!name || !model) return;
|
|
6
|
+
this.models.set(name, model);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static get(name) {
|
|
10
|
+
return this.models.get(name);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static has(name) {
|
|
14
|
+
return this.models.has(name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static all() {
|
|
18
|
+
return new Map(this.models);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static clear() {
|
|
22
|
+
this.models.clear();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = ModelRegistry;
|