@twin.org/entity-storage-connector-postgresql 0.10.1-next.1 → 0.10.1-next.11

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.
@@ -29,11 +29,22 @@ export class PostgreSqlEntityStorageConnector {
29
29
  * @internal
30
30
  */
31
31
  static _PARTITION_KEY_VALUE = "root";
32
+ /**
33
+ * Maximum length of the partition id column. The column leads the primary key and every
34
+ * index, so it is bounded rather than stored as unconstrained text.
35
+ * @internal
36
+ */
37
+ static _PARTITION_KEY_MAX_LENGTH = 255;
32
38
  /**
33
39
  * Maximum number of rows per INSERT statement in setBatch.
34
40
  * @internal
35
41
  */
36
42
  static _BATCH_CHUNK_SIZE = 1000;
43
+ /**
44
+ * The column the group ranking is emitted as when picking one row per group.
45
+ * @internal
46
+ */
47
+ static _GROUP_RANK_COLUMN = "__groupRank";
37
48
  /**
38
49
  * PostgreSQL's maximum identifier length in characters; longer names are silently truncated.
39
50
  * @internal
@@ -200,16 +211,17 @@ export class PostgreSqlEntityStorageConnector {
200
211
  }
201
212
  });
202
213
  }
214
+ const indexes = await this.readIndexes(dbConnection);
203
215
  for (const prop of this._entitySchema.properties ?? []) {
204
216
  if ((prop.isSecondary === true || !Is.empty(prop.sortDirection)) &&
205
217
  prop.type !== EntitySchemaPropertyType.Object &&
206
218
  prop.type !== EntitySchemaPropertyType.Array) {
207
- await this.ensureIndex(dbConnection, prop, nodeLogging);
219
+ await this.ensureIndex(dbConnection, indexes, prop, nodeLogging);
208
220
  }
209
221
  }
210
222
  const indexGroups = EntitySchemaHelper.getIndexGroups(this._entitySchema);
211
223
  for (const indexProperties of Object.values(indexGroups)) {
212
- await this.ensureCompositeIndex(dbConnection, indexProperties);
224
+ await this.ensureCompositeIndex(dbConnection, indexes, indexProperties);
213
225
  }
214
226
  }
215
227
  catch (error) {
@@ -648,7 +660,7 @@ export class PostgreSqlEntityStorageConnector {
648
660
  * @returns The connector implementation version.
649
661
  */
650
662
  connectorVersion() {
651
- return 0;
663
+ return 1;
652
664
  }
653
665
  /**
654
666
  * Get all the distinct partition context ids from the storage.
@@ -711,17 +723,20 @@ export class PostgreSqlEntityStorageConnector {
711
723
  });
712
724
  }
713
725
  /**
714
- * Finalize the migration by renaming the migration table to the original table name.
726
+ * Finalize the migration by dropping the source table and renaming the migration table into its name in one transaction.
715
727
  * @param targetConnector The connector pointing to the migration table.
716
728
  * @param options The optional migration options.
717
729
  * @param loggingComponentType The node logging component type.
718
730
  * @returns A connector pointing to the final (renamed) table.
719
731
  */
720
732
  async finalizeMigration(targetConnector, options, loggingComponentType) {
721
- // Teardown the existing table with the original name to free up the name for the new table
722
- await this.teardown(loggingComponentType);
723
- const dbConnection = await targetConnector.getClient();
724
- await dbConnection.unsafe(`ALTER TABLE "${targetConnector._config.tableName}" RENAME TO "${this._config.tableName}"`);
733
+ // One transaction drops the source and renames the migration table, so a failure or a
734
+ // process death leaves either the untouched source or the complete migrated table in place.
735
+ const dbConnection = await this.getClient();
736
+ await dbConnection.begin(async (transaction) => {
737
+ await transaction.unsafe(`DROP TABLE "${this._config.tableName}"`);
738
+ await transaction.unsafe(`ALTER TABLE "${targetConnector._config.tableName}" RENAME TO "${this._config.tableName}"`);
739
+ });
725
740
  const finalConnector = new PostgreSqlEntityStorageConnector({
726
741
  entitySchema: targetConnector._entitySchemaName,
727
742
  config: this._config,
@@ -878,6 +893,37 @@ export class PostgreSqlEntityStorageConnector {
878
893
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "queryFailed", { sql }, err);
879
894
  }
880
895
  }
896
+ /**
897
+ * Find all the entities which match the conditions, attaching to each one the entities from a
898
+ * second storage connector whose join property matches. The join behaves like a left join by
899
+ * default, a primary entity with no matches is still returned with an empty joined list, unless
900
+ * joinRequired asks for an inner join and those entities are left out altogether. Both connectors
901
+ * must be PostgreSQL connectors reading from the same database so the work can be done in a
902
+ * single statement.
903
+ * @param joinConnector The connector holding the entities to join to.
904
+ * @param joinOptions The properties to join on, the conditions, sort order, projection and
905
+ * paging for the primary entities, the optional grouping and group conditions, and the optional
906
+ * conditions, sort order and projection for the joined entities.
907
+ * @returns All the entities for the storage matching the conditions with their joined entities,
908
+ * and a cursor which can be used to request more entities.
909
+ * @throws GeneralError if the join connector does not read from the same server and database.
910
+ */
911
+ async queryJoin(joinConnector, joinOptions) {
912
+ Guards.object(PostgreSqlEntityStorageConnector.CLASS_NAME, "joinConnector", joinConnector);
913
+ // The join runs as one statement against this connector's connection, so the other side has
914
+ // to be a PostgreSQL connector reading from the same server and database.
915
+ const typedJoinConnector = joinConnector;
916
+ if (joinConnector.className?.() !== PostgreSqlEntityStorageConnector.CLASS_NAME ||
917
+ typedJoinConnector._config?.host !== this._config.host ||
918
+ typedJoinConnector._config?.port !== this._config.port ||
919
+ typedJoinConnector._config?.database !== this._config.database) {
920
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "joinConnectorMismatch", {
921
+ database: this._config.database
922
+ });
923
+ }
924
+ EntityStorageHelper.validateJoinOptions(this._entitySchema, typedJoinConnector._entitySchema, joinOptions);
925
+ return this.queryJoinPage(typedJoinConnector, joinOptions, joinOptions.groupProperty);
926
+ }
881
927
  /**
882
928
  * Count all the entities which match the conditions.
883
929
  * @param conditions The optional conditions to match for the entities.
@@ -902,6 +948,399 @@ export class PostgreSqlEntityStorageConnector {
902
948
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "countFailed", { sql: queryStr }, err);
903
949
  }
904
950
  }
951
+ /**
952
+ * Read a page of primary entities and their joined entities in a single statement. The page of
953
+ * primary rows is selected in a derived table so the limit and the cursor apply to the primary
954
+ * entities rather than to the rows the join multiplies them into. When grouping, the derived
955
+ * table keeps only the first row of each group in the sort order, which makes one row stand for
956
+ * the whole group and lets the same key set cursor step past every row the group holds.
957
+ * @param joinConnector The connector holding the entities to join to.
958
+ * @param joinOptions The join configuration.
959
+ * @param groupProperty The optional property to group the primary entities by.
960
+ * @returns The entities with their joined entities, and the next page cursor.
961
+ * @internal
962
+ */
963
+ async queryJoinPage(joinConnector, joinOptions, groupProperty) {
964
+ const returnSize = joinOptions.limit ?? PostgreSqlEntityStorageConnector._DEFAULT_LIMIT;
965
+ const pkPropName = String(this._primaryKeyProperty.property);
966
+ const joinColumn = String(joinOptions.property);
967
+ const joinedPrimaryKey = String(joinConnector._primaryKeyProperty.property);
968
+ const normalizedOptions = EntityStorageHelper.normalizeJoinOptions(joinOptions);
969
+ const keySetValues = EntityStorageHelper.decodeCursor(normalizedOptions, joinOptions.cursor);
970
+ let sql = "";
971
+ try {
972
+ const keySetCols = this.buildKeySetColumns(joinOptions.sortProperties);
973
+ // The key set columns, the join column and the group column are needed to page, to join
974
+ // and to re-attach the members of a group, so they are read even when the caller did not
975
+ // ask for them, then removed from the entities.
976
+ const primary = this.buildColumnSelection(this._entitySchema, joinOptions.properties, keySetCols
977
+ .map(c => c.prop)
978
+ .concat(joinColumn)
979
+ .concat(Is.empty(groupProperty) ? [] : [String(groupProperty)]));
980
+ const joined = this.buildColumnSelection(joinConnector._entitySchema, joinOptions.joinProperties, [joinedPrimaryKey]);
981
+ const partitionKey = await this.resolvePartitionKey();
982
+ const values = [];
983
+ // PostgreSQL numbers its placeholders, so every clause is built in the order it appears
984
+ // in the statement and its values are appended as it goes.
985
+ const where = this.buildWhereClause(joinOptions.conditions, partitionKey, "t", values.length + 1);
986
+ values.push(...where.values);
987
+ const whereClauses = [...where.whereClauses];
988
+ if (!Is.empty(groupProperty)) {
989
+ whereClauses.push(`t."${String(groupProperty)}" IS NOT NULL`);
990
+ }
991
+ const narrowing = await this.buildNarrowingClauses(joinConnector, joinOptions, groupProperty, partitionKey, "t", values.length + 1);
992
+ whereClauses.push(...narrowing.clauses);
993
+ values.push(...narrowing.values);
994
+ const columnList = primary.columns.map(c => `"${c}"`).join(", ");
995
+ const pageOrderBy = keySetCols.map(c => `"${c.prop}" ${c.asc ? "ASC" : "DESC"}`).join(", ");
996
+ const keySet = this.buildKeySetClause(keySetCols, keySetValues, values.length + 1);
997
+ values.push(...keySet.values);
998
+ let pageSql;
999
+ if (Is.empty(groupProperty)) {
1000
+ pageSql = `SELECT ${columnList} FROM "${this._config.tableName}" AS t WHERE ${[...whereClauses, ...keySet.clauses].join(" AND ")} ORDER BY ${pageOrderBy} LIMIT ${returnSize + 1}`;
1001
+ }
1002
+ else {
1003
+ // Ranking inside each group and keeping the first row collapses the group to the one
1004
+ // row the result stands on, so the ordering, the projection and the cursor all work
1005
+ // on ordinary rows rather than on a distinct list of group values.
1006
+ const rankedSql = `SELECT ${columnList}, ROW_NUMBER() OVER (PARTITION BY t."${String(groupProperty)}" ORDER BY ${keySetCols.map(c => `t."${c.prop}" ${c.asc ? "ASC" : "DESC"}`).join(", ")}) AS "${PostgreSqlEntityStorageConnector._GROUP_RANK_COLUMN}" FROM "${this._config.tableName}" AS t WHERE ${whereClauses.join(" AND ")}`;
1007
+ const rankedWhere = [
1008
+ `ranked."${PostgreSqlEntityStorageConnector._GROUP_RANK_COLUMN}" = 1`,
1009
+ ...keySet.clauses
1010
+ ];
1011
+ pageSql = `SELECT ${columnList} FROM (${rankedSql}) AS ranked WHERE ${rankedWhere.join(" AND ")} ORDER BY ${pageOrderBy} LIMIT ${returnSize + 1}`;
1012
+ }
1013
+ // A group stands on one row for ordering and paging, but its joined list has to hold the
1014
+ // matches of every entity in the group, so the other members are re-attached to the page
1015
+ // and the join hangs off them. The same joined entity reached through more than one
1016
+ // member is collapsed when the rows are collected.
1017
+ let fromClause = `(${pageSql}) AS p`;
1018
+ let joinFromAlias = "p";
1019
+ if (!Is.empty(groupProperty)) {
1020
+ const members = this.buildWhereClause(joinOptions.conditions, partitionKey, "m", values.length + 1);
1021
+ values.push(...members.values);
1022
+ const memberNarrowing = await this.buildNarrowingClauses(joinConnector, joinOptions, undefined, partitionKey, "m", values.length + 1);
1023
+ values.push(...memberNarrowing.values);
1024
+ fromClause += ` LEFT JOIN "${this._config.tableName}" AS m ON ${[
1025
+ `m."${String(groupProperty)}" = p."${String(groupProperty)}"`,
1026
+ ...members.whereClauses,
1027
+ ...memberNarrowing.clauses
1028
+ ].join(" AND ")}`;
1029
+ joinFromAlias = "m";
1030
+ }
1031
+ const join = await joinConnector.buildJoinClause(String(joinOptions.joinProperty), joinOptions.joinConditions, "j", joinFromAlias, joinColumn, values.length + 1);
1032
+ values.push(...join.values);
1033
+ // PostgreSQL returns a flat row, so each column is aliased by position and mapped back
1034
+ // afterwards rather than relying on the table it came from.
1035
+ const selectClause = primary.columns
1036
+ .map((c, i) => `p."${c}" AS "p${i}"`)
1037
+ .concat(joined.columns.map((c, i) => `j."${c}" AS "j${i}"`))
1038
+ .join(", ");
1039
+ const outerOrderBy = keySetCols
1040
+ .map(c => `p."${c.prop}" ${c.asc ? "ASC" : "DESC"}`)
1041
+ .concat(this.buildJoinOrderBy(joinOptions, "j"))
1042
+ .join(", ");
1043
+ sql = `SELECT ${selectClause} FROM ${fromClause} LEFT JOIN "${joinConnector._config.tableName}" AS j ON ${join.clause} ORDER BY ${outerOrderBy}`;
1044
+ const dbConnection = await this.getClient();
1045
+ const rows = await dbConnection.unsafe(sql, values);
1046
+ const groups = this.collectJoinRows(rows, primary.columns, joined.columns, pkPropName, joinedPrimaryKey);
1047
+ const hasMore = groups.length > returnSize;
1048
+ const pageGroups = hasMore ? groups.slice(0, returnSize) : groups;
1049
+ const entities = [];
1050
+ for (const group of pageGroups) {
1051
+ const entity = this.prepareJoinEntity(group.key, this._entitySchema, primary.internal);
1052
+ entities.push({
1053
+ ...entity,
1054
+ joined: group.joined.map(j => this.prepareJoinEntity(j, joinConnector._entitySchema, joined.internal))
1055
+ });
1056
+ }
1057
+ let nextCursor;
1058
+ if (hasMore && pageGroups.length > 0) {
1059
+ const lastRow = pageGroups[pageGroups.length - 1].key;
1060
+ nextCursor = EntityStorageHelper.encodeCursor(normalizedOptions, keySetCols.map(c => lastRow[c.prop]));
1061
+ }
1062
+ return { entities, cursor: nextCursor };
1063
+ }
1064
+ catch (err) {
1065
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "queryJoinFailed", { sql }, err);
1066
+ }
1067
+ }
1068
+ /**
1069
+ * Build the clauses which narrow the rows a page is built from, beyond the plain conditions.
1070
+ * An inner join requires the row to have at least one joined entity, and a group condition
1071
+ * requires the group the row belongs to to hold an entity which matches it.
1072
+ * @param joinConnector The connector holding the entities to join to.
1073
+ * @param joinOptions The join configuration.
1074
+ * @param groupProperty The optional property the entities are grouped by.
1075
+ * @param partitionKey The partition key of this connector.
1076
+ * @param alias The alias of the row being narrowed.
1077
+ * @param startIndex The number the first placeholder takes.
1078
+ * @returns The clauses and their bound values.
1079
+ * @internal
1080
+ */
1081
+ async buildNarrowingClauses(joinConnector, joinOptions, groupProperty, partitionKey, alias, startIndex) {
1082
+ const clauses = [];
1083
+ const values = [];
1084
+ if (joinOptions.joinRequired ?? false) {
1085
+ const exists = await joinConnector.buildJoinExistsClause(String(joinOptions.joinProperty), joinOptions.joinConditions, "jx", alias, String(joinOptions.property), startIndex + values.length);
1086
+ clauses.push(exists.clause);
1087
+ values.push(...exists.values);
1088
+ }
1089
+ if (!Is.empty(groupProperty) && Is.arrayValue(joinOptions.groupConditions)) {
1090
+ const groupColumn = String(groupProperty);
1091
+ for (let i = 0; i < joinOptions.groupConditions.length; i++) {
1092
+ const memberAlias = `gx${i}`;
1093
+ const group = this.buildWhereClause(joinOptions.groupConditions[i], partitionKey, memberAlias, startIndex + values.length);
1094
+ clauses.push(`EXISTS (SELECT 1 FROM "${this._config.tableName}" AS ${memberAlias} WHERE ${memberAlias}."${groupColumn}" = ${alias}."${groupColumn}" AND ${group.whereClauses.join(" AND ")})`);
1095
+ values.push(...group.values);
1096
+ }
1097
+ }
1098
+ return { clauses, values };
1099
+ }
1100
+ /**
1101
+ * Build the clause which requires a primary entity to have at least one joined entity. Called
1102
+ * on the connector holding the joined entities so its partition key and property types apply.
1103
+ * @param joinProperty The column on this connector's table to join to.
1104
+ * @param joinConditions The optional conditions to match for the joined entities.
1105
+ * @param alias The alias of the joined table inside the clause.
1106
+ * @param primaryAlias The alias of the primary entity being narrowed.
1107
+ * @param primaryColumn The column on the primary entity to join from.
1108
+ * @param startIndex The number the first placeholder takes.
1109
+ * @returns The clause and its bound values.
1110
+ * @internal
1111
+ */
1112
+ async buildJoinExistsClause(joinProperty, joinConditions, alias, primaryAlias, primaryColumn, startIndex) {
1113
+ const partitionKey = await this.resolvePartitionKey();
1114
+ const where = this.buildWhereClause(joinConditions, partitionKey, alias, startIndex);
1115
+ return {
1116
+ clause: `EXISTS (SELECT 1 FROM "${this._config.tableName}" AS ${alias} WHERE ${alias}."${joinProperty}" = ${primaryAlias}."${primaryColumn}" AND ${where.whereClauses.join(" AND ")})`,
1117
+ values: where.values
1118
+ };
1119
+ }
1120
+ /**
1121
+ * Build the ON clause which attaches the joined table, including its own partition key and any
1122
+ * conditions the caller supplied for the joined entities. Called on the connector holding the
1123
+ * joined entities so the partition key and the property types come from its own schema and
1124
+ * configuration.
1125
+ * @param joinProperty The column on this connector's table to join to.
1126
+ * @param joinConditions The optional conditions to match for the joined entities.
1127
+ * @param alias The alias of the joined table.
1128
+ * @param primaryAlias The alias of the table holding the primary entities.
1129
+ * @param primaryColumn The column on the primary table to join from.
1130
+ * @param startIndex The number the first placeholder takes.
1131
+ * @returns The ON clause and its bound values.
1132
+ * @internal
1133
+ */
1134
+ async buildJoinClause(joinProperty, joinConditions, alias, primaryAlias, primaryColumn, startIndex) {
1135
+ const partitionKey = await this.resolvePartitionKey();
1136
+ const where = this.buildWhereClause(joinConditions, partitionKey, alias, startIndex);
1137
+ return {
1138
+ clause: [
1139
+ `${alias}."${joinProperty}" = ${primaryAlias}."${primaryColumn}"`,
1140
+ ...where.whereClauses
1141
+ ].join(" AND "),
1142
+ values: where.values
1143
+ };
1144
+ }
1145
+ /**
1146
+ * Build the ORDER BY fragments which order the joined entities within each primary entity.
1147
+ * @param joinOptions The join configuration.
1148
+ * @param alias The alias of the joined table.
1149
+ * @returns The order by fragments, empty when no sort order was requested.
1150
+ * @internal
1151
+ */
1152
+ buildJoinOrderBy(joinOptions, alias) {
1153
+ return (joinOptions.joinSortProperties ?? []).map(s => `${alias}."${String(s.property)}" ${s.sortDirection === SortDirection.Ascending ? "ASC" : "DESC"}`);
1154
+ }
1155
+ /**
1156
+ * Build the ordered keySet columns used for the page order and the cursor, matching the
1157
+ * behaviour of query so both paginate the same way.
1158
+ * @param sortProperties The optional sort order.
1159
+ * @returns The ordered columns with their direction.
1160
+ * @internal
1161
+ */
1162
+ buildKeySetColumns(sortProperties) {
1163
+ const pkPropName = String(this._primaryKeyProperty.property);
1164
+ const keySetCols = [];
1165
+ for (const sortProperty of sortProperties ?? []) {
1166
+ keySetCols.push({
1167
+ prop: String(sortProperty.property),
1168
+ asc: sortProperty.sortDirection === SortDirection.Ascending
1169
+ });
1170
+ }
1171
+ if (!keySetCols.some(c => c.prop === pkPropName)) {
1172
+ keySetCols.push({ prop: pkPropName, asc: true });
1173
+ }
1174
+ return keySetCols;
1175
+ }
1176
+ /**
1177
+ * Build the keySet condition which continues the page from a previous cursor.
1178
+ * @param keySetCols The ordered keySet columns.
1179
+ * @param lastValues The key set values of the last entity of the previous page.
1180
+ * @param startIndex The number the first placeholder takes.
1181
+ * @returns The clauses and their bound values.
1182
+ * @internal
1183
+ */
1184
+ buildKeySetClause(keySetCols, lastValues, startIndex) {
1185
+ if (!Is.arrayValue(lastValues)) {
1186
+ return { clauses: [], values: [] };
1187
+ }
1188
+ const values = [];
1189
+ const orParts = [];
1190
+ for (let i = 0; i < keySetCols.length; i++) {
1191
+ const parts = [];
1192
+ for (let j = 0; j < i; j++) {
1193
+ values.push(lastValues[j]);
1194
+ parts.push(`"${keySetCols[j].prop}" = $${startIndex + values.length - 1}`);
1195
+ }
1196
+ const op = keySetCols[i].asc ? ">" : "<";
1197
+ values.push(lastValues[i]);
1198
+ parts.push(`"${keySetCols[i].prop}" ${op} $${startIndex + values.length - 1}`);
1199
+ orParts.push(parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`);
1200
+ }
1201
+ return { clauses: [`(${orParts.join(" OR ")})`], values };
1202
+ }
1203
+ /**
1204
+ * Work out which columns to read for one side of the join, honouring the caller's projection
1205
+ * but adding the columns the join itself needs.
1206
+ * @param schema The schema of the entities being read.
1207
+ * @param properties The optional projection requested by the caller.
1208
+ * @param required The columns the join needs regardless of the projection.
1209
+ * @returns The columns to read and the ones which were only added internally.
1210
+ * @internal
1211
+ */
1212
+ buildColumnSelection(schema, properties, required) {
1213
+ const columns = [];
1214
+ const internal = [];
1215
+ if (Is.arrayValue(properties)) {
1216
+ for (const prop of properties) {
1217
+ const column = String(prop);
1218
+ if (!columns.includes(column)) {
1219
+ columns.push(column);
1220
+ }
1221
+ }
1222
+ for (const column of required) {
1223
+ if (!columns.includes(column)) {
1224
+ columns.push(column);
1225
+ internal.push(column);
1226
+ }
1227
+ }
1228
+ }
1229
+ else {
1230
+ for (const prop of schema.properties ?? []) {
1231
+ const column = String(prop.property);
1232
+ if (!columns.includes(column)) {
1233
+ columns.push(column);
1234
+ }
1235
+ }
1236
+ }
1237
+ return { columns, internal };
1238
+ }
1239
+ /**
1240
+ * Collapse the flat rows returned by the join into one entry per primary entity, preserving the
1241
+ * order the database returned them in.
1242
+ * @param rows The rows from the join statement, whose columns are aliased by position.
1243
+ * @param primaryColumns The primary columns in the order they were aliased.
1244
+ * @param joinedColumns The joined columns in the order they were aliased.
1245
+ * @param identityColumn The column which identifies a primary entity.
1246
+ * @param joinedPrimaryKey The primary key column of the joined entities.
1247
+ * @returns One entry per primary entity with its joined entities.
1248
+ * @internal
1249
+ */
1250
+ collectJoinRows(rows, primaryColumns, joinedColumns, identityColumn, joinedPrimaryKey) {
1251
+ const groups = [];
1252
+ if (!Is.array(rows)) {
1253
+ return groups;
1254
+ }
1255
+ let current;
1256
+ let currentIdentity;
1257
+ let seenJoined = new Set();
1258
+ for (const row of rows) {
1259
+ const primaryRow = {};
1260
+ for (let i = 0; i < primaryColumns.length; i++) {
1261
+ primaryRow[primaryColumns[i]] = row[`p${i}`];
1262
+ }
1263
+ const identity = this.rowKey(primaryRow[identityColumn]);
1264
+ if (Is.undefined(current) || identity !== currentIdentity) {
1265
+ current = { key: primaryRow, joined: [] };
1266
+ currentIdentity = identity;
1267
+ seenJoined = new Set();
1268
+ groups.push(current);
1269
+ }
1270
+ const joinedRow = {};
1271
+ for (let i = 0; i < joinedColumns.length; i++) {
1272
+ joinedRow[joinedColumns[i]] = row[`j${i}`];
1273
+ }
1274
+ // A left join with no match produces a row whose joined columns are all null, and the
1275
+ // same joined entity appears more than once when several primary rows in a group share
1276
+ // the same join value.
1277
+ if (!Is.empty(joinedRow[joinedPrimaryKey])) {
1278
+ const joinedIdentity = this.rowKey(joinedRow[joinedPrimaryKey]);
1279
+ if (!seenJoined.has(joinedIdentity)) {
1280
+ seenJoined.add(joinedIdentity);
1281
+ current.joined.push(joinedRow);
1282
+ }
1283
+ }
1284
+ }
1285
+ return groups;
1286
+ }
1287
+ /**
1288
+ * Turn a raw column value into a key which can be compared between rows.
1289
+ * @param value The value read from the storage.
1290
+ * @returns The key for the value.
1291
+ * @internal
1292
+ */
1293
+ rowKey(value) {
1294
+ return Is.string(value) ? value : JSON.stringify(value);
1295
+ }
1296
+ /**
1297
+ * Apply to a row read by a join the same clean up a plain query applies to its entities, then
1298
+ * remove the columns which were only read to satisfy the join.
1299
+ * @param row The raw row read from the storage.
1300
+ * @param schema The schema of the entity.
1301
+ * @param internal The columns to remove.
1302
+ * @returns The entity.
1303
+ * @internal
1304
+ */
1305
+ prepareJoinEntity(row, schema, internal) {
1306
+ const prepared = { ...row };
1307
+ for (const prop of schema.properties ?? []) {
1308
+ const column = String(prop.property);
1309
+ if ((prop.type === EntitySchemaPropertyType.Object ||
1310
+ prop.type === EntitySchemaPropertyType.Array) &&
1311
+ Is.string(prepared[column])) {
1312
+ try {
1313
+ prepared[column] = JSON.parse(String(prepared[column]));
1314
+ }
1315
+ catch {
1316
+ // Text which is not JSON is left as it was read, matching what a plain query does.
1317
+ }
1318
+ }
1319
+ }
1320
+ return EntityStorageHelper.unPrepareEntity(prepared, [
1321
+ PostgreSqlEntityStorageConnector._PARTITION_KEY,
1322
+ ...internal
1323
+ ]);
1324
+ }
1325
+ /**
1326
+ * Get the partition key for the current context.
1327
+ * @returns The partition key, or undefined when the connector is not partitioned.
1328
+ * @internal
1329
+ */
1330
+ async resolvePartitionKey() {
1331
+ const contextIds = await ContextIdStore.getContextIds();
1332
+ return ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
1333
+ }
1334
+ /**
1335
+ * Qualify a column with a table alias, needed when the statement reads from more than one table.
1336
+ * @param column The column name.
1337
+ * @param tableAlias The optional table alias.
1338
+ * @returns The quoted column, prefixed with the alias when one was supplied.
1339
+ * @internal
1340
+ */
1341
+ qualifiedColumn(column, tableAlias) {
1342
+ return Is.stringValue(tableAlias) ? `${tableAlias}."${column}"` : `"${column}"`;
1343
+ }
905
1344
  /**
906
1345
  * Check if the database exists.
907
1346
  * @param adminClient The server-level connection to use for the check.
@@ -933,79 +1372,124 @@ export class PostgreSqlEntityStorageConnector {
933
1372
  }
934
1373
  }
935
1374
  /**
936
- * Ensure the secondary index for a property exists, replacing a legacy-named index if present.
1375
+ * Ensure the secondary index for a property exists, dropping a legacy-named index if present.
1376
+ * Every query is scoped to a single partition, so the index leads with the partition key and
1377
+ * the property follows it, letting one index serve both the partition filter and the sort.
937
1378
  * @param dbConnection The connection to query with.
1379
+ * @param indexes The indexes already on the table, keyed by index name.
938
1380
  * @param prop The indexed property.
939
1381
  * @param nodeLogging Optional logging component.
940
1382
  * @internal
941
1383
  */
942
- async ensureIndex(dbConnection, prop, nodeLogging) {
1384
+ async ensureIndex(dbConnection, indexes, prop, nodeLogging) {
943
1385
  const columnName = String(prop.property);
944
1386
  const indexName = IndexHelper.generateName(this._config.tableName, columnName);
945
- const indexRows = await dbConnection.unsafe(`SELECT i.relname AS "indexName", ix.indisunique AS "isUnique", ix.indnkeyatts AS "keyColumnCount"
946
- FROM pg_index ix
947
- JOIN pg_class t ON t.oid = ix.indrelid
948
- JOIN pg_namespace n ON n.oid = t.relnamespace
949
- JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ix.indkey[0]
950
- JOIN pg_class i ON i.oid = ix.indexrelid
951
- JOIN pg_am am ON am.oid = i.relam
952
- WHERE n.nspname = 'public'
953
- AND t.relname = $1
954
- AND a.attname = $2
955
- AND ix.indisvalid
956
- AND ix.indisready
957
- AND ix.indpred IS NULL
958
- AND am.amname = 'btree'`, [this._config.tableName, columnName]);
959
- const indexNames = indexRows.map(row => ObjectHelper.propertyGet(row, "indexName"));
960
- if (!Is.arrayValue(indexNames)) {
961
- await dbConnection.unsafe(`CREATE INDEX IF NOT EXISTS "${indexName}" ON "${this._config.tableName}" ("${columnName}")`);
962
- return;
1387
+ const keyColumns = [PostgreSqlEntityStorageConnector._PARTITION_KEY, columnName];
1388
+ if (!this.isIndexCovered(indexes, keyColumns)) {
1389
+ // An index of ours under the same name but with a different shape predates the partition
1390
+ // key leading the key columns, so it has to be replaced rather than left in place.
1391
+ if (!Is.empty(indexes[indexName])) {
1392
+ await dbConnection.unsafe(`DROP INDEX "${indexName}"`);
1393
+ }
1394
+ await dbConnection.unsafe(`CREATE INDEX IF NOT EXISTS "${indexName}" ON "${this._config.tableName}" ("${PostgreSqlEntityStorageConnector._PARTITION_KEY}", "${columnName}")`);
963
1395
  }
964
1396
  // TODO: remove the legacy index handling once every installation has bootstrapped on a release that contains it
965
1397
  const legacyName = IndexHelper.generateLegacyName(this._config.tableName, columnName, IndexHelper.DEFAULT_MAX_IDENTIFIER_LENGTH);
966
- if (!indexNames.includes(legacyName)) {
967
- return;
968
- }
1398
+ const legacyIndex = indexes[legacyName];
969
1399
  // The connector's own legacy indexes were always non-unique and single-column, anything else is an operator's
970
- const legacyRow = indexRows.find(row => ObjectHelper.propertyGet(row, "indexName") === legacyName);
971
- if (!Is.object(legacyRow) ||
972
- ObjectHelper.propertyGet(legacyRow, "isUnique") !== false ||
973
- Coerce.integer(ObjectHelper.propertyGet(legacyRow, "keyColumnCount")) !== 1) {
974
- return;
975
- }
976
- const hasCurrent = indexNames.includes(indexName);
977
- if (hasCurrent) {
1400
+ if (!Is.empty(legacyIndex) && legacyIndex.nonUnique && legacyIndex.columns.length === 1) {
978
1401
  await dbConnection.unsafe(`DROP INDEX "${legacyName}"`);
1402
+ await nodeLogging?.log({
1403
+ level: "info",
1404
+ source: PostgreSqlEntityStorageConnector.CLASS_NAME,
1405
+ ts: Date.now(),
1406
+ message: "legacyIndexDropped",
1407
+ data: {
1408
+ tableName: this._config.tableName,
1409
+ indexName: legacyName,
1410
+ newIndexName: indexName
1411
+ }
1412
+ });
979
1413
  }
980
- else {
981
- await dbConnection.unsafe(`ALTER INDEX "${legacyName}" RENAME TO "${indexName}"`);
982
- }
983
- await nodeLogging?.log({
984
- level: "info",
985
- source: PostgreSqlEntityStorageConnector.CLASS_NAME,
986
- ts: Date.now(),
987
- message: hasCurrent ? "legacyIndexDropped" : "legacyIndexRenamed",
988
- data: {
989
- tableName: this._config.tableName,
990
- indexName: legacyName,
991
- newIndexName: indexName
992
- }
993
- });
994
1414
  }
995
1415
  /**
996
1416
  * Ensure the composite index for a schema index group exists.
997
1417
  * A group needs at least two properties to form a composite index, otherwise it is skipped.
1418
+ * The partition key leads the index for the same reason it leads a single property index.
998
1419
  * @param dbConnection The connection to query with.
1420
+ * @param indexes The indexes already on the table, keyed by index name.
999
1421
  * @param indexProperties The properties in the group, ordered by their index position.
1000
1422
  * @internal
1001
1423
  */
1002
- async ensureCompositeIndex(dbConnection, indexProperties) {
1424
+ async ensureCompositeIndex(dbConnection, indexes, indexProperties) {
1003
1425
  const indexName = IndexHelper.generateCompositeName(this._config.tableName, indexProperties);
1004
- const indexCols = indexProperties
1005
- .map(indexProperty => `"${String(indexProperty.property.property)}" ${indexProperty.direction === SortDirection.Descending ? "DESC" : "ASC"}`)
1006
- .join(", ");
1426
+ const keyColumns = [
1427
+ PostgreSqlEntityStorageConnector._PARTITION_KEY,
1428
+ ...indexProperties.map(indexProperty => String(indexProperty.property.property))
1429
+ ];
1430
+ if (this.isIndexCovered(indexes, keyColumns)) {
1431
+ return;
1432
+ }
1433
+ // An index of ours under the same name but with a different shape predates the partition
1434
+ // key leading the key columns, so it has to be replaced rather than left in place.
1435
+ if (!Is.empty(indexes[indexName])) {
1436
+ await dbConnection.unsafe(`DROP INDEX "${indexName}"`);
1437
+ }
1438
+ const indexCols = [
1439
+ `"${PostgreSqlEntityStorageConnector._PARTITION_KEY}" ASC`,
1440
+ ...indexProperties.map(indexProperty => `"${String(indexProperty.property.property)}" ${indexProperty.direction === SortDirection.Descending ? "DESC" : "ASC"}`)
1441
+ ].join(", ");
1007
1442
  await dbConnection.unsafe(`CREATE INDEX IF NOT EXISTS "${indexName}" ON "${this._config.tableName}" (${indexCols})`);
1008
1443
  }
1444
+ /**
1445
+ * Read the key columns of every usable index on the table, in key order.
1446
+ * @param dbConnection The connection to query with.
1447
+ * @returns The key columns and uniqueness of each index, keyed by index name.
1448
+ * @internal
1449
+ */
1450
+ async readIndexes(dbConnection) {
1451
+ const indexRows = await dbConnection.unsafe(`SELECT i.relname AS "indexName", a.attname AS "columnName", k.pos AS "position", ix.indisunique AS "isUnique"
1452
+ FROM pg_index ix
1453
+ JOIN pg_class t ON t.oid = ix.indrelid
1454
+ JOIN pg_namespace n ON n.oid = t.relnamespace
1455
+ JOIN pg_class i ON i.oid = ix.indexrelid
1456
+ JOIN pg_am am ON am.oid = i.relam
1457
+ JOIN LATERAL generate_series(0, ix.indnkeyatts - 1) AS k(pos) ON true
1458
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ix.indkey[k.pos]
1459
+ WHERE n.nspname = 'public'
1460
+ AND t.relname = $1
1461
+ AND ix.indisvalid
1462
+ AND ix.indisready
1463
+ AND ix.indpred IS NULL
1464
+ AND am.amname = 'btree'
1465
+ ORDER BY i.relname, k.pos`, [this._config.tableName]);
1466
+ const indexes = {};
1467
+ for (const row of indexRows) {
1468
+ const indexName = ObjectHelper.propertyGet(row, "indexName");
1469
+ const columnName = ObjectHelper.propertyGet(row, "columnName");
1470
+ const position = Coerce.integer(ObjectHelper.propertyGet(row, "position"));
1471
+ if (Is.stringValue(indexName) && Is.stringValue(columnName) && Is.integer(position)) {
1472
+ indexes[indexName] ??= {
1473
+ columns: [],
1474
+ nonUnique: ObjectHelper.propertyGet(row, "isUnique") === false
1475
+ };
1476
+ // An expression key has no column to join to, leaving a hole which stops the
1477
+ // index from matching any key column list beyond that position.
1478
+ indexes[indexName].columns[position] = columnName;
1479
+ }
1480
+ }
1481
+ return indexes;
1482
+ }
1483
+ /**
1484
+ * Check if any of the indexes already starts with the given key columns.
1485
+ * @param indexes The indexes on the table, keyed by index name.
1486
+ * @param keyColumns The leading key columns the index must have, in order.
1487
+ * @returns True if an index already leads with the key columns.
1488
+ * @internal
1489
+ */
1490
+ isIndexCovered(indexes, keyColumns) {
1491
+ return Object.values(indexes).some(index => keyColumns.every((keyColumn, position) => index.columns[position] === keyColumn));
1492
+ }
1009
1493
  /**
1010
1494
  * Check if the table exists.
1011
1495
  * @returns True if the table exists, false otherwise.
@@ -1103,10 +1587,14 @@ export class PostgreSqlEntityStorageConnector {
1103
1587
  * Build where clause arrays for a query, combining partition key and optional conditions.
1104
1588
  * @param conditions The optional entity conditions to include.
1105
1589
  * @param partitionKey The partition key value.
1590
+ * @param tableAlias The optional table alias to qualify the columns with, needed when the
1591
+ * clauses are used in a statement which reads from more than one table.
1592
+ * @param startIndex The number the first placeholder takes, so clauses can be added to a
1593
+ * statement which already holds values.
1106
1594
  * @returns The where clauses and bound values.
1107
1595
  * @internal
1108
1596
  */
1109
- buildWhereClause(conditions, partitionKey) {
1597
+ buildWhereClause(conditions, partitionKey, tableAlias, startIndex = 1) {
1110
1598
  const whereClauses = [];
1111
1599
  const values = [];
1112
1600
  const finalConditions = {
@@ -1121,7 +1609,7 @@ export class PostgreSqlEntityStorageConnector {
1121
1609
  if (!Is.empty(conditions)) {
1122
1610
  finalConditions.conditions.push(conditions);
1123
1611
  }
1124
- this.buildQueryParameters("", finalConditions, whereClauses, values, 1);
1612
+ this.buildQueryParameters("", finalConditions, whereClauses, values, startIndex, tableAlias);
1125
1613
  return { whereClauses, values };
1126
1614
  }
1127
1615
  /**
@@ -1131,9 +1619,10 @@ export class PostgreSqlEntityStorageConnector {
1131
1619
  * @param whereClauses The where clauses to use in the query.
1132
1620
  * @param values The values to use in the query.
1133
1621
  * @param valueIndex The current value index.
1622
+ * @param tableAlias The optional table alias to qualify the columns with.
1134
1623
  * @internal
1135
1624
  */
1136
- buildQueryParameters(objectPath, condition, whereClauses, values, valueIndex) {
1625
+ buildQueryParameters(objectPath, condition, whereClauses, values, valueIndex, tableAlias) {
1137
1626
  if (Is.undefined(condition)) {
1138
1627
  return;
1139
1628
  }
@@ -1144,7 +1633,7 @@ export class PostgreSqlEntityStorageConnector {
1144
1633
  const joinConditions = condition.conditions.map(c => {
1145
1634
  const subWhereClauses = [];
1146
1635
  const subValues = [];
1147
- this.buildQueryParameters(objectPath, c, subWhereClauses, subValues, valueIndex);
1636
+ this.buildQueryParameters(objectPath, c, subWhereClauses, subValues, valueIndex, tableAlias);
1148
1637
  values.push(...subValues);
1149
1638
  valueIndex += subValues.length;
1150
1639
  return subWhereClauses.join(" AND ");
@@ -1157,7 +1646,7 @@ export class PostgreSqlEntityStorageConnector {
1157
1646
  return;
1158
1647
  }
1159
1648
  const schemaProp = this._entitySchema.properties?.find(p => p.property === condition.property);
1160
- const comparison = this.mapComparisonOperator(objectPath, condition, schemaProp?.type, values, valueIndex);
1649
+ const comparison = this.mapComparisonOperator(objectPath, condition, schemaProp?.type, values, valueIndex, tableAlias);
1161
1650
  whereClauses.push(comparison);
1162
1651
  }
1163
1652
  /**
@@ -1167,11 +1656,12 @@ export class PostgreSqlEntityStorageConnector {
1167
1656
  * @param type The type of the property.
1168
1657
  * @param values The values to use in the query.
1169
1658
  * @param valueIndex The current value index.
1659
+ * @param tableAlias The optional table alias to qualify the columns with.
1170
1660
  * @returns The comparison expression.
1171
1661
  * @throws GeneralError if the comparison operator is not supported.
1172
1662
  * @internal
1173
1663
  */
1174
- mapComparisonOperator(objectPath, comparator, type, values, valueIndex) {
1664
+ mapComparisonOperator(objectPath, comparator, type, values, valueIndex, tableAlias) {
1175
1665
  let prop = objectPath;
1176
1666
  if (prop.length > 0) {
1177
1667
  prop += ".";
@@ -1186,7 +1676,7 @@ export class PostgreSqlEntityStorageConnector {
1186
1676
  }
1187
1677
  values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
1188
1678
  const placeholders = inValues.map((value, index) => `$${valueIndex + index}`).join(", ");
1189
- return `"${prop}" IN (${placeholders})`;
1679
+ return `${this.qualifiedColumn(prop, tableAlias)} IN (${placeholders})`;
1190
1680
  }
1191
1681
  // null/undefined must use IS NULL / IS NOT NULL - never a parameterised placeholder.
1192
1682
  // Passing undefined through propertyToDbValue() coerces it to NaN for number fields
@@ -1202,10 +1692,10 @@ export class PostgreSqlEntityStorageConnector {
1202
1692
  const jsonPath = nestedParts
1203
1693
  .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
1204
1694
  .join("");
1205
- const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
1695
+ const jsonTextExpr = `(${this.qualifiedColumn(rootProp, tableAlias)}::jsonb ${jsonPath})`;
1206
1696
  return `${jsonTextExpr} ${nullCheck}`;
1207
1697
  }
1208
- return `"${prop}" ${nullCheck}`;
1698
+ return `${this.qualifiedColumn(prop, tableAlias)} ${nullCheck}`;
1209
1699
  }
1210
1700
  }
1211
1701
  const dbValue = this.propertyToDbValue(comparator.value, type);
@@ -1218,29 +1708,40 @@ export class PostgreSqlEntityStorageConnector {
1218
1708
  const jsonPath = nestedParts
1219
1709
  .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
1220
1710
  .join("");
1221
- const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
1711
+ const jsonTextExpr = `(${this.qualifiedColumn(rootProp, tableAlias)}::jsonb ${jsonPath})`;
1222
1712
  switch (comparator.comparison) {
1223
1713
  case ComparisonOperator.Includes: {
1224
1714
  values.pop();
1225
- values.push(`%${String(comparator.value).toLowerCase()}%`);
1715
+ values.push(`%${String(comparator.value)}%`);
1226
1716
  if (isArray) {
1227
1717
  const elemPath = nestedParts
1228
1718
  .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
1229
1719
  .join("");
1230
- return `EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE LOWER(elem${elemPath}) ILIKE $${valueIndex})`;
1720
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(rootProp, tableAlias)}) elem WHERE elem${elemPath} LIKE $${valueIndex})`;
1231
1721
  }
1232
- return `LOWER(${jsonTextExpr}) ILIKE $${valueIndex}`;
1722
+ return `${jsonTextExpr} LIKE $${valueIndex}`;
1233
1723
  }
