@twin.org/entity-storage-connector-mysql 0.0.3-next.9 → 0.9.0-next.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.
@@ -1 +1 @@
1
- {"version":3,"file":"IMySqlEntityStorageConnectorConstructorOptions.js","sourceRoot":"","sources":["../../../src/models/IMySqlEntityStorageConnectorConstructorOptions.ts"],"names":[],"mappings":"","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport type { IMySqlEntityStorageConnectorConfig } from \"./IMySqlEntityStorageConnectorConfig.js\";\n\n/**\n * The options for the MySql entity storage connector constructor.\n */\nexport interface IMySqlEntityStorageConnectorConstructorOptions {\n\t/**\n\t * The schema for the entity.\n\t */\n\tentitySchema: string;\n\n\t/**\n\t * The keys to use from the context ids to create partitions.\n\t */\n\tpartitionContextIds?: string[];\n\n\t/**\n\t * The type of logging component to use.\n\t * @default logging\n\t */\n\tloggingComponentType?: string;\n\n\t/**\n\t * The configuration for the connector.\n\t */\n\tconfig: IMySqlEntityStorageConnectorConfig;\n}\n"]}
1
+ {"version":3,"file":"IMySqlEntityStorageConnectorConstructorOptions.js","sourceRoot":"","sources":["../../../src/models/IMySqlEntityStorageConnectorConstructorOptions.ts"],"names":[],"mappings":"","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport type { IMySqlEntityStorageConnectorConfig } from \"./IMySqlEntityStorageConnectorConfig.js\";\n\n/**\n * The options for the MySql entity storage connector constructor.\n */\nexport interface IMySqlEntityStorageConnectorConstructorOptions {\n\t/**\n\t * The schema for the entity.\n\t */\n\tentitySchema: string;\n\n\t/**\n\t * The keys to use from the context ids to create partitions.\n\t */\n\tpartitionContextIds?: string[];\n\n\t/**\n\t * The type of logging component to use.\n\t */\n\tloggingComponentType?: string;\n\n\t/**\n\t * The configuration for the connector.\n\t */\n\tconfig: IMySqlEntityStorageConnectorConfig;\n}\n"]}
@@ -1,8 +1,9 @@
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, SharedStore } from "@twin.org/core";
4
+ import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, HealthStatus, Is, ObjectHelper, SharedStore, Validation } from "@twin.org/core";
5
5
  import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, EntitySchemaPropertyType, LogicalOperator, SortDirection } from "@twin.org/entity";
6
+ import { EntityStorageHelper } from "@twin.org/entity-storage-models";
6
7
  import { createPool } from "mysql2/promise";
7
8
  /**
8
9
  * Class for performing entity storage operations using MySql.
@@ -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
@@ -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.
@@ -165,7 +200,22 @@ export class MySqlEntityStorageConnector {
165
200
  * @returns Nothing.
166
201
  */
167
202
  async stop(nodeLoggingComponentType) {
168
- await this.close();
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
+ }
169
219
  }
