@twin.org/entity-storage-connector-postgresql 0.0.3-next.2 → 0.0.3-next.20

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 } 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.
@@ -346,25 +581,13 @@ export class PostgreSqlEntityStorageConnector {
346
581
  }
347
582
  orderByClause = `ORDER BY ${orderClauses.join(", ")}`;
348
583
  }
349
- const whereClauses = [];
350
- const values = [];
351
- const finalConditions = {
352
- conditions: [],
353
- logicalOperator: LogicalOperator.And
354
- };
355
- finalConditions.conditions.push({
356
- property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
357
- comparison: ComparisonOperator.Equals,
358
- value: partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
359
- });
360
- if (!Is.empty(conditions)) {
361
- finalConditions.conditions.push(conditions);
362
- }
363
- this.buildQueryParameters("", finalConditions, whereClauses, values, 1);
584
+ const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
364
585
  const startIndex = Coerce.number(cursor) ?? 0;
365
586
  sql = `SELECT ${properties ? properties.map(p => `"${String(p)}"`).join(", ") : "*"} FROM "${this._config.tableName}"`;
366
- sql += ` WHERE ${whereClauses.join(" AND ")}`;
367
- sql += ` ${orderByClause} LIMIT ${returnSize} OFFSET ${startIndex}`;
587
+ if (whereClauses.length > 0) {
588
+ sql += ` WHERE ${whereClauses.join(" AND ")}`;
589
+ }
590
+ sql += ` ${orderByClause} LIMIT ${returnSize + 1} OFFSET ${startIndex}`;
368
591
  const dbConnection = await this.createConnection();
369
592
  const rows = await dbConnection.unsafe(sql, values);
370
593
  if (this._entitySchema.properties) {
@@ -393,16 +616,17 @@ export class PostgreSqlEntityStorageConnector {
393
616
  }
394
617
  }
395
618
  }
396
- const entities = rows;
619
+ const hasMore = Is.array(rows) && rows.length > returnSize;
620
+ const resultRows = hasMore ? rows.slice(0, returnSize) : rows;
621
+ const entities = resultRows;
397
622
  for (let i = 0; i < entities.length; i++) {
398
- ObjectHelper.propertyDelete(entities[i], PostgreSqlEntityStorageConnector._PARTITION_KEY);
399
- entities[i] = ObjectHelper.removeEmptyProperties(entities[i], { removeNull: true });
623
+ entities[i] = EntityStorageHelper.unPrepareEntity(entities[i], [
624
+ PostgreSqlEntityStorageConnector._PARTITION_KEY
625
+ ]);
400
626
  }
401
627
  return {
402
628
  entities,
403
- cursor: Is.array(rows) && rows.length === returnSize
404
- ? Coerce.string(startIndex + returnSize)
405
- : undefined
629
+ cursor: hasMore ? Coerce.string(startIndex + returnSize) : undefined
406
630
  };
407
631
  }
408
632
  catch (err) {
@@ -410,21 +634,26 @@ export class PostgreSqlEntityStorageConnector {
410
634
  }
411
635
  }
412
636
  /**
413
- * Drop the table.
414
- * @returns Nothing.
637
+ * Count all the entities which match the conditions.
638
+ * @param conditions The optional conditions to match for the entities.
639
+ * @returns The total count of entities in the storage.
415
640
  */
