masterrecord 0.3.26 → 0.3.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +21 -1
- package/Entity/entityModel.js +136 -0
- package/Entity/entityModelBuilder.js +21 -3
- package/Entity/entityTrackerModel.js +251 -1
- package/QueryLanguage/queryMethods.js +330 -4
- package/SQLLiteEngine.js +4 -0
- package/Tools.js +15 -2
- package/context.js +198 -5
- package/mySQLEngine.js +11 -1
- package/package.json +1 -1
- package/postgresEngine.js +6 -1
- package/readme.md +1070 -102
- package/test/bulk-operations-test.js +235 -0
- package/test/cache-toObject-test.js +105 -0
- package/test/debug-id-test.js +63 -0
- package/test/double-where-bug-test.js +71 -0
- package/test/entity-methods-test.js +269 -0
- package/test/id-setting-validation.js +202 -0
- package/test/insert-return-test.js +39 -0
- package/test/lifecycle-hooks-test.js +258 -0
- package/test/query-helpers-test.js +258 -0
- package/test/query-isolation-test.js +59 -0
- package/test/simple-id-test.js +61 -0
- package/test/single-user-id-test.js +70 -0
- package/test/validation-test.js +302 -0
|
@@ -196,6 +196,76 @@ class queryMethods{
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Get first record ordered by primary key
|
|
201
|
+
*/
|
|
202
|
+
async first() {
|
|
203
|
+
// Find primary key
|
|
204
|
+
let primaryKey = null;
|
|
205
|
+
for (const fieldName in this.__entity) {
|
|
206
|
+
if (this.__entity[fieldName]?.primary === true) {
|
|
207
|
+
primaryKey = fieldName;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (primaryKey && !this.__queryObject.script.orderBy) {
|
|
213
|
+
// Use proper orderBy syntax with lambda expression
|
|
214
|
+
const orderByExpr = `e => e.${primaryKey}`;
|
|
215
|
+
this.orderBy(orderByExpr);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.__queryObject.script.take = 1;
|
|
219
|
+
return await this.single();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get last record ordered by primary key descending
|
|
224
|
+
*/
|
|
225
|
+
async last() {
|
|
226
|
+
let primaryKey = null;
|
|
227
|
+
for (const fieldName in this.__entity) {
|
|
228
|
+
if (this.__entity[fieldName]?.primary === true) {
|
|
229
|
+
primaryKey = fieldName;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (primaryKey && !this.__queryObject.script.orderBy) {
|
|
235
|
+
// Use proper orderByDescending syntax with lambda expression
|
|
236
|
+
const orderByExpr = `e => e.${primaryKey}`;
|
|
237
|
+
this.orderByDescending(orderByExpr);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.__queryObject.script.take = 1;
|
|
241
|
+
return await this.single();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check if any records match the query
|
|
246
|
+
*/
|
|
247
|
+
async exists() {
|
|
248
|
+
this.__queryObject.script.take = 1;
|
|
249
|
+
const result = await this.single();
|
|
250
|
+
return result !== null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Extract single column values as array
|
|
255
|
+
*/
|
|
256
|
+
async pluck(fieldName) {
|
|
257
|
+
if (!fieldName || typeof fieldName !== 'string') {
|
|
258
|
+
throw new Error('pluck() requires a field name string');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!this.__entity[fieldName]) {
|
|
262
|
+
throw new Error(`Field '${fieldName}' does not exist on ${this.__entity.__name}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const entities = await this.toList();
|
|
266
|
+
return entities.map(entity => entity[fieldName]);
|
|
267
|
+
}
|
|
268
|
+
|
|
199
269
|
/**
|
|
200
270
|
* Transform .includes() syntax to .any() syntax
|
|
201
271
|
* Converts: $$.includes(entity.field) => entity.field.any($$)
|
|
@@ -282,9 +352,10 @@ class queryMethods{
|
|
|
282
352
|
|
|
283
353
|
// Add array parameters and get comma-separated placeholders
|
|
284
354
|
const placeholders = this.__queryObject.parameters.addParams(itemArray, dbType);
|
|
285
|
-
// Replace $$
|
|
355
|
+
// Replace ONLY FIRST $$ occurrence (not all with /g flag)
|
|
356
|
+
// This ensures each parameter gets replaced in order
|
|
286
357
|
if(str.includes('$$')){
|
|
287
|
-
str = str.replace(
|
|
358
|
+
str = str.replace(/\$\$/, placeholders); // ✅ No 'g' flag - replace first only
|
|
288
359
|
} else {
|
|
289
360
|
// Replace single $ but not $N (postgres placeholders)
|
|
290
361
|
str = str.replace(/\$(?!\d)/, placeholders);
|
|
@@ -303,9 +374,10 @@ class queryMethods{
|
|
|
303
374
|
|
|
304
375
|
// Add parameter and replace placeholder
|
|
305
376
|
const placeholder = this.__queryObject.parameters.addParam(item, dbType);
|
|
306
|
-
// Replace $$
|
|
377
|
+
// Replace ONLY FIRST $$ occurrence (not all with /g flag)
|
|
378
|
+
// This ensures each parameter gets replaced in order
|
|
307
379
|
if(str.includes('$$')){
|
|
308
|
-
str = str.replace(
|
|
380
|
+
str = str.replace(/\$\$/, placeholder); // ✅ No 'g' flag - replace first only
|
|
309
381
|
} else {
|
|
310
382
|
// Replace single $ but not $N (postgres placeholders)
|
|
311
383
|
str = str.replace(/\$(?!\d)/, placeholder);
|
|
@@ -358,6 +430,7 @@ class queryMethods{
|
|
|
358
430
|
const cached = this.__context._queryCache.get(cacheKey);
|
|
359
431
|
if (cached) {
|
|
360
432
|
this.__reset();
|
|
433
|
+
// Cached entities already have methods - return directly
|
|
361
434
|
return cached;
|
|
362
435
|
}
|
|
363
436
|
}
|
|
@@ -407,6 +480,7 @@ class queryMethods{
|
|
|
407
480
|
const cached = this.__context._queryCache.get(cacheKey);
|
|
408
481
|
if (cached) {
|
|
409
482
|
this.__reset();
|
|
483
|
+
// Cached entities already have methods - return array directly
|
|
410
484
|
return cached;
|
|
411
485
|
}
|
|
412
486
|
}
|
|
@@ -465,6 +539,66 @@ class queryMethods{
|
|
|
465
539
|
enumerable: true,
|
|
466
540
|
configurable: true,
|
|
467
541
|
set: function(value) {
|
|
542
|
+
// Run validators before setting value
|
|
543
|
+
if (fieldDef && fieldDef.validators && Array.isArray(fieldDef.validators)) {
|
|
544
|
+
for (const validator of fieldDef.validators) {
|
|
545
|
+
let isValid = true;
|
|
546
|
+
let errorMsg = validator.message;
|
|
547
|
+
|
|
548
|
+
switch (validator.type) {
|
|
549
|
+
case 'required':
|
|
550
|
+
isValid = value !== null && value !== undefined && value !== '';
|
|
551
|
+
break;
|
|
552
|
+
|
|
553
|
+
case 'email':
|
|
554
|
+
if (value) {
|
|
555
|
+
isValid = validator.pattern.test(value);
|
|
556
|
+
}
|
|
557
|
+
break;
|
|
558
|
+
|
|
559
|
+
case 'minLength':
|
|
560
|
+
if (value && typeof value === 'string') {
|
|
561
|
+
isValid = value.length >= validator.length;
|
|
562
|
+
}
|
|
563
|
+
break;
|
|
564
|
+
|
|
565
|
+
case 'maxLength':
|
|
566
|
+
if (value && typeof value === 'string') {
|
|
567
|
+
isValid = value.length <= validator.length;
|
|
568
|
+
}
|
|
569
|
+
break;
|
|
570
|
+
|
|
571
|
+
case 'pattern':
|
|
572
|
+
if (value) {
|
|
573
|
+
isValid = validator.pattern.test(value);
|
|
574
|
+
}
|
|
575
|
+
break;
|
|
576
|
+
|
|
577
|
+
case 'min':
|
|
578
|
+
if (value !== null && value !== undefined) {
|
|
579
|
+
isValid = Number(value) >= validator.min;
|
|
580
|
+
}
|
|
581
|
+
break;
|
|
582
|
+
|
|
583
|
+
case 'max':
|
|
584
|
+
if (value !== null && value !== undefined) {
|
|
585
|
+
isValid = Number(value) <= validator.max;
|
|
586
|
+
}
|
|
587
|
+
break;
|
|
588
|
+
|
|
589
|
+
case 'custom':
|
|
590
|
+
if (typeof validator.validator === 'function') {
|
|
591
|
+
isValid = validator.validator(value);
|
|
592
|
+
}
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (!isValid) {
|
|
597
|
+
throw new Error(`Validation failed: ${errorMsg}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
468
602
|
this.__proto__["_" + fname] = value;
|
|
469
603
|
if(!this.__dirtyFields.includes(fname)){
|
|
470
604
|
this.__dirtyFields.push(fname);
|
|
@@ -498,8 +632,200 @@ class queryMethods{
|
|
|
498
632
|
return await this.__context.saveChanges();
|
|
499
633
|
};
|
|
500
634
|
|
|
635
|
+
// Convert entity to plain JavaScript object
|
|
636
|
+
newEntity.toObject = function(options = {}) {
|
|
637
|
+
const includeRelationships = options.includeRelationships !== false;
|
|
638
|
+
const depth = options.depth || 1;
|
|
639
|
+
const visited = options._visited || new WeakSet();
|
|
640
|
+
|
|
641
|
+
// Prevent circular reference infinite loops
|
|
642
|
+
if (visited.has(this)) {
|
|
643
|
+
return { __circular: true, __entityName: this.__name, id: this[this.__primaryKey] };
|
|
644
|
+
}
|
|
645
|
+
visited.add(this);
|
|
646
|
+
|
|
647
|
+
const plain = {};
|
|
648
|
+
|
|
649
|
+
// Iterate through entity definition
|
|
650
|
+
for (const fieldName in this.__entity) {
|
|
651
|
+
if (fieldName.startsWith('__')) continue;
|
|
652
|
+
|
|
653
|
+
const fieldDef = this.__entity[fieldName];
|
|
654
|
+
const isRelationship = fieldDef?.type === 'hasMany' ||
|
|
655
|
+
fieldDef?.type === 'hasOne' ||
|
|
656
|
+
fieldDef?.relationshipType === 'belongsTo';
|
|
657
|
+
|
|
658
|
+
// Skip relationships in this pass
|
|
659
|
+
if (!isRelationship) {
|
|
660
|
+
try {
|
|
661
|
+
plain[fieldName] = this[fieldName];
|
|
662
|
+
} catch (e) {
|
|
663
|
+
// Skip fields that throw errors when accessed
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Handle relationships recursively with depth limit and cycle detection
|
|
669
|
+
if (includeRelationships && depth > 0) {
|
|
670
|
+
for (const fieldName in this.__entity) {
|
|
671
|
+
const fieldDef = this.__entity[fieldName];
|
|
672
|
+
const isRelationship = fieldDef?.type === 'hasMany' ||
|
|
673
|
+
fieldDef?.type === 'hasOne' ||
|
|
674
|
+
fieldDef?.relationshipType === 'belongsTo';
|
|
675
|
+
|
|
676
|
+
if (isRelationship) {
|
|
677
|
+
try {
|
|
678
|
+
const value = this[fieldName];
|
|
679
|
+
|
|
680
|
+
if (Array.isArray(value)) {
|
|
681
|
+
plain[fieldName] = value.map(item => {
|
|
682
|
+
if (item?.toObject && typeof item.toObject === 'function') {
|
|
683
|
+
return item.toObject({
|
|
684
|
+
depth: depth - 1,
|
|
685
|
+
_visited: visited
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
return item;
|
|
689
|
+
});
|
|
690
|
+
} else if (value?.toObject && typeof value.toObject === 'function') {
|
|
691
|
+
plain[fieldName] = value.toObject({
|
|
692
|
+
depth: depth - 1,
|
|
693
|
+
_visited: visited
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
} catch (e) {
|
|
697
|
+
// Skip relationships that throw errors when accessed
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return plain;
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
// JSON.stringify compatibility
|
|
707
|
+
newEntity.toJSON = function() {
|
|
708
|
+
return this.toObject({ includeRelationships: false });
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
// Delete entity from database
|
|
712
|
+
newEntity.delete = async function() {
|
|
713
|
+
if (!this.__context) {
|
|
714
|
+
throw new Error('Cannot delete: entity is not attached to a context');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// Mark entity for deletion
|
|
718
|
+
this.__state = 'delete';
|
|
719
|
+
|
|
720
|
+
// Ensure entity is tracked
|
|
721
|
+
if (!this.__context.__trackedEntitiesMap.has(this.__ID)) {
|
|
722
|
+
this.__context.__track(this);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Execute delete via saveChanges
|
|
726
|
+
return await this.__context.saveChanges();
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
// Reload entity from database
|
|
730
|
+
newEntity.reload = async function() {
|
|
731
|
+
if (!this.__context) {
|
|
732
|
+
throw new Error('Cannot reload: entity is not attached to a context');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Get primary key
|
|
736
|
+
let primaryKey = null;
|
|
737
|
+
for (const fieldName in this.__entity) {
|
|
738
|
+
if (this.__entity[fieldName]?.primary === true) {
|
|
739
|
+
primaryKey = fieldName;
|
|
740
|
+
break;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const primaryKeyValue = this[primaryKey];
|
|
745
|
+
|
|
746
|
+
if (!primaryKeyValue) {
|
|
747
|
+
throw new Error('Cannot reload: entity has no primary key value');
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Fetch fresh from database
|
|
751
|
+
const EntityClass = this.__context[this.__name];
|
|
752
|
+
const fresh = await EntityClass.findById(primaryKeyValue);
|
|
753
|
+
if (!fresh) {
|
|
754
|
+
throw new Error(
|
|
755
|
+
`Cannot reload: ${this.__name} with ${primaryKey}=${primaryKeyValue} not found`
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Copy all field values from fresh entity to this entity
|
|
760
|
+
for (const fieldName in this.__entity) {
|
|
761
|
+
if (fieldName.startsWith('__')) continue;
|
|
762
|
+
|
|
763
|
+
const fieldDef = this.__entity[fieldName];
|
|
764
|
+
const isRelationship = fieldDef?.type === 'hasMany' ||
|
|
765
|
+
fieldDef?.type === 'hasOne' ||
|
|
766
|
+
fieldDef?.relationshipType === 'belongsTo';
|
|
767
|
+
|
|
768
|
+
// Only reload scalar fields
|
|
769
|
+
if (!isRelationship) {
|
|
770
|
+
this.__proto__["_" + fieldName] = fresh.__proto__["_" + fieldName];
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Reset dirty fields and state
|
|
775
|
+
this.__dirtyFields = [];
|
|
776
|
+
this.__state = 'track';
|
|
777
|
+
|
|
778
|
+
return this;
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
// Clone entity for duplication
|
|
782
|
+
newEntity.clone = function() {
|
|
783
|
+
if (!this.__context) {
|
|
784
|
+
throw new Error('Cannot clone: entity is not attached to a context');
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const EntityClass = this.__context[this.__name];
|
|
788
|
+
const cloned = EntityClass.new();
|
|
789
|
+
|
|
790
|
+
// Get primary key (to skip it)
|
|
791
|
+
let primaryKey = null;
|
|
792
|
+
for (const fieldName in this.__entity) {
|
|
793
|
+
if (this.__entity[fieldName]?.primary === true) {
|
|
794
|
+
primaryKey = fieldName;
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Copy all non-primary key fields
|
|
800
|
+
for (const fieldName in this.__entity) {
|
|
801
|
+
if (fieldName.startsWith('__')) continue;
|
|
802
|
+
if (fieldName === primaryKey) continue;
|
|
803
|
+
|
|
804
|
+
const fieldDef = this.__entity[fieldName];
|
|
805
|
+
const isRelationship = fieldDef?.type === 'hasMany' ||
|
|
806
|
+
fieldDef?.type === 'hasOne' ||
|
|
807
|
+
fieldDef?.relationshipType === 'belongsTo';
|
|
808
|
+
|
|
809
|
+
if (!isRelationship) {
|
|
810
|
+
cloned[fieldName] = this[fieldName];
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return cloned;
|
|
815
|
+
};
|
|
816
|
+
|
|
501
817
|
// Track the entity
|
|
502
818
|
this.__context.__track(newEntity);
|
|
819
|
+
|
|
820
|
+
// Copy lifecycle hooks from entity definition to entity instance
|
|
821
|
+
for (const fieldName in this.__entity) {
|
|
822
|
+
const fieldDef = this.__entity[fieldName];
|
|
823
|
+
if (fieldDef && fieldDef.lifecycle === true && fieldDef.method) {
|
|
824
|
+
// Bind the lifecycle hook method directly to this entity instance
|
|
825
|
+
newEntity[fieldName] = fieldDef.method.bind(newEntity);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
503
829
|
return newEntity;
|
|
504
830
|
}
|
|
505
831
|
|
package/SQLLiteEngine.js
CHANGED
|
@@ -583,6 +583,10 @@ class SQLLiteEngine {
|
|
|
583
583
|
var $that = this;
|
|
584
584
|
for (var ent in entity) {
|
|
585
585
|
if(!ent.startsWith("_")){
|
|
586
|
+
// Skip lifecycle hooks - they are not database columns
|
|
587
|
+
if(entity[ent].lifecycle === true){
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
586
590
|
if(!entity[ent].foreignKey){
|
|
587
591
|
if(entity[ent].relationshipTable){
|
|
588
592
|
if($that.chechUnsupportedWords(entity[ent].relationshipTable)){
|
package/Tools.js
CHANGED
|
@@ -84,7 +84,7 @@ class Tools{
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
static clearAllProto(proto){
|
|
87
|
-
|
|
87
|
+
|
|
88
88
|
var newproto = {}
|
|
89
89
|
if(proto.__proto__ ){
|
|
90
90
|
// Include non-enumerable own properties so we don't lose values defined via getters
|
|
@@ -93,6 +93,15 @@ class Tools{
|
|
|
93
93
|
if(!key.startsWith("_") && !key.startsWith("__")){
|
|
94
94
|
try{
|
|
95
95
|
const value = proto[key];
|
|
96
|
+
// Skip lifecycle hooks by checking entity definition
|
|
97
|
+
if(proto.__entity && proto.__entity[key] && proto.__entity[key].lifecycle === true){
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
// Skip functions EXCEPT if they're defined via getters (typeof returns value, not function)
|
|
101
|
+
// Only skip if it's actually a function value (methods like save, delete, toObject)
|
|
102
|
+
if(typeof value === "function"){
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
96
105
|
if(typeof value === "object" && value !== null){
|
|
97
106
|
// Recursively clone nested objects without altering the source
|
|
98
107
|
newproto[key] = this.clearAllProto(value);
|
|
@@ -180,10 +189,14 @@ class Tools{
|
|
|
180
189
|
// converts any object into SQL parameter select string
|
|
181
190
|
|
|
182
191
|
static convertEntityToSelectParameterString(obj){
|
|
183
|
-
// todo: loop throgh object and append string with comma to
|
|
192
|
+
// todo: loop throgh object and append string with comma to
|
|
184
193
|
var mainString = "";
|
|
185
194
|
const entries = Object.keys(obj);
|
|
186
195
|
for (const key of entries) {
|
|
196
|
+
// Skip lifecycle hooks - they are not database columns
|
|
197
|
+
if(obj[key].lifecycle === true){
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
187
200
|
if(obj[key].type !== 'hasManyThrough' && obj[key].type !== "hasMany" && obj[key].type !== "hasOne"){
|
|
188
201
|
if(obj[key].name){
|
|
189
202
|
mainString = mainString === "" ? `${obj.__name}.${obj[key].name}` : `${mainString}, ${obj.__name}.${obj[key].name}`;
|