@twin.org/entity-storage-connector-postgresql 0.0.3-next.8 → 0.9.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"IPostgreSqlEntityStorageConnectorConstructorOptions.js","sourceRoot":"","sources":["../../../src/models/IPostgreSqlEntityStorageConnectorConstructorOptions.ts"],"names":[],"mappings":"","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport type { IPostgreSqlEntityStorageConnectorConfig } from \"./IPostgreSqlEntityStorageConnectorConfig.js\";\n\n/**\n * The options for the PostgreSql entity storage connector constructor.\n */\nexport interface IPostgreSqlEntityStorageConnectorConstructorOptions {\n\t/**\n\t * The schema for the entity.\n\t */\n\tentitySchema: string;\n\n\t/**\n\t * The keys to use from the context ids to create partitions.\n\t */\n\tpartitionContextIds?: string[];\n\n\t/**\n\t * The type of logging component to use.\n\t * @default logging\n\t */\n\tloggingComponentType?: string;\n\n\t/**\n\t * The configuration for the connector.\n\t */\n\tconfig: IPostgreSqlEntityStorageConnectorConfig;\n}\n"]}
1
+ {"version":3,"file":"IPostgreSqlEntityStorageConnectorConstructorOptions.js","sourceRoot":"","sources":["../../../src/models/IPostgreSqlEntityStorageConnectorConstructorOptions.ts"],"names":[],"mappings":"","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport type { IPostgreSqlEntityStorageConnectorConfig } from \"./IPostgreSqlEntityStorageConnectorConfig.js\";\n\n/**\n * The options for the PostgreSql entity storage connector constructor.\n */\nexport interface IPostgreSqlEntityStorageConnectorConstructorOptions {\n\t/**\n\t * The schema for the entity.\n\t */\n\tentitySchema: string;\n\n\t/**\n\t * The keys to use from the context ids to create partitions.\n\t */\n\tpartitionContextIds?: string[];\n\n\t/**\n\t * The type of logging component to use.\n\t */\n\tloggingComponentType?: string;\n\n\t/**\n\t * The configuration for the connector.\n\t */\n\tconfig: IPostgreSqlEntityStorageConnectorConfig;\n}\n"]}
@@ -1,8 +1,9 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
3
  import { ContextIdHelper, ContextIdStore } from "@twin.org/context";
