masterrecord 0.2.36 → 0.3.1

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.
Files changed (38) hide show
  1. package/.claude/settings.local.json +20 -1
  2. package/Entity/entityModel.js +6 -0
  3. package/Entity/entityTrackerModel.js +20 -3
  4. package/Entity/fieldTransformer.js +266 -0
  5. package/Migrations/migrationMySQLQuery.js +145 -1
  6. package/Migrations/migrationPostgresQuery.js +402 -0
  7. package/Migrations/migrationSQLiteQuery.js +145 -1
  8. package/Migrations/schema.js +131 -28
  9. package/QueryLanguage/queryMethods.js +193 -15
  10. package/QueryLanguage/queryParameters.js +136 -0
  11. package/QueryLanguage/queryScript.js +13 -4
  12. package/SQLLiteEngine.js +331 -20
  13. package/context.js +91 -14
  14. package/docs/INCLUDES_CLARIFICATION.md +202 -0
  15. package/docs/METHODS_REFERENCE.md +184 -0
  16. package/docs/MIGRATIONS_GUIDE.md +699 -0
  17. package/docs/POSTGRESQL_SETUP.md +415 -0
  18. package/examples/jsonArrayTransformer.js +215 -0
  19. package/mySQLEngine.js +273 -17
  20. package/package.json +3 -3
  21. package/postgresEngine.js +600 -483
  22. package/postgresSyncConnect.js +209 -0
  23. package/readme.md +1046 -416
  24. package/test/anyCommaStringTest.js +237 -0
  25. package/test/anyMethodTest.js +176 -0
  26. package/test/findByIdTest.js +227 -0
  27. package/test/includesFeatureTest.js +183 -0
  28. package/test/includesTransformTest.js +110 -0
  29. package/test/newMethodTest.js +330 -0
  30. package/test/newMethodUnitTest.js +320 -0
  31. package/test/parameterizedPlaceholderTest.js +159 -0
  32. package/test/postgresEngineTest.js +463 -0
  33. package/test/postgresIntegrationTest.js +381 -0
  34. package/test/securityTest.js +268 -0
  35. package/test/singleDollarPlaceholderTest.js +238 -0
  36. package/test/transformerTest.js +287 -0
  37. package/test/verifyFindById.js +169 -0
  38. package/test/verifyNewMethod.js +191 -0
package/mySQLEngine.js CHANGED
@@ -2,27 +2,43 @@
2
2
 
3
3
  var tools = require('masterrecord/Tools');
4
4
  var util = require('util');
5
+ var FieldTransformer = require('masterrecord/Entity/fieldTransformer');
5
6
 
