@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.
- package/dist/es/models/IMySqlEntityStorageConnectorConstructorOptions.js.map +1 -1
- package/dist/es/mysqlEntityStorageConnector.js +368 -66
- package/dist/es/mysqlEntityStorageConnector.js.map +1 -1
- package/dist/types/models/IMySqlEntityStorageConnectorConstructorOptions.d.ts +0 -1
- package/dist/types/mysqlEntityStorageConnector.d.ts +61 -14
- package/docs/changelog.md +618 -64
- package/docs/reference/classes/MySqlEntityStorageConnector.md +268 -18
- package/docs/reference/interfaces/IMySqlEntityStorageConnectorConstructorOptions.md +0 -6
- package/locales/en.json +17 -2
- package/package.json +11 -11
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
|
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] =
|
|
353
|
-
|
|
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:
|
|
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
|
-
*
|
|
368
|
-
* @
|
|
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
|
|
547
|
+
async count(conditions) {
|
|
548
|
+
let sql;
|
|
371
549
|
try {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
561
|
+
catch (err) {
|
|
562
|
+
throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "countFailed", { sql }, err);
|
|
380
563
|
}
|
|
381
564
|
}
|
|
382
565
|
/**
|
|
383
|
-
*
|
|
384
|
-
* @returns
|
|
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
|
|
569
|
+
async getPartitionContextIds() {
|
|
570
|
+
if (!Is.arrayValue(this._partitionContextIds)) {
|
|
571
|
+
return [];
|
|
572
|
+
}
|
|
387
573
|
try {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
1042
|
+
if (!entitySchema.properties) {
|
|
741
1043
|
throw new GeneralError(MySqlEntityStorageConnector.CLASS_NAME, "entitySchemaPropertiesUndefined");
|
|
742
1044
|
}
|
|
743
1045
|
const primaryKeys = [];
|
|
744
|
-
const props = [...
|
|
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
|
|
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
|
|
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
|
|
1080
|
+
case EntitySchemaPropertyType.Integer:
|
|
779
1081
|
sqlType = "INT";
|
|
780
1082
|
switch (prop.format) {
|
|
781
1083
|
case "int8":
|