@twin.org/entity-storage-connector-mysql 0.0.3-next.2 → 0.0.3-next.21

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.
@@ -1,9 +1,10 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
3
  import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
4
- import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, Is, ObjectHelper } from "@twin.org/core";
4
+ import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, HealthStatus, Is, ObjectHelper, SharedStore } from "@twin.org/core";
5
5
  import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, EntitySchemaPropertyType, LogicalOperator, SortDirection } from "@twin.org/entity";
6
- import { createConnection } from "mysql2/promise";
6
+ import { EntityStorageHelper } from "@twin.org/entity-storage-models";
7
+ import { createPool } from "mysql2/promise";
7
8
  /**
8
9
  * Class for performing entity storage operations using MySql.
9
10
  */
@@ -27,6 +28,11 @@ export class MySqlEntityStorageConnector {
27
28
  * @internal
28
29
  */
29
30
  static _PARTITION_KEY_VALUE = "root";
31
+ /**
32
+ * The name for the schema.
33
+ * @internal
34
+ */
35
+ _entitySchemaName;
30
36
  /**
31
37
  * The schema for the entity.
32
38
  * @internal
@@ -43,10 +49,10 @@ export class MySqlEntityStorageConnector {
43
49
  */
44
50
  _config;
45
51
  /**
46
- * The configuration for the connector.
52
+ * The connection pool for MySql.
47
53
  * @internal
48
54
  */
49
- _connection;
55
+ _pool;
50
56
  /**
51
57
  * The primary key property.
52
58
  * @internal
@@ -65,6 +71,7 @@ export class MySqlEntityStorageConnector {
65
71
  Guards.stringValue(MySqlEntityStorageConnector.CLASS_NAME, "options.config.password", options.config.password);
66
72
  Guards.stringValue(MySqlEntityStorageConnector.CLASS_NAME, "options.config.database", options.config.database);
67
73
  Guards.stringValue(MySqlEntityStorageConnector.CLASS_NAME, "options.config.tableName", options.config.tableName);
74
+ this._entitySchemaName = options.entitySchema;
68
75
  this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
69
76
  this._partitionContextIds = options.partitionContextIds;
70
77
  this._primaryKeyProperty = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
@@ -77,6 +84,34 @@ export class MySqlEntityStorageConnector {
77
84
  className() {
78
85
  return MySqlEntityStorageConnector.CLASS_NAME;
79
86
  }
87
+ /**
88
+ * Get the health of the component.
89
+ * @returns The health of the component.
90
+ */
91
+ async health() {
92
+ try {
93
+ await this.getPool().query(`SELECT 1 FROM \`${this._config.database}\`.\`${this._config.tableName}\` LIMIT 0`);
94
+ return [
95
+ {
96
+ source: MySqlEntityStorageConnector.CLASS_NAME,
97
+ status: HealthStatus.Ok,
98
+ description: "healthDescription",
99
+ data: { database: this._config.database, tableName: this._config.tableName }
100
+ }
101
+ ];
102
+ }
103
+ catch {
104
+ return [
105
+ {
106
+ source: MySqlEntityStorageConnector.CLASS_NAME,
107
+ status: HealthStatus.Error,
108
+ description: "healthDescription",
109
+ message: "connectionFailed",
110
+ data: { database: this._config.database, tableName: this._config.tableName }
111
+ }
112
+ ];
113
+ }
114
+ }
80
115
  /**
81
116
  * Get the schema for the entities.
82
117
  * @returns The schema for the entities.
@@ -92,7 +127,7 @@ export class MySqlEntityStorageConnector {
92
127
  async bootstrap(nodeLoggingComponentType) {
93
128
  const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
94
129
  try {
95
- const dbConnection = await this.createConnection();
130
+ const pool = this.getPool();
96
131
  const databaseExists = await this.databaseExists();
97
132
  if (!databaseExists) {
98
133
  await nodeLogging?.log({
@@ -104,7 +139,7 @@ export class MySqlEntityStorageConnector {
104
139
  databaseName: this._config.database
105
140
  }
106
141
  });
107
- await dbConnection.query(`CREATE DATABASE IF NOT EXISTS \`${this._config.database}\``);
142
+ await pool.query(`CREATE DATABASE IF NOT EXISTS \`${this._config.database}\``);
108
143
  await this.waitForDatabaseExists();
109
144
  }
110
145
  else {
@@ -129,7 +164,7 @@ export class MySqlEntityStorageConnector {
129
164
  tableName: this._config.tableName
130
165
  }
131
166
  });
132
- await dbConnection.query(`CREATE TABLE IF NOT EXISTS \`${this._config.database}\`.\`${this._config.tableName}\` (${this.mapMySqlProperties()})`);
167
+ await pool.query(`CREATE TABLE IF NOT EXISTS \`${this._config.database}\`.\`${this._config.tableName}\` (${this.mapMySqlProperties()})`);
133
168
  await this.waitForTableExists();
134
169
  }
135
170
  else {
@@ -159,6 +194,29 @@ export class MySqlEntityStorageConnector {
159
194
  }
160
195
  return true;
161
196
  }
197
+ /**
198
+ * The component needs to be stopped when the node is closed.
199
+ * @param nodeLoggingComponentType The node logging component type.
200
+ * @returns Nothing.
201
+ */
202
+ async stop(nodeLoggingComponentType) {
203
+ if (this._pool) {
204
+ const poolConfig = this.createPoolConfig();
205
+ const poolId = `${poolConfig.host}|${poolConfig.port}|${poolConfig.user}`;
206
+ let sharedPools = SharedStore.get("mySqlPools");
207
+ sharedPools ??= {};
208
+ if (sharedPools[poolId]) {
209
+ // Decrease the use counter and close the pool if no longer used
210
+ sharedPools[poolId].useCounter--;
211
+ if (sharedPools[poolId].useCounter <= 0) {
212
+ await this._pool.end();
213
+ delete sharedPools[poolId];
214
+ }
215
+ SharedStore.set("mySqlPools", sharedPools);
216
+ }
217
+ this._pool = undefined;
218
+ }
219
+ }
162
220
  /**
163
221
  * Get an entity from MySql.
164
222
  * @param id The id of the entity to get, or the index value if secondaryIndex is set.
@@ -171,7 +229,7 @@ export class MySqlEntityStorageConnector {
171
229
  const contextIds = await ContextIdStore.getContextIds();
172
230
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
173
231
  try {
174
- const dbConnection = await this.createConnection();
232
+ const pool = this.getPool();
175
233
  const whereClauses = [];
176
234
  const values = [];
177
235
  whereClauses.push(`\`${MySqlEntityStorageConnector._PARTITION_KEY}\` = ?`);
@@ -190,11 +248,12 @@ export class MySqlEntityStorageConnector {
190
248
  }
191
249
  }
192
250
  const query = `SELECT * FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE ${whereClauses.join(" AND ")} LIMIT 1`;
193
- const [rows] = await dbConnection.query(query, values);
251
+ const [rows] = await pool.query(query, values);
194
252
  if (Is.array(rows) && rows.length === 1) {
195
- const item = ObjectHelper.removeEmptyProperties(rows[0], { removeNull: true });
196
- ObjectHelper.propertyDelete(item, MySqlEntityStorageConnector._PARTITION_KEY);
197
- return item;
253
+ const item = EntityStorageHelper.unPrepareEntity(rows[0], [
254
+ MySqlEntityStorageConnector._PARTITION_KEY
255
+ ]);
256
+ return this.coerceEntityTypes(item);
198
257
  }
199
258
  }
200
259
  catch (err) {
@@ -214,8 +273,13 @@ export class MySqlEntityStorageConnector {
214
273
  Guards.object(MySqlEntityStorageConnector.CLASS_NAME, "entity", entity);
215
274
  const contextIds = await ContextIdStore.getContextIds();
216
275
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
217
- EntitySchemaHelper.validateEntity(entity, this._entitySchema);
218
- const id = entity[this._primaryKeyProperty.property];
276
+ const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, [
277
+ {
278
+ property: MySqlEntityStorageConnector._PARTITION_KEY,
279
+ value: partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE
280
+ }
281
+ ], { nullBehavior: "nullify" });
282
+ const id = prepared[this._primaryKeyProperty.property];
219
283
  try {
220
284
  if (Is.arrayValue(conditions)) {
221
285
  const itemData = await this.get(id);
@@ -223,38 +287,100 @@ export class MySqlEntityStorageConnector {
223
287
  return;
224
288
  }
225
289
  }
226
- const finalEntity = ObjectHelper.clone(entity);
227
290
  const props = [...(this._entitySchema.properties ?? [])];
228
291
  props.unshift({
229
292
  property: MySqlEntityStorageConnector._PARTITION_KEY,
230
293
  type: EntitySchemaPropertyType.String
231
294
  });
232
- ObjectHelper.propertySet(finalEntity, MySqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE);
233
295
  const keys = [];
234
296
  const values = [];
235
297
  for (const prop of props) {
236
- if (!(Is.empty(finalEntity[prop.property]) && (prop.optional ?? false))) {
237
- keys.push(prop.property);
298
+ keys.push(prop.property);
299
+ const val = prepared[prop.property];
300
+ if (val === null || val === undefined) {
301
+ values.push(null);
302
+ }
303
+ else if (prop.type === EntitySchemaPropertyType.Object ||
304
+ prop.type === EntitySchemaPropertyType.Array) {
305
+ values.push(JSON.stringify(val));
306
+ }
307
+ else {
308
+ values.push(val);
309
+ }
310
+ }
311
+ let sql = `INSERT INTO \`${this._config.database}\`.\`${this._config.tableName}\``;
312
+ sql += ` (${keys.map(key => `\`${key}\``).join(", ")})`;
313
+ sql += ` VALUES (${values.map(() => "?").join(", ")})`;
314
+ sql += ` ON DUPLICATE KEY UPDATE ${keys.map(key => `\`${key}\` = VALUES(\`${key}\`)`).join(", ")};`;
315
+ const pool = this.getPool();
316
+ await pool.query(sql, values);
317
+ }
318
+ catch (err) {
319
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "setFailed", {
320
+ id
321
+ }, err);
322
+ }
323
+ }
324
+ /**
325
+ * Set multiple entities in a batch.
326
+ * @param entities The entities to set.
327
+ * @returns Nothing.
328
+ */
329
+ async setBatch(entities) {
330
+ Guards.arrayValue(MySqlEntityStorageConnector.CLASS_NAME, "entities", entities);
331
+ const contextIds = await ContextIdStore.getContextIds();
332
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
333
+ const preparedEntities = entities.map(entity => EntityStorageHelper.prepareEntity(entity, this._entitySchema, [
334
+ {
335
+ property: MySqlEntityStorageConnector._PARTITION_KEY,
336
+ value: partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE
337
+ }
338
+ ], { nullBehavior: "nullify" }));
339
+ try {
340
+ const props = [...(this._entitySchema.properties ?? [])];
341
+ props.unshift({
342
+ property: MySqlEntityStorageConnector._PARTITION_KEY,
343
+ type: EntitySchemaPropertyType.String
344
+ });
345
+ const keys = props.map(p => p.property);
346
+ const allValues = [];
347
+ for (const prepared of preparedEntities) {
348
+ for (const prop of props) {
349
+ const val = prepared[prop.property];
238
350
  if (prop.type === EntitySchemaPropertyType.Object ||
239
351
  prop.type === EntitySchemaPropertyType.Array) {
240
- values.push(JSON.stringify(finalEntity[prop.property]));
352
+ allValues.push(Is.empty(val) ? null : JSON.stringify(val));
241
353
  }
242
354
  else {
243
- values.push(finalEntity[prop.property]);
355
+ allValues.push(Is.empty(val) ? null : val);
244
356
  }
245
357
  }
246
358
  }
359
+ const rowPlaceholder = `(${keys.map(() => "?").join(", ")})`;
247
360
  let sql = `INSERT INTO \`${this._config.database}\`.\`${this._config.tableName}\``;
248
361
  sql += ` (${keys.map(key => `\`${key}\``).join(", ")})`;
249
- sql += ` VALUES (${values.map(() => "?").join(", ")})`;
362
+ sql += ` VALUES ${entities.map(() => rowPlaceholder).join(", ")}`;
250
363
  sql += ` ON DUPLICATE KEY UPDATE ${keys.map(key => `\`${key}\` = VALUES(\`${key}\`)`).join(", ")};`;
251
- const dbConnection = await this.createConnection();
252
- await dbConnection.query(sql, values);
364
+ const pool = this.getPool();
365
+ await pool.query(sql, allValues);
253
366
  }
254
367
  catch (err) {
255
- throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "setFailed", {
256
- id
257
- }, err);
368
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
369
+ }
370
+ }
371
+ /**
372
+ * Empty the entity storage.
373
+ * @returns Nothing.
374
+ */
375
+ async empty() {
376
+ const contextIds = await ContextIdStore.getContextIds();
377
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
378
+ try {
379
+ const pool = this.getPool();
380
+ await pool.query(`DELETE FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE \`${MySqlEntityStorageConnector._PARTITION_KEY}\` = ?`, [partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE]);
381
+ }
382
+ catch (err) {
383
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
258
384
  }
