@twin.org/entity-storage-connector-postgresql 0.0.3-next.2 → 0.0.3-next.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -12
- package/dist/es/postgreSqlEntityStorageConnector.js +471 -92
- package/dist/es/postgreSqlEntityStorageConnector.js.map +1 -1
- package/dist/types/postgreSqlEntityStorageConnector.d.ts +67 -5
- package/docs/changelog.md +425 -46
- package/docs/examples.md +98 -1
- package/docs/reference/classes/PostgreSqlEntityStorageConnector.md +301 -21
- package/docs/reference/interfaces/IPostgreSqlEntityStorageConnectorConfig.md +7 -7
- package/docs/reference/interfaces/IPostgreSqlEntityStorageConnectorConstructorOptions.md +6 -6
- package/locales/en.json +17 -2
- package/package.json +6 -6
|
@@ -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 } from "@twin.org/core";
|
|
4
|
+
import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, HealthStatus, Is, ObjectHelper } 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 postgres from "postgres";
|
|
7
8
|
/**
|
|
8
9
|
* Class for performing entity storage operations using ql.
|
|
@@ -27,6 +28,11 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
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 PostgreSqlEntityStorageConnector {
|
|
|
65
71
|
Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.password", options.config.password);
|
|
66
72
|
Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.database", options.config.database);
|
|
67
73
|
Guards.stringValue(PostgreSqlEntityStorageConnector.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);
|
|
@@ -153,6 +160,45 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
153
160
|
className() {
|
|
154
161
|
return PostgreSqlEntityStorageConnector.CLASS_NAME;
|
|
155
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Get the health of the component.
|
|
165
|
+
* @returns The health of the component.
|
|
166
|
+
*/
|
|
167
|
+
async health() {
|
|
168
|
+
try {
|
|
169
|
+
const sql = await this.createConnection();
|
|
170
|
+
await sql `SELECT 1 FROM ${sql(this._config.tableName)} LIMIT 0`;
|
|
171
|
+
return [
|
|
172
|
+
{
|
|
173
|
+
source: PostgreSqlEntityStorageConnector.CLASS_NAME,
|
|
174
|
+
status: HealthStatus.Ok,
|
|
175
|
+
description: "healthDescription",
|
|
176
|
+
data: { tableName: this._config.tableName }
|
|
177
|
+
}
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return [
|
|
182
|
+
{
|
|
183
|
+
source: PostgreSqlEntityStorageConnector.CLASS_NAME,
|
|
184
|
+
status: HealthStatus.Error,
|
|
185
|
+
description: "healthDescription",
|
|
186
|
+
message: "connectionFailed",
|
|
187
|
+
data: { tableName: this._config.tableName }
|
|
188
|
+
}
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The component needs to be stopped when the node is closed.
|
|
194
|
+
* @returns Nothing.
|
|
195
|
+
*/
|
|
196
|
+
async stop() {
|
|
197
|
+
if (this._connection) {
|
|
198
|
+
await this._connection.end();
|
|
199
|
+
this._connection = undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
156
202
|
/**
|
|
157
203
|
* Get the schema for the entities.
|
|
158
204
|
* @returns The schema for the entities.
|
|
@@ -219,9 +265,9 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
219
265
|
}
|
|
220
266
|
}
|
|
221
267
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
268
|
+
return EntityStorageHelper.unPrepareEntity(rows[0], [
|
|
269
|
+
PostgreSqlEntityStorageConnector._PARTITION_KEY
|
|
270
|
+
]);
|
|
225
271
|
}
|
|
226
272
|
}
|
|
227
273
|
catch (err) {
|
|
@@ -241,8 +287,13 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
241
287
|
Guards.object(PostgreSqlEntityStorageConnector.CLASS_NAME, "entity", entity);
|
|
242
288
|
const contextIds = await ContextIdStore.getContextIds();
|
|
243
289
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
244
|
-
|
|
245
|
-
|
|
290
|
+
const prepared = EntityStorageHelper.prepareEntity(entity, this._entitySchema, [
|
|
291
|
+
{
|
|
292
|
+
property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
|
|
293
|
+
value: partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
|
|
294
|
+
}
|
|
295
|
+
], { nullBehavior: "nullify" });
|
|
296
|
+
const id = prepared[this._primaryKeyProperty.property];
|
|
246
297
|
try {
|
|
247
298
|
if (Is.arrayValue(conditions)) {
|
|
248
299
|
const itemData = await this.get(id);
|
|
@@ -250,30 +301,21 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
250
301
|
return;
|
|
251
302
|
}
|
|
252
303
|
}
|
|
253
|
-
const finalEntity = ObjectHelper.clone(entity);
|
|
254
304
|
const props = [...(this._entitySchema.properties ?? [])];
|
|
255
305
|
props.unshift({
|
|
256
306
|
property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
|
|
257
307
|
type: EntitySchemaPropertyType.String
|
|
258
308
|
});
|
|
259
|
-
ObjectHelper.propertySet(finalEntity, PostgreSqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE);
|
|
260
|
-
ObjectHelper.propertySet(finalEntity, PostgreSqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE);
|
|
261
309
|
const keys = [];
|
|
262
310
|
const values = [];
|
|
263
311
|
for (const prop of props) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
values.push(null);
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
270
|
-
values.push(finalEntity[prop.property]);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
312
|
+
keys.push(prop.property);
|
|
313
|
+
const val = prepared[prop.property];
|
|
314
|
+
values.push(val ?? null);
|
|
273
315
|
}
|
|
274
316
|
let sql = `INSERT INTO "${this._config.tableName}"`;
|
|
275
317
|
sql += ` (${keys.map(key => `"${key}"`).join(", ")})`;
|
|
276
|
-
sql += ` VALUES (${values.map((
|
|
318
|
+
sql += ` VALUES (${values.map((value, i) => `$${i + 1}`).join(", ")})`;
|
|
277
319
|
sql += ` ON CONFLICT ("${PostgreSqlEntityStorageConnector._PARTITION_KEY}", "${this._primaryKeyProperty.property}")`;
|
|
278
320
|
sql += ` DO UPDATE SET ${keys.map(key => `"${key}" = EXCLUDED."${key}"`).join(", ")};`;
|
|
279
321
|
const dbConnection = await this.createConnection();
|
|
@@ -285,6 +327,69 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
285
327
|
}, err);
|
|
286
328
|
}
|
|
287
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Set multiple entities in a batch.
|
|
332
|
+
* @param entities The entities to set.
|
|
333
|
+
* @returns Nothing.
|
|
334
|
+
*/
|
|
335
|
+
async setBatch(entities) {
|
|
336
|
+
Guards.arrayValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "entities", entities);
|
|
337
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
338
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
339
|
+
const preparedEntities = entities.map(entity => EntityStorageHelper.prepareEntity(entity, this._entitySchema, [
|
|
340
|
+
{
|
|
341
|
+
property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
|
|
342
|
+
value: partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
|
|
343
|
+
}
|
|
344
|
+
], { nullBehavior: "nullify" }));
|
|
345
|
+
try {
|
|
346
|
+
const props = [...(this._entitySchema.properties ?? [])];
|
|
347
|
+
props.unshift({
|
|
348
|
+
property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
|
|
349
|
+
type: EntitySchemaPropertyType.String
|
|
350
|
+
});
|
|
351
|
+
const keys = props.map(p => p.property);
|
|
352
|
+
const allValues = [];
|
|
353
|
+
const rowPlaceholders = [];
|
|
354
|
+
for (const prepared of preparedEntities) {
|
|
355
|
+
const rowValues = [];
|
|
356
|
+
for (const prop of props) {
|
|
357
|
+
const val = prepared[prop.property];
|
|
358
|
+
allValues.push(Is.empty(val) ? null : val);
|
|
359
|
+
rowValues.push(`$${allValues.length}`);
|
|
360
|
+
}
|
|
361
|
+
rowPlaceholders.push(`(${rowValues.join(", ")})`);
|
|
362
|
+
}
|
|
363
|
+
let sql = `INSERT INTO "${this._config.tableName}"`;
|
|
364
|
+
sql += ` (${keys.map(key => `"${key}"`).join(", ")})`;
|
|
365
|
+
sql += ` VALUES ${rowPlaceholders.join(", ")}`;
|
|
366
|
+
sql += ` ON CONFLICT ("${PostgreSqlEntityStorageConnector._PARTITION_KEY}", "${this._primaryKeyProperty.property}")`;
|
|
367
|
+
sql += ` DO UPDATE SET ${keys.map(key => `"${key}" = EXCLUDED."${key}"`).join(", ")};`;
|
|
368
|
+
const dbConnection = await this.createConnection();
|
|
369
|
+
await dbConnection.unsafe(sql, allValues);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Empty all the entities.
|
|
377
|
+
* @returns Nothing.
|
|
378
|
+
*/
|
|
379
|
+
async empty() {
|
|
380
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
381
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
382
|
+
try {
|
|
383
|
+
const sql = `DELETE FROM "${this._config.tableName}" WHERE "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" = $1`;
|
|
384
|
+
const dbConnection = await this.createConnection();
|
|
385
|
+
await dbConnection.unsafe(sql, [
|
|
386
|
+
partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
|
|
387
|
+
]);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
288
393
|
/**
|
|
289
394
|
* Remove the entity.
|
|
290
395
|
* @param id The id of the entity to remove.
|
|
@@ -321,6 +426,136 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
321
426
|
}, err);
|
|
322
427
|
}
|
|
323
428
|
}
|
|
429
|
+
/**
|
|
430
|
+
* Remove multiple entities by their primary key IDs.
|
|
431
|
+
* @param ids The ids of the entities to remove.
|
|
432
|
+
* @returns Nothing.
|
|
433
|
+
*/
|
|
434
|
+
async removeBatch(ids) {
|
|
435
|
+
Guards.arrayValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "ids", ids);
|
|
436
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
437
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
438
|
+
try {
|
|
439
|
+
const sql = `DELETE FROM "${this._config.tableName}" WHERE "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" = $1 AND "${this._primaryKeyProperty.property}" = ANY($2)`;
|
|
440
|
+
const dbConnection = await this.createConnection();
|
|
441
|
+
await dbConnection.unsafe(sql, [
|
|
442
|
+
partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE,
|
|
443
|
+
ids
|
|
444
|
+
]);
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Teardown the entity storage by dropping the table.
|
|
452
|
+
* @param nodeLoggingComponentType The node logging component type.
|
|
453
|
+
* @returns True if the teardown process was successful.
|
|
454
|
+
*/
|
|
455
|
+
async teardown(nodeLoggingComponentType) {
|
|
456
|
+
const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
|
|
457
|
+
await nodeLogging?.log({
|
|
458
|
+
level: "info",
|
|
459
|
+
source: PostgreSqlEntityStorageConnector.CLASS_NAME,
|
|
460
|
+
ts: Date.now(),
|
|
461
|
+
message: "tableDropping",
|
|
462
|
+
data: { tableName: this._config.tableName }
|
|
463
|
+
});
|
|
464
|
+
try {
|
|
465
|
+
const tableExists = await this.tableExists();
|
|
466
|
+
if (tableExists) {
|
|
467
|
+
const dbConnection = await this.createConnection();
|
|
468
|
+
await dbConnection.unsafe(`DROP TABLE "${this._config.tableName}";`);
|
|
469
|
+
await this.waitForTableNotExists();
|
|
470
|
+
}
|
|
471
|
+
await nodeLogging?.log({
|
|
472
|
+
level: "info",
|
|
473
|
+
source: PostgreSqlEntityStorageConnector.CLASS_NAME,
|
|
474
|
+
ts: Date.now(),
|
|
475
|
+
message: "tableDropped",
|
|
476
|
+
data: { tableName: this._config.tableName }
|
|
477
|
+
});
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
await nodeLogging?.log({
|
|
482
|
+
level: "error",
|
|
483
|
+
source: PostgreSqlEntityStorageConnector.CLASS_NAME,
|
|
484
|
+
ts: Date.now(),
|
|
485
|
+
message: "teardownFailed",
|
|
486
|
+
error: BaseError.fromError(err)
|
|
487
|
+
});
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Get all the distinct partition context ids from the storage.
|
|
493
|
+
* @returns An array of context id objects, one per unique partition.
|
|
494
|
+
*/
|
|
495
|
+
async getPartitionContextIds() {
|
|
496
|
+
if (!Is.arrayValue(this._partitionContextIds)) {
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
const dbConnection = await this.createConnection();
|
|
501
|
+
const rows = await dbConnection.unsafe(`SELECT DISTINCT "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" FROM "${this._config.tableName}"`);
|
|
502
|
+
return rows
|
|
503
|
+
.map(row => row[PostgreSqlEntityStorageConnector._PARTITION_KEY])
|
|
504
|
+
.filter((id) => Is.stringValue(id))
|
|
505
|
+
.map(id => ContextIdHelper.shortSplit(this._partitionContextIds ?? [], id));
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "getPartitionContextIdsFailed", undefined, err);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Create a new target connector for the migration.
|
|
513
|
+
* @param entitySchemaName The entity schema name to use for the target connector.
|
|
514
|
+
* @returns A new connector configured with a migration table name.
|
|
515
|
+
*/
|
|
516
|
+
async createTargetConnector(entitySchemaName) {
|
|
517
|
+
return new PostgreSqlEntityStorageConnector({
|
|
518
|
+
entitySchema: entitySchemaName,
|
|
519
|
+
config: {
|
|
520
|
+
...this._config,
|
|
521
|
+
tableName: `${this._config.tableName}Migration${Date.now()}`
|
|
522
|
+
},
|
|
523
|
+
partitionContextIds: this._partitionContextIds
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Finalize the migration by renaming the migration table to the original table name.
|
|
528
|
+
* @param targetConnector The connector pointing to the migration table.
|
|
529
|
+
* @param options The optional migration options.
|
|
530
|
+
* @param loggingComponentType The node logging component type.
|
|
531
|
+
* @returns A connector pointing to the final (renamed) table.
|
|
532
|
+
*/
|
|
533
|
+
async finalizeMigration(targetConnector, options, loggingComponentType) {
|
|
534
|
+
// Teardown the existing table with the original name to free up the name for the new table
|
|
535
|
+
await this.teardown(loggingComponentType);
|
|
536
|
+
const dbConnection = await targetConnector.createConnection();
|
|
537
|
+
await dbConnection.unsafe(`ALTER TABLE "${targetConnector._config.tableName}" RENAME TO "${this._config.tableName}"`);
|
|
538
|
+
const finalConnector = new PostgreSqlEntityStorageConnector({
|
|
539
|
+
entitySchema: targetConnector._entitySchemaName,
|
|
540
|
+
config: this._config,
|
|
541
|
+
partitionContextIds: this._partitionContextIds
|
|
542
|
+
});
|
|
543
|
+
if (await finalConnector.bootstrap(loggingComponentType)) {
|
|
544
|
+
await targetConnector.stop();
|
|
545
|
+
return finalConnector;
|
|
546
|
+
}
|
|
547
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "finalizeMigrationFailedBootstrap", undefined);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Clean up the migration by tearing down the migration table.
|
|
551
|
+
* @param targetConnector The connector pointing to the migration table.
|
|
552
|
+
* @param options The optional migration options.
|
|
553
|
+
* @param loggingComponentType The node logging component type.
|
|
554
|
+
*/
|
|
555
|
+
async cleanupMigration(targetConnector, options, loggingComponentType) {
|
|
556
|
+
// If something failed the only thing to cleanup is the migration table
|
|
557
|
+
await targetConnector?.teardown?.(loggingComponentType);
|
|
558
|
+
}
|
|
324
559
|
/**
|
|
325
560
|
* Find all the entities which match the conditions.
|
|
326
561
|
* @param conditions The conditions to match for the entities.
|
|
@@ -346,25 +581,13 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
346
581
|
}
|
|
347
582
|
orderByClause = `ORDER BY ${orderClauses.join(", ")}`;
|
|
348
583
|
}
|
|
349
|
-
const whereClauses =
|
|
350
|
-
const values = [];
|
|
351
|
-
const finalConditions = {
|
|
352
|
-
conditions: [],
|
|
353
|
-
logicalOperator: LogicalOperator.And
|
|
354
|
-
};
|
|
355
|
-
finalConditions.conditions.push({
|
|
356
|
-
property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
|
|
357
|
-
comparison: ComparisonOperator.Equals,
|
|
358
|
-
value: partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
|
|
359
|
-
});
|
|
360
|
-
if (!Is.empty(conditions)) {
|
|
361
|
-
finalConditions.conditions.push(conditions);
|
|
362
|
-
}
|
|
363
|
-
this.buildQueryParameters("", finalConditions, whereClauses, values, 1);
|
|
584
|
+
const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
|
|
364
585
|
const startIndex = Coerce.number(cursor) ?? 0;
|
|
365
586
|
sql = `SELECT ${properties ? properties.map(p => `"${String(p)}"`).join(", ") : "*"} FROM "${this._config.tableName}"`;
|
|
366
|
-
|
|
367
|
-
|
|
587
|
+
if (whereClauses.length > 0) {
|
|
588
|
+
sql += ` WHERE ${whereClauses.join(" AND ")}`;
|
|
589
|
+
}
|
|
590
|
+
sql += ` ${orderByClause} LIMIT ${returnSize + 1} OFFSET ${startIndex}`;
|
|
368
591
|
const dbConnection = await this.createConnection();
|
|
369
592
|
const rows = await dbConnection.unsafe(sql, values);
|
|
370
593
|
if (this._entitySchema.properties) {
|
|
@@ -393,16 +616,17 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
393
616
|
}
|
|
394
617
|
}
|
|
395
618
|
}
|
|
396
|
-
const
|
|
619
|
+
const hasMore = Is.array(rows) && rows.length > returnSize;
|
|
620
|
+
const resultRows = hasMore ? rows.slice(0, returnSize) : rows;
|
|
621
|
+
const entities = resultRows;
|
|
397
622
|
for (let i = 0; i < entities.length; i++) {
|
|
398
|
-
|
|
399
|
-
|
|
623
|
+
entities[i] = EntityStorageHelper.unPrepareEntity(entities[i], [
|
|
624
|
+
PostgreSqlEntityStorageConnector._PARTITION_KEY
|
|
625
|
+
]);
|
|
400
626
|
}
|
|
401
627
|
return {
|
|
402
628
|
entities,
|
|
403
|
-
cursor:
|
|
404
|
-
? Coerce.string(startIndex + returnSize)
|
|
405
|
-
: undefined
|
|
629
|
+
cursor: hasMore ? Coerce.string(startIndex + returnSize) : undefined
|
|
406
630
|
};
|
|
407
631
|
}
|
|
408
632
|
catch (err) {
|
|
@@ -410,21 +634,26 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
410
634
|
}
|
|
411
635
|
}
|
|
412
636
|
/**
|
|
413
|
-
*
|
|
414
|
-
* @
|
|
637
|
+
* Count all the entities which match the conditions.
|
|
638
|
+
* @param conditions The optional conditions to match for the entities.
|
|
639
|
+
* @returns The total count of entities in the storage.
|
|
415
640
|
*/
|
|
416
|
-
async
|
|
641
|
+
async count(conditions) {
|
|
642
|
+
let queryStr;
|
|
417
643
|
try {
|
|
418
|
-
const tableExists = await this.tableExists();
|
|
419
|
-
if (!tableExists) {
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
644
|
const dbConnection = await this.createConnection();
|
|
423
|
-
await
|
|
424
|
-
|
|
645
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
646
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
647
|
+
const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
|
|
648
|
+
queryStr = `SELECT COUNT(*) AS count FROM "${this._config.tableName}"`;
|
|
649
|
+
if (whereClauses.length > 0) {
|
|
650
|
+
queryStr += ` WHERE ${whereClauses.join(" AND ")}`;
|
|
651
|
+
}
|
|
652
|
+
const result = await dbConnection.unsafe(queryStr, values);
|
|
653
|
+
return Number(result[0].count);
|
|
425
654
|
}
|
|
426
|
-
catch {
|
|
427
|
-
|
|
655
|
+
catch (err) {
|
|
656
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "countFailed", { sql: queryStr }, err);
|
|
428
657
|
}
|
|
429
658
|
}
|
|
430
659
|
/**
|
|
@@ -464,9 +693,8 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
464
693
|
async tableExists() {
|
|
465
694
|
try {
|
|
466
695
|
const dbConnection = await this.createConnection();
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
return tableExistsResult[0].to_regclass !== null;
|
|
696
|
+
const res = await dbConnection.unsafe("SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 LIMIT 1", [this._config.tableName]);
|
|
697
|
+
return res.length > 0;
|
|
470
698
|
}
|
|
471
699
|
catch {
|
|
472
700
|
return false;
|
|
@@ -524,6 +752,31 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
524
752
|
password: this._config.password
|
|
525
753
|
};
|
|
526
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* Build where clause arrays for a query, combining partition key and optional conditions.
|
|
757
|
+
* @param conditions The optional entity conditions to include.
|
|
758
|
+
* @param partitionKey The partition key value.
|
|
759
|
+
* @returns The where clauses and bound values.
|
|
760
|
+
* @internal
|
|
761
|
+
*/
|
|
762
|
+
buildWhereClause(conditions, partitionKey) {
|
|
763
|
+
const whereClauses = [];
|
|
764
|
+
const values = [];
|
|
765
|
+
const finalConditions = {
|
|
766
|
+
conditions: [],
|
|
767
|
+
logicalOperator: LogicalOperator.And
|
|
768
|
+
};
|
|
769
|
+
finalConditions.conditions.push({
|
|
770
|
+
property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
|
|
771
|
+
comparison: ComparisonOperator.Equals,
|
|
772
|
+
value: partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
|
|
773
|
+
});
|
|
774
|
+
if (!Is.empty(conditions)) {
|
|
775
|
+
finalConditions.conditions.push(conditions);
|
|
776
|
+
}
|
|
777
|
+
this.buildQueryParameters("", finalConditions, whereClauses, values, 1);
|
|
778
|
+
return { whereClauses, values };
|
|
779
|
+
}
|
|
527
780
|
/**
|
|
528
781
|
* Create an SQL condition clause.
|
|
529
782
|
* @param objectPath The path for the nested object.
|
|
@@ -579,44 +832,131 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
579
832
|
prop += comparator.property;
|
|
580
833
|
if (comparator.comparison === ComparisonOperator.In) {
|
|
581
834
|
const inValues = Is.array(comparator.value) ? comparator.value : [comparator.value];
|
|
835
|
+
if (inValues.length === 0) {
|
|
836
|
+
// PostgreSQL rejects `IN ()` as a syntax error — short-circuit to a condition
|
|
837
|
+
// that is always false so the query returns zero rows cleanly (#141).
|
|
838
|
+
return "1 = 0";
|
|
839
|
+
}
|
|
582
840
|
values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
|
|
583
|
-
const placeholders = inValues.map((
|
|
841
|
+
const placeholders = inValues.map((value, index) => `$${valueIndex + index}`).join(", ");
|
|
584
842
|
return `"${prop}" IN (${placeholders})`;
|
|
585
843
|
}
|
|
844
|
+
// null/undefined must use IS NULL / IS NOT NULL — never a parameterised placeholder.
|
|
845
|
+
// Passing undefined through propertyToDbValue() coerces it to NaN for number fields
|
|
846
|
+
// (Number(undefined) === NaN), and null coerces to 0 (Number(null) === 0), both of
|
|
847
|
+
// which produce semantically wrong or invalid SQL.
|
|
848
|
+
if (comparator.value === null || comparator.value === undefined) {
|
|
849
|
+
if (comparator.comparison === ComparisonOperator.Equals ||
|
|
850
|
+
comparator.comparison === ComparisonOperator.NotEquals) {
|
|
851
|
+
const nullCheck = comparator.comparison === ComparisonOperator.Equals ? "IS NULL" : "IS NOT NULL";
|
|
852
|
+
if (comparator.property.split(".").length > 1) {
|
|
853
|
+
const rootProp = comparator.property.split(".")[0];
|
|
854
|
+
const nestedParts = comparator.property.split(".").slice(1);
|
|
855
|
+
const jsonPath = nestedParts
|
|
856
|
+
.map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
|
|
857
|
+
.join("");
|
|
858
|
+
const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
|
|
859
|
+
return `${jsonTextExpr} ${nullCheck}`;
|
|
860
|
+
}
|
|
861
|
+
return `"${prop}" ${nullCheck}`;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
586
864
|
const dbValue = this.propertyToDbValue(comparator.value, type);
|
|
587
865
|
values.push(dbValue);
|
|
588
866
|
if (comparator.property.split(".").length > 1) {
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
867
|
+
const rootProp = comparator.property.split(".")[0];
|
|
868
|
+
const nestedParts = comparator.property.split(".").slice(1);
|
|
869
|
+
const rootSchema = this._entitySchema.properties?.find(p => p.property === rootProp);
|
|
870
|
+
const isArray = rootSchema?.type === EntitySchemaPropertyType.Array;
|
|
871
|
+
const jsonPath = nestedParts
|
|
592
872
|
.map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
|
|
593
873
|
.join("");
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
874
|
+
const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
|
|
875
|
+
switch (comparator.comparison) {
|
|
876
|
+
case ComparisonOperator.Includes: {
|
|
877
|
+
values.pop();
|
|
878
|
+
values.push(`%${String(comparator.value).toLowerCase()}%`);
|
|
879
|
+
if (isArray) {
|
|
880
|
+
const elemPath = nestedParts
|
|
881
|
+
.map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
|
|
882
|
+
.join("");
|
|
883
|
+
return `EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE LOWER(elem${elemPath}) ILIKE $${valueIndex})`;
|
|
884
|
+
}
|
|
885
|
+
return `LOWER(${jsonTextExpr}) ILIKE $${valueIndex}`;
|
|
886
|
+
}
|
|
887
|
+
case ComparisonOperator.NotIncludes: {
|
|
888
|
+
values.pop();
|
|
889
|
+
values.push(`%${String(comparator.value).toLowerCase()}%`);
|
|
890
|
+
if (isArray) {
|
|
891
|
+
const elemPath = nestedParts
|
|
892
|
+
.map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
|
|
893
|
+
.join("");
|
|
894
|
+
return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE LOWER(elem${elemPath}) ILIKE $${valueIndex})`;
|
|
895
|
+
}
|
|
896
|
+
return `LOWER(${jsonTextExpr}) NOT ILIKE $${valueIndex}`;
|
|
897
|
+
}
|
|
898
|
+
case ComparisonOperator.NotEquals:
|
|
899
|
+
return `${jsonTextExpr} <> $${valueIndex}`;
|
|
900
|
+
case ComparisonOperator.GreaterThan:
|
|
901
|
+
return `${jsonTextExpr} > $${valueIndex}`;
|
|
902
|
+
case ComparisonOperator.LessThan:
|
|
903
|
+
return `${jsonTextExpr} < $${valueIndex}`;
|
|
904
|
+
case ComparisonOperator.GreaterThanOrEqual:
|
|
905
|
+
return `${jsonTextExpr} >= $${valueIndex}`;
|
|
906
|
+
case ComparisonOperator.LessThanOrEqual:
|
|
907
|
+
return `${jsonTextExpr} <= $${valueIndex}`;
|
|
908
|
+
default:
|
|
909
|
+
return `${jsonTextExpr} = $${valueIndex}`;
|
|
910
|
+
}
|
|
613
911
|
}
|
|
614
|
-
|
|
615
|
-
|
|
912
|
+
switch (comparator.comparison) {
|
|
913
|
+
case ComparisonOperator.Equals:
|
|
914
|
+
if (Is.object(comparator.value) || Is.array(comparator.value)) {
|
|
915
|
+
return `"${prop}" = $${valueIndex}::jsonb`;
|
|
916
|
+
}
|
|
917
|
+
return `"${prop}" = $${valueIndex}`;
|
|
918
|
+
case ComparisonOperator.NotEquals:
|
|
919
|
+
if (Is.object(comparator.value) || Is.array(comparator.value)) {
|
|
920
|
+
return `"${prop}" != $${valueIndex}::jsonb`;
|
|
921
|
+
}
|
|
922
|
+
return `"${prop}" <> $${valueIndex}`;
|
|
923
|
+
case ComparisonOperator.GreaterThan:
|
|
924
|
+
return `"${prop}" > $${valueIndex}`;
|
|
925
|
+
case ComparisonOperator.LessThan:
|
|
926
|
+
return `"${prop}" < $${valueIndex}`;
|
|
927
|
+
case ComparisonOperator.GreaterThanOrEqual:
|
|
928
|
+
return `"${prop}" >= $${valueIndex}`;
|
|
929
|
+
case ComparisonOperator.LessThanOrEqual:
|
|
930
|
+
return `"${prop}" <= $${valueIndex}`;
|
|
931
|
+
case ComparisonOperator.Includes: {
|
|
932
|
+
if (type === EntitySchemaPropertyType.String) {
|
|
933
|
+
return `"${prop}" ILIKE '%' || $${valueIndex} || '%'`;
|
|
934
|
+
}
|
|
935
|
+
if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
|
|
936
|
+
return `EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
|
|
937
|
+
}
|
|
938
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
|
|
939
|
+
comparison: comparator.comparison,
|
|
940
|
+
type
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
case ComparisonOperator.NotIncludes: {
|
|
944
|
+
if (type === EntitySchemaPropertyType.String) {
|
|
945
|
+
return `"${prop}" NOT ILIKE '%' || $${valueIndex} || '%'`;
|
|
946
|
+
}
|
|
947
|
+
if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
|
|
948
|
+
return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
|
|
949
|
+
}
|
|
950
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
|
|
951
|
+
comparison: comparator.comparison,
|
|
952
|
+
type
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
default:
|
|
956
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
|
|
957
|
+
comparison: comparator.comparison
|
|
958
|
+
});
|
|
616
959
|
}
|
|
617
|
-
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
|
|
618
|
-
comparison: comparator.comparison
|
|
619
|
-
});
|
|
620
960
|
}
|
|
621
961
|
/**
|
|
622
962
|
* Format a value to insert into DB.
|
|
@@ -626,21 +966,19 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
626
966
|
* @internal
|
|
627
967
|
*/
|
|
628
968
|
propertyToDbValue(value, type) {
|
|
629
|
-
if (type ===
|
|
969
|
+
if (type === EntitySchemaPropertyType.String) {
|
|
630
970
|
return String(value);
|
|
631
971
|
}
|
|
632
|
-
else if (type ===
|
|
972
|
+
else if (type === EntitySchemaPropertyType.Number) {
|
|
633
973
|
return Number(value);
|
|
634
974
|
}
|
|
635
|
-
else if (type ===
|
|
975
|
+
else if (type === EntitySchemaPropertyType.Boolean) {
|
|
636
976
|
return Boolean(value);
|
|
637
977
|
}
|
|
638
|
-
else if (type ===
|
|
978
|
+
else if (type === EntitySchemaPropertyType.Object ||
|
|
979
|
+
type === EntitySchemaPropertyType.Array) {
|
|
639
980
|
return value;
|
|
640
981
|
}
|
|
641
|
-
if (Is.object(value)) {
|
|
642
|
-
return JSON.stringify(value);
|
|
643
|
-
}
|
|
644
982
|
return value;
|
|
645
983
|
}
|
|
646
984
|
/**
|
|
@@ -697,7 +1035,48 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
697
1035
|
});
|
|
698
1036
|
const columnDefinitions = props
|
|
699
1037
|
.map(prop => {
|
|
700
|
-
|
|
1038
|
+
let sqlType = sqlTypeMap[prop.type] || "TEXT";
|
|
1039
|
+
if (prop.format) {
|
|
1040
|
+
switch (prop.type) {
|
|
1041
|
+
case EntitySchemaPropertyType.String:
|
|
1042
|
+
switch (prop.format) {
|
|
1043
|
+
case "uuid":
|
|
1044
|
+
sqlType = "UUID";
|
|
1045
|
+
break;
|
|
1046
|
+
}
|
|
1047
|
+
break;
|
|
1048
|
+
case EntitySchemaPropertyType.Number:
|
|
1049
|
+
switch (prop.format) {
|
|
1050
|
+
case "float":
|
|
1051
|
+
sqlType = "REAL";
|
|
1052
|
+
break;
|
|
1053
|
+
case "double":
|
|
1054
|
+
sqlType = "DOUBLE PRECISION";
|
|
1055
|
+
break;
|
|
1056
|
+
}
|
|
1057
|
+
break;
|
|
1058
|
+
case EntitySchemaPropertyType.Integer:
|
|
1059
|
+
switch (prop.format) {
|
|
1060
|
+
case "int8":
|
|
1061
|
+
case "uint8":
|
|
1062
|
+
sqlType = "SMALLINT";
|
|
1063
|
+
break;
|
|
1064
|
+
case "int16":
|
|
1065
|
+
sqlType = "SMALLINT";
|
|
1066
|
+
break;
|
|
1067
|
+
case "uint16":
|
|
1068
|
+
case "int32":
|
|
1069
|
+
sqlType = "INTEGER";
|
|
1070
|
+
break;
|
|
1071
|
+
case "uint32":
|
|
1072
|
+
case "int64":
|
|
1073
|
+
case "uint64":
|
|
1074
|
+
sqlType = "BIGINT";
|
|
1075
|
+
break;
|
|
1076
|
+
}
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
701
1080
|
const columnName = String(prop.property);
|
|
702
1081
|
const nullable = prop.optional ? " NULL" : " NOT NULL";
|
|
703
1082
|
if (prop.isPrimary) {
|