@twin.org/entity-storage-connector-postgresql 0.0.2-next.9 → 0.0.3-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,969 @@
1
+ // Copyright 2024 IOTA Stiftung.
2
+ // SPDX-License-Identifier: Apache-2.0.
3
+ import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
4
+ import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, HealthStatus, Is, ObjectHelper } from "@twin.org/core";
5
+ import { ComparisonOperator, EntitySchemaFactory, EntitySchemaHelper, EntitySchemaPropertyType, LogicalOperator, SortDirection } from "@twin.org/entity";
6
+ import postgres from "postgres";
7
+ /**
8
+ * Class for performing entity storage operations using ql.
9
+ */
10
+ export class PostgreSqlEntityStorageConnector {
11
+ /**
12
+ * Runtime name for the class.
13
+ */
14
+ static CLASS_NAME = "PostgreSqlEntityStorageConnector";
15
+ /**
16
+ * Limit the number of entities when finding.
17
+ * @internal
18
+ */
19
+ static _DEFAULT_LIMIT = 40;
20
+ /**
21
+ * Partition id field name.
22
+ * @internal
23
+ */
24
+ static _PARTITION_KEY = "partitionId";
25
+ /**
26
+ * Partition id field value.
27
+ * @internal
28
+ */
29
+ static _PARTITION_KEY_VALUE = "root";
30
+ /**
31
+ * The schema for the entity.
32
+ * @internal
33
+ */
34
+ _entitySchema;
35
+ /**
36
+ * The keys to use from the context ids to create partitions.
37
+ * @internal
38
+ */
39
+ _partitionContextIds;
40
+ /**
41
+ * The primary key property.
42
+ * @internal
43
+ */
44
+ _primaryKeyProperty;
45
+ /**
46
+ * The configuration for the connector.
47
+ * @internal
48
+ */
49
+ _config;
50
+ /**
51
+ * The configuration for the connector.
52
+ * @internal
53
+ */
54
+ _connection;
55
+ /**
56
+ * Create a new instance of PostgreSqlEntityStorageConnector.
57
+ * @param options The options for the connector.
58
+ */
59
+ constructor(options) {
60
+ Guards.object(PostgreSqlEntityStorageConnector.CLASS_NAME, "options", options);
61
+ Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.entitySchema", options.entitySchema);
62
+ Guards.object(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config", options.config);
63
+ Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.host", options.config.host);
64
+ Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.user", options.config.user);
65
+ Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.password", options.config.password);
66
+ Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.database", options.config.database);
67
+ Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "options.config.tableName", options.config.tableName);
68
+ this._entitySchema = EntitySchemaFactory.get(options.entitySchema);
69
+ this._partitionContextIds = options.partitionContextIds;
70
+ this._primaryKeyProperty = EntitySchemaHelper.getPrimaryKey(this._entitySchema);
71
+ this._config = options.config;
72
+ }
73
+ /**
74
+ * Initialize the PostgreSql environment.
75
+ * @param nodeLoggingComponentType Optional type of the logging component.
76
+ * @returns A promise that resolves to a boolean indicating success.
77
+ */
78
+ async bootstrap(nodeLoggingComponentType) {
79
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
80
+ try {
81
+ const dbConnection = await this.createConnection();
82
+ const databaseExists = await this.databaseExists();
83
+ if (!databaseExists) {
84
+ await nodeLogging?.log({
85
+ level: "info",
86
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
87
+ ts: Date.now(),
88
+ message: "databaseCreating",
89
+ data: {
90
+ databaseName: this._config.database
91
+ }
92
+ });
93
+ await dbConnection.unsafe(`CREATE DATABASE "${this._config.database}";`);
94
+ await this.waitForDatabaseExists();
95
+ }
96
+ else {
97
+ await nodeLogging?.log({
98
+ level: "info",
99
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
100
+ ts: Date.now(),
101
+ message: "databaseExists",
102
+ data: {
103
+ databaseName: this._config.database
104
+ }
105
+ });
106
+ }
107
+ const tableExists = await this.tableExists();
108
+ if (!tableExists) {
109
+ await nodeLogging?.log({
110
+ level: "info",
111
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
112
+ ts: Date.now(),
113
+ message: "tableCreating",
114
+ data: {
115
+ tableName: this._config.tableName
116
+ }
117
+ });
118
+ const createTableQuery = `CREATE TABLE "${this._config.tableName}" (${this.mapPostgreSqlProperties(this._entitySchema)})`;
119
+ await dbConnection.unsafe(createTableQuery);
120
+ await this.waitForTableExists();
121
+ }
122
+ else {
123
+ await nodeLogging?.log({
124
+ level: "info",
125
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
126
+ ts: Date.now(),
127
+ message: "tableExists",
128
+ data: {
129
+ tableName: this._config.tableName
130
+ }
131
+ });
132
+ }
133
+ }
134
+ catch (error) {
135
+ await nodeLogging?.log({
136
+ level: "error",
137
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
138
+ ts: Date.now(),
139
+ message: "databaseCreateFailed",
140
+ error: BaseError.fromError(error),
141
+ data: {
142
+ databaseName: this._config.database
143
+ }
144
+ });
145
+ return false;
146
+ }
147
+ return true;
148
+ }
149
+ /**
150
+ * Returns the class name of the component.
151
+ * @returns The class name of the component.
152
+ */
153
+ className() {
154
+ return PostgreSqlEntityStorageConnector.CLASS_NAME;
155
+ }
156
+ /**
157
+ * Get the health of the component.
158
+ * @returns The health of the component.
159
+ */
160
+ async health() {
161
+ try {
162
+ const sql = await this.createConnection();
163
+ await sql `SELECT 1 FROM ${sql(this._config.tableName)} LIMIT 0`;
164
+ return [
165
+ {
166
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
167
+ status: HealthStatus.Ok,
168
+ description: "healthDescription"
169
+ }
170
+ ];
171
+ }
172
+ catch {
173
+ return [
174
+ {
175
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
176
+ status: HealthStatus.Error,
177
+ description: "healthDescription",
178
+ message: "connectionFailed"
179
+ }
180
+ ];
181
+ }
182
+ }
183
+ /**
184
+ * The component needs to be stopped when the node is closed.
185
+ * @returns Nothing.
186
+ */
187
+ async stop() {
188
+ if (this._connection) {
189
+ await this._connection.end();
190
+ this._connection = undefined;
191
+ }
192
+ }
193
+ /**
194
+ * Get the schema for the entities.
195
+ * @returns The schema for the entities.
196
+ */
197
+ getSchema() {
198
+ return this._entitySchema;
199
+ }
200
+ /**
201
+ * Get an entity from PostgreSql.
202
+ * @param id The id of the entity to get, or the index value if secondaryIndex is set.
203
+ * @param secondaryIndex Get the item using a secondary index.
204
+ * @param conditions The optional conditions to match for the entities.
205
+ * @returns The object if it can be found or undefined.
206
+ */
207
+ async get(id, secondaryIndex, conditions) {
208
+ Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "id", id);
209
+ const contextIds = await ContextIdStore.getContextIds();
210
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
211
+ try {
212
+ const dbConnection = await this.createConnection();
213
+ const whereClauses = [];
214
+ const values = [];
215
+ whereClauses.push(`"${PostgreSqlEntityStorageConnector._PARTITION_KEY}" = $1`);
216
+ values.push(partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE);
217
+ if (secondaryIndex) {
218
+ whereClauses.push(`"${String(secondaryIndex)}" = $2`);
219
+ values.push(id);
220
+ }
221
+ else {
222
+ whereClauses.push(`"${this._primaryKeyProperty.property}" = $2`);
223
+ values.push(id);
224
+ }
225
+ if (Is.arrayValue(conditions)) {
226
+ for (const condition of conditions) {
227
+ whereClauses.push(`"${String(condition.property)}" = $${values.length + 1}`);
228
+ values.push(condition.value);
229
+ }
230
+ }
231
+ const query = `SELECT * FROM "${this._config.tableName}" WHERE ${whereClauses.join(" AND ")} LIMIT 1`;
232
+ const rows = await dbConnection.unsafe(query, values);
233
+ if (Is.array(rows) && rows.length === 1) {
234
+ if (this._entitySchema.properties) {
235
+ for (const prop of this._entitySchema.properties) {
236
+ const row = rows[0];
237
+ let propColumn = prop.property;
238
+ propColumn = propColumn.toLowerCase();
239
+ if ((prop.type === EntitySchemaPropertyType.Object ||
240
+ prop.type === EntitySchemaPropertyType.Array) &&
241
+ typeof row[propColumn] === "string") {
242
+ let value;
243
+ try {
244
+ value = JSON.parse(rows[0][propColumn]);
245
+ }
246
+ catch {
247
+ // If JSON.parse fails, keep the value as string
248
+ // This handles cases where plain text was stored in Object/Array fields
249
+ value = rows[0][propColumn];
250
+ }
251
+ delete rows[0][propColumn];
252
+ rows[0][prop.property] = value;
253
+ }
254
+ if (row[propColumn] === null) {
255
+ rows[0][prop.property] = undefined;
256
+ }
257
+ }
258
+ }
259
+ const entity = ObjectHelper.removeEmptyProperties(rows[0], { removeNull: true });
260
+ ObjectHelper.propertyDelete(entity, PostgreSqlEntityStorageConnector._PARTITION_KEY);
261
+ return entity;
262
+ }
263
+ }
264
+ catch (err) {
265
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "getFailed", {
266
+ id
267
+ }, err);
268
+ }
269
+ return undefined;
270
+ }
271
+ /**
272
+ * Set an entity.
273
+ * @param entity The entity to set.
274
+ * @param conditions The optional conditions to match for the entities.
275
+ * @returns The id of the entity.
276
+ */
277
+ async set(entity, conditions) {
278
+ Guards.object(PostgreSqlEntityStorageConnector.CLASS_NAME, "entity", entity);
279
+ const contextIds = await ContextIdStore.getContextIds();
280
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
281
+ EntitySchemaHelper.validateEntity(entity, this.getSchema());
282
+ const id = entity[this._primaryKeyProperty.property];
283
+ try {
284
+ if (Is.arrayValue(conditions)) {
285
+ const itemData = await this.get(id);
286
+ if (Is.notEmpty(itemData) && !this.verifyConditions(conditions, itemData)) {
287
+ return;
288
+ }
289
+ }
290
+ const finalEntity = ObjectHelper.clone(entity);
291
+ const props = [...(this._entitySchema.properties ?? [])];
292
+ props.unshift({
293
+ property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
294
+ type: EntitySchemaPropertyType.String
295
+ });
296
+ ObjectHelper.propertySet(finalEntity, PostgreSqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE);
297
+ const keys = [];
298
+ const values = [];
299
+ for (const prop of props) {
300
+ if (!(Is.empty(finalEntity[prop.property]) && (prop.optional ?? false))) {
301
+ keys.push(prop.property);
302
+ if (finalEntity[prop.property] === undefined) {
303
+ values.push(null);
304
+ }
305
+ else {
306
+ values.push(finalEntity[prop.property]);
307
+ }
308
+ }
309
+ }
310
+ let sql = `INSERT INTO "${this._config.tableName}"`;
311
+ sql += ` (${keys.map(key => `"${key}"`).join(", ")})`;
312
+ sql += ` VALUES (${values.map((_, i) => `$${i + 1}`).join(", ")})`;
313
+ sql += ` ON CONFLICT ("${PostgreSqlEntityStorageConnector._PARTITION_KEY}", "${this._primaryKeyProperty.property}")`;
314
+ sql += ` DO UPDATE SET ${keys.map(key => `"${key}" = EXCLUDED."${key}"`).join(", ")};`;
315
+ const dbConnection = await this.createConnection();
316
+ await dbConnection.unsafe(sql, values);
317
+ }
318
+ catch (err) {
319
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "setFailed", {
320
+ id
321
+ }, err);
322
+ }
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(PostgreSqlEntityStorageConnector.CLASS_NAME, "entities", entities);
331
+ const contextIds = await ContextIdStore.getContextIds();
332
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
333
+ for (const entity of entities) {
334
+ EntitySchemaHelper.validateEntity(entity, this.getSchema());
335
+ }
336
+ try {
337
+ const props = [...(this._entitySchema.properties ?? [])];
338
+ props.unshift({
339
+ property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
340
+ type: EntitySchemaPropertyType.String
341
+ });
342
+ const keys = props.map(p => p.property);
343
+ const allValues = [];
344
+ const rowPlaceholders = [];
345
+ for (const entity of entities) {
346
+ const finalEntity = ObjectHelper.clone(entity);
347
+ ObjectHelper.propertySet(finalEntity, PostgreSqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE);
348
+ const rowValues = [];
349
+ for (const prop of props) {
350
+ const val = finalEntity[prop.property];
351
+ allValues.push(Is.empty(val) ? null : val);
352
+ rowValues.push(`$${allValues.length}`);
353
+ }
354
+ rowPlaceholders.push(`(${rowValues.join(", ")})`);
355
+ }
356
+ let sql = `INSERT INTO "${this._config.tableName}"`;
357
+ sql += ` (${keys.map(key => `"${key}"`).join(", ")})`;
358
+ sql += ` VALUES ${rowPlaceholders.join(", ")}`;
359
+ sql += ` ON CONFLICT ("${PostgreSqlEntityStorageConnector._PARTITION_KEY}", "${this._primaryKeyProperty.property}")`;
360
+ sql += ` DO UPDATE SET ${keys.map(key => `"${key}" = EXCLUDED."${key}"`).join(", ")};`;
361
+ const dbConnection = await this.createConnection();
362
+ await dbConnection.unsafe(sql, allValues);
363
+ }
364
+ catch (err) {
365
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "setBatchFailed", undefined, err);
366
+ }
367
+ }
368
+ /**
369
+ * Empty all the entities.
370
+ * @returns Nothing.
371
+ */
372
+ async empty() {
373
+ const contextIds = await ContextIdStore.getContextIds();
374
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
375
+ try {
376
+ const sql = `DELETE FROM "${this._config.tableName}" WHERE "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" = $1`;
377
+ const dbConnection = await this.createConnection();
378
+ await dbConnection.unsafe(sql, [
379
+ partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
380
+ ]);
381
+ }
382
+ catch (err) {
383
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "emptyFailed", undefined, err);
384
+ }
385
+ }
386
+ /**
387
+ * Remove the entity.
388
+ * @param id The id of the entity to remove.
389
+ * @param conditions The optional conditions to match for the entities.
390
+ * @returns Nothing.
391
+ */
392
+ async remove(id, conditions) {
393
+ Guards.stringValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "id", id);
394
+ const contextIds = await ContextIdStore.getContextIds();
395
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
396
+ try {
397
+ const dbConnection = await this.createConnection();
398
+ const itemData = await this.get(id);
399
+ if (Is.notEmpty(itemData)) {
400
+ const values = [];
401
+ const whereClauses = [];
402
+ whereClauses.push(`"${this._primaryKeyProperty.property}" = $${values.length + 1}`);
403
+ values.push(id);
404
+ whereClauses.push(`"${PostgreSqlEntityStorageConnector._PARTITION_KEY}" = $${values.length + 1}`);
405
+ values.push(partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE);
406
+ if (Is.arrayValue(conditions)) {
407
+ whereClauses.push(...conditions.map(condition => {
408
+ values.push(condition.value);
409
+ return `"${String(condition.property)}" = $${values.length}`;
410
+ }));
411
+ }
412
+ const query = `DELETE FROM "${this._config.tableName}" WHERE ${whereClauses.join(" AND ")}`;
413
+ await dbConnection.unsafe(query, values);
414
+ }
415
+ }
416
+ catch (err) {
417
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "removeFailed", {
418
+ id
419
+ }, err);
420
+ }
421
+ }
422
+ /**
423
+ * Remove multiple entities by their primary key IDs.
424
+ * @param ids The ids of the entities to remove.
425
+ * @returns Nothing.
426
+ */
427
+ async removeBatch(ids) {
428
+ Guards.arrayValue(PostgreSqlEntityStorageConnector.CLASS_NAME, "ids", ids);
429
+ const contextIds = await ContextIdStore.getContextIds();
430
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
431
+ try {
432
+ const sql = `DELETE FROM "${this._config.tableName}" WHERE "${PostgreSqlEntityStorageConnector._PARTITION_KEY}" = $1 AND "${this._primaryKeyProperty.property}" = ANY($2)`;
433
+ const dbConnection = await this.createConnection();
434
+ await dbConnection.unsafe(sql, [
435
+ partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE,
436
+ ids
437
+ ]);
438
+ }
439
+ catch (err) {
440
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "removeBatchFailed", undefined, err);
441
+ }
442
+ }
443
+ /**
444
+ * Teardown the entity storage by dropping the table.
445
+ * @param nodeLoggingComponentType The node logging component type.
446
+ * @returns True if the teardown process was successful.
447
+ */
448
+ async teardown(nodeLoggingComponentType) {
449
+ const nodeLogging = ComponentFactory.getIfExists(nodeLoggingComponentType);
450
+ await nodeLogging?.log({
451
+ level: "info",
452
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
453
+ ts: Date.now(),
454
+ message: "tableDropping",
455
+ data: { tableName: this._config.tableName }
456
+ });
457
+ try {
458
+ const tableExists = await this.tableExists();
459
+ if (tableExists) {
460
+ const dbConnection = await this.createConnection();
461
+ await dbConnection.unsafe(`DROP TABLE "${this._config.tableName}";`);
462
+ await this.waitForTableNotExists();
463
+ }
464
+ await nodeLogging?.log({
465
+ level: "info",
466
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
467
+ ts: Date.now(),
468
+ message: "tableDropped",
469
+ data: { tableName: this._config.tableName }
470
+ });
471
+ return true;
472
+ }
473
+ catch (err) {
474
+ await nodeLogging?.log({
475
+ level: "error",
476
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
477
+ ts: Date.now(),
478
+ message: "teardownFailed",
479
+ error: BaseError.fromError(err)
480
+ });
481
+ return false;
482
+ }
483
+ }
484
+ /**
485
+ * Find all the entities which match the conditions.
486
+ * @param conditions The conditions to match for the entities.
487
+ * @param sortProperties The optional sort order.
488
+ * @param properties The optional properties to return, defaults to all.
489
+ * @param cursor The cursor to request the next chunk of entities.
490
+ * @param limit The suggested number of entities to return in each chunk, in some scenarios can return a different amount.
491
+ * @returns All the entities for the storage matching the conditions,
492
+ * and a cursor which can be used to request more entities.
493
+ */
494
+ async query(conditions, sortProperties, properties, cursor, limit) {
495
+ const contextIds = await ContextIdStore.getContextIds();
496
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
497
+ let sql = "";
498
+ try {
499
+ const returnSize = limit ?? PostgreSqlEntityStorageConnector._DEFAULT_LIMIT;
500
+ let orderByClause = "";
501
+ if (Is.arrayValue(sortProperties)) {
502
+ const orderClauses = [];
503
+ for (const sortProperty of sortProperties) {
504
+ const direction = sortProperty.sortDirection === SortDirection.Ascending ? "ASC" : "DESC";
505
+ orderClauses.push(`"${String(sortProperty.property)}" ${direction}`);
506
+ }
507
+ orderByClause = `ORDER BY ${orderClauses.join(", ")}`;
508
+ }
509
+ const whereClauses = [];
510
+ const values = [];
511
+ const finalConditions = {
512
+ conditions: [],
513
+ logicalOperator: LogicalOperator.And
514
+ };
515
+ finalConditions.conditions.push({
516
+ property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
517
+ comparison: ComparisonOperator.Equals,
518
+ value: partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
519
+ });
520
+ if (!Is.empty(conditions)) {
521
+ finalConditions.conditions.push(conditions);
522
+ }
523
+ this.buildQueryParameters("", finalConditions, whereClauses, values, 1);
524
+ const startIndex = Coerce.number(cursor) ?? 0;
525
+ sql = `SELECT ${properties ? properties.map(p => `"${String(p)}"`).join(", ") : "*"} FROM "${this._config.tableName}"`;
526
+ sql += ` WHERE ${whereClauses.join(" AND ")}`;
527
+ sql += ` ${orderByClause} LIMIT ${returnSize} OFFSET ${startIndex}`;
528
+ const dbConnection = await this.createConnection();
529
+ const rows = await dbConnection.unsafe(sql, values);
530
+ if (this._entitySchema.properties) {
531
+ for (const row of rows) {
532
+ for (const prop of this._entitySchema.properties) {
533
+ let propColumn = prop.property;
534
+ propColumn = propColumn.toLowerCase();
535
+ if ((prop.type === EntitySchemaPropertyType.Object ||
536
+ prop.type === EntitySchemaPropertyType.Array) &&
537
+ Is.string(row[propColumn])) {
538
+ let value;
539
+ try {
540
+ value = JSON.parse(row[propColumn]);
541
+ }
542
+ catch {
543
+ // If JSON.parse fails, keep the value as string
544
+ // This handles cases where plain text was stored in Object/Array fields
545
+ value = row[propColumn];
546
+ }
547
+ delete row[propColumn];
548
+ row[prop.property] = value;
549
+ }
550
+ if (row[propColumn] === null) {
551
+ row[prop.property] = undefined;
552
+ }
553
+ }
554
+ }
555
+ }
556
+ const entities = rows;
557
+ for (let i = 0; i < entities.length; i++) {
558
+ ObjectHelper.propertyDelete(entities[i], PostgreSqlEntityStorageConnector._PARTITION_KEY);
559
+ entities[i] = ObjectHelper.removeEmptyProperties(entities[i], { removeNull: true });
560
+ }
561
+ return {
562
+ entities,
563
+ cursor: Is.array(rows) && rows.length === returnSize
564
+ ? Coerce.string(startIndex + returnSize)
565
+ : undefined
566
+ };
567
+ }
568
+ catch (err) {
569
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "queryFailed", { sql }, err);
570
+ }
571
+ }
572
+ /**
573
+ * Count all the entities which match the conditions.
574
+ * @returns The total count of entities in the storage.
575
+ */
576
+ async count() {
577
+ try {
578
+ const contextIds = await ContextIdStore.getContextIds();
579
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
580
+ const sql = await this.createConnection();
581
+ const result = await sql `SELECT COUNT(*) AS count FROM ${sql(this._config.tableName)} WHERE "partitionId" = ${partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE}`;
582
+ return Number(result[0].count);
583
+ }
584
+ catch (err) {
585
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "countFailed", undefined, err);
586
+ }
587
+ }
588
+ /**
589
+ * Check if the database exists.
590
+ * @returns True if the database exists, false otherwise.
591
+ * @internal
592
+ */
593
+ async databaseExists() {
594
+ try {
595
+ const dbConnection = await this.createConnection();
596
+ const res = await dbConnection.unsafe(`SELECT datname FROM pg_catalog.pg_database WHERE datname = '${this._config.database}'`);
597
+ return res.length > 0;
598
+ }
599
+ catch {
600
+ return false;
601
+ }
602
+ }
603
+ /**
604
+ * Wait for a database to exist.
605
+ * @returns Nothing.
606
+ * @internal
607
+ */
608
+ async waitForDatabaseExists() {
609
+ for (let attempt = 0; attempt < 20; attempt++) {
610
+ const databaseExists = await this.databaseExists();
611
+ if (databaseExists) {
612
+ break;
613
+ }
614
+ await new Promise(resolve => setTimeout(resolve, 250));
615
+ }
616
+ }
617
+ /**
618
+ * Check if the table exists.
619
+ * @returns True if the table exists, false otherwise.
620
+ * @internal
621
+ */
622
+ async tableExists() {
623
+ try {
624
+ const dbConnection = await this.createConnection();
625
+ const tableExistsQuery = `SELECT to_regclass('${this._config.tableName}')`;
626
+ const tableExistsResult = await dbConnection.unsafe(tableExistsQuery);
627
+ return tableExistsResult[0].to_regclass !== null;
628
+ }
629
+ catch {
630
+ return false;
631
+ }
632
+ }
633
+ /**
634
+ * Wait for a table to exist.
635
+ * @returns Nothing.
636
+ * @internal
637
+ */
638
+ async waitForTableExists() {
639
+ for (let attempt = 0; attempt < 20; attempt++) {
640
+ const tableExists = await this.tableExists();
641
+ if (tableExists) {
642
+ break;
643
+ }
644
+ await new Promise(resolve => setTimeout(resolve, 250));
645
+ }
646
+ }
647
+ /**
648
+ * Wait for a table to not exist.
649
+ * @returns Nothing.
650
+ * @internal
651
+ */
652
+ async waitForTableNotExists() {
653
+ for (let attempt = 0; attempt < 20; attempt++) {
654
+ const tableExists = await this.tableExists();
655
+ if (!tableExists) {
656
+ break;
657
+ }
658
+ await new Promise(resolve => setTimeout(resolve, 250));
659
+ }
660
+ }
661
+ /**
662
+ * Create a new DB connection.
663
+ * @returns The PostgreSql connection.
664
+ * @internal
665
+ */
666
+ async createConnection() {
667
+ if (Is.empty(this._connection)) {
668
+ this._connection = postgres(this.createConnectionConfig());
669
+ }
670
+ return this._connection;
671
+ }
672
+ /**
673
+ * Create a new DB connection configuration.
674
+ * @returns The PostgreSql connection configuration.
675
+ * @internal
676
+ */
677
+ createConnectionConfig() {
678
+ return {
679
+ host: this._config.host,
680
+ port: this._config.port ?? 5432,
681
+ user: this._config.user,
682
+ password: this._config.password
683
+ };
684
+ }
685
+ /**
686
+ * Create an SQL condition clause.
687
+ * @param objectPath The path for the nested object.
688
+ * @param condition The conditions to create the query from.
689
+ * @param whereClauses The where clauses to use in the query.
690
+ * @param values The values to use in the query.
691
+ * @param valueIndex The current value index.
692
+ * @internal
693
+ */
694
+ buildQueryParameters(objectPath, condition, whereClauses, values, valueIndex) {
695
+ if (Is.undefined(condition)) {
696
+ return;
697
+ }
698
+ if ("conditions" in condition) {
699
+ if (condition.conditions.length === 0) {
700
+ return;
701
+ }
702
+ const joinConditions = condition.conditions.map(c => {
703
+ const subWhereClauses = [];
704
+ const subValues = [];
705
+ this.buildQueryParameters(objectPath, c, subWhereClauses, subValues, valueIndex);
706
+ values.push(...subValues);
707
+ valueIndex += subValues.length;
708
+ return subWhereClauses.join(" AND ");
709
+ });
710
+ const logicalOperator = this.mapConditionalOperator(condition.logicalOperator);
711
+ const queryClause = joinConditions.filter(j => j.length > 0).join(` ${logicalOperator} `);
712
+ if (queryClause.length > 0) {
713
+ whereClauses.push(`(${queryClause})`);
714
+ }
715
+ return;
716
+ }
717
+ const schemaProp = this._entitySchema.properties?.find(p => p.property === condition.property);
718
+ const comparison = this.mapComparisonOperator(objectPath, condition, schemaProp?.type, values, valueIndex);
719
+ whereClauses.push(comparison);
720
+ }
721
+ /**
722
+ * Map the framework comparison operators to those in MySQL.
723
+ * @param objectPath The prefix to use for the condition.
724
+ * @param comparator The operator to map.
725
+ * @param type The type of the property.
726
+ * @param values The values to use in the query.
727
+ * @param valueIndex The current value index.
728
+ * @returns The comparison expression.
729
+ * @throws GeneralError if the comparison operator is not supported.
730
+ * @internal
731
+ */
732
+ mapComparisonOperator(objectPath, comparator, type, values, valueIndex) {
733
+ let prop = objectPath;
734
+ if (prop.length > 0) {
735
+ prop += ".";
736
+ }
737
+ prop += comparator.property;
738
+ if (comparator.comparison === ComparisonOperator.In) {
739
+ const inValues = Is.array(comparator.value) ? comparator.value : [comparator.value];
740
+ values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
741
+ const placeholders = inValues.map((_, index) => `$${valueIndex + index}`).join(", ");
742
+ return `"${prop}" IN (${placeholders})`;
743
+ }
744
+ // null/undefined must use IS NULL / IS NOT NULL — never a parameterised placeholder.
745
+ // Passing undefined through propertyToDbValue() coerces it to NaN for number fields
746
+ // (Number(undefined) === NaN), and null coerces to 0 (Number(null) === 0), both of
747
+ // which produce semantically wrong or invalid SQL.
748
+ if (comparator.value === null || comparator.value === undefined) {
749
+ if (comparator.comparison === ComparisonOperator.Equals ||
750
+ comparator.comparison === ComparisonOperator.NotEquals) {
751
+ const nullCheck = comparator.comparison === ComparisonOperator.Equals ? "IS NULL" : "IS NOT NULL";
752
+ if (comparator.property.split(".").length > 1) {
753
+ const rootProp = comparator.property.split(".")[0];
754
+ const nestedParts = comparator.property.split(".").slice(1);
755
+ const jsonPath = nestedParts
756
+ .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
757
+ .join("");
758
+ const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
759
+ return `${jsonTextExpr} ${nullCheck}`;
760
+ }
761
+ return `"${prop}" ${nullCheck}`;
762
+ }
763
+ }
764
+ const dbValue = this.propertyToDbValue(comparator.value, type);
765
+ values.push(dbValue);
766
+ if (comparator.property.split(".").length > 1) {
767
+ const rootProp = comparator.property.split(".")[0];
768
+ const nestedParts = comparator.property.split(".").slice(1);
769
+ const rootSchema = this._entitySchema.properties?.find(p => p.property === rootProp);
770
+ const isArray = rootSchema?.type === EntitySchemaPropertyType.Array;
771
+ const jsonPath = nestedParts
772
+ .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
773
+ .join("");
774
+ const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
775
+ switch (comparator.comparison) {
776
+ case ComparisonOperator.Includes: {
777
+ values.pop();
778
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
779
+ if (isArray) {
780
+ const elemPath = nestedParts
781
+ .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
782
+ .join("");
783
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE LOWER(elem${elemPath}) ILIKE $${valueIndex})`;
784
+ }
785
+ return `LOWER(${jsonTextExpr}) ILIKE $${valueIndex}`;
786
+ }
787
+ case ComparisonOperator.NotEquals:
788
+ return `${jsonTextExpr} <> $${valueIndex}`;
789
+ case ComparisonOperator.GreaterThan:
790
+ return `${jsonTextExpr} > $${valueIndex}`;
791
+ case ComparisonOperator.LessThan:
792
+ return `${jsonTextExpr} < $${valueIndex}`;
793
+ case ComparisonOperator.GreaterThanOrEqual:
794
+ return `${jsonTextExpr} >= $${valueIndex}`;
795
+ case ComparisonOperator.LessThanOrEqual:
796
+ return `${jsonTextExpr} <= $${valueIndex}`;
797
+ default:
798
+ return `${jsonTextExpr} = $${valueIndex}`;
799
+ }
800
+ }
801
+ switch (comparator.comparison) {
802
+ case ComparisonOperator.Equals:
803
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
804
+ return `"${prop}" = $${valueIndex}::jsonb`;
805
+ }
806
+ return `"${prop}" = $${valueIndex}`;
807
+ case ComparisonOperator.NotEquals:
808
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
809
+ return `"${prop}" != $${valueIndex}::jsonb`;
810
+ }
811
+ return `"${prop}" <> $${valueIndex}`;
812
+ case ComparisonOperator.GreaterThan:
813
+ return `"${prop}" > $${valueIndex}`;
814
+ case ComparisonOperator.LessThan:
815
+ return `"${prop}" < $${valueIndex}`;
816
+ case ComparisonOperator.GreaterThanOrEqual:
817
+ return `"${prop}" >= $${valueIndex}`;
818
+ case ComparisonOperator.LessThanOrEqual:
819
+ return `"${prop}" <= $${valueIndex}`;
820
+ case ComparisonOperator.Includes: {
821
+ if (type === EntitySchemaPropertyType.String) {
822
+ return `"${prop}" ILIKE '%' || $${valueIndex} || '%'`;
823
+ }
824
+ if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
825
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
826
+ }
827
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
828
+ comparison: comparator.comparison,
829
+ type
830
+ });
831
+ }
832
+ default:
833
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
834
+ comparison: comparator.comparison
835
+ });
836
+ }
837
+ }
838
+ /**
839
+ * Format a value to insert into DB.
840
+ * @param value The value to format.
841
+ * @param type The type for the property.
842
+ * @returns The value after conversion.
843
+ * @internal
844
+ */
845
+ propertyToDbValue(value, type) {
846
+ if (type === EntitySchemaPropertyType.String) {
847
+ return String(value);
848
+ }
849
+ else if (type === EntitySchemaPropertyType.Number) {
850
+ return Number(value);
851
+ }
852
+ else if (type === EntitySchemaPropertyType.Boolean) {
853
+ return Boolean(value);
854
+ }
855
+ else if (type === EntitySchemaPropertyType.Object ||
856
+ type === EntitySchemaPropertyType.Array) {
857
+ return value;
858
+ }
859
+ return value;
860
+ }
861
+ /**
862
+ * Map the framework conditional operators to those in MySQL.
863
+ * @param operator The operator to map.
864
+ * @returns The conditional operator.
865
+ * @throws GeneralError if the conditional operator is not supported.
866
+ * @internal
867
+ */
868
+ mapConditionalOperator(operator) {
869
+ if ((operator ?? LogicalOperator.And) === LogicalOperator.And) {
870
+ return "AND";
871
+ }
872
+ else if (operator === LogicalOperator.Or) {
873
+ return "OR";
874
+ }
875
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "conditionalNotSupported", {
876
+ operator
877
+ });
878
+ }
879
+ /**
880
+ * Verify the conditions for the entity.
881
+ * @param conditions The conditions to verify.
882
+ * @internal
883
+ */
884
+ verifyConditions(conditions, obj) {
885
+ return conditions.every(condition => ObjectHelper.propertyGet(obj, condition.property) === condition.value);
886
+ }
887
+ /**
888
+ * Map entity schema properties to SQL properties.
889
+ * @param entitySchema The schema of the entity.
890
+ * @returns The SQL properties as a string.
891
+ * @throws GeneralError if the entity properties do not exist.
892
+ */
893
+ mapPostgreSqlProperties(entitySchema) {
894
+ const sqlTypeMap = {
895
+ [EntitySchemaPropertyType.String]: "TEXT",
896
+ [EntitySchemaPropertyType.Number]: "REAL",
897
+ [EntitySchemaPropertyType.Integer]: "INTEGER",
898
+ [EntitySchemaPropertyType.Object]: "JSONB",
899
+ [EntitySchemaPropertyType.Array]: "JSONB",
900
+ [EntitySchemaPropertyType.Boolean]: "BOOLEAN"
901
+ };
902
+ if (!entitySchema.properties) {
903
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "entitySchemaPropertiesUndefined");
904
+ }
905
+ const primaryKeys = [];
906
+ const props = [...entitySchema.properties];
907
+ props.unshift({
908
+ property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
909
+ type: EntitySchemaPropertyType.String,
910
+ optional: false,
911
+ isPrimary: true
912
+ });
913
+ const columnDefinitions = props
914
+ .map(prop => {
915
+ let sqlType = sqlTypeMap[prop.type] || "TEXT";
916
+ if (prop.format) {
917
+ switch (prop.type) {
918
+ case EntitySchemaPropertyType.String:
919
+ switch (prop.format) {
920
+ case "uuid":
921
+ sqlType = "UUID";
922
+ break;
923
+ }
924
+ break;
925
+ case EntitySchemaPropertyType.Number:
926
+ switch (prop.format) {
927
+ case "float":
928
+ sqlType = "REAL";
929
+ break;
930
+ case "double":
931
+ sqlType = "DOUBLE PRECISION";
932
+ break;
933
+ }
934
+ break;
935
+ case EntitySchemaPropertyType.Integer:
936
+ switch (prop.format) {
937
+ case "int8":
938
+ case "uint8":
939
+ sqlType = "SMALLINT";
940
+ break;
941
+ case "int16":
942
+ sqlType = "SMALLINT";
943
+ break;
944
+ case "uint16":
945
+ case "int32":
946
+ sqlType = "INTEGER";
947
+ break;
948
+ case "uint32":
949
+ case "int64":
950
+ case "uint64":
951
+ sqlType = "BIGINT";
952
+ break;
953
+ }
954
+ break;
955
+ }
956
+ }
957
+ const columnName = String(prop.property);
958
+ const nullable = prop.optional ? " NULL" : " NOT NULL";
959
+ if (prop.isPrimary) {
960
+ primaryKeys.push(columnName);
961
+ }
962
+ return `"${columnName}" ${sqlType}${nullable}`;
963
+ })
964
+ .join(", ");
965
+ const primaryKeyDefinition = primaryKeys.length > 0 ? `, PRIMARY KEY ("${primaryKeys.join('", "')}")` : "";
966
+ return columnDefinitions + primaryKeyDefinition;
967
+ }
968
+ }
969
+ //# sourceMappingURL=postgreSqlEntityStorageConnector.js.map