@twin.org/entity-storage-connector-postgresql 0.10.1-next.4 → 0.10.1-next.6

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.
@@ -40,6 +40,11 @@ export class PostgreSqlEntityStorageConnector {
40
40
  * @internal
41
41
  */
42
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";
43
48
  /**
44
49
  * PostgreSQL's maximum identifier length in characters; longer names are silently truncated.
45
50
  * @internal
@@ -885,6 +890,37 @@ export class PostgreSqlEntityStorageConnector {
885
890
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "queryFailed", { sql }, err);
886
891
  }
887
892
  }
893
+ /**
894
+ * Find all the entities which match the conditions, attaching to each one the entities from a
895
+ * second storage connector whose join property matches. The join behaves like a left join by
896
+ * default, a primary entity with no matches is still returned with an empty joined list, unless
897
+ * joinRequired asks for an inner join and those entities are left out altogether. Both connectors
898
+ * must be PostgreSQL connectors reading from the same database so the work can be done in a
899
+ * single statement.
900
+ * @param joinConnector The connector holding the entities to join to.
901
+ * @param joinOptions The properties to join on, the conditions, sort order, projection and
902
+ * paging for the primary entities, the optional grouping and group conditions, and the optional
903
+ * conditions, sort order and projection for the joined entities.
904
+ * @returns All the entities for the storage matching the conditions with their joined entities,
905
+ * and a cursor which can be used to request more entities.
906
+ * @throws GeneralError if the join connector does not read from the same server and database.
907
+ */
908
+ async queryJoin(joinConnector, joinOptions) {
909
+ Guards.object(PostgreSqlEntityStorageConnector.CLASS_NAME, "joinConnector", joinConnector);
910
+ // The join runs as one statement against this connector's connection, so the other side has
911
+ // to be a PostgreSQL connector reading from the same server and database.
912
+ const typedJoinConnector = joinConnector;
913
+ if (joinConnector.className?.() !== PostgreSqlEntityStorageConnector.CLASS_NAME ||
914
+ typedJoinConnector._config?.host !== this._config.host ||
915
+ typedJoinConnector._config?.port !== this._config.port ||
916
+ typedJoinConnector._config?.database !== this._config.database) {
917
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "joinConnectorMismatch", {
918
+ database: this._config.database
919
+ });
920
+ }
921
+ EntityStorageHelper.validateJoinOptions(this._entitySchema, typedJoinConnector._entitySchema, joinOptions);
922
+ return this.queryJoinPage(typedJoinConnector, joinOptions, joinOptions.groupProperty);
923
+ }
888
924
  /**
889
925
  * Count all the entities which match the conditions.
890
926
  * @param conditions The optional conditions to match for the entities.
@@ -909,6 +945,399 @@ export class PostgreSqlEntityStorageConnector {
909
945
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "countFailed", { sql: queryStr }, err);
910
946
  }
911
947
  }
948
+ /**
949
+ * Read a page of primary entities and their joined entities in a single statement. The page of
950
+ * primary rows is selected in a derived table so the limit and the cursor apply to the primary
951
+ * entities rather than to the rows the join multiplies them into. When grouping, the derived
952
+ * table keeps only the first row of each group in the sort order, which makes one row stand for
953
+ * the whole group and lets the same key set cursor step past every row the group holds.
954
+ * @param joinConnector The connector holding the entities to join to.
955
+ * @param joinOptions The join configuration.
956
+ * @param groupProperty The optional property to group the primary entities by.
957
+ * @returns The entities with their joined entities, and the next page cursor.
958
+ * @internal
959
+ */
960
+ async queryJoinPage(joinConnector, joinOptions, groupProperty) {
961
+ const returnSize = joinOptions.limit ?? PostgreSqlEntityStorageConnector._DEFAULT_LIMIT;
962
+ const pkPropName = String(this._primaryKeyProperty.property);
963
+ const joinColumn = String(joinOptions.property);
964
+ const joinedPrimaryKey = String(joinConnector._primaryKeyProperty.property);
965
+ const normalizedOptions = EntityStorageHelper.normalizeJoinOptions(joinOptions);
966
+ const keySetValues = EntityStorageHelper.decodeCursor(normalizedOptions, joinOptions.cursor);
967
+ let sql = "";
968
+ try {
969
+ const keySetCols = this.buildKeySetColumns(joinOptions.sortProperties);
970
+ // The key set columns, the join column and the group column are needed to page, to join
971
+ // and to re-attach the members of a group, so they are read even when the caller did not
972
+ // ask for them, then removed from the entities.
973
+ const primary = this.buildColumnSelection(this._entitySchema, joinOptions.properties, keySetCols
974
+ .map(c => c.prop)
975
+ .concat(joinColumn)
976
+ .concat(Is.empty(groupProperty) ? [] : [String(groupProperty)]));
977
+ const joined = this.buildColumnSelection(joinConnector._entitySchema, joinOptions.joinProperties, [joinedPrimaryKey]);
978
+ const partitionKey = await this.resolvePartitionKey();
979
+ const values = [];
980
+ // PostgreSQL numbers its placeholders, so every clause is built in the order it appears
981
+ // in the statement and its values are appended as it goes.
982
+ const where = this.buildWhereClause(joinOptions.conditions, partitionKey, "t", values.length + 1);
983
+ values.push(...where.values);
984
+ const whereClauses = [...where.whereClauses];
985
+ if (!Is.empty(groupProperty)) {
986
+ whereClauses.push(`t."${String(groupProperty)}" IS NOT NULL`);
987
+ }
988
+ const narrowing = await this.buildNarrowingClauses(joinConnector, joinOptions, groupProperty, partitionKey, "t", values.length + 1);
989
+ whereClauses.push(...narrowing.clauses);
990
+ values.push(...narrowing.values);
991
+ const columnList = primary.columns.map(c => `"${c}"`).join(", ");
992
+ const pageOrderBy = keySetCols.map(c => `"${c.prop}" ${c.asc ? "ASC" : "DESC"}`).join(", ");
993
+ const keySet = this.buildKeySetClause(keySetCols, keySetValues, values.length + 1);
994
+ values.push(...keySet.values);
995
+ let pageSql;
996
+ if (Is.empty(groupProperty)) {
997
+ pageSql = `SELECT ${columnList} FROM "${this._config.tableName}" AS t WHERE ${[...whereClauses, ...keySet.clauses].join(" AND ")} ORDER BY ${pageOrderBy} LIMIT ${returnSize + 1}`;
998
+ }
999
+ else {
1000
+ // Ranking inside each group and keeping the first row collapses the group to the one
1001
+ // row the result stands on, so the ordering, the projection and the cursor all work
1002
+ // on ordinary rows rather than on a distinct list of group values.
1003
+ 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 ")}`;
1004
+ const rankedWhere = [
1005
+ `ranked."${PostgreSqlEntityStorageConnector._GROUP_RANK_COLUMN}" = 1`,
1006
+ ...keySet.clauses
1007
+ ];
1008
+ pageSql = `SELECT ${columnList} FROM (${rankedSql}) AS ranked WHERE ${rankedWhere.join(" AND ")} ORDER BY ${pageOrderBy} LIMIT ${returnSize + 1}`;
1009
+ }
1010
+ // A group stands on one row for ordering and paging, but its joined list has to hold the
1011
+ // matches of every entity in the group, so the other members are re-attached to the page
1012
+ // and the join hangs off them. The same joined entity reached through more than one
1013
+ // member is collapsed when the rows are collected.
1014
+ let fromClause = `(${pageSql}) AS p`;
1015
+ let joinFromAlias = "p";
1016
+ if (!Is.empty(groupProperty)) {
1017
+ const members = this.buildWhereClause(joinOptions.conditions, partitionKey, "m", values.length + 1);
1018
+ values.push(...members.values);
1019
+ const memberNarrowing = await this.buildNarrowingClauses(joinConnector, joinOptions, undefined, partitionKey, "m", values.length + 1);
1020
+ values.push(...memberNarrowing.values);
1021
+ fromClause += ` LEFT JOIN "${this._config.tableName}" AS m ON ${[
1022
+ `m."${String(groupProperty)}" = p."${String(groupProperty)}"`,
1023
+ ...members.whereClauses,
1024
+ ...memberNarrowing.clauses
1025
+ ].join(" AND ")}`;
1026
+ joinFromAlias = "m";
1027
+ }
1028
+ const join = await joinConnector.buildJoinClause(String(joinOptions.joinProperty), joinOptions.joinConditions, "j", joinFromAlias, joinColumn, values.length + 1);
1029
+ values.push(...join.values);
1030
+ // PostgreSQL returns a flat row, so each column is aliased by position and mapped back
1031
+ // afterwards rather than relying on the table it came from.
1032
+ const selectClause = primary.columns
1033
+ .map((c, i) => `p."${c}" AS "p${i}"`)
1034
+ .concat(joined.columns.map((c, i) => `j."${c}" AS "j${i}"`))
1035
+ .join(", ");
1036
+ const outerOrderBy = keySetCols
1037
+ .map(c => `p."${c.prop}" ${c.asc ? "ASC" : "DESC"}`)
1038
+ .concat(this.buildJoinOrderBy(joinOptions, "j"))
1039
+ .join(", ");
1040
+ sql = `SELECT ${selectClause} FROM ${fromClause} LEFT JOIN "${joinConnector._config.tableName}" AS j ON ${join.clause} ORDER BY ${outerOrderBy}`;
1041
+ const dbConnection = await this.getClient();
1042
+ const rows = await dbConnection.unsafe(sql, values);
1043
+ const groups = this.collectJoinRows(rows, primary.columns, joined.columns, pkPropName, joinedPrimaryKey);
1044
+ const hasMore = groups.length > returnSize;
1045
+ const pageGroups = hasMore ? groups.slice(0, returnSize) : groups;
1046
+ const entities = [];
1047
+ for (const group of pageGroups) {
1048
+ const entity = this.prepareJoinEntity(group.key, this._entitySchema, primary.internal);
1049
+ entities.push({
1050
+ ...entity,
1051
+ joined: group.joined.map(j => this.prepareJoinEntity(j, joinConnector._entitySchema, joined.internal))
1052
+ });
1053
+ }
1054
+ let nextCursor;
1055
+ if (hasMore && pageGroups.length > 0) {
1056
+ const lastRow = pageGroups[pageGroups.length - 1].key;
1057
+ nextCursor = EntityStorageHelper.encodeCursor(normalizedOptions, keySetCols.map(c => lastRow[c.prop]));
1058
+ }
1059
+ return { entities, cursor: nextCursor };
1060
+ }
1061
+ catch (err) {
1062
+ throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "queryJoinFailed", { sql }, err);
1063
+ }
1064
+ }
1065
+ /**
1066
+ * Build the clauses which narrow the rows a page is built from, beyond the plain conditions.
1067
+ * An inner join requires the row to have at least one joined entity, and a group condition
1068
+ * requires the group the row belongs to to hold an entity which matches it.
1069
+ * @param joinConnector The connector holding the entities to join to.
1070
+ * @param joinOptions The join configuration.
1071
+ * @param groupProperty The optional property the entities are grouped by.
1072
+ * @param partitionKey The partition key of this connector.
1073
+ * @param alias The alias of the row being narrowed.
1074
+ * @param startIndex The number the first placeholder takes.
1075
+ * @returns The clauses and their bound values.
1076
+ * @internal
1077
+ */
1078
+ async buildNarrowingClauses(joinConnector, joinOptions, groupProperty, partitionKey, alias, startIndex) {
1079
+ const clauses = [];
1080
+ const values = [];
1081
+ if (joinOptions.joinRequired ?? false) {
1082
+ const exists = await joinConnector.buildJoinExistsClause(String(joinOptions.joinProperty), joinOptions.joinConditions, "jx", alias, String(joinOptions.property), startIndex + values.length);
1083
+ clauses.push(exists.clause);
1084
+ values.push(...exists.values);
1085
+ }
1086
+ if (!Is.empty(groupProperty) && Is.arrayValue(joinOptions.groupConditions)) {
1087
+ const groupColumn = String(groupProperty);
1088
+ for (let i = 0; i < joinOptions.groupConditions.length; i++) {
1089
+ const memberAlias = `gx${i}`;
1090
+ const group = this.buildWhereClause(joinOptions.groupConditions[i], partitionKey, memberAlias, startIndex + values.length);
1091
+ clauses.push(`EXISTS (SELECT 1 FROM "${this._config.tableName}" AS ${memberAlias} WHERE ${memberAlias}."${groupColumn}" = ${alias}."${groupColumn}" AND ${group.whereClauses.join(" AND ")})`);
1092
+ values.push(...group.values);
1093
+ }
1094
+ }
1095
+ return { clauses, values };
1096
+ }
1097
+ /**
1098
+ * Build the clause which requires a primary entity to have at least one joined entity. Called
1099
+ * on the connector holding the joined entities so its partition key and property types apply.
1100
+ * @param joinProperty The column on this connector's table to join to.
1101
+ * @param joinConditions The optional conditions to match for the joined entities.
1102
+ * @param alias The alias of the joined table inside the clause.
1103
+ * @param primaryAlias The alias of the primary entity being narrowed.
1104
+ * @param primaryColumn The column on the primary entity to join from.
1105
+ * @param startIndex The number the first placeholder takes.
1106
+ * @returns The clause and its bound values.
1107
+ * @internal
1108
+ */
1109
+ async buildJoinExistsClause(joinProperty, joinConditions, alias, primaryAlias, primaryColumn, startIndex) {
1110
+ const partitionKey = await this.resolvePartitionKey();
1111
+ const where = this.buildWhereClause(joinConditions, partitionKey, alias, startIndex);
1112
+ return {
1113
+ clause: `EXISTS (SELECT 1 FROM "${this._config.tableName}" AS ${alias} WHERE ${alias}."${joinProperty}" = ${primaryAlias}."${primaryColumn}" AND ${where.whereClauses.join(" AND ")})`,
1114
+ values: where.values
1115
+ };
1116
+ }
1117
+ /**
1118
+ * Build the ON clause which attaches the joined table, including its own partition key and any
1119
+ * conditions the caller supplied for the joined entities. Called on the connector holding the
1120
+ * joined entities so the partition key and the property types come from its own schema and
1121
+ * configuration.
1122
+ * @param joinProperty The column on this connector's table to join to.
1123
+ * @param joinConditions The optional conditions to match for the joined entities.
1124
+ * @param alias The alias of the joined table.
1125
+ * @param primaryAlias The alias of the table holding the primary entities.
1126
+ * @param primaryColumn The column on the primary table to join from.
1127
+ * @param startIndex The number the first placeholder takes.
1128
+ * @returns The ON clause and its bound values.
1129
+ * @internal
1130
+ */
1131
+ async buildJoinClause(joinProperty, joinConditions, alias, primaryAlias, primaryColumn, startIndex) {
1132
+ const partitionKey = await this.resolvePartitionKey();
1133
+ const where = this.buildWhereClause(joinConditions, partitionKey, alias, startIndex);
1134
+ return {
1135
+ clause: [
1136
+ `${alias}."${joinProperty}" = ${primaryAlias}."${primaryColumn}"`,
1137
+ ...where.whereClauses
1138
+ ].join(" AND "),
1139
+ values: where.values
1140
+ };
1141
+ }
1142
+ /**
1143
+ * Build the ORDER BY fragments which order the joined entities within each primary entity.
1144
+ * @param joinOptions The join configuration.
1145
+ * @param alias The alias of the joined table.
1146
+ * @returns The order by fragments, empty when no sort order was requested.
1147
+ * @internal
1148
+ */
1149
+ buildJoinOrderBy(joinOptions, alias) {
1150
+ return (joinOptions.joinSortProperties ?? []).map(s => `${alias}."${String(s.property)}" ${s.sortDirection === SortDirection.Ascending ? "ASC" : "DESC"}`);
1151
+ }
1152
+ /**
1153
+ * Build the ordered keySet columns used for the page order and the cursor, matching the
1154
+ * behaviour of query so both paginate the same way.
1155
+ * @param sortProperties The optional sort order.
1156
+ * @returns The ordered columns with their direction.
1157
+ * @internal
1158
+ */
1159
+ buildKeySetColumns(sortProperties) {
1160
+ const pkPropName = String(this._primaryKeyProperty.property);
1161
+ const keySetCols = [];
1162
+ for (const sortProperty of sortProperties ?? []) {
1163
+ keySetCols.push({
1164
+ prop: String(sortProperty.property),
1165
+ asc: sortProperty.sortDirection === SortDirection.Ascending
1166
+ });
1167
+ }
1168
+ if (!keySetCols.some(c => c.prop === pkPropName)) {
1169
+ keySetCols.push({ prop: pkPropName, asc: true });
1170
+ }
1171
+ return keySetCols;
1172
+ }
1173
+ /**
1174
+ * Build the keySet condition which continues the page from a previous cursor.
1175
+ * @param keySetCols The ordered keySet columns.
1176
+ * @param lastValues The key set values of the last entity of the previous page.
1177
+ * @param startIndex The number the first placeholder takes.
1178
+ * @returns The clauses and their bound values.
1179
+ * @internal
1180
+ */
1181
+ buildKeySetClause(keySetCols, lastValues, startIndex) {
1182
+ if (!Is.arrayValue(lastValues)) {
1183
+ return { clauses: [], values: [] };
1184
+ }
1185
+ const values = [];
1186
+ const orParts = [];
1187
+ for (let i = 0; i < keySetCols.length; i++) {
1188
+ const parts = [];
1189
+ for (let j = 0; j < i; j++) {
1190
+ values.push(lastValues[j]);
1191
+ parts.push(`"${keySetCols[j].prop}" = $${startIndex + values.length - 1}`);
1192
+ }
1193
+ const op = keySetCols[i].asc ? ">" : "<";
1194
+ values.push(lastValues[i]);
1195
+ parts.push(`"${keySetCols[i].prop}" ${op} $${startIndex + values.length - 1}`);
1196
+ orParts.push(parts.length === 1 ? parts[0] : `(${parts.join(" AND ")})`);
1197
+ }
1198
+ return { clauses: [`(${orParts.join(" OR ")})`], values };
1199
+ }
1200
+ /**
1201
+ * Work out which columns to read for one side of the join, honouring the caller's projection
1202
+ * but adding the columns the join itself needs.
1203
+ * @param schema The schema of the entities being read.
1204
+ * @param properties The optional projection requested by the caller.
1205
+ * @param required The columns the join needs regardless of the projection.
1206
+ * @returns The columns to read and the ones which were only added internally.
1207
+ * @internal
1208
+ */
1209
+ buildColumnSelection(schema, properties, required) {
1210
+ const columns = [];
1211
+ const internal = [];
1212
+ if (Is.arrayValue(properties)) {
1213
+ for (const prop of properties) {
1214
+ const column = String(prop);
1215
+ if (!columns.includes(column)) {
1216
+ columns.push(column);
1217
+ }
1218
+ }
1219
+ for (const column of required) {
1220
+ if (!columns.includes(column)) {
1221
+ columns.push(column);
1222
+ internal.push(column);
1223
+ }
1224
+ }
1225
+ }
1226
+ else {
1227
+ for (const prop of schema.properties ?? []) {
1228
+ const column = String(prop.property);
1229
+ if (!columns.includes(column)) {
1230
+ columns.push(column);
1231
+ }
1232
+ }
1233
+ }
1234
+ return { columns, internal };
1235
+ }
1236
+ /**
1237
+ * Collapse the flat rows returned by the join into one entry per primary entity, preserving the
1238
+ * order the database returned them in.
1239
+ * @param rows The rows from the join statement, whose columns are aliased by position.
1240
+ * @param primaryColumns The primary columns in the order they were aliased.
1241
+ * @param joinedColumns The joined columns in the order they were aliased.
1242
+ * @param identityColumn The column which identifies a primary entity.
1243
+ * @param joinedPrimaryKey The primary key column of the joined entities.
1244
+ * @returns One entry per primary entity with its joined entities.
1245
+ * @internal
1246
+ */
1247
+ collectJoinRows(rows, primaryColumns, joinedColumns, identityColumn, joinedPrimaryKey) {
1248
+ const groups = [];
1249
+ if (!Is.array(rows)) {
1250
+ return groups;
1251
+ }
1252
+ let current;
1253
+ let currentIdentity;
1254
+ let seenJoined = new Set();
1255
+ for (const row of rows) {
1256
+ const primaryRow = {};
1257
+ for (let i = 0; i < primaryColumns.length; i++) {
1258
+ primaryRow[primaryColumns[i]] = row[`p${i}`];
1259
+ }
1260
+ const identity = this.rowKey(primaryRow[identityColumn]);
1261
+ if (Is.undefined(current) || identity !== currentIdentity) {
1262
+ current = { key: primaryRow, joined: [] };
1263
+ currentIdentity = identity;
1264
+ seenJoined = new Set();
1265
+ groups.push(current);
1266
+ }
1267
+ const joinedRow = {};
1268
+ for (let i = 0; i < joinedColumns.length; i++) {
1269
+ joinedRow[joinedColumns[i]] = row[`j${i}`];
1270
+ }
1271
+ // A left join with no match produces a row whose joined columns are all null, and the
1272
+ // same joined entity appears more than once when several primary rows in a group share
1273
+ // the same join value.
1274
+ if (!Is.empty(joinedRow[joinedPrimaryKey])) {
1275
+ const joinedIdentity = this.rowKey(joinedRow[joinedPrimaryKey]);
1276
+ if (!seenJoined.has(joinedIdentity)) {
1277
+ seenJoined.add(joinedIdentity);
1278
+ current.joined.push(joinedRow);
1279
+ }
1280
+ }
1281
+ }
1282
+ return groups;
1283
+ }
1284
+ /**
1285
+ * Turn a raw column value into a key which can be compared between rows.
1286
+ * @param value The value read from the storage.
1287
+ * @returns The key for the value.
1288
+ * @internal
1289
+ */
1290
+ rowKey(value) {
1291
+ return Is.string(value) ? value : JSON.stringify(value);
1292
+ }
1293
+ /**
1294
+ * Apply to a row read by a join the same clean up a plain query applies to its entities, then
1295
+ * remove the columns which were only read to satisfy the join.
1296
+ * @param row The raw row read from the storage.
1297
+ * @param schema The schema of the entity.
1298
+ * @param internal The columns to remove.
1299
+ * @returns The entity.
1300
+ * @internal
1301
+ */
1302
+ prepareJoinEntity(row, schema, internal) {
1303
+ const prepared = { ...row };
1304
+ for (const prop of schema.properties ?? []) {
1305
+ const column = String(prop.property);
1306
+ if ((prop.type === EntitySchemaPropertyType.Object ||
1307
+ prop.type === EntitySchemaPropertyType.Array) &&
1308
+ Is.string(prepared[column])) {
1309
+ try {
1310
+ prepared[column] = JSON.parse(String(prepared[column]));
1311
+ }
1312
+ catch {
1313
+ // Text which is not JSON is left as it was read, matching what a plain query does.
1314
+ }
1315
+ }
1316
+ }
1317
+ return EntityStorageHelper.unPrepareEntity(prepared, [
1318
+ PostgreSqlEntityStorageConnector._PARTITION_KEY,
1319
+ ...internal
1320
+ ]);
1321
+ }
1322
+ /**
1323
+ * Get the partition key for the current context.
1324
+ * @returns The partition key, or undefined when the connector is not partitioned.
1325
+ * @internal
1326
+ */
1327
+ async resolvePartitionKey() {
1328
+ const contextIds = await ContextIdStore.getContextIds();
1329
+ return ContextIdHelper.combinedContextKey(contextIds, this._partitionContextIds);
1330
+ }
1331
+ /**
1332
+ * Qualify a column with a table alias, needed when the statement reads from more than one table.
1333
+ * @param column The column name.
1334
+ * @param tableAlias The optional table alias.
1335
+ * @returns The quoted column, prefixed with the alias when one was supplied.
1336
+ * @internal
1337
+ */
1338
+ qualifiedColumn(column, tableAlias) {
1339
+ return Is.stringValue(tableAlias) ? `${tableAlias}."${column}"` : `"${column}"`;
1340
+ }
912
1341
  /**
913
1342
  * Check if the database exists.
914
1343
  * @param adminClient The server-level connection to use for the check.
@@ -1155,10 +1584,14 @@ export class PostgreSqlEntityStorageConnector {
1155
1584
  * Build where clause arrays for a query, combining partition key and optional conditions.
1156
1585
  * @param conditions The optional entity conditions to include.
1157
1586
  * @param partitionKey The partition key value.
1587
+ * @param tableAlias The optional table alias to qualify the columns with, needed when the
1588
+ * clauses are used in a statement which reads from more than one table.
1589
+ * @param startIndex The number the first placeholder takes, so clauses can be added to a
1590
+ * statement which already holds values.
1158
1591
  * @returns The where clauses and bound values.
1159
1592
  * @internal
1160
1593
  */
