masterrecord 0.3.27 → 0.3.30
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 +145 -1
- package/Entity/entityModelBuilder.js +21 -3
- package/Entity/entityTrackerModel.js +251 -1
- package/Migrations/migrationMySQLQuery.js +14 -0
- package/Migrations/migrationPostgresQuery.js +14 -0
- package/Migrations/migrationSQLiteQuery.js +14 -0
- package/Migrations/migrationTemplate.js +38 -0
- package/Migrations/migrations.js +107 -3
- package/Migrations/schema.js +66 -0
- 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 +1125 -8
- 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
|
|
|
@@ -1256,8 +1386,834 @@ await .findById(id) // Find by primary key
|
|
|
1256
1386
|
|
|
1257
1387
|
// Entity methods (Active Record style - REQUIRE AWAIT)
|
|
1258
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
|
+
## Field Constraints & Indexes
|
|
1794
|
+
|
|
1795
|
+
Define database constraints and performance indexes using the fluent API:
|
|
1796
|
+
|
|
1797
|
+
```javascript
|
|
1798
|
+
class User {
|
|
1799
|
+
id(db) {
|
|
1800
|
+
db.integer().primary().auto();
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
email(db) {
|
|
1804
|
+
db.string()
|
|
1805
|
+
.notNullable()
|
|
1806
|
+
.unique()
|
|
1807
|
+
.index(); // Creates performance index
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
username(db) {
|
|
1811
|
+
db.string()
|
|
1812
|
+
.notNullable()
|
|
1813
|
+
.index('idx_username_custom'); // Custom index name
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
status(db) {
|
|
1817
|
+
db.string().nullable();
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
created_at(db) {
|
|
1821
|
+
db.timestamp().default('CURRENT_TIMESTAMP');
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
```
|
|
1825
|
+
|
|
1826
|
+
### Available Constraint Methods
|
|
1827
|
+
|
|
1828
|
+
- `.notNullable()` - Column cannot be NULL
|
|
1829
|
+
- `.nullable()` - Column can be NULL (default)
|
|
1830
|
+
- `.unique()` - Unique constraint (enforces uniqueness at DB level)
|
|
1831
|
+
- `.index()` - Creates performance index (auto-generated name: `idx_tablename_columnname`)
|
|
1832
|
+
- `.index('custom_name')` - Creates index with custom name
|
|
1833
|
+
- `.primary()` - Primary key (automatically indexed)
|
|
1834
|
+
- `.default(value)` - Default value
|
|
1835
|
+
|
|
1836
|
+
### Index vs Unique Constraint
|
|
1837
|
+
|
|
1838
|
+
**Understanding the difference:**
|
|
1839
|
+
|
|
1840
|
+
- `.unique()` creates a UNIQUE constraint (prevents duplicate values, enforces data integrity)
|
|
1841
|
+
- `.index()` creates a performance index (improves query speed, allows duplicates)
|
|
1842
|
+
- You can use both together: `.unique().index()` creates a unique index for both integrity and performance
|
|
1843
|
+
|
|
1844
|
+
**Examples:**
|
|
1845
|
+
|
|
1846
|
+
```javascript
|
|
1847
|
+
// Email must be unique (no performance index)
|
|
1848
|
+
email(db) {
|
|
1849
|
+
db.string().notNullable().unique();
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// Username indexed for fast lookups (allows duplicates)
|
|
1853
|
+
username(db) {
|
|
1854
|
+
db.string().notNullable().index();
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// Email with both unique constraint AND performance index
|
|
1858
|
+
email(db) {
|
|
1859
|
+
db.string().notNullable().unique().index();
|
|
1860
|
+
}
|
|
1861
|
+
```
|
|
1862
|
+
|
|
1863
|
+
### Automatic Index Migration
|
|
1864
|
+
|
|
1865
|
+
When you add `.index()` to a field, MasterRecord automatically generates migration code:
|
|
1866
|
+
|
|
1867
|
+
```javascript
|
|
1868
|
+
// In your entity
|
|
1869
|
+
class User {
|
|
1870
|
+
email(db) {
|
|
1871
|
+
db.string().notNullable().index();
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// Generated migration (automatic)
|
|
1876
|
+
class Migration_20250101 extends masterrecord.schema {
|
|
1877
|
+
async up(table) {
|
|
1878
|
+
this.init(table);
|
|
1879
|
+
this.createIndex({
|
|
1880
|
+
tableName: 'User',
|
|
1881
|
+
columnName: 'email',
|
|
1882
|
+
indexName: 'idx_user_email'
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
async down(table) {
|
|
1887
|
+
this.init(table);
|
|
1888
|
+
this.dropIndex({
|
|
1889
|
+
tableName: 'User',
|
|
1890
|
+
columnName: 'email',
|
|
1891
|
+
indexName: 'idx_user_email'
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1259
1895
|
```
|
|
1260
1896
|
|
|
1897
|
+
**Rollback support:**
|
|
1898
|
+
|
|
1899
|
+
Migrations automatically include rollback logic. Running `masterrecord migrate down` will drop all indexes created by that migration.
|
|
1900
|
+
|
|
1901
|
+
---
|
|
1902
|
+
|
|
1903
|
+
## Business Logic Validation
|
|
1904
|
+
|
|
1905
|
+
Add validators to your entity definitions for automatic validation on property assignment.
|
|
1906
|
+
|
|
1907
|
+
**Built-in Validators:**
|
|
1908
|
+
- `required(message)` - Field must have a value
|
|
1909
|
+
- `email(message)` - Must be valid email format
|
|
1910
|
+
- `minLength(length, message)` - Minimum string length
|
|
1911
|
+
- `maxLength(length, message)` - Maximum string length
|
|
1912
|
+
- `pattern(regex, message)` - Must match regex pattern
|
|
1913
|
+
- `min(value, message)` - Minimum numeric value
|
|
1914
|
+
- `max(value, message)` - Maximum numeric value
|
|
1915
|
+
- `custom(fn, message)` - Custom validation function
|
|
1916
|
+
|
|
1917
|
+
**Example:**
|
|
1918
|
+
|
|
1919
|
+
```javascript
|
|
1920
|
+
class User {
|
|
1921
|
+
id(db) {
|
|
1922
|
+
db.integer().primary().auto();
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
name(db) {
|
|
1926
|
+
db.string()
|
|
1927
|
+
.required('Name is required')
|
|
1928
|
+
.minLength(3, 'Name must be at least 3 characters')
|
|
1929
|
+
.maxLength(50, 'Name cannot exceed 50 characters');
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
email(db) {
|
|
1933
|
+
db.string()
|
|
1934
|
+
.required('Email is required')
|
|
1935
|
+
.email('Must be a valid email address');
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
password(db) {
|
|
1939
|
+
db.string()
|
|
1940
|
+
.required('Password is required')
|
|
1941
|
+
.minLength(8, 'Password must be at least 8 characters')
|
|
1942
|
+
.maxLength(100);
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
username(db) {
|
|
1946
|
+
db.string()
|
|
1947
|
+
.required()
|
|
1948
|
+
.pattern(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores');
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
age(db) {
|
|
1952
|
+
db.integer()
|
|
1953
|
+
.min(18, 'Must be at least 18 years old')
|
|
1954
|
+
.max(120, 'Age cannot exceed 120');
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
status(db) {
|
|
1958
|
+
db.string()
|
|
1959
|
+
.custom((value) => {
|
|
1960
|
+
return ['active', 'inactive', 'pending'].includes(value);
|
|
1961
|
+
}, 'Status must be active, inactive, or pending');
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
```
|
|
1965
|
+
|
|
1966
|
+
**Validation Execution:**
|
|
1967
|
+
|
|
1968
|
+
Validators run automatically when you assign values:
|
|
1969
|
+
|
|
1970
|
+
```javascript
|
|
1971
|
+
const user = db.User.new();
|
|
1972
|
+
|
|
1973
|
+
// ❌ Validation fails immediately
|
|
1974
|
+
try {
|
|
1975
|
+
user.email = 'invalid-email';
|
|
1976
|
+
} catch (error) {
|
|
1977
|
+
console.log(error.message);
|
|
1978
|
+
// "Validation failed: Must be a valid email address"
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// ✅ Valid value accepted
|
|
1982
|
+
user.email = 'valid@example.com'; // OK
|
|
1983
|
+
|
|
1984
|
+
// ❌ Length validation
|
|
1985
|
+
try {
|
|
1986
|
+
user.password = 'short';
|
|
1987
|
+
} catch (error) {
|
|
1988
|
+
console.log(error.message);
|
|
1989
|
+
// "Validation failed: Password must be at least 8 characters"
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// ✅ Valid password
|
|
1993
|
+
user.password = 'secure-password-123'; // OK
|
|
1994
|
+
|
|
1995
|
+
// ❌ Custom validation
|
|
1996
|
+
try {
|
|
1997
|
+
user.status = 'invalid-status';
|
|
1998
|
+
} catch (error) {
|
|
1999
|
+
console.log(error.message);
|
|
2000
|
+
// "Validation failed: Status must be active, inactive, or pending"
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// ✅ Valid status
|
|
2004
|
+
user.status = 'active'; // OK
|
|
2005
|
+
|
|
2006
|
+
// Save (all fields already validated)
|
|
2007
|
+
await user.save();
|
|
2008
|
+
```
|
|
2009
|
+
|
|
2010
|
+
**Validator Chaining:**
|
|
2011
|
+
|
|
2012
|
+
Validators can be chained together:
|
|
2013
|
+
|
|
2014
|
+
```javascript
|
|
2015
|
+
email(db) {
|
|
2016
|
+
db.string()
|
|
2017
|
+
.required('Email is required') // ← First validator
|
|
2018
|
+
.email('Invalid email format') // ← Second validator
|
|
2019
|
+
.minLength(5, 'Email too short') // ← Third validator
|
|
2020
|
+
.maxLength(100, 'Email too long'); // ← Fourth validator
|
|
2021
|
+
}
|
|
2022
|
+
```
|
|
2023
|
+
|
|
2024
|
+
**Custom Validation:**
|
|
2025
|
+
|
|
2026
|
+
```javascript
|
|
2027
|
+
discount(db) {
|
|
2028
|
+
db.integer()
|
|
2029
|
+
.min(0, 'Discount cannot be negative')
|
|
2030
|
+
.max(100, 'Discount cannot exceed 100%')
|
|
2031
|
+
.custom((value) => {
|
|
2032
|
+
// Only allow multiples of 5
|
|
2033
|
+
return value % 5 === 0;
|
|
2034
|
+
}, 'Discount must be a multiple of 5');
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// Usage
|
|
2038
|
+
product.discount = 7; // ❌ Throws: "Discount must be a multiple of 5"
|
|
2039
|
+
product.discount = 10; // ✅ OK
|
|
2040
|
+
```
|
|
2041
|
+
|
|
2042
|
+
**Nullable Fields:**
|
|
2043
|
+
|
|
2044
|
+
Required validation respects nullable fields:
|
|
2045
|
+
|
|
2046
|
+
```javascript
|
|
2047
|
+
bio(db) {
|
|
2048
|
+
db.string()
|
|
2049
|
+
.maxLength(500, 'Bio cannot exceed 500 characters');
|
|
2050
|
+
// No .required() = field is optional
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
// Both are valid
|
|
2054
|
+
user.bio = null; // ✅ OK (nullable)
|
|
2055
|
+
user.bio = 'Short bio'; // ✅ OK (with value)
|
|
2056
|
+
user.bio = 'a'.repeat(501); // ❌ Throws: "Bio cannot exceed 500 characters"
|
|
2057
|
+
```
|
|
2058
|
+
|
|
2059
|
+
---
|
|
2060
|
+
|
|
2061
|
+
## Bulk Operations API
|
|
2062
|
+
|
|
2063
|
+
Efficiently create, update, or delete multiple entities in a single operation.
|
|
2064
|
+
|
|
2065
|
+
**Available Methods:**
|
|
2066
|
+
- `context.bulkCreate(entityName, data)` - Create multiple entities
|
|
2067
|
+
- `context.bulkUpdate(entityName, updates)` - Update multiple entities
|
|
2068
|
+
- `context.bulkDelete(entityName, ids)` - Delete multiple entities
|
|
2069
|
+
|
|
2070
|
+
### bulkCreate(entityName, data)
|
|
2071
|
+
|
|
2072
|
+
Create multiple entities efficiently in a batch operation.
|
|
2073
|
+
|
|
2074
|
+
**Example:**
|
|
2075
|
+
|
|
2076
|
+
```javascript
|
|
2077
|
+
// Create 5 users at once
|
|
2078
|
+
const users = await db.bulkCreate('User', [
|
|
2079
|
+
{ name: 'Alice', email: 'alice@example.com', status: 'active' },
|
|
2080
|
+
{ name: 'Bob', email: 'bob@example.com', status: 'active' },
|
|
2081
|
+
{ name: 'Charlie', email: 'charlie@example.com', status: 'inactive' },
|
|
2082
|
+
{ name: 'Dave', email: 'dave@example.com', status: 'active' },
|
|
2083
|
+
{ name: 'Eve', email: 'eve@example.com', status: 'pending' }
|
|
2084
|
+
]);
|
|
2085
|
+
|
|
2086
|
+
console.log(users.length); // 5
|
|
2087
|
+
console.log(users[0].id); // 1 (auto-increment IDs assigned)
|
|
2088
|
+
console.log(users[4].id); // 5
|
|
2089
|
+
|
|
2090
|
+
// Entities are returned in the same order
|
|
2091
|
+
console.log(users.map(u => u.name));
|
|
2092
|
+
// ['Alice', 'Bob', 'Charlie', 'Dave', 'Eve']
|
|
2093
|
+
```
|
|
2094
|
+
|
|
2095
|
+
**Performance:**
|
|
2096
|
+
|
|
2097
|
+
```javascript
|
|
2098
|
+
// ❌ SLOW: Multiple individual inserts
|
|
2099
|
+
for (const data of users) {
|
|
2100
|
+
const user = db.User.new();
|
|
2101
|
+
user.name = data.name;
|
|
2102
|
+
user.email = data.email;
|
|
2103
|
+
await user.save(); // Separate database call
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// ✅ FAST: Single bulk insert
|
|
2107
|
+
await db.bulkCreate('User', users); // One database call
|
|
2108
|
+
```
|
|
2109
|
+
|
|
2110
|
+
### bulkUpdate(entityName, updates)
|
|
2111
|
+
|
|
2112
|
+
Update multiple entities by their primary keys.
|
|
2113
|
+
|
|
2114
|
+
**Example:**
|
|
2115
|
+
|
|
2116
|
+
```javascript
|
|
2117
|
+
// Update multiple users' status
|
|
2118
|
+
await db.bulkUpdate('User', [
|
|
2119
|
+
{ id: 1, status: 'inactive' },
|
|
2120
|
+
{ id: 2, status: 'inactive' },
|
|
2121
|
+
{ id: 4, status: 'inactive' }
|
|
2122
|
+
]);
|
|
2123
|
+
|
|
2124
|
+
// Verify updates
|
|
2125
|
+
const user1 = await db.User.findById(1);
|
|
2126
|
+
console.log(user1.status); // 'inactive'
|
|
2127
|
+
|
|
2128
|
+
// Other fields unchanged
|
|
2129
|
+
console.log(user1.name); // Original name preserved
|
|
2130
|
+
console.log(user1.email); // Original email preserved
|
|
2131
|
+
```
|
|
2132
|
+
|
|
2133
|
+
**Partial Updates:**
|
|
2134
|
+
|
|
2135
|
+
Only the fields you specify are updated:
|
|
2136
|
+
|
|
2137
|
+
```javascript
|
|
2138
|
+
// Update only email for multiple users
|
|
2139
|
+
await db.bulkUpdate('User', [
|
|
2140
|
+
{ id: 1, email: 'newemail1@example.com' },
|
|
2141
|
+
{ id: 2, email: 'newemail2@example.com' }
|
|
2142
|
+
]);
|
|
2143
|
+
|
|
2144
|
+
// name, status, age, etc. remain unchanged
|
|
2145
|
+
```
|
|
2146
|
+
|
|
2147
|
+
### bulkDelete(entityName, ids)
|
|
2148
|
+
|
|
2149
|
+
Delete multiple entities by their primary keys.
|
|
2150
|
+
|
|
2151
|
+
**Example:**
|
|
2152
|
+
|
|
2153
|
+
```javascript
|
|
2154
|
+
// Delete multiple users by ID
|
|
2155
|
+
await db.bulkDelete('User', [3, 5, 7]);
|
|
2156
|
+
|
|
2157
|
+
// Verify deletion
|
|
2158
|
+
const user3 = await db.User.findById(3);
|
|
2159
|
+
console.log(user3); // null
|
|
2160
|
+
|
|
2161
|
+
const remaining = await db.User.toList();
|
|
2162
|
+
console.log(remaining.length); // Total users minus 3
|
|
2163
|
+
```
|
|
2164
|
+
|
|
2165
|
+
**Non-Existent IDs:**
|
|
2166
|
+
|
|
2167
|
+
Bulk delete handles non-existent IDs gracefully:
|
|
2168
|
+
|
|
2169
|
+
```javascript
|
|
2170
|
+
// Some IDs don't exist
|
|
2171
|
+
await db.bulkDelete('User', [999, 1000, 1001]);
|
|
2172
|
+
// ✅ No error thrown - operation completes successfully
|
|
2173
|
+
```
|
|
2174
|
+
|
|
2175
|
+
**Error Handling:**
|
|
2176
|
+
|
|
2177
|
+
```javascript
|
|
2178
|
+
// Empty array throws error
|
|
2179
|
+
try {
|
|
2180
|
+
await db.bulkCreate('User', []);
|
|
2181
|
+
} catch (error) {
|
|
2182
|
+
console.log(error.message);
|
|
2183
|
+
// "bulkCreate requires a non-empty array of data"
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// Invalid entity name throws error
|
|
2187
|
+
try {
|
|
2188
|
+
await db.bulkUpdate('NonExistentEntity', [{ id: 1 }]);
|
|
2189
|
+
} catch (error) {
|
|
2190
|
+
console.log(error.message);
|
|
2191
|
+
// "Entity NonExistentEntity not found"
|
|
2192
|
+
}
|
|
2193
|
+
```
|
|
2194
|
+
|
|
2195
|
+
**Lifecycle Hooks:**
|
|
2196
|
+
|
|
2197
|
+
Bulk operations execute lifecycle hooks for each entity:
|
|
2198
|
+
|
|
2199
|
+
```javascript
|
|
2200
|
+
class User {
|
|
2201
|
+
beforeSave() {
|
|
2202
|
+
console.log(`Saving user: ${this.name}`);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
await db.bulkCreate('User', [
|
|
2207
|
+
{ name: 'Alice' },
|
|
2208
|
+
{ name: 'Bob' }
|
|
2209
|
+
]);
|
|
2210
|
+
// Console output:
|
|
2211
|
+
// Saving user: Alice
|
|
2212
|
+
// Saving user: Bob
|
|
2213
|
+
```
|
|
2214
|
+
|
|
2215
|
+
---
|
|
2216
|
+
|
|
1261
2217
|
### Migration Methods
|
|
1262
2218
|
|
|
1263
2219
|
```javascript
|
|
@@ -1455,20 +2411,60 @@ await db.saveChanges(); // Batch insert
|
|
|
1455
2411
|
|
|
1456
2412
|
### 3. Use Indexes
|
|
1457
2413
|
|
|
2414
|
+
Define indexes directly in your entity using `.index()`:
|
|
2415
|
+
|
|
1458
2416
|
```javascript
|
|
1459
2417
|
class User {
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
2418
|
+
id(db) {
|
|
2419
|
+
db.integer().primary().auto(); // Primary keys are automatically indexed
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
email(db) {
|
|
2423
|
+
db.string()
|
|
2424
|
+
.notNullable()
|
|
2425
|
+
.unique()
|
|
2426
|
+
.index(); // Creates: idx_user_email
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
last_name(db) {
|
|
2430
|
+
db.string().index(); // Creates: idx_user_last_name
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
status(db) {
|
|
2434
|
+
db.string().index('idx_user_status'); // Custom index name
|
|
1465
2435
|
}
|
|
1466
2436
|
}
|
|
2437
|
+
```
|
|
2438
|
+
|
|
2439
|
+
**Migration automatically generates:**
|
|
1467
2440
|
|
|
1468
|
-
|
|
1469
|
-
//
|
|
2441
|
+
```javascript
|
|
2442
|
+
// In migration file (generated automatically)
|
|
2443
|
+
this.createIndex({
|
|
2444
|
+
tableName: 'User',
|
|
2445
|
+
columnName: 'email',
|
|
2446
|
+
indexName: 'idx_user_email'
|
|
2447
|
+
});
|
|
1470
2448
|
```
|
|
1471
2449
|
|
|
2450
|
+
**Rollback support:**
|
|
2451
|
+
|
|
2452
|
+
```javascript
|
|
2453
|
+
// Down migration automatically includes
|
|
2454
|
+
this.dropIndex({
|
|
2455
|
+
tableName: 'User',
|
|
2456
|
+
columnName: 'email',
|
|
2457
|
+
indexName: 'idx_user_email'
|
|
2458
|
+
});
|
|
2459
|
+
```
|
|
2460
|
+
|
|
2461
|
+
**Best practices:**
|
|
2462
|
+
- Index columns used in WHERE clauses
|
|
2463
|
+
- Index foreign key columns for join performance
|
|
2464
|
+
- Don't over-index - each index adds write overhead
|
|
2465
|
+
- Primary keys are automatically indexed (no need for `.index()`)
|
|
2466
|
+
- Use `.unique()` for data integrity, `.index()` for query performance
|
|
2467
|
+
|
|
1472
2468
|
### 4. Limit Result Sets
|
|
1473
2469
|
|
|
1474
2470
|
```javascript
|
|
@@ -1653,7 +2649,128 @@ Created by Alexander Rich
|
|
|
1653
2649
|
|
|
1654
2650
|
---
|
|
1655
2651
|
|
|
1656
|
-
## Recent Improvements
|
|
2652
|
+
## Recent Improvements
|
|
2653
|
+
|
|
2654
|
+
### v0.3.30 - Mature ORM Features (Latest)
|
|
2655
|
+
|
|
2656
|
+
MasterRecord is now feature-complete with lifecycle hooks, validation, and bulk operations - matching the capabilities of mature ORMs like Sequelize, TypeORM, and Prisma.
|
|
2657
|
+
|
|
2658
|
+
**🎯 Entity Serialization:**
|
|
2659
|
+
- ✅ **`.toObject()`** - Convert entities to plain JavaScript objects with circular reference protection
|
|
2660
|
+
- ✅ **`.toJSON()`** - Automatic JSON.stringify() compatibility for Express responses
|
|
2661
|
+
- ✅ **Circular Reference Handling** - Prevents infinite loops from bidirectional relationships
|
|
2662
|
+
- ✅ **Depth Control** - Configurable relationship traversal depth
|
|
2663
|
+
|
|
2664
|
+
**🎯 Active Record Pattern:**
|
|
2665
|
+
- ✅ **`.delete()`** - Entities can delete themselves (`await user.delete()`)
|
|
2666
|
+
- ✅ **`.reload()`** - Refresh entity from database, discard unsaved changes
|
|
2667
|
+
- ✅ **`.clone()`** - Create entity copies for duplication (excludes primary key)
|
|
2668
|
+
- ✅ **`.save()`** - Already existed, now part of complete Active Record pattern
|
|
2669
|
+
|
|
2670
|
+
**🎯 Query Helpers:**
|
|
2671
|
+
- ✅ **`.first()`** - Get first record ordered by primary key
|
|
2672
|
+
- ✅ **`.last()`** - Get last record ordered by primary key descending
|
|
2673
|
+
- ✅ **`.exists()`** - Check if any records match query (returns boolean)
|
|
2674
|
+
- ✅ **`.pluck(field)`** - Extract single column values as array
|
|
2675
|
+
|
|
2676
|
+
**🎯 Lifecycle Hooks:**
|
|
2677
|
+
- ✅ **`beforeSave()`** - Execute before insert or update (e.g., hash passwords)
|
|
2678
|
+
- ✅ **`afterSave()`** - Execute after successful save (e.g., logging)
|
|
2679
|
+
- ✅ **`beforeDelete()`** - Execute before deletion (can prevent deletion)
|
|
2680
|
+
- ✅ **`afterDelete()`** - Execute after deletion (e.g., cleanup)
|
|
2681
|
+
- ✅ **Hook Execution Order** - Guaranteed execution order with error handling
|
|
2682
|
+
- ✅ **Async Support** - Hooks can be async for database operations
|
|
2683
|
+
|
|
2684
|
+
**🎯 Business Logic Validation:**
|
|
2685
|
+
- ✅ **`.required()`** - Field must have a value
|
|
2686
|
+
- ✅ **`.email()`** - Must be valid email format
|
|
2687
|
+
- ✅ **`.minLength()` / `.maxLength()`** - String length constraints
|
|
2688
|
+
- ✅ **`.min()` / `.max()`** - Numeric value constraints
|
|
2689
|
+
- ✅ **`.pattern()`** - Must match regex pattern
|
|
2690
|
+
- ✅ **`.custom()`** - Custom validation functions
|
|
2691
|
+
- ✅ **Chainable Validators** - Multiple validators per field
|
|
2692
|
+
- ✅ **Immediate Validation** - Errors thrown on property assignment
|
|
2693
|
+
|
|
2694
|
+
**🎯 Bulk Operations API:**
|
|
2695
|
+
- ✅ **`bulkCreate()`** - Create multiple entities efficiently in one transaction
|
|
2696
|
+
- ✅ **`bulkUpdate()`** - Update multiple entities by primary key
|
|
2697
|
+
- ✅ **`bulkDelete()`** - Delete multiple entities by primary key
|
|
2698
|
+
- ✅ **Lifecycle Hook Support** - Hooks execute for each entity in bulk operations
|
|
2699
|
+
- ✅ **Auto-Increment IDs** - IDs properly assigned after bulk inserts
|
|
2700
|
+
|
|
2701
|
+
**🎯 Critical Bug Fixes:**
|
|
2702
|
+
- ✅ **Auto-Increment ID Bug Fixed** - IDs now correctly set on entities after insert (SQLite, MySQL, PostgreSQL)
|
|
2703
|
+
- ✅ **Lifecycle Hook Isolation** - Hooks excluded from SQL queries and INSERT/UPDATE operations
|
|
2704
|
+
- ✅ **Circular Reference Prevention** - WeakSet-based tracking prevents infinite loops
|
|
2705
|
+
|
|
2706
|
+
**Example Usage:**
|
|
2707
|
+
|
|
2708
|
+
```javascript
|
|
2709
|
+
// Entity serialization
|
|
2710
|
+
const user = await db.User.findById(1);
|
|
2711
|
+
const plain = user.toObject({ includeRelationships: true, depth: 2 });
|
|
2712
|
+
res.json(user); // Works automatically with toJSON()
|
|
2713
|
+
|
|
2714
|
+
// Active Record pattern
|
|
2715
|
+
await user.delete(); // Entity deletes itself
|
|
2716
|
+
await user.reload(); // Discard changes
|
|
2717
|
+
const copy = user.clone(); // Duplicate entity
|
|
2718
|
+
|
|
2719
|
+
// Query helpers
|
|
2720
|
+
const first = await db.User.first();
|
|
2721
|
+
const exists = await db.User.where(u => u.email == $$, 'test@test.com').exists();
|
|
2722
|
+
const emails = await db.User.where(u => u.status == $$, 'active').pluck('email');
|
|
2723
|
+
|
|
2724
|
+
// Lifecycle hooks
|
|
2725
|
+
class User {
|
|
2726
|
+
beforeSave() {
|
|
2727
|
+
if (this.__dirtyFields.includes('password')) {
|
|
2728
|
+
this.password = bcrypt.hashSync(this.password, 10);
|
|
2729
|
+
}
|
|
2730
|
+
this.updated_at = new Date();
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
beforeDelete() {
|
|
2734
|
+
if (this.role === 'admin') {
|
|
2735
|
+
throw new Error('Cannot delete admin user');
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
// Business validation
|
|
2741
|
+
class User {
|
|
2742
|
+
email(db) {
|
|
2743
|
+
db.string()
|
|
2744
|
+
.required('Email is required')
|
|
2745
|
+
.email('Must be a valid email address');
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
age(db) {
|
|
2749
|
+
db.integer()
|
|
2750
|
+
.min(18, 'Must be at least 18 years old')
|
|
2751
|
+
.max(120);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
// Bulk operations
|
|
2756
|
+
const users = await db.bulkCreate('User', [
|
|
2757
|
+
{ name: 'Alice', email: 'alice@example.com' },
|
|
2758
|
+
{ name: 'Bob', email: 'bob@example.com' },
|
|
2759
|
+
{ name: 'Charlie', email: 'charlie@example.com' }
|
|
2760
|
+
]);
|
|
2761
|
+
console.log(users.map(u => u.id)); // [1, 2, 3] - IDs assigned
|
|
2762
|
+
|
|
2763
|
+
await db.bulkUpdate('User', [
|
|
2764
|
+
{ id: 1, status: 'inactive' },
|
|
2765
|
+
{ id: 2, status: 'inactive' }
|
|
2766
|
+
]);
|
|
2767
|
+
|
|
2768
|
+
await db.bulkDelete('User', [3, 5, 7]);
|
|
2769
|
+
```
|
|
2770
|
+
|
|
2771
|
+
---
|
|
2772
|
+
|
|
2773
|
+
### v0.3.13 - FAANG Engineering Standards
|
|
1657
2774
|
|
|
1658
2775
|
MasterRecord has been upgraded to meet **FAANG engineering standards** (Google/Meta/Amazon) with critical bug fixes and performance improvements:
|
|
1659
2776
|
|