masterrecord 0.3.8 → 0.3.9

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.
@@ -72,10 +72,24 @@ class EntityTrackerModel {
72
72
  }
73
73
  }
74
74
  });
75
- }
75
+ }
76
76
  }
77
-
78
-
77
+
78
+ // Add Active Record-style .save() method
79
+ modelClass.save = async function() {
80
+ if (!this.__context) {
81
+ throw new Error('Cannot save: entity is not attached to a context');
82
+ }
83
+
84
+ // Ensure entity is tracked
85
+ if (!this.__context.__trackedEntitiesMap.has(this.__ID)) {
86
+ this.__context.__track(this);
87
+ }
88
+
89
+ // Save all tracked changes in the context
90
+ return await this.__context.saveChanges();
91
+ };
92
+
79
93
  return modelClass;
80
94
  }
81
95
 
@@ -465,6 +465,21 @@ class queryMethods{
465
465
  }
466
466
  }
467
467
 
468
+ // Add Active Record-style .save() method
469
+ newEntity.save = async function() {
470
+ if (!this.__context) {
471
+ throw new Error('Cannot save: entity is not attached to a context');
472
+ }
473
+
474
+ // Ensure entity is tracked
475
+ if (!this.__context.__trackedEntitiesMap.has(this.__ID)) {
476
+ this.__context.__track(this);
477
+ }
478
+
479
+ // Save all tracked changes in the context
480
+ return await this.__context.saveChanges();
481
+ };
482
+
468
483
  // Track the entity
469
484
  this.__context.__track(newEntity);
470
485
  return newEntity;
package/context.js CHANGED
@@ -662,6 +662,117 @@ class context {
662
662
  this.clearQueryCache();
663
663
  }
664
664
 
665
+ /**
666
+ * Attach a detached entity and mark it as modified
667
+ * Use this when an entity was loaded in a different context or passed from another service
668
+ * Similar to Entity Framework's context.Update() or Hibernate's session.merge()
669
+ *
670
+ * @param {object} entity - The detached entity to attach
671
+ * @param {object} changes - Optional: specific fields that were modified
672
+ *
673
+ * @example
674
+ * // Attach entity loaded elsewhere
675
+ * const task = await taskService.getTask(taskId);
676
+ * task.status = 'completed';
677
+ * db.attach(task); // Mark as modified
678
+ * await db.saveChanges();
679
+ *
680
+ * @example
681
+ * // Attach with specific changed fields
682
+ * db.attach(task, { status: 'completed', updated_at: new Date() });
683
+ * await db.saveChanges();
684
+ */
685
+ attach(entity, changes = null) {
686
+ if (!entity) {
687
+ throw new Error('Cannot attach null or undefined entity');
688
+ }
689
+
690
+ // Ensure entity has required metadata
691
+ if (!entity.__entity || !entity.__entity.__name) {
692
+ throw new Error('Entity must have __entity metadata. Make sure it was loaded through MasterRecord.');
693
+ }
694
+
695
+ // Mark entity as modified
696
+ entity.__state = 'modified';
697
+
698
+ // If specific changes provided, mark only those fields as dirty
699
+ if (changes) {
700
+ entity.__dirtyFields = entity.__dirtyFields || [];
701
+ for (const fieldName in changes) {
702
+ entity[fieldName] = changes[fieldName];
703
+ if (!entity.__dirtyFields.includes(fieldName)) {
704
+ entity.__dirtyFields.push(fieldName);
705
+ }
706
+ }
707
+ } else {
708
+ // Mark all fields as potentially modified
709
+ entity.__dirtyFields = entity.__dirtyFields || [];
710
+
711
+ // If no dirty fields yet, mark all non-metadata fields as dirty
712
+ if (entity.__dirtyFields.length === 0) {
713
+ for (const fieldName in entity.__entity) {
714
+ if (!fieldName.startsWith('__') &&
715
+ entity.__entity[fieldName].type !== 'hasMany' &&
716
+ entity.__entity[fieldName].type !== 'hasOne') {
717
+ entity.__dirtyFields.push(fieldName);
718
+ }
719
+ }
720
+ }
721
+ }
722
+
723
+ // Ensure context reference
724
+ entity.__context = this;
725
+
726
+ // Track the entity
727
+ this.__track(entity);
728
+
729
+ return entity;
730
+ }
731
+
732
+ /**
733
+ * Attach multiple detached entities at once
734
+ *
735
+ * @example
736
+ * const tasks = await taskService.getTasks();
737
+ * tasks.forEach(t => t.status = 'completed');
738
+ * db.attachAll(tasks);
739
+ * await db.saveChanges();
740
+ */
741
+ attachAll(entities) {
742
+ if (!Array.isArray(entities)) {
743
+ throw new Error('attachAll() requires an array of entities');
744
+ }
745
+
746
+ return entities.map(entity => this.attach(entity));
747
+ }
748
+
749
+ /**
750
+ * Update a detached entity by primary key
751
+ * Loads the entity, applies changes, and marks as modified
752
+ * Similar to Sequelize's Model.update()
753
+ *
754
+ * @example
755
+ * // Update without loading first
756
+ * await db.update('Task', { id: taskId }, { status: 'completed' });
757
+ * await db.saveChanges();
758
+ */
759
+ async update(entityName, primaryKey, changes) {
760
+ // Get entity class
761
+ const EntityClass = this[entityName];
762
+ if (!EntityClass) {
763
+ throw new Error(`Entity ${entityName} not found in context`);
764
+ }
765
+
766
+ // Load entity
767
+ const entity = EntityClass.findById(primaryKey);
768
+ if (!entity) {
769
+ throw new Error(`${entityName} with id ${primaryKey} not found`);
770
+ }
771
+
772
+ // Apply changes and attach
773
+ return this.attach(entity, changes);
774
+ }
775
+
665
776
  // __track(model){