1161
- buildWhereClause(conditions, partitionKey) {
1594
+ buildWhereClause(conditions, partitionKey, tableAlias, startIndex = 1) {
1162
1595
  const whereClauses = [];
1163
1596
  const values = [];
1164
1597
  const finalConditions = {
@@ -1173,7 +1606,7 @@ export class PostgreSqlEntityStorageConnector {
1173
1606
  if (!Is.empty(conditions)) {
1174
1607
  finalConditions.conditions.push(conditions);
1175
1608
  }
1176
- this.buildQueryParameters("", finalConditions, whereClauses, values, 1);
1609
+ this.buildQueryParameters("", finalConditions, whereClauses, values, startIndex, tableAlias);
1177
1610
  return { whereClauses, values };
1178
1611
  }
1179
1612
  /**
@@ -1183,9 +1616,10 @@ export class PostgreSqlEntityStorageConnector {
1183
1616
  * @param whereClauses The where clauses to use in the query.
1184
1617
  * @param values The values to use in the query.
1185
1618
  * @param valueIndex The current value index.
1619
+ * @param tableAlias The optional table alias to qualify the columns with.
1186
1620
  * @internal
1187
1621
  */
1188
- buildQueryParameters(objectPath, condition, whereClauses, values, valueIndex) {
1622
+ buildQueryParameters(objectPath, condition, whereClauses, values, valueIndex, tableAlias) {
1189
1623
  if (Is.undefined(condition)) {
1190
1624
  return;
1191
1625
  }
@@ -1196,7 +1630,7 @@ export class PostgreSqlEntityStorageConnector {
1196
1630
  const joinConditions = condition.conditions.map(c => {
1197
1631
  const subWhereClauses = [];
1198
1632
  const subValues = [];
1199
- this.buildQueryParameters(objectPath, c, subWhereClauses, subValues, valueIndex);
1633
+ this.buildQueryParameters(objectPath, c, subWhereClauses, subValues, valueIndex, tableAlias);
1200
1634
  values.push(...subValues);
1201
1635
  valueIndex += subValues.length;
1202
1636
  return subWhereClauses.join(" AND ");
@@ -1209,7 +1643,7 @@ export class PostgreSqlEntityStorageConnector {
1209
1643
  return;
1210
1644
  }
1211
1645
  const schemaProp = this._entitySchema.properties?.find(p => p.property === condition.property);
1212
- const comparison = this.mapComparisonOperator(objectPath, condition, schemaProp?.type, values, valueIndex);
1646
+ const comparison = this.mapComparisonOperator(objectPath, condition, schemaProp?.type, values, valueIndex, tableAlias);
1213
1647
  whereClauses.push(comparison);
1214
1648
  }
1215
1649
  /**
@@ -1219,11 +1653,12 @@ export class PostgreSqlEntityStorageConnector {
1219
1653
  * @param type The type of the property.
1220
1654
  * @param values The values to use in the query.
1221
1655
  * @param valueIndex The current value index.
1656
+ * @param tableAlias The optional table alias to qualify the columns with.
1222
1657
  * @returns The comparison expression.
1223
1658
  * @throws GeneralError if the comparison operator is not supported.
1224
1659
  * @internal
1225
1660
  */
1226
- mapComparisonOperator(objectPath, comparator, type, values, valueIndex) {
1661
+ mapComparisonOperator(objectPath, comparator, type, values, valueIndex, tableAlias) {
1227
1662
  let prop = objectPath;
1228
1663
  if (prop.length > 0) {
1229
1664
  prop += ".";
@@ -1238,7 +1673,7 @@ export class PostgreSqlEntityStorageConnector {
1238
1673
  }
1239
1674
  values.push(...inValues.map(val => this.propertyToDbValue(val, type)));
1240
1675
  const placeholders = inValues.map((value, index) => `$${valueIndex + index}`).join(", ");
1241
- return `"${prop}" IN (${placeholders})`;
1676
+ return `${this.qualifiedColumn(prop, tableAlias)} IN (${placeholders})`;
1242
1677
  }
1243
1678
  // null/undefined must use IS NULL / IS NOT NULL - never a parameterised placeholder.
1244
1679
  // Passing undefined through propertyToDbValue() coerces it to NaN for number fields
@@ -1254,10 +1689,10 @@ export class PostgreSqlEntityStorageConnector {
1254
1689
  const jsonPath = nestedParts
1255
1690
  .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
1256
1691
  .join("");
1257
- const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
1692
+ const jsonTextExpr = `(${this.qualifiedColumn(rootProp, tableAlias)}::jsonb ${jsonPath})`;
1258
1693
  return `${jsonTextExpr} ${nullCheck}`;
1259
1694
  }
1260
- return `"${prop}" ${nullCheck}`;
1695
+ return `${this.qualifiedColumn(prop, tableAlias)} ${nullCheck}`;
1261
1696
  }
1262
1697
  }
1263
1698
  const dbValue = this.propertyToDbValue(comparator.value, type);
@@ -1270,7 +1705,7 @@ export class PostgreSqlEntityStorageConnector {
1270
1705
  const jsonPath = nestedParts
1271
1706
  .map((p, i, arr) => (i === arr.length - 1 ? `->> '${p}'` : `-> '${p}'`))
1272
1707
  .join("");
1273
- const jsonTextExpr = `("${rootProp}"::jsonb ${jsonPath})`;
1708
+ const jsonTextExpr = `(${this.qualifiedColumn(rootProp, tableAlias)}::jsonb ${jsonPath})`;
1274
1709
  switch (comparator.comparison) {
1275
1710
  case ComparisonOperator.Includes: {
1276
1711
  values.pop();
@@ -1279,7 +1714,7 @@ export class PostgreSqlEntityStorageConnector {
1279
1714
  const elemPath = nestedParts
1280
1715
  .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
1281
1716
  .join("");
1282
- return `EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE elem${elemPath} LIKE $${valueIndex})`;
1717
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(rootProp, tableAlias)}) elem WHERE elem${elemPath} LIKE $${valueIndex})`;
1283
1718
  }
1284
1719
  return `${jsonTextExpr} LIKE $${valueIndex}`;
1285
1720
  }
@@ -1290,7 +1725,7 @@ export class PostgreSqlEntityStorageConnector {
1290
1725
  const elemPath = nestedParts
1291
1726
  .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
1292
1727
  .join("");
1293
- return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE elem${elemPath} LIKE $${valueIndex})`;
1728
+ return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(rootProp, tableAlias)}) elem WHERE elem${elemPath} LIKE $${valueIndex})`;
1294
1729
  }
1295
1730
  return `${jsonTextExpr} NOT LIKE $${valueIndex}`;
1296
1731
  }
@@ -1301,7 +1736,7 @@ export class PostgreSqlEntityStorageConnector {
1301
1736
  const elemPath = nestedParts
1302
1737
  .map((p, i, arr) => (i === arr.length - 1 ? `->>'${p}'` : `->'${p}'`))
1303
1738
  .join("");
1304
- return `EXISTS (SELECT 1 FROM jsonb_array_elements("${rootProp}") elem WHERE elem${elemPath} LIKE $${valueIndex})`;
1739
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(rootProp, tableAlias)}) elem WHERE elem${elemPath} LIKE $${valueIndex})`;
1305
1740
  }
1306
1741
  return `${jsonTextExpr} LIKE $${valueIndex}`;
1307
1742
  }
@@ -1322,28 +1757,28 @@ export class PostgreSqlEntityStorageConnector {
1322
1757
  switch (comparator.comparison) {
1323
1758
  case ComparisonOperator.Equals:
1324
1759
  if (Is.object(comparator.value) || Is.array(comparator.value)) {
1325
- return `"${prop}" = $${valueIndex}::jsonb`;
1760
+ return `${this.qualifiedColumn(prop, tableAlias)} = $${valueIndex}::jsonb`;
1326
1761
  }
1327
- return `"${prop}" = $${valueIndex}`;
1762
+ return `${this.qualifiedColumn(prop, tableAlias)} = $${valueIndex}`;
1328
1763
  case ComparisonOperator.NotEquals:
1329
1764
  if (Is.object(comparator.value) || Is.array(comparator.value)) {
1330
- return `"${prop}" != $${valueIndex}::jsonb`;
1765
+ return `${this.qualifiedColumn(prop, tableAlias)} != $${valueIndex}::jsonb`;
1331
1766
  }
1332
- return `"${prop}" <> $${valueIndex}`;
1767
+ return `${this.qualifiedColumn(prop, tableAlias)} <> $${valueIndex}`;
1333
1768
  case ComparisonOperator.GreaterThan:
1334
- return `"${prop}" > $${valueIndex}`;
1769
+ return `${this.qualifiedColumn(prop, tableAlias)} > $${valueIndex}`;
1335
1770
  case ComparisonOperator.LessThan:
1336
- return `"${prop}" < $${valueIndex}`;
1771
+ return `${this.qualifiedColumn(prop, tableAlias)} < $${valueIndex}`;
1337
1772
  case ComparisonOperator.GreaterThanOrEqual:
1338
- return `"${prop}" >= $${valueIndex}`;
1773
+ return `${this.qualifiedColumn(prop, tableAlias)} >= $${valueIndex}`;
1339
1774
  case ComparisonOperator.LessThanOrEqual:
1340
- return `"${prop}" <= $${valueIndex}`;
1775
+ return `${this.qualifiedColumn(prop, tableAlias)} <= $${valueIndex}`;
1341
1776
  case ComparisonOperator.Includes: {
1342
1777
  if (type === EntitySchemaPropertyType.String) {
1343
- return `"${prop}" LIKE '%' || $${valueIndex} || '%'`;
1778
+ return `${this.qualifiedColumn(prop, tableAlias)} LIKE '%' || $${valueIndex} || '%'`;
1344
1779
  }
1345
1780
  if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
1346
- return `EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
1781
+ return `EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(prop, tableAlias)}) elem WHERE elem @> $${valueIndex}::jsonb)`;
1347
1782
  }
1348
1783
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
1349
1784
  comparison: comparator.comparison,
@@ -1352,10 +1787,10 @@ export class PostgreSqlEntityStorageConnector {
1352
1787
  }
1353
1788
  case ComparisonOperator.NotIncludes: {
1354
1789
  if (type === EntitySchemaPropertyType.String) {
1355
- return `"${prop}" NOT LIKE '%' || $${valueIndex} || '%'`;
1790
+ return `${this.qualifiedColumn(prop, tableAlias)} NOT LIKE '%' || $${valueIndex} || '%'`;
1356
1791
  }
1357
1792
  if (type === EntitySchemaPropertyType.Array || type === EntitySchemaPropertyType.Object) {
1358
- return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements("${prop}") elem WHERE elem @> $${valueIndex}::jsonb)`;
1793
+ return `NOT EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(prop, tableAlias)}) elem WHERE elem @> $${valueIndex}::jsonb)`;
1359
1794
  }
1360
1795
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
1361
1796
  comparison: comparator.comparison,
@@ -1366,7 +1801,7 @@ export class PostgreSqlEntityStorageConnector {
1366
1801
  if (type === EntitySchemaPropertyType.String) {
1367
1802
  values.pop();
1368
1803
  values.push(`${this.escapeLike(String(comparator.value))}%`);
1369
- return `"${prop}" LIKE $${valueIndex}`;
1804
+ return `${this.qualifiedColumn(prop, tableAlias)} LIKE $${valueIndex}`;
1370
1805
  }
1371
1806
  throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
1372
1807
  comparison: comparator.comparison,