@twin.org/entity-storage-connector-postgresql 0.0.3-next.3 → 0.0.3-next.31
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/models/IPostgreSqlEntityStorageConnectorConstructorOptions.js.map +1 -1
- package/dist/es/postgreSqlEntityStorageConnector.js +474 -89
- package/dist/es/postgreSqlEntityStorageConnector.js.map +1 -1
- package/dist/types/models/IPostgreSqlEntityStorageConnectorConstructorOptions.d.ts +0 -1
- package/dist/types/postgreSqlEntityStorageConnector.d.ts +67 -12
- package/docs/changelog.md +608 -48
- 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 -12
- 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, 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 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.
|
|
@@ -334,6 +569,13 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
334
569
|
async query(conditions, sortProperties, properties, cursor, limit) {
|
|
335
570
|
const contextIds = await ContextIdStore.getContextIds();
|
|
336
571
|
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
572
|
+
EntityStorageHelper.validateSortProperties(this._entitySchema, sortProperties);
|
|
573
|
+
EntityStorageHelper.validateProperties(this._entitySchema, properties);
|
|
574
|
+
if (!Is.empty(limit)) {
|
|
575
|
+
const validationFailures = [];
|
|
576
|
+
Validation.integer("limit", limit, validationFailures, undefined, { minValue: 1 });
|
|
577
|
+
Validation.asValidationError(PostgreSqlEntityStorageConnector.CLASS_NAME, "query", validationFailures);
|
|
578
|
+
}
|
|
337
579
|
let sql = "";
|
|
338
580
|
try {
|
|
339
581
|
const returnSize = limit ?? PostgreSqlEntityStorageConnector._DEFAULT_LIMIT;
|
|
@@ -346,25 +588,13 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
346
588
|
}
|
|
347
589
|
orderByClause = `ORDER BY ${orderClauses.join(", ")}`;
|
|
348
590
|
}
|
|
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);
|
|
591
|
+
const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
|
|
364
592
|
const startIndex = Coerce.number(cursor) ?? 0;
|
|
365
593
|
sql = `SELECT ${properties ? properties.map(p => `"${String(p)}"`).join(", ") : "*"} FROM "${this._config.tableName}"`;
|
|
366
|
-
|
|
367
|
-
|
|
594
|
+
if (whereClauses.length > 0) {
|
|
595
|
+
sql += ` WHERE ${whereClauses.join(" AND ")}`;
|
|
596
|
+
}
|
|
597
|
+
sql += ` ${orderByClause} LIMIT ${returnSize + 1} OFFSET ${startIndex}`;
|
|
368
598
|
const dbConnection = await this.createConnection();
|
|
369
599
|
const rows = await dbConnection.unsafe(sql, values);
|
|
370
600
|
if (this._entitySchema.properties) {
|
|
@@ -393,16 +623,17 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
393
623
|
}
|
|
394
624
|
}
|
|
395
625
|
}
|
|
396
|
-
const
|
|
626
|
+
const hasMore = Is.array(rows) && rows.length > returnSize;
|
|
627
|
+
const resultRows = hasMore ? rows.slice(0, returnSize) : rows;
|
|
628
|
+
const entities = resultRows;
|
|
397
629
|
for (let i = 0; i < entities.length; i++) {
|
|
398
|
-
|
|
399
|
-
|
|
630
|
+
entities[i] = EntityStorageHelper.unPrepareEntity(entities[i], [
|
|
631
|
+
PostgreSqlEntityStorageConnector._PARTITION_KEY
|
|
632
|
+
]);
|
|
400
633
|
}
|
|
401
634
|
return {
|
|
402
635
|
entities,
|
|
403
|
-
cursor:
|
|
404
|
-
? Coerce.string(startIndex + returnSize)
|
|
405
|
-
: undefined
|
|
636
|
+
cursor: hasMore ? Coerce.string(startIndex + returnSize) : undefined
|
|
406
637
|
};
|
|
407
638
|
}
|
|
408
639
|
catch (err) {
|
|
@@ -410,21 +641,26 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
410
641
|
}
|
|
411
642
|
}
|
|
412
643
|
/**
|
|
413
|
-
*
|
|
414
|
-
* @
|
|
644
|
+
* Count all the entities which match the conditions.
|
|
645
|
+
* @param conditions The optional conditions to match for the entities.
|
|
646
|
+
* @returns The total count of entities in the storage.
|
|
415
647
|
*/
|
|
416
|
-
async
|
|
648
|
+
async count(conditions) {
|
|
649
|
+
let queryStr;
|
|
417
650
|
try {
|
|
418
|
-
const tableExists = await this.tableExists();
|
|
419
|
-
if (!tableExists) {
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
651
|
const dbConnection = await this.createConnection();
|
|
423
|
-
await
|
|
424
|
-
|
|
652
|
+
const contextIds = await ContextIdStore.getContextIds();
|
|
653
|
+
const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
|
|
654
|
+
const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
|
|
655
|
+
queryStr = `SELECT COUNT(*) AS count FROM "${this._config.tableName}"`;
|
|
656
|
+
if (whereClauses.length > 0) {
|
|
657
|
+
queryStr += ` WHERE ${whereClauses.join(" AND ")}`;
|
|
658
|
+
}
|
|
659
|
+
const result = await dbConnection.unsafe(queryStr, values);
|
|
660
|
+
return Number(result[0].count);
|
|
425
661
|
}
|
|
426
|
-
catch {
|
|
427
|
-
|
|
662
|
+
catch (err) {
|
|
663
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "countFailed", { sql: queryStr }, err);
|
|
428
664
|
}
|
|
429
665
|
}
|
|
430
666
|
/**
|
|
@@ -464,9 +700,8 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
464
700
|
async tableExists() {
|
|
465
701
|
try {
|
|
466
702
|
const dbConnection = await this.createConnection();
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
return tableExistsResult[0].to_regclass !== null;
|
|
703
|
+
const res = await dbConnection.unsafe("SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 LIMIT 1", [this._config.tableName]);
|
|
704
|
+
return res.length > 0;
|
|
470
705
|
}
|
|
471
706
|
catch {
|
|
472
707
|
return false;
|
|
@@ -524,6 +759,31 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
524
759
|
password: this._config.password
|
|
525
760
|
};
|
|
526
761
|
}
|
|
762
|
+
/**
|
|
763
|
+
* Build where clause arrays for a query, combining partition key and optional conditions.
|
|
764
|
+
* @param conditions The optional entity conditions to include.
|
|
765
|
+
* @param partitionKey The partition key value.
|
|
766
|
+
* @returns The where clauses and bound values.
|
|
767
|
+
* @internal
|
|
768
|
+
*/
|
|
769
|
+
buildWhereClause(conditions, partitionKey) {
|
|
770
|
+
const whereClauses = [];
|
|
771
|
+
const values = [];
|
|
772
|
+
const finalConditions = {
|
|
773
|
+
conditions: [],
|
|
774
|
+
logicalOperator: LogicalOperator.And
|
|
775
|
+
};
|
|
776
|
+
finalConditions.conditions.push({
|
|
777
|
+
property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
|
|
778
|
+
comparison: ComparisonOperator.Equals,
|
|
779
|
+
value: partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
|
|
780
|
+
});
|
|
781
|
+
if (!Is.empty(conditions)) {
|
|
782
|
+
finalConditions.conditions.push(conditions);
|
|
783
|
+
}
|
|
784
|
+
this.buildQueryParameters("", finalConditions, whereClauses, values, 1);
|
|
785
|
+
return { whereClauses, values };
|
|
786
|
+
}
|
|
527
787
|
/**
|
|
528
788
|
* Create an SQL condition clause.
|
|
529
789
|
* @param objectPath The path for the nested object.
|
|
@@ -579,50 +839,131 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
579
839
|
prop += comparator.property;
|
|
580
840
|
if (comparator.comparison === ComparisonOperator.In) {
|
|
581
841
|
const inValues = Is.array(comparator.value) ? comparator.value : [comparator.value];
|
|
842
|
+
if (inValues.length === 0) {
|
|
843
|
+
// PostgreSQL rejects `IN ()` as a syntax error — short-circuit to a condition
|
|
844
|
+
// that is always false so the query returns zero rows cleanly (#141).
|
|
845
|
+
return "1 = 0";
|
|
846
|
+
}
|
|
582
847
|
values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
|
|
583
|
-
const placeholders = inValues.map((
|
|
848
|
+
const placeholders = inValues.map((value, index) => `$${valueIndex + index}`).join(", ");
|
|
584
849
|
return `"${prop}" IN (${placeholders})`;
|
|
585
850
|
}
|
|
851
|
+
// null/undefined must use IS NULL / IS NOT NULL — never a parameterised placeholder.
|
|
852
|
+
// Passing undefined through propertyToDbValue() coerces it to NaN for number fields
|
|
853
|
+
// (Number(undefined) === NaN), and null coerces to 0 (Number(null) === 0), both of
|
|
854
|
+
// which produce semantically wrong or invalid SQL.
|
|
855
|
+
if (comparator.value === null || comparator.value === undefined) {
|
|
856
|
+
if (comparator.comparison === ComparisonOperator.Equals ||
|
|
857
|
+
comparator.comparison === ComparisonOperator.NotEquals) {
|
|
858
|
+
const nullCheck = comparator.comparison === ComparisonOperator.Equals ? "IS NULL" : "IS NOT NULL";
|
|
859
|
+
if (comparator.property.split(".").length > 1) {
|
|
860
|
+
const rootProp = comparator.property.split(".")[0];
|
|
861
|
+
const nestedParts = comparator.property.split(".").slice(1);
|
|
862
|
+
const jsonPath = nestedParts
|
|
863
|
+
.map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
|
|
864
|
+
.join("");
|
|
865
|
+
const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
|
|
866
|
+
return `${jsonTextExpr} ${nullCheck}`;
|
|
867
|
+
}
|
|
868
|
+
return `"${prop}" ${nullCheck}`;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
586
871
|
const dbValue = this.propertyToDbValue(comparator.value, type);
|
|
587
872
|
values.push(dbValue);
|
|
588
873
|
if (comparator.property.split(".").length > 1) {
|
|
589
|
-
const
|
|
590
|
-
|
|
591
|
-
|
|
874
|
+
const rootProp = comparator.property.split(".")[0];
|
|
875
|
+
const nestedParts = comparator.property.split(".").slice(1);
|
|
876
|
+
const rootSchema = this._entitySchema.properties?.find(p => p.property === rootProp);
|
|
877
|
+
const isArray = rootSchema?.type === EntitySchemaPropertyType.Array;
|
|
878
|
+
const jsonPath = nestedParts
|
|
592
879
|
.map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
|
|
593
880
|
.join("");
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
881
|
+
const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
|
|
882
|
+
switch (comparator.comparison) {
|
|
883
|
+
case ComparisonOperator.Includes: {
|
|
884
|
+
values.pop();
|
|
885
|
+
values.push(`%${String(comparator.value).toLowerCase()}%`);
|
|
886
|
+
if (isArray) {
|
|
887
|
+
const elemPath = nestedParts
|
|
888
|
+
.map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
|
|
889
|
+
.join("");
|
|
890
|
+
return `EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE LOWER(elem${elemPath}) ILIKE $${valueIndex})`;
|
|
891
|
+
}
|
|
892
|
+
return `LOWER(${jsonTextExpr}) ILIKE $${valueIndex}`;
|
|
893
|
+
}
|
|
894
|
+
case ComparisonOperator.NotIncludes: {
|
|
895
|
+
values.pop();
|
|
896
|
+
values.push(`%${String(comparator.value).toLowerCase()}%`);
|
|
897
|
+
if (isArray) {
|
|
898
|
+
const elemPath = nestedParts
|
|
899
|
+
.map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
|
|
900
|
+
.join("");
|
|
901
|
+
return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE LOWER(elem${elemPath}) ILIKE $${valueIndex})`;
|
|
902
|
+
}
|
|
903
|
+
return `LOWER(${jsonTextExpr}) NOT ILIKE $${valueIndex}`;
|
|
904
|
+
}
|
|
905
|
+
case ComparisonOperator.NotEquals:
|
|
906
|
+
return `${jsonTextExpr} <> $${valueIndex}`;
|
|
907
|
+
case ComparisonOperator.GreaterThan:
|
|
908
|
+
return `${jsonTextExpr} > $${valueIndex}`;
|
|
909
|
+
case ComparisonOperator.LessThan:
|
|
910
|
+
return `${jsonTextExpr} < $${valueIndex}`;
|
|
911
|
+
case ComparisonOperator.GreaterThanOrEqual:
|
|
912
|
+
return `${jsonTextExpr} >= $${valueIndex}`;
|
|
913
|
+
case ComparisonOperator.LessThanOrEqual:
|
|
914
|
+
return `${jsonTextExpr} <= $${valueIndex}`;
|
|
915
|
+
default:
|
|
916
|
+
return `${jsonTextExpr} = $${valueIndex}`;
|
|
599
917
|
}
|
|
600
|
-
return `"${prop}" = $${valueIndex}`;
|
|
601
918
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
919
|
+
switch (comparator.comparison) {
|
|
920
|
+
case ComparisonOperator.Equals:
|
|
921
|
+
if (Is.object(comparator.value) || Is.array(comparator.value)) {
|
|
922
|
+
return `"${prop}" = $${valueIndex}::jsonb`;
|
|
923
|
+
}
|
|
924
|
+
return `"${prop}" = $${valueIndex}`;
|
|
925
|
+
case ComparisonOperator.NotEquals:
|
|
926
|
+
if (Is.object(comparator.value) || Is.array(comparator.value)) {
|
|
927
|
+
return `"${prop}" != $${valueIndex}::jsonb`;
|
|
928
|
+
}
|
|
929
|
+
return `"${prop}" <> $${valueIndex}`;
|
|
930
|
+
case ComparisonOperator.GreaterThan:
|
|
931
|
+
return `"${prop}" > $${valueIndex}`;
|
|
932
|
+
case ComparisonOperator.LessThan:
|
|
933
|
+
return `"${prop}" < $${valueIndex}`;
|
|
934
|
+
case ComparisonOperator.GreaterThanOrEqual:
|
|
935
|
+
return `"${prop}" >= $${valueIndex}`;
|
|
936
|
+
case ComparisonOperator.LessThanOrEqual:
|
|
937
|
+
return `"${prop}" <= $${valueIndex}`;
|
|
938
|
+
case ComparisonOperator.Includes: {
|
|
939
|
+
if (type === EntitySchemaPropertyType.String) {
|
|
940
|
+
return `"${prop}" ILIKE '%' || $${valueIndex} || '%'`;
|
|
941
|
+
}
|
|
942
|
+
if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
|
|
943
|
+
return `EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
|
|
944
|
+
}
|
|
945
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
|
|
946
|
+
comparison: comparator.comparison,
|
|
947
|
+
type
|
|
948
|
+
});
|
|
605
949
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
950
|
+
case ComparisonOperator.NotIncludes: {
|
|
951
|
+
if (type === EntitySchemaPropertyType.String) {
|
|
952
|
+
return `"${prop}" NOT ILIKE '%' || $${valueIndex} || '%'`;
|
|
953
|
+
}
|
|
954
|
+
if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
|
|
955
|
+
return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
|
|
956
|
+
}
|
|
957
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
|
|
958
|
+
comparison: comparator.comparison,
|
|
959
|
+
type
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
default:
|
|
963
|
+
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
|
|
964
|
+
comparison: comparator.comparison
|
|
965
|
+
});
|
|
622
966
|
}
|
|
623
|
-
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
|
|
624
|
-
comparison: comparator.comparison
|
|
625
|
-
});
|
|
626
967
|
}
|
|
627
968
|
/**
|
|
628
969
|
* Format a value to insert into DB.
|
|
@@ -668,6 +1009,8 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
668
1009
|
/**
|
|
669
1010
|
* Verify the conditions for the entity.
|
|
670
1011
|
* @param conditions The conditions to verify.
|
|
1012
|
+
* @param obj The object to verify the conditions against.
|
|
1013
|
+
* @returns True if all conditions are met, false otherwise.
|
|
671
1014
|
* @internal
|
|
672
1015
|
*/
|
|
673
1016
|
verifyConditions(conditions, obj) {
|
|
@@ -678,6 +1021,7 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
678
1021
|
* @param entitySchema The schema of the entity.
|
|
679
1022
|
* @returns The SQL properties as a string.
|
|
680
1023
|
* @throws GeneralError if the entity properties do not exist.
|
|
1024
|
+
* @internal
|
|
681
1025
|
*/
|
|
682
1026
|
mapPostgreSqlProperties(entitySchema) {
|
|
683
1027
|
const sqlTypeMap = {
|
|
@@ -701,7 +1045,48 @@ export class PostgreSqlEntityStorageConnector {
|
|
|
701
1045
|
});
|
|
702
1046
|
const columnDefinitions = props
|
|
703
1047
|
.map(prop => {
|
|
704
|
-
|
|
1048
|
+
let sqlType = sqlTypeMap[prop.type] || "TEXT";
|
|
1049
|
+
if (prop.format) {
|
|
1050
|
+
switch (prop.type) {
|
|
1051
|
+
case EntitySchemaPropertyType.String:
|
|
1052
|
+
switch (prop.format) {
|
|
1053
|
+
case "uuid":
|
|
1054
|
+
sqlType = "UUID";
|
|
1055
|
+
break;
|
|
1056
|
+
}
|
|
1057
|
+
break;
|
|
1058
|
+
case EntitySchemaPropertyType.Number:
|
|
1059
|
+
switch (prop.format) {
|
|
1060
|
+
case "float":
|
|
1061
|
+
sqlType = "REAL";
|
|
1062
|
+
break;
|
|
1063
|
+
case "double":
|
|
1064
|
+
sqlType = "DOUBLE PRECISION";
|
|
1065
|
+
break;
|
|
1066
|
+
}
|
|
1067
|
+
break;
|
|
1068
|
+
case EntitySchemaPropertyType.Integer:
|
|
1069
|
+
switch (prop.format) {
|
|
1070
|
+
case "int8":
|
|
1071
|
+
case "uint8":
|
|
1072
|
+
sqlType = "SMALLINT";
|
|
1073
|
+
break;
|
|
1074
|
+
case "int16":
|
|
1075
|
+
sqlType = "SMALLINT";
|
|
1076
|
+
break;
|
|
1077
|
+
case "uint16":
|
|
1078
|
+
case "int32":
|
|
1079
|
+
sqlType = "INTEGER";
|
|
1080
|
+
break;
|
|
1081
|
+
case "uint32":
|
|
1082
|
+
case "int64":
|
|
1083
|
+
case "uint64":
|
|
1084
|
+
sqlType = "BIGINT";
|
|
1085
|
+
break;
|
|
1086
|
+
}
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
705
1090
|
const columnName = String(prop.property);
|
|
706
1091
|
const nullable = prop.optional ? " NULL" : " NOT NULL";
|
|
707
1092
|
if (prop.isPrimary) {
|