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.
@@ -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 $$ first (preferred), then $ (backwards compatibility)
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(/\$\$/g, placeholders);
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 $$ first (preferred), then $ (backwards compatibility)
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(/\$\$/g, placeholder);
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}`;