outlet-orm 2.5.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/src/Model.js ADDED
@@ -0,0 +1,659 @@
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
+ /**
16
+ * Ensure a default database connection exists.
17
+ * If none is set, it will be initialized from environment (.env) lazily.
18
+ */
19
+ static ensureConnection() {
20
+ if (!this.connection) {
21
+ // Lazy require to avoid circular dependencies
22
+ const DatabaseConnection = require('./DatabaseConnection');
23
+ this.connection = new DatabaseConnection();
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Set the default database connection for all models
29
+ * @param {DatabaseConnection} connection
30
+ */
31
+ static setConnection(connection) {
32
+ this.connection = connection;
33
+ }
34
+
35
+ /**
36
+ * Set the morph map for polymorphic relations
37
+ * @param {Object} map
38
+ */
39
+ static setMorphMap(map) {
40
+ this.morphMap = map;
41
+ }
42
+
43
+ constructor(attributes = {}) {
44
+ // Auto-initialize connection on first model instantiation if missing
45
+ this.constructor.ensureConnection();
46
+ this.attributes = {};
47
+ this.original = {};
48
+ this.relations = {};
49
+ this.touches = [];
50
+ this.exists = false;
51
+ this.fill(attributes);
52
+ }
53
+
54
+ // ==================== Query Builder ====================
55
+
56
+ /**
57
+ * Begin querying the model
58
+ * @returns {QueryBuilder}
59
+ */
60
+ static query() {
61
+ // Ensure a connection exists even when using static APIs without instantiation
62
+ this.ensureConnection();
63
+ return new QueryBuilder(this);
64
+ }
65
+
66
+ /**
67
+ * Get all records
68
+ * @returns {Promise<Array<Model>>}
69
+ */
70
+ static all() {
71
+ return this.query().get();
72
+ }
73
+
74
+ /**
75
+ * Find a model by its primary key
76
+ * @param {any} id
77
+ * @returns {Promise<Model|null>}
78
+ */
79
+ static find(id) {
80
+ return this.query().where(this.primaryKey, id).first();
81
+ }
82
+
83
+ /**
84
+ * Find a model by its primary key or throw an error
85
+ * @param {any} id
86
+ * @returns {Promise<Model>}
87
+ */
88
+ static findOrFail(id) {
89
+ return this.query().where(this.primaryKey, id).firstOrFail();
90
+ }
91
+
92
+ /**
93
+ * Add a where clause
94
+ * @param {string} column
95
+ * @param {string|any} operator
96
+ * @param {any} value
97
+ * @returns {QueryBuilder}
98
+ */
99
+ static where(column, operator, value) {
100
+ if (arguments.length === 2) {
101
+ value = operator;
102
+ operator = '=';
103
+ }
104
+ return this.query().where(column, operator, value);
105
+ }
106
+
107
+ /**
108
+ * Create a new model and save it
109
+ * @param {Object} attributes
110
+ * @returns {Promise<Model>}
111
+ */
112
+ static create(attributes) {
113
+ const instance = new this(attributes);
114
+ return instance.save();
115
+ }
116
+
117
+ /**
118
+ * Insert data without creating model instances
119
+ * @param {Object|Array<Object>} data
120
+ * @returns {Promise<any>}
121
+ */
122
+ static async insert(data) {
123
+ const query = this.query();
124
+ return query.insert(data);
125
+ }
126
+
127
+ /**
128
+ * Update records
129
+ * @param {Object} attributes
130
+ * @returns {Promise<any>}
131
+ */
132
+ static async update(attributes) {
133
+ return this.query().update(attributes);
134
+ }
135
+
136
+ /**
137
+ * Update by primary key and fetch the updated model (optionally with relations)
138
+ * @param {any} id
139
+ * @param {Object} attributes
140
+ * @param {string[]} [relations]
141
+ * @returns {Promise<Model|null>}
142
+ */
143
+ static async updateAndFetchById(id, attributes, relations = []) {
144
+ await this.query().where(this.primaryKey, id).update(attributes);
145
+ const qb = this.query().where(this.primaryKey, id);
146
+ if (relations && relations.length) qb.with(...relations);
147
+ return qb.first();
148
+ }
149
+
150
+ /**
151
+ * Update by primary key only (convenience)
152
+ * @param {any} id
153
+ * @param {Object} attributes
154
+ * @returns {Promise<any>}
155
+ */
156
+ static async updateById(id, attributes) {
157
+ return this.query().where(this.primaryKey, id).update(attributes);
158
+ }
159
+
160
+ /**
161
+ * Delete records
162
+ * @returns {Promise<any>}
163
+ */
164
+ static async delete() {
165
+ return this.query().delete();
166
+ }
167
+
168
+ /**
169
+ * Get the first record
170
+ * @returns {Promise<Model|null>}
171
+ */
172
+ static first() {
173
+ return this.query().first();
174
+ }
175
+
176
+ /**
177
+ * Add an order by clause
178
+ * @param {string} column
179
+ * @param {string} direction
180
+ * @returns {QueryBuilder}
181
+ */
182
+ static orderBy(column, direction = 'asc') {
183
+ return this.query().orderBy(column, direction);
184
+ }
185
+
186
+ /**
187
+ * Limit the number of results
188
+ * @param {number} value
189
+ * @returns {QueryBuilder}
190
+ */
191
+ static limit(value) {
192
+ return this.query().limit(value);
193
+ }
194
+
195
+ /**
196
+ * Offset the results
197
+ * @param {number} value
198
+ * @returns {QueryBuilder}
199
+ */
200
+ static offset(value) {
201
+ return this.query().offset(value);
202
+ }
203
+
204
+ /**
205
+ * Paginate the results
206
+ * @param {number} page
207
+ * @param {number} perPage
208
+ * @returns {Promise<Object>}
209
+ */
210
+ static paginate(page = 1, perPage = 15) {
211
+ return this.query().paginate(page, perPage);
212
+ }
213
+
214
+ /**
215
+ * Add a where in clause
216
+ * @param {string} column
217
+ * @param {Array} values
218
+ * @returns {QueryBuilder}
219
+ */
220
+ static whereIn(column, values) {
221
+ return this.query().whereIn(column, values);
222
+ }
223
+
224
+ /**
225
+ * Add a where null clause
226
+ * @param {string} column
227
+ * @returns {QueryBuilder}
228
+ */
229
+ static whereNull(column) {
230
+ return this.query().whereNull(column);
231
+ }
232
+
233
+ /**
234
+ * Add a where not null clause
235
+ * @param {string} column
236
+ * @returns {QueryBuilder}
237
+ */
238
+ static whereNotNull(column) {
239
+ return this.query().whereNotNull(column);
240
+ }
241
+
242
+ /**
243
+ * Count records
244
+ * @returns {Promise<number>}
245
+ */
246
+ static count() {
247
+ return this.query().count();
248
+ }
249
+
250
+ /**
251
+ * Eager load relations on the query
252
+ * @param {...string} relations
253
+ * @returns {QueryBuilder}
254
+ */
255
+ static with(...relations) {
256
+ return this.query().with(...relations);
257
+ }
258
+
259
+ // ==================== Instance Methods ====================
260
+
261
+ /**
262
+ * Fill the model with attributes
263
+ * @param {Object} attributes
264
+ * @returns {this}
265
+ */
266
+ fill(attributes) {
267
+ for (const [key, value] of Object.entries(attributes)) {
268
+ if (this.constructor.fillable.length === 0 || this.constructor.fillable.includes(key)) {
269
+ this.setAttribute(key, value);
270
+ }
271
+ }
272
+ return this;
273
+ }
274
+
275
+ /**
276
+ * Set an attribute
277
+ * @param {string} key
278
+ * @param {any} value
279
+ * @returns {this}
280
+ */
281
+ setAttribute(key, value) {
282
+ this.attributes[key] = this.castAttribute(key, value);
283
+ return this;
284
+ }
285
+
286
+ /**
287
+ * Get an attribute
288
+ * @param {string} key
289
+ * @returns {any}
290
+ */
291
+ getAttribute(key) {
292
+ if (this.relations[key]) {
293
+ return this.relations[key];
294
+ }
295
+ return this.castAttribute(key, this.attributes[key]);
296
+ }
297
+
298
+ /**
299
+ * Cast an attribute to the proper type
300
+ * @param {string} key
301
+ * @param {any} value
302
+ * @returns {any}
303
+ */
304
+ castAttribute(key, value) {
305
+ const cast = this.constructor.casts[key];
306
+ if (!cast || value === null || value === undefined) return value;
307
+
308
+ switch (cast) {
309
+ case 'int':
310
+ case 'integer':
311
+ return parseInt(value, 10);
312
+ case 'float':
313
+ case 'double':
314
+ return parseFloat(value);
315
+ case 'string':
316
+ return String(value);
317
+ case 'bool':
318
+ case 'boolean':
319
+ return Boolean(value);
320
+ case 'array':
321
+ case 'json':
322
+ return typeof value === 'string' ? JSON.parse(value) : value;
323
+ case 'date':
324
+ return value instanceof Date ? value : new Date(value);
325
+ default:
326
+ return value;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Save the model
332
+ * @returns {Promise<this>}
333
+ */
334
+ async save() {
335
+ if (this.exists) {
336
+ return this.performUpdate();
337
+ }
338
+ return this.performInsert();
339
+ }
340
+
341
+ /**
342
+ * Perform an insert operation
343
+ * @returns {Promise<this>}
344
+ */
345
+ async performInsert() {
346
+ if (this.constructor.timestamps) {
347
+ const now = new Date();
348
+ this.setAttribute('created_at', now);
349
+ this.setAttribute('updated_at', now);
350
+ }
351
+
352
+ const data = this.attributes;
353
+ const result = await this.constructor.connection.insert(this.constructor.table, data);
354
+
355
+ this.setAttribute(this.constructor.primaryKey, result.insertId);
356
+ this.exists = true;
357
+ this.original = { ...this.attributes };
358
+
359
+ await this.touchParents();
360
+
361
+ return this;
362
+ }
363
+
364
+ /**
365
+ * Perform an update operation
366
+ * @returns {Promise<this>}
367
+ */
368
+ async performUpdate() {
369
+ if (this.constructor.timestamps) {
370
+ this.setAttribute('updated_at', new Date());
371
+ }
372
+
373
+ const dirty = this.getDirty();
374
+ if (Object.keys(dirty).length === 0) {
375
+ return this;
376
+ }
377
+
378
+ await this.constructor.connection.update(
379
+ this.constructor.table,
380
+ dirty,
381
+ { [this.constructor.primaryKey]: this.getAttribute(this.constructor.primaryKey) }
382
+ );
383
+
384
+ this.original = { ...this.attributes };
385
+
386
+ await this.touchParents();
387
+
388
+ return this;
389
+ }
390
+
391
+ /**
392
+ * Touch parent models for belongsTo relations with touches enabled
393
+ * @returns {Promise<void>}
394
+ */
395
+ async touchParents() {
396
+ for (const relation of this.touches) {
397
+ if (relation.touchesParent) {
398
+ const foreignKeyValue = this.getAttribute(relation.foreignKey);
399
+ if (foreignKeyValue) {
400
+ await this.constructor.connection.update(
401
+ relation.related.table,
402
+ { updated_at: new Date() },
403
+ { [relation.ownerKey]: foreignKeyValue }
404
+ );
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Delete the model
412
+ * @returns {Promise<boolean>}
413
+ */
414
+ async destroy() {
415
+ if (!this.exists) {
416
+ return false;
417
+ }
418
+
419
+ await this.constructor.connection.delete(
420
+ this.constructor.table,
421
+ { [this.constructor.primaryKey]: this.getAttribute(this.constructor.primaryKey) }
422
+ );
423
+
424
+ this.exists = false;
425
+ return true;
426
+ }
427
+
428
+ /**
429
+ * Get the attributes that have been changed
430
+ * @returns {Object}
431
+ */
432
+ getDirty() {
433
+ const dirty = {};
434
+ for (const [key, value] of Object.entries(this.attributes)) {
435
+ if (JSON.stringify(value) !== JSON.stringify(this.original[key])) {
436
+ dirty[key] = value;
437
+ }
438
+ }
439
+ return dirty;
440
+ }
441
+
442
+ /**
443
+ * Check if the model has been modified
444
+ * @returns {boolean}
445
+ */
446
+ isDirty() {
447
+ return Object.keys(this.getDirty()).length > 0;
448
+ }
449
+
450
+ /**
451
+ * Convert the model to JSON
452
+ * @returns {Object}
453
+ */
454
+ toJSON() {
455
+ const json = { ...this.attributes };
456
+
457
+ // Hide specified attributes
458
+ this.constructor.hidden.forEach(key => {
459
+ delete json[key];
460
+ });
461
+
462
+ // Add relations
463
+ Object.assign(json, this.relations);
464
+
465
+ return json;
466
+ }
467
+
468
+ /**
469
+ * Load one or multiple relations on this model instance.
470
+ * Supports dot-notation for nested relations (e.g., 'posts.comments').
471
+ * @param {...string|Array<string>} relations
472
+ * @returns {Promise<this>}
473
+ */
474
+ async load(...relations) {
475
+ const list = relations.length === 1 && Array.isArray(relations[0])
476
+ ? relations[0]
477
+ : relations;
478
+
479
+ for (const rel of list) {
480
+ if (typeof rel !== 'string' || !rel) continue;
481
+ await this._loadRelationPath(rel);
482
+ }
483
+ return this;
484
+ }
485
+
486
+ /**
487
+ * Internal: load a relation path with optional nesting (a.b.c)
488
+ * @param {string} path
489
+ * @private
490
+ */
491
+ async _loadRelationPath(path) {
492
+ const segments = path.split('.');
493
+ const head = segments[0];
494
+ const tail = segments.slice(1).join('.');
495
+
496
+ const relationFn = this[head];
497
+ if (typeof relationFn !== 'function') return;
498
+
499
+ const relation = relationFn.call(this);
500
+ if (!relation || typeof relation.get !== 'function') return;
501
+
502
+ const value = await relation.get();
503
+ this.relations[head] = value;
504
+
505
+ if (tail) {
506
+ if (Array.isArray(value)) {
507
+ await Promise.all(
508
+ value.map(v => (v && typeof v.load === 'function') ? v.load(tail) : null)
509
+ );
510
+ } else if (value && typeof value.load === 'function') {
511
+ await value.load(tail);
512
+ }
513
+ }
514
+ }
515
+
516
+ // ==================== Relationships ====================
517
+
518
+ /**
519
+ * Define a one-to-one relationship
520
+ * @param {typeof Model} related
521
+ * @param {string} foreignKey
522
+ * @param {string} localKey
523
+ * @returns {HasOneRelation}
524
+ */
525
+ hasOne(related, foreignKey, localKey) {
526
+ const HasOneRelation = require('./Relations/HasOneRelation');
527
+ localKey = localKey || this.constructor.primaryKey;
528
+ foreignKey = foreignKey || `${this.constructor.table.slice(0, -1)}_id`;
529
+
530
+ return new HasOneRelation(this, related, foreignKey, localKey);
531
+ }
532
+
533
+ /**
534
+ * Define a one-to-many relationship
535
+ * @param {typeof Model} related
536
+ * @param {string} foreignKey
537
+ * @param {string} localKey
538
+ * @returns {HasManyRelation}
539
+ */
540
+ hasMany(related, foreignKey, localKey) {
541
+ const HasManyRelation = require('./Relations/HasManyRelation');
542
+ localKey = localKey || this.constructor.primaryKey;
543
+ foreignKey = foreignKey || `${this.constructor.table.slice(0, -1)}_id`;
544
+
545
+ return new HasManyRelation(this, related, foreignKey, localKey);
546
+ }
547
+
548
+ /**
549
+ * Define an inverse one-to-one or many relationship
550
+ * @param {typeof Model} related
551
+ * @param {string} foreignKey
552
+ * @param {string} ownerKey
553
+ * @returns {BelongsToRelation}
554
+ */
555
+ belongsTo(related, foreignKey, ownerKey) {
556
+ const BelongsToRelation = require('./Relations/BelongsToRelation');
557
+ ownerKey = ownerKey || related.primaryKey;
558
+ foreignKey = foreignKey || `${related.table.slice(0, -1)}_id`;
559
+
560
+ return new BelongsToRelation(this, related, foreignKey, ownerKey);
561
+ }
562
+
563
+ /**
564
+ * Define a many-to-many relationship
565
+ * @param {typeof Model} related
566
+ * @param {string} pivot
567
+ * @param {string} foreignPivotKey
568
+ * @param {string} relatedPivotKey
569
+ * @param {string} parentKey
570
+ * @param {string} relatedKey
571
+ * @returns {BelongsToManyRelation}
572
+ */
573
+ belongsToMany(related, pivot, foreignPivotKey, relatedPivotKey, parentKey, relatedKey) {
574
+ const BelongsToManyRelation = require('./Relations/BelongsToManyRelation');
575
+ return new BelongsToManyRelation(
576
+ this, related, pivot, foreignPivotKey, relatedPivotKey, parentKey, relatedKey
577
+ );
578
+ }
579
+
580
+ /**
581
+ * Define a has-many-through relationship
582
+ * @param {typeof Model} relatedFinal
583
+ * @param {typeof Model} through
584
+ * @param {string} [foreignKeyOnThrough]
585
+ * @param {string} [throughKeyOnFinal]
586
+ * @param {string} [localKey]
587
+ * @param {string} [throughLocalKey]
588
+ * @returns {HasManyThroughRelation}
589
+ */
590
+ hasManyThrough(relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey) {
591
+ const HasManyThroughRelation = require('./Relations/HasManyThroughRelation');
592
+ return new HasManyThroughRelation(
593
+ this, relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey
594
+ );
595
+ }
596
+
597
+ /**
598
+ * Define a has-one-through relationship
599
+ * @param {typeof Model} relatedFinal
600
+ * @param {typeof Model} through
601
+ * @param {string} [foreignKeyOnThrough]
602
+ * @param {string} [throughKeyOnFinal]
603
+ * @param {string} [localKey]
604
+ * @param {string} [throughLocalKey]
605
+ * @returns {HasOneThroughRelation}
606
+ */
607
+ hasOneThrough(relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey) {
608
+ const HasOneThroughRelation = require('./Relations/HasOneThroughRelation');
609
+ return new HasOneThroughRelation(
610
+ this, relatedFinal, through, foreignKeyOnThrough, throughKeyOnFinal, localKey, throughLocalKey
611
+ );
612
+ }
613
+
614
+ /**
615
+ * Define a polymorphic inverse relationship
616
+ * @param {string} name
617
+ * @param {string} [typeColumn]
618
+ * @param {string} [idColumn]
619
+ * @returns {MorphToRelation}
620
+ */
621
+ morphTo(name, typeColumn, idColumn) {
622
+ const MorphToRelation = require('./Relations/MorphToRelation');
623
+ return new MorphToRelation(this, name, typeColumn, idColumn);
624
+ }
625
+
626
+ /**
627
+ * Define a polymorphic one-to-one relationship
628
+ * @param {typeof Model} related
629
+ * @param {string} morphType
630
+ * @param {string} [foreignKey]
631
+ * @param {string} [localKey]
632
+ * @returns {MorphOneRelation}
633
+ */
634
+ morphOne(related, morphType, foreignKey, localKey) {
635
+ const MorphOneRelation = require('./Relations/MorphOneRelation');
636
+ localKey = localKey || this.constructor.primaryKey;
637
+ foreignKey = foreignKey || `${morphType}_id`;
638
+
639
+ return new MorphOneRelation(this, related, morphType, foreignKey, localKey);
640
+ }
641
+
642
+ /**
643
+ * Define a polymorphic one-to-many relationship
644
+ * @param {typeof Model} related
645
+ * @param {string} morphType
646
+ * @param {string} [foreignKey]
647
+ * @param {string} [localKey]
648
+ * @returns {MorphManyRelation}
649
+ */
650
+ morphMany(related, morphType, foreignKey, localKey) {
651
+ const MorphManyRelation = require('./Relations/MorphManyRelation');
652
+ localKey = localKey || this.constructor.primaryKey;
653
+ foreignKey = foreignKey || `${morphType}_id`;
654
+
655
+ return new MorphManyRelation(this, related, morphType, foreignKey, localKey);
656
+ }
657
+ }
658
+
659
+ module.exports = Model;