outlet-orm 2.5.1 → 3.2.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 CHANGED
@@ -12,6 +12,30 @@ class Model {
12
12
  static casts = {};
13
13
  static connection = null;
14
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
+ updating: [],
28
+ saving: [],
29
+ saved: [],
30
+ deleting: [],
31
+ deleted: [],
32
+ restoring: [],
33
+ restored: []
34
+ };
35
+
36
+ // Validation rules
37
+ static rules = {};
38
+
15
39
  /**
16
40
  * Ensure a default database connection exists.
17
41
  * If none is set, it will be initialized from environment (.env) lazily.
@@ -24,6 +48,15 @@ class Model {
24
48
  }
25
49
  }
26
50
 
51
+ /**
52
+ * Get the current database connection
53
+ * @returns {DatabaseConnection}
54
+ */
55
+ static getConnection() {
56
+ this.ensureConnection();
57
+ return this.connection;
58
+ }
59
+
27
60
  /**
28
61
  * Set the default database connection for all models
29
62
  * @param {DatabaseConnection} connection
@@ -49,9 +82,369 @@ class Model {
49
82
  this.touches = [];
50
83
  this.exists = false;
51
84
  this._showHidden = false;
85
+ this._withTrashed = false;
86
+ this._onlyTrashed = false;
52
87
  this.fill(attributes);
53
88
  }
54
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
+
55
448
  // ==================== Query Builder ====================
56
449
 
57
450
  /**
@@ -354,10 +747,21 @@ class Model {
354
747
  * @returns {Promise<this>}
355
748
  */
356
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;
357
755
  if (this.exists) {
358
- return this.performUpdate();
756
+ result = await this.performUpdate();
757
+ } else {
758
+ result = await this.performInsert();
359
759
  }
360
- return this.performInsert();
760
+
761
+ // Fire saved event
762
+ await this.constructor.fireEvent('saved', this);
763
+
764
+ return result;
361
765
  }
362
766
 
363
767
  /**
@@ -365,6 +769,10 @@ class Model {
365
769
  * @returns {Promise<this>}
366
770
  */