666
777
  // this.__trackedEntities.push(model);
667
778
  // return model;
@@ -0,0 +1,477 @@
1
+ # Active Record Pattern - entity.save()
2
+
3
+ MasterRecord now supports **both** patterns:
4
+
5
+ 1. **Entity Framework style** - `context.saveChanges()`
6
+ 2. **Active Record style** - `entity.save()` ✨ NEW!
7
+
8
+ ---
9
+
10
+ ## Active Record Style (NEW)
11
+
12
+ ### Basic Usage
13
+
14
+ ```javascript
15
+ // Load entity
16
+ const task = db.Task.findById(1);
17
+
18
+ // Modify
19
+ task.status = 'completed';
20
+ task.completed_at = new Date();
21
+
22
+ // Save (like Active Record!)
23
+ await task.save(); // ✅ Entity saves itself!
24
+ ```
25
+
26
+ ### Create New Entity
27
+
28
+ ```javascript
29
+ // Create
30
+ const task = db.Task.new();
31
+ task.name = 'New Task';
32
+ task.status = 'pending';
33
+
34
+ // Save
35
+ await task.save(); // ✅ Like Active Record!
36
+
37
+ console.log(task.id); // Auto-generated ID
38
+ ```
39
+
40
+ ### Update Multiple Fields
41
+
42
+ ```javascript
43
+ const user = db.User.findById(userId);
44
+ user.name = 'Updated Name';
45
+ user.email = 'new@example.com';
46
+ user.updated_at = new Date();
47
+
48
+ await user.save(); // ✅ All changes saved
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Solving the Detached Entity Problem
54
+
55
+ ### Before (Manual attach)
56
+
57
+ ```javascript
58
+ // ❌ Required manual attach
59
+ const task = await taskService.getTask(taskId);
60
+ task.status = 'completed';
61
+ db.attach(task); // Manual attach
62
+ await db.saveChanges();
63
+ ```
64
+
65
+ ### After (Active Record style)
66
+
67
+ ```javascript
68
+ // ✅ Just call .save()!
69
+ const task = await taskService.getTask(taskId);
70
+ task.status = 'completed';
71
+ await task.save(); // Entity saves itself!
72
+ ```
73
+
74
+ **Why this works:**
75
+ - Entity has `__context` reference
76
+ - `.save()` automatically tracks the entity
77
+ - No manual `attach()` needed!
78
+
79
+ ---
80
+
81
+ ## Your Original Problem - SOLVED
82
+
83
+ ### Your Original Code (BROKEN)
84
+
85
+ ```javascript
86
+ class AnnotationService {
87
+ async createAnnotation(taskId, data) {
88
+ const task = await this._taskService.getTask(taskId);
89
+
90
+ const annotation = this._qaContext.Annotation.new();
91
+ annotation.task_id = taskId;
92
+ this._qaContext.Annotation.add(annotation);
93
+
94
+ task.status = 'completed'; // ❌ Not tracked
95
+
96
+ await this._qaContext.saveChanges(); // ❌ Task not saved
97
+ }
98
+ }
99
+ ```
100
+
101
+ ### Solution 1: Active Record Style (EASIEST) ✨
102
+
103
+ ```javascript
104
+ class AnnotationService {
105
+ async createAnnotation(taskId, data) {
106
+ const task = await this._taskService.getTask(taskId);
107
+
108
+ const annotation = this._qaContext.Annotation.new();
109
+ annotation.task_id = taskId;
110
+ annotation.data = data;
111
+
112
+ task.status = 'completed';
113
+
114
+ // Save both (Active Record style!)
115
+ await annotation.save(); // ✅ Saves annotation
116
+ await task.save(); // ✅ Saves task
117
+
118
+ return annotation;
119
+ }
120
+ }
121
+ ```
122
+
123
+ ### Solution 2: Entity Framework Style
124
+
125
+ ```javascript
126
+ class AnnotationService {
127
+ async createAnnotation(taskId, data) {
128
+ const task = await this._taskService.getTask(taskId);
129
+
130
+ const annotation = this._qaContext.Annotation.new();
131
+ annotation.task_id = taskId;
132
+ this._qaContext.Annotation.add(annotation);
133
+
134
+ task.status = 'completed';
135
+ this._qaContext.attach(task); // Attach detached entity
136
+
137
+ await this._qaContext.saveChanges(); // Save all
138
+ return annotation;
139
+ }
140
+ }
141
+ ```
142
+
143
+ ### Solution 3: Pure Active Record
144
+
145
+ ```javascript
146
+ class AnnotationService {
147
+ async createAnnotation(taskId, data) {
148
+ const task = await this._taskService.getTask(taskId);
149
+ task.status = 'completed';
150
+ await task.save(); // Save task first
151
+
152
+ const annotation = this._qaContext.Annotation.new();
153
+ annotation.task_id = taskId;
154
+ annotation.data = data;
155
+ await annotation.save(); // Save annotation
156
+
157
+ return annotation;
158
+ }
159
+ }
160
+ ```
161
+
162
+ ---
163
+
164
+ ## Comparison: Both Patterns
165
+
166
+ ### Entity Framework Style
167
+
168
+ ```javascript
169
+ // Load entities
170
+ const user = db.User.findById(1);
171
+ const task = db.Task.findById(2);
172
+
173
+ // Modify both
174
+ user.name = 'Updated';
175
+ task.status = 'completed';
176
+
177
+ // Save all at once
178
+ await db.saveChanges(); // Batch save
179
+ ```
180
+
181
+ **Pros:**
182
+ - ✅ Batch operations (efficient)
183
+ - ✅ Transaction-like behavior
184
+ - ✅ One save call for multiple entities
185
+
186
+ **Cons:**
187
+ - ❌ Less intuitive
188
+ - ❌ Need to track which context has which entities
189
+
190
+ ### Active Record Style (NEW)
191
+
192
+ ```javascript
193
+ // Load entities
194
+ const user = db.User.findById(1);
195
+ const task = db.Task.findById(2);
196
+
197
+ // Modify and save individually
198
+ user.name = 'Updated';
199
+ await user.save(); // Save user
200
+
201
+ task.status = 'completed';
202
+ await task.save(); // Save task
203
+ ```
204
+
205
+ **Pros:**
206
+ - ✅ More intuitive (entity saves itself)
207
+ - ✅ No detached entity issues
208
+ - ✅ Works across contexts
209
+ - ✅ Familiar to Rails developers
210
+
211
+ **Cons:**
212
+ - ❌ Multiple database calls
213
+ - ❌ No automatic batching
214
+
215
+ ---
216
+
217
+ ## When to Use Each Pattern
218
+
219
+ ### Use `entity.save()` when:
220
+
221
+ ✅ Working with single entities
222
+ ✅ Entity from external service (detached)
223
+ ✅ Quick updates to one entity
224
+ ✅ Familiar with Active Record (Rails)
225
+ ✅ Want explicit control
226
+
227
+ ```javascript
228
+ // Single entity updates
229
+ const user = db.User.findById(userId);
230
+ user.last_login = new Date();
231
+ await user.save(); // Clear and simple
232
+ ```
233
+
234
+ ### Use `context.saveChanges()` when:
235
+
236
+ ✅ Batch operations (multiple entities)
237
+ ✅ Need transaction-like behavior
238
+ ✅ Performance critical (fewer DB calls)
239
+ ✅ Familiar with Entity Framework
240
+
241
+ ```javascript
242
+ // Batch updates
243
+ const users = db.User.where(u => u.active == false).toList();
244
+ users.forEach(u => u.deleted_at = new Date());
245
+ await db.saveChanges(); // One batch save
246
+ ```
247
+
248
+ ---
249
+
250
+ ## Complete Examples
251
+
252
+ ### Example 1: User Registration
253
+
254
+ ```javascript
255
+ async function registerUser(data) {
256
+ const db = new AppContext();
257
+
258
+ // Create user
259
+ const user = db.User.new();
260
+ user.email = data.email;
261
+ user.name = data.name;
262
+ user.password_hash = await hash(data.password);
263
+
264
+ // Save (Active Record style)
265
+ await user.save(); // ✅
266
+
267
+ // Send welcome email
268
+ await sendWelcomeEmail(user);
269
+
270
+ return user;
271
+ }
272
+ ```
273
+
274
+ ### Example 2: Task Completion
275
+
276
+ ```javascript
277
+ async function completeTask(taskId) {
278
+ const db = new AppContext();
279
+
280
+ const task = db.Task.findById(taskId);
281
+ task.status = 'completed';
282
+ task.completed_at = new Date();
283
+
284
+ // Save (Active Record style)
285
+ await task.save(); // ✅
286
+
287
+ // Notify user
288
+ await notifyUser(task.user_id, 'Task completed');
289
+
290
+ return task;
291
+ }
292
+ ```
293
+
294
+ ### Example 3: Bulk Update (Entity Framework style)
295
+
296
+ ```javascript
297
+ async function archiveOldTasks() {
298
+ const db = new AppContext();
299
+
300
+ const oldTasks = db.Task
301
+ .where(t => t.status == $$, 'completed')
302
+ .where(t => t.completed_at < $$, thirtyDaysAgo)
303
+ .toList();
304
+
305
+ oldTasks.forEach(task => {
306
+ task.archived = true;
307
+ task.archived_at = new Date();
308
+ });
309
+
310
+ // Batch save (Entity Framework style)
311
+ await db.saveChanges(); // ✅ Efficient batch update
312
+
313
+ return oldTasks.length;
314
+ }
315
+ ```
316
+
317
+ ### Example 4: Mixed Pattern
318
+
319
+ ```javascript
320
+ async function processOrder(orderId) {
321
+ const db = new AppContext();
322
+
323
+ // Load order
324
+ const order = db.Order.findById(orderId);
325
+ order.status = 'processing';
326
+ await order.save(); // Save immediately (Active Record)
327
+
328
+ // Create line items
329
+ const items = order.items.map(item => {
330
+ const lineItem = db.LineItem.new();
331
+ lineItem.order_id = orderId;
332
+ lineItem.product_id = item.productId;
333
+ lineItem.quantity = item.quantity;
334
+ return lineItem;
335
+ });
336
+
337
+ // Batch save line items (Entity Framework)
338
+ await db.saveChanges(); // ✅ Efficient for multiple items
339
+
340
+ return order;
341
+ }
342
+ ```
343
+
344
+ ---
345
+
346
+ ## Rails vs MasterRecord
347
+
348
+ ### Rails (Active Record)
349
+
350
+ ```ruby
351
+ # Create
352
+ user = User.new
353
+ user.name = 'John'
354
+ user.save
355
+
356
+ # Update
357
+ user = User.find(1)
358
+ user.name = 'Jane'
359
+ user.save
360
+
361
+ # Delete
362
+ user.destroy
363
+ ```
364
+
365
+ ### MasterRecord (Active Record style)
366
+
367
+ ```javascript
368
+ // Create
369
+ const user = db.User.new();
370
+ user.name = 'John';
371
+ await user.save();
372
+
373
+ // Update
374
+ const user = db.User.findById(1);
375
+ user.name = 'Jane';
376
+ await user.save();
377
+
378
+ // Delete
379
+ db.remove(user);
380
+ await db.saveChanges(); // Or await user.save()
381
+ ```
382
+
383
+ ---
384
+
385
+ ## Important Notes
386
+
387
+ ### 1. `.save()` saves ALL tracked changes
388
+
389
+ ```javascript
390
+ const db = new AppContext();
391
+
392
+ const user1 = db.User.findById(1);
393
+ user1.name = 'Updated 1';
394
+
395
+ const user2 = db.User.findById(2);
396
+ user2.name = 'Updated 2';
397
+
398
+ // Calling save() on either entity saves BOTH
399
+ await user1.save(); // Saves user1 AND user2!
400
+ ```
401
+
402
+ **Why?** `.save()` calls `context.saveChanges()`, which saves all tracked entities in that context.
403
+
404
+ **To save only one entity:** Use separate contexts or save immediately:
405
+
406
+ ```javascript
407
+ // Option 1: Separate contexts
408
+ const db1 = new AppContext();
409
+ const user1 = db1.User.findById(1);
410
+ user1.name = 'Updated 1';
411
+ await user1.save(); // Only saves user1
412
+
413
+ const db2 = new AppContext();
414
+ const user2 = db2.User.findById(2);
415
+ user2.name = 'Updated 2';
416
+ await user2.save(); // Only saves user2
417
+
418
+ // Option 2: Save immediately
419
+ const db = new AppContext();
420
+ const user1 = db.User.findById(1);
421
+ user1.name = 'Updated 1';
422
+ await user1.save(); // Saves and clears tracking
423
+
424
+ const user2 = db.User.findById(2);
425
+ user2.name = 'Updated 2';
426
+ await user2.save(); // Only user2 tracked at this point
427
+ ```
428
+
429
+ ### 2. Entity must have `__context`
430
+
431
+ ```javascript
432
+ // ✅ WORKS: Entity loaded through MasterRecord
433
+ const user = db.User.findById(1);
434
+ await user.save(); // Has __context reference
435
+
436
+ // ❌ ERROR: Plain object
437
+ const user = { id: 1, name: 'Test' };
438
+ await user.save(); // Error: no __context
439
+ ```
440
+
441
+ ### 3. Async/await required
442
+
443
+ ```javascript
444
+ // ✅ GOOD
445
+ await user.save();
446
+
447
+ // ❌ BAD: Doesn't wait for save
448
+ user.save();
449
+ ```
450
+
451
+ ---
452
+
453
+ ## Summary
454
+
455
+ **MasterRecord now supports Active Record style!** 🎉
456
+
457
+ ```javascript
458
+ // Both patterns work:
459
+
460
+ // Active Record (NEW)
461
+ await entity.save();
462
+
463
+ // Entity Framework
464
+ await context.saveChanges();
465
+
466
+ // Choose what feels natural for your use case!
467
+ ```
468
+
469
+ **Solves the detached entity problem:**
470
+ - No need for manual `attach()`
471
+ - Entity knows its context
472
+ - Just call `.save()`!
473
+
474
+ **Best of both worlds:** ✨
475
+ - Active Record: Intuitive entity-level saves
476
+ - Entity Framework: Efficient batch operations
477
+ - You choose which to use!