@twin.org/entity-storage-connector-postgresql 0.10.1-next.5 → 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.
- package/dist/es/postgreSqlEntityStorageConnector.js +461 -26
- package/dist/es/postgreSqlEntityStorageConnector.js.map +1 -1
- package/dist/types/postgreSqlEntityStorageConnector.d.ts +22 -1
- package/docs/changelog.md +16 -0
- package/docs/reference/classes/PostgreSqlEntityStorageConnector.md +50 -0
- package/locales/en.json +2 -0
- package/package.json +2 -2
|
@@ -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,
|
|
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
|
|
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 = `(
|
|
1692
|
+
const jsonTextExpr = `(${this.qualifiedColumn(rootProp, tableAlias)}::jsonb ${jsonPath})`;
|
|
1258
1693
|
return `${jsonTextExpr} ${nullCheck}`;
|
|
1259
1694
|
}
|
|
1260
|
-
return
|
|
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 = `(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
1760
|
+
return `${this.qualifiedColumn(prop, tableAlias)} = $${valueIndex}::jsonb`;
|
|
1326
1761
|
}
|
|
1327
|
-
return
|
|
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
|
|
1765
|
+
return `${this.qualifiedColumn(prop, tableAlias)} != $${valueIndex}::jsonb`;
|
|
1331
1766
|
}
|
|
1332
|
-
return
|
|
1767
|
+
return `${this.qualifiedColumn(prop, tableAlias)} <> $${valueIndex}`;
|
|
1333
1768
|
case ComparisonOperator.GreaterThan:
|
|
1334
|
-
return
|
|
1769
|
+
return `${this.qualifiedColumn(prop, tableAlias)} > $${valueIndex}`;
|
|
1335
1770
|
case ComparisonOperator.LessThan:
|
|
1336
|
-
return
|
|
1771
|
+
return `${this.qualifiedColumn(prop, tableAlias)} < $${valueIndex}`;
|
|
1337
1772
|
case ComparisonOperator.GreaterThanOrEqual:
|
|
1338
|
-
return
|
|
1773
|
+
return `${this.qualifiedColumn(prop, tableAlias)} >= $${valueIndex}`;
|
|
1339
1774
|
case ComparisonOperator.LessThanOrEqual:
|
|
1340
|
-
return
|
|
1775
|
+
return `${this.qualifiedColumn(prop, tableAlias)} <= $${valueIndex}`;
|
|
1341
1776
|
case ComparisonOperator.Includes: {
|
|
1342
1777
|
if (type === EntitySchemaPropertyType.String) {
|
|
1343
|
-
return
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
1804
|
+
return `${this.qualifiedColumn(prop, tableAlias)} LIKE $${valueIndex}`;
|
|
1370
1805
|
}
|
|
1371
1806
|
throw new GeneralError(PostgreSqlEntityStorageConnector.CLASS_NAME, "comparisonNotSupported", {
|
|
1372
1807
|
comparison: comparator.comparison,
|