4
- import { BaseError, Coerce, ComponentFactory, GeneralError, Guards, Is, ObjectHelper } 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,35 @@ 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
+ }
156
192
  /**
157
193
  * The component needs to be stopped when the node is closed.
158
194
  * @returns Nothing.
@@ -229,9 +265,9 @@ export class PostgreSqlEntityStorageConnector {
229
265
  }
230
266
  }
231
267
  }
232
- const entity = ObjectHelper.removeEmptyProperties(rows[0], { removeNull: true });
233
- ObjectHelper.propertyDelete(entity, PostgreSqlEntityStorageConnector._PARTITION_KEY);
234
- return entity;
268
+ return EntityStorageHelper.unPrepareEntity(rows[0], [
269
+ PostgreSqlEntityStorageConnector._PARTITION_KEY
270
+ ]);
235
271
  }
236
272
  }
237
273
  catch (err) {
@@ -251,8 +287,13 @@ export class PostgreSqlEntityStorageConnector {
251
287
  Guards.object(PostgreSqlEntityStorageConnector.CLASS_NAME, "entity", entity);
252
288
  const contextIds = await ContextIdStore.getContextIds();
253
289
  const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
254
- EntitySchemaHelper.validateEntity(entity, this.getSchema());
255
- const id = entity[this._primaryKeyProperty.property];
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];
256
297
  try {
257
298
  if (Is.arrayValue(conditions)) {
258
299
  const itemData = await this.get(id);
@@ -260,30 +301,21 @@ export class PostgreSqlEntityStorageConnector {
260
301
  return;
261
302
  }
262
303
  }
263
- const finalEntity = ObjectHelper.clone(entity);
264
304
  const props = [...(this._entitySchema.properties ?? [])];
265
305
  props.unshift({
266
306
  property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
267
307
  type: EntitySchemaPropertyType.String
268
308
  });
269
- ObjectHelper.propertySet(finalEntity, PostgreSqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE);
270
- ObjectHelper.propertySet(finalEntity, PostgreSqlEntityStorageConnector._PARTITION_KEY, partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE);
271
309
  const keys = [];
272
310
  const values = [];
273
311
  for (const prop of props) {
274
- if (!(Is.empty(finalEntity[prop.property]) && (prop.optional ?? false))) {
275
- keys.push(prop.property);
276
- if (finalEntity[prop.property] === undefined) {
277
- values.push(null);
278
- }
279
- else {
280
- values.push(finalEntity[prop.property]);
281
- }
282
- }
312
+ keys.push(prop.property);
313
+ const val = prepared[prop.property];
314
+ values.push(val ?? null);
283
315
  }
284
316
  let sql = `INSERT INTO "${this._config.tableName}"`;
285
317
  sql += ` (${keys.map(key => `"${key}"`).join(", ")})`;
286
- sql += ` VALUES (${values.map((_, i) => `$${i + 1}`).join(", ")})`;
318
+ sql += ` VALUES (${values.map((value, i) => `$${i + 1}`).join(", ")})`;
287
319
  sql += ` ON CONFLICT ("${PostgreSqlEntityStorageConnector._PARTITION_KEY}", "${this._primaryKeyProperty.property}")`;
288
320
  sql += ` DO UPDATE SET ${keys.map(key => `"${key}" = EXCLUDED."${key}"`).join(", ")};`;
289
321
  const dbConnection = await this.createConnection();
@@ -295,6 +327,69 @@ export class PostgreSqlEntityStorageConnector {
295
327
  }, err);
296
328
  }
297
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
+ }
298
393
  /**
299
394
  * Remove the entity.
300
395
  * @param id The id of the entity to remove.
@@ -331,6 +426,136 @@ export class PostgreSqlEntityStorageConnector {
331
426
  }, err);
332
427
  }
333
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
+ }
334
559
  /**
335
560
  * Find all the entities which match the conditions.
336
561
  * @param conditions The conditions to match for the entities.
@@ -344,6 +569,13 @@ export class PostgreSqlEntityStorageConnector {
344
569
  async query(conditions, sortProperties, properties, cursor, limit) {
345
570
  const contextIds = await ContextIdStore.getContextIds();
346
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
+ }
347
579
  let sql = "";
348
580
  try {
349
581
  const returnSize = limit ?? PostgreSqlEntityStorageConnector._DEFAULT_LIMIT;
@@ -356,25 +588,13 @@ export class PostgreSqlEntityStorageConnector {
356
588
  }
357
589
  orderByClause = `ORDER BY ${orderClauses.join(", ")}`;
358
590
  }
359
- const whereClauses = [];
360
- const values = [];
361
- const finalConditions = {
362
- conditions: [],
363
- logicalOperator: LogicalOperator.And
364
- };
365
- finalConditions.conditions.push({
366
- property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
367
- comparison: ComparisonOperator.Equals,
368
- value: partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
369
- });
370
- if (!Is.empty(conditions)) {
371
- finalConditions.conditions.push(conditions);
372
- }
373
- this.buildQueryParameters("", finalConditions, whereClauses, values, 1);
591
+ const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
374
592
  const startIndex = Coerce.number(cursor) ?? 0;
375
593
  sql = `SELECT ${properties ? properties.map(p => `"${String(p)}"`).join(", ") : "*"} FROM "${this._config.tableName}"`;
376
- sql += ` WHERE ${whereClauses.join(" AND ")}`;
377
- sql += ` ${orderByClause} LIMIT ${returnSize} OFFSET ${startIndex}`;
594
+ if (whereClauses.length > 0) {
595
+ sql += ` WHERE ${whereClauses.join(" AND ")}`;
596
+ }
597
+ sql += ` ${orderByClause} LIMIT ${returnSize + 1} OFFSET ${startIndex}`;
378
598
  const dbConnection = await this.createConnection();
379
599
  const rows = await dbConnection.unsafe(sql, values);
380
600
  if (this._entitySchema.properties) {
@@ -403,16 +623,17 @@ export class PostgreSqlEntityStorageConnector {
403
623
  }
404
624
  }
405
625
  }
406
- const entities = rows;
626
+ const hasMore = Is.array(rows) && rows.length > returnSize;
627
+ const resultRows = hasMore ? rows.slice(0, returnSize) : rows;
628
+ const entities = resultRows;
407
629
  for (let i = 0; i < entities.length; i++) {
408
- ObjectHelper.propertyDelete(entities[i], PostgreSqlEntityStorageConnector._PARTITION_KEY);
409
- entities[i] = ObjectHelper.removeEmptyProperties(entities[i], { removeNull: true });
630
+ entities[i] = EntityStorageHelper.unPrepareEntity(entities[i], [
631
+ PostgreSqlEntityStorageConnector._PARTITION_KEY
632
+ ]);
410
633
  }
411
634
  return {
412
635
  entities,
413
- cursor: Is.array(rows) && rows.length === returnSize
414
- ? Coerce.string(startIndex + returnSize)
415
- : undefined
636
+ cursor: hasMore ? Coerce.string(startIndex + returnSize) : undefined
416
637
  };
417
638
  }
418
639
  catch (err) {
@@ -420,21 +641,26 @@ export class PostgreSqlEntityStorageConnector {
420
641
  }
421
642
  }
422
643
  /**
423
- * Drop the table.
424
- * @returns Nothing.
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.
425
647
  */
