@twin.org/entity-storage-connector-postgresql 0.10.0 → 0.10.1-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,13 +211,18 @@ 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
  }
222
+ const indexGroups = EntitySchemaHelper.getIndexGroups(this._entitySchema);
223
+ for (const indexProperties of Object.values(indexGroups)) {
224
+ await this.ensureCompositeIndex(dbConnection, indexes, indexProperties);
225
+ }
210
226
  }
211
227
  catch (error) {
212
228
  await nodeLogging?.log({
@@ -644,7 +660,7 @@ export class PostgreSqlEntityStorageConnector {
644
660
  * @returns The connector implementation version.
645
661
  */
646
662
  connectorVersion() {
647
- return 0;
663
+ return 1;
648
664
  }
649
665
  /**
650
666
  * Get all the distinct partition context ids from the storage.
@@ -707,17 +723,20 @@ export class PostgreSqlEntityStorageConnector {
707
723
  });
708
724
  }
709
725
  /**
710
- * 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.
711
727
  * @param targetConnector The connector pointing to the migration table.
712
728
  * @param options The optional migration options.
713
729
  * @param loggingComponentType The node logging component type.
714
730
  * @returns A connector pointing to the final (renamed) table.
715
731
  */
716
732
  async finalizeMigration(targetConnector, options, loggingComponentType) {
717
- // Teardown the existing table with the original name to free up the name for the new table
718
- await this.teardown(loggingComponentType);
719
- const dbConnection = await targetConnector.getClient();
720
- 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
+ });
721
740
  const finalConnector = new PostgreSqlEntityStorageConnector({
722
741
  entitySchema: targetConnector._entitySchemaName,
723
742
  config: this._config,
@@ -874,6 +893,37 @@ export class PostgreSqlEntityStorageConnector {
874
893
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "queryFailed", { sql }, err);
875
894
  }
876
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
+ }
877
927
  /**
878
928
  * Count all the entities which match the conditions.
879
929
  * @param conditions The optional conditions to match for the entities.
@@ -898,6 +948,399 @@ export class PostgreSqlEntityStorageConnector {
898
948
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "countFailed", { sql: queryStr }, err);
899
949
  }
900
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
+ }
901
1344
  /**
902
1345
  * Check if the database exists.
903
1346
  * @param adminClient The server-level connection to use for the check.
@@ -929,64 +1372,123 @@ export class PostgreSqlEntityStorageConnector {
929
1372
  }
930
1373
  }
931
1374
  /**
932
- * 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.
933
1378
  * @param dbConnection The connection to query with.
1379
+ * @param indexes The indexes already on the table, keyed by index name.
934
1380
  * @param prop The indexed property.
935
1381
  * @param nodeLogging Optional logging component.
936
1382
  * @internal
937
1383
  */
938
- async ensureIndex(dbConnection, prop, nodeLogging) {
1384
+ async ensureIndex(dbConnection, indexes, prop, nodeLogging) {
939
1385
  const columnName = String(prop.property);
940
1386
  const indexName = IndexHelper.generateName(this._config.tableName, columnName);
941
- const indexRows = await dbConnection.unsafe(`SELECT i.relname AS "indexName", ix.indisunique AS "isUnique", ix.indnkeyatts AS "keyColumnCount"
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}")`);
1395
+ }
1396
+ // TODO: remove the legacy index handling once every installation has bootstrapped on a release that contains it
1397
+ const legacyName = IndexHelper.generateLegacyName(this._config.tableName, columnName, IndexHelper.DEFAULT_MAX_IDENTIFIER_LENGTH);
1398
+ const legacyIndex = indexes[legacyName];
1399
+ // The connector's own legacy indexes were always non-unique and single-column, anything else is an operator's
1400
+ if (!Is.empty(legacyIndex) && legacyIndex.nonUnique && legacyIndex.columns.length === 1) {
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
+ });
1413
+ }
1414
+ }
1415
+ /**
1416
+ * Ensure the composite index for a schema index group exists.
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.
1419
+ * @param dbConnection The connection to query with.
1420
+ * @param indexes The indexes already on the table, keyed by index name.
1421
+ * @param indexProperties The properties in the group, ordered by their index position.
1422
+ * @internal
1423
+ */
1424
+ async ensureCompositeIndex(dbConnection, indexes, indexProperties) {
1425
+ const indexName = IndexHelper.generateCompositeName(this._config.tableName, indexProperties);
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(", ");
1442
+ await dbConnection.unsafe(`CREATE INDEX IF NOT EXISTS "${indexName}" ON "${this._config.tableName}" (${indexCols})`);
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"
942
1452
  FROM pg_index ix
943
1453
  JOIN pg_class t ON t.oid = ix.indrelid
944
1454
  JOIN pg_namespace n ON n.oid = t.relnamespace
945
- JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ix.indkey[0]
946
1455
  JOIN pg_class i ON i.oid = ix.indexrelid
947
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]
948
1459
  WHERE n.nspname = 'public'
949
1460
  AND t.relname = $1
950
- AND a.attname = $2
951
1461
  AND ix.indisvalid
952
1462
  AND ix.indisready
953
1463
  AND ix.indpred IS NULL
954
- AND am.amname = 'btree'`, [this._config.tableName, columnName]);
955
- const indexNames = indexRows.map(row => ObjectHelper.propertyGet(row, "indexName"));
956
- if (!Is.arrayValue(indexNames)) {
957
- await dbConnection.unsafe(`CREATE INDEX IF NOT EXISTS "${indexName}" ON "${this._config.tableName}" ("${columnName}")`);
958
- return;
959
- }
960
- // TODO: remove the legacy index handling once every installation has bootstrapped on a release that contains it
961
- const legacyName = IndexHelper.generateLegacyName(this._config.tableName, columnName, IndexHelper.DEFAULT_MAX_IDENTIFIER_LENGTH);
962
- if (!indexNames.includes(legacyName)) {
963
- return;
964
- }
965
- // The connector's own legacy indexes were always non-unique and single-column, anything else is an operator's
966
- const legacyRow = indexRows.find(row => ObjectHelper.propertyGet(row, "indexName") === legacyName);
967
- if (!Is.object(legacyRow) ||
968
- ObjectHelper.propertyGet(legacyRow, "isUnique") !== false ||
969
- Coerce.integer(ObjectHelper.propertyGet(legacyRow, "keyColumnCount")) !== 1) {
970
- return;
971
- }
972
- const hasCurrent = indexNames.includes(indexName);
973
- if (hasCurrent) {
974
- await dbConnection.unsafe(`DROP INDEX "${legacyName}"`);
975
- }
976
- else {
977
- await dbConnection.unsafe(`ALTER INDEX "${legacyName}" RENAME TO "${indexName}"`);
978
- }
979
- await nodeLogging?.log({
980
- level: "info",
981
- source: PostgreSqlEntityStorageConnector.CLASS_NAME,
982
- ts: Date.now(),
983
- message: hasCurrent ? "legacyIndexDropped" : "legacyIndexRenamed",
984
- data: {
985
- tableName: this._config.tableName,
986
- indexName: legacyName,
987
- newIndexName: indexName
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;
988
1479
  }
989
- });
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));
990
1492
  }
991
1493
  /**
992
1494
  * Check if the table exists.
@@ -1085,10 +1587,14 @@ export class PostgreSqlEntityStorageConnector {
1085
1587
  * Build where clause arrays for a query, combining partition key and optional conditions.
1086
1588
  * @param conditions The optional entity conditions to include.
1087
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.
1088
1594
  * @returns The where clauses and bound values.
1089
1595
  * @internal
1090
1596
  */
1091
- buildWhereClause(conditions, partitionKey) {
1597
+ buildWhereClause(conditions, partitionKey, tableAlias, startIndex = 1) {
1092
1598
  const whereClauses = [];
1093
1599
  const values = [];
1094
1600
  const finalConditions = {
@@ -1103,7 +1609,7 @@ export class PostgreSqlEntityStorageConnector {
1103
1609
  if (!Is.empty(conditions)) {
1104
1610
  finalConditions.conditions.push(conditions);
1105
1611
  }
1106
- this.buildQueryParameters("", finalConditions, whereClauses, values, 1);
1612
+ this.buildQueryParameters("", finalConditions, whereClauses, values, startIndex, tableAlias);
1107
1613
  return { whereClauses, values };
1108
1614
  }
1109
1615
  /**
@@ -1113,9 +1619,10 @@ export class PostgreSqlEntityStorageConnector {
1113
1619
  * @param whereClauses The where clauses to use in the query.
1114
1620
  * @param values The values to use in the query.
1115
1621
  * @param valueIndex The current value index.
1622
+ * @param tableAlias The optional table alias to qualify the columns with.
1116
1623
  * @internal
1117
1624
  */
1118
- buildQueryParameters(objectPath, condition, whereClauses, values, valueIndex) {
1625
+ buildQueryParameters(objectPath, condition, whereClauses, values, valueIndex, tableAlias) {
1119
1626
  if (Is.undefined(condition)) {
1120
1627
  return;
1121
1628
  }
@@ -1126,7 +1633,7 @@ export class PostgreSqlEntityStorageConnector {
1126
1633
  const joinConditions = condition.conditions.map(c => {
1127
1634
  const subWhereClauses = [];
1128
1635
  const subValues = [];
1129
- this.buildQueryParameters(objectPath, c, subWhereClauses, subValues, valueIndex);
1636
+ this.buildQueryParameters(objectPath, c, subWhereClauses, subValues, valueIndex, tableAlias);
1130
1637
  values.push(...subValues);
1131
1638
  valueIndex += subValues.length;
1132
1639
  return subWhereClauses.join(" AND ");
@@ -1139,7 +1646,7 @@ export class PostgreSqlEntityStorageConnector {
1139
1646
  return;
1140
1647
  }
1141
1648
  const schemaProp = this._entitySchema.properties?.find(p => p.property === condition.property);
1142
- const comparison = this.mapComparisonOperator(objectPath, condition, schemaProp?.type, values, valueIndex);
1649
+ const comparison = this.mapComparisonOperator(objectPath, condition, schemaProp?.type, values, valueIndex, tableAlias);
1143
1650
  whereClauses.push(comparison);
1144
1651
  }
1145
1652
  /**
@@ -1149,11 +1656,12 @@ export class PostgreSqlEntityStorageConnector {
1149
1656
  * @param type The type of the property.
1150
1657
  * @param values The values to use in the query.
1151
1658
  * @param valueIndex The current value index.
1659
+ * @param tableAlias The optional table alias to qualify the columns with.
1152
1660
  * @returns The comparison expression.
1153
1661
  * @throws GeneralError if the comparison operator is not supported.
1154
1662
  * @internal
1155
1663
  */
1156
- mapComparisonOperator(objectPath, comparator, type, values, valueIndex) {
1664
+ mapComparisonOperator(objectPath, comparator, type, values, valueIndex, tableAlias) {
1157
1665
  let prop = objectPath;
1158
1666
  if (prop.length > 0) {
1159
1667
  prop += ".";
@@ -1168,7 +1676,7 @@ export class PostgreSqlEntityStorageConnector {
1168
1676
  }
1169
1677
  values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
1170
1678
  const placeholders = inValues.map((value, index) => `$${valueIndex + index}`).join(", ");
1171
- return `"${prop}" IN (${placeholders})`;
1679
+ return `${this.qualifiedColumn(prop, tableAlias)} IN (${placeholders})`;
1172
1680
  }
1173
1681
  // null/undefined must use IS NULL / IS NOT NULL - never a parameterised placeholder.
1174
1682
  // Passing undefined through propertyToDbValue() coerces it to NaN for number fields
@@ -1184,10 +1692,10 @@ export class PostgreSqlEntityStorageConnector {
1184
1692
  const jsonPath = nestedParts
1185
1693
  .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
1186
1694
  .join("");
1187
- const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
1695
+ const jsonTextExpr = `(${this.qualifiedColumn(rootProp, tableAlias)}::jsonb ${jsonPath})`;
1188
1696
  return `${jsonTextExpr} ${nullCheck}`;
1189
1697
  }
1190
- return `"${prop}" ${nullCheck}`;
1698
+ return `${this.qualifiedColumn(prop, tableAlias)} ${nullCheck}`;
1191
1699
  }
1192
1700
  }
1193
1701
  const dbValue = this.propertyToDbValue(comparator.value, type);
@@ -1200,29 +1708,40 @@ export class PostgreSqlEntityStorageConnector {
1200
1708
  const jsonPath = nestedParts
1201
1709
  .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
1202
1710
  .join("");
1203
- const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
1711
+ const jsonTextExpr = `(${this.qualifiedColumn(rootProp, tableAlias)}::jsonb ${jsonPath})`;
1204
1712
  switch (comparator.comparison) {
1205
1713
  case ComparisonOperator.Includes: {
1206
1714
  values.pop();
1207
- values.push(`%${String(comparator.value).toLowerCase()}%`);
1715
+ values.push(`%${String(comparator.value)}%`);
1208
1716
  if (isArray) {
1209
1717
  const elemPath = nestedParts
1210
1718
  .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
1211
1719
  .join("");
1212
- 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})`;
1213
1721
  }
