@twin.org/entity-storage-connector-postgresql 0.0.3-next.3 → 0.0.3-next.30

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,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
- const entity = ObjectHelper.removeEmptyProperties(rows[0], { removeNull: true });
223
- ObjectHelper.propertyDelete(entity, PostgreSqlEntityStorageConnector._PARTITION_KEY);
224
- return entity;
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
- EntitySchemaHelper.validateEntity(entity, this.getSchema());
245
- 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];
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
- if (!(Is.empty(finalEntity[prop.property]) && (prop.optional ?? false))) {
265
- keys.push(prop.property);
266
- if (finalEntity[prop.property] === undefined) {
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((_, i) => `$${i + 1}`).join(", ")})`;
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
- sql += ` WHERE ${whereClauses.join(" AND ")}`;
367
- 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}`;
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 entities = rows;
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
- ObjectHelper.propertyDelete(entities[i], PostgreSqlEntityStorageConnector._PARTITION_KEY);
399
- entities[i] = ObjectHelper.removeEmptyProperties(entities[i], { removeNull: true });
630
+ entities[i] = EntityStorageHelper.unPrepareEntity(entities[i], [
631
+ PostgreSqlEntityStorageConnector._PARTITION_KEY
632
+ ]);
400
633
  }
401
634
  return {
402
635
  entities,
403
- cursor: Is.array(rows) && rows.length === returnSize
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
- * Drop the table.
414
- * @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.
415
647
  */
416
- async tableDrop() {
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 dbConnection.unsafe(`DROP TABLE "${this._config.tableName}";`);
424
- 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);
425
661
  }
426
- catch {
427
- // Ignore errors
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 tableExistsQuery = `SELECT to_regclass('${this._config.tableName}')`;
468
- const tableExistsResult = await dbConnection.unsafe(tableExistsQuery);
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((_, index) => `$${valueIndex + index}`).join(", ");
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 jsonPath = comparator.property
590
- .split(".")
591
- .slice(1)
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
- return `("${comparator.property.split(".")[0]}"::jsonb ${jsonPath}) = $${valueIndex}`;
595
- }
596
- else if (comparator.comparison === ComparisonOperator.Equals) {
597
- if (Is.object(comparator.value) || Is.array(comparator.value)) {
598
- return `"${prop}" = $${valueIndex}::jsonb`;
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
- else if (comparator.comparison === ComparisonOperator.NotEquals) {
603
- if (Is.object(comparator.value) || Is.array(comparator.value)) {
604
- return `"${prop}" != $${valueIndex}::jsonb`;
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
- return `"${prop}" <> $${valueIndex}`;
607
- }
608
- else if (comparator.comparison === ComparisonOperator.GreaterThan) {
609
- return `"${prop}" > $${valueIndex}`;
610
- }
611
- else if (comparator.comparison === ComparisonOperator.LessThan) {
612
- return `"${prop}" < $${valueIndex}`;
613
- }
614
- else if (comparator.comparison === ComparisonOperator.GreaterThanOrEqual) {
615
- return `"${prop}" >= $${valueIndex}`;
616
- }
617
- else if (comparator.comparison === ComparisonOperator.LessThanOrEqual) {
618
- return `"${prop}" <= $${valueIndex}`;
619
- }
620
- else if (comparator.comparison === ComparisonOperator.Includes) {
621
- return `EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
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
- 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
+ }
705
1090
  const columnName = String(prop.property);
706
1091
  const nullable = prop.optional ? " NULL" : " NOT NULL";
707
1092
  if (prop.isPrimary) {