masterrecord 0.3.57 → 0.3.59
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/QueryLanguage/queryMethods.js +8 -0
- package/context.js +7 -0
- package/insertManager.js +17 -11
- package/package.json +1 -1
- package/test/post-insert-tracking-test.js +258 -0
- package/test/promise-detection-test.js +61 -0
|
@@ -613,6 +613,14 @@ class queryMethods{
|
|
|
613
613
|
if(!this.__dirtyFields.includes(fname)){
|
|
614
614
|
this.__dirtyFields.push(fname);
|
|
615
615
|
}
|
|
616
|
+
// After INSERT (state transitions to "track"), behave like
|
|
617
|
+
// query-loaded entities: mark modified and re-register with tracker
|
|
618
|
+
if (this.__state === 'track') {
|
|
619
|
+
this.__state = 'modified';
|
|
620
|
+
}
|
|
621
|
+
if (this.__context && typeof this.__context.__track === 'function') {
|
|
622
|
+
this.__context.__track(this);
|
|
623
|
+
}
|
|
616
624
|
},
|
|
617
625
|
get: function(){
|
|
618
626
|
// Apply get function if defined
|
package/context.js
CHANGED
|
@@ -1614,6 +1614,13 @@ class context {
|
|
|
1614
1614
|
await entity.afterSave.call(entity);
|
|
1615
1615
|
}
|
|
1616
1616
|
}
|
|
1617
|
+
|
|
1618
|
+
// Transition inserted entities to tracked state so subsequent
|
|
1619
|
+
// property changes trigger UPDATE instead of a second INSERT
|
|
1620
|
+
for (const entity of entities) {
|
|
1621
|
+
entity.__state = 'track';
|
|
1622
|
+
entity.__dirtyFields = [];
|
|
1623
|
+
}
|
|
1617
1624
|
}
|
|
1618
1625
|
|
|
1619
1626
|
/**
|
package/insertManager.js
CHANGED
|
@@ -352,22 +352,28 @@ class InsertManager {
|
|
|
352
352
|
if (!isRelationship) {
|
|
353
353
|
const val = currentRealModel[entity];
|
|
354
354
|
if (val != null && typeof val === 'object') {
|
|
355
|
-
|
|
355
|
+
// Always reject Promises — a set() transform cannot meaningfully handle them
|
|
356
356
|
if (typeof val.then === 'function') {
|
|
357
|
+
const entityName = entityModel.__name || 'unknown';
|
|
357
358
|
this._errorModel.isValid = false;
|
|
358
359
|
this._errorModel.errors.push(
|
|
359
360
|
`Property '${entity}' on entity '${entityName}' contains a Promise. Did you forget to await an async call?`
|
|
360
361
|
);
|
|
361
|
-
} else if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
)
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
)
|
|
362
|
+
} else if (!currentEntity.set) {
|
|
363
|
+
// Only flag Array/Object when there is no custom set() transform,
|
|
364
|
+
// since the setter may serialize them to a scalar (e.g. JSON.stringify)
|
|
365
|
+
const entityName = entityModel.__name || 'unknown';
|
|
366
|
+
if (Array.isArray(val)) {
|
|
367
|
+
this._errorModel.isValid = false;
|
|
368
|
+
this._errorModel.errors.push(
|
|
369
|
+
`Property '${entity}' on entity '${entityName}' contains an Array, expected a scalar value`
|
|
370
|
+
);
|
|
371
|
+
} else if (!(val instanceof Date)) {
|
|
372
|
+
this._errorModel.isValid = false;
|
|
373
|
+
this._errorModel.errors.push(
|
|
374
|
+
`Property '${entity}' on entity '${entityName}' contains an Object, expected a scalar value`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
371
377
|
}
|
|
372
378
|
}
|
|
373
379
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "masterrecord",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.59",
|
|
4
4
|
"description": "An Object-relational mapping for the Master framework. Master Record connects classes to relational database tables to establish a database with almost zero-configuration ",
|
|
5
5
|
"main": "MasterRecord.js",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Post-INSERT entity tracking
|
|
3
|
+
*
|
|
4
|
+
* Verifies that after saveChanges() INSERTs a new entity, modifying
|
|
5
|
+
* properties on that same in-memory object correctly transitions to
|
|
6
|
+
* "modified" state and re-registers with the change tracker, so that
|
|
7
|
+
* a subsequent saveChanges() issues an UPDATE.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Alias 'masterrecord' to local root
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const Module = require('module');
|
|
13
|
+
const __MASTERRECORD_ROOT__ = path.join(__dirname, '..');
|
|
14
|
+
const __ORIGINAL_REQUIRE__ = Module.prototype.require;
|
|
15
|
+
Module.prototype.require = function(request) {
|
|
16
|
+
if (request === 'masterrecord' || request.startsWith('masterrecord/')) {
|
|
17
|
+
const resolved = request === 'masterrecord'
|
|
18
|
+
? __MASTERRECORD_ROOT__
|
|
19
|
+
: path.join(__MASTERRECORD_ROOT__, request.slice('masterrecord/'.length));
|
|
20
|
+
return __ORIGINAL_REQUIRE__.call(this, resolved);
|
|
21
|
+
}
|
|
22
|
+
return __ORIGINAL_REQUIRE__.call(this, request);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const queryMethods = require('../QueryLanguage/queryMethods');
|
|
26
|
+
|
|
27
|
+
console.log("╔════════════════════════════════════════════════════════════════╗");
|
|
28
|
+
console.log("║ Post-INSERT Entity Tracking Test ║");
|
|
29
|
+
console.log("╚════════════════════════════════════════════════════════════════╝\n");
|
|
30
|
+
|
|
31
|
+
let passed = 0;
|
|
32
|
+
let failed = 0;
|
|
33
|
+
|
|
34
|
+
function assert(condition, label) {
|
|
35
|
+
if (condition) {
|
|
36
|
+
console.log(` ✓ ${label}`);
|
|
37
|
+
passed++;
|
|
38
|
+
} else {
|
|
39
|
+
console.log(` ✗ ${label}`);
|
|
40
|
+
failed++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Minimal simulated context with tracking
|
|
45
|
+
function makeContext(entity) {
|
|
46
|
+
const ctx = {
|
|
47
|
+
__trackedEntities: [],
|
|
48
|
+
__trackedEntitiesMap: new Map(),
|
|
49
|
+
__entities: [entity],
|
|
50
|
+
__track(model) {
|
|
51
|
+
if (!this.__trackedEntitiesMap.has(model.__ID)) {
|
|
52
|
+
this.__trackedEntities.push(model);
|
|
53
|
+
this.__trackedEntitiesMap.set(model.__ID, model);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
__clearTracked() {
|
|
57
|
+
this.__trackedEntities = [];
|
|
58
|
+
this.__trackedEntitiesMap.clear();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return ctx;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Entity definition
|
|
65
|
+
const userEntity = {
|
|
66
|
+
__name: 'User',
|
|
67
|
+
id: { type: 'integer', primary: true, auto: true, nullable: true },
|
|
68
|
+
status: { type: 'string', nullable: true },
|
|
69
|
+
name: { type: 'string', nullable: true },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Build a queryMethods-style dbset so we can call .new()
|
|
73
|
+
function makeDbSet(ctx, entity) {
|
|
74
|
+
const qs = new queryMethods();
|
|
75
|
+
qs.__entity = entity;
|
|
76
|
+
qs.__context = ctx;
|
|
77
|
+
return qs;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// -----------------------------------------------------------
|
|
81
|
+
// Test 1: .new() entity starts in "insert" state
|
|
82
|
+
// -----------------------------------------------------------
|
|
83
|
+
console.log("Test 1: .new() entity starts in insert state");
|
|
84
|
+
console.log("──────────────────────────────────────────────────");
|
|
85
|
+
{
|
|
86
|
+
const ctx = makeContext(userEntity);
|
|
87
|
+
const dbset = makeDbSet(ctx, userEntity);
|
|
88
|
+
const entity = dbset.new();
|
|
89
|
+
|
|
90
|
+
assert(entity.__state === 'insert', 'State is "insert"');
|
|
91
|
+
assert(Array.isArray(entity.__dirtyFields), 'Has dirtyFields array');
|
|
92
|
+
assert(ctx.__trackedEntitiesMap.has(entity.__ID), 'Entity is tracked');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// -----------------------------------------------------------
|
|
96
|
+
// Test 2: Setting properties during "insert" state keeps state
|
|
97
|
+
// -----------------------------------------------------------
|
|
98
|
+
console.log("\nTest 2: Properties set during insert state keep insert state");
|
|
99
|
+
console.log("──────────────────────────────────────────────────");
|
|
100
|
+
{
|
|
101
|
+
const ctx = makeContext(userEntity);
|
|
102
|
+
const dbset = makeDbSet(ctx, userEntity);
|
|
103
|
+
const entity = dbset.new();
|
|
104
|
+
|
|
105
|
+
entity.status = 'queued';
|
|
106
|
+
entity.name = 'Test';
|
|
107
|
+
|
|
108
|
+
assert(entity.__state === 'insert', 'State stays "insert"');
|
|
109
|
+
assert(entity.__dirtyFields.includes('status'), 'status in dirtyFields');
|
|
110
|
+
assert(entity.__dirtyFields.includes('name'), 'name in dirtyFields');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// -----------------------------------------------------------
|
|
114
|
+
// Test 3: Simulating post-INSERT transition
|
|
115
|
+
// -----------------------------------------------------------
|
|
116
|
+
console.log("\nTest 3: After INSERT, entity transitions to track state");
|
|
117
|
+
console.log("──────────────────────────────────────────────────");
|
|
118
|
+
{
|
|
119
|
+
const ctx = makeContext(userEntity);
|
|
120
|
+
const dbset = makeDbSet(ctx, userEntity);
|
|
121
|
+
const entity = dbset.new();
|
|
122
|
+
entity.status = 'queued';
|
|
123
|
+
|
|
124
|
+
// Simulate what _processBatchInserts now does after INSERT
|
|
125
|
+
entity.__state = 'track';
|
|
126
|
+
entity.__dirtyFields = [];
|
|
127
|
+
|
|
128
|
+
// Simulate saveChanges clearing tracked entities
|
|
129
|
+
ctx.__clearTracked();
|
|
130
|
+
|
|
131
|
+
assert(entity.__state === 'track', 'State is "track" after INSERT');
|
|
132
|
+
assert(entity.__dirtyFields.length === 0, 'dirtyFields cleared');
|
|
133
|
+
assert(!ctx.__trackedEntitiesMap.has(entity.__ID), 'Entity removed from tracker');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// -----------------------------------------------------------
|
|
137
|
+
// Test 4: Modifying after INSERT transitions to "modified" and re-tracks
|
|
138
|
+
// -----------------------------------------------------------
|
|
139
|
+
console.log("\nTest 4: Modification after INSERT transitions to modified + re-tracks");
|
|
140
|
+
console.log("──────────────────────────────────────────────────");
|
|
141
|
+
{
|
|
142
|
+
const ctx = makeContext(userEntity);
|
|
143
|
+
const dbset = makeDbSet(ctx, userEntity);
|
|
144
|
+
const entity = dbset.new();
|
|
145
|
+
entity.status = 'queued';
|
|
146
|
+
|
|
147
|
+
// Simulate post-INSERT transition + clear
|
|
148
|
+
entity.__state = 'track';
|
|
149
|
+
entity.__dirtyFields = [];
|
|
150
|
+
ctx.__clearTracked();
|
|
151
|
+
|
|
152
|
+
// Now modify — this is the bug scenario
|
|
153
|
+
entity.status = 'completed';
|
|
154
|
+
|
|
155
|
+
assert(entity.__state === 'modified', 'State transitions to "modified"');
|
|
156
|
+
assert(entity.__dirtyFields.includes('status'), 'status in dirtyFields');
|
|
157
|
+
assert(entity.__dirtyFields.length === 1, 'Only modified field is dirty');
|
|
158
|
+
assert(ctx.__trackedEntitiesMap.has(entity.__ID), 'Entity re-registered with tracker');
|
|
159
|
+
assert(ctx.__trackedEntities.length === 1, 'Exactly one tracked entity');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// -----------------------------------------------------------
|
|
163
|
+
// Test 5: Multiple modifications after INSERT
|
|
164
|
+
// -----------------------------------------------------------
|
|
165
|
+
console.log("\nTest 5: Multiple modifications after INSERT");
|
|
166
|
+
console.log("──────────────────────────────────────────────────");
|
|
167
|
+
{
|
|
168
|
+
const ctx = makeContext(userEntity);
|
|
169
|
+
const dbset = makeDbSet(ctx, userEntity);
|
|
170
|
+
const entity = dbset.new();
|
|
171
|
+
entity.status = 'queued';
|
|
172
|
+
entity.name = 'Job1';
|
|
173
|
+
|
|
174
|
+
// Simulate post-INSERT + clear
|
|
175
|
+
entity.__state = 'track';
|
|
176
|
+
entity.__dirtyFields = [];
|
|
177
|
+
ctx.__clearTracked();
|
|
178
|
+
|
|
179
|
+
// Multiple modifications
|
|
180
|
+
entity.status = 'completed';
|
|
181
|
+
entity.name = 'Job1-done';
|
|
182
|
+
|
|
183
|
+
assert(entity.__state === 'modified', 'State is "modified"');
|
|
184
|
+
assert(entity.__dirtyFields.includes('status'), 'status tracked');
|
|
185
|
+
assert(entity.__dirtyFields.includes('name'), 'name tracked');
|
|
186
|
+
assert(ctx.__trackedEntities.length === 1, 'Entity tracked only once (idempotent)');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// -----------------------------------------------------------
|
|
190
|
+
// Test 6: Entity value is correct after post-INSERT modification
|
|
191
|
+
// -----------------------------------------------------------
|
|
192
|
+
console.log("\nTest 6: Values are correct through full lifecycle");
|
|
193
|
+
console.log("──────────────────────────────────────────────────");
|
|
194
|
+
{
|
|
195
|
+
const ctx = makeContext(userEntity);
|
|
196
|
+
const dbset = makeDbSet(ctx, userEntity);
|
|
197
|
+
const entity = dbset.new();
|
|
198
|
+
entity.status = 'queued';
|
|
199
|
+
|
|
200
|
+
assert(entity.status === 'queued', 'Initial value correct');
|
|
201
|
+
|
|
202
|
+
// Post-INSERT
|
|
203
|
+
entity.__state = 'track';
|
|
204
|
+
entity.__dirtyFields = [];
|
|
205
|
+
ctx.__clearTracked();
|
|
206
|
+
|
|
207
|
+
assert(entity.status === 'queued', 'Value preserved after INSERT transition');
|
|
208
|
+
|
|
209
|
+
entity.status = 'completed';
|
|
210
|
+
assert(entity.status === 'completed', 'Updated value correct');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// -----------------------------------------------------------
|
|
214
|
+
// Test 7: _processTrackedEntities would route correctly
|
|
215
|
+
// -----------------------------------------------------------
|
|
216
|
+
console.log("\nTest 7: Simulated _processTrackedEntities routes to UPDATE");
|
|
217
|
+
console.log("──────────────────────────────────────────────────");
|
|
218
|
+
{
|
|
219
|
+
const ctx = makeContext(userEntity);
|
|
220
|
+
const dbset = makeDbSet(ctx, userEntity);
|
|
221
|
+
const entity = dbset.new();
|
|
222
|
+
entity.status = 'queued';
|
|
223
|
+
|
|
224
|
+
// Post-INSERT
|
|
225
|
+
entity.__state = 'track';
|
|
226
|
+
entity.__dirtyFields = [];
|
|
227
|
+
ctx.__clearTracked();
|
|
228
|
+
|
|
229
|
+
// Modify
|
|
230
|
+
entity.status = 'completed';
|
|
231
|
+
|
|
232
|
+
// Simulate _processTrackedEntities grouping
|
|
233
|
+
const toInsert = [];
|
|
234
|
+
const toUpdate = [];
|
|
235
|
+
for (const tracked of ctx.__trackedEntities) {
|
|
236
|
+
switch (tracked.__state) {
|
|
237
|
+
case 'insert': toInsert.push(tracked); break;
|
|
238
|
+
case 'modified':
|
|
239
|
+
if (tracked.__dirtyFields && tracked.__dirtyFields.length > 0) {
|
|
240
|
+
toUpdate.push(tracked);
|
|
241
|
+
}
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
assert(toInsert.length === 0, 'No entities routed to INSERT');
|
|
247
|
+
assert(toUpdate.length === 1, 'One entity routed to UPDATE');
|
|
248
|
+
assert(toUpdate[0] === entity, 'Correct entity routed to UPDATE');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Summary
|
|
252
|
+
console.log("\n" + "=".repeat(64));
|
|
253
|
+
console.log(`Test Results: ${passed} passed, ${failed} failed`);
|
|
254
|
+
console.log("=".repeat(64));
|
|
255
|
+
|
|
256
|
+
if (failed > 0) {
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
@@ -219,6 +219,67 @@ console.log("──────────────────────
|
|
|
219
219
|
assert(err.errors.length === 3, 'Three errors (Promise + Array + Object)');
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
// -----------------------------------------------------------
|
|
223
|
+
// Test 9: Array allowed when field has a custom set() transform
|
|
224
|
+
// -----------------------------------------------------------
|
|
225
|
+
console.log("\nTest 9: Array allowed with custom set() transform");
|
|
226
|
+
console.log("──────────────────────────────────────────────────");
|
|
227
|
+
{
|
|
228
|
+
const entityWithSetter = {
|
|
229
|
+
__name: 'BatchJob',
|
|
230
|
+
id: { type: 'integer', primary: true, auto: true, nullable: true },
|
|
231
|
+
name: { type: 'string', nullable: false },
|
|
232
|
+
error_log: { type: 'string', nullable: true, set: function(value) {
|
|
233
|
+
if (Array.isArray(value)) return JSON.stringify(value);
|
|
234
|
+
return value;
|
|
235
|
+
}},
|
|
236
|
+
config_snapshot: { type: 'string', nullable: true, set: function(value) {
|
|
237
|
+
if (typeof value === 'object' && value !== null) return JSON.stringify(value);
|
|
238
|
+
return value;
|
|
239
|
+
}},
|
|
240
|
+
};
|
|
241
|
+
const { mgr, err } = makeManager();
|
|
242
|
+
const realModel = {
|
|
243
|
+
name: 'job1',
|
|
244
|
+
error_log: ['err1', 'err2'],
|
|
245
|
+
config_snapshot: { retries: 3 },
|
|
246
|
+
__entity: entityWithSetter,
|
|
247
|
+
};
|
|
248
|
+
const cleanModel = { ...realModel };
|
|
249
|
+
|
|
250
|
+
mgr.validateEntity(cleanModel, realModel, entityWithSetter);
|
|
251
|
+
|
|
252
|
+
assert(err.isValid, 'Array with set() passes validation');
|
|
253
|
+
assert(err.errors.length === 0, 'No errors when set() is defined');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// -----------------------------------------------------------
|
|
257
|
+
// Test 10: Promise still rejected even when field has set()
|
|
258
|
+
// -----------------------------------------------------------
|
|
259
|
+
console.log("\nTest 10: Promise rejected even with custom set()");
|
|
260
|
+
console.log("──────────────────────────────────────────────────");
|
|
261
|
+
{
|
|
262
|
+
const entityWithSetter = {
|
|
263
|
+
__name: 'BatchJob',
|
|
264
|
+
id: { type: 'integer', primary: true, auto: true, nullable: true },
|
|
265
|
+
error_log: { type: 'string', nullable: true, set: function(value) {
|
|
266
|
+
if (Array.isArray(value)) return JSON.stringify(value);
|
|
267
|
+
return value;
|
|
268
|
+
}},
|
|
269
|
+
};
|
|
270
|
+
const { mgr, err } = makeManager();
|
|
271
|
+
const realModel = {
|
|
272
|
+
error_log: Promise.resolve(['err1']),
|
|
273
|
+
__entity: entityWithSetter,
|
|
274
|
+
};
|
|
275
|
+
const cleanModel = { ...realModel };
|
|
276
|
+
|
|
277
|
+
mgr.validateEntity(cleanModel, realModel, entityWithSetter);
|
|
278
|
+
|
|
279
|
+
assert(!err.isValid, 'Promise still rejected with set()');
|
|
280
|
+
assert(err.errors[0].includes('Promise'), 'Error mentions Promise');
|
|
281
|
+
}
|
|
282
|
+
|
|
222
283
|
// Summary
|
|
223
284
|
console.log("\n" + "=".repeat(64));
|
|
224
285
|
console.log(`Test Results: ${passed} passed, ${failed} failed`);
|