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

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,34 @@ 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
+ data: { database: this._config.database, tableName: this._config.tableName }
93
+ }
94
+ ];
95
+ }
96
+ catch {
97
+ return [
98
+ {
99
+ source: MySqlEntityStorageConnector.CLASS_NAME,
100
+ status: HealthStatus.Error,
101
+ description: "healthDescription",
102
+ message: "connectionFailed",
103
+ data: { database: this._config.database, tableName: this._config.tableName }
104
+ }
105
+ ];
106
+ }
107
+ }
80
108
  /**
81
109
  * Get the schema for the entities.
82
110
  * @returns The schema for the entities.
@@ -92,7 +120,7 @@ export class MySqlEntityStorageConnector {
92
120
  async bootstrap(nodeLoggingComponentType) {
93
121
  const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
94
122
  try {
95
- const dbConnection = await this.createConnection();
123
+ const pool = this.getPool();
96
124
  const databaseExists = await this.databaseExists();
97
125
  if (!databaseExists) {
98
126
  await nodeLogging?.log({
@@ -104,7 +132,7 @@ export class MySqlEntityStorageConnector {
104
132
  databaseName: this._config.database
105
133
  }
106
134
  });
107
- await dbConnection.query(`CREATE DATABASE IF NOT EXISTS \`${this._config.database}\``);
135
+ await pool.query(`CREATE DATABASE IF NOT EXISTS \`${this._config.database}\``);
108
136
  await this.waitForDatabaseExists();
109
137
  }
110
138
  else {
@@ -129,7 +157,7 @@ export class MySqlEntityStorageConnector {
129
157
  tableName: this._config.tableName
130
158
  }
131
159
  });
132
- await dbConnection.query(`CREATE TABLE IF NOT EXISTS \`${this._config.database}\`.\`${this._config.tableName}\` (${this.mapMySqlProperties()})`);
160
+ await pool.query(`CREATE TABLE IF NOT EXISTS \`${this._config.database}\`.\`${this._config.tableName}\` (${this.mapMySqlProperties()})`);
133
161
  await this.waitForTableExists();
134
162
  }
135
163
  else {
@@ -159,6 +187,29 @@ export class MySqlEntityStorageConnector {
159
187
  }
160
188
  return true;
161
189
  }
190
+ /**
191
+ * The component needs to be stopped when the node is closed.
192
+ * @param nodeLoggingComponentType The node logging component type.
193
+ * @returns Nothing.
194
+ */
195
+ async stop(nodeLoggingComponentType) {
196
+ if (this._pool) {
197
+ const poolConfig = this.createPoolConfig();
198
+ const poolId = `${poolConfig.host}|${poolConfig.port}|${poolConfig.user}`;
199
+ let sharedPools = SharedStore.get("mySqlPools");
200
+ sharedPools ??= {};
201
+ if (sharedPools[poolId]) {
202
+ // Decrease the use counter and close the pool if no longer used
203
+ sharedPools[poolId].useCounter--;
204
+ if (sharedPools[poolId].useCounter <= 0) {
205
+ await this._pool.end();
206
+ delete sharedPools[poolId];
207
+ }
208
+ SharedStore.set("mySqlPools", sharedPools);
209
+ }
210
+ this._pool = undefined;
211
+ }
212
+ }
162
213
  /**
163
214
  * Get an entity from MySql.
164
215
  * @param id The id of the entity to get, or the index value if secondaryIndex is set.
@@ -171,7 +222,7 @@ export class MySqlEntityStorageConnector {
171
222
  const contextIds = await ContextIdStore.getContextIds();
172
223
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
173
224
  try {
174
- const dbConnection = await this.createConnection();
225
+ const pool = this.getPool();
175
226
  const whereClauses = [];
176
227
  const values = [];
177
228
  whereClauses.push(`\`${MySqlEntityStorageConnector._PARTITION_KEY}\` = ?`);
@@ -190,7 +241,7 @@ export class MySqlEntityStorageConnector {
190
241
  }
191
242
  }
192
243
  const query = `SELECT * FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE ${whereClauses.join(" AND ")} LIMIT 1`;
193
- const [rows] = await dbConnection.query(query, values);
244
+ const [rows] = await pool.query(query, values);
194
245
  if (Is.array(rows) && rows.length === 1) {
195
246
  const item = ObjectHelper.removeEmptyProperties(rows[0], { removeNull: true });
196
247
  ObjectHelper.propertyDelete(item, MySqlEntityStorageConnector._PARTITION_KEY);
@@ -248,8 +299,8 @@ export class MySqlEntityStorageConnector {
248
299
  sql += ` (${keys.map(key => `\`${key}\``).join(", ")})`;
249
300
  sql += ` VALUES (${values.map(() => "?").join(", ")})`;
250
301
  sql += ` ON DUPLICATE KEY UPDATE ${keys.map(key => `\`${key}\` = VALUES(\`${key}\`)`).join(", ")};`;
251
- const dbConnection = await this.createConnection();
252
- await dbConnection.query(sql, values);
302
+ const pool = this.getPool();
303
+ await pool.query(sql, values);
253
304
  }
254
305
  catch (err) {
255
306
  throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "setFailed", {
@@ -257,6 +308,67 @@ export class MySqlEntityStorageConnector {
257
308
  }, err);
258
309
  }
259
310
  }
311
+ /**
312
+ * Set multiple entities in a batch.
313
+ * @param entities The entities to set.
314
+ * @returns Nothing.
315
+ */
316
+ async setBatch(entities) {
317
+ Guards.arrayValue(MySqlEntityStorageConnector.CLASS_NAME, "entities", entities);
318
+ const contextIds = await ContextIdStore.getContextIds();
319
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
320
+ for (const entity of entities) {
321
+ EntitySchemaHelper.validateEntity(entity, this._entitySchema);
322
+ }
323
+ try {
324
+ const props = [...(this._entitySchema.properties ?? [])];
325
+ props.unshift({
326
+ property: MySqlEntityStorageConnector._PARTITION_KEY,
327
+ type: EntitySchemaPropertyType.String
328
+ });
329
+ const keys = props.map(p => p.property);
330
+ const allValues = [];
331
+ for (const entity of entities) {
332
+ const finalEntity = ObjectHelper.clone(entity);
333
+ ObjectHelper.propertySet(finalEntity, MySqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE);
334
+ for (const prop of props) {
335
+ const val = finalEntity[prop.property];
336
+ if (prop.type === EntitySchemaPropertyType.Object ||
337
+ prop.type === EntitySchemaPropertyType.Array) {
338
+ allValues.push(Is.empty(val) ? null : JSON.stringify(val));
339
+ }
340
+ else {
341
+ allValues.push(Is.empty(val) ? null : val);
342
+ }
343
+ }
344
+ }
345
+ const rowPlaceholder = `(${keys.map(() => "?").join(", ")})`;
346
+ let sql = `INSERT INTO \`${this._config.database}\`.\`${this._config.tableName}\``;
347
+ sql += ` (${keys.map(key => `\`${key}\``).join(", ")})`;
348
+ sql += ` VALUES ${entities.map(() => rowPlaceholder).join(", ")}`;
349
+ sql += ` ON DUPLICATE KEY UPDATE ${keys.map(key => `\`${key}\` = VALUES(\`${key}\`)`).join(", ")};`;
350
+ const pool = this.getPool();
351
+ await pool.query(sql, allValues);
352
+ }
353
+ catch (err) {
354
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
355
+ }
356
+ }
357
+ /**
358
+ * Empty the entity storage.
359
+ * @returns Nothing.
360
+ */
361
+ async empty() {
362
+ const contextIds = await ContextIdStore.getContextIds();
363
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
364
+ try {
365
+ const pool = this.getPool();
366
+ await pool.query(`DELETE FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE \`${MySqlEntityStorageConnector._PARTITION_KEY}\` = ?`, [partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE]);
367
+ }
368
+ catch (err) {
369
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
370
+ }
371
+ }
260
372
  /**
261
373
  * Remove the entity.
262
374
  * @param id The id of the entity to remove.
@@ -268,7 +380,7 @@ export class MySqlEntityStorageConnector {
268
380
  const contextIds = await ContextIdStore.getContextIds();
269
381
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
270
382
  try {
271
- const dbConnection = await this.createConnection();
383
+ const pool = this.getPool();
272
384
  const itemData = await this.get(id, undefined, conditions);
273
385
  if (Is.notEmpty(itemData)) {
274
386
  const values = [];
@@ -284,7 +396,7 @@ export class MySqlEntityStorageConnector {
284
396
  }));
285
397
  }
286
398
  const query = `DELETE FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE ${whereClauses.join(" AND ")}`;
287
- await dbConnection.query(query, values);
399
+ await pool.query(query, values);
288
400
  }
289
401
  }
290
402
  catch (err) {
@@ -293,6 +405,67 @@ export class MySqlEntityStorageConnector {
293
405
  }, err);
294
406
  }
295
407
  }
408
+ /**
409
+ * Teardown the entity storage by dropping the table.
410
+ * @param nodeLoggingComponentType The node logging component type.
411
+ * @returns True if the teardown process was successful.
412
+ */
413
+ async teardown(nodeLoggingComponentType) {
414
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
415
+ await nodeLogging?.log({
416
+ level: "info",
417
+ source: MySqlEntityStorageConnector.CLASS_NAME,
418
+ ts: Date.now(),
419
+ message: "tableDropping",
420
+ data: { tableName: this._config.tableName }
421
+ });
422
+ try {
423
+ if (await this.tableExists()) {
424
+ const pool = this.getPool();
425
+ await pool.query(`DROP TABLE \`${this._config.database}\`.\`${this._config.tableName}\`;`);
426
+ await this.waitForTableNotExists();
427
+ }
428
+ await nodeLogging?.log({
429
+ level: "info",
430
+ source: MySqlEntityStorageConnector.CLASS_NAME,
431
+ ts: Date.now(),
432
+ message: "tableDropped",
433
+ data: { tableName: this._config.tableName }
434
+ });
435
+ return true;
436
+ }
437
+ catch (err) {
438
+ await nodeLogging?.log({
439
+ level: "error",
440
+ source: MySqlEntityStorageConnector.CLASS_NAME,
441
+ ts: Date.now(),
442
+ message: "teardownFailed",
443
+ error: BaseError.fromError(err)
444
+ });
445
+ return false;
446
+ }
447
+ }
448
+ /**
449
+ * Remove multiple entities by their primary key IDs.
450
+ * @param ids The ids of the entities to remove.
451
+ * @returns Nothing.
452
+ */
453
+ async removeBatch(ids) {
454
+ Guards.arrayValue(MySqlEntityStorageConnector.CLASS_NAME, "ids", ids);
455
+ const contextIds = await ContextIdStore.getContextIds();
456
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
457
+ try {
458
+ const pool = this.getPool();
459
+ const sql = `DELETE FROM \`${this._config.database}\`.\`${this._config.tableName}\` WHERE \`${MySqlEntityStorageConnector._PARTITION_KEY}\` = ? AND \`${String(this._primaryKeyProperty.property)}\` IN (?)`;
460
+ await pool.query(sql, [
461
+ partitionKey ?? MySqlEntityStorageConnector._PARTITION_KEY_VALUE,
462
+ ids
463
+ ]);
464
+ }
465
+ catch (err) {
466
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
467
+ }
468
+ }
296
469
  /**
297
470
  * Find all the entities which match the conditions.
298
471
  * @param conditions The conditions to match for the entities.
@@ -337,8 +510,8 @@ export class MySqlEntityStorageConnector {
337
510
  sql = `SELECT ${properties ? properties.map(p => `\`${String(p)}\``).join(", ") : "*"} FROM \`${this._config.database}\`.\`${this._config.tableName}\``;
338
511
  sql += ` WHERE ${whereClauses.join(" AND ")} ${orderByClause}`;
339
512
  sql += ` LIMIT ${returnSize} OFFSET ${startIndex}`;
340
- const dbConnection = await this.createConnection();
341
- const [rows] = (await dbConnection.query(sql, values)) ?? [];
513
+ const pool = this.getPool();
514
+ const [rows] = (await pool.query(sql, values)) ?? [];
342
515
  const entities = rows;
343
516
  for (let i = 0; i < entities.length; i++) {
344
517
  entities[i] = ObjectHelper.removeEmptyProperties(entities[i], { removeNull: true });
@@ -356,34 +529,19 @@ export class MySqlEntityStorageConnector {
356
529
  }
357
530
  }
358
531
  /**
359
- * Drop the table.
360
- * @returns Nothing.
532
+ * Count all the entities which match the conditions.
533
+ * @returns The total count of entities in the storage.
361
534
  */