1214
- return `LOWER(${jsonTextExpr}) ILIKE $${valueIndex}`;
1722
+ return `${jsonTextExpr} LIKE $${valueIndex}`;
1215
1723
  }
1216
1724
  case ComparisonOperator.NotIncludes: {
1217
1725
  values.pop();
1218
- 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))}%`);
1219
1738
  if (isArray) {
1220
1739
  const elemPath = nestedParts
1221
1740
  .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
1222
1741
  .join("");
1223
- 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})`;
1224
1743
  }
1225
- return `LOWER(${jsonTextExpr}) NOT ILIKE $${valueIndex}`;
1744
+ return `${jsonTextExpr} LIKE $${valueIndex}`;
1226
1745
  }
1227
1746
  case ComparisonOperator.NotEquals:
1228
1747
  return `${jsonTextExpr} <> $${valueIndex}`;
@@ -1241,28 +1760,28 @@ export class PostgreSqlEntityStorageConnector {
1241
1760
  switch (comparator.comparison) {
1242
1761
  case ComparisonOperator.Equals:
1243
1762
  if (Is.object(comparator.value) || Is.array(comparator.value)) {
1244
- return `"${prop}" = $${valueIndex}::jsonb`;
1763
+ return `${this.qualifiedColumn(prop, tableAlias)} = $${valueIndex}::jsonb`;
1245
1764
  }
1246
- return `"${prop}" = $${valueIndex}`;
1765
+ return `${this.qualifiedColumn(prop, tableAlias)} = $${valueIndex}`;
1247
1766
  case ComparisonOperator.NotEquals:
1248
1767
  if (Is.object(comparator.value) || Is.array(comparator.value)) {
1249
- return `"${prop}" != $${valueIndex}::jsonb`;
1768
+ return `${this.qualifiedColumn(prop, tableAlias)} != $${valueIndex}::jsonb`;
1250
1769
  }
1251
- return `"${prop}" <> $${valueIndex}`;
1770
+ return `${this.qualifiedColumn(prop, tableAlias)} <> $${valueIndex}`;
1252
1771
  case ComparisonOperator.GreaterThan:
1253
- return `"${prop}" > $${valueIndex}`;
1772
+ return `${this.qualifiedColumn(prop, tableAlias)} > $${valueIndex}`;
1254
1773
  case ComparisonOperator.LessThan:
1255
- return `"${prop}" < $${valueIndex}`;
1774
+ return `${this.qualifiedColumn(prop, tableAlias)} < $${valueIndex}`;
1256
1775
  case ComparisonOperator.GreaterThanOrEqual:
1257
- return `"${prop}" >= $${valueIndex}`;
1776
+ return `${this.qualifiedColumn(prop, tableAlias)} >= $${valueIndex}`;
1258
1777
  case ComparisonOperator.LessThanOrEqual:
1259
- return `"${prop}" <= $${valueIndex}`;
1778
+ return `${this.qualifiedColumn(prop, tableAlias)} <= $${valueIndex}`;
1260
1779
  case ComparisonOperator.Includes: {
1261
1780
  if (type === EntitySchemaPropertyType.String) {
1262
- return `"${prop}" ILIKE '%' || $${valueIndex} || '%'`;
1781
+ return `${this.qualifiedColumn(prop, tableAlias)} LIKE '%' || $${valueIndex} || '%'`;
1263
1782
  }
1264
1783
  if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
1265
- 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)`;
1266
1785
  }
1267
1786
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
1268
1787
  comparison: comparator.comparison,
@@ -1271,10 +1790,21 @@ export class PostgreSqlEntityStorageConnector {
1271
1790
  }
1272
1791
  case ComparisonOperator.NotIncludes: {
1273
1792
  if (type === EntitySchemaPropertyType.String) {
1274
- return `"${prop}" NOT ILIKE '%' || $${valueIndex} || '%'`;
1793
+ return `${this.qualifiedColumn(prop, tableAlias)} NOT LIKE '%' || $${valueIndex} || '%'`;
1275
1794
  }
1276
1795
  if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
1277
- 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}`;
1278
1808
  }
1279
1809
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
1280
1810
  comparison: comparator.comparison,
@@ -1287,6 +1817,15 @@ export class PostgreSqlEntityStorageConnector {
1287
1817
  });
1288
1818
  }
1289
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
+ }
1290
1829
  /**
1291
1830
  * Format a value to insert into DB.
1292
1831
  * @param value The value to format.
@@ -1372,6 +1911,7 @@ export class PostgreSqlEntityStorageConnector {
1372
1911
  props.unshift({
1373
1912
  property: PostgreSqlEntityStorageConnector._PARTITION_KEY,
1374
1913
  type: EntitySchemaPropertyType.String,
1914
+ maxLength: PostgreSqlEntityStorageConnector._PARTITION_KEY_MAX_LENGTH,
1375
1915
  optional: false,
1376
1916
  isPrimary: true
1377
1917
  });