367
771
  async performInsert() {
772
+ // Fire creating event
773
+ const shouldContinue = await this.constructor.fireEvent('creating', this);
774
+ if (!shouldContinue) return this;
775
+
368
776
  if (this.constructor.timestamps) {
369
777
  const now = new Date();
370
778
  this.setAttribute('created_at', now);
@@ -380,6 +788,9 @@ class Model {
380
788
 
381
789
  await this.touchParents();
382
790
 
791
+ // Fire created event
792
+ await this.constructor.fireEvent('created', this);
793
+
383
794
  return this;
384
795
  }
385
796
 
@@ -388,6 +799,10 @@ class Model {
388
799
  * @returns {Promise<this>}
389
800
  */
390
801
  async performUpdate() {
802
+ // Fire updating event
803
+ const shouldContinue = await this.constructor.fireEvent('updating', this);
804
+ if (!shouldContinue) return this;
805
+
391
806
  if (this.constructor.timestamps) {
392
807
  this.setAttribute('updated_at', new Date());
393
808
  }
@@ -400,13 +815,16 @@ class Model {
400
815
  await this.constructor.connection.update(
401
816
  this.constructor.table,
402
817
  dirty,
403
- { [this.constructor.primaryKey]: this.getAttribute(this.constructor.primaryKey) }
818
+ { wheres: [{ type: 'basic', column: this.constructor.primaryKey, operator: '=', value: this.getAttribute(this.constructor.primaryKey) }] }
404
819
  );
405
820
 
406
821
  this.original = { ...this.attributes };
407
822
 
408
823
  await this.touchParents();
409
824
 
825
+ // Fire updated event
826
+ await this.constructor.fireEvent('updated', this);
827
+
410
828
  return this;
411
829
  }
412
830
 
@@ -422,7 +840,7 @@ class Model {
422
840
  await this.constructor.connection.update(
423
841
  relation.related.table,
424
842
  { updated_at: new Date() },
425
- { [relation.ownerKey]: foreignKeyValue }
843
+ { wheres: [{ type: 'basic', column: relation.ownerKey, operator: '=', value: foreignKeyValue }] }
426
844
  );
427
845
  }
428
846
  }
@@ -430,7 +848,7 @@ class Model {
430
848
  }
431
849
 
432
850
  /**
433
- * Delete the model
851
+ * Delete the model (soft delete if enabled)
434
852
  * @returns {Promise<boolean>}
435
853
  */
436
854
  async destroy() {
@@ -438,12 +856,29 @@ class Model {
438
856
  return false;
439
857
  }
440
858
 
441
- await this.constructor.connection.delete(
442
- this.constructor.table,
443
- { [this.constructor.primaryKey]: this.getAttribute(this.constructor.primaryKey) }
444
- );
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);
445
881
 
446
- this.exists = false;
447
882
  return true;
448
883
  }
449
884
 
@@ -16,6 +16,76 @@ class QueryBuilder {
16
16
  this.groupBys = [];
17
17
  this.havings = [];
18
18
  this._showHidden = false;
19
+ this._withTrashed = false;
20
+ this._onlyTrashed = false;
21
+ this._excludedScopes = [];
22
+ this._excludeAllScopes = false;
23
+ }
24
+
25
+ /**
26
+ * Apply global scopes to the query
27
+ * @private
28
+ */
29
+ _applyGlobalScopes() {
30
+ if (this._excludeAllScopes) return;
31
+
32
+ const scopes = this.model.globalScopes || {};
33
+ for (const [name, scopeFn] of Object.entries(scopes)) {
34
+ if (!this._excludedScopes.includes(name)) {
35
+ scopeFn(this);
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Apply soft delete constraints
42
+ * @private
43
+ */
44
+ _applySoftDeleteConstraints() {
45
+ if (!this.model.softDeletes) return;
46
+
47
+ if (this._onlyTrashed) {
48
+ this.whereNotNull(this.model.DELETED_AT);
49
+ } else if (!this._withTrashed) {
50
+ this.whereNull(this.model.DELETED_AT);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Include soft deleted records
56
+ * @returns {this}
57
+ */
58
+ withTrashed() {
59
+ this._withTrashed = true;
60
+ return this;
61
+ }
62
+
63
+ /**
64
+ * Only get soft deleted records
65
+ * @returns {this}
66
+ */
67
+ onlyTrashed() {
68
+ this._onlyTrashed = true;
69
+ return this;
70
+ }
71
+
72
+ /**
73
+ * Query without a specific global scope
74
+ * @param {string} name
75
+ * @returns {this}
76
+ */
77
+ withoutGlobalScope(name) {
78
+ this._excludedScopes.push(name);
79
+ return this;
80
+ }
81
+
82
+ /**
83
+ * Query without all global scopes
84
+ * @returns {this}
85
+ */
86
+ withoutGlobalScopes() {
87
+ this._excludeAllScopes = true;
88
+ return this;
19
89
  }
20
90
 
21
91
  /**
@@ -441,6 +511,10 @@ class QueryBuilder {
441
511
  * @returns {Promise<Array>}
442
512
  */
443
513
  async get() {
514
+ // Apply global scopes and soft delete constraints
515
+ this._applyGlobalScopes();
516
+ this._applySoftDeleteConstraints();
517
+
444
518
  const rows = await this.model.connection.select(
445
519
  this.model.table,
446
520
  this.buildQuery()
@@ -486,6 +560,10 @@ class QueryBuilder {
486
560
  async paginate(page = 1, perPage = 15) {
487
561
  const offset = (page - 1) * perPage;
488
562
 
563
+ // Apply scopes for count
564
+ this._applyGlobalScopes();
565
+ this._applySoftDeleteConstraints();
566
+
489
567
  const total = await this.count();
490
568
  const data = await this.offset(offset).limit(perPage).get();
491
569
 
@@ -505,6 +583,10 @@ class QueryBuilder {
505
583
  * @returns {Promise<number>}
506
584
  */
507
585
  async count() {
586
+ // Apply scopes for count
587
+ this._applyGlobalScopes();
588
+ this._applySoftDeleteConstraints();
589
+
508
590
  const result = await this.model.connection.count(
509
591
  this.model.table,
510
592
  this.buildQuery()
package/src/index.js CHANGED
@@ -9,6 +9,10 @@ const HasManyRelation = require('./Relations/HasManyRelation');
9
9
  const BelongsToRelation = require('./Relations/BelongsToRelation');
10
10
  const BelongsToManyRelation = require('./Relations/BelongsToManyRelation');
11
11
  const HasManyThroughRelation = require('./Relations/HasManyThroughRelation');
12
+ const HasOneThroughRelation = require('./Relations/HasOneThroughRelation');
13
+ const MorphOneRelation = require('./Relations/MorphOneRelation');
14
+ const MorphManyRelation = require('./Relations/MorphManyRelation');
15
+ const MorphToRelation = require('./Relations/MorphToRelation');
12
16
 
13
17
  module.exports = {
14
18
  Model,
@@ -19,5 +23,9 @@ module.exports = {
19
23
  HasManyRelation,
20
24
  BelongsToRelation,
21
25
  BelongsToManyRelation,
22
- HasManyThroughRelation
26
+ HasManyThroughRelation,
27
+ HasOneThroughRelation,
28
+ MorphOneRelation,
29
+ MorphManyRelation,
30
+ MorphToRelation
23
31
  };