416
- async tableDrop() {
641
+ async count(conditions) {
642
+ let queryStr;
417
643
  try {
418
- const tableExists = await this.tableExists();
419
- if (!tableExists) {
420
- return;
421
- }
422
644
  const dbConnection = await this.createConnection();
423
- await dbConnection.unsafe(`DROP TABLE "${this._config.tableName}";`);
424
- await this.waitForTableNotExists();
645
+ const contextIds = await ContextIdStore.getContextIds();
646
+ const partitionKey = ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
647
+ const { whereClauses, values } = this.buildWhereClause(conditions, partitionKey);
648
+ queryStr = `SELECT COUNT(*) AS count FROM "${this._config.tableName}"`;
649
+ if (whereClauses.length > 0) {
650
+ queryStr += ` WHERE ${whereClauses.join(" AND ")}`;
651
+ }
652
+ const result = await dbConnection.unsafe(queryStr, values);
653
+ return Number(result[0].count);
425
654
  }
426
- catch {
427
- // Ignore errors
655
+ catch (err) {
656
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "countFailed", { sql: queryStr }, err);
428
657
  }
429
658
  }
430
659
  /**
@@ -464,9 +693,8 @@ export class PostgreSqlEntityStorageConnector {
464
693
  async tableExists() {
465
694
  try {
466
695
  const dbConnection = await this.createConnection();
467
- const tableExistsQuery = `SELECT to_regclass('${this._config.tableName}')`;
468
- const tableExistsResult = await dbConnection.unsafe(tableExistsQuery);
469
- return tableExistsResult[0].to_regclass !== null;
696
+ const res = await dbConnection.unsafe("SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 LIMIT 1", [this._config.tableName]);
697
+ return res.length > 0;
470
698
  }
471
699
  catch {
472
700
  return false;
@@ -524,6 +752,31 @@ export class PostgreSqlEntityStorageConnector {
524
752
  password: this._config.password
525
753
  };
526
754
  }
755
+ /**
756
+ * Build where clause arrays for a query, combining partition key and optional conditions.
757
+ * @param conditions The optional entity conditions to include.
758
+ * @param partitionKey The partition key value.
759
+ * @returns The where clauses and bound values.
760
+ * @internal
761
+ */
762
+ buildWhereClause(conditions, partitionKey) {
763
+ const whereClauses = [];
764
+ const values = [];
765
+ const finalConditions = {
766
+ conditions: [],
767
+ logicalOperator: LogicalOperator.And
768
+ };
769
+ finalConditions.conditions.push({
770
+ property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
771
+ comparison: ComparisonOperator.Equals,
772
+ value: partitionKey ?? PostgreSqlEntityStorageConnector._PARTITION_KEY_VALUE
773
+ });
774
+ if (!Is.empty(conditions)) {
775
+ finalConditions.conditions.push(conditions);
776
+ }
777
+ this.buildQueryParameters("", finalConditions, whereClauses, values, 1);
778
+ return { whereClauses, values };
779
+ }
527
780
  /**
528
781
  * Create an SQL condition clause.
529
782
  * @param objectPath The path for the nested object.
@@ -580,43 +833,125 @@ export class PostgreSqlEntityStorageConnector {
580
833
  if (comparator.comparison === ComparisonOperator.In) {
581
834
  const inValues = Is.array(comparator.value) ? comparator.value : [comparator.value];
582
835
  values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
583
- const placeholders = inValues.map((_, index) => `$${valueIndex + index}`).join(", ");
836
+ const placeholders = inValues.map((value, index) => `$${valueIndex + index}`).join(", ");
584
837
  return `"${prop}" IN (${placeholders})`;
585
838
  }
839
+ // null/undefined must use IS NULL / IS NOT NULL — never a parameterised placeholder.
840
+ // Passing undefined through propertyToDbValue() coerces it to NaN for number fields
841
+ // (Number(undefined) === NaN), and null coerces to 0 (Number(null) === 0), both of
842
+ // which produce semantically wrong or invalid SQL.
843
+ if (comparator.value === null || comparator.value === undefined) {
844
+ if (comparator.comparison === ComparisonOperator.Equals ||
845
+ comparator.comparison === ComparisonOperator.NotEquals) {
846
+ const nullCheck = comparator.comparison === ComparisonOperator.Equals ? "IS NULL" : "IS NOT NULL";
847
+ if (comparator.property.split(".").length > 1) {
848
+ const rootProp = comparator.property.split(".")[0];
849
+ const nestedParts = comparator.property.split(".").slice(1);
850
+ const jsonPath = nestedParts
851
+ .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
852
+ .join("");
853
+ const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
854
+ return `${jsonTextExpr} ${nullCheck}`;
855
+ }
856
+ return `"${prop}" ${nullCheck}`;
857
+ }
858
+ }
586
859
  const dbValue = this.propertyToDbValue(comparator.value, type);
587
860
  values.push(dbValue);
588
861
  if (comparator.property.split(".").length > 1) {
589
- const jsonPath = comparator.property
590
- .split(".")
591
- .slice(1)
862
+ const rootProp = comparator.property.split(".")[0];
863
+ const nestedParts = comparator.property.split(".").slice(1);
864
+ const rootSchema = this._entitySchema.properties?.find(p => p.property === rootProp);
865
+ const isArray = rootSchema?.type === EntitySchemaPropertyType.Array;
866
+ const jsonPath = nestedParts
592
867
  .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
593
868
  .join("");
594
- return `("${comparator.property.split(".")[0]}"::jsonb ${jsonPath}) = $${valueIndex}`;
595
- }
596
- else if (comparator.comparison === ComparisonOperator.Equals) {
597
- return `"${prop}" = $${valueIndex}`;
598
- }
599
- else if (comparator.comparison === ComparisonOperator.NotEquals) {
600
- return `"${prop}" <> $${valueIndex}`;
601
- }
602
- else if (comparator.comparison === ComparisonOperator.GreaterThan) {
603
- return `"${prop}" > $${valueIndex}`;
604
- }
605
- else if (comparator.comparison === ComparisonOperator.LessThan) {
606
- return `"${prop}" < $${valueIndex}`;
607
- }
608
- else if (comparator.comparison === ComparisonOperator.GreaterThanOrEqual) {
609
- return `"${prop}" >= $${valueIndex}`;
610
- }
611
- else if (comparator.comparison === ComparisonOperator.LessThanOrEqual) {
612
- return `"${prop}" <= $${valueIndex}`;
869
+ const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
870
+ switch (comparator.comparison) {
871
+ case ComparisonOperator.Includes: {
872
+ values.pop();
873
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
874
+ if (isArray) {
875
+ const elemPath = nestedParts
876
+ .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
877
+ .join("");
878
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE LOWER(elem${elemPath}) ILIKE $${valueIndex})`;
879
+ }
880
+ return `LOWER(${jsonTextExpr}) ILIKE $${valueIndex}`;
881
+ }
882
+ case ComparisonOperator.NotIncludes: {
883
+ values.pop();
884
+ values.push(`%${String(comparator.value).toLowerCase()}%`);
885
+ if (isArray) {
886
+ const elemPath = nestedParts
887
+ .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
888
+ .join("");
889
+ return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE LOWER(elem${elemPath}) ILIKE $${valueIndex})`;
890
+ }
891
+ return `LOWER(${jsonTextExpr}) NOT ILIKE $${valueIndex}`;
892
+ }
893
+ case ComparisonOperator.NotEquals:
894
+ return `${jsonTextExpr} <> $${valueIndex}`;
895
+ case ComparisonOperator.GreaterThan:
896
+ return `${jsonTextExpr} > $${valueIndex}`;
897
+ case ComparisonOperator.LessThan:
898
+ return `${jsonTextExpr} < $${valueIndex}`;
899
+ case ComparisonOperator.GreaterThanOrEqual:
900
+ return `${jsonTextExpr} >= $${valueIndex}`;
901
+ case ComparisonOperator.LessThanOrEqual:
902
+ return `${jsonTextExpr} <= $${valueIndex}`;
903
+ default:
904
+ return `${jsonTextExpr} = $${valueIndex}`;
905
+ }
613
906
  }
614
- else if (comparator.comparison === ComparisonOperator.Includes) {
615
- return `EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
907
+ switch (comparator.comparison) {
908
+ case ComparisonOperator.Equals:
909
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
910
+ return `"${prop}" = $${valueIndex}::jsonb`;
911
+ }
912
+ return `"${prop}" = $${valueIndex}`;
913
+ case ComparisonOperator.NotEquals:
914
+ if (Is.object(comparator.value) || Is.array(comparator.value)) {
915
+ return `"${prop}" != $${valueIndex}::jsonb`;
916
+ }
917
+ return `"${prop}" <> $${valueIndex}`;
918
+ case ComparisonOperator.GreaterThan:
919
+ return `"${prop}" > $${valueIndex}`;
920
+ case ComparisonOperator.LessThan:
921
+ return `"${prop}" < $${valueIndex}`;
922
+ case ComparisonOperator.GreaterThanOrEqual:
923
+ return `"${prop}" >= $${valueIndex}`;
924
+ case ComparisonOperator.LessThanOrEqual:
925
+ return `"${prop}" <= $${valueIndex}`;
926
+ case ComparisonOperator.Includes: {
927
+ if (type === EntitySchemaPropertyType.String) {
928
+ return `"${prop}" ILIKE '%' || $${valueIndex} || '%'`;
929
+ }
930
+ if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
931
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
932
+ }
933
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
934
+ comparison: comparator.comparison,
935
+ type
936
+ });
937
+ }
938
+ case ComparisonOperator.NotIncludes: {
939
+ if (type === EntitySchemaPropertyType.String) {
940
+ return `"${prop}" NOT ILIKE '%' || $${valueIndex} || '%'`;
941
+ }
942
+ if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
943
+ return `NOT 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
+ });
949
+ }
950
+ default:
951
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
952
+ comparison: comparator.comparison
953
+ });
616
954
  }
617
- throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
618
- comparison: comparator.comparison
619
- });
620
955
  }
621
956
  /**
622
957
  * Format a value to insert into DB.
@@ -626,21 +961,19 @@ export class PostgreSqlEntityStorageConnector {
626
961
  * @internal
627
962
  */
628
963
  propertyToDbValue(value, type) {
629
- if (type === "string") {
964
+ if (type === EntitySchemaPropertyType.String) {
630
965
  return String(value);
631
966
  }
632
- else if (type === "number") {
967
+ else if (type === EntitySchemaPropertyType.Number) {
633
968
  return Number(value);
634
969
  }
635
- else if (type === "boolean") {
970
+ else if (type === EntitySchemaPropertyType.Boolean) {
636
971
  return Boolean(value);
637
972
  }
638
- else if (type === "array") {
973
+ else if (type === EntitySchemaPropertyType.Object ||
974
+ type === EntitySchemaPropertyType.Array) {
639
975
  return value;
640
976
  }
641
- if (Is.object(value)) {
642
- return JSON.stringify(value);
643
- }
644
977
  return value;
645
978
  }
646
979
  /**
@@ -697,7 +1030,48 @@ export class PostgreSqlEntityStorageConnector {
697
1030
  });
698
1031
  const columnDefinitions = props
699
1032
  .map(prop => {
700
- const sqlType = sqlTypeMap[prop.type] || "TEXT";
1033
+ let sqlType = sqlTypeMap[prop.type] || "TEXT";
1034
+ if (prop.format) {
1035
+ switch (prop.type) {
1036
+ case EntitySchemaPropertyType.String:
1037
+ switch (prop.format) {
1038
+ case "uuid":
1039
+ sqlType = "UUID";
1040
+ break;
1041
+ }
1042
+ break;
1043
+ case EntitySchemaPropertyType.Number:
1044
+ switch (prop.format) {
1045
+ case "float":
1046
+ sqlType = "REAL";
1047
+ break;
1048
+ case "double":
1049
+ sqlType = "DOUBLE PRECISION";
1050
+ break;
1051
+ }
1052
+ break;
1053
+ case EntitySchemaPropertyType.Integer:
1054
+ switch (prop.format) {
1055
+ case "int8":
1056
+ case "uint8":
1057
+ sqlType = "SMALLINT";
1058
+ break;
1059
+ case "int16":
1060
+ sqlType = "SMALLINT";
1061
+ break;
1062
+ case "uint16":
1063
+ case "int32":
1064
+ sqlType = "INTEGER";
1065
+ break;
1066
+ case "uint32":
1067
+ case "int64":
1068
+ case "uint64":
1069
+ sqlType = "BIGINT";
1070
+ break;
1071
+ }
1072
+ break;
1073
+ }
1074
+ }
701
1075
  const columnName = String(prop.property);
702
1076
  const nullable = prop.optional ? " NULL" : " NOT NULL";
703
1077
  if (prop.isPrimary) {