6
7
  class SQLLiteEngine {
7
8
 
8
9
  unsupportedWords = ["order"]
9
10
 
10
11
  update(query){
11
- var sqlQuery = ` UPDATE ${query.tableName} SET ${query.arg} WHERE ${query.tableName}.${query.primaryKey} = ${query.primaryKeyValue}` // primary key for that table =
12
- return this._run(sqlQuery);
12
+ // Use parameterized query for security
13
+ // query.arg now contains {sql, params} from _buildSQLEqualToParameterized
14
+ if(query.arg && typeof query.arg === 'object' && query.arg.sql && query.arg.params){
15
+ var sqlQuery = ` UPDATE ${query.tableName} SET ${query.arg.sql} WHERE ${query.tableName}.${query.primaryKey} = ?`;
16
+ // Add primaryKeyValue to params array
17
+ var params = [...query.arg.params, query.primaryKeyValue];
18
+ return this._runWithParams(sqlQuery, params);
19
+ } else {
20
+ // Fallback to old method (for backwards compatibility during migration)
21
+ var sqlQuery = ` UPDATE ${query.tableName} SET ${query.arg} WHERE ${query.tableName}.${query.primaryKey} = ?`;
22
+ return this._runWithParams(sqlQuery, [query.primaryKeyValue]);
23
+ }
13
24
  }
14
25
 
15
26
  delete(queryObject){
16
27
  var sqlObject = this._buildDeleteObject(queryObject);
17
- var sqlQuery = `DELETE FROM ${sqlObject.tableName} WHERE ${sqlObject.tableName}.${sqlObject.primaryKey} = ${sqlObject.value}`;
18
- return this._run(sqlQuery);
28
+ // Use parameterized query to prevent SQL injection
29
+ var sqlQuery = `DELETE FROM ${sqlObject.tableName} WHERE ${sqlObject.tableName}.${sqlObject.primaryKey} = ?`;
30
+ return this._runWithParams(sqlQuery, [sqlObject.value]);
19
31
  }
20
32
 
21
33
  insert(queryObject){
22
- var sqlObject = this._buildSQLInsertObject(queryObject, queryObject.__entity);
23
- var query = `INSERT INTO ${sqlObject.tableName} (${sqlObject.columns}) VALUES (${sqlObject.values})`;
24
- var queryObj = this._run(query);
25
- // return
34
+ // Use NEW SECURE parameterized version
35
+ var sqlObject = this._buildSQLInsertObjectParameterized(queryObject, queryObject.__entity);
36
+ if(sqlObject === -1){
37
+ throw new Error('INSERT failed: No columns to insert');
38
+ }
39
+ var query = `INSERT INTO ${sqlObject.tableName} (${sqlObject.columns}) VALUES (${sqlObject.placeholders})`;
40
+ var queryObj = this._runWithParams(query, sqlObject.params);
41
+ // return
26
42
  var open = {
27
43
  "id": queryObj.insertId
28
44
  };
@@ -39,9 +55,12 @@ class SQLLiteEngine {
39
55
  queryString = this.buildQuery(query, entity, context);
40
56
  }
41
57
  if(queryString.query){
58
+ // Get parameters from query script
59
+ const params = query.parameters ? query.parameters.getParams() : [];
42
60
  console.log("SQL:", queryString.query);
61
+ console.log("Params:", params);
43
62
  this.db.connect(this.db);
44
- const result = this.db.query(queryString.query);
63
+ const result = this.db.query(queryString.query, params);
45
64
  console.log("results:", result);
46
65
  return result;
47
66
  }
@@ -64,9 +83,13 @@ class SQLLiteEngine {
64
83
  }
65
84
  if(queryString.query){
66
85
  var queryCount = queryObject.count(queryString.query)
67
- console.log("SQL:", queryCount );
68
- var queryReturn = this.db.prepare(queryCount ).get();
69
- return queryReturn;
86
+ // Get parameters from query script
87
+ const params = query.parameters ? query.parameters.getParams() : [];
88
+ console.log("SQL:", queryCount);
89
+ console.log("Params:", params);
90
+ this.db.connect(this.db);
91
+ var queryReturn = this.db.query(queryCount, params);
92
+ return queryReturn[0]; // MySQL returns array, get first row
70
93
  }
71
94
  return null;
72
95
  } catch (err) {
@@ -85,9 +108,12 @@ class SQLLiteEngine {
85
108
  queryString = this.buildQuery(query, entity, context);
86
109
  }
87
110
  if(queryString.query){
111
+ // Get parameters from query script
112
+ const params = query.parameters ? query.parameters.getParams() : [];
88
113
  console.log("SQL:", queryString.query);
114
+ console.log("Params:", params);
89
115
  this.db.connect(this.db);
90
- const result = this.db.query(queryString.query);
116
+ const result = this.db.query(queryString.query, params);
91
117
  console.log("results:", result);
92
118
  return result;
93
119
  }
@@ -101,18 +127,18 @@ class SQLLiteEngine {
101
127
  // Introspection helpers
102
128
  tableExists(tableName){
103
129
  try{
104
- const sql = `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '${tableName}'`;
130
+ const sql = `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`;
105
131
  this.db.connect(this.db);
106
- const res = this.db.query(sql);
132
+ const res = this.db.query(sql, [tableName]);
107
133
  return Array.isArray(res) ? res.length > 0 : !!res?.length;
108
134
  }catch(_){ return false; }
109
135
  }
110
136
 
111
137
  getTableInfo(tableName){
112
138
  try{
113
- const sql = `SELECT COLUMN_NAME as name, COLUMN_DEFAULT as dflt_value, IS_NULLABLE as is_nullable, DATA_TYPE as data_type FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '${tableName}'`;
139
+ const sql = `SELECT COLUMN_NAME as name, COLUMN_DEFAULT as dflt_value, IS_NULLABLE as is_nullable, DATA_TYPE as data_type FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`;
114
140
  this.db.connect(this.db);
115
- const res = this.db.query(sql);
141
+ const res = this.db.query(sql, [tableName]);
116
142
  return res || [];
117
143
  }catch(_){ return []; }
118
144
  }
@@ -182,6 +208,12 @@ class SQLLiteEngine {
182
208
  if(func === "IN"){
183
209
  return `${ent}.${field} ${func} ${arg}`;
184
210
  }
211
+ // Check if arg is a parameterized placeholder (? for MySQL/SQLite, $1/$2/etc for Postgres)
212
+ var isPlaceholder = (arg === '?' || /^\$\d+$/.test(arg));
213
+ if(isPlaceholder){
214
+ // Don't quote placeholders - they must remain as bare ? or $1
215
+ return `${ent}.${field} ${func} ${arg}`;
216
+ }
185
217
  var safeArg = (typeof arg === 'string' || arg instanceof String)
186
218
  ? $that._santizeSingleQuotes(arg, { entityName: ent, fieldName: field })
187
219
  : String(arg);
@@ -528,6 +560,177 @@ class SQLLiteEngine {
528
560
  return {tableName: tableName, primaryKey : primaryKey, value : value};
529
561
  }
530
562
 
563
+ /**
564
+ * NEW SECURE VERSION: Build SQL SET clause with parameterized queries (MySQL)
565
+ * Returns {sql: "column1 = ?, column2 = ?", params: [value1, value2]}
566
+ */
567
+ _buildSQLEqualToParameterized(model){
568
+ var $that = this;
569
+ var sqlParts = [];
570
+ var params = [];
571
+ var dirtyFields = model.__dirtyFields;
572
+
573
+ for (var column in dirtyFields) {
574
+ var fieldName = dirtyFields[column];
575
+ var entityDef = model.__entity[fieldName];
576
+ if(entityDef && entityDef.nullable === false && entityDef.primary !== true){
577
+ var persistedValue;
578
+ switch(entityDef.type){
579
+ case "integer": persistedValue = model["_" + fieldName]; break;
580
+ case "belongsTo": persistedValue = model["_" + fieldName] !== undefined ? model["_" + fieldName] : model[fieldName]; break;
581
+ default: persistedValue = model[fieldName];
582
+ }
583
+ var isEmptyString = (typeof persistedValue === 'string') && (persistedValue.trim() === '');
584
+ if(persistedValue === undefined || persistedValue === null || isEmptyString){
585
+ throw `Entity ${model.__entity.__name} column ${fieldName} is a required Field`;
586
+ }
587
+ }
588
+
589
+ var type = model.__entity[dirtyFields[column]].type;
590
+ if(model.__entity[dirtyFields[column]].relationshipType === "belongsTo"){ type = "belongsTo"; }
591
+
592
+ switch(type){
593
+ case "belongsTo":
594
+ const foreignKey = model.__entity[dirtyFields[column]].foreignKey;
595
+ let fkValue = model[dirtyFields[column]];
596
+ // 🔥 Apply toDatabase transformer
597
+ try { fkValue = FieldTransformer.toDatabase(fkValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]); }
598
+ catch(transformError) { throw new Error(`UPDATE failed: ${transformError.message}`); }
599
+ try { fkValue = $that._validateAndCoerceFieldType(fkValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]); }
600
+ catch(typeError) { throw new Error(`UPDATE failed: ${typeError.message}`); }
601
+ var fore = `_${dirtyFields[column]}`;
602
+ sqlParts.push(`${foreignKey} = ?`);
603
+ params.push(model[fore]);
604
+ break;
605
+ case "integer":
606
+ var intValue = model["_" + dirtyFields[column]];
607
+ // 🔥 Apply toDatabase transformer
608
+ try { intValue = FieldTransformer.toDatabase(intValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]); }
609
+ catch(transformError) { throw new Error(`UPDATE failed: ${transformError.message}`); }
610
+ try { intValue = $that._validateAndCoerceFieldType(intValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]); }
611
+ catch(typeError) { throw new Error(`UPDATE failed: ${typeError.message}`); }
612
+ sqlParts.push(`${dirtyFields[column]} = ?`);
613
+ params.push(intValue);
614
+ break;
615
+ case "string":
616
+ var strValue = model[dirtyFields[column]];
617
+ // 🔥 Apply toDatabase transformer
618
+ try { strValue = FieldTransformer.toDatabase(strValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]); }
619
+ catch(transformError) { throw new Error(`UPDATE failed: ${transformError.message}`); }
620
+ try { strValue = $that._validateAndCoerceFieldType(strValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]); }
621
+ catch(typeError) { throw new Error(`UPDATE failed: ${typeError.message}`); }
622
+ sqlParts.push(`${dirtyFields[column]} = ?`);
623
+ params.push(strValue);
624
+ break;
625
+ case "boolean":
626
+ var boolValue = model[dirtyFields[column]];
627
+ // 🔥 Apply toDatabase transformer
628
+ try { boolValue = FieldTransformer.toDatabase(boolValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]); }
629
+ catch(transformError) { throw new Error(`UPDATE failed: ${transformError.message}`); }
630
+ try { boolValue = $that._validateAndCoerceFieldType(boolValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]); }
631
+ catch(typeError) { throw new Error(`UPDATE failed: ${typeError.message}`); }
632
+ boolValue = $that._convertValueForDatabase(boolValue, model.__entity[dirtyFields[column]].type);
633
+ var bool = model.__entity[dirtyFields[column]].valueConversion ? tools.convertBooleanToNumber(boolValue) : boolValue;
634
+ sqlParts.push(`${dirtyFields[column]} = ?`);
635
+ params.push(bool);
636
+ break;
637
+ case "time":
638
+ var timeValue = model[dirtyFields[column]];
639
+ // 🔥 Apply toDatabase transformer
640
+ try { timeValue = FieldTransformer.toDatabase(timeValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]); }
641
+ catch(transformError) { throw new Error(`UPDATE failed: ${transformError.message}`); }
642
+ try { timeValue = $that._validateAndCoerceFieldType(timeValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]); }
643
+ catch(typeError) { throw new Error(`UPDATE failed: ${typeError.message}`); }
644
+ sqlParts.push(`${dirtyFields[column]} = ?`);
645
+ params.push(timeValue);
646
+ break;
647
+ case "hasMany":
648
+ sqlParts.push(`${dirtyFields[column]} = ?`);
649
+ params.push(model[dirtyFields[column]]);
650
+ break;
651
+ default:
652
+ sqlParts.push(`${dirtyFields[column]} = ?`);
653
+ params.push(model[dirtyFields[column]]);
654
+ }
655
+ }
656
+
657
+ return sqlParts.length > 0 ? { sql: sqlParts.join(', '), params: params } : -1;
658
+ }
659
+
660
+ /**
661
+ * NEW SECURE VERSION: Build SQL INSERT with parameterized queries (MySQL)
662
+ * Returns {tableName, columns, placeholders, params}
663
+ */
664
+ _buildSQLInsertObjectParameterized(fields, modelEntity){
665
+ var $that = this;
666
+ var columnNames = [];
667
+ var params = [];
668
+
669
+ for (var column in modelEntity) {
670
+ if(column.indexOf("__") === -1 ){
671
+ var fieldColumn = fields[column];
672
+
673
+ if((fieldColumn !== undefined && fieldColumn !== null ) && typeof(fieldColumn) !== "object"){
674
+ // 🔥 Apply toDatabase transformer before validation
675
+ try { fieldColumn = FieldTransformer.toDatabase(fieldColumn, modelEntity[column], modelEntity.__name, column); }
676
+ catch(transformError) { throw new Error(`INSERT failed: ${transformError.message}`); }
677
+
678
+ try { fieldColumn = $that._validateAndCoerceFieldType(fieldColumn, modelEntity[column], modelEntity.__name, column); }
679
+ catch(typeError) { throw new Error(`INSERT failed: ${typeError.message}`); }
680
+
681
+ fieldColumn = $that._convertValueForDatabase(fieldColumn, modelEntity[column].type);
682
+
683
+ var relationship = modelEntity[column].relationshipType;
684
+ var actualColumn = relationship === "belongsTo" ? modelEntity[column].foreignKey : column;
685
+ columnNames.push(actualColumn);
686
+ params.push(fieldColumn);
687
+ }
688
+ else{
689
+ switch(modelEntity[column].type){
690
+ case "belongsTo":
691
+ var fieldObject = tools.findTrackedObject(fields.__context.__trackedEntities, column);
692
+ if(Object.keys(fieldObject).length > 0){
693
+ var primaryKey = tools.getPrimaryKeyObject(fieldObject.__entity);
694
+ fieldColumn = fieldObject[primaryKey];
695
+ var actualColumn = modelEntity[column].foreignKey;
696
+ columnNames.push(actualColumn);
697
+ params.push(fieldColumn);
698
+ }
699
+ break;
700
+ }
701
+ }
702
+ }
703
+ }
704
+
705
+ if(columnNames.length > 0){
706
+ var placeholders = params.map(() => '?').join(', ');
707
+ return { tableName: modelEntity.__name, columns: columnNames.join(', '), placeholders: placeholders, params: params };
708
+ } else {
709
+ return -1;
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Convert validated value to database-specific format
715
+ * Modern ORM pattern: transparent database-specific conversions
716
+ *
717
+ * @param {*} value - Already validated value
718
+ * @param {string} fieldType - Field type from entity definition
719
+ * @returns {*} Database-ready value
720
+ */
721
+ _convertValueForDatabase(value, fieldType){
722
+ if(value === undefined || value === null){
723
+ return value;
724
+ }
725
+
726
+ // MySQL boolean conversion: JavaScript boolean → TINYINT (1/0)
727
+ if(fieldType === 'boolean' && typeof value === 'boolean'){
728
+ return value ? 1 : 0;
729
+ }
730
+
731
+ return value;
732
+ }
733
+
531
734
  /**
532
735
  * Validate and coerce field value to match entity type definition
533
736
  * Throws detailed error if type cannot be coerced
@@ -748,6 +951,59 @@ class SQLLiteEngine {
748
951
  }
749
952
  }
750
953
 
954
+ /**
955
+ * NEW SECURE VERSION: Execute query with parameters
956
+ * Prevents SQL injection by using parameterized queries
957
+ */
958
+ _executeWithParams(query, params = []){
959
+ console.log("SQL:", query);
960
+ console.log("Params:", params);
961
+ try{
962
+ this.db.connect(this.db);
963
+ const res = this.db.query(query, params);
964
+ if(res === null){
965
+ const dbName = (this.db && this.db.config && this.db.config.database) ? this.db.config.database : '(unknown)';
966
+ if(this.db && this.db.lastErrorCode === 'ER_BAD_DB_ERROR'){
967
+ console.error(`MySQL execute skipped: database '${dbName}' does not exist`);
968
+ }else{
969
+ console.error('MySQL execute skipped: connection not defined');
970
+ }
971
+ return null;
972
+ }
973
+ return res;
974
+ }catch(err){
975
+ const code = err && err.code ? err.code : '';
976
+ if(code === 'ER_BAD_DB_ERROR' || code === 'ECONNREFUSED' || code === 'PROTOCOL_CONNECTION_LOST'){
977
+ const dbName = (this.db && this.db.config && this.db.config.database) ? this.db.config.database : '(unknown)';
978
+ if(code === 'ER_BAD_DB_ERROR'){
979
+ console.error(`MySQL execute skipped: database '${dbName}' does not exist`);
980
+ } else {
981
+ console.error('MySQL execute skipped: connection not defined');
982
+ }
983
+ return null;
984
+ }
985
+ console.error(err);
986
+ return null;
987
+ }
988
+ }
989
+
990
+ /**
991
+ * NEW SECURE VERSION: Run query with parameters
992
+ * Prevents SQL injection by using parameterized queries
993
+ */
994
+ _runWithParams(query, params = []){
995
+ try{
996
+ console.log("SQL:", query);
997
+ console.log("Params:", params);
998
+ this.db.connect(this.db);
999
+ const result = this.db.query(query, params);
1000
+ return result;
1001
+ }
1002
+ catch (error) {
1003
+ console.error(error);
1004
+ }
1005
+ }
1006
+
751
1007
  setDB(db, type){
752
1008
  this.db = db;
753
1009
  this.dbType = type; // this will let us know which type of sqlengine to use.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "masterrecord",
3
- "version": "0.2.36",
3
+ "version": "0.3.1",
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": {
@@ -31,8 +31,8 @@
31
31
  "glob": "^13.0.0",
32
32
  "deep-object-diff": "^1.1.9",
33
33
  "pg": "^8.16.3",
34
- "sync-mysql2": "^1.0.7",
34
+ "sync-mysql2": "^1.0.8",
35
35
  "app-root-path": "^3.1.0",
36
- "better-sqlite3": "^12.5.0"
36
+ "better-sqlite3": "^12.6.0"
37
37
  }
38
38
  }