@twin.org/entity-storage-connector-mysql 0.0.3-next.1 → 0.0.3-next.10

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # TWIN Entity Storage Connector MySQL
1
+ # Entity Storage Connector MySQL
2
2
 
3
- Entity Storage connector implementation using MySQL storage.
3
+ This package provides a MySQL backend for relational persistence and SQL-based access patterns. It is designed to work with the wider storage ecosystem so applications can keep behaviour consistent across connectors and environments.
4
4
 
5
5
  ## Installation
6
6
 
@@ -8,18 +8,13 @@ Entity Storage connector implementation using MySQL storage.
8
8
  npm install @twin.org/entity-storage-connector-mysql
9
9
  ```
10
10
 
11
- ## Testing
11
+ ## Docker
12
12
 
13
- The tests developed are functional tests and need an instance of MySql up and running. To run MySql locally:
13
+ To perform testing of this component it may be necessary to launch a local instance to communicate with.
14
14
 
15
- ```sh
16
- docker run -p 3400:3306 --name twin-entity-storage-mysql --hostname mysql -e MYSQL_ROOT_PASSWORD=password -d mysql:latest
17
- ```
18
-
19
- Afterwards you can run the tests as follows:
20
-
21
- ```sh
22
- npm run test
15
+ ```shell
16
+ docker pull mysql:latest
17
+ docker run -d --name twin-entity-storage-mysql -e MYSQL_ROOT_PASSWORD=password -p 3400:3306 mysql:latest
23
18
  ```
24
19
 
25
20
  ## Examples
@@ -1 +1 @@
1
- {"version":3,"file":"IMySqlEntityStorageConnectorConfig.js","sourceRoot":"","sources":["../../../src/models/IMySqlEntityStorageConnectorConfig.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\n\n/**\n * Configuration for the MySql Entity Storage Connector.\n */\nexport interface IMySqlEntityStorageConnectorConfig {\n\t/**\n\t * The host for the MySql instance.\n\t */\n\thost: string;\n\n\t/**\n\t * The port for the MySql instance.\n\t */\n\tport?: number;\n\n\t/**\n\t * The user for the MySql instance.\n\t */\n\tuser: string;\n\n\t/**\n\t * The password for the MySql instance.\n\t */\n\tpassword: string;\n\n\t/**\n\t * The name of the database to be used.\n\t */\n\tdatabase: string;\n\n\t/**\n\t * The name of the table to be used.\n\t */\n\ttableName: string;\n}\n"]}
1
+ {"version":3,"file":"IMySqlEntityStorageConnectorConfig.js","sourceRoot":"","sources":["../../../src/models/IMySqlEntityStorageConnectorConfig.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\n\n/**\n * Configuration for the MySql Entity Storage Connector.\n */\nexport interface IMySqlEntityStorageConnectorConfig {\n\t/**\n\t * The host for the MySql instance.\n\t */\n\thost: string;\n\n\t/**\n\t * The port for the MySql instance.\n\t */\n\tport?: number;\n\n\t/**\n\t * The user for the MySql instance.\n\t */\n\tuser: string;\n\n\t/**\n\t * The password for the MySql instance.\n\t */\n\tpassword: string;\n\n\t/**\n\t * The name of the database to be used.\n\t */\n\tdatabase: string;\n\n\t/**\n\t * The name of the table to be used.\n\t */\n\ttableName: string;\n\n\t/**\n\t * Optional connection pool configuration.\n\t */\n\tpool?: {\n\t\t/**\n\t\t * Maximum number of connections in pool.\n\t\t * @default 10\n\t\t */\n\t\tconnectionLimit?: number;\n\n\t\t/**\n\t\t * Maximum number of idle connections.\n\t\t * @default 10\n\t\t */\n\t\tmaxIdle?: number;\n\n\t\t/**\n\t\t * Time in ms before removing idle connection.\n\t\t * @default 60000 (1 minute)\n\t\t */\n\t\tidleTimeout?: number;\n\n\t\t/**\n\t\t * Enable TCP keep-alive.\n\t\t * @default true\n\t\t */\n\t\tenableKeepAlive?: boolean;\n\n\t\t/**\n\t\t * Wait for available connection when pool is full.\n\t\t * @default true\n\t\t */\n\t\twaitForConnections?: boolean;\n\n\t\t/**\n\t\t * Maximum queued requests (0 = unlimited).\n\t\t * @default 0\n\t\t */\n\t\tqueueLimit?: number;\n\t};\n}\n"]}
@@ -1,9 +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 } 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 { createPool } from "mysql2/promise";
7
7
  /**
8
8
  * Class for performing entity storage operations using MySql.
9
9
  */
