@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.
- package/dist/es/postgreSqlEntityStorageConnector.js +615 -75
- package/dist/es/postgreSqlEntityStorageConnector.js.map +1 -1
- package/dist/types/postgreSqlEntityStorageConnector.d.ts +23 -2
- package/docs/changelog.md +220 -0
- package/docs/reference/classes/PostgreSqlEntityStorageConnector.md +51 -1
- package/locales/en.json +3 -2
- package/package.json +8 -8
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
718
|
-
|
|
719
|
-
const dbConnection = await
|
|
720
|
-
await dbConnection.
|
|
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,
|
|
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
|
|
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'
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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,
|
|
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
|
|
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 = `(
|
|
1695
|
+
const jsonTextExpr = `(${this.qualifiedColumn(rootProp, tableAlias)}::jsonb ${jsonPath})`;
|
|
1188
1696
|
return `${jsonTextExpr} ${nullCheck}`;
|
|
1189
1697
|
}
|
|
1190
|
-
return
|
|
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 = `(
|
|
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)
|
|
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(
|
|
1720
|
+
return `EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(rootProp, tableAlias)}) elem WHERE elem${elemPath} LIKE $${valueIndex})`;
|
|
1213
1721
|
}
|
|
1214
|
-
return
|
|
1722
|
+
return `${jsonTextExpr} LIKE $${valueIndex}`;
|
|
1215
1723
|
}
|
|
1216
1724
|
case ComparisonOperator.NotIncludes: {
|
|
1217
1725
|
values.pop();
|
|
1218
|
-
values.push(`%${String(comparator.value)
|
|
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 `
|
|
1742
|
+
return `EXISTS (SELECT 1 FROM jsonb_array_elements(${this.qualifiedColumn(rootProp, tableAlias)}) elem WHERE elem${elemPath} LIKE $${valueIndex})`;
|
|
1224
1743
|
}
|
|
1225
|
-
return
|
|
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
|
|
1763
|
+
return `${this.qualifiedColumn(prop, tableAlias)} = $${valueIndex}::jsonb`;
|
|
1245
1764
|
}
|
|
1246
|
-
return
|
|
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
|
|
1768
|
+
return `${this.qualifiedColumn(prop, tableAlias)} != $${valueIndex}::jsonb`;
|
|
1250
1769
|
}
|
|
1251
|
-
return
|
|
1770
|
+
return `${this.qualifiedColumn(prop, tableAlias)} <> $${valueIndex}`;
|
|
1252
1771
|
case ComparisonOperator.GreaterThan:
|
|
1253
|
-
return
|
|
1772
|
+
return `${this.qualifiedColumn(prop, tableAlias)} > $${valueIndex}`;
|
|
1254
1773
|
case ComparisonOperator.LessThan:
|
|
1255
|
-
return
|
|
1774
|
+
return `${this.qualifiedColumn(prop, tableAlias)} < $${valueIndex}`;
|
|
1256
1775
|
case ComparisonOperator.GreaterThanOrEqual:
|
|
1257
|
-
return
|
|
1776
|
+
return `${this.qualifiedColumn(prop, tableAlias)} >= $${valueIndex}`;
|
|
1258
1777
|
case ComparisonOperator.LessThanOrEqual:
|
|
1259
|
-
return
|
|
1778
|
+
return `${this.qualifiedColumn(prop, tableAlias)} <= $${valueIndex}`;
|
|
1260
1779
|
case ComparisonOperator.Includes: {
|
|
1261
1780
|
if (type === EntitySchemaPropertyType.String) {
|
|
1262
|
-
return
|
|
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(
|
|
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
|
|
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(
|
|
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
|
});
|