362
- async tableDrop() {
535
+ async count() {
363
536
  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
- }
537
+ const contextIds = await ContextIdStore.getContextIds();
538
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
539
+ const pool = this.getPool();
540
+ 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]);
541
+ return Number(rows[0].count);
369
542
  }
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
543
+ catch (err) {
544
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
387
545
  }
388
546
  }
389
547
  /**
@@ -392,8 +550,8 @@ export class MySqlEntityStorageConnector {
392
550
  */
393
551
  async databaseExists() {
394
552
  try {
395
- const dbConnection = await this.createConnection();
396
- const [rows] = await dbConnection.query("SHOW DATABASES LIKE ?;", [this._config.database]);
553
+ const pool = this.getPool();
554
+ const [rows] = await pool.query("SHOW DATABASES LIKE ?;", [this._config.database]);
397
555
  return Is.arrayValue(rows);
398
556
  }
399
557
  catch {
@@ -421,8 +579,8 @@ export class MySqlEntityStorageConnector {
421
579
  */
422
580
  async tableExists() {
423
581
  try {
424
- const dbConnection = await this.createConnection();
425
- const [rows] = await dbConnection.query("SHOW TABLES FROM ?? LIKE ?", [
582
+ const pool = this.getPool();
583
+ const [rows] = await pool.query("SHOW TABLES FROM ?? LIKE ?", [
426
584
  this._config.database,
427
585
  this._config.tableName
428
586
  ]);
@@ -461,29 +619,49 @@ export class MySqlEntityStorageConnector {
461
619
  }
462
620
  }
463
621
  /**
464
- * Create a new DB connection.
465
- * @returns The MySql connection.
622
+ * Get or create the connection pool.
623
+ * @returns The MySql connection pool.
466
624
  * @internal
467
625
  */
468
- async createConnection() {
469
- if (this._connection) {
470
- return this._connection;
626
+ getPool() {
627
+ if (!this._pool) {
628
+ const poolConfig = this.createPoolConfig();
629
+ const poolId = `${poolConfig.host}|${poolConfig.port}|${poolConfig.user}`;
630
+ let sharedPools = SharedStore.get("mySqlPools");
631
+ sharedPools ??= {};
632
+ // If there is no pool for the id, create it
633
+ if (!sharedPools[poolId]) {
634
+ sharedPools[poolId] = {
635
+ pool: createPool(poolConfig),
636
+ useCounter: 0
637
+ };
638
+ SharedStore.set("mySqlPools", sharedPools);
639
+ }
640
+ // Increase the use counter and return the pool
641
+ sharedPools[poolId].useCounter++;
642
+ this._pool = sharedPools[poolId].pool;
471
643
  }
472
- const newConnection = await createConnection(this.createConnectionConfig());
473
- this._connection = newConnection;
474
- return newConnection;
644
+ return this._pool;
475
645
  }
476
646
  /**
477
- * Create a new DB connection configuration.
478
- * @returns The MySql connection configuration.
647
+ * Create the connection pool configuration.
648
+ * @returns The MySql pool configuration.
479
649
  * @internal
480
650
  */
481
- createConnectionConfig() {
651
+ createPoolConfig() {
652
+ const poolConfig = this._config.pool ?? {};
482
653
  return {
483
654
  host: this._config.host,
484
655
  port: this._config.port ?? 3306,
485
656
  user: this._config.user,
486
- password: this._config.password
657
+ password: this._config.password,
658
+ connectionLimit: poolConfig.connectionLimit ?? 10,
659
+ maxIdle: poolConfig.maxIdle ?? 10,
660
+ idleTimeout: poolConfig.idleTimeout ?? 60000,
661
+ enableKeepAlive: poolConfig.enableKeepAlive ?? true,
662
+ keepAliveInitialDelay: 0,
663
+ waitForConnections: poolConfig.waitForConnections ?? true,
664
+ queueLimit: poolConfig.queueLimit ?? 0
487
665
  };
488
666
  }
489
667
  /**
@@ -536,42 +714,93 @@ export class MySqlEntityStorageConnector {
536
714
  prop += ".";
537
715
  }
538
716
  prop += comparator.property;
539
- // prop = prop.replace(/\./g, "->");
540
717
  if (comparator.comparison === ComparisonOperator.In) {
541
718
  const inValues = Is.array(comparator.value) ? comparator.value : [comparator.value];
542
719
  values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
543
720
  const placeholders = inValues.map(() => "?").join(", ");
544
721
  return `\`${prop}\` IN (${placeholders})`;
545
722
  }
723
+ // null/undefined must use IS NULL / IS NOT NULL — never a parameterised placeholder.
724
+ // Passing undefined through propertyToDbValue() coerces it to NaN for number fields
725
+ // (Number(undefined) === NaN), and null coerces to 0 (Number(null) === 0), both of
726
+ // which produce semantically wrong or invalid SQL.
727
+ if (comparator.value === null || comparator.value === undefined) {
728
+ if (comparator.comparison === ComparisonOperator.Equals ||
729
+ comparator.comparison === ComparisonOperator.NotEquals) {
730
+ const nullCheck = comparator.comparison === ComparisonOperator.Equals ? "IS NULL" : "IS NOT NULL";
731
+ if (comparator.property.split(".").length > 1) {
732
+ const rootProp = comparator.property.split(".")[0];
733
+ const nestedPath = comparator.property.split(".").slice(1).join(".");
734
+ const rootSchema = this._entitySchema.properties?.find(p => p.property === rootProp);
735
+ const isArray = rootSchema?.type === EntitySchemaPropertyType.Array;
736
+ const jsonPath = isArray ? `$[*].${nestedPath}` : `$.${nestedPath}`;
737
+ const jsonExpr = `JSON_UNQUOTE(JSON_EXTRACT(\`${rootProp}\`, '${jsonPath}'))`;
738
+ return `${jsonExpr} ${nullCheck}`;
739
+ }
740
+ return `\`${prop}\` ${nullCheck}`;
741
+ }
742
+ }
546
743
  const dbValue = this.propertyToDbValue(comparator.value, type);
547
744
  values.push(dbValue);
548
745
  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}\` <= ?`;
746
+ const rootProp = comparator.property.split(".")[0];
747
+ const nestedPath = comparator.property.split(".").slice(1).join(".");
748
+ const rootSchema = this._entitySchema.properties?.find(p => p.property === rootProp);
749
+ const isArray = rootSchema?.type === EntitySchemaPropertyType.Array;
750
+ const jsonPath = isArray ? `$[*].${nestedPath}` : `$.${nestedPath}`;
751
+ const jsonExpr = `JSON_UNQUOTE(JSON_EXTRACT(\`${rootProp}\`, '${jsonPath}'))`;
752
+ switch (comparator.comparison) {
753
+ case ComparisonOperator.Includes: {
754
+ values.pop();
755
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
756
+ return `LOWER(${jsonExpr}) LIKE ?`;
757
+ }
758
+ case ComparisonOperator.NotEquals:
759
+ return `${jsonExpr} <> ?`;
760
+ case ComparisonOperator.GreaterThan:
761
+ return `${jsonExpr} > ?`;
762
+ case ComparisonOperator.LessThan:
763
+ return `${jsonExpr} < ?`;
764
+ case ComparisonOperator.GreaterThanOrEqual:
765
+ return `${jsonExpr} >= ?`;
766
+ case ComparisonOperator.LessThanOrEqual:
767
+ return `${jsonExpr} <= ?`;
768
+ default:
769
+ return `${jsonExpr} = ?`;
770
+ }
568
771
  }
569
- else if (comparator.comparison === ComparisonOperator.Includes) {
570
- return `JSON_CONTAINS(\`${prop}\`, ?)`;
772
+ switch (comparator.comparison) {
773
+ case ComparisonOperator.Equals:
774
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
775
+ return `JSON_CONTAINS(\`${prop}\`, ?)`;
776
+ }
777
+ return `\`${prop}\` = ?`;
778
+ case ComparisonOperator.NotEquals:
779
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
780
+ return `NOT JSON_CONTAINS(\`${prop}\`, ?)`;
781
+ }
782
+ return `\`${prop}\` <> ?`;
783
+ case ComparisonOperator.GreaterThan:
784
+ return `\`${prop}\` > ?`;
785
+ case ComparisonOperator.LessThan:
786
+ return `\`${prop}\` < ?`;
787
+ case ComparisonOperator.GreaterThanOrEqual:
788
+ return `\`${prop}\` >= ?`;
789
+ case ComparisonOperator.LessThanOrEqual:
790
+ return `\`${prop}\` <= ?`;
791
+ case ComparisonOperator.Includes: {
792
+ if (type === EntitySchemaPropertyType.String) {
793
+ values.pop();
794
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
795
+ return `LOWER(\`${prop}\`) LIKE ?`;
796
+ }
797
+ return `JSON_CONTAINS(\`${prop}\`, ?)`;
798
+ }
799
+ default:
800
+ throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
801
+ comparison: comparator.comparison
802
+ });
571
803
  }
572
- throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
573
- comparison: comparator.comparison
574
- });
575
804
  }
576
805
  /**
577
806
  * Format a value to insert into DB.