@@ -43,10 +43,10 @@ export class MySqlEntityStorageConnector {
43
43
  */
44
44
  _config;
45
45
  /**
46
- * The configuration for the connector.
46
+ * The connection pool for MySql.
47
47
  * @internal
48
48
  */
49
- _connection;
49
+ _pool;
50
50
  /**
51
51
  * The primary key property.
52
52
  * @internal
@@ -77,6 +77,32 @@ export class MySqlEntityStorageConnector {
77
77
  className() {
78
78
  return MySqlEntityStorageConnector.CLASS_NAME;
79
79
  }
80
+ /**
81
+ * Get the health of the component.
82
+ * @returns The health of the component.
83
+ */
84
+ async health() {
85
+ try {
86
+ await this.getPool().query(`SELECT 1 FROM \`${this._config.database}\`.\`${this._config.tableName}\` LIMIT 0`);
87
+ return [
88
+ {
89
+ source: MySqlEntityStorageConnector.CLASS_NAME,
90
+ status: HealthStatus.Ok,
91
+ description: "healthDescription"
92
+ }
93
+ ];
94
+ }
95
+ catch {
96
+ return [
97
+ {
98
+ source: MySqlEntityStorageConnector.CLASS_NAME,
99
+ status: HealthStatus.Error,
100
+ description: "healthDescription",
101
+ message: "connectionFailed"
102
+ }
103
+ ];
104
+ }
105
+ }
80
106
  /**
81
107
  * Get the schema for the entities.
82
108
  * @returns The schema for the entities.
@@ -92,7 +118,7 @@ export class MySqlEntityStorageConnector {
92
118
  async bootstrap(nodeLoggingComponentType) {
93
119
  const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
94
120
  try {
95
- const dbConnection = await this.createConnection();
121
+ const pool = this.getPool();
96
122
  const databaseExists = await this.databaseExists();
97
123
  if (!databaseExists) {
98
124
  await nodeLogging?.log({
@@ -104,7 +130,7 @@ export class MySqlEntityStorageConnector {
104
130
  databaseName: this._config.database
105
131
  }
106
132
  });
107
- await dbConnection.query(`CREATE DATABASE IF NOT EXISTS \`${this._config.database}\``);
133
+ await pool.query(`CREATE DATABASE IF NOT EXISTS \`${this._config.database}\``);
108
134
  await this.waitForDatabaseExists();
109
135
  }
110
136
  else {
@@ -129,7 +155,7 @@ export class MySqlEntityStorageConnector {
129
155
  tableName: this._config.tableName
130
156
  }
131
157
  });
132
- await dbConnection.query(`CREATE TABLE IF NOT EXISTS \`${this._config.database}\`.\`${this._config.tableName}\` (${this.mapMySqlProperties()})`);
158
+ await pool.query(`CREATE TABLE IF NOT EXISTS \`${this._config.database}\`.\`${this._config.tableName}\` (${this.mapMySqlProperties()})`);
133
159
  await this.waitForTableExists();
134
160
  }
135
161
  else {
@@ -159,6 +185,29 @@ export class MySqlEntityStorageConnector {
159
185
  }
160
186
  return true;
161
187
  }
188
+ /**
189
+ * The component needs to be stopped when the node is closed.
190
+ * @param nodeLoggingComponentType The node logging component type.
191
+ * @returns Nothing.
192
+ */
193
+ async stop(nodeLoggingComponentType) {
194
+ if (this._pool) {
195
+ const poolConfig = this.createPoolConfig();
196
+ const poolId = `${poolConfig.host}|${poolConfig.port}|${poolConfig.user}`;
197
+ let sharedPools = SharedStore.get("mySqlPools");
198
+ sharedPools ??= {};
199
+ if (sharedPools[poolId]) {
200
+ // Decrease the use counter and close the pool if no longer used
201
+ sharedPools[poolId].useCounter--;
202
+ if (sharedPools[poolId].useCounter <= 0) {
203
+ await this._pool.end();
204
+ delete sharedPools[poolId];
205
+ }
206
+ SharedStore.set("mySqlPools", sharedPools);
207
+ }
208
+ this._pool = undefined;
209
+ }
210
+ }
162
211
  /**
163
212
  * Get an entity from MySql.
164
213
  * @param id The id of the entity to get, or the index value if secondaryIndex is set.
@@ -171,7 +220,7 @@ export class MySqlEntityStorageConnector {
171
220
  const contextIds = await ContextIdStore.getContextIds();
172
221
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
173
222
  try {
174
- const dbConnection = await this.createConnection();
223
+ const pool = this.getPool();
175
224
  const whereClauses = [];
176
225
  const values = [];
177
226
  whereClauses.push(`\`${MySqlEntityStorageConnector._PARTITION_KEY}\` = ?`);
@@ -190,7 +239,7 @@ export class MySqlEntityStorageConnector {
190
239
  }
191
240
  }
192
241
  const query = `SELECT * FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE ${whereClauses.join(" AND ")} LIMIT 1`;
193
- const [rows] = await dbConnection.query(query, values);
242
+ const [rows] = await pool.query(query, values);
194
243
  if (Is.array(rows) && rows.length === 1) {
195
244
  const item = ObjectHelper.removeEmptyProperties(rows[0], { removeNull: true });
196
245
  ObjectHelper.propertyDelete(item, MySqlEntityStorageConnector._PARTITION_KEY);
@@ -248,8 +297,8 @@ export class MySqlEntityStorageConnector {
248
297
  sql += ` (${keys.map(key => `\`${key}\``).join(", ")})`;
249
298
  sql += ` VALUES (${values.map(() => "?").join(", ")})`;
250
299
  sql += ` ON DUPLICATE KEY UPDATE ${keys.map(key => `\`${key}\` = VALUES(\`${key}\`)`).join(", ")};`;
251
- const dbConnection = await this.createConnection();
252
- await dbConnection.query(sql, values);
300
+ const pool = this.getPool();
301
+ await pool.query(sql, values);
253
302
  }
254
303
  catch (err) {
255
304
  throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "setFailed", {
@@ -257,6 +306,67 @@ export class MySqlEntityStorageConnector {
257
306
  }, err);
258
307
  }
259
308
  }
309
+ /**
310
+ * Set multiple entities in a batch.
311
+ * @param entities The entities to set.
312
+ * @returns Nothing.
313
+ */
314
+ async setBatch(entities) {
315
+ Guards.arrayValue(MySqlEntityStorageConnector.CLASS_NAME, "entities", entities);
316
+ const contextIds = await ContextIdStore.getContextIds();
317
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
318
+ for (const entity of entities) {
319
+ EntitySchemaHelper.validateEntity(entity, this._entitySchema);
320
+ }
321
+ try {
322
+ const props = [...(this._entitySchema.properties ?? [])];
323
+ props.unshift({
324
+ property: MySqlEntityStorageConnector._PARTITION_KEY,
325
+ type: EntitySchemaPropertyType.String
326
+ });
327
+ const keys = props.map(p => p.property);
328
+ const allValues = [];
329
+ for (const entity of entities) {
330
+ const finalEntity = ObjectHelper.clone(entity);
331
+ ObjectHelper.propertySet(finalEntity, MySqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE);
332
+ for (const prop of props) {
333
+ const val = finalEntity[prop.property];
334
+ if (prop.type === EntitySchemaPropertyType.Object ||
335
+ prop.type === EntitySchemaPropertyType.Array) {
336
+ allValues.push(Is.empty(val) ? null : JSON.stringify(val));
337
+ }
338
+ else {
339
+ allValues.push(Is.empty(val) ? null : val);
340
+ }
341
+ }
342
+ }
343
+ const rowPlaceholder = `(${keys.map(() => "?").join(", ")})`;
344
+ let sql = `INSERT INTO \`${this._config.database}\`.\`${this._config.tableName}\``;
345
+ sql += ` (${keys.map(key => `\`${key}\``).join(", ")})`;
346
+ sql += ` VALUES ${entities.map(() => rowPlaceholder).join(", ")}`;
347
+ sql += ` ON DUPLICATE KEY UPDATE ${keys.map(key => `\`${key}\` = VALUES(\`${key}\`)`).join(", ")};`;
348
+ const pool = this.getPool();
349
+ await pool.query(sql, allValues);
350
+ }
351
+ catch (err) {
352
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
353
+ }
354
+ }
355
+ /**
356
+ * Empty the entity storage.
357
+ * @returns Nothing.
358
+ */
359
+ async empty() {
360
+ const contextIds = await ContextIdStore.getContextIds();
361
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
362
+ try {
363
+ const pool = this.getPool();
364
+ await pool.query(`DELETE FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE \`${MySqlEntityStorageConnector._PARTITION_KEY}\` = ?`, [partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE]);
365
+ }
366
+ catch (err) {
367
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
368
+ }
369
+ }
260
370
  /**
261
371
  * Remove the entity.
262
372
  * @param id The id of the entity to remove.
@@ -268,7 +378,7 @@ export class MySqlEntityStorageConnector {
268
378
  const contextIds = await ContextIdStore.getContextIds();
269
379
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
270
380
  try {
271
- const dbConnection = await this.createConnection();
381
+ const pool = this.getPool();
272
382
  const itemData = await this.get(id, undefined, conditions);
273
383
  if (Is.notEmpty(itemData)) {
274
384
  const values = [];
@@ -284,7 +394,7 @@ export class MySqlEntityStorageConnector {
284
394
  }));
285
395
  }
286
396
  const query = `DELETE FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE ${whereClauses.join(" AND ")}`;
287
- await dbConnection.query(query, values);
397
+ await pool.query(query, values);
288
398
  }
289
399
  }
290
400
  catch (err) {
@@ -293,6 +403,67 @@ export class MySqlEntityStorageConnector {
293
403
  }, err);
294
404
  }
295
405
  }
406
+ /**
407
+ * Teardown the entity storage by dropping the table.
408
+ * @param nodeLoggingComponentType The node logging component type.
409
+ * @returns True if the teardown process was successful.
410
+ */
411
+ async teardown(nodeLoggingComponentType) {
412
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
413
+ await nodeLogging?.log({
414
+ level: "info",
415
+ source: MySqlEntityStorageConnector.CLASS_NAME,
416
+ ts: Date.now(),
417
+ message: "tableDropping",
418
+ data: { tableName: this._config.tableName }
419
+ });
420
+ try {
421
+ if (await this.tableExists()) {
422
+ const pool = this.getPool();
423
+ await pool.query(`DROP TABLE \`${this._config.database}\`.\`${this._config.tableName}\`;`);
424
+ await this.waitForTableNotExists();
425
+ }
426
+ await nodeLogging?.log({
427
+ level: "info",
428
+ source: MySqlEntityStorageConnector.CLASS_NAME,
429
+ ts: Date.now(),
430
+ message: "tableDropped",
431
+ data: { tableName: this._config.tableName }
432
+ });
433
+ return true;
434
+ }
435
+ catch (err) {
436
+ await nodeLogging?.log({
437
+ level: "error",
438
+ source: MySqlEntityStorageConnector.CLASS_NAME,
439
+ ts: Date.now(),
440
+ message: "teardownFailed",
441
+ error: BaseError.fromError(err)
442
+ });
443
+ return false;
444
+ }
445
+ }
446
+ /**
447
+ * Remove multiple entities by their primary key IDs.
448
+ * @param ids The ids of the entities to remove.
449
+ * @returns Nothing.
450
+ */
451
+ async removeBatch(ids) {
452
+ Guards.arrayValue(MySqlEntityStorageConnector.CLASS_NAME, "ids", ids);
453
+ const contextIds = await ContextIdStore.getContextIds();
454
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
455
+ try {
456
+ const pool = this.getPool();
457
+ const sql = `DELETE FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE \`${MySqlEntityStorageConnector._PARTITION_KEY}\` = ? AND \`${String(this._primaryKeyProperty.property)}\` IN (?)`;
458
+ await pool.query(sql, [
459
+ partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE,
460
+ ids
461
+ ]);
462
+ }
463
+ catch (err) {
464
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
465
+ }
466
+ }
296
467
  /**
297
468
  * Find all the entities which match the conditions.
298
469
  * @param conditions The conditions to match for the entities.
@@ -337,8 +508,8 @@ export class MySqlEntityStorageConnector {
337
508
  sql = `SELECT ${properties ? properties.map(p => `\`${String(p)}\``).join(", ") : "*"} FROM \`${this._config.database}\`.\`${this._config.tableName}\``;
338
509
  sql += ` WHERE ${whereClauses.join(" AND ")} ${orderByClause}`;
339
510
  sql += ` LIMIT ${returnSize} OFFSET ${startIndex}`;
340
- const dbConnection = await this.createConnection();
341
- const [rows] = (await dbConnection.query(sql, values)) ?? [];
511
+ const pool = this.getPool();
512
+ const [rows] = (await pool.query(sql, values)) ?? [];
342
513
  const entities = rows;
343
514
  for (let i = 0; i < entities.length; i++) {
344
515
  entities[i] = ObjectHelper.removeEmptyProperties(entities[i], { removeNull: true });
@@ -356,34 +527,19 @@ export class MySqlEntityStorageConnector {
356
527
  }
357
528
  }
358
529
  /**
359
- * Drop the table.
360
- * @returns Nothing.
530
+ * Count all the entities which match the conditions.
531
+ * @returns The total count of entities in the storage.
361
532
  */