170
220
  /**
171
221
  * Get an entity from MySql.
@@ -200,9 +250,10 @@ export class MySqlEntityStorageConnector {
200
250
  const query = `SELECT * FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE ${whereClauses.join(" AND ")} LIMIT 1`;
201
251
  const [rows] = await pool.query(query, values);
202
252
  if (Is.array(rows) && rows.length === 1) {
203
- const item = ObjectHelper.removeEmptyProperties(rows[0], { removeNull: true });
204
- ObjectHelper.propertyDelete(item, MySqlEntityStorageConnector._PARTITION_KEY);
205
- return item;
253
+ const item = EntityStorageHelper.unPrepareEntity(rows[0], [
254
+ MySqlEntityStorageConnector._PARTITION_KEY
255
+ ]);
256
+ return this.coerceEntityTypes(item);
206
257
  }
207
258
  }
208
259
  catch (err) {
@@ -222,8 +273,13 @@ export class MySqlEntityStorageConnector {
222
273
  Guards.object(MySqlEntityStorageConnector.CLASS_NAME, "entity", entity);
223
274
  const contextIds = await ContextIdStore.getContextIds();
224
275
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
225
- EntitySchemaHelper.validateEntity(entity, this._entitySchema);
226
- 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];
227
283
  try {
228
284
  if (Is.arrayValue(conditions)) {
229
285
  const itemData = await this.get(id);
@@ -231,25 +287,25 @@ export class MySqlEntityStorageConnector {
231
287
  return;
232
288
  }
233
289
  }
234
- const finalEntity = ObjectHelper.clone(entity);
235
290
  const props = [...(this._entitySchema.properties ?? [])];
236
291
  props.unshift({
237
292
  property: MySqlEntityStorageConnector._PARTITION_KEY,
238
293
  type: EntitySchemaPropertyType.String
239
294
  });
240
- ObjectHelper.propertySet(finalEntity, MySqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE);
241
295
  const keys = [];
242
296
  const values = [];
243
297
  for (const prop of props) {
244
- if (!(Is.empty(finalEntity[prop.property]) && (prop.optional ?? false))) {
245
- keys.push(prop.property);
246
- if (prop.type === EntitySchemaPropertyType.Object ||
247
- prop.type === EntitySchemaPropertyType.Array) {
248
- values.push(JSON.stringify(finalEntity[prop.property]));
249
- }
250
- else {
251
- values.push(finalEntity[prop.property]);
252
- }
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);
253
309
  }
254
310
  }
255
311
  let sql = `INSERT INTO \`${this._config.database}\`.\`${this._config.tableName}\``;
@@ -265,6 +321,68 @@ export class MySqlEntityStorageConnector {
265
321
  }, err);
266
322
  }
267
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];
350
+ if (prop.type === EntitySchemaPropertyType.Object ||
351
+ prop.type === EntitySchemaPropertyType.Array) {
352
+ allValues.push(Is.empty(val) ? null : JSON.stringify(val));
353
+ }
354
+ else {
355
+ allValues.push(Is.empty(val) ? null : val);
356
+ }
357
+ }
358
+ }
359
+ const rowPlaceholder = `(${keys.map(() => "?").join(", ")})`;
360
+ let sql = `INSERT INTO \`${this._config.database}\`.\`${this._config.tableName}\``;
361
+ sql += ` (${keys.map(key => `\`${key}\``).join(", ")})`;
362
+ sql += ` VALUES ${entities.map(() => rowPlaceholder).join(", ")}`;
363
+ sql += ` ON DUPLICATE KEY UPDATE ${keys.map(key => `\`${key}\` = VALUES(\`${key}\`)`).join(", ")};`;
364
+ const pool = this.getPool();
365
+ await pool.query(sql, allValues);
366
+ }
367
+ catch (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);
384
+ }
385
+ }
268
386
  /**
269
387
  * Remove the entity.
270
388
  * @param id The id of the entity to remove.
@@ -301,6 +419,67 @@ export class MySqlEntityStorageConnector {
301
419
  }, err);
302
420
  }
303
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
+ }
304
483
  /**
305
484
  * Find all the entities which match the conditions.
306
485
  * @param conditions The conditions to match for the entities.
@@ -314,6 +493,13 @@ export class MySqlEntityStorageConnector {
314
493
  async query(conditions, sortProperties, properties, cursor, limit) {
315
494
  const contextIds = await ContextIdStore.getContextIds();
316
495
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
496
+ EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
497
+ EntityStorageHelper.validateProperties(this._entitySchema, properties);
498
+ if (!Is.empty(limit)) {
499
+ const validationFailures = [];
500
+ Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
501
+ Validation.asValidationError(MySqlEntityStorageConnector.CLASS_NAME, "query", validationFailures);
502
+ }
317
503
  let sql = "";
318
504
  try {
319
505
  const returnSize = limit ?? MySqlEntityStorageConnector._DEFAULT_LIMIT;
@@ -326,37 +512,27 @@ export class MySqlEntityStorageConnector {
326
512
  }
327
513
  orderByClause = `ORDER BY ${orderClauses.join(", ")}`;
328
514
  }
329
- const whereClauses = [];
330
- const values = [];
331
- const finalConditions = {
332
- conditions: [],
333
- logicalOperator: LogicalOperator.And
334
- };
335
- finalConditions.conditions.push({
336
- property: MySqlEntityStorageConnector._PARTITION_KEY,
337
- comparison: ComparisonOperator.Equals,
338
- value: partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE
339
- });
340
- if (!Is.empty(conditions)) {
341
- finalConditions.conditions.push(conditions);
342
- }
343
- this.buildQueryParameters("", finalConditions, whereClauses, values);
515
+ const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
344
516
  const startIndex = Coerce.number(cursor) ?? 0;
345
517
  sql = `SELECT ${properties ? properties.map(p => `\`${String(p)}\``).join(", ") : "*"} FROM \`${this._config.database}\`.\`${this._config.tableName}\``;
346
- sql += ` WHERE ${whereClauses.join(" AND ")} ${orderByClause}`;
347
- sql += ` LIMIT ${returnSize} OFFSET ${startIndex}`;
518
+ if (whereClauses.length > 0) {
519
+ sql += ` WHERE ${whereClauses.join(" AND ")}`;
520
+ }
521
+ sql += ` ${orderByClause} LIMIT ${returnSize + 1} OFFSET ${startIndex}`;
348
522
  const pool = this.getPool();
349
523
  const [rows] = (await pool.query(sql, values)) ?? [];
350
- const entities = rows;
524
+ const hasMore = Is.array(rows) && rows.length > returnSize;
525
+ const resultRows = hasMore ? rows.slice(0, returnSize) : rows;
526
+ const entities = resultRows;
351
527
  for (let i = 0; i < entities.length; i++) {
352
- entities[i] = ObjectHelper.removeEmptyProperties(entities[i], { removeNull: true });
353
- ObjectHelper.propertyDelete(entities[i], MySqlEntityStorageConnector._PARTITION_KEY);
528
+ entities[i] = EntityStorageHelper.unPrepareEntity(entities[i], [
529
+ MySqlEntityStorageConnector._PARTITION_KEY
530
+ ]);
531
+ entities[i] = this.coerceEntityTypes(entities[i]);
354
532
  }
355
533
  return {
356
534
  entities,
357
- cursor: Is.array(rows) && rows.length === returnSize
358
- ? Coerce.string(startIndex + returnSize)
359
- : undefined
535
+ cursor: hasMore ? Coerce.string(startIndex + returnSize) : undefined
360
536
  };
361
537
  }
362
538
  catch (err) {
@@ -364,36 +540,99 @@ export class MySqlEntityStorageConnector {
364
540
  }
365
541
  }
366
542
  /**
367
- * Drop the table.
368
- * @returns Nothing.
543
+ * Count all the entities which match the conditions.
544
+ * @param conditions The optional conditions to match for the entities.
545
+ * @returns The total count of entities in the storage.
369
546
  */
