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/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
- constructor() {
1461
- this.email = {
1462
- type: 'string',
1463
- unique: true // Automatically creates index
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
- // For complex queries, add database indexes manually
1469
- // CREATE INDEX idx_user_status ON User(status);
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 (v0.3.13)
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