1234
1724
  case ComparisonOperator.NotIncludes: {
1235
1725
  values.pop();
1236
- values.push(`%${String(comparator.value).toLowerCase()}%`);
1726
+ values.push(`%${String(comparator.value)}%`);
1727
+ if (isArray) {
1728
+ const elemPath = nestedParts
1729
+ .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
1730
+ .join("");
1731
+ return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(rootProp, tableAlias)}) elem WHERE elem${elemPath} LIKE $${valueIndex})`;
1732
+ }
1733
+ return `${jsonTextExpr} NOT LIKE $${valueIndex}`;
1734
+ }
1735
+ case ComparisonOperator.StartsWith: {
1736
+ values.pop();
1737
+ values.push(`${this.escapeLike(String(comparator.value))}%`);
1237
1738
  if (isArray) {
1238
1739
  const elemPath = nestedParts
1239
1740
  .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
1240
1741
  .join("");
1241
- return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE LOWER(elem${elemPath}) ILIKE $${valueIndex})`;
1742
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(rootProp, tableAlias)}) elem WHERE elem${elemPath} LIKE $${valueIndex})`;
1242
1743
  }
1243
- return `LOWER(${jsonTextExpr}) NOT ILIKE $${valueIndex}`;
1744
+ return `${jsonTextExpr} LIKE $${valueIndex}`;
1244
1745
  }
1245
1746
  case ComparisonOperator.NotEquals:
1246
1747
  return `${jsonTextExpr} <> $${valueIndex}`;
@@ -1259,28 +1760,28 @@ export class PostgreSqlEntityStorageConnector {
1259
1760
  switch (comparator.comparison) {
1260
1761
  case ComparisonOperator.Equals:
1261
1762
  if (Is.object(comparator.value) || Is.array(comparator.value)) {
1262
- return `"${prop}" = $${valueIndex}::jsonb`;
1763
+ return `${this.qualifiedColumn(prop, tableAlias)} = $${valueIndex}::jsonb`;
1263
1764
  }
1264
- return `"${prop}" = $${valueIndex}`;
1765
+ return `${this.qualifiedColumn(prop, tableAlias)} = $${valueIndex}`;
1265
1766
  case ComparisonOperator.NotEquals:
1266
1767
  if (Is.object(comparator.value) || Is.array(comparator.value)) {
1267
- return `"${prop}" != $${valueIndex}::jsonb`;
1768
+ return `${this.qualifiedColumn(prop, tableAlias)} != $${valueIndex}::jsonb`;
1268
1769
  }
1269
- return `"${prop}" <> $${valueIndex}`;
1770
+ return `${this.qualifiedColumn(prop, tableAlias)} <> $${valueIndex}`;
1270
1771
  case ComparisonOperator.GreaterThan:
1271
- return `"${prop}" > $${valueIndex}`;
1772
+ return `${this.qualifiedColumn(prop, tableAlias)} > $${valueIndex}`;
1272
1773
  case ComparisonOperator.LessThan:
1273
- return `"${prop}" < $${valueIndex}`;
1774
+ return `${this.qualifiedColumn(prop, tableAlias)} < $${valueIndex}`;
1274
1775
  case ComparisonOperator.GreaterThanOrEqual:
1275
- return `"${prop}" >= $${valueIndex}`;
1776
+ return `${this.qualifiedColumn(prop, tableAlias)} >= $${valueIndex}`;
1276
1777
  case ComparisonOperator.LessThanOrEqual:
1277
- return `"${prop}" <= $${valueIndex}`;
1778
+ return `${this.qualifiedColumn(prop, tableAlias)} <= $${valueIndex}`;
1278
1779
  case ComparisonOperator.Includes: {
1279
1780
  if (type === EntitySchemaPropertyType.String) {
1280
- return `"${prop}" ILIKE '%' || $${valueIndex} || '%'`;
1781
+ return `${this.qualifiedColumn(prop, tableAlias)} LIKE '%' || $${valueIndex} || '%'`;
1281
1782
  }
1282
1783
  if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
1283
- return `EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
1784
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(prop, tableAlias)}) elem WHERE elem @> $${valueIndex}::jsonb)`;
1284
1785
  }
1285
1786
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
1286
1787
  comparison: comparator.comparison,
@@ -1289,10 +1790,21 @@ export class PostgreSqlEntityStorageConnector {
1289
1790
  }
1290
1791
  case ComparisonOperator.NotIncludes: {
1291
1792
  if (type === EntitySchemaPropertyType.String) {
1292
- return `"${prop}" NOT ILIKE '%' || $${valueIndex} || '%'`;
1793
+ return `${this.qualifiedColumn(prop, tableAlias)} NOT LIKE '%' || $${valueIndex} || '%'`;
1293
1794
  }
1294
1795
  if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
1295
- return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
1796
+ return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(prop, tableAlias)}) elem WHERE elem @> $${valueIndex}::jsonb)`;
1797
+ }
1798
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
1799
+ comparison: comparator.comparison,
1800
+ type
1801
+ });
1802
+ }
1803
+ case ComparisonOperator.StartsWith: {
1804
+ if (type === EntitySchemaPropertyType.String) {
1805
+ values.pop();
1806
+ values.push(`${this.escapeLike(String(comparator.value))}%`);
1807
+ return `${this.qualifiedColumn(prop, tableAlias)} LIKE $${valueIndex}`;
1296
1808
  }
1297
1809
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
1298
1810
  comparison: comparator.comparison,
@@ -1305,6 +1817,15 @@ export class PostgreSqlEntityStorageConnector {
1305
1817
  });
1306
1818
  }
1307
1819
  }
1820
+ /**
1821
+ * Escape the LIKE wildcard characters in a value so they match literally.
1822
+ * @param value The value to escape.
1823
+ * @returns The escaped value.
1824
+ * @internal
1825
+ */
1826
+ escapeLike(value) {
1827
+ return value.replace(/[\\%_]/g, "\\$&");
1828
+ }
1308
1829
  /**
1309
1830
  * Format a value to insert into DB.
1310
1831
  * @param value The value to format.
@@ -1390,6 +1911,7 @@ export class PostgreSqlEntityStorageConnector {
1390
1911
  props.unshift({
1391
1912
  property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
1392
1913
  type: EntitySchemaPropertyType.String,
1914
+ maxLength: PostgreSqlEntityStorageConnector._PARTITION_KEY_MAX_LENGTH,
1393
1915
  optional: false,
1394
1916
  isPrimary: true
1395
1917
  });