370
- async tableDrop() {
547
+ async count(conditions) {
548
+ let sql;
371
549
  try {
372
- if (await this.tableExists()) {
373
- const pool = this.getPool();
374
- await pool.query(`DROP TABLE \`${this._config.database}\`.\`${this._config.tableName}\`;`);
375
- await this.waitForTableNotExists();
550
+ const pool = this.getPool();
551
+ const contextIds = await ContextIdStore.getContextIds();
552
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
553
+ const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
554
+ sql = `SELECT COUNT(*) AS count FROM \`${this._config.database}\`.\`${this._config.tableName}\``;
555
+ if (whereClauses.length > 0) {
556
+ sql += ` WHERE ${whereClauses.join(" AND ")}`;
376
557
  }
558
+ const [rows] = await pool.query(sql, values);
559
+ return Number(rows[0].count);
377
560
  }
378
- catch {
379
- // Ignore errors
561
+ catch (err) {
562
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "countFailed", { sql }, err);
380
563
  }
381
564
  }
382
565
  /**
383
- * Empty the table.
384
- * @returns Nothing.
566
+ * Get all unique partition context ids present in the table.
567
+ * @returns An array of context id objects, one per unique partition.
385
568
  */
386
- async tableEmpty() {
569
+ async getPartitionContextIds() {
570
+ if (!Is.arrayValue(this._partitionContextIds)) {
571
+ return [];
572
+ }
387
573
  try {
388
- if (await this.tableExists()) {
389
- const pool = this.getPool();
390
- await pool.query(`TRUNCATE TABLE \`${this._config.database}\`.\`${this._config.tableName}\`;`);
391
- }
574
+ const pool = this.getPool();
575
+ const [rows] = await pool.query(`SELECT DISTINCT \`${MySqlEntityStorageConnector._PARTITION_KEY}\` FROM \`${this._config.database}\`.\`${this._config.tableName}\``);
576
+ return rows
577
+ .map(row => row[MySqlEntityStorageConnector._PARTITION_KEY])
578
+ .filter((id) => Is.stringValue(id))
579
+ .map(id => ContextIdHelper.shortSplit(this._partitionContextIds ?? [], id));
392
580
  }
393
- catch {
394
- // Ignore errors
581
+ catch (err) {
582
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
395
583
  }
396
584
  }
585
+ /**
586
+ * Create the target connector for performing the migration using a temporary table.
587
+ * @param newEntitySchema The name of the new entity schema to create the connector for.
588
+ * @returns Connector for performing the migration.
589
+ */
590
+ async createTargetConnector(newEntitySchema) {
591
+ const migrationTableName = `${this._config.tableName}Migration${Date.now()}`;
592
+ return new MySqlEntityStorageConnector({
593
+ entitySchema: newEntitySchema,
594
+ config: {
595
+ ...this._config,
596
+ tableName: migrationTableName
597
+ },
598
+ partitionContextIds: this._partitionContextIds
599
+ });
600
+ }
601
+ /**
602
+ * Finalize the migration by dropping the source table and renaming the migration table to the original name.
603
+ * @param targetConnector The connector holding the migrated data in a temporary table.
604
+ * @param options The options to control how the migration is finalized.
605
+ * @param loggingComponentType The logging component type to use during finalization.
606
+ * @returns The final connector using the original table name with the new schema.
607
+ */
608
+ async finalizeMigration(targetConnector, options, loggingComponentType) {
609
+ // Teardown the existing table with the original name to free up the name for the new table
610
+ await this.teardown(loggingComponentType);
611
+ // RENAME TABLE is an atomic metadata-only operation in MySQL — no data copying needed.
612
+ const pool = this.getPool();
613
+ await pool.query(`RENAME TABLE \`${targetConnector._config.database}\`.\`${targetConnector._config.tableName}\` TO \`${this._config.database}\`.\`${this._config.tableName}\``);
614
+ const finalConnector = new MySqlEntityStorageConnector({
615
+ entitySchema: targetConnector._entitySchemaName,
616
+ config: this._config,
617
+ partitionContextIds: this._partitionContextIds
618
+ });
619
+ if (await finalConnector.bootstrap(loggingComponentType)) {
620
+ await targetConnector.stop();
621
+ return finalConnector;
622
+ }
623
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap", undefined);
624
+ }
625
+ /**
626
+ * Cleanup a failed or aborted migration by dropping the temporary migration table.
627
+ * @param targetConnector The target connector to cleanup.
628
+ * @param options The options to control how the migration is cleaned up.
629
+ * @param loggingComponentType The optional component type to use for logging.
630
+ * @returns A promise that resolves when the cleanup is complete.
631
+ */
632
+ async cleanupMigration(targetConnector, options, loggingComponentType) {
633
+ // If something failed the only thing to cleanup is the migration table
634
+ await targetConnector?.teardown?.(loggingComponentType);
635
+ }
397
636
  /**
398
637
  * Check if the database exists.
399
638
  * @returns True if the database exists, false otherwise.
@@ -431,6 +670,22 @@ export class MySqlEntityStorageConnector {
431
670
  this._pool = undefined;
432
671
  }
433
672
  }
673
+ /**
674
+ * Coerce MySQL raw row values back to proper TypeScript types based on the entity schema.
675
+ * MySQL returns TINYINT(1) as 0/1 rather than false/true; this method converts those.
676
+ * @param entity The raw entity row from MySQL.
677
+ * @returns The entity with schema-correct types.
678
+ * @internal
679
+ */
680
+ coerceEntityTypes(entity) {
681
+ for (const prop of this._entitySchema.properties ?? []) {
682
+ const value = entity[prop.property];
683
+ if (prop.type === EntitySchemaPropertyType.Boolean && !Is.empty(value)) {
684
+ ObjectHelper.propertySet(entity, prop.property, Boolean(value));
685
+ }
686
+ }
687
+ return entity;
688
+ }
434
689
  /**
435
690
  * Wait for a database to exist.
436
691
  * @returns Nothing.
@@ -537,6 +792,31 @@ export class MySqlEntityStorageConnector {
537
792
  queueLimit: poolConfig.queueLimit ?? 0
538
793
  };
539
794
  }
795
+ /**
796
+ * Build where clause arrays for a query, combining partition key and optional conditions.
797
+ * @param conditions The optional entity conditions to include.
798
+ * @param partitionKey The partition key value.
799
+ * @returns The where clauses and bound values.
800
+ * @internal
801
+ */
802
+ buildWhereClause(conditions, partitionKey) {
803
+ const whereClauses = [];
804
+ const values = [];
805
+ const finalConditions = {
806
+ conditions: [],
807
+ logicalOperator: LogicalOperator.And
808
+ };
809
+ finalConditions.conditions.push({
810
+ property: MySqlEntityStorageConnector._PARTITION_KEY,
811
+ comparison: ComparisonOperator.Equals,
812
+ value: partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE
813
+ });
814
+ if (!Is.empty(conditions)) {
815
+ finalConditions.conditions.push(conditions);
816
+ }
817
+ this.buildQueryParameters("", finalConditions, whereClauses, values);
818
+ return { whereClauses, values };
819
+ }
540
820
  /**
541
821
  * Create an SQL condition clause.
542
822
  * @param objectPath The path for the nested object.
@@ -589,6 +869,11 @@ export class MySqlEntityStorageConnector {
589
869
  prop += comparator.property;
590
870
  if (comparator.comparison === ComparisonOperator.In) {
591
871
  const inValues = Is.array(comparator.value) ? comparator.value : [comparator.value];
872
+ if (inValues.length === 0) {
873
+ // MySQL rejects `IN ()` as a syntax error — short-circuit to a condition
874
+ // that is always false so the query returns zero rows cleanly (#141).
875
+ return "1 = 0";
876
+ }
592
877
  values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
593
878
  const placeholders = inValues.map(() => "?").join(", ");
594
879
  return `\`${prop}\` IN (${placeholders})`;
@@ -667,8 +952,20 @@ export class MySqlEntityStorageConnector {
667
952
  values.push(`%${String(comparator.value).toLowerCase()}%`);
668
953
  return `LOWER(\`${prop}\`) LIKE ?`;
669
954
  }
955
+ values.pop();
956
+ values.push(JSON.stringify(comparator.value));
670
957
  return `JSON_CONTAINS(\`${prop}\`, ?)`;
671
958
  }
959
+ case ComparisonOperator.NotIncludes: {
960
+ if (type === EntitySchemaPropertyType.String) {
961
+ values.pop();
962
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
963
+ return `LOWER(\`${prop}\`) NOT LIKE ?`;
964
+ }
965
+ values.pop();
966
+ values.push(JSON.stringify(comparator.value));
967
+ return `NOT JSON_CONTAINS(\`${prop}\`, ?)`;
968
+ }
672
969
  default:
673
970
  throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
674
971
  comparison: comparator.comparison
@@ -693,7 +990,7 @@ export class MySqlEntityStorageConnector {
693
990
  return Number(value);
694
991
  }
695
992
  else if (type === "boolean") {
696
- return Boolean(value);
993
+ return value ? 1 : 0;
697
994
  }
698
995
  return value;
699
996
  }
@@ -718,6 +1015,8 @@ export class MySqlEntityStorageConnector {
718
1015
  /**
719
1016
  * Verify the conditions for the entity.
720
1017
  * @param conditions The conditions to verify.
1018
+ * @param obj The object to verify the conditions against.
1019
+ * @returns True if all conditions are met, false otherwise.
721
1020
  * @internal
722
1021
  */
723
1022
  verifyConditions(conditions, obj) {
@@ -725,10 +1024,13 @@ export class MySqlEntityStorageConnector {
725
1024
  }
726
1025
  /**
727
1026
  * Map entity schema properties to SQL properties.
1027
+ * @param schema The schema to use, defaults to the connector's own schema.
728
1028
  * @returns The SQL properties as a string.
729
1029
  * @throws GeneralError if the entity properties do not exist.
1030
+ * @internal
730
1031
  */
731
- mapMySqlProperties() {
1032
+ mapMySqlProperties(schema) {
1033
+ const entitySchema = schema ?? this._entitySchema;
732
1034
  const sqlTypeMap = {
733
1035
  [EntitySchemaPropertyType.String]: "LONGTEXT",
734
1036
  [EntitySchemaPropertyType.Number]: "FLOAT",
@@ -737,11 +1039,11 @@ export class MySqlEntityStorageConnector {
737
1039
  [EntitySchemaPropertyType.Array]: "JSON",
738
1040
  [EntitySchemaPropertyType.Boolean]: "TINYINT(1)"
739
1041
  };
740
- if (!this._entitySchema.properties) {
1042
+ if (!entitySchema.properties) {
741
1043
  throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "entitySchemaPropertiesUndefined");
742
1044
  }
743
1045
  const primaryKeys = [];
744
- const props = [...this._entitySchema.properties];
1046
+ const props = [...entitySchema.properties];
745
1047
  props.unshift({
746
1048
  property: MySqlEntityStorageConnector._PARTITION_KEY,
747
1049
  type: EntitySchemaPropertyType.String,
@@ -752,7 +1054,7 @@ export class MySqlEntityStorageConnector {
752
1054
  let sqlType = sqlTypeMap[prop.type] || "TEXT";
753
1055
  if (prop.format) {
754
1056
  switch (prop.type) {
755
- case "string":
1057
+ case EntitySchemaPropertyType.String:
756
1058
  sqlType = "LONGTEXT";
757
1059
  switch (prop.format) {
758
1060
  case "uuid":
@@ -764,7 +1066,7 @@ export class MySqlEntityStorageConnector {
764
1066
  break;
765
1067
  }
766
1068
  break;
767
- case "number":
1069
+ case EntitySchemaPropertyType.Number:
768
1070
  sqlType = "FLOAT";
769
1071
  switch (prop.format) {
770
1072
  case "float":
@@ -775,7 +1077,7 @@ export class MySqlEntityStorageConnector {
775
1077
  break;
776
1078
  }
777
1079
  break;
778
- case "integer":
1080
+ case EntitySchemaPropertyType.Integer:
779
1081
  sqlType = "INT";
780
1082
  switch (prop.format) {
781
1083
  case "int8":