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.
@@ -38,7 +38,27 @@
38
38
  "Bash(master=development masterrecord update-database qaContext)",
39
39
  "Bash(chmod:*)",
40
40
  "Bash(npm install:*)",
41
- "Bash(sqlite3:*)"
41
+ "Bash(sqlite3:*)",
42
+ "Bash(for file in /Users/alexanderrich/Documents/development/bookbaghq/bookbag-training/components/models/app/controllers/api/*.js)",
43
+ "Bash(do)",
44
+ "Bash(if grep -q \"this._currentUser = req.authService.currentUser\" \"$file\")",
45
+ "Bash(then)",
46
+ "Bash(fi)",
47
+ "Bash(echo:*)",
48
+ "Bash(/tmp/fix_controllers.sh)",
49
+ "Bash(wc -l echo \"\" echo \"Controllers now using async pattern:\" find /Users/alexanderrich/Documents/development/bookbaghq/bookbag-training/components -name \"*Controller.js\" -path \"*/controllers/api/*\" -exec grep -l \"async _getCurrentUser\\(\\)\" {})",
50
+ "Bash(/tmp/fix_controllers.sh:*)",
51
+ "Bash(wc -l echo \"\" echo \"Total with async _getCurrentUser\\(\\):\" find /Users/alexanderrich/Documents/development/bookbaghq/bookbag-training/components -name \"*Controller.js\" -path \"*/controllers/api/*\" -exec grep -l \"async _getCurrentUser\\(\\)\" {})",
52
+ "Bash(wc -l echo echo 'Controllers with async _getCurrentUser\\(\\):' find /Users/alexanderrich/Documents/development/bookbaghq/bookbag-training/components -name *Controller.js -path */controllers/* -exec grep -l 'async _getCurrentUser\\(\\)' {})",
53
+ "Bash(sort -u __NEW_LINE_463d116b631aaffc__ echo 'Actual controller files \\(without .js extension\\):' find /Users/alexanderrich/Documents/development/bookbaghq/bookbag-training/components/*/app/controllers/api -name *.js -type f)",
54
+ "Bash(xargs:*)",
55
+ "Bash(sort -u __NEW_LINE_463d116b631aaffc__ echo echo 'Files that exist but not referenced in routes \\(might be OK\\):' comm -13 /tmp/route_refs.txt /tmp/actual_files.txt)",
56
+ "Bash(head -10 __NEW_LINE_463d116b631aaffc__ echo \"\" echo \"Routes referencing non-existent files \\(PROBLEMS\\):\" comm -23 /tmp/route_refs.txt /tmp/actual_files.txt)",
57
+ "Bash(for file in /Users/alexanderrich/Documents/development/bookbaghq/bookbag-training/components/qa/app/controllers/api/*.js)",
58
+ "Bash(wc:*)",
59
+ "Bash(npm link)",
60
+ "Bash(npm link:*)",
61
+ "Bash(1)"
42
62
  ],
43
63
  "deny": [],
44
64
  "ask": []
