outlet-orm 5.0.0 → 5.5.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/src/Model.js CHANGED
@@ -1,1118 +1,1141 @@
1
- const QueryBuilder = require('./QueryBuilder');
2
-
3
- /**
4
- * Base Model class inspired by Laravel Eloquent
5
- */
6
- class Model {
7
- static table = '';
8
- static primaryKey = 'id';
9
- static timestamps = true;
10
- static fillable = [];
11
- static hidden = [];
12
- static casts = {};
13
- static connection = null;
14
-
15
- // Soft Deletes
16
- static softDeletes = false;
17
- static DELETED_AT = 'deleted_at';
18
-
19
- // Scopes
20
- static globalScopes = {};
21
-
22
- // Events/Hooks
23
- static eventListeners = {
24
- creating: [],
25
- created: [],
26
- updating: [],
27
- updated: [],
28
- saving: [],
29
- saved: [],
30
- deleting: [],
31
- deleted: [],
32
- restoring: [],
33
- restored: []
34
- };
35
-
36
- // Validation rules
37
- static rules = {};
38
-
39
- /**
40
- * Ensure a default database connection exists.
41
- * If none is set, it will be initialized from environment (.env) lazily.
42
- */
43
- static ensureConnection() {
44
- if (!this.connection) {
45
- // Lazy require to avoid circular dependencies
46
- const DatabaseConnection = require('./DatabaseConnection');
47
- this.connection = new DatabaseConnection();
48
- }
49
- }
50
-
51
- /**
52
- * Get the current database connection
53
- * @returns {DatabaseConnection}
54
- */
55
- static getConnection() {
56
- this.ensureConnection();
57
- return this.connection;
58
- }
59
-
60
- /**
61
- * Set the default database connection for all models
62
- * @param {DatabaseConnection} connection
63
- */
64
- static setConnection(connection) {
65
- this.connection = connection;
66
- }
67
-
68
- /**
69
- * Set the morph map for polymorphic relations
70
- * @param {Object} map
71
- */
72
- static setMorphMap(map) {
73
- this.morphMap = map;
74
- }
75
-
76
- constructor(attributes = {}) {
77
- // Auto-initialize connection on first model instantiation if missing
78
- this.constructor.ensureConnection();
79
- this.attributes = {};
80
- this.original = {};
81
- this.relations = {};
82
- this.touches = [];
83
- this.exists = false;
84
- this._showHidden = false;
85
- this._withTrashed = false;
86
- this._onlyTrashed = false;
87
- this.fill(attributes);
88
- }
89
-
90
- // ==================== Events/Hooks ====================
91
-
92
- /**
93
- * Register an event listener
94
- * @param {string} event - Event name (creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored)
95
- * @param {Function} callback - Callback function
96
- */
97
- static on(event, callback) {
98
- if (!this.eventListeners[event]) {
99
- this.eventListeners[event] = [];
100
- }
101
- this.eventListeners[event].push(callback);
102
- }
103
-
104
- /**
105
- * Fire an event
106
- * @param {string} event
107
- * @param {Model} model
108
- * @returns {Promise<boolean>} - Returns false if event should be cancelled
109
- */
110
- static async fireEvent(event, model) {
111
- const listeners = this.eventListeners[event] || [];
112
- for (const listener of listeners) {
113
- const result = await listener(model);
114
- if (result === false) {
115
- return false; // Cancel the operation
116
- }
117
- }
118
- return true;
119
- }
120
-
121
- /**
122
- * Register a 'creating' event listener
123
- * @param {Function} callback
124
- */
125
- static creating(callback) {
126
- this.on('creating', callback);
127
- }
128
-
129
- /**
130
- * Register a 'created' event listener
131
- * @param {Function} callback
132
- */
133
- static created(callback) {
134
- this.on('created', callback);
135
- }
136
-
137
- /**
138
- * Register an 'updating' event listener
139
- * @param {Function} callback
140
- */
141
- static updating(callback) {
142
- this.on('updating', callback);
143
- }
144
-
145
- /**
146
- * Register an 'updated' event listener
147
- * @param {Function} callback
148
- */
149
- static updated(callback) {
150
- this.on('updated', callback);
151
- }
152
-
153
- /**
154
- * Register a 'saving' event listener (fires on both create and update)
155
- * @param {Function} callback
156
- */
157
- static saving(callback) {
158
- this.on('saving', callback);
159
- }
160
-
161
- /**
162
- * Register a 'saved' event listener (fires after both create and update)
163
- * @param {Function} callback
164
- */
165
- static saved(callback) {
166
- this.on('saved', callback);
167
- }
168
-
169
- /**
170
- * Register a 'deleting' event listener
171
- * @param {Function} callback
172
- */
173
- static deleting(callback) {
174
- this.on('deleting', callback);
175
- }
176
-
177
- /**
178
- * Register a 'deleted' event listener
179
- * @param {Function} callback
180
- */
181
- static deleted(callback) {
182
- this.on('deleted', callback);
183
- }
184
-
185
- /**
186
- * Register a 'restoring' event listener
187
- * @param {Function} callback
188
- */
189
- static restoring(callback) {
190
- this.on('restoring', callback);
191
- }
192
-
193
- /**
194
- * Register a 'restored' event listener
195
- * @param {Function} callback
196
- */
197
- static restored(callback) {
198
- this.on('restored', callback);
199
- }
200
-
201
- // ==================== Scopes ====================
202
-
203
- /**
204
- * Add a global scope
205
- * @param {string} name - Scope name
206
- * @param {Function} callback - Function that modifies the query builder
207
- */
208
- static addGlobalScope(name, callback) {
209
- if (!this.globalScopes) {
210
- this.globalScopes = {};
211
- }
212
- this.globalScopes[name] = callback;
213
- }
214
-
215
- /**
216
- * Remove a global scope
217
- * @param {string} name - Scope name
218
- */
219
- static removeGlobalScope(name) {
220
- if (this.globalScopes && this.globalScopes[name]) {
221
- delete this.globalScopes[name];
222
- }
223
- }
224
-
225
- /**
226
- * Query without a specific global scope
227
- * @param {string} name - Scope name to exclude
228
- * @returns {QueryBuilder}
229
- */
230
- static withoutGlobalScope(name) {
231
- const query = this.query();
232
- query._excludedScopes = query._excludedScopes || [];
233
- query._excludedScopes.push(name);
234
- return query;
235
- }
236
-
237
- /**
238
- * Query without all global scopes
239
- * @returns {QueryBuilder}
240
- */
241
- static withoutGlobalScopes() {
242
- const query = this.query();
243
- query._excludeAllScopes = true;
244
- return query;
245
- }
246
-
247
- // ==================== Soft Deletes ====================
248
-
249
- /**
250
- * Query including soft deleted models
251
- * @returns {QueryBuilder}
252
- */
253
- static withTrashed() {
254
- const query = this.query();
255
- query._withTrashed = true;
256
- return query;
257
- }
258
-
259
- /**
260
- * Query only soft deleted models
261
- * @returns {QueryBuilder}
262
- */
263
- static onlyTrashed() {
264
- const query = this.query();
265
- query._onlyTrashed = true;
266
- return query;
267
- }
268
-
269
- /**
270
- * Check if model is soft deleted
271
- * @returns {boolean}
272
- */
273
- trashed() {
274
- return this.constructor.softDeletes && this.attributes[this.constructor.DELETED_AT] !== null;
275
- }
276
-
277
- /**
278
- * Restore a soft deleted model
279
- * @returns {Promise<this>}
280
- */
281
- async restore() {
282
- if (!this.constructor.softDeletes) {
283
- throw new Error('This model does not use soft deletes');
284
- }
285
-
286
- // Fire restoring event
287
- const shouldContinue = await this.constructor.fireEvent('restoring', this);
288
- if (!shouldContinue) return this;
289
-
290
- this.setAttribute(this.constructor.DELETED_AT, null);
291
-
292
- await this.constructor.connection.update(
293
- this.constructor.table,
294
- { [this.constructor.DELETED_AT]: null },
295
- { wheres: [{ type: 'basic', column: this.constructor.primaryKey, operator: '=', value: this.getAttribute(this.constructor.primaryKey) }] }
296
- );
297
-
298
- // Fire restored event
299
- await this.constructor.fireEvent('restored', this);
300
-
301
- return this;
302
- }
303
-
304
- /**
305
- * Force delete a soft deleted model (permanent delete)
306
- * @returns {Promise<boolean>}
307
- */
308
- async forceDelete() {
309
- // Fire deleting event
310
- const shouldContinue = await this.constructor.fireEvent('deleting', this);
311
- if (!shouldContinue) return false;
312
-
313
- await this.constructor.connection.delete(
314
- this.constructor.table,
315
- { wheres: [{ type: 'basic', column: this.constructor.primaryKey, operator: '=', value: this.getAttribute(this.constructor.primaryKey) }] }
316
- );
317
-
318
- this.exists = false;
319
-
320
- // Fire deleted event
321
- await this.constructor.fireEvent('deleted', this);
322
-
323
- return true;
324
- }
325
-
326
- // ==================== Validation ====================
327
-
328
- /**
329
- * Validate the model attributes
330
- * @returns {Object} - { valid: boolean, errors: Object }
331
- */
332
- validate() {
333
- const rules = this.constructor.rules;
334
- const errors = {};
335
- let valid = true;
336
-
337
- for (const [field, ruleString] of Object.entries(rules)) {
338
- const fieldRules = typeof ruleString === 'string' ? ruleString.split('|') : ruleString;
339
- const value = this.attributes[field];
340
-
341
- for (const rule of fieldRules) {
342
- const [ruleName, ruleParam] = rule.split(':');
343
- const error = this._validateRule(field, value, ruleName, ruleParam);
344
- if (error) {
345
- if (!errors[field]) errors[field] = [];
346
- errors[field].push(error);
347
- valid = false;
348
- }
349
- }
350
- }
351
-
352
- return { valid, errors };
353
- }
354
-
355
- /**
356
- * Validate a single rule
357
- * @private
358
- */
359
- _validateRule(field, value, ruleName, ruleParam) {
360
- switch (ruleName) {
361
- case 'required':
362
- if (value === undefined || value === null || value === '') {
363
- return `${field} is required`;
364
- }
365
- break;
366
-
367
- case 'string':
368
- if (value !== undefined && value !== null && typeof value !== 'string') {
369
- return `${field} must be a string`;
370
- }
371
- break;
372
-
373
- case 'number':
374
- case 'numeric':
375
- if (value !== undefined && value !== null && typeof value !== 'number' && isNaN(Number(value))) {
376
- return `${field} must be a number`;
377
- }
378
- break;
379
-
380
- case 'email':
381
- if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
382
- return `${field} must be a valid email`;
383
- }
384
- break;
385
-
386
- case 'min':
387
- if (typeof value === 'string' && value.length < parseInt(ruleParam, 10)) {
388
- return `${field} must be at least ${ruleParam} characters`;
389
- }
390
- if (typeof value === 'number' && value < parseInt(ruleParam, 10)) {
391
- return `${field} must be at least ${ruleParam}`;
392
- }
393
- break;
394
-
395
- case 'max':
396
- if (typeof value === 'string' && value.length > parseInt(ruleParam, 10)) {
397
- return `${field} must not exceed ${ruleParam} characters`;
398
- }
399
- if (typeof value === 'number' && value > parseInt(ruleParam, 10)) {
400
- return `${field} must not exceed ${ruleParam}`;
401
- }
402
- break;
403
-
404
- case 'in':
405
- if (value !== undefined && value !== null) {
406
- const allowed = ruleParam.split(',');
407
- if (!allowed.includes(String(value))) {
408
- return `${field} must be one of: ${ruleParam}`;
409
- }
410
- }
411
- break;
412
-
413
- case 'boolean':
414
- if (value !== undefined && value !== null && typeof value !== 'boolean') {
415
- return `${field} must be a boolean`;
416
- }
417
- break;
418
-
419
- case 'date':
420
- if (value !== undefined && value !== null && isNaN(Date.parse(value))) {
421
- return `${field} must be a valid date`;
422
- }
423
- break;
424
-
425
- case 'regex':
426
- if (value && !new RegExp(ruleParam).test(value)) {
427
- return `${field} format is invalid`;
428
- }
429
- break;
430
- }
431
-
432
- return null;
433
- }
434
-
435
- /**
436
- * Validate and throw if invalid
437
- * @throws {Error}
438
- */
439
- validateOrFail() {
440
- const { valid, errors } = this.validate();
441
- if (!valid) {
442
- const error = new Error('Validation failed');
443
- error.errors = errors;
444
- throw error;
445
- }
446
- }
447
-
448
- // ==================== Query Builder ====================
449
-
450
- /**
451
- * Begin querying the model
452
- * @returns {QueryBuilder}
453
- */
454
- static query() {
455
- // Ensure a connection exists even when using static APIs without instantiation
456
- this.ensureConnection();
457
- return new QueryBuilder(this);
458
- }
459
-
460
- /**
461
- * Get all records
462
- * @returns {Promise<Array<Model>>}
463
- */
464
- static all() {
465
- return this.query().get();
466
- }
467
-
468
- /**
469
- * Find a model by its primary key
470
- * @param {any} id
471
- * @returns {Promise<Model|null>}
472
- */
473
- static find(id) {
474
- return this.query().where(this.primaryKey, id).first();
475
- }
476
-
477
- /**
478
- * Find a model by its primary key or throw an error
479
- * @param {any} id
480
- * @returns {Promise<Model>}
481
- */
482
- static findOrFail(id) {
483
- return this.query().where(this.primaryKey, id).firstOrFail();
484
- }
485
-
486
- /**
487
- * Add a where clause
488
- * @param {string} column
489
- * @param {string|any} operator
490
- * @param {any} value
491
- * @returns {QueryBuilder}
492
- */
493
- static where(column, operator, value) {
494
- if (arguments.length === 2) {
495
- value = operator;
496
- operator = '=';
497
- }
498
- return this.query().where(column, operator, value);
499
- }
500
-
501
- /**
502
- * Create a new model and save it
503
- * @param {Object} attributes
504
- * @returns {Promise<Model>}
505
- */
506
- static create(attributes) {
507
- const instance = new this(attributes);
508
- return instance.save();
509
- }
510
-
511
- /**
512
- * Insert data without creating model instances
513
- * @param {Object|Array<Object>} data
514
- * @returns {Promise<any>}
515
- */
516
- static async insert(data) {
517
- const query = this.query();
518
- return query.insert(data);
519
- }
520
-
521
- /**
522
- * Update records
523
- * @param {Object} attributes
524
- * @returns {Promise<any>}
525
- */
526
- static async update(attributes) {
527
- return this.query().update(attributes);
528
- }
529
-
530
- /**
531
- * Update by primary key and fetch the updated model (optionally with relations)
532
- * @param {any} id
533
- * @param {Object} attributes
534
- * @param {string[]} [relations]
535
- * @returns {Promise<Model|null>}
536
- */
537
- static async updateAndFetchById(id, attributes, relations = []) {
538
- await this.query().where(this.primaryKey, id).update(attributes);
539
- const qb = this.query().where(this.primaryKey, id);
540
- if (relations && relations.length) qb.with(...relations);
541
- return qb.first();
542
- }
543
-
544
- /**
545
- * Update by primary key only (convenience)
546
- * @param {any} id
547
- * @param {Object} attributes
548
- * @returns {Promise<any>}
549
- */
550
- static async updateById(id, attributes) {
551
- return this.query().where(this.primaryKey, id).update(attributes);
552
- }
553
-
554
- /**
555
- * Delete records
556
- * @returns {Promise<any>}
557
- */
558
- static async delete() {
559
- return this.query().delete();
560
- }
561
-
562
- /**
563
- * Get the first record
564
- * @returns {Promise<Model|null>}
565
- */
566
- static first() {
567
- return this.query().first();
568
- }
569
-
570
- /**
571
- * Add an order by clause
572
- * @param {string} column
573
- * @param {string} direction
574
- * @returns {QueryBuilder}
575
- */
576
- static orderBy(column, direction = 'asc') {
577
- return this.query().orderBy(column, direction);
578
- }
579
-
580
- /**
581
- * Limit the number of results
582
- * @param {number} value
583
- * @returns {QueryBuilder}
584
- */
585
- static limit(value) {
586
- return this.query().limit(value);
587
- }
588
-
589
- /**
590
- * Offset the results
591
- * @param {number} value
592
- * @returns {QueryBuilder}
593
- */
594
- static offset(value) {
595
- return this.query().offset(value);
596
- }
597
-
598
- /**
599
- * Paginate the results
600
- * @param {number} page
601
- * @param {number} perPage
602
- * @returns {Promise<Object>}
603
- */
604
- static paginate(page = 1, perPage = 15) {
605
- return this.query().paginate(page, perPage);
606
- }
607
-
608
- /**
609
- * Add a where in clause
610
- * @param {string} column
611
- * @param {Array} values
612
- * @returns {QueryBuilder}
613
- */
614
- static whereIn(column, values) {
615
- return this.query().whereIn(column, values);
616
- }
617
-
618
- /**
619
- * Add a where null clause
620
- * @param {string} column
621
- * @returns {QueryBuilder}
622
- */
623
- static whereNull(column) {
624
- return this.query().whereNull(column);
625
- }
626
-
627
- /**
628
- * Add a where not null clause
629
- * @param {string} column
630
- * @returns {QueryBuilder}
631
- */
632
- static whereNotNull(column) {
633
- return this.query().whereNotNull(column);
634
- }
635
-
636
- /**
637
- * Count records
638
- * @returns {Promise<number>}
639
- */
640
- static count() {
641
- return this.query().count();
642
- }
643
-
644
- /**
645
- * Eager load relations on the query
646
- * @param {...string} relations
647
- * @returns {QueryBuilder}
648
- */
649
- static with(...relations) {
650
- return this.query().with(...relations);
651
- }
652
-
653
- /**
654
- * Include hidden attributes in query results
655
- * @returns {QueryBuilder}
656
- */
657
- static withHidden() {
658
- const query = this.query();
659
- query._showHidden = true;
660
- return query;
661
- }
662
-
663
- /**
664
- * Control visibility of hidden attributes in query results
665
- * @param {boolean} show - If false (default), hidden attributes will be hidden. If true, they will be shown.
666
- * @returns {QueryBuilder}
667
- */
668
- static withoutHidden(show = false) {
669
- const query = this.query();
670
- query._showHidden = show;
671
- return query;
672
- }
673
-
674
- // ==================== Instance Methods ====================
675
-
676
- /**
677
- * Fill the model with attributes
678
- * @param {Object} attributes
679
- * @returns {this}
680
- */
681
- fill(attributes) {
682
- for (const [key, value] of Object.entries(attributes)) {
683
- if (this.constructor.fillable.length === 0 || this.constructor.fillable.includes(key)) {
684
- this.setAttribute(key, value);
685
- }
686
- }
687
- return this;
688
- }
689
-
690
- /**
691
- * Set an attribute
692
- * @param {string} key
693
- * @param {any} value
694
- * @returns {this}
695
- */
696
- setAttribute(key, value) {
697
- this.attributes[key] = this.castAttribute(key, value);
698
- return this;
699
- }
700
-
701
- /**
702
- * Get an attribute
703
- * @param {string} key
704
- * @returns {any}
705
- */
706
- getAttribute(key) {
707
- if (this.relations[key]) {
708
- return this.relations[key];
709
- }
710
- return this.castAttribute(key, this.attributes[key]);
711
- }
712
-
713
- /**
714
- * Cast an attribute to the proper type
715
- * @param {string} key
716
- * @param {any} value
717
- * @returns {any}
718
- */
719
- castAttribute(key, value) {
720
- const cast = this.constructor.casts[key];
721
- if (!cast || value === null || value === undefined) return value;
722
-
723
- switch (cast) {
724
- case 'int':
725
- case 'integer':
726
- return parseInt(value, 10);
727
- case 'float':
728
- case 'double':
729
- return parseFloat(value);
730
- case 'string':
731
- return String(value);
732
- case 'bool':
733
- case 'boolean':
734
- return Boolean(value);
735
- case 'array':
736
- case 'json':
737
- return typeof value === 'string' ? JSON.parse(value) : value;
738
- case 'date':
739
- return value instanceof Date ? value : new Date(value);
740
- default:
741
- return value;
742
- }
743
- }
744
-
745
- /**
746
- * Save the model
747
- * @returns {Promise<this>}
748
- */
749
- async save() {
750
- // Fire saving event
751
- const shouldContinue = await this.constructor.fireEvent('saving', this);
752
- if (!shouldContinue) return this;
753
-
754
- let result;
755
- if (this.exists) {
756
- result = await this.performUpdate();
757
- } else {
758
- result = await this.performInsert();
759
- }
760
-
761
- // Fire saved event
762
- await this.constructor.fireEvent('saved', this);
763
-
764
- return result;
765
- }
766
-
767
- /**
768
- * Perform an insert operation
769
- * @returns {Promise<this>}
770
- */
771
- async performInsert() {
772
- // Fire creating event
773
- const shouldContinue = await this.constructor.fireEvent('creating', this);
774
- if (!shouldContinue) return this;
775
-
776
- if (this.constructor.timestamps) {
777
- const now = new Date();
778
- this.setAttribute('created_at', now);
779
- this.setAttribute('updated_at', now);
780
- }
781
-
782
- const data = this.attributes;
783
- const result = await this.constructor.connection.insert(this.constructor.table, data);
784
-
785
- this.setAttribute(this.constructor.primaryKey, result.insertId);
786
- this.exists = true;
787
- this.original = { ...this.attributes };
788
-
789
- await this.touchParents();
790
-
791
- // Fire created event
792
- await this.constructor.fireEvent('created', this);
793
-
794
- return this;
795
- }
796
-
797
- /**
798
- * Perform an update operation
799
- * @returns {Promise<this>}
800
- */
801
- async performUpdate() {
802
- // Fire updating event
803
- const shouldContinue = await this.constructor.fireEvent('updating', this);
804
- if (!shouldContinue) return this;
805
-
806
- if (this.constructor.timestamps) {
807
- this.setAttribute('updated_at', new Date());
808
- }
809
-
810
- const dirty = this.getDirty();
811
- if (Object.keys(dirty).length === 0) {
812
- return this;
813
- }
814
-
815
- await this.constructor.connection.update(
816
- this.constructor.table,
817
- dirty,
818
- { wheres: [{ type: 'basic', column: this.constructor.primaryKey, operator: '=', value: this.getAttribute(this.constructor.primaryKey) }] }
819
- );
820
-
821
- this.original = { ...this.attributes };
822
-
823
- await this.touchParents();
824
-
825
- // Fire updated event
826
- await this.constructor.fireEvent('updated', this);
827
-
828
- return this;
829
- }
830
-
831
- /**
832
- * Touch parent models for belongsTo relations with touches enabled
833
- * @returns {Promise<void>}
834
- */
835
- async touchParents() {
836
- for (const relation of this.touches) {
837
- if (relation.touchesParent) {
838
- const foreignKeyValue = this.getAttribute(relation.foreignKey);
839
- if (foreignKeyValue) {
840
- await this.constructor.connection.update(
841
- relation.related.table,
842
- { updated_at: new Date() },
843
- { wheres: [{ type: 'basic', column: relation.ownerKey, operator: '=', value: foreignKeyValue }] }
844
- );
845
- }
846
- }
847
- }
848
- }
849
-
850
- /**
851
- * Delete the model (soft delete if enabled)
852
- * @returns {Promise<boolean>}
853
- */
854
- async destroy() {
855
- if (!this.exists) {
856
- return false;
857
- }
858
-
859
- // Fire deleting event
860
- const shouldContinue = await this.constructor.fireEvent('deleting', this);
861
- if (!shouldContinue) return false;
862
-
863
- // Soft delete if enabled
864
- if (this.constructor.softDeletes) {
865
- this.setAttribute(this.constructor.DELETED_AT, new Date());
866
- await this.constructor.connection.update(
867
- this.constructor.table,
868
- { [this.constructor.DELETED_AT]: new Date() },
869
- { wheres: [{ type: 'basic', column: this.constructor.primaryKey, operator: '=', value: this.getAttribute(this.constructor.primaryKey) }] }
870
- );
871
- } else {
872
- await this.constructor.connection.delete(
873
- this.constructor.table,
874
- { wheres: [{ type: 'basic', column: this.constructor.primaryKey, operator: '=', value: this.getAttribute(this.constructor.primaryKey) }] }
875
- );
876
- this.exists = false;
877
- }
878
-
879
- // Fire deleted event
880
- await this.constructor.fireEvent('deleted', this);
881
-
882
- return true;
883
- }
884
-
885
- /**
886
- * Get the attributes that have been changed
887
- * @returns {Object}
888
- */
889
- getDirty() {
890
- const dirty = {};
891
- for (const [key, value] of Object.entries(this.attributes)) {
892
- if (JSON.stringify(value) !== JSON.stringify(this.original[key])) {
893
- dirty[key] = value;
894
- }
895
- }
896
- return dirty;
897
- }
898
-
899
- /**
900
- * Check if the model has been modified
901
- * @returns {boolean}
902
- */
903
- isDirty() {
904
- return Object.keys(this.getDirty()).length > 0;
905
- }
906
-
907
- /**
908
- * Convert the model to JSON
909
- * @returns {Object}
910
- */
911
- toJSON() {
912
- const json = { ...this.attributes };
913
-
914
- // Hide specified attributes unless _showHidden is true
915
- if (!this._showHidden) {
916
- this.constructor.hidden.forEach(key => {
917
- delete json[key];
918
- });
919
- }
920
-
921
- // Add relations
922
- Object.assign(json, this.relations);
923
-
924
- return json;
925
- }
926
-
927
- /**
928
- * Load one or multiple relations on this model instance.
929
- * Supports dot-notation for nested relations (e.g., 'posts.comments').
930
- * @param {...string|Array<string>} relations
931
- * @returns {Promise<this>}
932
- */
933
- async load(...relations) {
934
- const list = relations.length === 1 && Array.isArray(relations[0])
935
- ? relations[0]
936
- : relations;
937
-
938
- for (const rel of list) {
939
- if (typeof rel !== 'string' || !rel) continue;
940
- await this._loadRelationPath(rel);
941
- }
942
- return this;
943
- }
944
-
945
- /**
946
- * Internal: load a relation path with optional nesting (a.b.c)
947
- * @param {string} path
948
- * @private
949
- */
950
- async _loadRelationPath(path) {
951
- const segments = path.split('.');
952
- const head = segments[0];
953
- const tail = segments.slice(1).join('.');
954
-
955
- const relationFn = this[head];
956
- if (typeof relationFn !== 'function') return;
957
-
958
- const relation = relationFn.call(this);
959
- if (!relation || typeof relation.get !== 'function') return;
960
-
961
- const value = await relation.get();
962
- this.relations[head] = value;
963
-
964
- if (tail) {
965
- if (Array.isArray(value)) {
966
- await Promise.all(
967
- value.map(v => (v && typeof v.load === 'function') ? v.load(tail) : null)
968
- );
969
- } else if (value && typeof value.load === 'function') {
970
- await value.load(tail);
971
- }
972
- }
973
- }
974
-
975
- // ==================== Relationships ====================
976
-
977
- /**
978
- * Define a one-to-one relationship
979
- * @param {typeof Model} related
980
- * @param {string} foreignKey
981
- * @param {string} localKey
982
- * @returns {HasOneRelation}
983
- */
984
- hasOne(related, foreignKey, localKey) {
985
- const HasOneRelation = require('./Relations/HasOneRelation');
986
- localKey = localKey || this.constructor.primaryKey;
987
- foreignKey = foreignKey || `${this.constructor.table.slice(0, -1)}_id`;
988
-
989
- return new HasOneRelation(this, related, foreignKey, localKey);
990
- }
991
-
992
- /**
993
- * Define a one-to-many relationship
994
- * @param {typeof Model} related
995
- * @param {string} foreignKey
996
- * @param {string} localKey
997
- * @returns {HasManyRelation}
998
- */
999
- hasMany(related, foreignKey, localKey) {
1000
- const HasManyRelation = require('./Relations/HasManyRelation');
1001
- localKey = localKey || this.constructor.primaryKey;
1002
- foreignKey = foreignKey || `${this.constructor.table.slice(0, -1)}_id`;
1003
-
1004
- return new HasManyRelation(this, related, foreignKey, localKey);
1005
- }
1006
-
1007
- /**
1008
- * Define an inverse one-to-one or many relationship
1009
- * @param {typeof Model} related
1010
- * @param {string} foreignKey
1011
- * @param {string} ownerKey
1012
- * @returns {BelongsToRelation}
1013
- */
1014
- belongsTo(related, foreignKey, ownerKey) {
1015
- const BelongsToRelation = require('./Relations/BelongsToRelation');
1016
- ownerKey = ownerKey || related.primaryKey;
1017
- foreignKey = foreignKey || `${related.table.slice(0, -1)}_id`;
1018
-
1019
- return new BelongsToRelation(this, related, foreignKey, ownerKey);
1020
- }
1021
-
1022
- /**
1023
- * Define a many-to-many relationship
1024
- * @param {typeof Model} related
1025
- * @param {string} pivot
1026
- * @param {string} foreignPivotKey
1027
- * @param {string} relatedPivotKey
1028
- * @param {string} parentKey
1029
- * @param {string} relatedKey
1030
- * @returns {BelongsToManyRelation}
1031
- */
1032
- belongsToMany(related, pivot, foreignPivotKey, relatedPivotKey, parentKey, relatedKey) {
1033
- const BelongsToManyRelation = require('./Relations/BelongsToManyRelation');
1034
- return new BelongsToManyRelation(
1035
- this, related, pivot, foreignPivotKey, relatedPivotKey, parentKey, relatedKey
1036
- );
1037
- }
1038
-
1039
- /**
1040
- * Define a has-many-through relationship
1041
- * @param {typeof Model} relatedFinal
1042
- * @param {typeof Model} through
1043
- * @param {string} [foreignKeyOnThrough]
1044
- * @param {string} [throughKeyOnFinal]
1045
- * @param {string} [localKey]
1046
- * @param {string} [throughLocalKey]
1047
- * @returns {HasManyThroughRelation}
1048
- */
1049
- hasManyThrough(relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey) {
1050
- const HasManyThroughRelation = require('./Relations/HasManyThroughRelation');
1051
- return new HasManyThroughRelation(
1052
- this, relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey
1053
- );
1054
- }
1055
-
1056
- /**
1057
- * Define a has-one-through relationship
1058
- * @param {typeof Model} relatedFinal
1059
- * @param {typeof Model} through
1060
- * @param {string} [foreignKeyOnThrough]
1061
- * @param {string} [throughKeyOnFinal]
1062
- * @param {string} [localKey]
1063
- * @param {string} [throughLocalKey]
1064
- * @returns {HasOneThroughRelation}
1065
- */
1066
- hasOneThrough(relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey) {
1067
- const HasOneThroughRelation = require('./Relations/HasOneThroughRelation');
1068
- return new HasOneThroughRelation(
1069
- this, relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey
1070
- );
1071
- }
1072
-
1073
- /**
1074
- * Define a polymorphic inverse relationship
1075
- * @param {string} name
1076
- * @param {string} [typeColumn]
1077
- * @param {string} [idColumn]
1078
- * @returns {MorphToRelation}
1079
- */
1080
- morphTo(name, typeColumn, idColumn) {
1081
- const MorphToRelation = require('./Relations/MorphToRelation');
1082
- return new MorphToRelation(this, name, typeColumn, idColumn);
1083
- }
1084
-
1085
- /**
1086
- * Define a polymorphic one-to-one relationship
1087
- * @param {typeof Model} related
1088
- * @param {string} morphType
1089
- * @param {string} [foreignKey]
1090
- * @param {string} [localKey]
1091
- * @returns {MorphOneRelation}
1092
- */
1093
- morphOne(related, morphType, foreignKey, localKey) {
1094
- const MorphOneRelation = require('./Relations/MorphOneRelation');
1095
- localKey = localKey || this.constructor.primaryKey;
1096
- foreignKey = foreignKey || `${morphType}_id`;
1097
-
1098
- return new MorphOneRelation(this, related, morphType, foreignKey, localKey);
1099
- }
1100
-
1101
- /**
1102
- * Define a polymorphic one-to-many relationship
1103
- * @param {typeof Model} related
1104
- * @param {string} morphType
1105
- * @param {string} [foreignKey]
1106
- * @param {string} [localKey]
1107
- * @returns {MorphManyRelation}
1108
- */
1109
- morphMany(related, morphType, foreignKey, localKey) {
1110
- const MorphManyRelation = require('./Relations/MorphManyRelation');
1111
- localKey = localKey || this.constructor.primaryKey;
1112
- foreignKey = foreignKey || `${morphType}_id`;
1113
-
1114
- return new MorphManyRelation(this, related, morphType, foreignKey, localKey);
1115
- }
1116
- }
1117
-
1118
- module.exports = Model;
1
+ const QueryBuilder = require('./QueryBuilder');
2
+
3
+ /**
4
+ * Base Model class inspired by Laravel Eloquent
5
+ */
6
+ class Model {
7
+ static table = '';
8
+ static primaryKey = 'id';
9
+ static timestamps = true;
10
+ static fillable = [];
11
+ static hidden = [];
12
+ static casts = {};
13
+ static connection = null;
14
+
15
+ // Soft Deletes
16
+ static softDeletes = false;
17
+ static DELETED_AT = 'deleted_at';
18
+
19
+ // Scopes
20
+ static globalScopes = {};
21
+
22
+ // Events/Hooks
23
+ static eventListeners = {
24
+ creating: [],
25
+ created: [],
26
+ updating: [],
27
+ updated: [],
28
+ saving: [],
29
+ saved: [],
30
+ deleting: [],
31
+ deleted: [],
32
+ restoring: [],
33
+ restored: []
34
+ };
35
+
36
+ // Validation rules
37
+ static rules = {};
38
+
39
+ /**
40
+ * Ensure a default database connection exists.
41
+ * If none is set, it will be initialized from environment (.env) lazily.
42
+ */
43
+ static ensureConnection() {
44
+ if (!this.connection) {
45
+ // Lazy require to avoid circular dependencies
46
+ const DatabaseConnection = require('./DatabaseConnection');
47
+ this.connection = new DatabaseConnection();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Get the current database connection
53
+ * @returns {DatabaseConnection}
54
+ */
55
+ static getConnection() {
56
+ this.ensureConnection();
57
+ return this.connection;
58
+ }
59
+
60
+ /**
61
+ * Set the default database connection for all models
62
+ * @param {DatabaseConnection} connection
63
+ */
64
+ static setConnection(connection) {
65
+ this.connection = connection;
66
+ }
67
+
68
+ /**
69
+ * Set the morph map for polymorphic relations
70
+ * @param {Object} map
71
+ */
72
+ static setMorphMap(map) {
73
+ this.morphMap = map;
74
+ }
75
+
76
+ constructor(attributes = {}) {
77
+ // Auto-initialize connection on first model instantiation if missing
78
+ this.constructor.ensureConnection();
79
+ this.attributes = {};
80
+ this.original = {};
81
+ this.relations = {};
82
+ this.touches = [];
83
+ this.exists = false;
84
+ this._showHidden = false;
85
+ this._withTrashed = false;
86
+ this._onlyTrashed = false;
87
+ this.fill(attributes);
88
+ }
89
+
90
+ // ==================== Events/Hooks ====================
91
+
92
+ /**
93
+ * Register an event listener
94
+ * @param {string} event - Event name (creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored)
95
+ * @param {Function} callback - Callback function
96
+ */
97
+ static on(event, callback) {
98
+ if (!Object.prototype.hasOwnProperty.call(this, 'eventListeners')) {
99
+ this.eventListeners = {
100
+ creating: [], created: [], updating: [], updated: [],
101
+ saving: [], saved: [], deleting: [], deleted: [],
102
+ restoring: [], restored: []
103
+ };
104
+ }
105
+ if (!this.eventListeners[event]) {
106
+ this.eventListeners[event] = [];
107
+ }
108
+ this.eventListeners[event].push(callback);
109
+ }
110
+
111
+ /**
112
+ * Fire an event
113
+ * @param {string} event
114
+ * @param {Model} model
115
+ * @returns {Promise<boolean>} - Returns false if event should be cancelled
116
+ */
117
+ static async fireEvent(event, model) {
118
+ if (!Object.prototype.hasOwnProperty.call(this, 'eventListeners')) {
119
+ return true;
120
+ }
121
+ const listeners = this.eventListeners[event] || [];
122
+ for (const listener of listeners) {
123
+ const result = await listener(model);
124
+ if (result === false) {
125
+ return false; // Cancel the operation
126
+ }
127
+ }
128
+ return true;
129
+ }
130
+
131
+ /**
132
+ * Register a 'creating' event listener
133
+ * @param {Function} callback
134
+ */
135
+ static creating(callback) {
136
+ this.on('creating', callback);
137
+ }
138
+
139
+ /**
140
+ * Register a 'created' event listener
141
+ * @param {Function} callback
142
+ */
143
+ static created(callback) {
144
+ this.on('created', callback);
145
+ }
146
+
147
+ /**
148
+ * Register an 'updating' event listener
149
+ * @param {Function} callback
150
+ */
151
+ static updating(callback) {
152
+ this.on('updating', callback);
153
+ }
154
+
155
+ /**
156
+ * Register an 'updated' event listener
157
+ * @param {Function} callback
158
+ */
159
+ static updated(callback) {
160
+ this.on('updated', callback);
161
+ }
162
+
163
+ /**
164
+ * Register a 'saving' event listener (fires on both create and update)
165
+ * @param {Function} callback
166
+ */
167
+ static saving(callback) {
168
+ this.on('saving', callback);
169
+ }
170
+
171
+ /**
172
+ * Register a 'saved' event listener (fires after both create and update)
173
+ * @param {Function} callback
174
+ */
175
+ static saved(callback) {
176
+ this.on('saved', callback);
177
+ }
178
+
179
+ /**
180
+ * Register a 'deleting' event listener
181
+ * @param {Function} callback
182
+ */
183
+ static deleting(callback) {
184
+ this.on('deleting', callback);
185
+ }
186
+
187
+ /**
188
+ * Register a 'deleted' event listener
189
+ * @param {Function} callback
190
+ */
191
+ static deleted(callback) {
192
+ this.on('deleted', callback);
193
+ }
194
+
195
+ /**
196
+ * Register a 'restoring' event listener
197
+ * @param {Function} callback
198
+ */
199
+ static restoring(callback) {
200
+ this.on('restoring', callback);
201
+ }
202
+
203
+ /**
204
+ * Register a 'restored' event listener
205
+ * @param {Function} callback
206
+ */
207
+ static restored(callback) {
208
+ this.on('restored', callback);
209
+ }
210
+
211
+ // ==================== Scopes ====================
212
+
213
+ /**
214
+ * Add a global scope
215
+ * @param {string} name - Scope name
216
+ * @param {Function} callback - Function that modifies the query builder
217
+ */
218
+ static addGlobalScope(name, callback) {
219
+ if (!this.globalScopes) {
220
+ this.globalScopes = {};
221
+ }
222
+ this.globalScopes[name] = callback;
223
+ }
224
+
225
+ /**
226
+ * Remove a global scope
227
+ * @param {string} name - Scope name
228
+ */
229
+ static removeGlobalScope(name) {
230
+ if (this.globalScopes && this.globalScopes[name]) {
231
+ delete this.globalScopes[name];
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Query without a specific global scope
237
+ * @param {string} name - Scope name to exclude
238
+ * @returns {QueryBuilder}
239
+ */
240
+ static withoutGlobalScope(name) {
241
+ const query = this.query();
242
+ query._excludedScopes = query._excludedScopes || [];
243
+ query._excludedScopes.push(name);
244
+ return query;
245
+ }
246
+
247
+ /**
248
+ * Query without all global scopes
249
+ * @returns {QueryBuilder}
250
+ */
251
+ static withoutGlobalScopes() {
252
+ const query = this.query();
253
+ query._excludeAllScopes = true;
254
+ return query;
255
+ }
256
+
257
+ // ==================== Soft Deletes ====================
258
+
259
+ /**
260
+ * Query including soft deleted models
261
+ * @returns {QueryBuilder}
262
+ */
263
+ static withTrashed() {
264
+ const query = this.query();
265
+ query._withTrashed = true;
266
+ return query;
267
+ }
268
+
269
+ /**
270
+ * Query only soft deleted models
271
+ * @returns {QueryBuilder}
272
+ */
273
+ static onlyTrashed() {
274
+ const query = this.query();
275
+ query._onlyTrashed = true;
276
+ return query;
277
+ }
278
+
279
+ /**
280
+ * Check if model is soft deleted
281
+ * @returns {boolean}
282
+ */
283
+ trashed() {
284
+ return this.constructor.softDeletes && this.attributes[this.constructor.DELETED_AT] !== null;
285
+ }
286
+
287
+ /**
288
+ * Restore a soft deleted model
289
+ * @returns {Promise<this>}
290
+ */
291
+ async restore() {
292
+ if (!this.constructor.softDeletes) {
293
+ throw new Error('This model does not use soft deletes');
294
+ }
295
+
296
+ // Fire restoring event
297
+ const shouldContinue = await this.constructor.fireEvent('restoring', this);
298
+ if (!shouldContinue) return this;
299
+
300
+ this.setAttribute(this.constructor.DELETED_AT, null);
301
+
302
+ await this.constructor.connection.update(
303
+ this.constructor.table,
304
+ { [this.constructor.DELETED_AT]: null },
305
+ { wheres: [{ type: 'basic', column: this.constructor.primaryKey, operator: '=', value: this.getAttribute(this.constructor.primaryKey) }] }
306
+ );
307
+
308
+ // Fire restored event
309
+ await this.constructor.fireEvent('restored', this);
310
+
311
+ return this;
312
+ }
313
+
314
+ /**
315
+ * Force delete a soft deleted model (permanent delete)
316
+ * @returns {Promise<boolean>}
317
+ */
318
+ async forceDelete() {
319
+ // Fire deleting event
320
+ const shouldContinue = await this.constructor.fireEvent('deleting', this);
321
+ if (!shouldContinue) return false;
322
+
323
+ await this.constructor.connection.delete(
324
+ this.constructor.table,
325
+ { wheres: [{ type: 'basic', column: this.constructor.primaryKey, operator: '=', value: this.getAttribute(this.constructor.primaryKey) }] }
326
+ );
327
+
328
+ this.exists = false;
329
+
330
+ // Fire deleted event
331
+ await this.constructor.fireEvent('deleted', this);
332
+
333
+ return true;
334
+ }
335
+
336
+ // ==================== Validation ====================
337
+
338
+ /**
339
+ * Validate the model attributes
340
+ * @returns {Object} - { valid: boolean, errors: Object }
341
+ */
342
+ validate() {
343
+ const rules = this.constructor.rules;
344
+ const errors = {};
345
+ let valid = true;
346
+
347
+ for (const [field, ruleString] of Object.entries(rules)) {
348
+ const fieldRules = typeof ruleString === 'string' ? ruleString.split('|') : ruleString;
349
+ const value = this.attributes[field];
350
+
351
+ for (const rule of fieldRules) {
352
+ const [ruleName, ruleParam] = rule.split(':');
353
+ const error = this._validateRule(field, value, ruleName, ruleParam);
354
+ if (error) {
355
+ if (!errors[field]) errors[field] = [];
356
+ errors[field].push(error);
357
+ valid = false;
358
+ }
359
+ }
360
+ }
361
+
362
+ return { valid, errors };
363
+ }
364
+
365
+ /**
366
+ * Validate a single rule
367
+ * @private
368
+ */
369
+ _validateRule(field, value, ruleName, ruleParam) {
370
+ switch (ruleName) {
371
+ case 'required':
372
+ if (value === undefined || value === null || value === '') {
373
+ return `${field} is required`;
374
+ }
375
+ break;
376
+
377
+ case 'string':
378
+ if (value !== undefined && value !== null && typeof value !== 'string') {
379
+ return `${field} must be a string`;
380
+ }
381
+ break;
382
+
383
+ case 'number':
384
+ case 'numeric':
385
+ if (value !== undefined && value !== null && typeof value !== 'number' && isNaN(Number(value))) {
386
+ return `${field} must be a number`;
387
+ }
388
+ break;
389
+
390
+ case 'email':
391
+ if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
392
+ return `${field} must be a valid email`;
393
+ }
394
+ break;
395
+
396
+ case 'min':
397
+ if (typeof value === 'string' && value.length < parseInt(ruleParam, 10)) {
398
+ return `${field} must be at least ${ruleParam} characters`;
399
+ }
400
+ if (typeof value === 'number' && value < parseInt(ruleParam, 10)) {
401
+ return `${field} must be at least ${ruleParam}`;
402
+ }
403
+ break;
404
+
405
+ case 'max':
406
+ if (typeof value === 'string' && value.length > parseInt(ruleParam, 10)) {
407
+ return `${field} must not exceed ${ruleParam} characters`;
408
+ }
409
+ if (typeof value === 'number' && value > parseInt(ruleParam, 10)) {
410
+ return `${field} must not exceed ${ruleParam}`;
411
+ }
412
+ break;
413
+
414
+ case 'in':
415
+ if (value !== undefined && value !== null) {
416
+ const allowed = ruleParam.split(',');
417
+ if (!allowed.includes(String(value))) {
418
+ return `${field} must be one of: ${ruleParam}`;
419
+ }
420
+ }
421
+ break;
422
+
423
+ case 'boolean':
424
+ if (value !== undefined && value !== null && typeof value !== 'boolean') {
425
+ return `${field} must be a boolean`;
426
+ }
427
+ break;
428
+
429
+ case 'date':
430
+ if (value !== undefined && value !== null && isNaN(Date.parse(value))) {
431
+ return `${field} must be a valid date`;
432
+ }
433
+ break;
434
+
435
+ case 'regex':
436
+ try {
437
+ const re = new RegExp(ruleParam);
438
+ if (value && !re.test(value)) {
439
+ return `${field} format is invalid`;
440
+ }
441
+ } catch {
442
+ return `${field} has an invalid regex rule`;
443
+ }
444
+ break;
445
+ }
446
+
447
+ return null;
448
+ }
449
+
450
+ /**
451
+ * Validate and throw if invalid
452
+ * @throws {Error}
453
+ */
454
+ validateOrFail() {
455
+ const { valid, errors } = this.validate();
456
+ if (!valid) {
457
+ const error = new Error('Validation failed');
458
+ error.errors = errors;
459
+ throw error;
460
+ }
461
+ }
462
+
463
+ // ==================== Query Builder ====================
464
+
465
+ /**
466
+ * Begin querying the model
467
+ * @returns {QueryBuilder}
468
+ */
469
+ static query() {
470
+ // Ensure a connection exists even when using static APIs without instantiation
471
+ this.ensureConnection();
472
+ return new QueryBuilder(this);
473
+ }
474
+
475
+ /**
476
+ * Get all records
477
+ * @returns {Promise<Array<Model>>}
478
+ */
479
+ static all() {
480
+ return this.query().get();
481
+ }
482
+
483
+ /**
484
+ * Find a model by its primary key
485
+ * @param {any} id
486
+ * @returns {Promise<Model|null>}
487
+ */
488
+ static find(id) {
489
+ return this.query().where(this.primaryKey, id).first();
490
+ }
491
+
492
+ /**
493
+ * Find a model by its primary key or throw an error
494
+ * @param {any} id
495
+ * @returns {Promise<Model>}
496
+ */
497
+ static findOrFail(id) {
498
+ return this.query().where(this.primaryKey, id).firstOrFail();
499
+ }
500
+
501
+ /**
502
+ * Add a where clause
503
+ * @param {string} column
504
+ * @param {string|any} operator
505
+ * @param {any} value
506
+ * @returns {QueryBuilder}
507
+ */
508
+ static where(column, operator, value) {
509
+ if (arguments.length === 2) {
510
+ value = operator;
511
+ operator = '=';
512
+ }
513
+ return this.query().where(column, operator, value);
514
+ }
515
+
516
+ /**
517
+ * Create a new model and save it
518
+ * @param {Object} attributes
519
+ * @returns {Promise<Model>}
520
+ */
521
+ static create(attributes) {
522
+ const instance = new this(attributes);
523
+ return instance.save();
524
+ }
525
+
526
+ /**
527
+ * Insert data without creating model instances
528
+ * @param {Object|Array<Object>} data
529
+ * @returns {Promise<any>}
530
+ */
531
+ static async insert(data) {
532
+ const query = this.query();
533
+ return query.insert(data);
534
+ }
535
+
536
+ /**
537
+ * Update records
538
+ * @param {Object} attributes
539
+ * @returns {Promise<any>}
540
+ */
541
+ static async update(attributes) {
542
+ return this.query().update(attributes);
543
+ }
544
+
545
+ /**
546
+ * Update by primary key and fetch the updated model (optionally with relations)
547
+ * @param {any} id
548
+ * @param {Object} attributes
549
+ * @param {string[]} [relations]
550
+ * @returns {Promise<Model|null>}
551
+ */
552
+ static async updateAndFetchById(id, attributes, relations = []) {
553
+ await this.query().where(this.primaryKey, id).update(attributes);
554
+ const qb = this.query().where(this.primaryKey, id);
555
+ if (relations && relations.length) qb.with(...relations);
556
+ return qb.first();
557
+ }
558
+
559
+ /**
560
+ * Update by primary key only (convenience)
561
+ * @param {any} id
562
+ * @param {Object} attributes
563
+ * @returns {Promise<any>}
564
+ */
565
+ static async updateById(id, attributes) {
566
+ return this.query().where(this.primaryKey, id).update(attributes);
567
+ }
568
+
569
+ /**
570
+ * Delete records
571
+ * @returns {Promise<any>}
572
+ */
573
+ static async delete() {
574
+ throw new Error('Cannot call static delete() without conditions. Use query().delete() instead.');
575
+ }
576
+
577
+ /**
578
+ * Get the first record
579
+ * @returns {Promise<Model|null>}
580
+ */
581
+ static first() {
582
+ return this.query().first();
583
+ }
584
+
585
+ /**
586
+ * Add an order by clause
587
+ * @param {string} column
588
+ * @param {string} direction
589
+ * @returns {QueryBuilder}
590
+ */
591
+ static orderBy(column, direction = 'asc') {
592
+ return this.query().orderBy(column, direction);
593
+ }
594
+
595
+ /**
596
+ * Limit the number of results
597
+ * @param {number} value
598
+ * @returns {QueryBuilder}
599
+ */
600
+ static limit(value) {
601
+ return this.query().limit(value);
602
+ }
603
+
604
+ /**
605
+ * Offset the results
606
+ * @param {number} value
607
+ * @returns {QueryBuilder}
608
+ */
609
+ static offset(value) {
610
+ return this.query().offset(value);
611
+ }
612
+
613
+ /**
614
+ * Paginate the results
615
+ * @param {number} page
616
+ * @param {number} perPage
617
+ * @returns {Promise<Object>}
618
+ */
619
+ static paginate(page = 1, perPage = 15) {
620
+ return this.query().paginate(page, perPage);
621
+ }
622
+
623
+ /**
624
+ * Add a where in clause
625
+ * @param {string} column
626
+ * @param {Array} values
627
+ * @returns {QueryBuilder}
628
+ */
629
+ static whereIn(column, values) {
630
+ return this.query().whereIn(column, values);
631
+ }
632
+
633
+ /**
634
+ * Add a where null clause
635
+ * @param {string} column
636
+ * @returns {QueryBuilder}
637
+ */
638
+ static whereNull(column) {
639
+ return this.query().whereNull(column);
640
+ }
641
+
642
+ /**
643
+ * Add a where not null clause
644
+ * @param {string} column
645
+ * @returns {QueryBuilder}
646
+ */
647
+ static whereNotNull(column) {
648
+ return this.query().whereNotNull(column);
649
+ }
650
+
651
+ /**
652
+ * Count records
653
+ * @returns {Promise<number>}
654
+ */
655
+ static count() {
656
+ return this.query().count();
657
+ }
658
+
659
+ /**
660
+ * Eager load relations on the query
661
+ * @param {...string} relations
662
+ * @returns {QueryBuilder}
663
+ */
664
+ static with(...relations) {
665
+ return this.query().with(...relations);
666
+ }
667
+
668
+ /**
669
+ * Include hidden attributes in query results
670
+ * @returns {QueryBuilder}
671
+ */
672
+ static withHidden() {
673
+ const query = this.query();
674
+ query._showHidden = true;
675
+ return query;
676
+ }
677
+
678
+ /**
679
+ * Control visibility of hidden attributes in query results
680
+ * @param {boolean} show - If false (default), hidden attributes will be hidden. If true, they will be shown.
681
+ * @returns {QueryBuilder}
682
+ */
683
+ static withoutHidden(show = false) {
684
+ const query = this.query();
685
+ query._showHidden = show;
686
+ return query;
687
+ }
688
+
689
+ // ==================== Instance Methods ====================
690
+
691
+ /**
692
+ * Fill the model with attributes
693
+ * @param {Object} attributes
694
+ * @returns {this}
695
+ */
696
+ fill(attributes) {
697
+ const fillable = this.constructor.fillable || [];
698
+ for (const [key, value] of Object.entries(attributes)) {
699
+ if (fillable.includes(key)) {
700
+ this.setAttribute(key, value);
701
+ }
702
+ }
703
+ return this;
704
+ }
705
+
706
+ /**
707
+ * Set an attribute
708
+ * @param {string} key
709
+ * @param {any} value
710
+ * @returns {this}
711
+ */
712
+ setAttribute(key, value) {
713
+ this.attributes[key] = this.castAttribute(key, value);
714
+ return this;
715
+ }
716
+
717
+ /**
718
+ * Get an attribute
719
+ * @param {string} key
720
+ * @returns {any}
721
+ */
722
+ getAttribute(key) {
723
+ if (this.relations[key]) {
724
+ return this.relations[key];
725
+ }
726
+ return this.castAttribute(key, this.attributes[key]);
727
+ }
728
+
729
+ /**
730
+ * Cast an attribute to the proper type
731
+ * @param {string} key
732
+ * @param {any} value
733
+ * @returns {any}
734
+ */
735
+ castAttribute(key, value) {
736
+ const cast = this.constructor.casts[key];
737
+ if (!cast || value === null || value === undefined) return value;
738
+
739
+ switch (cast) {
740
+ case 'int':
741
+ case 'integer':
742
+ return parseInt(value, 10);
743
+ case 'float':
744
+ case 'double':
745
+ return parseFloat(value);
746
+ case 'string':
747
+ return String(value);
748
+ case 'bool':
749
+ case 'boolean':
750
+ return Boolean(value);
751
+ case 'array':
752
+ case 'json':
753
+ return typeof value === 'string' ? JSON.parse(value) : value;
754
+ case 'date':
755
+ return value instanceof Date ? value : new Date(value);
756
+ default:
757
+ return value;
758
+ }
759
+ }
760
+
761
+ /**
762
+ * Save the model
763
+ * @returns {Promise<this>}
764
+ */
765
+ async save() {
766
+ // Fire saving event
767
+ const shouldContinue = await this.constructor.fireEvent('saving', this);
768
+ if (!shouldContinue) return this;
769
+
770
+ let result;
771
+ if (this.exists) {
772
+ result = await this.performUpdate();
773
+ } else {
774
+ result = await this.performInsert();
775
+ }
776
+
777
+ // Fire saved event
778
+ await this.constructor.fireEvent('saved', this);
779
+
780
+ return result;
781
+ }
782
+
783
+ /**
784
+ * Perform an insert operation
785
+ * @returns {Promise<this>}
786
+ */
787
+ async performInsert() {
788
+ // Fire creating event
789
+ const shouldContinue = await this.constructor.fireEvent('creating', this);
790
+ if (!shouldContinue) return this;
791
+
792
+ if (this.constructor.timestamps) {
793
+ const now = new Date();
794
+ this.setAttribute('created_at', now);
795
+ this.setAttribute('updated_at', now);
796
+ }
797
+
798
+ const data = this.attributes;
799
+ const result = await this.constructor.connection.insert(this.constructor.table, data);
800
+
801
+ this.setAttribute(this.constructor.primaryKey, result.insertId);
802
+ this.exists = true;
803
+ this.original = { ...this.attributes };
804
+
805
+ await this.touchParents();
806
+
807
+ // Fire created event
808
+ await this.constructor.fireEvent('created', this);
809
+
810
+ return this;
811
+ }
812
+
813
+ /**
814
+ * Perform an update operation
815
+ * @returns {Promise<this>}
816
+ */
817
+ async performUpdate() {
818
+ // Fire updating event
819
+ const shouldContinue = await this.constructor.fireEvent('updating', this);
820
+ if (!shouldContinue) return this;
821
+
822
+ if (this.constructor.timestamps) {
823
+ this.setAttribute('updated_at', new Date());
824
+ }
825
+
826
+ const dirty = this.getDirty();
827
+ if (Object.keys(dirty).length === 0) {
828
+ return this;
829
+ }
830
+
831
+ await this.constructor.connection.update(
832
+ this.constructor.table,
833
+ dirty,
834
+ { wheres: [{ type: 'basic', column: this.constructor.primaryKey, operator: '=', value: this.getAttribute(this.constructor.primaryKey) }] }
835
+ );
836
+
837
+ this.original = { ...this.attributes };
838
+
839
+ await this.touchParents();
840
+
841
+ // Fire updated event
842
+ await this.constructor.fireEvent('updated', this);
843
+
844
+ return this;
845
+ }
846
+
847
+ /**
848
+ * Touch parent models for belongsTo relations with touches enabled
849
+ * @returns {Promise<void>}
850
+ */
851
+ async touchParents() {
852
+ for (const relation of this.touches) {
853
+ if (relation.touchesParent) {
854
+ const foreignKeyValue = this.getAttribute(relation.foreignKey);
855
+ if (foreignKeyValue) {
856
+ await this.constructor.connection.update(
857
+ relation.related.table,
858
+ { updated_at: new Date() },
859
+ { wheres: [{ type: 'basic', column: relation.ownerKey, operator: '=', value: foreignKeyValue }] }
860
+ );
861
+ }
862
+ }
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Delete the model (soft delete if enabled)
868
+ * @returns {Promise<boolean>}
869
+ */
870
+ async destroy() {
871
+ if (!this.exists) {
872
+ return false;
873
+ }
874
+
875
+ // Fire deleting event
876
+ const shouldContinue = await this.constructor.fireEvent('deleting', this);
877
+ if (!shouldContinue) return false;
878
+
879
+ // Soft delete if enabled
880
+ if (this.constructor.softDeletes) {
881
+ this.setAttribute(this.constructor.DELETED_AT, new Date());
882
+ await this.constructor.connection.update(
883
+ this.constructor.table,
884
+ { [this.constructor.DELETED_AT]: new Date() },
885
+ { wheres: [{ type: 'basic', column: this.constructor.primaryKey, operator: '=', value: this.getAttribute(this.constructor.primaryKey) }] }
886
+ );
887
+ } else {
888
+ await this.constructor.connection.delete(
889
+ this.constructor.table,
890
+ { wheres: [{ type: 'basic', column: this.constructor.primaryKey, operator: '=', value: this.getAttribute(this.constructor.primaryKey) }] }
891
+ );
892
+ this.exists = false;
893
+ }
894
+
895
+ // Fire deleted event
896
+ await this.constructor.fireEvent('deleted', this);
897
+
898
+ return true;
899
+ }
900
+
901
+ /**
902
+ * Get the attributes that have been changed
903
+ * @returns {Object}
904
+ */
905
+ getDirty() {
906
+ const dirty = {};
907
+ for (const [key, value] of Object.entries(this.attributes)) {
908
+ if (JSON.stringify(value) !== JSON.stringify(this.original[key])) {
909
+ dirty[key] = value;
910
+ }
911
+ }
912
+ return dirty;
913
+ }
914
+
915
+ /**
916
+ * Check if the model has been modified
917
+ * @returns {boolean}
918
+ */
919
+ isDirty() {
920
+ return Object.keys(this.getDirty()).length > 0;
921
+ }
922
+
923
+ /**
924
+ * Convert the model to JSON
925
+ * @returns {Object}
926
+ */
927
+ toJSON() {
928
+ const json = { ...this.attributes };
929
+
930
+ // Hide specified attributes unless _showHidden is true
931
+ if (!this._showHidden) {
932
+ this.constructor.hidden.forEach(key => {
933
+ delete json[key];
934
+ });
935
+ }
936
+
937
+ // Add relations
938
+ Object.assign(json, this.relations);
939
+
940
+ return json;
941
+ }
942
+
943
+ /**
944
+ * Load one or multiple relations on this model instance.
945
+ * Supports dot-notation for nested relations (e.g., 'posts.comments').
946
+ * @param {...string|Array<string>} relations
947
+ * @returns {Promise<this>}
948
+ */
949
+ async load(...relations) {
950
+ const list = relations.length === 1 && Array.isArray(relations[0])
951
+ ? relations[0]
952
+ : relations;
953
+
954
+ for (const rel of list) {
955
+ if (typeof rel !== 'string' || !rel) continue;
956
+ await this._loadRelationPath(rel);
957
+ }
958
+ return this;
959
+ }
960
+
961
+ /**
962
+ * Internal: load a relation path with optional nesting (a.b.c)
963
+ * @param {string} path
964
+ * @private
965
+ */
966
+ async _loadRelationPath(path) {
967
+ const segments = path.split('.');
968
+ const head = segments[0];
969
+ const tail = segments.slice(1).join('.');
970
+
971
+ // Prevent prototype pollution and calling built-in methods
972
+ const builtIns = ['constructor', 'load', 'save', 'delete', 'update', 'query', 'with', 'withCount', 'hasOne', 'hasMany', 'belongsTo', 'belongsToMany', 'morphTo', 'morphOne', 'morphMany', 'hasOneThrough', 'hasManyThrough'];
973
+ if (builtIns.includes(head) || head.startsWith('__')) return;
974
+
975
+ const relationFn = this[head];
976
+ if (typeof relationFn !== 'function') return;
977
+
978
+ const relation = relationFn.call(this);
979
+ if (!relation || typeof relation.get !== 'function') return;
980
+
981
+ const value = await relation.get();
982
+ this.relations[head] = value;
983
+
984
+ if (tail) {
985
+ if (Array.isArray(value)) {
986
+ await Promise.all(
987
+ value.map(v => (v && typeof v.load === 'function') ? v.load(tail) : null)
988
+ );
989
+ } else if (value && typeof value.load === 'function') {
990
+ await value.load(tail);
991
+ }
992
+ }
993
+ }
994
+
995
+ // ==================== Relationships ====================
996
+
997
+ /**
998
+ * Define a one-to-one relationship
999
+ * @param {typeof Model} related
1000
+ * @param {string} foreignKey
1001
+ * @param {string} localKey
1002
+ * @returns {HasOneRelation}
1003
+ */
1004
+ hasOne(related, foreignKey, localKey) {
1005
+ const HasOneRelation = require('./Relations/HasOneRelation');
1006
+ localKey = localKey || this.constructor.primaryKey;
1007
+ const pluralize = require('pluralize');
1008
+ foreignKey = foreignKey || `${pluralize.singular(this.constructor.table)}_id`;
1009
+
1010
+ return new HasOneRelation(this, related, foreignKey, localKey);
1011
+ }
1012
+
1013
+ /**
1014
+ * Define a one-to-many relationship
1015
+ * @param {typeof Model} related
1016
+ * @param {string} foreignKey
1017
+ * @param {string} localKey
1018
+ * @returns {HasManyRelation}
1019
+ */
1020
+ hasMany(related, foreignKey, localKey) {
1021
+ const HasManyRelation = require('./Relations/HasManyRelation');
1022
+ localKey = localKey || this.constructor.primaryKey;
1023
+ const pluralize = require('pluralize');
1024
+ foreignKey = foreignKey || `${pluralize.singular(this.constructor.table)}_id`;
1025
+
1026
+ return new HasManyRelation(this, related, foreignKey, localKey);
1027
+ }
1028
+
1029
+ /**
1030
+ * Define an inverse one-to-one or many relationship
1031
+ * @param {typeof Model} related
1032
+ * @param {string} foreignKey
1033
+ * @param {string} ownerKey
1034
+ * @returns {BelongsToRelation}
1035
+ */
1036
+ belongsTo(related, foreignKey, ownerKey) {
1037
+ const BelongsToRelation = require('./Relations/BelongsToRelation');
1038
+ ownerKey = ownerKey || related.primaryKey;
1039
+ const pluralize = require('pluralize');
1040
+ foreignKey = foreignKey || `${pluralize.singular(related.table)}_id`;
1041
+
1042
+ return new BelongsToRelation(this, related, foreignKey, ownerKey);
1043
+ }
1044
+
1045
+ /**
1046
+ * Define a many-to-many relationship
1047
+ * @param {typeof Model} related
1048
+ * @param {string} pivot
1049
+ * @param {string} foreignPivotKey
1050
+ * @param {string} relatedPivotKey
1051
+ * @param {string} parentKey
1052
+ * @param {string} relatedKey
1053
+ * @returns {BelongsToManyRelation}
1054
+ */
1055
+ belongsToMany(related, pivot, foreignPivotKey, relatedPivotKey, parentKey, relatedKey) {
1056
+ const BelongsToManyRelation = require('./Relations/BelongsToManyRelation');
1057
+ return new BelongsToManyRelation(
1058
+ this, related, pivot, foreignPivotKey, relatedPivotKey, parentKey, relatedKey
1059
+ );
1060
+ }
1061
+
1062
+ /**
1063
+ * Define a has-many-through relationship
1064
+ * @param {typeof Model} relatedFinal
1065
+ * @param {typeof Model} through
1066
+ * @param {string} [foreignKeyOnThrough]
1067
+ * @param {string} [throughKeyOnFinal]
1068
+ * @param {string} [localKey]
1069
+ * @param {string} [throughLocalKey]
1070
+ * @returns {HasManyThroughRelation}
1071
+ */
1072
+ hasManyThrough(relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey) {
1073
+ const HasManyThroughRelation = require('./Relations/HasManyThroughRelation');
1074
+ return new HasManyThroughRelation(
1075
+ this, relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey
1076
+ );
1077
+ }
1078
+
1079
+ /**
1080
+ * Define a has-one-through relationship
1081
+ * @param {typeof Model} relatedFinal
1082
+ * @param {typeof Model} through
1083
+ * @param {string} [foreignKeyOnThrough]
1084
+ * @param {string} [throughKeyOnFinal]
1085
+ * @param {string} [localKey]
1086
+ * @param {string} [throughLocalKey]
1087
+ * @returns {HasOneThroughRelation}
1088
+ */
1089
+ hasOneThrough(relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey) {
1090
+ const HasOneThroughRelation = require('./Relations/HasOneThroughRelation');
1091
+ return new HasOneThroughRelation(
1092
+ this, relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey
1093
+ );
1094
+ }
1095
+
1096
+ /**
1097
+ * Define a polymorphic inverse relationship
1098
+ * @param {string} name
1099
+ * @param {string} [typeColumn]
1100
+ * @param {string} [idColumn]
1101
+ * @returns {MorphToRelation}
1102
+ */
1103
+ morphTo(name, typeColumn, idColumn) {
1104
+ const MorphToRelation = require('./Relations/MorphToRelation');
1105
+ return new MorphToRelation(this, name, typeColumn, idColumn);
1106
+ }
1107
+
1108
+ /**
1109
+ * Define a polymorphic one-to-one relationship
1110
+ * @param {typeof Model} related
1111
+ * @param {string} morphType
1112
+ * @param {string} [foreignKey]
1113
+ * @param {string} [localKey]
1114
+ * @returns {MorphOneRelation}
1115
+ */
1116
+ morphOne(related, morphType, foreignKey, localKey) {
1117
+ const MorphOneRelation = require('./Relations/MorphOneRelation');
1118
+ localKey = localKey || this.constructor.primaryKey;
1119
+ foreignKey = foreignKey || `${morphType}_id`;
1120
+
1121
+ return new MorphOneRelation(this, related, morphType, foreignKey, localKey);
1122
+ }
1123
+
1124
+ /**
1125
+ * Define a polymorphic one-to-many relationship
1126
+ * @param {typeof Model} related
1127
+ * @param {string} morphType
1128
+ * @param {string} [foreignKey]
1129
+ * @param {string} [localKey]
1130
+ * @returns {MorphManyRelation}
1131
+ */
1132
+ morphMany(related, morphType, foreignKey, localKey) {
1133
+ const MorphManyRelation = require('./Relations/MorphManyRelation');
1134
+ localKey = localKey || this.constructor.primaryKey;
1135
+ foreignKey = foreignKey || `${morphType}_id`;
1136
+
1137
+ return new MorphManyRelation(this, related, morphType, foreignKey, localKey);
1138
+ }
1139
+ }
1140
+
1141
+ module.exports = Model;