masterrecord 0.3.26 → 0.3.29
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/.claude/settings.local.json +21 -1
- package/Entity/entityModel.js +136 -0
- package/Entity/entityModelBuilder.js +21 -3
- package/Entity/entityTrackerModel.js +251 -1
- package/QueryLanguage/queryMethods.js +330 -4
- package/SQLLiteEngine.js +4 -0
- package/Tools.js +15 -2
- package/context.js +198 -5
- package/mySQLEngine.js +11 -1
- package/package.json +1 -1
- package/postgresEngine.js +6 -1
- package/readme.md +1070 -102
- package/test/bulk-operations-test.js +235 -0
- package/test/cache-toObject-test.js +105 -0
- package/test/debug-id-test.js +63 -0
- package/test/double-where-bug-test.js +71 -0
- package/test/entity-methods-test.js +269 -0
- package/test/id-setting-validation.js +202 -0
- package/test/insert-return-test.js +39 -0
- package/test/lifecycle-hooks-test.js +258 -0
- package/test/query-helpers-test.js +258 -0
- package/test/query-isolation-test.js +59 -0
- package/test/simple-id-test.js +61 -0
- package/test/single-user-id-test.js +70 -0
- package/test/validation-test.js +302 -0
package/readme.md
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
🔹 **Multi-Database Support** - MySQL, PostgreSQL, SQLite with consistent API
|
|
11
11
|
🔹 **Code-First Design** - Define entities in JavaScript, generate schema automatically
|
|
12
12
|
🔹 **Fluent Query API** - Lambda-based queries with parameterized placeholders
|
|
13
|
+
🔹 **Active Record Pattern** - Entities with `.save()`, `.delete()`, `.reload()` methods
|
|
14
|
+
🔹 **Entity Serialization** - `.toObject()` and `.toJSON()` with circular reference protection
|
|
15
|
+
🔹 **Lifecycle Hooks** - `beforeSave`, `afterSave`, `beforeDelete`, `afterDelete` hooks
|
|
16
|
+
🔹 **Business Validation** - Built-in validators (required, email, length, pattern, custom)
|
|
17
|
+
🔹 **Bulk Operations** - Efficient `bulkCreate`, `bulkUpdate`, `bulkDelete` APIs
|
|
13
18
|
🔹 **Query Result Caching** - Production-grade in-memory and Redis caching with automatic invalidation
|
|
14
19
|
🔹 **Migration System** - CLI-driven migrations with rollback support
|
|
15
20
|
🔹 **SQL Injection Protection** - Automatic parameterized queries throughout
|
|
@@ -33,6 +38,24 @@
|
|
|
33
38
|
- [Database Configuration](#database-configuration)
|
|
34
39
|
- [Entity Definitions](#entity-definitions)
|
|
35
40
|
- [Querying](#querying)
|
|
41
|
+
- [Entity Serialization](#entity-serialization)
|
|
42
|
+
- [.toObject()](#toobjectoptions)
|
|
43
|
+
- [.toJSON()](#tojson)
|
|
44
|
+
- [Entity Instance Methods](#entity-instance-methods)
|
|
45
|
+
- [.delete()](#delete)
|
|
46
|
+
- [.reload()](#reload)
|
|
47
|
+
- [.clone()](#clone)
|
|
48
|
+
- [Query Helper Methods](#query-helper-methods)
|
|
49
|
+
- [.first()](#first)
|
|
50
|
+
- [.last()](#last)
|
|
51
|
+
- [.exists()](#exists)
|
|
52
|
+
- [.pluck()](#pluckfieldname)
|
|
53
|
+
- [Lifecycle Hooks](#lifecycle-hooks)
|
|
54
|
+
- [Business Logic Validation](#business-logic-validation)
|
|
55
|
+
- [Bulk Operations API](#bulk-operations-api)
|
|
56
|
+
- [bulkCreate()](#bulkcreateentityname-data)
|
|
57
|
+
- [bulkUpdate()](#bulkupdateentityname-updates)
|
|
58
|
+
- [bulkDelete()](#bulkdeleteentityname-ids)
|
|
36
59
|
- [Migrations](#migrations)
|
|
37
60
|
- [Advanced Features](#advanced-features)
|
|
38
61
|
- [Query Result Caching](#query-result-caching)
|
|
@@ -45,6 +68,113 @@
|
|
|
45
68
|
- [Examples](#examples)
|
|
46
69
|
- [Performance Tips](#performance-tips)
|
|
47
70
|
- [Security](#security)
|
|
71
|
+
- [Best Practices](#best-practices-critical)
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## ⚠️ Best Practices (CRITICAL)
|
|
76
|
+
|
|
77
|
+
### 1. Creating Entity Instances
|
|
78
|
+
|
|
79
|
+
**ALWAYS** use `context.Entity.new()` to create new entity instances:
|
|
80
|
+
|
|
81
|
+
```javascript
|
|
82
|
+
// ✅ CORRECT - Creates proper data instance with getters/setters
|
|
83
|
+
const task = this._qaContext.QaTask.new();
|
|
84
|
+
const annotation = this._qaContext.QaAnnotation.new();
|
|
85
|
+
const project = this._qaContext.QaProject.new();
|
|
86
|
+
|
|
87
|
+
task.name = "My Task";
|
|
88
|
+
task.status = "active";
|
|
89
|
+
await db.saveChanges(); // ✅ Saves correctly
|
|
90
|
+
|
|
91
|
+
// ❌ WRONG - Creates schema definition object with function properties
|
|
92
|
+
const task = new QaTask(); // task.name is a FUNCTION, not a property!
|
|
93
|
+
|
|
94
|
+
task.name = "My Task"; // ❌ Doesn't work - name is a function
|
|
95
|
+
await db.saveChanges(); // ❌ Error: "Type mismatch: Expected string, got function"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Why?**
|
|
99
|
+
- `new Entity()` creates a **schema definition object** where properties are methods that define the schema
|
|
100
|
+
- `context.Entity.new()` creates a **data instance** with proper getters/setters for storing values
|
|
101
|
+
- Using `new Entity()` causes runtime errors: `"Type mismatch for Entity.field: Expected integer, got function with value undefined"`
|
|
102
|
+
|
|
103
|
+
**Error Example:**
|
|
104
|
+
```
|
|
105
|
+
Error: INSERT failed: Type mismatch for QaTask.name: Expected string, got function with value undefined
|
|
106
|
+
at SQLLiteEngine._buildSQLInsertObjectParameterized
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**This error means:** You used `new Entity()` instead of `context.Entity.new()`
|
|
110
|
+
|
|
111
|
+
### 2. Saving Changes - ALWAYS use `await`
|
|
112
|
+
|
|
113
|
+
**ALWAYS** use `await` when calling `saveChanges()`:
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
// ✅ CORRECT - Waits for database write to complete
|
|
117
|
+
await this._qaContext.saveChanges();
|
|
118
|
+
|
|
119
|
+
// ❌ WRONG - Returns immediately without waiting for database write
|
|
120
|
+
this._qaContext.saveChanges(); // Promise never completes!
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Why?**
|
|
124
|
+
- `saveChanges()` is **async** and returns a Promise
|
|
125
|
+
- Without `await`, code continues before database write completes
|
|
126
|
+
- Causes **data loss** - appears successful but nothing saves to database
|
|
127
|
+
- Results in "phantom saves" - data in memory but not persisted
|
|
128
|
+
|
|
129
|
+
**Symptoms of missing `await`:**
|
|
130
|
+
- API returns success but data not in database
|
|
131
|
+
- Queries after save return old/missing data
|
|
132
|
+
- Intermittent save failures
|
|
133
|
+
- Race conditions
|
|
134
|
+
|
|
135
|
+
**Repository Pattern - Make Methods Async:**
|
|
136
|
+
```javascript
|
|
137
|
+
// ✅ CORRECT - Async method with await
|
|
138
|
+
async create(entity) {
|
|
139
|
+
this._qaContext.Entity.add(entity);
|
|
140
|
+
await this._qaContext.saveChanges();
|
|
141
|
+
return entity;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ❌ WRONG - Synchronous method calling async saveChanges
|
|
145
|
+
create(entity) {
|
|
146
|
+
this._qaContext.Entity.add(entity);
|
|
147
|
+
this._qaContext.saveChanges(); // No await - returns before save completes!
|
|
148
|
+
return entity; // Returns entity with undefined ID
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### 3. Quick Reference Card
|
|
153
|
+
|
|
154
|
+
```javascript
|
|
155
|
+
// Entity Creation
|
|
156
|
+
✅ const user = db.User.new(); // CORRECT
|
|
157
|
+
❌ const user = new User(); // WRONG - creates schema object
|
|
158
|
+
|
|
159
|
+
// Saving Data
|
|
160
|
+
✅ await db.saveChanges(); // CORRECT - waits for completion
|
|
161
|
+
❌ db.saveChanges(); // WRONG - fire and forget
|
|
162
|
+
|
|
163
|
+
// Repository Methods
|
|
164
|
+
✅ async create(entity) { // CORRECT - async method
|
|
165
|
+
await db.saveChanges();
|
|
166
|
+
}
|
|
167
|
+
❌ create(entity) { // WRONG - sync method
|
|
168
|
+
db.saveChanges(); // No await!
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Querying (all require await)
|
|
172
|
+
✅ const users = await db.User.toList(); // CORRECT
|
|
173
|
+
✅ const user = await db.User.findById(1); // CORRECT
|
|
174
|
+
❌ const users = db.User.toList(); // WRONG - returns Promise
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
48
178
|
|
|
49
179
|
## Installation
|
|
50
180
|
|
|
@@ -416,7 +546,7 @@ const user = db.User.new();
|
|
|
416
546
|
user.tags = ['admin', 'moderator']; // Assign array
|
|
417
547
|
await db.saveChanges(); // Stored as '["admin","moderator"]'
|
|
418
548
|
|
|
419
|
-
const loaded = db.User.findById(user.id);
|
|
549
|
+
const loaded = await db.User.findById(user.id);
|
|
420
550
|
console.log(loaded.tags); // ['admin', 'moderator'] - JavaScript array!
|
|
421
551
|
```
|
|
422
552
|
|
|
@@ -425,19 +555,19 @@ console.log(loaded.tags); // ['admin', 'moderator'] - JavaScript array!
|
|
|
425
555
|
### Basic Queries
|
|
426
556
|
|
|
427
557
|
```javascript
|
|
428
|
-
// Find all
|
|
429
|
-
const users = db.User.toList();
|
|
558
|
+
// Find all (requires await)
|
|
559
|
+
const users = await db.User.toList();
|
|
430
560
|
|
|
431
|
-
// Find by primary key
|
|
432
|
-
const user = db.User.findById(123);
|
|
561
|
+
// Find by primary key (requires await)
|
|
562
|
+
const user = await db.User.findById(123);
|
|
433
563
|
|
|
434
|
-
// Find single with where clause
|
|
435
|
-
const alice = db.User
|
|
564
|
+
// Find single with where clause (requires await)
|
|
565
|
+
const alice = await db.User
|
|
436
566
|
.where(u => u.email == $$, 'alice@example.com')
|
|
437
567
|
.single();
|
|
438
568
|
|
|
439
|
-
// Find multiple with conditions
|
|
440
|
-
const adults = db.User
|
|
569
|
+
// Find multiple with conditions (requires await)
|
|
570
|
+
const adults = await db.User
|
|
441
571
|
.where(u => u.age >= $$, 18)
|
|
442
572
|
.toList();
|
|
443
573
|
```
|
|
@@ -447,16 +577,16 @@ const adults = db.User
|
|
|
447
577
|
**Always use `$$` placeholders** for SQL injection protection:
|
|
448
578
|
|
|
449
579
|
```javascript
|
|
450
|
-
// Single parameter
|
|
451
|
-
const user = db.User.where(u => u.id == $$, 123).single();
|
|
580
|
+
// Single parameter (requires await)
|
|
581
|
+
const user = await db.User.where(u => u.id == $$, 123).single();
|
|
452
582
|
|
|
453
|
-
// Multiple parameters
|
|
454
|
-
const results = db.User
|
|
583
|
+
// Multiple parameters (requires await)
|
|
584
|
+
const results = await db.User
|
|
455
585
|
.where(u => u.age > $$ && u.status == $$, 25, 'active')
|
|
456
586
|
.toList();
|
|
457
587
|
|
|
458
|
-
// Single $ for OR conditions
|
|
459
|
-
const results = db.User
|
|
588
|
+
// Single $ for OR conditions (requires await)
|
|
589
|
+
const results = await db.User
|
|
460
590
|
.where(u => u.status == $ || u.status == null, 'active')
|
|
461
591
|
.toList();
|
|
462
592
|
```
|
|
@@ -464,22 +594,22 @@ const results = db.User
|
|
|
464
594
|
### IN Clauses
|
|
465
595
|
|
|
466
596
|
```javascript
|
|
467
|
-
// Array parameter with .includes()
|
|
597
|
+
// Array parameter with .includes() (requires await)
|
|
468
598
|
const ids = [1, 2, 3, 4, 5];
|
|
469
|
-
const users = db.User
|
|
599
|
+
const users = await db.User
|
|
470
600
|
.where(u => $$.includes(u.id), ids)
|
|
471
601
|
.toList();
|
|
472
602
|
|
|
473
603
|
// Generated SQL: WHERE id IN ($1, $2, $3, $4, $5)
|
|
474
604
|
// PostgreSQL parameters: [1, 2, 3, 4, 5]
|
|
475
605
|
|
|
476
|
-
// Alternative .any() syntax
|
|
477
|
-
const users = db.User
|
|
606
|
+
// Alternative .any() syntax (requires await)
|
|
607
|
+
const users = await db.User
|
|
478
608
|
.where(u => u.id.any($$), [1, 2, 3])
|
|
479
609
|
.toList();
|
|
480
610
|
|
|
481
|
-
// Comma-separated strings (auto-splits)
|
|
482
|
-
const users = db.User
|
|
611
|
+
// Comma-separated strings (auto-splits) (requires await)
|
|
612
|
+
const users = await db.User
|
|
483
613
|
.where(u => u.id.any($$), "1,2,3,4,5")
|
|
484
614
|
.toList();
|
|
485
615
|
```
|
|
@@ -498,8 +628,8 @@ if (minAge) {
|
|
|
498
628
|
query = query.where(u => u.age >= $$, minAge);
|
|
499
629
|
}
|
|
500
630
|
|
|
501
|
-
// Add sorting and pagination
|
|
502
|
-
const users = query
|
|
631
|
+
// Add sorting and pagination (requires await)
|
|
632
|
+
const users = await query
|
|
503
633
|
.orderBy(u => u.created_at)
|
|
504
634
|
.skip(offset)
|
|
505
635
|
.take(limit)
|
|
@@ -509,13 +639,13 @@ const users = query
|
|
|
509
639
|
### Ordering
|
|
510
640
|
|
|
511
641
|
```javascript
|
|
512
|
-
// Ascending
|
|
513
|
-
const users = db.User
|
|
642
|
+
// Ascending (requires await)
|
|
643
|
+
const users = await db.User
|
|
514
644
|
.orderBy(u => u.name)
|
|
515
645
|
.toList();
|
|
516
646
|
|
|
517
|
-
// Descending
|
|
518
|
-
const users = db.User
|
|
647
|
+
// Descending (requires await)
|
|
648
|
+
const users = await db.User
|
|
519
649
|
.orderByDescending(u => u.created_at)
|
|
520
650
|
.toList();
|
|
521
651
|
```
|
|
@@ -523,17 +653,17 @@ const users = db.User
|
|
|
523
653
|
### Pagination
|
|
524
654
|
|
|
525
655
|
```javascript
|
|
526
|
-
// Skip 20, take 10
|
|
527
|
-
const users = db.User
|
|
656
|
+
// Skip 20, take 10 (requires await)
|
|
657
|
+
const users = await db.User
|
|
528
658
|
.orderBy(u => u.id)
|
|
529
659
|
.skip(20)
|
|
530
660
|
.take(10)
|
|
531
661
|
.toList();
|
|
532
662
|
|
|
533
|
-
// Page-based pagination
|
|
663
|
+
// Page-based pagination (requires await)
|
|
534
664
|
const page = 2;
|
|
535
665
|
const pageSize = 10;
|
|
536
|
-
const users = db.User
|
|
666
|
+
const users = await db.User
|
|
537
667
|
.skip(page * pageSize)
|
|
538
668
|
.take(pageSize)
|
|
539
669
|
.toList();
|
|
@@ -542,11 +672,11 @@ const users = db.User
|
|
|
542
672
|
### Counting
|
|
543
673
|
|
|
544
674
|
```javascript
|
|
545
|
-
// Count all
|
|
546
|
-
const total = db.User.count();
|
|
675
|
+
// Count all (requires await)
|
|
676
|
+
const total = await db.User.count();
|
|
547
677
|
|
|
548
|
-
// Count with conditions
|
|
549
|
-
const activeCount = db.User
|
|
678
|
+
// Count with conditions (requires await)
|
|
679
|
+
const activeCount = await db.User
|
|
550
680
|
.where(u => u.status == $$, 'active')
|
|
551
681
|
.count();
|
|
552
682
|
```
|
|
@@ -554,19 +684,19 @@ const activeCount = db.User
|
|
|
554
684
|
### Complex Queries
|
|
555
685
|
|
|
556
686
|
```javascript
|
|
557
|
-
// Multiple conditions with OR
|
|
558
|
-
const results = db.User
|
|
687
|
+
// Multiple conditions with OR (requires await)
|
|
688
|
+
const results = await db.User
|
|
559
689
|
.where(u => (u.status == 'active' || u.status == 'pending') && u.age >= $$, 18)
|
|
560
690
|
.orderBy(u => u.name)
|
|
561
691
|
.toList();
|
|
562
692
|
|
|
563
|
-
// Nullable checks
|
|
564
|
-
const usersWithoutEmail = db.User
|
|
693
|
+
// Nullable checks (requires await)
|
|
694
|
+
const usersWithoutEmail = await db.User
|
|
565
695
|
.where(u => u.email == null)
|
|
566
696
|
.toList();
|
|
567
697
|
|
|
568
|
-
// LIKE queries
|
|
569
|
-
const matching = db.User
|
|
698
|
+
// LIKE queries (requires await)
|
|
699
|
+
const matching = await db.User
|
|
570
700
|
.where(u => u.name.like($$), '%john%')
|
|
571
701
|
.toList();
|
|
572
702
|
```
|
|
@@ -838,13 +968,13 @@ const user = db.User.findById(1); // DB query
|
|
|
838
968
|
const user2 = db.User.findById(1); // DB query again (no cache)
|
|
839
969
|
|
|
840
970
|
// OPT-IN: Enable caching with .cache()
|
|
841
|
-
const categories = db.Categories.cache().toList(); // DB query, cached
|
|
842
|
-
const categories2 = db.Categories.cache().toList(); // Cache hit! (instant)
|
|
971
|
+
const categories = await db.Categories.cache().toList(); // DB query, cached
|
|
972
|
+
const categories2 = await db.Categories.cache().toList(); // Cache hit! (instant)
|
|
843
973
|
|
|
844
974
|
// Update invalidates cache automatically
|
|
845
|
-
const cat = db.Categories.findById(1);
|
|
975
|
+
const cat = await db.Categories.findById(1);
|
|
846
976
|
cat.name = "Updated";
|
|
847
|
-
db.saveChanges(); // Cache for Categories table cleared
|
|
977
|
+
await db.saveChanges(); // Cache for Categories table cleared
|
|
848
978
|
|
|
849
979
|
// End request (clears cache - like Active Record)
|
|
850
980
|
db.endRequest(); // Cache cleared for next request
|
|
@@ -865,9 +995,9 @@ app.use((req, res, next) => {
|
|
|
865
995
|
});
|
|
866
996
|
|
|
867
997
|
// In your routes
|
|
868
|
-
app.get('/categories', (req, res) => {
|
|
998
|
+
app.get('/categories', async (req, res) => {
|
|
869
999
|
// Cache is fresh for this request
|
|
870
|
-
const categories = req.db.Categories.cache().toList();
|
|
1000
|
+
const categories = await req.db.Categories.cache().toList();
|
|
871
1001
|
res.json(categories);
|
|
872
1002
|
// Cache auto-cleared after response
|
|
873
1003
|
});
|
|
@@ -900,14 +1030,14 @@ Use `.cache()` for frequently accessed, rarely changed data:
|
|
|
900
1030
|
|
|
901
1031
|
```javascript
|
|
902
1032
|
// DEFAULT: Always hits database (safe)
|
|
903
|
-
const liveData = db.Analytics
|
|
1033
|
+
const liveData = await db.Analytics
|
|
904
1034
|
.where(a => a.date == $$, today)
|
|
905
1035
|
.toList(); // No caching (default)
|
|
906
1036
|
|
|
907
1037
|
// OPT-IN: Cache reference data
|
|
908
|
-
const categories = db.Categories.cache().toList(); // Cached for 5 seconds (default TTL)
|
|
909
|
-
const settings = db.Settings.cache().toList(); // Cached
|
|
910
|
-
const countries = db.Countries.cache().toList(); // Cached
|
|
1038
|
+
const categories = await db.Categories.cache().toList(); // Cached for 5 seconds (default TTL)
|
|
1039
|
+
const settings = await db.Settings.cache().toList(); // Cached
|
|
1040
|
+
const countries = await db.Countries.cache().toList(); // Cached
|
|
911
1041
|
|
|
912
1042
|
// When to use .cache():
|
|
913
1043
|
// ✅ Reference data (categories, settings, countries)
|
|
@@ -940,7 +1070,7 @@ db.clearQueryCache();
|
|
|
940
1070
|
|
|
941
1071
|
// Disable caching temporarily
|
|
942
1072
|
db.setQueryCacheEnabled(false);
|
|
943
|
-
const freshData = db.User.toList();
|
|
1073
|
+
const freshData = await db.User.toList();
|
|
944
1074
|
db.setQueryCacheEnabled(true);
|
|
945
1075
|
```
|
|
946
1076
|
|
|
@@ -983,21 +1113,21 @@ MasterRecord automatically invalidates cache entries when data changes:
|
|
|
983
1113
|
|
|
984
1114
|
```javascript
|
|
985
1115
|
// Query with caching enabled
|
|
986
|
-
const categories = db.Categories.cache().toList(); // DB query, cached
|
|
1116
|
+
const categories = await db.Categories.cache().toList(); // DB query, cached
|
|
987
1117
|
|
|
988
1118
|
// Any modification to Categories table invalidates ALL cached Category queries
|
|
989
|
-
const cat = db.Categories.findById(1);
|
|
1119
|
+
const cat = await db.Categories.findById(1);
|
|
990
1120
|
cat.name = "Updated";
|
|
991
|
-
db.saveChanges(); // Invalidates all cached Categories queries
|
|
1121
|
+
await db.saveChanges(); // Invalidates all cached Categories queries
|
|
992
1122
|
|
|
993
1123
|
// Next cached query hits database (fresh data)
|
|
994
|
-
const categoriesAgain = db.Categories.cache().toList(); // DB query (cache cleared)
|
|
1124
|
+
const categoriesAgain = await db.Categories.cache().toList(); // DB query (cache cleared)
|
|
995
1125
|
|
|
996
1126
|
// Non-cached queries are unaffected (always fresh)
|
|
997
|
-
const users = db.User.toList(); // No .cache() = always DB query
|
|
1127
|
+
const users = await db.User.toList(); // No .cache() = always DB query
|
|
998
1128
|
|
|
999
1129
|
// Queries for OTHER tables' caches are unaffected
|
|
1000
|
-
const settings = db.Settings.cache().toList(); // Still cached (different table)
|
|
1130
|
+
const settings = await db.Settings.cache().toList(); // Still cached (different table)
|
|
1001
1131
|
```
|
|
1002
1132
|
|
|
1003
1133
|
**Invalidation rules:**
|
|
@@ -1024,12 +1154,12 @@ Expected performance improvements:
|
|
|
1024
1154
|
**DO use .cache():**
|
|
1025
1155
|
```javascript
|
|
1026
1156
|
// Reference data (rarely changes)
|
|
1027
|
-
const categories = db.Categories.cache().toList();
|
|
1028
|
-
const settings = db.Settings.cache().toList();
|
|
1029
|
-
const countries = db.Countries.cache().toList();
|
|
1157
|
+
const categories = await db.Categories.cache().toList();
|
|
1158
|
+
const settings = await db.Settings.cache().toList();
|
|
1159
|
+
const countries = await db.Countries.cache().toList();
|
|
1030
1160
|
|
|
1031
1161
|
// Expensive aggregations (stable results)
|
|
1032
|
-
const totalRevenue = db.Orders
|
|
1162
|
+
const totalRevenue = await db.Orders
|
|
1033
1163
|
.where(o => o.year == $$, 2024)
|
|
1034
1164
|
.cache()
|
|
1035
1165
|
.count();
|
|
@@ -1038,20 +1168,20 @@ const totalRevenue = db.Orders
|
|
|
1038
1168
|
**DON'T use .cache():**
|
|
1039
1169
|
```javascript
|
|
1040
1170
|
// User-specific data (default is safe - no caching)
|
|
1041
|
-
const user = db.User.findById(userId); // Always fresh
|
|
1171
|
+
const user = await db.User.findById(userId); // Always fresh
|
|
1042
1172
|
|
|
1043
1173
|
// Real-time data (default is safe)
|
|
1044
|
-
const liveOrders = db.Orders
|
|
1174
|
+
const liveOrders = await db.Orders
|
|
1045
1175
|
.where(o => o.status == $$, 'pending')
|
|
1046
1176
|
.toList(); // Always fresh
|
|
1047
1177
|
|
|
1048
1178
|
// Financial transactions (default is safe)
|
|
1049
|
-
const balance = db.Transactions
|
|
1179
|
+
const balance = await db.Transactions
|
|
1050
1180
|
.where(t => t.user_id == $$, userId)
|
|
1051
1181
|
.toList(); // Always fresh
|
|
1052
1182
|
|
|
1053
1183
|
// User-specific sensitive data (default is safe)
|
|
1054
|
-
const permissions = db.UserPermissions
|
|
1184
|
+
const permissions = await db.UserPermissions
|
|
1055
1185
|
.where(p => p.user_id == $$, userId)
|
|
1056
1186
|
.toList(); // Always fresh
|
|
1057
1187
|
```
|
|
@@ -1089,12 +1219,12 @@ app.use((req, res, next) => {
|
|
|
1089
1219
|
});
|
|
1090
1220
|
|
|
1091
1221
|
// In routes - cache is fresh per request
|
|
1092
|
-
app.get('/api/categories', (req, res) => {
|
|
1222
|
+
app.get('/api/categories', async (req, res) => {
|
|
1093
1223
|
// First call in this request - DB query
|
|
1094
|
-
const categories = req.db.Categories.cache().toList();
|
|
1224
|
+
const categories = await req.db.Categories.cache().toList();
|
|
1095
1225
|
|
|
1096
1226
|
// Second call in same request - cache hit
|
|
1097
|
-
const categoriesAgain = req.db.Categories.cache().toList();
|
|
1227
|
+
const categoriesAgain = await req.db.Categories.cache().toList();
|
|
1098
1228
|
|
|
1099
1229
|
res.json(categories);
|
|
1100
1230
|
// After response, cache is automatically cleared
|
|
@@ -1118,18 +1248,18 @@ const db1 = new AppContext();
|
|
|
1118
1248
|
const db2 = new AppContext();
|
|
1119
1249
|
|
|
1120
1250
|
// Context 1: Cache data with .cache()
|
|
1121
|
-
const categories1 = db1.Categories.cache().toList(); // DB query, cached
|
|
1251
|
+
const categories1 = await db1.Categories.cache().toList(); // DB query, cached
|
|
1122
1252
|
|
|
1123
1253
|
// Context 2: Sees cached data
|
|
1124
|
-
const categories2 = db2.Categories.cache().toList(); // Cache hit!
|
|
1254
|
+
const categories2 = await db2.Categories.cache().toList(); // Cache hit!
|
|
1125
1255
|
|
|
1126
1256
|
// Context 2: Updates invalidate cache for BOTH contexts
|
|
1127
|
-
const cat = db2.Categories.findById(1);
|
|
1257
|
+
const cat = await db2.Categories.findById(1);
|
|
1128
1258
|
cat.name = "Updated";
|
|
1129
|
-
db2.saveChanges(); // Invalidates shared cache
|
|
1259
|
+
await db2.saveChanges(); // Invalidates shared cache
|
|
1130
1260
|
|
|
1131
1261
|
// Context 1: Sees fresh data
|
|
1132
|
-
const categories3 = db1.Categories.cache().toList(); // Cache miss, fresh data
|
|
1262
|
+
const categories3 = await db1.Categories.cache().toList(); // Cache miss, fresh data
|
|
1133
1263
|
console.log(categories3[0].name); // "Updated"
|
|
1134
1264
|
```
|
|
1135
1265
|
|
|
@@ -1168,8 +1298,9 @@ class AnalyticsContext extends context {
|
|
|
1168
1298
|
const userDb = new UserContext();
|
|
1169
1299
|
const analyticsDb = new AnalyticsContext();
|
|
1170
1300
|
|
|
1171
|
-
const user = userDb.User.findById(123);
|
|
1172
|
-
analyticsDb.Event.new()
|
|
1301
|
+
const user = await userDb.User.findById(123);
|
|
1302
|
+
const event = analyticsDb.Event.new();
|
|
1303
|
+
event.log('user_login', user.id);
|
|
1173
1304
|
await analyticsDb.saveChanges();
|
|
1174
1305
|
```
|
|
1175
1306
|
|
|
@@ -1232,7 +1363,7 @@ context.setQueryCacheEnabled(bool) // Enable/disable caching
|
|
|
1232
1363
|
### Query Methods
|
|
1233
1364
|
|
|
1234
1365
|
```javascript
|
|
1235
|
-
// Chainable query builders
|
|
1366
|
+
// Chainable query builders (do not execute query)
|
|
1236
1367
|
.where(query, ...params) // Add WHERE condition
|
|
1237
1368
|
.and(query, ...params) // Add AND condition
|
|
1238
1369
|
.orderBy(field) // Sort ascending
|
|
@@ -1242,21 +1373,737 @@ context.setQueryCacheEnabled(bool) // Enable/disable caching
|
|
|
1242
1373
|
.include(relationship) // Eager load
|
|
1243
1374
|
.cache() // Enable caching for this query (opt-in)
|
|
1244
1375
|
|
|
1245
|
-
// Terminal methods (execute query)
|
|
1246
|
-
.toList()
|
|
1247
|
-
.single()
|
|
1248
|
-
.first()
|
|
1249
|
-
.count()
|
|
1250
|
-
.any()
|
|
1376
|
+
// Terminal methods (execute query - ALL REQUIRE AWAIT)
|
|
1377
|
+
await .toList() // Return array of all records
|
|
1378
|
+
await .single() // Return one or null
|
|
1379
|
+
await .first() // Return first or null
|
|
1380
|
+
await .count() // Return count
|
|
1381
|
+
await .any() // Return boolean
|
|
1251
1382
|
|
|
1252
|
-
// Convenience methods
|
|
1253
|
-
.findById(id)
|
|
1254
|
-
.new() // Create new entity instance
|
|
1383
|
+
// Convenience methods (REQUIRE AWAIT)
|
|
1384
|
+
await .findById(id) // Find by primary key
|
|
1385
|
+
.new() // Create new entity instance (synchronous)
|
|
1255
1386
|
|
|
1256
|
-
// Entity methods (Active Record style)
|
|
1387
|
+
// Entity methods (Active Record style - REQUIRE AWAIT)
|
|
1257
1388
|
await entity.save() // Save this entity (and all tracked changes)
|
|
1389
|
+
await entity.delete() // Delete this entity
|
|
1390
|
+
await entity.reload() // Reload from database, discarding changes
|
|
1391
|
+
entity.clone() // Create a copy for duplication (synchronous)
|
|
1392
|
+
entity.toObject(options) // Convert to plain JavaScript object (synchronous)
|
|
1393
|
+
entity.toJSON() // JSON.stringify compatibility (synchronous)
|
|
1394
|
+
```
|
|
1395
|
+
|
|
1396
|
+
---
|
|
1397
|
+
|
|
1398
|
+
## Entity Serialization
|
|
1399
|
+
|
|
1400
|
+
### .toObject(options)
|
|
1401
|
+
|
|
1402
|
+
Convert a MasterRecord entity to a plain JavaScript object, removing all internal properties and handling circular references automatically.
|
|
1403
|
+
|
|
1404
|
+
**Parameters:**
|
|
1405
|
+
- `options.includeRelationships` (boolean, default: `true`) - Include related entities
|
|
1406
|
+
- `options.depth` (number, default: `1`) - Maximum depth for relationship traversal
|
|
1407
|
+
|
|
1408
|
+
**Examples:**
|
|
1409
|
+
|
|
1410
|
+
```javascript
|
|
1411
|
+
// Basic usage - get plain object
|
|
1412
|
+
const user = await db.User.findById(1);
|
|
1413
|
+
const plain = user.toObject();
|
|
1414
|
+
console.log(plain);
|
|
1415
|
+
// { id: 1, name: 'Alice', email: 'alice@example.com', age: 28 }
|
|
1416
|
+
|
|
1417
|
+
// Include relationships
|
|
1418
|
+
const userWithPosts = user.toObject({ includeRelationships: true });
|
|
1419
|
+
console.log(userWithPosts);
|
|
1420
|
+
// {
|
|
1421
|
+
// id: 1,
|
|
1422
|
+
// name: 'Alice',
|
|
1423
|
+
// Posts: [
|
|
1424
|
+
// { id: 10, title: 'First Post', content: '...' },
|
|
1425
|
+
// { id: 11, title: 'Second Post', content: '...' }
|
|
1426
|
+
// ]
|
|
1427
|
+
// }
|
|
1428
|
+
|
|
1429
|
+
// Control relationship depth
|
|
1430
|
+
const deep = user.toObject({ includeRelationships: true, depth: 3 });
|
|
1431
|
+
|
|
1432
|
+
// Exclude relationships
|
|
1433
|
+
const shallow = user.toObject({ includeRelationships: false });
|
|
1434
|
+
```
|
|
1435
|
+
|
|
1436
|
+
**Circular Reference Protection:**
|
|
1437
|
+
|
|
1438
|
+
`.toObject()` automatically prevents infinite loops from circular references:
|
|
1439
|
+
|
|
1440
|
+
```javascript
|
|
1441
|
+
// Scenario: User → Posts → User creates a cycle
|
|
1442
|
+
const user = await db.User.findById(1);
|
|
1443
|
+
await user.Posts; // Load posts relationship
|
|
1444
|
+
|
|
1445
|
+
const plain = user.toObject({ includeRelationships: true, depth: 2 });
|
|
1446
|
+
// Circular references marked as:
|
|
1447
|
+
// { __circular: true, __entityName: 'User', id: 1 }
|
|
1448
|
+
```
|
|
1449
|
+
|
|
1450
|
+
**Why It's Needed:**
|
|
1451
|
+
|
|
1452
|
+
MasterRecord entities have internal properties that cause `JSON.stringify()` to fail:
|
|
1453
|
+
|
|
1454
|
+
```javascript
|
|
1455
|
+
const user = await db.User.findById(1);
|
|
1456
|
+
|
|
1457
|
+
// ❌ FAILS: TypeError: Converting circular structure to JSON
|
|
1458
|
+
JSON.stringify(user);
|
|
1459
|
+
|
|
1460
|
+
// ✅ WORKS: Use toObject() or toJSON()
|
|
1461
|
+
const plain = user.toObject();
|
|
1462
|
+
JSON.stringify(plain); // Success!
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
### .toJSON()
|
|
1466
|
+
|
|
1467
|
+
Used automatically by `JSON.stringify()` and Express `res.json()`. Returns the same as `.toObject({ includeRelationships: false })`.
|
|
1468
|
+
|
|
1469
|
+
**Examples:**
|
|
1470
|
+
|
|
1471
|
+
```javascript
|
|
1472
|
+
// JSON.stringify automatically calls toJSON()
|
|
1473
|
+
const user = await db.User.findById(1);
|
|
1474
|
+
const json = JSON.stringify(user);
|
|
1475
|
+
console.log(json);
|
|
1476
|
+
// '{"id":1,"name":"Alice","email":"alice@example.com"}'
|
|
1477
|
+
|
|
1478
|
+
// Express automatically uses toJSON()
|
|
1479
|
+
app.get('/api/users/:id', async (req, res) => {
|
|
1480
|
+
const user = await db.User.findById(req.params.id);
|
|
1481
|
+
res.json(user); // ✅ Works automatically!
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
// Array of entities
|
|
1485
|
+
app.get('/api/users', async (req, res) => {
|
|
1486
|
+
const users = await db.User.toList();
|
|
1487
|
+
res.json(users); // ✅ Each entity's toJSON() called automatically
|
|
1488
|
+
});
|
|
1489
|
+
```
|
|
1490
|
+
|
|
1491
|
+
---
|
|
1492
|
+
|
|
1493
|
+
## Entity Instance Methods
|
|
1494
|
+
|
|
1495
|
+
### .delete()
|
|
1496
|
+
|
|
1497
|
+
Delete an entity without manually calling `context.remove()` and `context.saveChanges()`.
|
|
1498
|
+
|
|
1499
|
+
**Example:**
|
|
1500
|
+
|
|
1501
|
+
```javascript
|
|
1502
|
+
// Before
|
|
1503
|
+
const user = await db.User.findById(1);
|
|
1504
|
+
db.remove(user);
|
|
1505
|
+
await db.saveChanges();
|
|
1506
|
+
|
|
1507
|
+
// After (Active Record style)
|
|
1508
|
+
const user = await db.User.findById(1);
|
|
1509
|
+
await user.delete(); // ✅ Entity deletes itself
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
**Cascade Deletion:**
|
|
1513
|
+
|
|
1514
|
+
If your entity has cascade delete rules, they will be applied automatically:
|
|
1515
|
+
|
|
1516
|
+
```javascript
|
|
1517
|
+
class User {
|
|
1518
|
+
constructor() {
|
|
1519
|
+
this.id = { type: 'integer', primary: true, auto: true };
|
|
1520
|
+
|
|
1521
|
+
// Posts will be deleted when user is deleted
|
|
1522
|
+
this.Posts = {
|
|
1523
|
+
type: 'hasMany',
|
|
1524
|
+
model: 'Post',
|
|
1525
|
+
foreignKey: 'user_id',
|
|
1526
|
+
cascade: true // Enable cascade delete
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
const user = await db.User.findById(1);
|
|
1532
|
+
await user.delete(); // ✅ Also deletes related Posts automatically
|
|
1533
|
+
```
|
|
1534
|
+
|
|
1535
|
+
### .reload()
|
|
1536
|
+
|
|
1537
|
+
Refresh an entity from the database, discarding any unsaved changes.
|
|
1538
|
+
|
|
1539
|
+
**Example:**
|
|
1540
|
+
|
|
1541
|
+
```javascript
|
|
1542
|
+
const user = await db.User.findById(1);
|
|
1543
|
+
console.log(user.name); // 'Alice'
|
|
1544
|
+
|
|
1545
|
+
user.name = 'Modified';
|
|
1546
|
+
console.log(user.name); // 'Modified'
|
|
1547
|
+
|
|
1548
|
+
await user.reload(); // ✅ Fetch fresh data from database
|
|
1549
|
+
console.log(user.name); // 'Alice' - changes discarded
|
|
1550
|
+
```
|
|
1551
|
+
|
|
1552
|
+
**Use Cases:**
|
|
1553
|
+
- Discard unsaved changes
|
|
1554
|
+
- Refresh stale data after external updates
|
|
1555
|
+
- Synchronize after concurrent modifications
|
|
1556
|
+
- Reset entity to clean state
|
|
1557
|
+
|
|
1558
|
+
### .clone()
|
|
1559
|
+
|
|
1560
|
+
Create a copy of an entity for duplication (primary key excluded).
|
|
1561
|
+
|
|
1562
|
+
**Example:**
|
|
1563
|
+
|
|
1564
|
+
```javascript
|
|
1565
|
+
const user = await db.User.findById(1);
|
|
1566
|
+
const duplicate = user.clone();
|
|
1567
|
+
|
|
1568
|
+
duplicate.name = 'Copy of ' + user.name;
|
|
1569
|
+
duplicate.email = 'copy@example.com';
|
|
1570
|
+
|
|
1571
|
+
await duplicate.save();
|
|
1572
|
+
console.log(duplicate.id); // ✅ New ID (different from original)
|
|
1573
|
+
```
|
|
1574
|
+
|
|
1575
|
+
**Notes:**
|
|
1576
|
+
- Primary key is automatically excluded
|
|
1577
|
+
- Relationships are not cloned (set manually if needed)
|
|
1578
|
+
- Useful for templates and duplicating records
|
|
1579
|
+
|
|
1580
|
+
---
|
|
1581
|
+
|
|
1582
|
+
## Query Helper Methods
|
|
1583
|
+
|
|
1584
|
+
### .first()
|
|
1585
|
+
|
|
1586
|
+
Get the first record ordered by primary key.
|
|
1587
|
+
|
|
1588
|
+
**Example:**
|
|
1589
|
+
|
|
1590
|
+
```javascript
|
|
1591
|
+
// Automatically orders by primary key
|
|
1592
|
+
const firstUser = await db.User.first();
|
|
1593
|
+
|
|
1594
|
+
// With custom order (respects existing orderBy)
|
|
1595
|
+
const newestUser = await db.User
|
|
1596
|
+
.orderByDescending(u => u.created_at)
|
|
1597
|
+
.first();
|
|
1598
|
+
|
|
1599
|
+
// With conditions
|
|
1600
|
+
const firstActive = await db.User
|
|
1601
|
+
.where(u => u.status == $$, 'active')
|
|
1602
|
+
.first();
|
|
1603
|
+
```
|
|
1604
|
+
|
|
1605
|
+
### .last()
|
|
1606
|
+
|
|
1607
|
+
Get the last record ordered by primary key (descending).
|
|
1608
|
+
|
|
1609
|
+
**Example:**
|
|
1610
|
+
|
|
1611
|
+
```javascript
|
|
1612
|
+
const lastUser = await db.User.last();
|
|
1613
|
+
|
|
1614
|
+
// With custom order
|
|
1615
|
+
const oldestUser = await db.User
|
|
1616
|
+
.orderBy(u => u.created_at)
|
|
1617
|
+
.last();
|
|
1618
|
+
```
|
|
1619
|
+
|
|
1620
|
+
### .exists()
|
|
1621
|
+
|
|
1622
|
+
Check if any records match the query (returns boolean).
|
|
1623
|
+
|
|
1624
|
+
**Example:**
|
|
1625
|
+
|
|
1626
|
+
```javascript
|
|
1627
|
+
// Before
|
|
1628
|
+
const count = await db.User
|
|
1629
|
+
.where(u => u.email == $$, 'test@example.com')
|
|
1630
|
+
.count();
|
|
1631
|
+
const exists = count > 0;
|
|
1632
|
+
|
|
1633
|
+
// After
|
|
1634
|
+
const exists = await db.User
|
|
1635
|
+
.where(u => u.email == $$, 'test@example.com')
|
|
1636
|
+
.exists();
|
|
1637
|
+
|
|
1638
|
+
if (exists) {
|
|
1639
|
+
throw new Error('Email already registered');
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Check if any users exist
|
|
1643
|
+
const hasUsers = await db.User.exists();
|
|
1644
|
+
if (!hasUsers) {
|
|
1645
|
+
// Create default admin user
|
|
1646
|
+
}
|
|
1647
|
+
```
|
|
1648
|
+
|
|
1649
|
+
### .pluck(fieldName)
|
|
1650
|
+
|
|
1651
|
+
Extract a single column as an array.
|
|
1652
|
+
|
|
1653
|
+
**Example:**
|
|
1654
|
+
|
|
1655
|
+
```javascript
|
|
1656
|
+
// Get all active user emails
|
|
1657
|
+
const emails = await db.User
|
|
1658
|
+
.where(u => u.status == $$, 'active')
|
|
1659
|
+
.pluck('email');
|
|
1660
|
+
console.log(emails);
|
|
1661
|
+
// ['alice@example.com', 'bob@example.com', 'charlie@example.com']
|
|
1662
|
+
|
|
1663
|
+
// Get all user IDs
|
|
1664
|
+
const ids = await db.User.pluck('id');
|
|
1665
|
+
console.log(ids); // [1, 2, 3, 4, 5]
|
|
1666
|
+
|
|
1667
|
+
// With sorting
|
|
1668
|
+
const recentEmails = await db.User
|
|
1669
|
+
.orderByDescending(u => u.created_at)
|
|
1670
|
+
.take(10)
|
|
1671
|
+
.pluck('email');
|
|
1672
|
+
```
|
|
1673
|
+
|
|
1674
|
+
---
|
|
1675
|
+
|
|
1676
|
+
## Lifecycle Hooks
|
|
1677
|
+
|
|
1678
|
+
Add lifecycle hooks to your entity definitions to execute logic before/after database operations.
|
|
1679
|
+
|
|
1680
|
+
**Available Hooks:**
|
|
1681
|
+
- `beforeSave()` - Execute before insert or update
|
|
1682
|
+
- `afterSave()` - Execute after insert or update
|
|
1683
|
+
- `beforeDelete()` - Execute before deletion
|
|
1684
|
+
- `afterDelete()` - Execute after deletion
|
|
1685
|
+
|
|
1686
|
+
**Example:**
|
|
1687
|
+
|
|
1688
|
+
```javascript
|
|
1689
|
+
const bcrypt = require('bcrypt');
|
|
1690
|
+
|
|
1691
|
+
class User {
|
|
1692
|
+
constructor() {
|
|
1693
|
+
this.id = { type: 'integer', primary: true, auto: true };
|
|
1694
|
+
this.email = { type: 'string' };
|
|
1695
|
+
this.password = { type: 'string' };
|
|
1696
|
+
this.created_at = { type: 'timestamp' };
|
|
1697
|
+
this.updated_at = { type: 'timestamp' };
|
|
1698
|
+
this.role = { type: 'string' };
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Hash password before saving
|
|
1702
|
+
beforeSave() {
|
|
1703
|
+
// Only hash if password was changed
|
|
1704
|
+
if (this.__dirtyFields.includes('password')) {
|
|
1705
|
+
this.password = bcrypt.hashSync(this.password, 10);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// Set timestamps automatically
|
|
1710
|
+
beforeSave() {
|
|
1711
|
+
if (this.__state === 'insert') {
|
|
1712
|
+
this.created_at = new Date();
|
|
1713
|
+
}
|
|
1714
|
+
this.updated_at = new Date();
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Log after successful save
|
|
1718
|
+
afterSave() {
|
|
1719
|
+
console.log(`User ${this.id} saved successfully`);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Prevent deleting admin users
|
|
1723
|
+
beforeDelete() {
|
|
1724
|
+
if (this.role === 'admin') {
|
|
1725
|
+
throw new Error('Cannot delete admin user');
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// Cleanup related data after deletion
|
|
1730
|
+
async afterDelete() {
|
|
1731
|
+
console.log(`User ${this.id} deleted, cleaning up related data...`);
|
|
1732
|
+
// Cleanup logic here (e.g., delete user files, clear cache)
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
```
|
|
1736
|
+
|
|
1737
|
+
**Usage:**
|
|
1738
|
+
|
|
1739
|
+
```javascript
|
|
1740
|
+
// Hooks execute automatically during save
|
|
1741
|
+
const user = db.User.new();
|
|
1742
|
+
user.email = 'alice@example.com';
|
|
1743
|
+
user.password = 'plain-text-password';
|
|
1744
|
+
await user.save();
|
|
1745
|
+
// ✅ beforeSave() hashes password automatically
|
|
1746
|
+
// ✅ afterSave() logs success message
|
|
1747
|
+
|
|
1748
|
+
// Load and update
|
|
1749
|
+
const user = await db.User.findById(1);
|
|
1750
|
+
user.email = 'newemail@example.com';
|
|
1751
|
+
await user.save();
|
|
1752
|
+
// ✅ beforeSave() sets updated_at timestamp
|
|
1753
|
+
// ✅ Password not re-hashed (not in dirtyFields)
|
|
1754
|
+
|
|
1755
|
+
// Hooks can prevent operations
|
|
1756
|
+
const admin = await db.User.where(u => u.role == $$, 'admin').single();
|
|
1757
|
+
try {
|
|
1758
|
+
await admin.delete();
|
|
1759
|
+
} catch (error) {
|
|
1760
|
+
console.log(error.message); // "Cannot delete admin user"
|
|
1761
|
+
}
|
|
1762
|
+
// ✅ beforeDelete() prevented deletion
|
|
1763
|
+
```
|
|
1764
|
+
|
|
1765
|
+
**Hook Execution Order:**
|
|
1766
|
+
|
|
1767
|
+
```javascript
|
|
1768
|
+
// Insert:
|
|
1769
|
+
// 1. beforeSave()
|
|
1770
|
+
// 2. SQL INSERT
|
|
1771
|
+
// 3. afterSave()
|
|
1772
|
+
|
|
1773
|
+
// Update:
|
|
1774
|
+
// 1. beforeSave()
|
|
1775
|
+
// 2. SQL UPDATE
|
|
1776
|
+
// 3. afterSave()
|
|
1777
|
+
|
|
1778
|
+
// Delete:
|
|
1779
|
+
// 1. beforeDelete()
|
|
1780
|
+
// 2. SQL DELETE
|
|
1781
|
+
// 3. afterDelete()
|
|
1782
|
+
```
|
|
1783
|
+
|
|
1784
|
+
**Notes:**
|
|
1785
|
+
- Hooks can be async (use `async` keyword)
|
|
1786
|
+
- Exceptions in `before*` hooks prevent the operation
|
|
1787
|
+
- Hooks execute for each entity during batch operations
|
|
1788
|
+
- Access entity state via `this.__state` ('insert', 'modified', 'delete')
|
|
1789
|
+
- Access changed fields via `this.__dirtyFields` array
|
|
1790
|
+
|
|
1791
|
+
---
|
|
1792
|
+
|
|
1793
|
+
## Business Logic Validation
|
|
1794
|
+
|
|
1795
|
+
Add validators to your entity definitions for automatic validation on property assignment.
|
|
1796
|
+
|
|
1797
|
+
**Built-in Validators:**
|
|
1798
|
+
- `required(message)` - Field must have a value
|
|
1799
|
+
- `email(message)` - Must be valid email format
|
|
1800
|
+
- `minLength(length, message)` - Minimum string length
|
|
1801
|
+
- `maxLength(length, message)` - Maximum string length
|
|
1802
|
+
- `pattern(regex, message)` - Must match regex pattern
|
|
1803
|
+
- `min(value, message)` - Minimum numeric value
|
|
1804
|
+
- `max(value, message)` - Maximum numeric value
|
|
1805
|
+
- `custom(fn, message)` - Custom validation function
|
|
1806
|
+
|
|
1807
|
+
**Example:**
|
|
1808
|
+
|
|
1809
|
+
```javascript
|
|
1810
|
+
class User {
|
|
1811
|
+
id(db) {
|
|
1812
|
+
db.integer().primary().auto();
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
name(db) {
|
|
1816
|
+
db.string()
|
|
1817
|
+
.required('Name is required')
|
|
1818
|
+
.minLength(3, 'Name must be at least 3 characters')
|
|
1819
|
+
.maxLength(50, 'Name cannot exceed 50 characters');
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
email(db) {
|
|
1823
|
+
db.string()
|
|
1824
|
+
.required('Email is required')
|
|
1825
|
+
.email('Must be a valid email address');
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
password(db) {
|
|
1829
|
+
db.string()
|
|
1830
|
+
.required('Password is required')
|
|
1831
|
+
.minLength(8, 'Password must be at least 8 characters')
|
|
1832
|
+
.maxLength(100);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
username(db) {
|
|
1836
|
+
db.string()
|
|
1837
|
+
.required()
|
|
1838
|
+
.pattern(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores');
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
age(db) {
|
|
1842
|
+
db.integer()
|
|
1843
|
+
.min(18, 'Must be at least 18 years old')
|
|
1844
|
+
.max(120, 'Age cannot exceed 120');
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
status(db) {
|
|
1848
|
+
db.string()
|
|
1849
|
+
.custom((value) => {
|
|
1850
|
+
return ['active', 'inactive', 'pending'].includes(value);
|
|
1851
|
+
}, 'Status must be active, inactive, or pending');
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
```
|
|
1855
|
+
|
|
1856
|
+
**Validation Execution:**
|
|
1857
|
+
|
|
1858
|
+
Validators run automatically when you assign values:
|
|
1859
|
+
|
|
1860
|
+
```javascript
|
|
1861
|
+
const user = db.User.new();
|
|
1862
|
+
|
|
1863
|
+
// ❌ Validation fails immediately
|
|
1864
|
+
try {
|
|
1865
|
+
user.email = 'invalid-email';
|
|
1866
|
+
} catch (error) {
|
|
1867
|
+
console.log(error.message);
|
|
1868
|
+
// "Validation failed: Must be a valid email address"
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// ✅ Valid value accepted
|
|
1872
|
+
user.email = 'valid@example.com'; // OK
|
|
1873
|
+
|
|
1874
|
+
// ❌ Length validation
|
|
1875
|
+
try {
|
|
1876
|
+
user.password = 'short';
|
|
1877
|
+
} catch (error) {
|
|
1878
|
+
console.log(error.message);
|
|
1879
|
+
// "Validation failed: Password must be at least 8 characters"
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// ✅ Valid password
|
|
1883
|
+
user.password = 'secure-password-123'; // OK
|
|
1884
|
+
|
|
1885
|
+
// ❌ Custom validation
|
|
1886
|
+
try {
|
|
1887
|
+
user.status = 'invalid-status';
|
|
1888
|
+
} catch (error) {
|
|
1889
|
+
console.log(error.message);
|
|
1890
|
+
// "Validation failed: Status must be active, inactive, or pending"
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// ✅ Valid status
|
|
1894
|
+
user.status = 'active'; // OK
|
|
1895
|
+
|
|
1896
|
+
// Save (all fields already validated)
|
|
1897
|
+
await user.save();
|
|
1898
|
+
```
|
|
1899
|
+
|
|
1900
|
+
**Validator Chaining:**
|
|
1901
|
+
|
|
1902
|
+
Validators can be chained together:
|
|
1903
|
+
|
|
1904
|
+
```javascript
|
|
1905
|
+
email(db) {
|
|
1906
|
+
db.string()
|
|
1907
|
+
.required('Email is required') // ← First validator
|
|
1908
|
+
.email('Invalid email format') // ← Second validator
|
|
1909
|
+
.minLength(5, 'Email too short') // ← Third validator
|
|
1910
|
+
.maxLength(100, 'Email too long'); // ← Fourth validator
|
|
1911
|
+
}
|
|
1912
|
+
```
|
|
1913
|
+
|
|
1914
|
+
**Custom Validation:**
|
|
1915
|
+
|
|
1916
|
+
```javascript
|
|
1917
|
+
discount(db) {
|
|
1918
|
+
db.integer()
|
|
1919
|
+
.min(0, 'Discount cannot be negative')
|
|
1920
|
+
.max(100, 'Discount cannot exceed 100%')
|
|
1921
|
+
.custom((value) => {
|
|
1922
|
+
// Only allow multiples of 5
|
|
1923
|
+
return value % 5 === 0;
|
|
1924
|
+
}, 'Discount must be a multiple of 5');
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// Usage
|
|
1928
|
+
product.discount = 7; // ❌ Throws: "Discount must be a multiple of 5"
|
|
1929
|
+
product.discount = 10; // ✅ OK
|
|
1930
|
+
```
|
|
1931
|
+
|
|
1932
|
+
**Nullable Fields:**
|
|
1933
|
+
|
|
1934
|
+
Required validation respects nullable fields:
|
|
1935
|
+
|
|
1936
|
+
```javascript
|
|
1937
|
+
bio(db) {
|
|
1938
|
+
db.string()
|
|
1939
|
+
.maxLength(500, 'Bio cannot exceed 500 characters');
|
|
1940
|
+
// No .required() = field is optional
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// Both are valid
|
|
1944
|
+
user.bio = null; // ✅ OK (nullable)
|
|
1945
|
+
user.bio = 'Short bio'; // ✅ OK (with value)
|
|
1946
|
+
user.bio = 'a'.repeat(501); // ❌ Throws: "Bio cannot exceed 500 characters"
|
|
1258
1947
|
```
|
|
1259
1948
|
|
|
1949
|
+
---
|
|
1950
|
+
|
|
1951
|
+
## Bulk Operations API
|
|
1952
|
+
|
|
1953
|
+
Efficiently create, update, or delete multiple entities in a single operation.
|
|
1954
|
+
|
|
1955
|
+
**Available Methods:**
|
|
1956
|
+
- `context.bulkCreate(entityName, data)` - Create multiple entities
|
|
1957
|
+
- `context.bulkUpdate(entityName, updates)` - Update multiple entities
|
|
1958
|
+
- `context.bulkDelete(entityName, ids)` - Delete multiple entities
|
|
1959
|
+
|
|
1960
|
+
### bulkCreate(entityName, data)
|
|
1961
|
+
|
|
1962
|
+
Create multiple entities efficiently in a batch operation.
|
|
1963
|
+
|
|
1964
|
+
**Example:**
|
|
1965
|
+
|
|
1966
|
+
```javascript
|
|
1967
|
+
// Create 5 users at once
|
|
1968
|
+
const users = await db.bulkCreate('User', [
|
|
1969
|
+
{ name: 'Alice', email: 'alice@example.com', status: 'active' },
|
|
1970
|
+
{ name: 'Bob', email: 'bob@example.com', status: 'active' },
|
|
1971
|
+
{ name: 'Charlie', email: 'charlie@example.com', status: 'inactive' },
|
|
1972
|
+
{ name: 'Dave', email: 'dave@example.com', status: 'active' },
|
|
1973
|
+
{ name: 'Eve', email: 'eve@example.com', status: 'pending' }
|
|
1974
|
+
]);
|
|
1975
|
+
|
|
1976
|
+
console.log(users.length); // 5
|
|
1977
|
+
console.log(users[0].id); // 1 (auto-increment IDs assigned)
|
|
1978
|
+
console.log(users[4].id); // 5
|
|
1979
|
+
|
|
1980
|
+
// Entities are returned in the same order
|
|
1981
|
+
console.log(users.map(u => u.name));
|
|
1982
|
+
// ['Alice', 'Bob', 'Charlie', 'Dave', 'Eve']
|
|
1983
|
+
```
|
|
1984
|
+
|
|
1985
|
+
**Performance:**
|
|
1986
|
+
|
|
1987
|
+
```javascript
|
|
1988
|
+
// ❌ SLOW: Multiple individual inserts
|
|
1989
|
+
for (const data of users) {
|
|
1990
|
+
const user = db.User.new();
|
|
1991
|
+
user.name = data.name;
|
|
1992
|
+
user.email = data.email;
|
|
1993
|
+
await user.save(); // Separate database call
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// ✅ FAST: Single bulk insert
|
|
1997
|
+
await db.bulkCreate('User', users); // One database call
|
|
1998
|
+
```
|
|
1999
|
+
|
|
2000
|
+
### bulkUpdate(entityName, updates)
|
|
2001
|
+
|
|
2002
|
+
Update multiple entities by their primary keys.
|
|
2003
|
+
|
|
2004
|
+
**Example:**
|
|
2005
|
+
|
|
2006
|
+
```javascript
|
|
2007
|
+
// Update multiple users' status
|
|
2008
|
+
await db.bulkUpdate('User', [
|
|
2009
|
+
{ id: 1, status: 'inactive' },
|
|
2010
|
+
{ id: 2, status: 'inactive' },
|
|
2011
|
+
{ id: 4, status: 'inactive' }
|
|
2012
|
+
]);
|
|
2013
|
+
|
|
2014
|
+
// Verify updates
|
|
2015
|
+
const user1 = await db.User.findById(1);
|
|
2016
|
+
console.log(user1.status); // 'inactive'
|
|
2017
|
+
|
|
2018
|
+
// Other fields unchanged
|
|
2019
|
+
console.log(user1.name); // Original name preserved
|
|
2020
|
+
console.log(user1.email); // Original email preserved
|
|
2021
|
+
```
|
|
2022
|
+
|
|
2023
|
+
**Partial Updates:**
|
|
2024
|
+
|
|
2025
|
+
Only the fields you specify are updated:
|
|
2026
|
+
|
|
2027
|
+
```javascript
|
|
2028
|
+
// Update only email for multiple users
|
|
2029
|
+
await db.bulkUpdate('User', [
|
|
2030
|
+
{ id: 1, email: 'newemail1@example.com' },
|
|
2031
|
+
{ id: 2, email: 'newemail2@example.com' }
|
|
2032
|
+
]);
|
|
2033
|
+
|
|
2034
|
+
// name, status, age, etc. remain unchanged
|
|
2035
|
+
```
|
|
2036
|
+
|
|
2037
|
+
### bulkDelete(entityName, ids)
|
|
2038
|
+
|
|
2039
|
+
Delete multiple entities by their primary keys.
|
|
2040
|
+
|
|
2041
|
+
**Example:**
|
|
2042
|
+
|
|
2043
|
+
```javascript
|
|
2044
|
+
// Delete multiple users by ID
|
|
2045
|
+
await db.bulkDelete('User', [3, 5, 7]);
|
|
2046
|
+
|
|
2047
|
+
// Verify deletion
|
|
2048
|
+
const user3 = await db.User.findById(3);
|
|
2049
|
+
console.log(user3); // null
|
|
2050
|
+
|
|
2051
|
+
const remaining = await db.User.toList();
|
|
2052
|
+
console.log(remaining.length); // Total users minus 3
|
|
2053
|
+
```
|
|
2054
|
+
|
|
2055
|
+
**Non-Existent IDs:**
|
|
2056
|
+
|
|
2057
|
+
Bulk delete handles non-existent IDs gracefully:
|
|
2058
|
+
|
|
2059
|
+
```javascript
|
|
2060
|
+
// Some IDs don't exist
|
|
2061
|
+
await db.bulkDelete('User', [999, 1000, 1001]);
|
|
2062
|
+
// ✅ No error thrown - operation completes successfully
|
|
2063
|
+
```
|
|
2064
|
+
|
|
2065
|
+
**Error Handling:**
|
|
2066
|
+
|
|
2067
|
+
```javascript
|
|
2068
|
+
// Empty array throws error
|
|
2069
|
+
try {
|
|
2070
|
+
await db.bulkCreate('User', []);
|
|
2071
|
+
} catch (error) {
|
|
2072
|
+
console.log(error.message);
|
|
2073
|
+
// "bulkCreate requires a non-empty array of data"
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// Invalid entity name throws error
|
|
2077
|
+
try {
|
|
2078
|
+
await db.bulkUpdate('NonExistentEntity', [{ id: 1 }]);
|
|
2079
|
+
} catch (error) {
|
|
2080
|
+
console.log(error.message);
|
|
2081
|
+
// "Entity NonExistentEntity not found"
|
|
2082
|
+
}
|
|
2083
|
+
```
|
|
2084
|
+
|
|
2085
|
+
**Lifecycle Hooks:**
|
|
2086
|
+
|
|
2087
|
+
Bulk operations execute lifecycle hooks for each entity:
|
|
2088
|
+
|
|
2089
|
+
```javascript
|
|
2090
|
+
class User {
|
|
2091
|
+
beforeSave() {
|
|
2092
|
+
console.log(`Saving user: ${this.name}`);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
await db.bulkCreate('User', [
|
|
2097
|
+
{ name: 'Alice' },
|
|
2098
|
+
{ name: 'Bob' }
|
|
2099
|
+
]);
|
|
2100
|
+
// Console output:
|
|
2101
|
+
// Saving user: Alice
|
|
2102
|
+
// Saving user: Bob
|
|
2103
|
+
```
|
|
2104
|
+
|
|
2105
|
+
---
|
|
2106
|
+
|
|
1260
2107
|
### Migration Methods
|
|
1261
2108
|
|
|
1262
2109
|
```javascript
|
|
@@ -1315,14 +2162,14 @@ demo();
|
|
|
1315
2162
|
async function getUsers(page = 0, pageSize = 10) {
|
|
1316
2163
|
const db = new AppContext();
|
|
1317
2164
|
|
|
1318
|
-
const users = db.User
|
|
2165
|
+
const users = await db.User
|
|
1319
2166
|
.where(u => u.status == $$, 'active')
|
|
1320
2167
|
.orderBy(u => u.created_at)
|
|
1321
2168
|
.skip(page * pageSize)
|
|
1322
2169
|
.take(pageSize)
|
|
1323
2170
|
.toList();
|
|
1324
2171
|
|
|
1325
|
-
const total = db.User
|
|
2172
|
+
const total = await db.User
|
|
1326
2173
|
.where(u => u.status == $$, 'active')
|
|
1327
2174
|
.count();
|
|
1328
2175
|
|
|
@@ -1373,7 +2220,7 @@ async function searchUsers(filters) {
|
|
|
1373
2220
|
.take(filters.pageSize);
|
|
1374
2221
|
}
|
|
1375
2222
|
|
|
1376
|
-
return query.toList();
|
|
2223
|
+
return await query.toList();
|
|
1377
2224
|
}
|
|
1378
2225
|
```
|
|
1379
2226
|
|
|
@@ -1403,7 +2250,7 @@ post.author_id = author.id;
|
|
|
1403
2250
|
await db.saveChanges();
|
|
1404
2251
|
|
|
1405
2252
|
// Query with relationships
|
|
1406
|
-
const posts = db.Post
|
|
2253
|
+
const posts = await db.Post
|
|
1407
2254
|
.where(p => p.author_id == $$, author.id)
|
|
1408
2255
|
.toList();
|
|
1409
2256
|
|
|
@@ -1416,15 +2263,15 @@ console.log(`${author.name} has ${posts.length} posts`);
|
|
|
1416
2263
|
|
|
1417
2264
|
```javascript
|
|
1418
2265
|
// ✅ GOOD: Cache reference data that rarely changes
|
|
1419
|
-
const categories = db.Categories.cache().toList(); // Opt-in caching
|
|
1420
|
-
const settings = db.Settings.cache().toList();
|
|
2266
|
+
const categories = await db.Categories.cache().toList(); // Opt-in caching
|
|
2267
|
+
const settings = await db.Settings.cache().toList();
|
|
1421
2268
|
|
|
1422
2269
|
// ✅ GOOD: Queries without .cache() are always fresh (safe default)
|
|
1423
|
-
const user1 = db.User.findById(123); // Always DB query (no cache)
|
|
1424
|
-
const user2 = db.User.findById(123); // Always DB query (no cache)
|
|
2270
|
+
const user1 = await db.User.findById(123); // Always DB query (no cache)
|
|
2271
|
+
const user2 = await db.User.findById(123); // Always DB query (no cache)
|
|
1425
2272
|
|
|
1426
2273
|
// ✅ GOOD: Cache expensive queries with stable results
|
|
1427
|
-
const revenue2024 = db.Orders
|
|
2274
|
+
const revenue2024 = await db.Orders
|
|
1428
2275
|
.where(o => o.year == $$, 2024)
|
|
1429
2276
|
.cache() // Historical data doesn't change
|
|
1430
2277
|
.count();
|
|
@@ -1472,13 +2319,13 @@ class User {
|
|
|
1472
2319
|
|
|
1473
2320
|
```javascript
|
|
1474
2321
|
// ✅ GOOD: Limit results
|
|
1475
|
-
const recentUsers = db.User
|
|
2322
|
+
const recentUsers = await db.User
|
|
1476
2323
|
.orderByDescending(u => u.created_at)
|
|
1477
2324
|
.take(100)
|
|
1478
2325
|
.toList();
|
|
1479
2326
|
|
|
1480
2327
|
// ❌ BAD: Load everything
|
|
1481
|
-
const allUsers = db.User.toList();
|
|
2328
|
+
const allUsers = await db.User.toList();
|
|
1482
2329
|
```
|
|
1483
2330
|
|
|
1484
2331
|
### 5. Use Connection Pooling (PostgreSQL)
|
|
@@ -1500,7 +2347,7 @@ MasterRecord uses **parameterized queries throughout** to prevent SQL injection:
|
|
|
1500
2347
|
|
|
1501
2348
|
```javascript
|
|
1502
2349
|
// ✅ SAFE: Parameterized
|
|
1503
|
-
const user = db.User.where(u => u.name == $$, userInput).single();
|
|
2350
|
+
const user = await db.User.where(u => u.name == $$, userInput).single();
|
|
1504
2351
|
|
|
1505
2352
|
// ❌ UNSAFE: Never do this
|
|
1506
2353
|
// const query = `SELECT * FROM User WHERE name = '${userInput}'`;
|
|
@@ -1520,12 +2367,12 @@ While SQL injection is prevented, always validate business logic:
|
|
|
1520
2367
|
|
|
1521
2368
|
```javascript
|
|
1522
2369
|
// Validate input before querying
|
|
1523
|
-
function getUser(userId) {
|
|
2370
|
+
async function getUser(userId) {
|
|
1524
2371
|
if (!Number.isInteger(userId) || userId <= 0) {
|
|
1525
2372
|
throw new Error('Invalid user ID');
|
|
1526
2373
|
}
|
|
1527
2374
|
|
|
1528
|
-
return db.User.findById(userId);
|
|
2375
|
+
return await db.User.findById(userId);
|
|
1529
2376
|
}
|
|
1530
2377
|
```
|
|
1531
2378
|
|
|
@@ -1652,7 +2499,128 @@ Created by Alexander Rich
|
|
|
1652
2499
|
|
|
1653
2500
|
---
|
|
1654
2501
|
|
|
1655
|
-
## Recent Improvements
|
|
2502
|
+
## Recent Improvements
|
|
2503
|
+
|
|
2504
|
+
### v0.3.30 - Mature ORM Features (Latest)
|
|
2505
|
+
|
|
2506
|
+
MasterRecord is now feature-complete with lifecycle hooks, validation, and bulk operations - matching the capabilities of mature ORMs like Sequelize, TypeORM, and Prisma.
|
|
2507
|
+
|
|
2508
|
+
**🎯 Entity Serialization:**
|
|
2509
|
+
- ✅ **`.toObject()`** - Convert entities to plain JavaScript objects with circular reference protection
|
|
2510
|
+
- ✅ **`.toJSON()`** - Automatic JSON.stringify() compatibility for Express responses
|
|
2511
|
+
- ✅ **Circular Reference Handling** - Prevents infinite loops from bidirectional relationships
|
|
2512
|
+
- ✅ **Depth Control** - Configurable relationship traversal depth
|
|
2513
|
+
|
|
2514
|
+
**🎯 Active Record Pattern:**
|
|
2515
|
+
- ✅ **`.delete()`** - Entities can delete themselves (`await user.delete()`)
|
|
2516
|
+
- ✅ **`.reload()`** - Refresh entity from database, discard unsaved changes
|
|
2517
|
+
- ✅ **`.clone()`** - Create entity copies for duplication (excludes primary key)
|
|
2518
|
+
- ✅ **`.save()`** - Already existed, now part of complete Active Record pattern
|
|
2519
|
+
|
|
2520
|
+
**🎯 Query Helpers:**
|
|
2521
|
+
- ✅ **`.first()`** - Get first record ordered by primary key
|
|
2522
|
+
- ✅ **`.last()`** - Get last record ordered by primary key descending
|
|
2523
|
+
- ✅ **`.exists()`** - Check if any records match query (returns boolean)
|
|
2524
|
+
- ✅ **`.pluck(field)`** - Extract single column values as array
|
|
2525
|
+
|
|
2526
|
+
**🎯 Lifecycle Hooks:**
|
|
2527
|
+
- ✅ **`beforeSave()`** - Execute before insert or update (e.g., hash passwords)
|
|
2528
|
+
- ✅ **`afterSave()`** - Execute after successful save (e.g., logging)
|
|
2529
|
+
- ✅ **`beforeDelete()`** - Execute before deletion (can prevent deletion)
|
|
2530
|
+
- ✅ **`afterDelete()`** - Execute after deletion (e.g., cleanup)
|
|
2531
|
+
- ✅ **Hook Execution Order** - Guaranteed execution order with error handling
|
|
2532
|
+
- ✅ **Async Support** - Hooks can be async for database operations
|
|
2533
|
+
|
|
2534
|
+
**🎯 Business Logic Validation:**
|
|
2535
|
+
- ✅ **`.required()`** - Field must have a value
|
|
2536
|
+
- ✅ **`.email()`** - Must be valid email format
|
|
2537
|
+
- ✅ **`.minLength()` / `.maxLength()`** - String length constraints
|
|
2538
|
+
- ✅ **`.min()` / `.max()`** - Numeric value constraints
|
|
2539
|
+
- ✅ **`.pattern()`** - Must match regex pattern
|
|
2540
|
+
- ✅ **`.custom()`** - Custom validation functions
|
|
2541
|
+
- ✅ **Chainable Validators** - Multiple validators per field
|
|
2542
|
+
- ✅ **Immediate Validation** - Errors thrown on property assignment
|
|
2543
|
+
|
|
2544
|
+
**🎯 Bulk Operations API:**
|
|
2545
|
+
- ✅ **`bulkCreate()`** - Create multiple entities efficiently in one transaction
|
|
2546
|
+
- ✅ **`bulkUpdate()`** - Update multiple entities by primary key
|
|
2547
|
+
- ✅ **`bulkDelete()`** - Delete multiple entities by primary key
|
|
2548
|
+
- ✅ **Lifecycle Hook Support** - Hooks execute for each entity in bulk operations
|
|
2549
|
+
- ✅ **Auto-Increment IDs** - IDs properly assigned after bulk inserts
|
|
2550
|
+
|
|
2551
|
+
**🎯 Critical Bug Fixes:**
|
|
2552
|
+
- ✅ **Auto-Increment ID Bug Fixed** - IDs now correctly set on entities after insert (SQLite, MySQL, PostgreSQL)
|
|
2553
|
+
- ✅ **Lifecycle Hook Isolation** - Hooks excluded from SQL queries and INSERT/UPDATE operations
|
|
2554
|
+
- ✅ **Circular Reference Prevention** - WeakSet-based tracking prevents infinite loops
|
|
2555
|
+
|
|
2556
|
+
**Example Usage:**
|
|
2557
|
+
|
|
2558
|
+
```javascript
|
|
2559
|
+
// Entity serialization
|
|
2560
|
+
const user = await db.User.findById(1);
|
|
2561
|
+
const plain = user.toObject({ includeRelationships: true, depth: 2 });
|
|
2562
|
+
res.json(user); // Works automatically with toJSON()
|
|
2563
|
+
|
|
2564
|
+
// Active Record pattern
|
|
2565
|
+
await user.delete(); // Entity deletes itself
|
|
2566
|
+
await user.reload(); // Discard changes
|
|
2567
|
+
const copy = user.clone(); // Duplicate entity
|
|
2568
|
+
|
|
2569
|
+
// Query helpers
|
|
2570
|
+
const first = await db.User.first();
|
|
2571
|
+
const exists = await db.User.where(u => u.email == $$, 'test@test.com').exists();
|
|
2572
|
+
const emails = await db.User.where(u => u.status == $$, 'active').pluck('email');
|
|
2573
|
+
|
|
2574
|
+
// Lifecycle hooks
|
|
2575
|
+
class User {
|
|
2576
|
+
beforeSave() {
|
|
2577
|
+
if (this.__dirtyFields.includes('password')) {
|
|
2578
|
+
this.password = bcrypt.hashSync(this.password, 10);
|
|
2579
|
+
}
|
|
2580
|
+
this.updated_at = new Date();
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
beforeDelete() {
|
|
2584
|
+
if (this.role === 'admin') {
|
|
2585
|
+
throw new Error('Cannot delete admin user');
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
// Business validation
|
|
2591
|
+
class User {
|
|
2592
|
+
email(db) {
|
|
2593
|
+
db.string()
|
|
2594
|
+
.required('Email is required')
|
|
2595
|
+
.email('Must be a valid email address');
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
age(db) {
|
|
2599
|
+
db.integer()
|
|
2600
|
+
.min(18, 'Must be at least 18 years old')
|
|
2601
|
+
.max(120);
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
// Bulk operations
|
|
2606
|
+
const users = await db.bulkCreate('User', [
|
|
2607
|
+
{ name: 'Alice', email: 'alice@example.com' },
|
|
2608
|
+
{ name: 'Bob', email: 'bob@example.com' },
|
|
2609
|
+
{ name: 'Charlie', email: 'charlie@example.com' }
|
|
2610
|
+
]);
|
|
2611
|
+
console.log(users.map(u => u.id)); // [1, 2, 3] - IDs assigned
|
|
2612
|
+
|
|
2613
|
+
await db.bulkUpdate('User', [
|
|
2614
|
+
{ id: 1, status: 'inactive' },
|
|
2615
|
+
{ id: 2, status: 'inactive' }
|
|
2616
|
+
]);
|
|
2617
|
+
|
|
2618
|
+
await db.bulkDelete('User', [3, 5, 7]);
|
|
2619
|
+
```
|
|
2620
|
+
|
|
2621
|
+
---
|
|
2622
|
+
|
|
2623
|
+
### v0.3.13 - FAANG Engineering Standards
|
|
1656
2624
|
|
|
1657
2625
|
MasterRecord has been upgraded to meet **FAANG engineering standards** (Google/Meta/Amazon) with critical bug fixes and performance improvements:
|
|
1658
2626
|
|