masterrecord 0.3.15 → 0.3.17

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.
@@ -0,0 +1,836 @@
1
+ // Version 1.0.0 - Complete MySQL implementation with mysql2/promise
2
+ const tools = require('./Tools');
3
+ const FieldTransformer = require('./Entity/fieldTransformer');
4
+
5
+ class MySQLEngine {
6
+
7
+ constructor() {
8
+ this.pool = null;
9
+ this.db = null;
10
+ this.dbType = 'mysql';
11
+ this.unsupportedWords = ["order"];
12
+ }
13
+
14
+ /**
15
+ * Initialize MySQL connection pool
16
+ * @param {Object} pool - MySQL connection pool from mysql2/promise
17
+ */
18
+ setDB(pool, type) {
19
+ this.pool = pool;
20
+ this.db = pool;
21
+ this.dbType = type || 'mysql';
22
+ }
23
+
24
+ /**
25
+ * UPDATE with parameterized query (MySQL uses ?)
26
+ */
27
+ async update(query) {
28
+ // Security: ONLY use parameterized queries
29
+ if (!query.arg || typeof query.arg !== 'object' || !query.arg.sql || !query.arg.params) {
30
+ throw new Error('UPDATE failed: Invalid parameterized query structure. Check entity definition.');
31
+ }
32
+
33
+ const sqlQuery = `UPDATE \`${query.tableName}\` SET ${query.arg.sql} WHERE \`${query.tableName}\`.\`${query.primaryKey}\` = ?`;
34
+ const params = [...query.arg.params, query.primaryKeyValue];
35
+ return await this._runWithParams(sqlQuery, params);
36
+ }
37
+
38
+ /**
39
+ * DELETE with parameterized query
40
+ */
41
+ async delete(queryObject) {
42
+ const sqlObject = this._buildDeleteObject(queryObject);
43
+ const sqlQuery = `DELETE FROM \`${sqlObject.tableName}\` WHERE \`${sqlObject.tableName}\`.\`${sqlObject.primaryKey}\` = ?`;
44
+ return await this._runWithParams(sqlQuery, [sqlObject.value]);
45
+ }
46
+
47
+ /**
48
+ * INSERT with parameterized query
49
+ * MySQL uses LAST_INSERT_ID() to get the inserted ID
50
+ */
51
+ async insert(queryObject) {
52
+ const sqlObject = this._buildSQLInsertObjectParameterized(queryObject, queryObject.__entity);
53
+ if (sqlObject === -1) {
54
+ throw new Error('INSERT failed: No columns to insert');
55
+ }
56
+
57
+ const query = `INSERT INTO \`${sqlObject.tableName}\` (${sqlObject.columns}) VALUES (${sqlObject.placeholders})`;
58
+ const result = await this._runWithParams(query, sqlObject.params);
59
+
60
+ return {
61
+ id: result.insertId
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Batch insert using MySQL's multi-value INSERT
67
+ */
68
+ async bulkInsert(entities) {
69
+ if (!entities || entities.length === 0) return [];
70
+
71
+ // Group by table name
72
+ const byTable = {};
73
+ for (const entity of entities) {
74
+ const tableName = entity.__entity.__name;
75
+ if (!byTable[tableName]) byTable[tableName] = [];
76
+ byTable[tableName].push(entity);
77
+ }
78
+
79
+ const results = [];
80
+ for (const tableName in byTable) {
81
+ const tableEntities = byTable[tableName];
82
+
83
+ // Build multi-value INSERT
84
+ const first = this._buildSQLInsertObjectParameterized(tableEntities[0], tableEntities[0].__entity);
85
+ const allParams = [...first.params];
86
+ const valueGroups = [`(${first.placeholders})`];
87
+
88
+ for (let i = 1; i < tableEntities.length; i++) {
89
+ const sqlObj = this._buildSQLInsertObjectParameterized(tableEntities[i], tableEntities[i].__entity);
90
+ valueGroups.push(`(${sqlObj.placeholders})`);
91
+ allParams.push(...sqlObj.params);
92
+ }
93
+
94
+ const query = `INSERT INTO \`${first.tableName}\` (${first.columns}) VALUES ${valueGroups.join(', ')}`;
95
+ const result = await this._runWithParams(query, allParams);
96
+ results.push(result);
97
+ }
98
+
99
+ return results;
100
+ }
101
+
102
+ /**
103
+ * Batch update (execute in sequence for MySQL)
104
+ */
105
+ async bulkUpdate(updateQueries) {
106
+ if (!updateQueries || updateQueries.length === 0) return;
107
+
108
+ for (const query of updateQueries) {
109
+ await this.update(query);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Batch delete using WHERE IN
115
+ */
116
+ async bulkDelete(tableName, ids) {
117
+ if (!ids || ids.length === 0) return;
118
+
119
+ const placeholders = ids.map(() => '?').join(', ');
120
+ const query = `DELETE FROM \`${tableName}\` WHERE id IN (${placeholders})`;
121
+ return await this._runWithParams(query, ids);
122
+ }
123
+
124
+ /**
125
+ * SELECT single record
126
+ */
127
+ async get(query, entity, context) {
128
+ try {
129
+ let queryString;
130
+ if (query.raw) {
131
+ queryString = { query: query.raw };
132
+ } else {
133
+ queryString = this.buildQuery(query, entity, context);
134
+ }
135
+
136
+ if (queryString.query) {
137
+ const params = query.parameters ? query.parameters.getParams() : [];
138
+ if (process.env.LOG_SQL === 'true' || process.env.NODE_ENV !== 'production') {
139
+ console.debug("[SQL]", queryString.query);
140
+ console.debug("[Params]", params);
141
+ }
142
+ const result = await this._runWithParams(queryString.query, params);
143
+ return result[0] || null;
144
+ }
145
+ return null;
146
+ } catch (err) {
147
+ console.error('MySQL get error:', err);
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * SELECT COUNT
154
+ */
155
+ async getCount(queryObject, entity, context) {
156
+ const query = queryObject.script;
157
+ try {
158
+ let queryString;
159
+ if (query.raw) {
160
+ queryString = { query: query.raw };
161
+ } else {
162
+ queryString = this.buildQuery(query, entity, context);
163
+ }
164
+
165
+ if (queryString.query) {
166
+ const queryCount = queryObject.count(queryString.query);
167
+ const params = query.parameters ? query.parameters.getParams() : [];
168
+ if (process.env.LOG_SQL === 'true' || process.env.NODE_ENV !== 'production') {
169
+ console.debug("[SQL]", queryCount);
170
+ console.debug("[Params]", params);
171
+ }
172
+ const result = await this._runWithParams(queryCount, params);
173
+ return result[0] || null;
174
+ }
175
+ return null;
176
+ } catch (err) {
177
+ console.error('MySQL getCount error:', err);
178
+ return null;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * SELECT multiple records
184
+ */
185
+ async all(query, entity, context) {
186
+ try {
187
+ let queryString;
188
+ if (query.raw) {
189
+ queryString = { query: query.raw };
190
+ } else {
191
+ queryString = this.buildQuery(query, entity, context);
192
+ }
193
+
194
+ if (queryString.query) {
195
+ const params = query.parameters ? query.parameters.getParams() : [];
196
+ if (process.env.LOG_SQL === 'true' || process.env.NODE_ENV !== 'production') {
197
+ console.debug("[SQL]", queryString.query);
198
+ console.debug("[Params]", params);
199
+ }
200
+ const result = await this._runWithParams(queryString.query, params);
201
+ return result || [];
202
+ }
203
+ return [];
204
+ } catch (err) {
205
+ console.error('MySQL all error:', err);
206
+ return [];
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Execute raw SQL with parameters
212
+ */
213
+ async exec(query, params = []) {
214
+ return await this._runWithParams(query, params);
215
+ }
216
+
217
+ /**
218
+ * Introspection: Check if table exists
219
+ */
220
+ async tableExists(tableName) {
221
+ try {
222
+ const sql = `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`;
223
+ const res = await this._runWithParams(sql, [tableName]);
224
+ return Array.isArray(res) ? res.length > 0 : !!res?.length;
225
+ } catch (_) {
226
+ return false;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Introspection: Get table column information
232
+ */
233
+ async getTableInfo(tableName) {
234
+ try {
235
+ 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 = ?`;
236
+ const res = await this._runWithParams(sql, [tableName]);
237
+ return res || [];
238
+ } catch (_) {
239
+ return [];
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Build complete SELECT query
245
+ */
246
+ buildQuery(query, entity, context) {
247
+ if (!entity) {
248
+ console.log("Error: Entity object is blank");
249
+ return { query: "" };
250
+ }
251
+
252
+ const queryObject = {
253
+ entity: this.getEntity(entity.__name, query.entityMap),
254
+ select: this.buildSelect(query, entity),
255
+ from: this.buildFrom(query, entity),
256
+ include: this.buildInclude(query, entity, context, {}),
257
+ where: this.buildWhere(query, entity)
258
+ };
259
+
260
+ const queryString = `${queryObject.select} ${queryObject.from} ${queryObject.include} ${queryObject.where}`;
261
+ return {
262
+ query: queryString,
263
+ entity: this.getEntity(entity.__name, query.entityMap)
264
+ };
265
+ }
266
+
267
+ buildSelect(query, entity) {
268
+ const select = "SELECT";
269
+ const arr = [];
270
+ const $that = this;
271
+
272
+ if (query.select) {
273
+ for (const item in query.select.selectFields) {
274
+ arr.push(`${$that.getEntity(entity.__name, query.entityMap)}.\`${query.select.selectFields[item]}\``);
275
+ }
276
+ } else {
277
+ const entityList = this.getEntityList(entity);
278
+ for (const item in entityList) {
279
+ arr.push(`${$that.getEntity(entity.__name, query.entityMap)}.\`${entityList[item]}\``);
280
+ }
281
+ }
282
+ return `${select} ${arr.join(', ')} `;
283
+ }
284
+
285
+ buildFrom(query, entity) {
286
+ const entityName = this.getEntity(entity.__name, query.entityMap);
287
+ if (entityName) {
288
+ return `FROM \`${entity.__name}\` AS ${entityName}`;
289
+ }
290
+ return "";
291
+ }
292
+
293
+ buildInclude(query, entity, context) {
294
+ const includeQueries = [];
295
+ const $that = this;
296
+
297
+ for (let part in query.include) {
298
+ const includeEntity = query.include[part];
299
+ if (includeEntity) {
300
+ const parentObj = includeEntity[query.parentName];
301
+ let currentContext = "";
302
+ if (includeEntity.selectFields) {
303
+ currentContext = context[tools.capitalizeFirstLetter(includeEntity.selectFields[0])];
304
+ }
305
+
306
+ if (parentObj) {
307
+ parentObj.entityMap = query.entityMap;
308
+ let foreignKey = $that.getForeignKey(entity.__name, currentContext.__entity);
309
+ let mainPrimaryKey = $that.getPrimarykey(entity);
310
+ let mainEntity = $that.getEntity(entity.__name, query.entityMap);
311
+
312
+ if (currentContext.__entity[entity.__name].type === "hasManyThrough") {
313
+ const foreignTable = tools.capitalizeFirstLetter(currentContext.__entity[entity.__name].foreignTable);
314
+ foreignKey = $that.getPrimarykey(currentContext.__entity);
315
+ mainPrimaryKey = context[foreignTable].__entity[currentContext.__entity.__name].foreignKey;
316
+ mainEntity = $that.getEntity(foreignTable, query.entityMap);
317
+ }
318
+
319
+ if (parentObj.select) {
320
+ parentObj.select.selectFields.push(foreignKey);
321
+ } else {
322
+ parentObj.select = {
323
+ selectFields: [foreignKey]
324
+ };
325
+ }
326
+
327
+ const innerQuery = $that.buildQuery(parentObj, currentContext.__entity, context);
328
+ includeQueries.push(`LEFT JOIN (${innerQuery.query}) AS ${innerQuery.entity} ON ${mainEntity}.\`${mainPrimaryKey}\` = ${innerQuery.entity}.\`${foreignKey}\``);
329
+ }
330
+ }
331
+ }
332
+ return includeQueries.join(' ');
333
+ }
334
+
335
+ buildWhere(query, mainQuery) {
336
+ const whereEntity = query.where;
337
+ const $that = this;
338
+
339
+ if (!whereEntity) {
340
+ return "";
341
+ }
342
+
343
+ const entityAlias = this.getEntity(query.parentName, query.entityMap);
344
+ const item = whereEntity[query.parentName].query;
345
+ const exprs = item.expressions || [];
346
+
347
+ function exprToSql(expr) {
348
+ let field = expr.field.toLowerCase();
349
+ let ent = entityAlias;
350
+ if (mainQuery[field]) {
351
+ if (mainQuery[field].isNavigational) {
352
+ ent = $that.getEntity(field, query.entityMap);
353
+ if (item.fields && item.fields[1]) {
354
+ field = item.fields[1];
355
+ }
356
+ }
357
+ }
358
+ let func = expr.func;
359
+ let arg = expr.arg;
360
+ if ((!func && typeof arg === 'undefined')) {
361
+ return null;
362
+ }
363
+ if (func === 'exists' && typeof arg === 'undefined') {
364
+ const isNull = expr.negate === true;
365
+ return `${ent}.\`${field}\` is ${isNull ? '' : 'not '}null`;
366
+ }
367
+ if (arg === "null") {
368
+ if (func === "=") func = "is";
369
+ if (func === "!=") func = "is not";
370
+ return `${ent}.\`${field}\` ${func} ${arg}`;
371
+ }
372
+ if (func === "IN") {
373
+ return `${ent}.\`${field}\` ${func} ${arg}`;
374
+ }
375
+ const isPlaceholder = (arg === '?');
376
+ if (isPlaceholder) {
377
+ return `${ent}.\`${field}\` ${func} ${arg}`;
378
+ }
379
+ const safeArg = (typeof arg === 'string' || arg instanceof String)
380
+ ? $that._santizeSingleQuotes(arg, { entityName: ent, fieldName: field })
381
+ : String(arg);
382
+ return `${ent}.\`${field}\` ${func} '${safeArg}'`;
383
+ }
384
+
385
+ const pieces = [];
386
+ for (let i = 0; i < exprs.length; i++) {
387
+ const e = exprs[i];
388
+ if (e.group) {
389
+ const gid = e.group;
390
+ const orParts = [];
391
+ while (i < exprs.length && exprs[i].group === gid) {
392
+ const sql = exprToSql(exprs[i]);
393
+ if (sql) { orParts.push(sql); }
394
+ i++;
395
+ }
396
+ i--;
397
+ if (orParts.length > 0) {
398
+ pieces.push(`(${orParts.join(" or ")})`);
399
+ }
400
+ } else {
401
+ const sql = exprToSql(e);
402
+ if (sql) { pieces.push(sql); }
403
+ }
404
+ }
405
+
406
+ if (pieces.length === 0) {
407
+ return "";
408
+ }
409
+ return `WHERE ${pieces.join(" and ")}`;
410
+ }
411
+
412
+ getForeignKey(name, entity) {
413
+ if (entity && name) {
414
+ return entity[name].foreignKey;
415
+ }
416
+ }
417
+
418
+ getPrimarykey(entity) {
419
+ for (const item in entity) {
420
+ if (entity[item].primary) {
421
+ if (entity[item].primary === true) {
422
+ return entity[item].name;
423
+ }
424
+ }
425
+ }
426
+ }
427
+
428
+ getForeignTable(name, entity) {
429
+ if (entity && name) {
430
+ return entity[name].foreignTable;
431
+ }
432
+ }
433
+
434
+ getEntity(name, maps) {
435
+ for (let item in maps) {
436
+ const map = maps[item];
437
+ if (tools.capitalizeFirstLetter(name) === map.name) {
438
+ return map.entity;
439
+ }
440
+ }
441
+ return "";
442
+ }
443
+
444
+ getEntityList(entity) {
445
+ const entitiesList = [];
446
+ const $that = this;
447
+
448
+ for (const ent in entity) {
449
+ if (!ent.startsWith("_")) {
450
+ if (!entity[ent].foreignKey) {
451
+ if (entity[ent].relationshipTable) {
452
+ if ($that.chechUnsupportedWords(entity[ent].relationshipTable)) {
453
+ entitiesList.push(`'${entity[ent].relationshipTable}'`);
454
+ } else {
455
+ entitiesList.push(entity[ent].relationshipTable);
456
+ }
457
+ } else {
458
+ if ($that.chechUnsupportedWords(ent)) {
459
+ entitiesList.push(`'${ent}'`);
460
+ } else {
461
+ entitiesList.push(ent);
462
+ }
463
+ }
464
+ } else {
465
+ if (entity[ent].relationshipType === "belongsTo") {
466
+ const name = entity[ent].foreignKey;
467
+ if ($that.chechUnsupportedWords(name)) {
468
+ entitiesList.push(`'${name}'`);
469
+ } else {
470
+ entitiesList.push(name);
471
+ }
472
+ }
473
+ }
474
+ }
475
+ }
476
+
477
+ // Ensure primary key is always included
478
+ try {
479
+ const pk = this.getPrimarykey(entity);
480
+ if (pk) {
481
+ const hasPk = entitiesList.indexOf(pk) !== -1 || entitiesList.indexOf(`\`${pk}\``) !== -1;
482
+ if (!hasPk) { entitiesList.unshift(pk); }
483
+ }
484
+ } catch (_) { /* ignore */ }
485
+
486
+ return entitiesList;
487
+ }
488
+
489
+ chechUnsupportedWords(word) {
490
+ for (const item in this.unsupportedWords) {
491
+ const text = this.unsupportedWords[item];
492
+ if (text === word) {
493
+ return true;
494
+ }
495
+ }
496
+ return false;
497
+ }
498
+
499
+ /**
500
+ * Build SQL SET clause with parameterized queries (MySQL uses ?)
501
+ */
502
+ _buildSQLEqualToParameterized(model) {
503
+ const $that = this;
504
+ const sqlParts = [];
505
+ const params = [];
506
+ const dirtyFields = model.__dirtyFields;
507
+
508
+ for (const column in dirtyFields) {
509
+ const fieldName = dirtyFields[column];
510
+ const entityDef = model.__entity[fieldName];
511
+
512
+ if (entityDef && entityDef.nullable === false && entityDef.primary !== true) {
513
+ let persistedValue;
514
+ switch (entityDef.type) {
515
+ case "integer":
516
+ persistedValue = model["_" + fieldName];
517
+ break;
518
+ case "belongsTo":
519
+ persistedValue = model["_" + fieldName] !== undefined ? model["_" + fieldName] : model[fieldName];
520
+ break;
521
+ default:
522
+ persistedValue = model[fieldName];
523
+ }
524
+ const isEmptyString = (typeof persistedValue === 'string') && (persistedValue.trim() === '');
525
+ if (persistedValue === undefined || persistedValue === null || isEmptyString) {
526
+ throw new Error(`Entity ${model.__entity.__name} column ${fieldName} is a required Field`);
527
+ }
528
+ }
529
+
530
+ let type = model.__entity[dirtyFields[column]].type;
531
+ if (model.__entity[dirtyFields[column]].relationshipType === "belongsTo") {
532
+ type = "belongsTo";
533
+ }
534
+
535
+ switch (type) {
536
+ case "belongsTo":
537
+ const foreignKey = model.__entity[dirtyFields[column]].foreignKey;
538
+ let fkValue = model[dirtyFields[column]];
539
+ try {
540
+ fkValue = FieldTransformer.toDatabase(fkValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]);
541
+ } catch (transformError) {
542
+ throw new Error(`UPDATE failed: ${transformError.message}`);
543
+ }
544
+ try {
545
+ fkValue = $that._validateAndCoerceFieldType(fkValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]);
546
+ } catch (typeError) {
547
+ throw new Error(`UPDATE failed: ${typeError.message}`);
548
+ }
549
+ const fore = `_${dirtyFields[column]}`;
550
+ sqlParts.push(`\`${foreignKey}\` = ?`);
551
+ params.push(model[fore]);
552
+ break;
553
+
554
+ case "integer":
555
+ let intValue = model["_" + dirtyFields[column]];
556
+ try {
557
+ intValue = FieldTransformer.toDatabase(intValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]);
558
+ } catch (transformError) {
559
+ throw new Error(`UPDATE failed: ${transformError.message}`);
560
+ }
561
+ try {
562
+ intValue = $that._validateAndCoerceFieldType(intValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]);
563
+ } catch (typeError) {
564
+ throw new Error(`UPDATE failed: ${typeError.message}`);
565
+ }
566
+ sqlParts.push(`\`${dirtyFields[column]}\` = ?`);
567
+ params.push(intValue);
568
+ break;
569
+
570
+ case "string":
571
+ let strValue = model[dirtyFields[column]];
572
+ try {
573
+ strValue = FieldTransformer.toDatabase(strValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]);
574
+ } catch (transformError) {
575
+ throw new Error(`UPDATE failed: ${transformError.message}`);
576
+ }
577
+ try {
578
+ strValue = $that._validateAndCoerceFieldType(strValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]);
579
+ } catch (typeError) {
580
+ throw new Error(`UPDATE failed: ${typeError.message}`);
581
+ }
582
+ sqlParts.push(`\`${dirtyFields[column]}\` = ?`);
583
+ params.push(strValue);
584
+ break;
585
+
586
+ case "boolean":
587
+ let boolValue = model[dirtyFields[column]];
588
+ try {
589
+ boolValue = FieldTransformer.toDatabase(boolValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]);
590
+ } catch (transformError) {
591
+ throw new Error(`UPDATE failed: ${transformError.message}`);
592
+ }
593
+ try {
594
+ boolValue = $that._validateAndCoerceFieldType(boolValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]);
595
+ } catch (typeError) {
596
+ throw new Error(`UPDATE failed: ${typeError.message}`);
597
+ }
598
+ boolValue = $that._convertValueForDatabase(boolValue, model.__entity[dirtyFields[column]].type);
599
+ const bool = model.__entity[dirtyFields[column]].valueConversion ? tools.convertBooleanToNumber(boolValue) : boolValue;
600
+ sqlParts.push(`\`${dirtyFields[column]}\` = ?`);
601
+ params.push(bool);
602
+ break;
603
+
604
+ case "time":
605
+ let timeValue = model[dirtyFields[column]];
606
+ try {
607
+ timeValue = FieldTransformer.toDatabase(timeValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]);
608
+ } catch (transformError) {
609
+ throw new Error(`UPDATE failed: ${transformError.message}`);
610
+ }
611
+ try {
612
+ timeValue = $that._validateAndCoerceFieldType(timeValue, model.__entity[dirtyFields[column]], model.__entity.__name, dirtyFields[column]);
613
+ } catch (typeError) {
614
+ throw new Error(`UPDATE failed: ${typeError.message}`);
615
+ }
616
+ sqlParts.push(`\`${dirtyFields[column]}\` = ?`);
617
+ params.push(timeValue);
618
+ break;
619
+
620
+ case "hasMany":
621
+ sqlParts.push(`\`${dirtyFields[column]}\` = ?`);
622
+ params.push(model[dirtyFields[column]]);
623
+ break;
624
+
625
+ default:
626
+ sqlParts.push(`\`${dirtyFields[column]}\` = ?`);
627
+ params.push(model[dirtyFields[column]]);
628
+ }
629
+ }
630
+
631
+ return sqlParts.length > 0 ? { sql: sqlParts.join(', '), params: params } : -1;
632
+ }
633
+
634
+ /**
635
+ * Build parameterized INSERT object for MySQL (uses ?)
636
+ */
637
+ _buildSQLInsertObjectParameterized(fields, modelEntity) {
638
+ const $that = this;
639
+ const columnNames = [];
640
+ const params = [];
641
+
642
+ for (const column in modelEntity) {
643
+ if (column.indexOf("__") === -1) {
644
+ let fieldColumn = fields[column];
645
+
646
+ if ((fieldColumn !== undefined && fieldColumn !== null) && typeof(fieldColumn) !== "object") {
647
+ try {
648
+ fieldColumn = FieldTransformer.toDatabase(fieldColumn, modelEntity[column], modelEntity.__name, column);
649
+ } catch (transformError) {
650
+ throw new Error(`INSERT failed: ${transformError.message}`);
651
+ }
652
+
653
+ try {
654
+ fieldColumn = $that._validateAndCoerceFieldType(fieldColumn, modelEntity[column], modelEntity.__name, column);
655
+ } catch (typeError) {
656
+ throw new Error(`INSERT failed: ${typeError.message}`);
657
+ }
658
+
659
+ fieldColumn = $that._convertValueForDatabase(fieldColumn, modelEntity[column].type);
660
+
661
+ const relationship = modelEntity[column].relationshipType;
662
+ const actualColumn = relationship === "belongsTo" ? modelEntity[column].foreignKey : column;
663
+ columnNames.push(`\`${actualColumn}\``);
664
+ params.push(fieldColumn);
665
+ } else {
666
+ switch (modelEntity[column].type) {
667
+ case "belongsTo":
668
+ const fieldObject = tools.findTrackedObject(fields.__context.__trackedEntities, column);
669
+ if (Object.keys(fieldObject).length > 0) {
670
+ const primaryKey = tools.getPrimaryKeyObject(fieldObject.__entity);
671
+ fieldColumn = fieldObject[primaryKey];
672
+ const actualColumn = modelEntity[column].foreignKey;
673
+ columnNames.push(`\`${actualColumn}\``);
674
+ params.push(fieldColumn);
675
+ }
676
+ break;
677
+ }
678
+ }
679
+ }
680
+ }
681
+
682
+ if (columnNames.length > 0) {
683
+ const placeholders = params.map(() => '?').join(', ');
684
+ return {
685
+ tableName: modelEntity.__name,
686
+ columns: columnNames.join(', '),
687
+ placeholders: placeholders,
688
+ params: params
689
+ };
690
+ } else {
691
+ return -1;
692
+ }
693
+ }
694
+
695
+ _buildDeleteObject(currentModel) {
696
+ const primaryKey = currentModel.__Key === undefined ? tools.getPrimaryKeyObject(currentModel.__entity) : currentModel.__Key;
697
+ const value = currentModel.__value === undefined ? currentModel[primaryKey] : currentModel.__value;
698
+ const tableName = currentModel.__tableName === undefined ? currentModel.__entity.__name : currentModel.__tableName;
699
+ return { tableName: tableName, primaryKey: primaryKey, value: value };
700
+ }
701
+
702
+ /**
703
+ * Convert validated value to database-specific format
704
+ */
705
+ _convertValueForDatabase(value, fieldType) {
706
+ if (value === undefined || value === null) {
707
+ return value;
708
+ }
709
+
710
+ // MySQL boolean conversion: JavaScript boolean → TINYINT (1/0)
711
+ if (fieldType === 'boolean' && typeof value === 'boolean') {
712
+ return value ? 1 : 0;
713
+ }
714
+
715
+ return value;
716
+ }
717
+
718
+ /**
719
+ * Validate and coerce field type
720
+ */
721
+ _validateAndCoerceFieldType(value, entityDef, entityName, fieldName) {
722
+ if (value === undefined || value === null) {
723
+ return value;
724
+ }
725
+
726
+ const expectedType = entityDef.type;
727
+ const actualType = typeof value;
728
+
729
+ switch (expectedType) {
730
+ case "integer":
731
+ if (actualType === 'number') {
732
+ if (!Number.isInteger(value)) {
733
+ console.warn(`⚠️ Field ${entityName}.${fieldName}: Expected integer but got float ${value}, rounding to ${Math.round(value)}`);
734
+ return Math.round(value);
735
+ }
736
+ return value;
737
+ }
738
+ if (actualType === 'string') {
739
+ const parsed = parseInt(value, 10);
740
+ if (isNaN(parsed)) {
741
+ throw new Error(`Type mismatch for ${entityName}.${fieldName}: Expected integer, got string "${value}" which cannot be converted to a number`);
742
+ }
743
+ console.warn(`⚠️ Field ${entityName}.${fieldName}: Auto-converting string "${value}" to integer ${parsed}`);
744
+ return parsed;
745
+ }
746
+ if (actualType === 'boolean') {
747
+ console.warn(`⚠️ Field ${entityName}.${fieldName}: Auto-converting boolean ${value} to integer ${value ? 1 : 0}`);
748
+ return value ? 1 : 0;
749
+ }
750
+ throw new Error(`Type mismatch for ${entityName}.${fieldName}: Expected integer, got ${actualType} with value ${JSON.stringify(value)}`);
751
+
752
+ case "string":
753
+ if (actualType === 'string') {
754
+ return value;
755
+ }
756
+ if (['number', 'boolean'].includes(actualType)) {
757
+ console.warn(`⚠️ Field ${entityName}.${fieldName}: Auto-converting ${actualType} ${value} to string "${String(value)}"`);
758
+ return String(value);
759
+ }
760
+ throw new Error(`Type mismatch for ${entityName}.${fieldName}: Expected string, got ${actualType} with value ${JSON.stringify(value)}`);
761
+
762
+ case "boolean":
763
+ case "bool":
764
+ if (typeof value === 'boolean') return value;
765
+ if (value === 1 || value === '1' || value === 'true' || value === true) return true;
766
+ if (value === 0 || value === '0' || value === 'false' || value === false) return false;
767
+ throw new Error(`Invalid boolean value: ${value}`);
768
+
769
+ case "time":
770
+ if (actualType === 'string' || actualType === 'number') {
771
+ return value;
772
+ }
773
+ throw new Error(`Type mismatch for ${entityName}.${fieldName}: Expected time (string/number), got ${actualType} with value ${JSON.stringify(value)}`);
774
+
775
+ default:
776
+ if (actualType === 'object') {
777
+ console.warn(`⚠️ Field ${entityName}.${fieldName}: Setting object value for type "${expectedType}". This may cause issues.`);
778
+ }
779
+ return value;
780
+ }
781
+ }
782
+
783
+ /**
784
+ * Execute parameterized query with mysql2/promise
785
+ */
786
+ async _runWithParams(query, params = []) {
787
+ try {
788
+ if (process.env.LOG_SQL === 'true' || process.env.NODE_ENV !== 'production') {
789
+ console.debug("[SQL]", query);
790
+ console.debug("[Params]", params);
791
+ }
792
+
793
+ const [results] = await this.pool.execute(query, params);
794
+ return results;
795
+ } catch (error) {
796
+ console.error('MySQL query error:', error);
797
+ throw error;
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Sanitize single quotes (legacy, prefer parameterized queries)
803
+ */
804
+ _santizeSingleQuotes(value, context) {
805
+ if (typeof value === 'string' || value instanceof String) {
806
+ return value.replace(/'/g, "''");
807
+ } else {
808
+ const details = context || {};
809
+ const entityName = details.entityName || 'UnknownEntity';
810
+ const fieldName = details.fieldName || 'UnknownField';
811
+ const valueType = (value === null) ? 'null' : (value === undefined ? 'undefined' : typeof value);
812
+ let preview;
813
+ try {
814
+ preview = (value === null || value === undefined) ? String(value) : JSON.stringify(value);
815
+ } catch (_) {
816
+ preview = '[unserializable]';
817
+ }
818
+ if (preview && preview.length > 120) { preview = preview.substring(0, 120) + '…'; }
819
+ const message = `Field is not a string: entity=${entityName}, field=${fieldName}, type=${valueType}, value=${preview}`;
820
+ console.error(message);
821
+ throw new Error(message);
822
+ }
823
+ }
824
+
825
+ /**
826
+ * Close database connection pool
827
+ */
828
+ async close() {
829
+ if (this.pool) {
830
+ await this.pool.end();
831
+ console.log('MySQL pool closed');
832
+ }
833
+ }
834
+ }
835
+
836
+ module.exports = MySQLEngine;