@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.
Files changed (29) hide show
  1. package/dist/{auth-users-columns-C-FDnL_e.js → auth-users-columns-JJ8ngvy5.js} +59 -17
  2. package/dist/auth-users-columns-JJ8ngvy5.js.map +1 -0
  3. package/dist/{backup-service-BH0Dzo_h.js → backup-service-czK-OAuG.js} +2 -2
  4. package/dist/{backup-service-BH0Dzo_h.js.map → backup-service-czK-OAuG.js.map} +1 -1
  5. package/dist/{ensure-collection-policies-DoHwhVf8.js → ensure-collection-policies-D5PtQLyR.js} +3 -3
  6. package/dist/{ensure-collection-policies-DoHwhVf8.js.map → ensure-collection-policies-D5PtQLyR.js.map} +1 -1
  7. package/dist/{ensure-collection-tables-DT2eq859.js → ensure-collection-tables-BHUjQ-z4.js} +3 -3
  8. package/dist/{ensure-collection-tables-DT2eq859.js.map → ensure-collection-tables-BHUjQ-z4.js.map} +1 -1
  9. package/dist/index.es.js +485 -19
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/{rls-bootstrap-sql-69hYT8nr.js → rls-bootstrap-sql-B5Sajku6.js} +2 -2
  12. package/dist/{rls-bootstrap-sql-69hYT8nr.js.map → rls-bootstrap-sql-B5Sajku6.js.map} +1 -1
  13. package/dist/{rls-enforcement-gUNDfm7l.js → rls-enforcement-BDBfuTD4.js} +4 -3
  14. package/dist/rls-enforcement-BDBfuTD4.js.map +1 -0
  15. package/dist/services/FetchService.d.ts +22 -0
  16. package/dist/{src-DCdn3Val.js → src-BBFsDaeA.js} +60 -2
  17. package/dist/src-BBFsDaeA.js.map +1 -0
  18. package/dist/utils/drizzle-conditions.d.ts +168 -1
  19. package/dist/utils/pg-error-utils.d.ts +27 -0
  20. package/dist/{websocket-D2jXv0Ds.js → websocket-BVgDVO-V.js} +2 -2
  21. package/dist/{websocket-D2jXv0Ds.js.map → websocket-BVgDVO-V.js.map} +1 -1
  22. package/package.json +6 -6
  23. package/src/services/FetchService.ts +155 -15
  24. package/src/services/PersistService.ts +23 -2
  25. package/src/utils/drizzle-conditions.ts +594 -1
  26. package/src/utils/pg-error-utils.ts +49 -4
  27. package/dist/auth-users-columns-C-FDnL_e.js.map +0 -1
  28. package/dist/rls-enforcement-gUNDfm7l.js.map +0 -1
  29. 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)