426
- async tableDrop() {
648
+ async count(conditions) {
649
+ let queryStr;
427
650
  try {
428
- const tableExists = await this.tableExists();
429
- if (!tableExists) {
430
- return;
431
- }
432
651
  const dbConnection = await this.createConnection();
433
- await dbConnection.unsafe(`DROP TABLE "${this._config.tableName}";`);
434
- await this.waitForTableNotExists();
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);
435
661
  }
436
- catch {
437
- // Ignore errors
662
+ catch (err) {
663
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "countFailed", { sql: queryStr }, err);
438
664
  }
439
665
  }
440
666
  /**
@@ -474,9 +700,8 @@ export class PostgreSqlEntityStorageConnector {
474
700
  async tableExists() {
475
701
  try {
476
702
  const dbConnection = await this.createConnection();
477
- const tableExistsQuery = `SELECT to_regclass('${this._config.tableName}')`;
478
- const tableExistsResult = await dbConnection.unsafe(tableExistsQuery);
479
- 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;
480
705
  }
481
706
  catch {
482
707
  return false;
@@ -534,6 +759,31 @@ export class PostgreSqlEntityStorageConnector {
534
759
  password: this._config.password
535
760
  };
536
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
+ }
537
787
  /**
538
788
  * Create an SQL condition clause.
539
789
  * @param objectPath The path for the nested object.
@@ -589,8 +839,13 @@ export class PostgreSqlEntityStorageConnector {
589
839
  prop += comparator.property;
590
840
  if (comparator.comparison === ComparisonOperator.In) {
591
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
+ }
592
847
  values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
593
- const placeholders = inValues.map((_, index) => `$${valueIndex + index}`).join(", ");
848
+ const placeholders = inValues.map((value, index) => `$${valueIndex + index}`).join(", ");
594
849
  return `"${prop}" IN (${placeholders})`;
595
850
  }
596
851
  // null/undefined must use IS NULL / IS NOT NULL — never a parameterised placeholder.
@@ -636,6 +891,17 @@ export class PostgreSqlEntityStorageConnector {
636
891
  }
637
892
  return `LOWER(${jsonTextExpr}) ILIKE $${valueIndex}`;
638
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
+ }
639
905
  case ComparisonOperator.NotEquals:
640
906
  return `${jsonTextExpr} <> $${valueIndex}`;
641
907
  case ComparisonOperator.GreaterThan:
@@ -681,6 +947,18 @@ export class PostgreSqlEntityStorageConnector {
681
947
  type
682
948
  });
683
949
  }
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
+ }
684
962
  default:
685
963
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
686
964
  comparison: comparator.comparison
@@ -731,6 +1009,8 @@ export class PostgreSqlEntityStorageConnector {
731
1009
  /**
732
1010
  * Verify the conditions for the entity.
733
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.
734
1014
  * @internal
735
1015
  */
736
1016
  verifyConditions(conditions, obj) {
@@ -741,6 +1021,7 @@ export class PostgreSqlEntityStorageConnector {
741
1021
  * @param entitySchema The schema of the entity.
742
1022
  * @returns The SQL properties as a string.
743
1023
  * @throws GeneralError if the entity properties do not exist.
1024
+ * @internal
744
1025
  */
745
1026
  mapPostgreSqlProperties(entitySchema) {
746
1027
  const sqlTypeMap = {
@@ -764,7 +1045,48 @@ export class PostgreSqlEntityStorageConnector {
764
1045
  });
765
1046
  const columnDefinitions = props
766
1047
  .map(prop => {
767
- const sqlType = sqlTypeMap[prop.type] || "TEXT";
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
+ }
768
1090
  const columnName = String(prop.property);
769
1091
  const nullable = prop.optional ? " NULL" : " NOT NULL";
770
1092
  if (prop.isPrimary) {