@rebasepro/server-postgres 0.14.1 → 0.14.2-canary.g27a129e
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/{auth-users-columns-C-FDnL_e.js → auth-users-columns-JJ8ngvy5.js} +59 -17
- package/dist/auth-users-columns-JJ8ngvy5.js.map +1 -0
- package/dist/{backup-service-BH0Dzo_h.js → backup-service-czK-OAuG.js} +2 -2
- package/dist/{backup-service-BH0Dzo_h.js.map → backup-service-czK-OAuG.js.map} +1 -1
- package/dist/{ensure-collection-policies-DoHwhVf8.js → ensure-collection-policies-D5PtQLyR.js} +3 -3
- package/dist/{ensure-collection-policies-DoHwhVf8.js.map → ensure-collection-policies-D5PtQLyR.js.map} +1 -1
- package/dist/{ensure-collection-tables-DT2eq859.js → ensure-collection-tables-BHUjQ-z4.js} +3 -3
- package/dist/{ensure-collection-tables-DT2eq859.js.map → ensure-collection-tables-BHUjQ-z4.js.map} +1 -1
- package/dist/index.es.js +485 -19
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-69hYT8nr.js → rls-bootstrap-sql-B5Sajku6.js} +2 -2
- package/dist/{rls-bootstrap-sql-69hYT8nr.js.map → rls-bootstrap-sql-B5Sajku6.js.map} +1 -1
- package/dist/{rls-enforcement-gUNDfm7l.js → rls-enforcement-BDBfuTD4.js} +4 -3
- package/dist/rls-enforcement-BDBfuTD4.js.map +1 -0
- package/dist/services/FetchService.d.ts +22 -0
- package/dist/{src-DCdn3Val.js → src-BBFsDaeA.js} +60 -2
- package/dist/src-BBFsDaeA.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +168 -1
- package/dist/utils/pg-error-utils.d.ts +27 -0
- package/dist/{websocket-D2jXv0Ds.js → websocket-BVgDVO-V.js} +2 -2
- package/dist/{websocket-D2jXv0Ds.js.map → websocket-BVgDVO-V.js.map} +1 -1
- package/package.json +6 -6
- package/src/services/FetchService.ts +155 -15
- package/src/services/PersistService.ts +23 -2
- package/src/utils/drizzle-conditions.ts +594 -1
- package/src/utils/pg-error-utils.ts +49 -4
- package/dist/auth-users-columns-C-FDnL_e.js.map +0 -1
- package/dist/rls-enforcement-gUNDfm7l.js.map +0 -1
- package/dist/src-DCdn3Val.js.map +0 -1
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
ALL_WHERE_FILTER_OPS,
|
|
5
5
|
CollectionConfig, FilterValues, WhereFilterOp, JoinStep, LogicalCondition, FilterCondition,
|
|
6
6
|
ResolvedRelation, ResolvedBelongsTo, ResolvedHasOne, ResolvedHasMany,
|
|
7
|
-
ResolvedForeignKeyOnTarget, ResolvedManyToMany, hasForeignKeyOnTarget, isManyToMany
|
|
7
|
+
ResolvedForeignKeyOnTarget, ResolvedManyToMany, hasForeignKeyOnTarget, isManyToMany,
|
|
8
|
+
encodeRelationAggregateSort, type RelationAggregateFn, type RelationAggregateSort
|
|
8
9
|
} from "@rebasepro/types";
|
|
9
10
|
import {
|
|
10
11
|
fieldKeyForColumn, getColumnName, getTableName, normalizeToEntityRelation, resolveCollectionRelations, toFilterTuples
|
|
@@ -102,6 +103,23 @@ const targetOf = (relation: ResolvedRelation): CollectionConfig | undefined => {
|
|
|
102
103
|
}
|
|
103
104
|
};
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The SQL each aggregate sort function compiles to.
|
|
108
|
+
*
|
|
109
|
+
* A lookup rather than interpolation, and `sql.raw` applied to five constants
|
|
110
|
+
* written out here rather than to anything derived from a request. The key is
|
|
111
|
+
* already validated by `parseRelationAggregateSort` — this is the second lock
|
|
112
|
+
* on the same door, so that a future caller reaching the builder directly
|
|
113
|
+
* cannot put a string from the wire where a function name goes.
|
|
114
|
+
*/
|
|
115
|
+
const AGGREGATE_SQL: Record<RelationAggregateFn, SQL> = {
|
|
116
|
+
min: sql.raw("min"),
|
|
117
|
+
max: sql.raw("max"),
|
|
118
|
+
count: sql.raw("count"),
|
|
119
|
+
sum: sql.raw("sum"),
|
|
120
|
+
avg: sql.raw("avg")
|
|
121
|
+
};
|
|
122
|
+
|
|
105
123
|
/** Column types `ILIKE '%…%'` is defined on. */
|
|
106
124
|
const ILIKE_SQL_TYPES = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
|
|
107
125
|
|
|
@@ -212,6 +230,27 @@ type FilterTarget =
|
|
|
212
230
|
/** Bound here so the compile step cannot be reached without them. */
|
|
213
231
|
registry: PostgresCollectionRegistry;
|
|
214
232
|
sourceIdColumn: AnyPgColumn;
|
|
233
|
+
}
|
|
234
|
+
| {
|
|
235
|
+
/**
|
|
236
|
+
* A *column of the related row* — `applications.status`. Same `EXISTS`
|
|
237
|
+
* as `relation`, with the predicate moved off the target's id and onto
|
|
238
|
+
* one of its columns.
|
|
239
|
+
*/
|
|
240
|
+
kind: "relation-field";
|
|
241
|
+
/**
|
|
242
|
+
* `via` is not among them: it is refused at resolution, and leaving it
|
|
243
|
+
* in the type would let a later edit reach the compile step with a
|
|
244
|
+
* relation there is no correlation for.
|
|
245
|
+
*/
|
|
246
|
+
relation: ResolvedBelongsTo | ResolvedForeignKeyOnTarget | ResolvedManyToMany;
|
|
247
|
+
registry: PostgresCollectionRegistry;
|
|
248
|
+
/** The column on *this* table the subquery correlates back to. */
|
|
249
|
+
sourceIdColumn: AnyPgColumn;
|
|
250
|
+
/** The table the predicate is asked of, already resolved. */
|
|
251
|
+
targetTable: PgTable<any>;
|
|
252
|
+
/** The column on {@link targetTable} the predicate compares. */
|
|
253
|
+
targetColumn: AnyPgColumn;
|
|
215
254
|
};
|
|
216
255
|
|
|
217
256
|
/**
|
|
@@ -229,6 +268,25 @@ type FilterTarget =
|
|
|
229
268
|
* of habit, and refusing it would be pedantry about a distinction this layer
|
|
230
269
|
* erases anyway.
|
|
231
270
|
*/
|
|
271
|
+
/**
|
|
272
|
+
* Split `applications.status` into the relation and the column it addresses.
|
|
273
|
+
*
|
|
274
|
+
* One dot, and only the first one: a relation name cannot contain a dot, and
|
|
275
|
+
* everything after it is handed to the target as a single column key. A second
|
|
276
|
+
* dot would be a second hop — `talents.applications.job.title` — which is a
|
|
277
|
+
* different feature (it needs a chain of subqueries, and a chain has to decide
|
|
278
|
+
* what "some" means at every level), so it is refused rather than silently read
|
|
279
|
+
* as a column named `job.title` that no table has.
|
|
280
|
+
*
|
|
281
|
+
* Returns `undefined` for a field with no dot, which leaves every existing
|
|
282
|
+
* filter on exactly the path it took before.
|
|
283
|
+
*/
|
|
284
|
+
function parseRelationFieldPath(field: string): { relationKey: string; fieldKey: string } | undefined {
|
|
285
|
+
const dot = field.indexOf(".");
|
|
286
|
+
if (dot <= 0 || dot === field.length - 1) return undefined;
|
|
287
|
+
return { relationKey: field.slice(0, dot), fieldKey: field.slice(dot + 1) };
|
|
288
|
+
}
|
|
289
|
+
|
|
232
290
|
function parseJsonFieldPath(field: string): { columnKey: string; path: string[] } | undefined {
|
|
233
291
|
if (!field.includes("->")) return undefined;
|
|
234
292
|
|
|
@@ -567,6 +625,17 @@ export class DrizzleConditionBuilder {
|
|
|
567
625
|
}
|
|
568
626
|
return { kind: "relation", relation, registry, sourceIdColumn: correlationColumn };
|
|
569
627
|
}
|
|
628
|
+
|
|
629
|
+
// `applications.status` — a column of the related row rather than
|
|
630
|
+
// its id. Resolved last of the relation shapes, so a relation
|
|
631
|
+
// literally named with a dot still wins above.
|
|
632
|
+
const relationField = parseRelationFieldPath(field);
|
|
633
|
+
if (relationField && registry && sourceIdColumn) {
|
|
634
|
+
const target = this.resolveRelationFieldTarget(
|
|
635
|
+
table, relationField, collection, registry, sourceIdColumn, field, collectionPath
|
|
636
|
+
);
|
|
637
|
+
if (target) return target;
|
|
638
|
+
}
|
|
570
639
|
}
|
|
571
640
|
|
|
572
641
|
// No collection in hand — the shapes an owning relation's key takes by
|
|
@@ -609,6 +678,106 @@ export class DrizzleConditionBuilder {
|
|
|
609
678
|
);
|
|
610
679
|
}
|
|
611
680
|
|
|
681
|
+
/**
|
|
682
|
+
* `applications.status` — the relation, and the column of the target it
|
|
683
|
+
* addresses.
|
|
684
|
+
*
|
|
685
|
+
* `undefined` when the first segment names no relation: the field simply is
|
|
686
|
+
* not a relation path, and resolution carries on to the guesses and then to
|
|
687
|
+
* the unknown-field answer, which is where a typo belongs. A segment that
|
|
688
|
+
* *does* name a relation is a different matter — the author plainly meant
|
|
689
|
+
* this shape — so everything after that point throws rather than returning,
|
|
690
|
+
* naming what went wrong. Falling through would report "unknown filter
|
|
691
|
+
* field 'applications.status'" and list the columns of the wrong table.
|
|
692
|
+
*
|
|
693
|
+
* `via` is refused for the reason it is absent from
|
|
694
|
+
* `filterableRelationKinds`: its join path is authored source → target with
|
|
695
|
+
* no stated inverse, so there is nothing to correlate a subquery back to.
|
|
696
|
+
*/
|
|
697
|
+
private static resolveRelationFieldTarget(
|
|
698
|
+
table: PgTable<any>,
|
|
699
|
+
path: { relationKey: string; fieldKey: string },
|
|
700
|
+
collection: CollectionConfig,
|
|
701
|
+
registry: PostgresCollectionRegistry,
|
|
702
|
+
sourceIdColumn: AnyPgColumn,
|
|
703
|
+
field: string,
|
|
704
|
+
collectionPath: string
|
|
705
|
+
): FilterTarget | undefined {
|
|
706
|
+
const relation = resolveCollectionRelations(collection)[path.relationKey];
|
|
707
|
+
if (!relation) return undefined;
|
|
708
|
+
|
|
709
|
+
if (relation.kind === "via") {
|
|
710
|
+
throw ApiError.badRequest(
|
|
711
|
+
`Cannot filter by '${field}' on collection '${collectionPath}': '${path.relationKey}' is a ` +
|
|
712
|
+
"`via` relation, whose join path is authored one way only, so there is nothing to correlate " +
|
|
713
|
+
"a subquery back to.",
|
|
714
|
+
"UNSUPPORTED_RELATION_FILTER",
|
|
715
|
+
{ field, collection: collectionPath, relation: path.relationKey, kind: relation.kind }
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const targetCollection = targetOf(relation);
|
|
720
|
+
const targetTable = targetCollection && registry.getTable(getTableName(targetCollection));
|
|
721
|
+
if (!targetCollection || !targetTable) {
|
|
722
|
+
throw new Error(
|
|
723
|
+
`Table not found for the target of relation '${relation.relationName}' on '${collectionPath}', ` +
|
|
724
|
+
`so '${field}' has nothing to filter against.`
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const targetColumn = relationColumn(targetTable, targetCollection, path.fieldKey)
|
|
729
|
+
?? (path.fieldKey in targetTable
|
|
730
|
+
? targetTable[path.fieldKey as keyof typeof targetTable] as AnyPgColumn
|
|
731
|
+
: undefined);
|
|
732
|
+
if (!targetColumn) {
|
|
733
|
+
let validFields: string[] = [];
|
|
734
|
+
try {
|
|
735
|
+
validFields = Object.keys(getTableColumns(targetTable)).sort();
|
|
736
|
+
} catch {
|
|
737
|
+
// Same tolerance as the column path: a table stand-in without
|
|
738
|
+
// Drizzle's column symbols still gets the error, just no list.
|
|
739
|
+
}
|
|
740
|
+
throw ApiError.badRequest(
|
|
741
|
+
`Unknown field '${path.fieldKey}' on '${targetCollection.slug}', the target of relation ` +
|
|
742
|
+
`'${path.relationKey}' on collection '${collectionPath}'` +
|
|
743
|
+
(validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""),
|
|
744
|
+
"UNKNOWN_FILTER_FIELD",
|
|
745
|
+
{
|
|
746
|
+
field,
|
|
747
|
+
collection: collectionPath,
|
|
748
|
+
relation: path.relationKey,
|
|
749
|
+
targetCollection: targetCollection.slug,
|
|
750
|
+
...(validFields.length > 0 && { validFields })
|
|
751
|
+
}
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// The column on *this* table the subquery correlates back to. Only
|
|
756
|
+
// `hasMany`/`hasOne` can name a different one; `belongsTo` correlates
|
|
757
|
+
// from its own foreign key, and a many-to-many from the primary key the
|
|
758
|
+
// junction was built against.
|
|
759
|
+
const correlationColumn = relation.kind === "belongsTo"
|
|
760
|
+
? relationColumn(table, collection, relation.localKey)
|
|
761
|
+
: hasForeignKeyOnTarget(relation) && relation.sourceKey
|
|
762
|
+
? relationColumn(table, collection, relation.sourceKey)
|
|
763
|
+
: sourceIdColumn;
|
|
764
|
+
if (!correlationColumn) {
|
|
765
|
+
throw new Error(
|
|
766
|
+
`Relation '${relation.relationName}' on '${collectionPath}' names a key that is not a column ` +
|
|
767
|
+
`there, so '${field}' has nothing to correlate against.`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
kind: "relation-field",
|
|
773
|
+
relation,
|
|
774
|
+
registry,
|
|
775
|
+
sourceIdColumn: correlationColumn,
|
|
776
|
+
targetTable,
|
|
777
|
+
targetColumn
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
612
781
|
/**
|
|
613
782
|
* Build filter conditions from FilterValues
|
|
614
783
|
*/
|
|
@@ -688,6 +857,9 @@ export class DrizzleConditionBuilder {
|
|
|
688
857
|
if (target.kind === "json") {
|
|
689
858
|
return this.buildJsonPathCondition(target.column, target.path, op, value);
|
|
690
859
|
}
|
|
860
|
+
if (target.kind === "relation-field") {
|
|
861
|
+
return this.buildRelationFieldCondition(target, op, value, field, collectionPath);
|
|
862
|
+
}
|
|
691
863
|
return this.buildRelationFilterCondition(
|
|
692
864
|
target.relation, op, value, target.sourceIdColumn, target.registry, field, collectionPath
|
|
693
865
|
);
|
|
@@ -991,6 +1163,427 @@ export class DrizzleConditionBuilder {
|
|
|
991
1163
|
}
|
|
992
1164
|
}
|
|
993
1165
|
|
|
1166
|
+
/**
|
|
1167
|
+
* A filter on a *column of the related row* — `applications.status`.
|
|
1168
|
+
*
|
|
1169
|
+
* The same `EXISTS` {@link buildRelationFilterCondition} builds, with the
|
|
1170
|
+
* predicate moved off the target's id and onto one of its columns:
|
|
1171
|
+
*
|
|
1172
|
+
* EXISTS (SELECT 1 FROM talent_applications AS t
|
|
1173
|
+
* WHERE t.talent_id = talents.id
|
|
1174
|
+
* AND t.status IN ('applied', 'reviewing', 'interview'))
|
|
1175
|
+
*
|
|
1176
|
+
* which is the shape every "who is waiting" queue is written in. Without
|
|
1177
|
+
* it the only way to ask is to fetch every row and filter in the browser,
|
|
1178
|
+
* and a filter the client applies after paging is not a filter — the page
|
|
1179
|
+
* was already chosen without it.
|
|
1180
|
+
*
|
|
1181
|
+
* A many-to-many needs one more table than the id filter does. That one
|
|
1182
|
+
* stops at the junction, because the junction already holds the value it
|
|
1183
|
+
* compares; a column of the target is a table further out, so the subquery
|
|
1184
|
+
* joins the target to the junction and correlates from the junction. The
|
|
1185
|
+
* join is inside `EXISTS`, so it cannot multiply the outer rows the way a
|
|
1186
|
+
* top-level join through a junction would.
|
|
1187
|
+
*
|
|
1188
|
+
* `belongsTo` is included even though its foreign key is a column here:
|
|
1189
|
+
* `author.name` is a column of another table either way, and refusing the
|
|
1190
|
+
* one relation kind that reads most naturally would be a rule about
|
|
1191
|
+
* implementation rather than about meaning.
|
|
1192
|
+
*
|
|
1193
|
+
* Under RLS the subquery runs as the reader, so it sees the target rows
|
|
1194
|
+
* that reader's policies allow and no others. On the positive direction
|
|
1195
|
+
* that is exactly right. On the negative — `!=`, `not-in`, and any
|
|
1196
|
+
* `NOT EXISTS` — "no related row satisfies this" and "no related row this
|
|
1197
|
+
* reader can see satisfies this" are the same sentence, so a target table
|
|
1198
|
+
* with row-level security and no `SELECT` policy for `rebase_user` makes
|
|
1199
|
+
* every row look unmatched and the negative filter over-reports. Nothing is
|
|
1200
|
+
* leaked: the outer table's own policies still decide which rows exist. The
|
|
1201
|
+
* cause is a missing policy on the target rather than anything here, and it
|
|
1202
|
+
* is the same caveat the id-filter path carries.
|
|
1203
|
+
*/
|
|
1204
|
+
static buildRelationFieldCondition(
|
|
1205
|
+
target: Extract<FilterTarget, { kind: "relation-field" }>,
|
|
1206
|
+
op: WhereFilterOp,
|
|
1207
|
+
value: unknown,
|
|
1208
|
+
field: string,
|
|
1209
|
+
collectionPath: string
|
|
1210
|
+
): SQL {
|
|
1211
|
+
const alias = "__rel_field";
|
|
1212
|
+
const { relation, targetTable, targetColumn } = target;
|
|
1213
|
+
const ref = sql`${sql.identifier(alias)}.${sql.identifier(targetColumn.name)}`;
|
|
1214
|
+
|
|
1215
|
+
const { predicate, negate } = this.buildRelationColumnPredicate(
|
|
1216
|
+
ref, targetColumn, op, value, field, collectionPath
|
|
1217
|
+
);
|
|
1218
|
+
|
|
1219
|
+
// Everything inside the subquery is referenced by identifier against a
|
|
1220
|
+
// local alias — see `buildRelationScopeCondition` for why a Drizzle
|
|
1221
|
+
// column object cannot be used there. Only `sourceIdColumn` stays a
|
|
1222
|
+
// column object, which is what binds it to the *outer* row.
|
|
1223
|
+
let from: SQL;
|
|
1224
|
+
let correlation: SQL;
|
|
1225
|
+
|
|
1226
|
+
if (relation.kind === "manyToMany") {
|
|
1227
|
+
const { table: junctionName, sourceColumn, targetColumn: junctionTargetColumn } = relation.through;
|
|
1228
|
+
const junctionTable = target.registry.getTable(junctionName);
|
|
1229
|
+
if (!junctionTable) {
|
|
1230
|
+
throw new Error(`Junction table not found: ${junctionName}`);
|
|
1231
|
+
}
|
|
1232
|
+
const sourceCol = junctionTable[sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1233
|
+
const targetCol = junctionTable[junctionTargetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1234
|
+
if (!sourceCol || !targetCol) {
|
|
1235
|
+
throw new Error(
|
|
1236
|
+
`Junction columns '${sourceColumn}'/'${junctionTargetColumn}' not found in '${junctionName}'`
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
const targetKey = this.primaryKeyColumn(targetTable);
|
|
1240
|
+
if (!targetKey) {
|
|
1241
|
+
throw new Error(
|
|
1242
|
+
`No primary key or "id" column in the target table of relation '${relation.relationName}', ` +
|
|
1243
|
+
`so '${field}' has nothing to join the junction against.`
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
const junctionAlias = "__rel_field_junction";
|
|
1247
|
+
from = sql`${targetTable} AS ${sql.identifier(alias)} INNER JOIN ${junctionTable} AS ${sql.identifier(junctionAlias)} ON ${sql.identifier(junctionAlias)}.${sql.identifier(targetCol.name)} = ${sql.identifier(alias)}.${sql.identifier(targetKey.name)}`;
|
|
1248
|
+
correlation = sql`${sql.identifier(junctionAlias)}.${sql.identifier(sourceCol.name)} = ${target.sourceIdColumn}`;
|
|
1249
|
+
} else if (relation.kind === "belongsTo") {
|
|
1250
|
+
const targetKey = this.primaryKeyColumn(targetTable);
|
|
1251
|
+
if (!targetKey) {
|
|
1252
|
+
throw new Error(
|
|
1253
|
+
`No primary key or "id" column in the target table of relation '${relation.relationName}', ` +
|
|
1254
|
+
`so '${field}' has nothing to correlate against.`
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
from = sql`${targetTable} AS ${sql.identifier(alias)}`;
|
|
1258
|
+
correlation = sql`${sql.identifier(alias)}.${sql.identifier(targetKey.name)} = ${target.sourceIdColumn}`;
|
|
1259
|
+
} else {
|
|
1260
|
+
const foreignKey = relationColumn(targetTable, targetOf(relation), relation.foreignKeyOnTarget);
|
|
1261
|
+
if (!foreignKey) {
|
|
1262
|
+
throw new Error(
|
|
1263
|
+
`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of ` +
|
|
1264
|
+
`relation '${relation.relationName}'.`
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
from = sql`${targetTable} AS ${sql.identifier(alias)}`;
|
|
1268
|
+
correlation = sql`${sql.identifier(alias)}.${sql.identifier(foreignKey.name)} = ${target.sourceIdColumn}`;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const where = predicate ? sql`${correlation} AND ${predicate}` : correlation;
|
|
1272
|
+
const exists = sql`EXISTS (SELECT 1 FROM ${from} WHERE ${where})`;
|
|
1273
|
+
return negate ? sql`NOT ${exists}` : exists;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* The inner predicate of a relation *column* filter, and whether the
|
|
1278
|
+
* `EXISTS` wrapping it is negated.
|
|
1279
|
+
*
|
|
1280
|
+
* The negation rule is the one {@link buildRelationFilterPredicate} states
|
|
1281
|
+
* and holds for exactly the same reason, one column over. A negative
|
|
1282
|
+
* operator is `NOT EXISTS` of the **positive** predicate, never `EXISTS` of
|
|
1283
|
+
* a negated one: `EXISTS (… AND status != 'hired')` asks "does some
|
|
1284
|
+
* application differ from hired", which is true of nearly every candidate
|
|
1285
|
+
* with more than one application and answers nothing anybody asked.
|
|
1286
|
+
* `NOT EXISTS (… AND status = 'hired')` asks "is there no hired
|
|
1287
|
+
* application", which is what unticking a value means — and it makes `==`
|
|
1288
|
+
* and `!=` partition the rows, the way a filter implies they do.
|
|
1289
|
+
*
|
|
1290
|
+
* `is-null` and `is-not-null` are the exception, and deliberately not a
|
|
1291
|
+
* complementary pair here. On a column they compile to `EXISTS (… AND col
|
|
1292
|
+
* IS NULL)` and `EXISTS (… AND col IS NOT NULL)` — "has a related row whose
|
|
1293
|
+
* column is unset" and "has one where it is set" — which is the plain
|
|
1294
|
+
* reading of `applications.status is-not-null` and the useful one. They are
|
|
1295
|
+
* both true of a candidate with two applications, one of each. Making
|
|
1296
|
+
* `is-not-null` the negation instead would make it "no application has an
|
|
1297
|
+
* unset status", which is true of a candidate with no applications at all
|
|
1298
|
+
* and so answers a queue with the very rows the queue exists to exclude.
|
|
1299
|
+
*
|
|
1300
|
+
* Unlike the id path, every operator is available: the compared value is an
|
|
1301
|
+
* ordinary column, so `>=` on a date and `ilike` on a name mean here what
|
|
1302
|
+
* they mean anywhere else. Only an operator that does not exist is refused,
|
|
1303
|
+
* and it throws rather than returning `null` — a dropped condition widens
|
|
1304
|
+
* the read, which is the whole reason this file fails closed.
|
|
1305
|
+
*/
|
|
1306
|
+
private static buildRelationColumnPredicate(
|
|
1307
|
+
ref: SQL,
|
|
1308
|
+
column: AnyPgColumn,
|
|
1309
|
+
op: WhereFilterOp,
|
|
1310
|
+
value: unknown,
|
|
1311
|
+
field: string,
|
|
1312
|
+
collectionPath: string
|
|
1313
|
+
): { predicate?: SQL; negate: boolean } {
|
|
1314
|
+
value = unwrapRelationFilterValue(value);
|
|
1315
|
+
const isNullish = value === null || value === undefined;
|
|
1316
|
+
|
|
1317
|
+
const equals = () => sql`${ref} = ${value}`;
|
|
1318
|
+
const inList = (): SQL => {
|
|
1319
|
+
const values = toMembershipList(value);
|
|
1320
|
+
// An empty list matches nothing, and `NOT EXISTS (… AND FALSE)`
|
|
1321
|
+
// gives `not-in []` — which excludes nothing — for free. Dropping
|
|
1322
|
+
// the condition instead would match everything.
|
|
1323
|
+
return values.length === 0
|
|
1324
|
+
? sql`FALSE`
|
|
1325
|
+
: sql`${ref} IN (${sql.join(values.map(v => sql`${v}`), sql`, `)})`;
|
|
1326
|
+
};
|
|
1327
|
+
const isNull = () => sql`${ref} IS NULL`;
|
|
1328
|
+
const contains = (): SQL => {
|
|
1329
|
+
const meta = getColumnMeta(column);
|
|
1330
|
+
const isNativeArray = meta.dataType === "array" || meta.columnType === "PgArray";
|
|
1331
|
+
if (op === "array-contains-any") {
|
|
1332
|
+
if (Array.isArray(value) && value.length === 0) return sql`FALSE`;
|
|
1333
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
1334
|
+
return isNativeArray
|
|
1335
|
+
? sql`${ref} && ARRAY[${sql.join(value.map(v => sql`${v}`), sql`, `)}]`
|
|
1336
|
+
: sql`${ref} ?| array[${sql.join(value.map(v => sql`${String(v)}`), sql`, `)}]`;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
return isNativeArray
|
|
1340
|
+
? sql`${ref} @> ARRAY[${value}]`
|
|
1341
|
+
: sql`${ref} @> ${JSON.stringify([value])}`;
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
switch (op) {
|
|
1345
|
+
case "==":
|
|
1346
|
+
return { predicate: isNullish ? isNull() : equals(), negate: false };
|
|
1347
|
+
case "!=":
|
|
1348
|
+
return { predicate: isNullish ? isNull() : equals(), negate: true };
|
|
1349
|
+
case ">":
|
|
1350
|
+
return { predicate: sql`${ref} > ${value}`, negate: false };
|
|
1351
|
+
case ">=":
|
|
1352
|
+
return { predicate: sql`${ref} >= ${value}`, negate: false };
|
|
1353
|
+
case "<":
|
|
1354
|
+
return { predicate: sql`${ref} < ${value}`, negate: false };
|
|
1355
|
+
case "<=":
|
|
1356
|
+
return { predicate: sql`${ref} <= ${value}`, negate: false };
|
|
1357
|
+
case "in":
|
|
1358
|
+
return { predicate: isNullish ? isNull() : inList(), negate: false };
|
|
1359
|
+
case "not-in":
|
|
1360
|
+
return { predicate: isNullish ? isNull() : inList(), negate: true };
|
|
1361
|
+
case "like":
|
|
1362
|
+
return { predicate: sql`${ref} LIKE ${String(value)}`, negate: false };
|
|
1363
|
+
case "not-like":
|
|
1364
|
+
return { predicate: sql`${ref} LIKE ${String(value)}`, negate: true };
|
|
1365
|
+
case "ilike":
|
|
1366
|
+
case "not-ilike": {
|
|
1367
|
+
// `ILIKE` is only defined on the text family. Asking it of a
|
|
1368
|
+
// date or an integer is a Postgres error at execution time — a
|
|
1369
|
+
// 500 on a request whose only fault is an operator the admin
|
|
1370
|
+
// offered for the wrong column.
|
|
1371
|
+
if (!supportsILike(column)) {
|
|
1372
|
+
throw ApiError.badRequest(
|
|
1373
|
+
`Operator '${op}' cannot be applied to '${field}' on collection '${collectionPath}': ` +
|
|
1374
|
+
`'${column.name}' is ${column.getSQLType?.() ?? "not a text column"}, and case-insensitive ` +
|
|
1375
|
+
"matching is only defined on text.",
|
|
1376
|
+
"UNSUPPORTED_RELATION_FILTER_OPERATOR",
|
|
1377
|
+
{ field, collection: collectionPath, operator: op }
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
return { predicate: sql`${ref} ILIKE ${String(value)}`, negate: op === "not-ilike" };
|
|
1381
|
+
}
|
|
1382
|
+
case "is-null":
|
|
1383
|
+
return { predicate: isNull(), negate: false };
|
|
1384
|
+
case "is-not-null":
|
|
1385
|
+
return { predicate: sql`${ref} IS NOT NULL`, negate: false };
|
|
1386
|
+
case "array-contains":
|
|
1387
|
+
case "array-contains-any":
|
|
1388
|
+
return { predicate: contains(), negate: false };
|
|
1389
|
+
default:
|
|
1390
|
+
throw ApiError.badRequest(
|
|
1391
|
+
`Unknown filter operator '${op}'. Valid operators: ${ALL_WHERE_FILTER_OPS.join(", ")}.`,
|
|
1392
|
+
"UNKNOWN_FILTER_OPERATOR",
|
|
1393
|
+
{ operator: op, validOperators: ALL_WHERE_FILTER_OPS }
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* An aggregate over the rows a relation reaches, as a scalar expression —
|
|
1400
|
+
* what `orderBy: [{ relation: "applications", field: "created_at", agg:
|
|
1401
|
+
* "min" }, "asc"]` compiles to.
|
|
1402
|
+
*
|
|
1403
|
+
* (SELECT min(t.created_at) FROM talent_applications AS t
|
|
1404
|
+
* WHERE t.talent_id = talents.id)
|
|
1405
|
+
*
|
|
1406
|
+
* A correlated scalar subquery rather than a `LEFT JOIN LATERAL`: the join
|
|
1407
|
+
* would have to be threaded into a query the relational query builder
|
|
1408
|
+
* assembles, while a scalar expression drops straight into `ORDER BY` and
|
|
1409
|
+
* into the keyset comparison behind cursor paging — which has to be the
|
|
1410
|
+
* *same* expression, or paging and ordering disagree and rows are skipped.
|
|
1411
|
+
*
|
|
1412
|
+
* `correlateTo` is what the subquery is pinned against. Left out, it is the
|
|
1413
|
+
* outer row's key column and the expression is correlated in the ordinary
|
|
1414
|
+
* way. Given a literal — the cursor row's id — the subquery stops being
|
|
1415
|
+
* correlated at all, so Postgres evaluates it once for the whole statement
|
|
1416
|
+
* rather than per row. That is how a cursor pages over an aggregate it has
|
|
1417
|
+
* no stored value for: the value is recomputed from the id it does have.
|
|
1418
|
+
*
|
|
1419
|
+
* Over zero related rows `count` is 0 and every other function is NULL,
|
|
1420
|
+
* which is what puts "nobody waiting" at a defined end of the order rather
|
|
1421
|
+
* than wherever a missing value would land. See `buildOrderExpressions` for
|
|
1422
|
+
* where that end is pinned.
|
|
1423
|
+
*
|
|
1424
|
+
* Under RLS the subquery runs as the reader, so a related row the reader
|
|
1425
|
+
* cannot see does not contribute — an aggregate is over the rows that
|
|
1426
|
+
* reader can see, which is the only total it could honestly report.
|
|
1427
|
+
*/
|
|
1428
|
+
static buildRelationAggregateExpression(
|
|
1429
|
+
spec: RelationAggregateSort,
|
|
1430
|
+
table: PgTable<any>,
|
|
1431
|
+
collection: CollectionConfig,
|
|
1432
|
+
registry: PostgresCollectionRegistry,
|
|
1433
|
+
sourceIdColumn: AnyPgColumn,
|
|
1434
|
+
collectionPath: string,
|
|
1435
|
+
correlateTo?: unknown
|
|
1436
|
+
): SQL {
|
|
1437
|
+
const relation = resolveCollectionRelations(collection)[spec.relation];
|
|
1438
|
+
if (!relation) {
|
|
1439
|
+
throw ApiError.badRequest(
|
|
1440
|
+
`Cannot sort by '${encodeRelationAggregateSort(spec)}' on collection '${collectionPath}': ` +
|
|
1441
|
+
`'${spec.relation}' is not a relation there.`,
|
|
1442
|
+
"UNKNOWN_ORDER_BY_FIELD",
|
|
1443
|
+
{ field: encodeRelationAggregateSort(spec), collection: collectionPath, relation: spec.relation }
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
if (relation.kind === "via") {
|
|
1447
|
+
throw ApiError.badRequest(
|
|
1448
|
+
`Cannot sort by '${encodeRelationAggregateSort(spec)}' on collection '${collectionPath}': ` +
|
|
1449
|
+
"`via` relations are authored one way only, so there is nothing to correlate a subquery back to.",
|
|
1450
|
+
"ORDER_BY_FIELD_NOT_SORTABLE",
|
|
1451
|
+
{ field: encodeRelationAggregateSort(spec), collection: collectionPath, kind: relation.kind }
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const targetCollection = targetOf(relation);
|
|
1456
|
+
const targetTable = targetCollection && registry.getTable(getTableName(targetCollection));
|
|
1457
|
+
if (!targetCollection || !targetTable) {
|
|
1458
|
+
throw new Error(
|
|
1459
|
+
`Table not found for the target of relation '${relation.relationName}' on '${collectionPath}', ` +
|
|
1460
|
+
"so there is nothing to aggregate."
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const alias = "__rel_agg";
|
|
1465
|
+
// `count` with no field counts the related rows themselves. Every other
|
|
1466
|
+
// function needs something to aggregate, and a key that named no column
|
|
1467
|
+
// never got this far — `parseRelationAggregateSort` refuses it.
|
|
1468
|
+
let aggregand: SQL = sql`*`;
|
|
1469
|
+
if (spec.field) {
|
|
1470
|
+
const column = relationColumn(targetTable, targetCollection, spec.field)
|
|
1471
|
+
?? (spec.field in targetTable
|
|
1472
|
+
? targetTable[spec.field as keyof typeof targetTable] as AnyPgColumn
|
|
1473
|
+
: undefined);
|
|
1474
|
+
if (!column) {
|
|
1475
|
+
let validFields: string[] = [];
|
|
1476
|
+
try {
|
|
1477
|
+
validFields = Object.keys(getTableColumns(targetTable)).sort();
|
|
1478
|
+
} catch {
|
|
1479
|
+
// A table stand-in without Drizzle's column symbols.
|
|
1480
|
+
}
|
|
1481
|
+
throw ApiError.badRequest(
|
|
1482
|
+
`Unknown field '${spec.field}' on '${targetCollection.slug}', the target of relation ` +
|
|
1483
|
+
`'${spec.relation}' on collection '${collectionPath}'` +
|
|
1484
|
+
(validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""),
|
|
1485
|
+
"UNKNOWN_ORDER_BY_FIELD",
|
|
1486
|
+
{
|
|
1487
|
+
field: encodeRelationAggregateSort(spec),
|
|
1488
|
+
collection: collectionPath,
|
|
1489
|
+
relation: spec.relation,
|
|
1490
|
+
targetCollection: targetCollection.slug,
|
|
1491
|
+
...(validFields.length > 0 && { validFields })
|
|
1492
|
+
}
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
aggregand = sql`${sql.identifier(alias)}.${sql.identifier(column.name)}`;
|
|
1496
|
+
} else if (spec.agg !== "count") {
|
|
1497
|
+
throw ApiError.badRequest(
|
|
1498
|
+
`'${spec.agg}' needs a field to aggregate — only 'count' means something on its own.`,
|
|
1499
|
+
"ORDER_BY_FIELD_NOT_SORTABLE",
|
|
1500
|
+
{ field: encodeRelationAggregateSort(spec), collection: collectionPath }
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// The literal pins the subquery to one row; the column object binds it
|
|
1505
|
+
// to the outer row, because a Drizzle column renders qualified with its
|
|
1506
|
+
// own table. See `buildRelationScopeCondition`.
|
|
1507
|
+
const source: SQL = correlateTo === undefined ? sql`${sourceIdColumn}` : sql`${correlateTo}`;
|
|
1508
|
+
|
|
1509
|
+
let from: SQL;
|
|
1510
|
+
let correlation: SQL;
|
|
1511
|
+
|
|
1512
|
+
if (relation.kind === "manyToMany") {
|
|
1513
|
+
const { table: junctionName, sourceColumn, targetColumn } = relation.through;
|
|
1514
|
+
const junctionTable = registry.getTable(junctionName);
|
|
1515
|
+
if (!junctionTable) {
|
|
1516
|
+
throw new Error(`Junction table not found: ${junctionName}`);
|
|
1517
|
+
}
|
|
1518
|
+
const sourceCol = junctionTable[sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1519
|
+
const targetCol = junctionTable[targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
1520
|
+
if (!sourceCol || !targetCol) {
|
|
1521
|
+
throw new Error(
|
|
1522
|
+
`Junction columns '${sourceColumn}'/'${targetColumn}' not found in '${junctionName}'`
|
|
1523
|
+
);
|
|
1524
|
+
}
|
|
1525
|
+
const targetKey = this.primaryKeyColumn(targetTable);
|
|
1526
|
+
if (!targetKey) {
|
|
1527
|
+
throw new Error(
|
|
1528
|
+
`No primary key or "id" column in the target table of relation '${relation.relationName}'.`
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
const junctionAlias = "__rel_agg_junction";
|
|
1532
|
+
from = sql`${targetTable} AS ${sql.identifier(alias)} INNER JOIN ${junctionTable} AS ${sql.identifier(junctionAlias)} ON ${sql.identifier(junctionAlias)}.${sql.identifier(targetCol.name)} = ${sql.identifier(alias)}.${sql.identifier(targetKey.name)}`;
|
|
1533
|
+
correlation = sql`${sql.identifier(junctionAlias)}.${sql.identifier(sourceCol.name)} = ${source}`;
|
|
1534
|
+
} else if (relation.kind === "belongsTo") {
|
|
1535
|
+
const targetKey = this.primaryKeyColumn(targetTable);
|
|
1536
|
+
const localKey = relationColumn(table, collection, relation.localKey);
|
|
1537
|
+
if (!targetKey || !localKey) {
|
|
1538
|
+
throw new Error(
|
|
1539
|
+
`Relation '${relation.relationName}' on '${collectionPath}' names a key that is not a column, ` +
|
|
1540
|
+
"so there is nothing to aggregate against."
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
from = sql`${targetTable} AS ${sql.identifier(alias)}`;
|
|
1544
|
+
// A to-one reaches one row, so the aggregate is that row's value.
|
|
1545
|
+
// Pinning by a literal cursor id means looking the key up on the
|
|
1546
|
+
// cursor row rather than reading it off the outer one.
|
|
1547
|
+
const owner: SQL = correlateTo === undefined
|
|
1548
|
+
? sql`${localKey}`
|
|
1549
|
+
: sql`(SELECT ${sql.identifier(localKey.name)} FROM ${table} WHERE ${sourceIdColumn} = ${correlateTo})`;
|
|
1550
|
+
correlation = sql`${sql.identifier(alias)}.${sql.identifier(targetKey.name)} = ${owner}`;
|
|
1551
|
+
} else {
|
|
1552
|
+
const foreignKey = relationColumn(targetTable, targetCollection, relation.foreignKeyOnTarget);
|
|
1553
|
+
if (!foreignKey) {
|
|
1554
|
+
throw new Error(
|
|
1555
|
+
`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of ` +
|
|
1556
|
+
`relation '${relation.relationName}'.`
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
// `sourceKey` names a column other than the primary key when the
|
|
1560
|
+
// link joins on one; correlating on the id anyway aggregates over
|
|
1561
|
+
// nothing and every row sorts as though it had no related rows.
|
|
1562
|
+
const sourceKeyColumn = relation.sourceKey
|
|
1563
|
+
? relationColumn(table, collection, relation.sourceKey)
|
|
1564
|
+
: sourceIdColumn;
|
|
1565
|
+
if (!sourceKeyColumn) {
|
|
1566
|
+
throw new Error(
|
|
1567
|
+
`\`sourceKey: "${relation.sourceKey}"\` on relation '${relation.relationName}' is not a ` +
|
|
1568
|
+
`column on '${collectionPath}'.`
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
const pinned: SQL = correlateTo === undefined
|
|
1572
|
+
? sql`${sourceKeyColumn}`
|
|
1573
|
+
: relation.sourceKey
|
|
1574
|
+
? sql`(SELECT ${sql.identifier(sourceKeyColumn.name)} FROM ${table} WHERE ${sourceIdColumn} = ${correlateTo})`
|
|
1575
|
+
: sql`${correlateTo}`;
|
|
1576
|
+
from = sql`${targetTable} AS ${sql.identifier(alias)}`;
|
|
1577
|
+
correlation = sql`${sql.identifier(alias)}.${sql.identifier(foreignKey.name)} = ${pinned}`;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// The function name is not interpolated from input: it comes off a
|
|
1581
|
+
// five-member union the parser validated, and is written out here so
|
|
1582
|
+
// nothing string-shaped reaches the statement.
|
|
1583
|
+
const aggregate = AGGREGATE_SQL[spec.agg];
|
|
1584
|
+
return sql`(SELECT ${aggregate}(${aggregand}) FROM ${from} WHERE ${correlation})`;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
994
1587
|
/** The column a table's rows are keyed by: its primary key, else `id`. */
|
|
995
1588
|
private static primaryKeyColumn(table: PgTable<any>): AnyPgColumn | undefined {
|
|
996
1589
|
return (Object.values(table).find((col: Record<string, unknown>) => col.primary)
|