@@ -111,10 +111,18 @@ class EntityModel {
111
111
 
112
112
  unique(){
113
113
  this.obj.unique = true; // yes
114
- return this;
114
+ return this;
115
115
 
116
116
  }
117
117
 
118
+ index(indexName){
119
+ if(!this.obj.indexes){
120
+ this.obj.indexes = [];
121
+ }
122
+ this.obj.indexes.push(indexName || true);
123
+ return this;
124
+ }
125
+
118
126
  // this means that it can be an empty field
119
127
  nullable(){
120
128
  this.obj.nullable = true; // yes
@@ -217,5 +225,141 @@ class EntityModel {
217
225
  this.obj.nullable = false;
218
226
  return this
219
227
  }
228
+
229
+ // ===== Validation Methods =====
230
+
231
+ /**
232
+ * Validate that field value is required (not null, undefined, or empty string)
233
+ * @param {string} message - Custom error message
234
+ */
235
+ required(message) {
236
+ if (!this.obj.validators) {
237
+ this.obj.validators = [];
238
+ }
239
+ this.obj.validators.push({
240
+ type: 'required',
241
+ message: message || `${this.obj.name} is required`
242
+ });
243
+ this.obj.nullable = false;
244
+ return this;
245
+ }
246
+
247
+ /**
248
+ * Validate email format
249
+ * @param {string} message - Custom error message
250
+ */
251
+ email(message) {
252
+ if (!this.obj.validators) {
253
+ this.obj.validators = [];
254
+ }
255
+ this.obj.validators.push({
256
+ type: 'email',
257
+ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
258
+ message: message || `${this.obj.name} must be a valid email address`
259
+ });
260
+ return this;
261
+ }
262
+
263
+ /**
264
+ * Validate minimum string length
265
+ * @param {number} length - Minimum length
266
+ * @param {string} message - Custom error message
267
+ */
268
+ minLength(length, message) {
269
+ if (!this.obj.validators) {
270
+ this.obj.validators = [];
271
+ }
272
+ this.obj.validators.push({
273
+ type: 'minLength',
274
+ length: length,
275
+ message: message || `${this.obj.name} must be at least ${length} characters`
276
+ });
277
+ return this;
278
+ }
279
+
280
+ /**
281
+ * Validate maximum string length
282
+ * @param {number} length - Maximum length
283
+ * @param {string} message - Custom error message
284
+ */
285
+ maxLength(length, message) {
286
+ if (!this.obj.validators) {
287
+ this.obj.validators = [];
288
+ }
289
+ this.obj.validators.push({
290
+ type: 'maxLength',
291
+ length: length,
292
+ message: message || `${this.obj.name} must be at most ${length} characters`
293
+ });
294
+ return this;
295
+ }
296
+
297
+ /**
298
+ * Validate against regex pattern
299
+ * @param {RegExp} pattern - Regular expression to match
300
+ * @param {string} message - Custom error message
301
+ */
302
+ pattern(regex, message) {
303
+ if (!this.obj.validators) {
304
+ this.obj.validators = [];
305
+ }
306
+ this.obj.validators.push({
307
+ type: 'pattern',
308
+ pattern: regex,
309
+ message: message || `${this.obj.name} format is invalid`
310
+ });
311
+ return this;
312
+ }
313
+
314
+ /**
315
+ * Validate minimum numeric value
316
+ * @param {number} min - Minimum value
317
+ * @param {string} message - Custom error message
318
+ */
319
+ min(minValue, message) {
320
+ if (!this.obj.validators) {
321
+ this.obj.validators = [];
322
+ }
323
+ this.obj.validators.push({
324
+ type: 'min',
325
+ min: minValue,
326
+ message: message || `${this.obj.name} must be at least ${minValue}`
327
+ });
328
+ return this;
329
+ }
330
+
331
+ /**
332
+ * Validate maximum numeric value
333
+ * @param {number} max - Maximum value
334
+ * @param {string} message - Custom error message
335
+ */
336
+ max(maxValue, message) {
337
+ if (!this.obj.validators) {
338
+ this.obj.validators = [];
339
+ }
340
+ this.obj.validators.push({
341
+ type: 'max',
342
+ max: maxValue,
343
+ message: message || `${this.obj.name} must be at most ${maxValue}`
344
+ });
345
+ return this;
346
+ }
347
+
348
+ /**
349
+ * Custom validation function
350
+ * @param {Function} validatorFn - Function that returns true if valid, false if invalid
351
+ * @param {string} message - Custom error message
352
+ */
353
+ custom(validatorFn, message) {
354
+ if (!this.obj.validators) {
355
+ this.obj.validators = [];
356
+ }
357
+ this.obj.validators.push({
358
+ type: 'custom',
359
+ validator: validatorFn,
360
+ message: message || `${this.obj.name} is invalid`
361
+ });
362
+ return this;
363
+ }
220
364
  }
221
365
  module.exports = EntityModel;
@@ -17,16 +17,34 @@ class EntityModelBuilder {
17
17
  if (constructorIndex > -1) {
18
18
  methodNamesArray.splice(constructorIndex, 1);
19
19
  }
20
+
21
+ // Define lifecycle hook method names that should not be treated as field definitions
22
+ const lifecycleHooks = ['beforeSave', 'afterSave', 'beforeDelete', 'afterDelete'];
23
+
20
24
  // loop through all method names in the entity model
21
25
  for (var i = 0; i < methodNamesArray.length; i++) {
26
+ const methodName = methodNamesArray[i];
27
+
28
+ // Skip lifecycle hooks - they should not be called during entity construction
29
+ if (lifecycleHooks.includes(methodName)) {
30
+ // Store lifecycle hooks with the actual method function so they can be copied to entity instances
31
+ obj[methodName] = {
32
+ virtual: true,
33
+ lifecycle: true,
34
+ name: methodName,
35
+ method: mod[methodName] // Store the method (not bound yet - will be bound to entity instance)
36
+ };
37
+ continue;
38
+ }
39
+
22
40
  let MDB = new modelDB(model.name); // create a new instance of entity Model class
23
- mod[methodNamesArray[i]](MDB);
41
+ mod[methodName](MDB);
24
42
  this.cleanNull(MDB.obj); // remove objects that are null or undefined
25
43
  if(Object.keys(MDB.obj).length === 0){
26
44
  MDB.obj.virtual = true;
27
45
  }
28
- MDB.obj.name = methodNamesArray[i];
29
- obj[methodNamesArray[i]] = MDB.obj;
46
+ MDB.obj.name = methodName;
47
+ obj[methodName] = MDB.obj;
30
48
  }
31
49
  return obj;
32
50
  }
@@ -14,11 +14,12 @@ class EntityTrackerModel {
14
14
  var modelClass = this.buildObject(); // build entity with models
15
15
  modelClass.__proto__ = {};
16
16
  const modelFields = Object.entries(dataModel); /// return array of objects
17
+
17
18
  modelClass.__entity = currentEntity;
18
19
  modelClass.__name = currentEntity.__name;
19
20
  modelClass.__context = context;
20
21
  this.buildRelationshipModels(modelClass, currentEntity, dataModel);
21
-
22
+
22
23
  // loop through data model fields
23
24
  for (const [modelField, modelFieldValue] of modelFields) {
24
25
 
@@ -44,6 +45,67 @@ class EntityTrackerModel {
44
45
 
45
46
  Object.defineProperty(modelClass,modelField, {
46
47
  set: function(value) {
48
+ // Run validators before setting value
49
+ const fieldDef = currentEntity[modelField];
50
+ if (fieldDef && fieldDef.validators && Array.isArray(fieldDef.validators)) {
51
+ for (const validator of fieldDef.validators) {
52
+ let isValid = true;
53
+ let errorMsg = validator.message;
54
+
55
+ switch (validator.type) {
56
+ case 'required':
57
+ isValid = value !== null && value !== undefined && value !== '';
58
+ break;
59
+
60
+ case 'email':
61
+ if (value) {
62
+ isValid = validator.pattern.test(value);
63
+ }
64
+ break;
65
+
66
+ case 'minLength':
67
+ if (value && typeof value === 'string') {
68
+ isValid = value.length >= validator.length;
69
+ }
70
+ break;
71
+
72
+ case 'maxLength':
73
+ if (value && typeof value === 'string') {
74
+ isValid = value.length <= validator.length;
75
+ }
76
+ break;
77
+
78
+ case 'pattern':
79
+ if (value) {
80
+ isValid = validator.pattern.test(value);
81
+ }
82
+ break;
83
+
84
+ case 'min':
85
+ if (value !== null && value !== undefined) {
86
+ isValid = Number(value) >= validator.min;
87
+ }
88
+ break;
89
+
90
+ case 'max':
91
+ if (value !== null && value !== undefined) {
92
+ isValid = Number(value) <= validator.max;
93
+ }
94
+ break;
95
+
96
+ case 'custom':
97
+ if (typeof validator.validator === 'function') {
98
+ isValid = validator.validator(value);
99
+ }
100
+ break;
101
+ }
102
+
103
+ if (!isValid) {
104
+ throw new Error(`Validation failed: ${errorMsg}`);
105
+ }
106
+ }
107
+ }
108
+
47
109
  modelClass.__state = "modified";
48
110
  modelClass.__dirtyFields.push(modelField);
49
111
  // ensure this entity is tracked on any modification
@@ -93,6 +155,194 @@ class EntityTrackerModel {
93
155
  return await this.__context.saveChanges();
94
156
  };
95
157
 
158
+ // Convert entity to plain JavaScript object
159
+ modelClass.toObject = function(options = {}) {
160
+ const includeRelationships = options.includeRelationships !== false;
161
+ const depth = options.depth || 1;
162
+ const visited = options._visited || new WeakSet();
163
+
164
+ // Prevent circular reference infinite loops
165
+ if (visited.has(this)) {
166
+ return { __circular: true, __entityName: this.__name, id: this[this.__primaryKey] };
167
+ }
168
+ visited.add(this);
169
+
170
+ const plain = {};
171
+
172
+ // Method 1: Access internal _values property (v0.3.28+ architecture)
173
+ // This is the FASTEST method - direct access to plain data storage
174
+ if (this._values && typeof this._values === 'object') {
175
+ for (const key in this._values) {
176
+ if (this._values.hasOwnProperty(key)) {
177
+ plain[key] = this._values[key];
178
+ }
179
+ }
180
+ } else {
181
+ // Method 2: Fallback - iterate through entity definition (for older versions)
182
+ for (const fieldName in this.__entity) {
183
+ if (fieldName.startsWith('__')) continue;
184
+
185
+ const fieldDef = this.__entity[fieldName];
186
+ const isRelationship = fieldDef?.type === 'hasMany' ||
187
+ fieldDef?.type === 'hasOne' ||
188
+ fieldDef?.relationshipType === 'belongsTo';
189
+
190
+ // Skip relationships in this pass
191
+ if (!isRelationship) {
192
+ try {
193
+ plain[fieldName] = this[fieldName];
194
+ } catch (e) {
195
+ // Skip fields that throw errors when accessed
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ // Handle relationships recursively with depth limit and cycle detection
202
+ if (includeRelationships && depth > 0) {
203
+ for (const fieldName in this.__entity) {
204
+ const fieldDef = this.__entity[fieldName];
205
+ const isRelationship = fieldDef?.type === 'hasMany' ||
206
+ fieldDef?.type === 'hasOne' ||
207
+ fieldDef?.relationshipType === 'belongsTo';
208
+
209
+ if (isRelationship) {
210
+ try {
211
+ const value = this[fieldName];
212
+
213
+ if (Array.isArray(value)) {
214
+ plain[fieldName] = value.map(item => {
215
+ if (item?.toObject && typeof item.toObject === 'function') {
216
+ return item.toObject({
217
+ depth: depth - 1,
218
+ _visited: visited
219
+ });
220
+ }
221
+ return item;
222
+ });
223
+ } else if (value?.toObject && typeof value.toObject === 'function') {
224
+ plain[fieldName] = value.toObject({
225
+ depth: depth - 1,
226
+ _visited: visited
227
+ });
228
+ }
229
+ } catch (e) {
230
+ // Skip relationships that throw errors when accessed
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ return plain;
237
+ };
238
+
239
+ // JSON.stringify compatibility - prevents circular reference errors
240
+ modelClass.toJSON = function() {
241
+ return this.toObject({ includeRelationships: false });
242
+ };
243
+
244
+ // Delete entity from database
245
+ modelClass.delete = async function() {
246
+ if (!this.__context) {
247
+ throw new Error('Cannot delete: entity is not attached to a context');
248
+ }
249
+
250
+ // Mark entity for deletion
251
+ this.__state = 'delete';
252
+
253
+ // Ensure entity is tracked
254
+ if (!this.__context.__trackedEntitiesMap.has(this.__ID)) {
255
+ this.__context.__track(this);
256
+ }
257
+
258
+ // Execute delete via saveChanges (handles cascade deletion)
259
+ return await this.__context.saveChanges();
260
+ };
261
+
262
+ // Reload entity from database
263
+ modelClass.reload = async function() {
264
+ if (!this.__context) {
265
+ throw new Error('Cannot reload: entity is not attached to a context');
266
+ }
267
+
268
+ // Get primary key
269
+ const primaryKey = tools.getPrimaryKeyObject(this.__entity);
270
+ const primaryKeyValue = this[primaryKey];
271
+
272
+ if (!primaryKeyValue) {
273
+ throw new Error('Cannot reload: entity has no primary key value');
274
+ }
275
+
276
+ // Fetch fresh from database
277
+ const EntityClass = this.__context[this.__name];
278
+ const fresh = await EntityClass.findById(primaryKeyValue);
279
+ if (!fresh) {
280
+ throw new Error(
281
+ `Cannot reload: ${this.__name} with ${primaryKey}=${primaryKeyValue} not found`
282
+ );
283
+ }
284
+
285
+ // Copy all field values from fresh entity to this entity
286
+ for (const fieldName in this.__entity) {
287
+ if (fieldName.startsWith('__')) continue;
288
+
289
+ const fieldDef = this.__entity[fieldName];
290
+ const isRelationship = fieldDef?.type === 'hasMany' ||
291
+ fieldDef?.type === 'hasOne' ||
292
+ fieldDef?.relationshipType === 'belongsTo';
293
+
294
+ // Only reload scalar fields
295
+ if (!isRelationship) {
296
+ this.__proto__["_" + fieldName] = fresh.__proto__["_" + fieldName];
297
+ }
298
+ }
299
+
300
+ // Reset dirty fields and state
301
+ this.__dirtyFields = [];
302
+ this.__state = 'track';
303
+
304
+ return this;
305
+ };
306
+
307
+ // Clone entity for duplication
308
+ modelClass.clone = function() {
309
+ if (!this.__context) {
310
+ throw new Error('Cannot clone: entity is not attached to a context');
311
+ }
312
+
313
+ const EntityClass = this.__context[this.__name];
314
+ const cloned = EntityClass.new();
315
+
316
+ // Get primary key (to skip it)
317
+ const primaryKey = tools.getPrimaryKeyObject(this.__entity);
318
+
319
+ // Copy all non-primary key fields
320
+ for (const fieldName in this.__entity) {
321
+ if (fieldName.startsWith('__')) continue;
322
+ if (fieldName === primaryKey) continue;
323
+
324
+ const fieldDef = this.__entity[fieldName];
325
+ const isRelationship = fieldDef?.type === 'hasMany' ||
326
+ fieldDef?.type === 'hasOne' ||
327
+ fieldDef?.relationshipType === 'belongsTo';
328
+
329
+ if (!isRelationship) {
330
+ cloned[fieldName] = this[fieldName];
331
+ }
332
+ }
333
+
334
+ return cloned;
335
+ };
336
+
337
+ // Copy lifecycle hooks from entity definition to entity instance
338
+ for (const fieldName in currentEntity) {
339
+ const fieldDef = currentEntity[fieldName];
340
+ if (fieldDef && fieldDef.lifecycle === true && fieldDef.method) {
341
+ // Bind the lifecycle hook method directly to this entity instance
342
+ modelClass[fieldName] = fieldDef.method.bind(modelClass);
343
+ }
344
+ }
345
+
96
346
  return modelClass;
97
347
  }
98
348
 
@@ -232,6 +232,20 @@ class migrationMySQLQuery {
232
232
  return `ALTER TABLE \`${table.tableName}\` RENAME COLUMN \`${table.name}\` TO \`${table.newName}\``
233
233
  }
234
234
 
235
+ createIndex(indexInfo){
236
+ const indexName = indexInfo.indexName === true
237
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
238
+ : indexInfo.indexName;
239
+ return `CREATE INDEX \`${indexName}\` ON \`${indexInfo.tableName}\`(\`${indexInfo.columnName}\`)`;
240
+ }
241
+
242
+ dropIndex(indexInfo){
243
+ const indexName = indexInfo.indexName === true
244
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
245
+ : indexInfo.indexName;
246
+ return `DROP INDEX \`${indexName}\` ON \`${indexInfo.tableName}\``;
247
+ }
248
+
235
249
  /**
236
250
  * SEED DATA METHODS
237
251
  * Support for inserting seed data during migrations
@@ -219,6 +219,20 @@ class migrationPostgresQuery {
219
219
  return `ALTER TABLE "${table.tableName}" RENAME COLUMN "${table.name}" TO "${table.newName}"`;
220
220
  }
221
221
 
222
+ createIndex(indexInfo){
223
+ const indexName = indexInfo.indexName === true
224
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
225
+ : indexInfo.indexName;
226
+ return `CREATE INDEX IF NOT EXISTS "${indexName}" ON "${indexInfo.tableName}"("${indexInfo.columnName}")`;
227
+ }
228
+
229
+ dropIndex(indexInfo){
230
+ const indexName = indexInfo.indexName === true
231
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
232
+ : indexInfo.indexName;
233
+ return `DROP INDEX IF EXISTS "${indexName}"`;
234
+ }
235
+
222
236
  /**
223
237
  * SEED DATA METHODS
224
238
  * Support for inserting seed data during migrations
@@ -187,6 +187,20 @@ class migrationSQLiteQuery {
187
187
  return `ALTER TABLE ${table.tableName} RENAME COLUMN ${table.name} TO ${table.newName}`
188
188
  }
189
189
 
190
+ createIndex(indexInfo){
191
+ const indexName = indexInfo.indexName === true
192
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
193
+ : indexInfo.indexName;
194
+ return `CREATE INDEX IF NOT EXISTS ${indexName} ON ${indexInfo.tableName}(${indexInfo.columnName})`;
195
+ }
196
+
197
+ dropIndex(indexInfo){
198
+ const indexName = indexInfo.indexName === true
199
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
200
+ : indexInfo.indexName;
201
+ return `DROP INDEX IF EXISTS ${indexName}`;
202
+ }
203
+
190
204
  /**
191
205
  * SEED DATA METHODS
192
206
  * Support for inserting seed data during migrations
@@ -82,6 +82,44 @@ module.exports = ${this.name};
82
82
  }
83
83
  }
84
84
 
85
+ createIndex(type, indexInfo){
86
+ const indexName = indexInfo.indexName === true
87
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
88
+ : indexInfo.indexName;
89
+
90
+ const indexInfoStr = JSON.stringify({
91
+ tableName: indexInfo.tableName,
92
+ columnName: indexInfo.columnName,
93
+ indexName: indexInfo.indexName
94
+ });
95
+
96
+ if(type === "up"){
97
+ this.#up += os.EOL + ` this.createIndex(${indexInfoStr});`
98
+ }
99
+ else{
100
+ this.#down += os.EOL + ` this.dropIndex(${indexInfoStr});`
101
+ }
102
+ }
103
+
104
+ dropIndex(type, indexInfo){
105
+ const indexName = indexInfo.indexName === true
106
+ ? `idx_${indexInfo.tableName.toLowerCase()}_${indexInfo.columnName.toLowerCase()}`
107
+ : indexInfo.indexName;
108
+
109
+ const indexInfoStr = JSON.stringify({
110
+ tableName: indexInfo.tableName,
111
+ columnName: indexInfo.columnName,
112
+ indexName: indexInfo.indexName
113
+ });
114
+
115
+ if(type === "up"){
116
+ this.#up += os.EOL + ` this.dropIndex(${indexInfoStr});`
117
+ }
118
+ else{
119
+ this.#down += os.EOL + ` this.createIndex(${indexInfoStr});`
120
+ }
121
+ }
122
+
85
123
  }
86
124
 
87
125
  module.exports = MigrationTemplate;