362
- async tableDrop() {
533
+ async count() {
363
534
  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();
368
- }
535
+ const contextIds = await ContextIdStore.getContextIds();
536
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
537
+ const pool = this.getPool();
538
+ const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE \`${MySqlEntityStorageConnector._PARTITION_KEY}\` = ?`, [partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE]);
539
+ return Number(rows[0].count);
369
540
  }
370
- catch {
371
- // Ignore errors
372
- }
373
- }
374
- /**
375
- * Empty the table.
376
- * @returns Nothing.
377
- */
378
- async tableEmpty() {
379
- 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
- }
384
- }
385
- catch {
386
- // Ignore errors
541
+ catch (err) {
542
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
387
543
  }
388
544
  }
389
545
  /**
@@ -392,8 +548,8 @@ export class MySqlEntityStorageConnector {
392
548
  */
393
549
  async databaseExists() {
394
550
  try {
395
- const dbConnection = await this.createConnection();
396
- const [rows] = await dbConnection.query("SHOW DATABASES LIKE ?;", [this._config.database]);
551
+ const pool = this.getPool();
552
+ const [rows] = await pool.query("SHOW DATABASES LIKE ?;", [this._config.database]);
397
553
  return Is.arrayValue(rows);
398
554
  }
399
555
  catch {
@@ -421,8 +577,8 @@ export class MySqlEntityStorageConnector {
421
577
  */
422
578
  async tableExists() {
423
579
  try {
424
- const dbConnection = await this.createConnection();
425
- const [rows] = await dbConnection.query("SHOW TABLES FROM ?? LIKE ?", [
580
+ const pool = this.getPool();
581
+ const [rows] = await pool.query("SHOW TABLES FROM ?? LIKE ?", [
426
582
  this._config.database,
427
583
  this._config.tableName
428
584
  ]);
@@ -461,29 +617,49 @@ export class MySqlEntityStorageConnector {
461
617
  }
462
618
  }
463
619
  /**
464
- * Create a new DB connection.
465
- * @returns The MySql connection.
620
+ * Get or create the connection pool.
621
+ * @returns The MySql connection pool.
466
622
  * @internal
467
623
  */
468
- async createConnection() {
469
- if (this._connection) {
470
- return this._connection;
624
+ getPool() {
625
+ if (!this._pool) {
626
+ const poolConfig = this.createPoolConfig();
627
+ const poolId = `${poolConfig.host}|${poolConfig.port}|${poolConfig.user}`;
628
+ let sharedPools = SharedStore.get("mySqlPools");
629
+ sharedPools ??= {};
630
+ // If there is no pool for the id, create it
631
+ if (!sharedPools[poolId]) {
632
+ sharedPools[poolId] = {
633
+ pool: createPool(poolConfig),
634
+ useCounter: 0
635
+ };
636
+ SharedStore.set("mySqlPools", sharedPools);
637
+ }
638
+ // Increase the use counter and return the pool
639
+ sharedPools[poolId].useCounter++;
640
+ this._pool = sharedPools[poolId].pool;
471
641
  }
472
- const newConnection = await createConnection(this.createConnectionConfig());
473
- this._connection = newConnection;
474
- return newConnection;
642
+ return this._pool;
475
643
  }
476
644
  /**
477
- * Create a new DB connection configuration.
478
- * @returns The MySql connection configuration.
645
+ * Create the connection pool configuration.
646
+ * @returns The MySql pool configuration.
479
647
  * @internal
480
648
  */
481
- createConnectionConfig() {
649
+ createPoolConfig() {
650
+ const poolConfig = this._config.pool ?? {};
482
651
  return {
483
652
  host: this._config.host,
484
653
  port: this._config.port ?? 3306,
485
654
  user: this._config.user,
486
- password: this._config.password
655
+ password: this._config.password,
656
+ connectionLimit: poolConfig.connectionLimit ?? 10,
657
+ maxIdle: poolConfig.maxIdle ?? 10,
658
+ idleTimeout: poolConfig.idleTimeout ?? 60000,
659
+ enableKeepAlive: poolConfig.enableKeepAlive ?? true,
660
+ keepAliveInitialDelay: 0,
661
+ waitForConnections: poolConfig.waitForConnections ?? true,
662
+ queueLimit: poolConfig.queueLimit ?? 0
487
663
  };
488
664
  }
489
665
  /**
@@ -536,42 +712,93 @@ export class MySqlEntityStorageConnector {
536
712
  prop += ".";
537
713
  }
538
714
  prop += comparator.property;
539
- // prop = prop.replace(/\./g, "->");
540
715
  if (comparator.comparison === ComparisonOperator.In) {
541
716
  const inValues = Is.array(comparator.value) ? comparator.value : [comparator.value];
542
717
  values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
543
718
  const placeholders = inValues.map(() => "?").join(", ");
544
719
  return `\`${prop}\` IN (${placeholders})`;
545
720
  }
721
+ // null/undefined must use IS NULL / IS NOT NULL — never a parameterised placeholder.
722
+ // Passing undefined through propertyToDbValue() coerces it to NaN for number fields
723
+ // (Number(undefined) === NaN), and null coerces to 0 (Number(null) === 0), both of
724
+ // which produce semantically wrong or invalid SQL.
725
+ if (comparator.value === null || comparator.value === undefined) {
726
+ if (comparator.comparison === ComparisonOperator.Equals ||
727
+ comparator.comparison === ComparisonOperator.NotEquals) {
728
+ const nullCheck = comparator.comparison === ComparisonOperator.Equals ? "IS NULL" : "IS NOT NULL";
729
+ if (comparator.property.split(".").length > 1) {
730
+ const rootProp = comparator.property.split(".")[0];
731
+ const nestedPath = comparator.property.split(".").slice(1).join(".");
732
+ const rootSchema = this._entitySchema.properties?.find(p => p.property === rootProp);
733
+ const isArray = rootSchema?.type === EntitySchemaPropertyType.Array;
734
+ const jsonPath = isArray ? `$[*].${nestedPath}` : `$.${nestedPath}`;
735
+ const jsonExpr = `JSON_UNQUOTE(JSON_EXTRACT(\`${rootProp}\`, '${jsonPath}'))`;
736
+ return `${jsonExpr} ${nullCheck}`;
737
+ }
738
+ return `\`${prop}\` ${nullCheck}`;
739
+ }
740
+ }
546
741
  const dbValue = this.propertyToDbValue(comparator.value, type);
547
742
  values.push(dbValue);
548
743
  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}\` <= ?`;
744
+ const rootProp = comparator.property.split(".")[0];
745
+ const nestedPath = comparator.property.split(".").slice(1).join(".");
746
+ const rootSchema = this._entitySchema.properties?.find(p => p.property === rootProp);
747
+ const isArray = rootSchema?.type === EntitySchemaPropertyType.Array;
748
+ const jsonPath = isArray ? `$[*].${nestedPath}` : `$.${nestedPath}`;
749
+ const jsonExpr = `JSON_UNQUOTE(JSON_EXTRACT(\`${rootProp}\`, '${jsonPath}'))`;
750
+ switch (comparator.comparison) {
751
+ case ComparisonOperator.Includes: {
752
+ values.pop();
753
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
754
+ return `LOWER(${jsonExpr}) LIKE ?`;
755
+ }
756
+ case ComparisonOperator.NotEquals:
757
+ return `${jsonExpr} <> ?`;
758
+ case ComparisonOperator.GreaterThan:
759
+ return `${jsonExpr} > ?`;
760
+ case ComparisonOperator.LessThan:
761
+ return `${jsonExpr} < ?`;
762
+ case ComparisonOperator.GreaterThanOrEqual:
763
+ return `${jsonExpr} >= ?`;
764
+ case ComparisonOperator.LessThanOrEqual:
765
+ return `${jsonExpr} <= ?`;
766
+ default:
767
+ return `${jsonExpr} = ?`;
768
+ }
568
769
  }
569
- else if (comparator.comparison === ComparisonOperator.Includes) {
570
- return `JSON_CONTAINS(\`${prop}\`, ?)`;
770
+ switch (comparator.comparison) {
771
+ case ComparisonOperator.Equals:
772
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
773
+ return `JSON_CONTAINS(\`${prop}\`, ?)`;
774
+ }
775
+ return `\`${prop}\` = ?`;
776
+ case ComparisonOperator.NotEquals:
777
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
778
+ return `NOT JSON_CONTAINS(\`${prop}\`, ?)`;
779
+ }
780
+ return `\`${prop}\` <> ?`;
781
+ case ComparisonOperator.GreaterThan:
782
+ return `\`${prop}\` > ?`;
783
+ case ComparisonOperator.LessThan:
784
+ return `\`${prop}\` < ?`;
785
+ case ComparisonOperator.GreaterThanOrEqual:
786
+ return `\`${prop}\` >= ?`;
787
+ case ComparisonOperator.LessThanOrEqual:
788
+ return `\`${prop}\` <= ?`;
789
+ case ComparisonOperator.Includes: {
790
+ if (type === EntitySchemaPropertyType.String) {
791
+ values.pop();
792
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
793
+ return `LOWER(\`${prop}\`) LIKE ?`;
794
+ }
795
+ return `JSON_CONTAINS(\`${prop}\`, ?)`;
796
+ }
797
+ default:
798
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
799
+ comparison: comparator.comparison
800
+ });
571
801
  }
572
- throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
573
- comparison: comparator.comparison
574
- });
575
802
  }
576
803
  /**
577
804
  * Format a value to insert into DB.