259
385
  }
260
386
  /**
@@ -268,7 +394,7 @@ export class MySqlEntityStorageConnector {
268
394
  const contextIds = await ContextIdStore.getContextIds();
269
395
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
270
396
  try {
271
- const dbConnection = await this.createConnection();
397
+ const pool = this.getPool();
272
398
  const itemData = await this.get(id, undefined, conditions);
273
399
  if (Is.notEmpty(itemData)) {
274
400
  const values = [];
@@ -284,7 +410,7 @@ export class MySqlEntityStorageConnector {
284
410
  }));
285
411
  }
286
412
  const query = `DELETE FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE ${whereClauses.join(" AND ")}`;
287
- await dbConnection.query(query, values);
413
+ await pool.query(query, values);
288
414
  }
289
415
  }
290
416
  catch (err) {
@@ -293,6 +419,67 @@ export class MySqlEntityStorageConnector {
293
419
  }, err);
294
420
  }
295
421
  }
422
+ /**
423
+ * Teardown the entity storage by dropping the table.
424
+ * @param nodeLoggingComponentType The node logging component type.
425
+ * @returns True if the teardown process was successful.
426
+ */
427
+ async teardown(nodeLoggingComponentType) {
428
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
429
+ await nodeLogging?.log({
430
+ level: "info",
431
+ source: MySqlEntityStorageConnector.CLASS_NAME,
432
+ ts: Date.now(),
433
+ message: "tableDropping",
434
+ data: { tableName: this._config.tableName }
435
+ });
436
+ try {
437
+ if (await this.tableExists()) {
438
+ const pool = this.getPool();
439
+ await pool.query(`DROP TABLE \`${this._config.database}\`.\`${this._config.tableName}\`;`);
440
+ await this.waitForTableNotExists();
441
+ }
442
+ await nodeLogging?.log({
443
+ level: "info",
444
+ source: MySqlEntityStorageConnector.CLASS_NAME,
445
+ ts: Date.now(),
446
+ message: "tableDropped",
447
+ data: { tableName: this._config.tableName }
448
+ });
449
+ return true;
450
+ }
451
+ catch (err) {
452
+ await nodeLogging?.log({
453
+ level: "error",
454
+ source: MySqlEntityStorageConnector.CLASS_NAME,
455
+ ts: Date.now(),
456
+ message: "teardownFailed",
457
+ error: BaseError.fromError(err)
458
+ });
459
+ return false;
460
+ }
461
+ }
462
+ /**
463
+ * Remove multiple entities by their primary key IDs.
464
+ * @param ids The ids of the entities to remove.
465
+ * @returns Nothing.
466
+ */
467
+ async removeBatch(ids) {
468
+ Guards.arrayValue(MySqlEntityStorageConnector.CLASS_NAME, "ids", ids);
469
+ const contextIds = await ContextIdStore.getContextIds();
470
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
471
+ try {
472
+ const pool = this.getPool();
473
+ const sql = `DELETE FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE \`${MySqlEntityStorageConnector._PARTITION_KEY}\` = ? AND \`${String(this._primaryKeyProperty.property)}\` IN (?)`;
474
+ await pool.query(sql, [
475
+ partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE,
476
+ ids
477
+ ]);
478
+ }
479
+ catch (err) {
480
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
481
+ }
482
+ }
296
483
  /**
297
484
  * Find all the entities which match the conditions.
298
485
  * @param conditions The conditions to match for the entities.
@@ -318,37 +505,27 @@ export class MySqlEntityStorageConnector {
318
505
  }
319
506
  orderByClause = `ORDER BY ${orderClauses.join(", ")}`;
320
507
  }
321
- const whereClauses = [];
322
- const values = [];
323
- const finalConditions = {
324
- conditions: [],
325
- logicalOperator: LogicalOperator.And
326
- };
327
- finalConditions.conditions.push({
328
- property: MySqlEntityStorageConnector._PARTITION_KEY,
329
- comparison: ComparisonOperator.Equals,
330
- value: partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE
331
- });
332
- if (!Is.empty(conditions)) {
333
- finalConditions.conditions.push(conditions);
334
- }
335
- this.buildQueryParameters("", finalConditions, whereClauses, values);
508
+ const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
336
509
  const startIndex = Coerce.number(cursor) ?? 0;
337
510
  sql = `SELECT ${properties ? properties.map(p => `\`${String(p)}\``).join(", ") : "*"} FROM \`${this._config.database}\`.\`${this._config.tableName}\``;
338
- sql += ` WHERE ${whereClauses.join(" AND ")} ${orderByClause}`;
339
- sql += ` LIMIT ${returnSize} OFFSET ${startIndex}`;
340
- const dbConnection = await this.createConnection();
341
- const [rows] = (await dbConnection.query(sql, values)) ?? [];
342
- const entities = rows;
511
+ if (whereClauses.length > 0) {
512
+ sql += ` WHERE ${whereClauses.join(" AND ")}`;
513
+ }
514
+ sql += ` ${orderByClause} LIMIT ${returnSize + 1} OFFSET ${startIndex}`;
515
+ const pool = this.getPool();
516
+ const [rows] = (await pool.query(sql, values)) ?? [];
517
+ const hasMore = Is.array(rows) && rows.length > returnSize;
518
+ const resultRows = hasMore ? rows.slice(0, returnSize) : rows;
519
+ const entities = resultRows;
343
520
  for (let i = 0; i < entities.length; i++) {
344
- entities[i] = ObjectHelper.removeEmptyProperties(entities[i], { removeNull: true });
345
- ObjectHelper.propertyDelete(entities[i], MySqlEntityStorageConnector._PARTITION_KEY);
521
+ entities[i] = EntityStorageHelper.unPrepareEntity(entities[i], [
522
+ MySqlEntityStorageConnector._PARTITION_KEY
523
+ ]);
524
+ entities[i] = this.coerceEntityTypes(entities[i]);
346
525
  }
347
526
  return {
348
527
  entities,
349
- cursor: Is.array(rows) && rows.length === returnSize
350
- ? Coerce.string(startIndex + returnSize)
351
- : undefined
528
+ cursor: hasMore ? Coerce.string(startIndex + returnSize) : undefined
352
529
  };
353
530
  }
354
531
  catch (err) {
@@ -356,50 +533,152 @@ export class MySqlEntityStorageConnector {
356
533
  }
357
534
  }
358
535
  /**
359
- * Drop the table.
360
- * @returns Nothing.
536
+ * Count all the entities which match the conditions.
537
+ * @param conditions The optional conditions to match for the entities.
538
+ * @returns The total count of entities in the storage.
361
539
  */
362
- async tableDrop() {
540
+ async count(conditions) {
541
+ let sql;
363
542
  try {
364
- if (await this.tableExists()) {
365
- const dbConnection = await this.createConnection();
366
- await dbConnection.query(`DROP TABLE \`${this._config.database}\`.\`${this._config.tableName}\`;`);
367
- await this.waitForTableNotExists();
543
+ const pool = this.getPool();
544
+ const contextIds = await ContextIdStore.getContextIds();
545
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
546
+ const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
547
+ sql = `SELECT COUNT(*) AS count FROM \`${this._config.database}\`.\`${this._config.tableName}\``;
548
+ if (whereClauses.length > 0) {
549
+ sql += ` WHERE ${whereClauses.join(" AND ")}`;
368
550
  }
551
+ const [rows] = await pool.query(sql, values);
552
+ return Number(rows[0].count);
369
553
  }
370
- catch {
371
- // Ignore errors
554
+ catch (err) {
555
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "countFailed", { sql }, err);
372
556
  }
373
557
  }
374
558
  /**
375
- * Empty the table.
376
- * @returns Nothing.
559
+ * Get all unique partition context ids present in the table.
560
+ * @returns An array of context id objects, one per unique partition.
377
561
  */
378
- async tableEmpty() {
562
+ async getPartitionContextIds() {
563
+ if (!Is.arrayValue(this._partitionContextIds)) {
564
+ return [];
565
+ }
379
566
  try {
380
- if (await this.tableExists()) {
381
- const dbConnection = await this.createConnection();
382
- await dbConnection.query(`TRUNCATE TABLE \`${this._config.database}\`.\`${this._config.tableName}\`;`);
383
- }
567
+ const pool = this.getPool();
568
+ const [rows] = await pool.query(`SELECT DISTINCT \`${MySqlEntityStorageConnector._PARTITION_KEY}\` FROM \`${this._config.database}\`.\`${this._config.tableName}\``);
569
+ return rows
570
+ .map(row => row[MySqlEntityStorageConnector._PARTITION_KEY])
571
+ .filter((id) => Is.stringValue(id))
572
+ .map(id => ContextIdHelper.shortSplit(this._partitionContextIds ?? [], id));
384
573
  }
385
- catch {
386
- // Ignore errors
574
+ catch (err) {
575
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
387
576
  }
388
577
  }
578
+ /**
579
+ * Create the target connector for performing the migration using a temporary table.
580
+ * @param newEntitySchema The name of the new entity schema to create the connector for.
581
+ * @returns Connector for performing the migration.
582
+ */
583
+ async createTargetConnector(newEntitySchema) {
584
+ const migrationTableName = `${this._config.tableName}Migration${Date.now()}`;
585
+ return new MySqlEntityStorageConnector({
586
+ entitySchema: newEntitySchema,
587
+ config: {
588
+ ...this._config,
589
+ tableName: migrationTableName
590
+ },
591
+ partitionContextIds: this._partitionContextIds
592
+ });
593
+ }
594
+ /**
595
+ * Finalize the migration by dropping the source table and renaming the migration table to the original name.
596
+ * @param targetConnector The connector holding the migrated data in a temporary table.
597
+ * @param options The options to control how the migration is finalized.
598
+ * @param loggingComponentType The logging component type to use during finalization.
599
+ * @returns The final connector using the original table name with the new schema.
600
+ */
601
+ async finalizeMigration(targetConnector, options, loggingComponentType) {
602
+ // Teardown the existing table with the original name to free up the name for the new table
603
+ await this.teardown(loggingComponentType);
604
+ // RENAME TABLE is an atomic metadata-only operation in MySQL — no data copying needed.
605
+ const pool = this.getPool();
606
+ await pool.query(`RENAME TABLE \`${targetConnector._config.database}\`.\`${targetConnector._config.tableName}\` TO \`${this._config.database}\`.\`${this._config.tableName}\``);
607
+ const finalConnector = new MySqlEntityStorageConnector({
608
+ entitySchema: targetConnector._entitySchemaName,
609
+ config: this._config,
610
+ partitionContextIds: this._partitionContextIds
611
+ });
612
+ if (await finalConnector.bootstrap(loggingComponentType)) {
613
+ await targetConnector.stop();
614
+ return finalConnector;
615
+ }
616
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap", undefined);
617
+ }
618
+ /**
619
+ * Cleanup a failed or aborted migration by dropping the temporary migration table.
620
+ * @param targetConnector The target connector to cleanup.
621
+ * @param options The options to control how the migration is cleaned up.
622
+ * @param loggingComponentType The optional component type to use for logging.
623
+ * @returns A promise that resolves when the cleanup is complete.
624
+ */
625
+ async cleanupMigration(targetConnector, options, loggingComponentType) {
626
+ // If something failed the only thing to cleanup is the migration table
627
+ await targetConnector?.teardown?.(loggingComponentType);
628
+ }
389
629
  /**
390
630
  * Check if the database exists.
391
631
  * @returns True if the database exists, false otherwise.
392
632
  */
393
633
  async databaseExists() {
394
634
  try {
395
- const dbConnection = await this.createConnection();
396
- const [rows] = await dbConnection.query("SHOW DATABASES LIKE ?;", [this._config.database]);
635
+ const pool = this.getPool();
636
+ const [rows] = await pool.query("SHOW DATABASES LIKE ?;", [this._config.database]);
397
637
  return Is.arrayValue(rows);
398
638
  }
399
639
  catch {
400
640
  return false;
401
641
  }
402
642
  }
643
+ /**
644
+ * Close the connection pool and release all connections.
645
+ * Should be called when the connector is no longer needed.
646
+ * @returns Nothing.
647
+ */
648
+ async close() {
649
+ if (this._pool) {
650
+ const poolConfig = this.createPoolConfig();
651
+ const poolId = `${poolConfig.host}|${poolConfig.port}|${poolConfig.user}`;
652
+ let sharedPools = SharedStore.get("mySqlPools");
653
+ sharedPools ??= {};
654
+ if (sharedPools[poolId]) {
655
+ // Decrease the use counter and close the pool if no longer used
656
+ sharedPools[poolId].useCounter--;
657
+ if (sharedPools[poolId].useCounter <= 0) {
658
+ await this._pool.end();
659
+ delete sharedPools[poolId];
660
+ }
661
+ SharedStore.set("mySqlPools", sharedPools);
662
+ }
663
+ this._pool = undefined;
664
+ }
665
+ }
666
+ /**
667
+ * Coerce MySQL raw row values back to proper TypeScript types based on the entity schema.
668
+ * MySQL returns TINYINT(1) as 0/1 rather than false/true; this method converts those.
669
+ * @param entity The raw entity row from MySQL.
670
+ * @returns The entity with schema-correct types.
671
+ * @internal
672
+ */
673
+ coerceEntityTypes(entity) {
674
+ for (const prop of this._entitySchema.properties ?? []) {
675
+ const value = entity[prop.property];
676
+ if (prop.type === EntitySchemaPropertyType.Boolean && !Is.empty(value)) {
677
+ ObjectHelper.propertySet(entity, prop.property, Boolean(value));
678
+ }
679
+ }
680
+ return entity;
681
+ }
403
682
  /**
404
683
  * Wait for a database to exist.
405
684
  * @returns Nothing.
@@ -421,8 +700,8 @@ export class MySqlEntityStorageConnector {
421
700
  */
422
701
  async tableExists() {
423
702
  try {
424
- const dbConnection = await this.createConnection();
425
- const [rows] = await dbConnection.query("SHOW TABLES FROM ?? LIKE ?", [
703
+ const pool = this.getPool();
704
+ const [rows] = await pool.query("SHOW TABLES FROM ?? LIKE ?", [
426
705
  this._config.database,
427
706
  this._config.tableName
428
707
  ]);
@@ -461,31 +740,76 @@ export class MySqlEntityStorageConnector {
461
740
  }
462
741
  }
463
742
  /**
464
- * Create a new DB connection.
465
- * @returns The MySql connection.
743
+ * Get or create the connection pool.
744
+ * @returns The MySql connection pool.
466
745
  * @internal
467
746
  */
468
- async createConnection() {
469
- if (this._connection) {
470
- return this._connection;
747
+ getPool() {
748
+ if (!this._pool) {
749
+ const poolConfig = this.createPoolConfig();
750
+ const poolId = `${poolConfig.host}|${poolConfig.port}|${poolConfig.user}`;
751
+ let sharedPools = SharedStore.get("mySqlPools");
752
+ sharedPools ??= {};
753
+ // If there is no pool for the id, create it
754
+ if (!sharedPools[poolId]) {
755
+ sharedPools[poolId] = {
756
+ pool: createPool(poolConfig),
757
+ useCounter: 0
758
+ };
759
+ SharedStore.set("mySqlPools", sharedPools);
760
+ }
761
+ // Increase the use counter and return the pool
762
+ sharedPools[poolId].useCounter++;
763
+ this._pool = sharedPools[poolId].pool;
471
764
  }
472
- const newConnection = await createConnection(this.createConnectionConfig());
473
- this._connection = newConnection;
474
- return newConnection;
765
+ return this._pool;
475
766
  }
476
767
  /**
477
- * Create a new DB connection configuration.
478
- * @returns The MySql connection configuration.
768
+ * Create the connection pool configuration.
769
+ * @returns The MySql pool configuration.
479
770
  * @internal
480
771
  */
481
- createConnectionConfig() {
772
+ createPoolConfig() {
773
+ const poolConfig = this._config.pool ?? {};
482
774
  return {
483
775
  host: this._config.host,
484
776
  port: this._config.port ?? 3306,
485
777
  user: this._config.user,
486
- password: this._config.password
778
+ password: this._config.password,
779
+ connectionLimit: poolConfig.connectionLimit ?? 10,
780
+ maxIdle: poolConfig.maxIdle ?? 10,
781
+ idleTimeout: poolConfig.idleTimeout ?? 60000,
782
+ enableKeepAlive: poolConfig.enableKeepAlive ?? true,
783
+ keepAliveInitialDelay: 0,
784
+ waitForConnections: poolConfig.waitForConnections ?? true,
785
+ queueLimit: poolConfig.queueLimit ?? 0
487
786
  };
488
787
  }
788
+ /**
789
+ * Build where clause arrays for a query, combining partition key and optional conditions.
790
+ * @param conditions The optional entity conditions to include.
791
+ * @param partitionKey The partition key value.
792
+ * @returns The where clauses and bound values.
793
+ * @internal
794
+ */
795
+ buildWhereClause(conditions, partitionKey) {
796
+ const whereClauses = [];
797
+ const values = [];
798
+ const finalConditions = {
799
+ conditions: [],
800
+ logicalOperator: LogicalOperator.And
801
+ };
802
+ finalConditions.conditions.push({
803
+ property: MySqlEntityStorageConnector._PARTITION_KEY,
804
+ comparison: ComparisonOperator.Equals,
805
+ value: partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE
806
+ });
807
+ if (!Is.empty(conditions)) {
808
+ finalConditions.conditions.push(conditions);
809
+ }
810
+ this.buildQueryParameters("", finalConditions, whereClauses, values);
811
+ return { whereClauses, values };
812
+ }
489
813
  /**
490
814
  * Create an SQL condition clause.
491
815
  * @param objectPath The path for the nested object.
@@ -536,42 +860,106 @@ export class MySqlEntityStorageConnector {
536
860
  prop += ".";
537
861
  }
538
862
  prop += comparator.property;
539
- // prop = prop.replace(/\./g, "->");
540
863
  if (comparator.comparison === ComparisonOperator.In) {
541
864
  const inValues = Is.array(comparator.value) ? comparator.value : [comparator.value];
865
+ if (inValues.length === 0) {
866
+ // MySQL rejects `IN ()` as a syntax error — short-circuit to a condition
867
+ // that is always false so the query returns zero rows cleanly (#141).
868
+ return "1 = 0";
869
+ }
542
870
  values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
543
871
  const placeholders = inValues.map(() => "?").join(", ");
544
872
  return `\`${prop}\` IN (${placeholders})`;
545
873
  }
874
+ // null/undefined must use IS NULL / IS NOT NULL — never a parameterised placeholder.
875
+ // Passing undefined through propertyToDbValue() coerces it to NaN for number fields
876
+ // (Number(undefined) === NaN), and null coerces to 0 (Number(null) === 0), both of
877
+ // which produce semantically wrong or invalid SQL.
878
+ if (comparator.value === null || comparator.value === undefined) {
879
+ if (comparator.comparison === ComparisonOperator.Equals ||
880
+ comparator.comparison === ComparisonOperator.NotEquals) {
881
+ const nullCheck = comparator.comparison === ComparisonOperator.Equals ? "IS NULL" : "IS NOT NULL";
882
+ if (comparator.property.split(".").length > 1) {
883
+ const rootProp = comparator.property.split(".")[0];
884
+ const nestedPath = comparator.property.split(".").slice(1).join(".");
885
+ const rootSchema = this._entitySchema.properties?.find(p => p.property === rootProp);
886
+ const isArray = rootSchema?.type === EntitySchemaPropertyType.Array;
887
+ const jsonPath = isArray ? `$[*].${nestedPath}` : `$.${nestedPath}`;
888
+ const jsonExpr = `JSON_UNQUOTE(JSON_EXTRACT(\`${rootProp}\`, '${jsonPath}'))`;
889
+ return `${jsonExpr} ${nullCheck}`;
890
+ }
891
+ return `\`${prop}\` ${nullCheck}`;
892
+ }
893
+ }
546
894
  const dbValue = this.propertyToDbValue(comparator.value, type);
547
895
  values.push(dbValue);
548
896
  if (comparator.property.split(".").length > 1) {
549
- return `JSON_UNQUOTE(JSON_EXTRACT(\`${comparator.property.split(".")[0]}\`, '$.${comparator.property.split(".").slice(1).join(".")}')) = ?`;
550
- }
551
- else if (comparator.comparison === ComparisonOperator.Equals) {
552
- return `\`${prop}\` = ?`;
553
- }
554
- else if (comparator.comparison === ComparisonOperator.NotEquals) {
555
- return `\`${prop}\` <> ?`;
556
- }
557
- else if (comparator.comparison === ComparisonOperator.GreaterThan) {
558
- return `\`${prop}\` > ?`;
559
- }
560
- else if (comparator.comparison === ComparisonOperator.LessThan) {
561
- return `\`${prop}\` < ?`;
562
- }
563
- else if (comparator.comparison === ComparisonOperator.GreaterThanOrEqual) {
564
- return `\`${prop}\` >= ?`;
565
- }
566
- else if (comparator.comparison === ComparisonOperator.LessThanOrEqual) {
567
- return `\`${prop}\` <= ?`;
897
+ const rootProp = comparator.property.split(".")[0];
898
+ const nestedPath = comparator.property.split(".").slice(1).join(".");
899
+ const rootSchema = this._entitySchema.properties?.find(p => p.property === rootProp);
900
+ const isArray = rootSchema?.type === EntitySchemaPropertyType.Array;
901
+ const jsonPath = isArray ? `$[*].${nestedPath}` : `$.${nestedPath}`;
902
+ const jsonExpr = `JSON_UNQUOTE(JSON_EXTRACT(\`${rootProp}\`, '${jsonPath}'))`;
903
+ switch (comparator.comparison) {
904
+ case ComparisonOperator.Includes: {
905
+ values.pop();
906
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
907
+ return `LOWER(${jsonExpr}) LIKE ?`;
908
+ }
909
+ case ComparisonOperator.NotEquals:
910
+ return `${jsonExpr} <> ?`;
911
+ case ComparisonOperator.GreaterThan:
912
+ return `${jsonExpr} > ?`;
913
+ case ComparisonOperator.LessThan:
914
+ return `${jsonExpr} < ?`;
915
+ case ComparisonOperator.GreaterThanOrEqual:
916
+ return `${jsonExpr} >= ?`;
917
+ case ComparisonOperator.LessThanOrEqual:
918
+ return `${jsonExpr} <= ?`;
919
+ default:
920
+ return `${jsonExpr} = ?`;
921
+ }
568
922
  }
569
- else if (comparator.comparison === ComparisonOperator.Includes) {
570
- return `JSON_CONTAINS(\`${prop}\`, ?)`;
923
+ switch (comparator.comparison) {
924
+ case ComparisonOperator.Equals:
925
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
926
+ return `JSON_CONTAINS(\`${prop}\`, ?)`;
927
+ }
928
+ return `\`${prop}\` = ?`;
929
+ case ComparisonOperator.NotEquals:
930
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
931
+ return `NOT JSON_CONTAINS(\`${prop}\`, ?)`;
932
+ }
933
+ return `\`${prop}\` <> ?`;
934
+ case ComparisonOperator.GreaterThan:
935
+ return `\`${prop}\` > ?`;
936
+ case ComparisonOperator.LessThan:
937
+ return `\`${prop}\` < ?`;
938
+ case ComparisonOperator.GreaterThanOrEqual:
939
+ return `\`${prop}\` >= ?`;
940
+ case ComparisonOperator.LessThanOrEqual:
941
+ return `\`${prop}\` <= ?`;
942
+ case ComparisonOperator.Includes: {
943
+ if (type === EntitySchemaPropertyType.String) {
944
+ values.pop();
945
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
946
+ return `LOWER(\`${prop}\`) LIKE ?`;
947
+ }
948
+ return `JSON_CONTAINS(\`${prop}\`, ?)`;
949
+ }
950
+ case ComparisonOperator.NotIncludes: {
951
+ if (type === EntitySchemaPropertyType.String) {
952
+ values.pop();
953
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
954
+ return `LOWER(\`${prop}\`) NOT LIKE ?`;
955
+ }
956
+ return `NOT JSON_CONTAINS(\`${prop}\`, ?)`;
957
+ }
958
+ default:
959
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
960
+ comparison: comparator.comparison
961
+ });
571
962
  }
572
- throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
573
- comparison: comparator.comparison
574
- });
575
963
  }
576
964
  /**
577
965
  * Format a value to insert into DB.
@@ -591,7 +979,7 @@ export class MySqlEntityStorageConnector {
591
979
  return Number(value);
592
980
  }
593
981
  else if (type === "boolean") {
594
- return Boolean(value);
982
+ return value ? 1 : 0;
595
983
  }
596
984
  return value;
597
985
  }
@@ -623,10 +1011,12 @@ export class MySqlEntityStorageConnector {
623
1011
  }
624
1012
  /**
625
1013
  * Map entity schema properties to SQL properties.
1014
+ * @param schema The schema to use, defaults to the connector's own schema.
626
1015
  * @returns The SQL properties as a string.
627
1016
  * @throws GeneralError if the entity properties do not exist.
628
1017
  */
629
- mapMySqlProperties() {
1018
+ mapMySqlProperties(schema) {
1019
+ const entitySchema = schema ?? this._entitySchema;
630
1020
  const sqlTypeMap = {
631
1021
  [EntitySchemaPropertyType.String]: "LONGTEXT",
632
1022
  [EntitySchemaPropertyType.Number]: "FLOAT",
@@ -635,11 +1025,11 @@ export class MySqlEntityStorageConnector {
635
1025
  [EntitySchemaPropertyType.Array]: "JSON",
636
1026
  [EntitySchemaPropertyType.Boolean]: "TINYINT(1)"
637
1027
  };
638
- if (!this._entitySchema.properties) {
1028
+ if (!entitySchema.properties) {
639
1029
  throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "entitySchemaPropertiesUndefined");
640
1030
  }
641
1031
  const primaryKeys = [];
642
- const props = [...this._entitySchema.properties];
1032
+ const props = [...entitySchema.properties];
643
1033
  props.unshift({
644
1034
  property: MySqlEntityStorageConnector._PARTITION_KEY,
645
1035
  type: EntitySchemaPropertyType.String,
@@ -650,7 +1040,7 @@ export class MySqlEntityStorageConnector {
650
1040
  let sqlType = sqlTypeMap[prop.type] || "TEXT";
651
1041
  if (prop.format) {
652
1042
  switch (prop.type) {
653
- case "string":
1043
+ case EntitySchemaPropertyType.String:
654
1044
  sqlType = "LONGTEXT";
655
1045
  switch (prop.format) {
656
1046
  case "uuid":
@@ -662,7 +1052,7 @@ export class MySqlEntityStorageConnector {
662
1052
  break;
663
1053
  }
664
1054
  break;
665
- case "number":
1055
+ case EntitySchemaPropertyType.Number:
666
1056
  sqlType = "FLOAT";
667
1057
  switch (prop.format) {
668
1058
  case "float":
@@ -673,7 +1063,7 @@ export class MySqlEntityStorageConnector {
673
1063
  break;
674
1064
  }
675
1065
  break;
676
- case "integer":
1066
+ case EntitySchemaPropertyType.Integer:
677
1067
  sqlType = "INT";
678
1068
  switch (prop.format) {
679
1069
  case "int8":