@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 0.18.0

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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,2743 +0,0 @@
1
- import { and, eq, or, sql, SQL, ilike, inArray, getTableColumns } from "drizzle-orm";
2
- import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
- import {
4
- ALL_WHERE_FILTER_OPS,
5
- CollectionConfig, FilterValues, WhereFilterOp, JoinStep, LogicalCondition, FilterCondition,
6
- ResolvedRelation, ResolvedBelongsTo, ResolvedHasOne, ResolvedHasMany,
7
- ResolvedForeignKeyOnTarget, ResolvedManyToMany, hasForeignKeyOnTarget, isManyToMany,
8
- encodeRelationAggregateSort, type RelationAggregateFn, type RelationAggregateSort
9
- } from "@rebasepro/types";
10
- import {
11
- fieldKeyForColumn, getColumnName, getTableName, normalizeToEntityRelation, resolveCollectionRelations, toFilterTuples
12
- } from "@rebasepro/common";
13
- import { generateForeignKeyName, toWireKey } from "@rebasepro/utils";
14
- /**
15
- * Postgres's own default for `pg_trgm.word_similarity_threshold`. Named here
16
- * because the fuzzy predicate has to know when the index-backed operator agrees
17
- * with the collection's declared threshold and when it would narrow too far.
18
- */
19
- const PG_TRGM_WORD_SIMILARITY_DEFAULT = 0.6;
20
- import { buildSearchColumnSpec, SEARCH_UNACCENT_FN, type SearchColumnSpec } from "../schema/search-column";
21
- import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
22
- import { ConditionBuilderStatic } from "../interfaces";
23
- import { ApiError, logger } from "@rebasepro/server";
24
- import { getColumnMeta } from "../services/collection-helpers";
25
-
26
- /**
27
- * What to do with a filter field that resolves to no column at all.
28
- *
29
- * - `"error"` (default) — reject the request. A filter that cannot be
30
- * compiled is *dropped*, and dropping a condition can only ever widen the
31
- * result set. On a data plane where row-level security is the last line of
32
- * defence, a typo'd or renamed filter key therefore runs the query without
33
- * that condition and returns everything RLS happens to allow.
34
- * - `"warn"` — the historical behaviour: log and silently drop the condition.
35
- * Only for a deployment that knowingly sends filter keys the table does not
36
- * have and has satisfied itself that widening is safe there.
37
- */
38
- export type UnknownFilterFieldsMode = "error" | "warn";
39
-
40
- /**
41
- * A user's search term, made safe to drop inside a `%…%` LIKE pattern.
42
- *
43
- * The term is already a bind parameter, so this is not about injection. It is
44
- * about the two things a LIKE metacharacter does when it arrives from a search
45
- * box:
46
- *
47
- * 1. **It changes the query.** `%` and `_` are wildcards, so searching for
48
- * `50%` returned every row and `a_c` matched `abc`. Nothing the caller
49
- * could type would find a literal `%`.
50
- * 2. **It is a cost the caller chooses.** Postgres matches LIKE by
51
- * backtracking: each `%` re-tries every remaining offset, so
52
- * `?searchString=a%a%a%a%a%a%a%b` is polynomial with an attacker-chosen
53
- * exponent — evaluated per row, OR-ed across every string property of the
54
- * collection, on a sequential scan (a leading `%` cannot use an index), and
55
- * the page limit does not bound it because the scan happens first.
56
- *
57
- * This is the server-side half of the pattern `like-pattern-redos.test.ts`
58
- * hardened the offline evaluator against; that test's own note ("the same
59
- * translation in the Mongo driver hands the expression to the database, where
60
- * it occupies a server thread instead") describes this call site.
61
- *
62
- * Backslash is the default `ESCAPE` character for LIKE, and the pattern is
63
- * bound rather than interpolated, so a single backslash here reaches the
64
- * matcher as one. Escaping the escape character first is what keeps a term
65
- * ending in `\` from swallowing the closing `%`.
66
- *
67
- * Note this is a *substring search*, not the `like` filter operator: a caller
68
- * who wants wildcards has `?title=like.foo%` for that, where the pattern is the
69
- * documented input.
70
- */
71
- export const escapeLikePattern = (value: string): string =>
72
- value.replace(/[\\%_]/g, ch => `\\${ch}`);
73
-
74
- /**
75
- * The Drizzle column a relation's column name addresses on a table.
76
- *
77
- * A relation names its link in *column* terms — `localKey: "author_id"`,
78
- * `foreignKeyOnTarget: "author_id"` — because that is what the database and
79
- * every FK constraint call it. A Drizzle table is keyed by the *wire* name,
80
- * `authorId`. Indexing the table with the column, which is what every one of
81
- * these call sites used to do, therefore finds nothing the moment the two
82
- * differ: for a `columnName`-carrying property that was already true, and it is
83
- * now true of every derived foreign key.
84
- *
85
- * `undefined` rather than a throw: each caller already has a message naming the
86
- * relation it was resolving, which is worth more than a generic one here.
87
- */
88
- const relationColumn = (
89
- table: PgTable<any>,
90
- collection: CollectionConfig | undefined,
91
- column: string
92
- ): AnyPgColumn | undefined => {
93
- const key = fieldKeyForColumn(collection, column);
94
- return (key in table ? table[key as keyof typeof table] as AnyPgColumn : undefined) || undefined;
95
- };
96
-
97
- /** The target collection of a relation, or `undefined` if its thunk cannot resolve. */
98
- const targetOf = (relation: ResolvedRelation): CollectionConfig | undefined => {
99
- try {
100
- return relation.target();
101
- } catch {
102
- return undefined;
103
- }
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
-
123
- /** Column types `ILIKE '%…%'` is defined on. */
124
- const ILIKE_SQL_TYPES = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
125
-
126
- /**
127
- * Can this column be matched with `ILIKE`?
128
- *
129
- * Asked of the column's *declared SQL type*, never with `instanceof`. The
130
- * previous version tested `column instanceof PgVarchar || … PgText || … PgChar`,
131
- * and `instanceof` compares class identity: it is only true when the column was
132
- * constructed by the very same copy of `drizzle-orm` that this module imported.
133
- *
134
- * An application's generated schema builds its tables with the app's own
135
- * `drizzle-orm`, and this driver declares its own dependency on one. When the
136
- * two ranges do not overlap — an app scaffolded against `^0.44` with a driver
137
- * asking for `^0.45` — a strict installer gives the driver a second copy, every
138
- * check returns false, no condition is produced, and the caller compiles that
139
- * into an impossible `WHERE`. The result is a 200 with an empty page for every
140
- * search on every collection without a `search` block: the failure looks
141
- * exactly like "nothing matched". Observed in production, not theorised.
142
- *
143
- * `getSQLType()` is a value the column reports about itself, so it crosses
144
- * module instances the way a class identity cannot. It also happens to fix
145
- * `citext`, which the `instanceof` list never covered.
146
- */
147
- const supportsILike = (column: AnyPgColumn): boolean => {
148
- const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
149
- return ILIKE_SQL_TYPES.test(sqlType);
150
- };
151
-
152
- /**
153
- * Process-wide default, set once when the driver is constructed.
154
- *
155
- * The condition builder is a set of *static* methods reached from a dozen
156
- * `FetchService` call sites, none of which carry the driver's config — the
157
- * service is built from `(db, registry)` alone. Threading an option from
158
- * `createPostgresAdapter` down to each of them would mean touching every
159
- * intermediate signature to plumb a value that is a single deployment-wide
160
- * switch. A module-level default set at adapter construction, plus an explicit
161
- * per-call override for callers that have one (tests, mainly), buys the same
162
- * control for none of the churn. It is safe by default, so the only reason to
163
- * set it at all is to opt *out*.
164
- */
165
- let defaultUnknownFilterFieldsMode: UnknownFilterFieldsMode = "error";
166
-
167
- /** Set the process-wide behaviour for unresolvable filter fields. */
168
- export function configureUnknownFilterFields(mode: UnknownFilterFieldsMode): void {
169
- defaultUnknownFilterFieldsMode = mode;
170
- }
171
-
172
- /** The process-wide behaviour for unresolvable filter fields. */
173
- export function getUnknownFilterFieldsMode(): UnknownFilterFieldsMode {
174
- return defaultUnknownFilterFieldsMode;
175
- }
176
-
177
- /** Per-call context for compiling a filter into SQL. */
178
- export interface FilterCompilationOptions {
179
- /**
180
- * Overrides the process-wide {@link UnknownFilterFieldsMode} for this call.
181
- */
182
- unknownFields?: UnknownFilterFieldsMode;
183
- /**
184
- * The collection the filter is written against. Its resolved relations are
185
- * what turn an owning-relation filter key into the foreign-key column it
186
- * actually lives in; without it only the default key shapes can be guessed.
187
- */
188
- collection?: CollectionConfig;
189
- /**
190
- * The driver's registry, for relations whose link is not on this row at
191
- * all. A `manyToMany` compiles to an `EXISTS` over its junction and a
192
- * `hasMany`/`hasOne` to one over the target table — neither of which this
193
- * builder can reach from the collection alone.
194
- */
195
- registry?: PostgresCollectionRegistry;
196
- /**
197
- * The key column of the table being filtered — what those `EXISTS`
198
- * subqueries correlate back to.
199
- *
200
- * It has to be the Drizzle column object rather than a name: a column
201
- * renders qualified with its own table, which is what binds it to the
202
- * *outer* row instead of to the junction or target aliased inside the
203
- * subquery. See {@link DrizzleConditionBuilder.buildRelationFilterCondition}.
204
- */
205
- sourceIdColumn?: AnyPgColumn;
206
- }
207
-
208
- /**
209
- * What a filter field turns out to name.
210
- *
211
- * A field naming a column compiles to a comparison on it. A field naming a
212
- * relation that owns no column here compiles to a whole `EXISTS` condition
213
- * instead, so there is no column to hand back — which is why resolution
214
- * answers with a discriminated result rather than a column. The caller cannot
215
- * tell the two apart from the field name, and the difference is not cosmetic:
216
- * one is `column <op> value`, the other is a correlated subquery.
217
- */
218
- type FilterTarget =
219
- | { kind: "column"; column: AnyPgColumn }
220
- | {
221
- /** A path *inside* a json/jsonb column — `metadata->>country`. */
222
- kind: "json";
223
- column: AnyPgColumn;
224
- /** The keys to walk, outermost first. Always at least one. */
225
- path: string[];
226
- }
227
- | {
228
- kind: "relation";
229
- relation: ResolvedForeignKeyOnTarget | ResolvedManyToMany;
230
- /** Bound here so the compile step cannot be reached without them. */
231
- registry: PostgresCollectionRegistry;
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;
254
- };
255
-
256
- /**
257
- * Split `metadata->address->>city` into its column and its path.
258
- *
259
- * The arrows are PostgREST's spelling and Postgres's own, so the filter reads
260
- * the same as the SQL it becomes — and, more usefully, the same as what someone
261
- * would have written by hand in the SQL console while working out what to ask
262
- * for. A field with no arrow is not a JSON path and returns `undefined`, which
263
- * leaves every existing filter on exactly the path it took before.
264
- *
265
- * Both arrows are accepted and mean the same thing here: the extraction is
266
- * always compiled to `->>` (text) at the leaf, because that is the only form a
267
- * comparison can be made against. `->` is allowed because people write it out
268
- * of habit, and refusing it would be pedantry about a distinction this layer
269
- * erases anyway.
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
-
290
- function parseJsonFieldPath(field: string): { columnKey: string; path: string[] } | undefined {
291
- if (!field.includes("->")) return undefined;
292
-
293
- const segments = field.split(/->>?/).map(s => s.trim()).filter(Boolean);
294
- if (segments.length < 2) return undefined;
295
-
296
- const [columnKey, ...path] = segments;
297
- return { columnKey, path };
298
- }
299
-
300
- /**
301
- * Filter values may arrive as relation wire objects — `EntityRelation`
302
- * instances or their JSON form `{ __type: "relation", id, path }` — e.g. when
303
- * the admin filters a relation column. SQL comparisons need the raw id, so
304
- * unwrap them here (element-wise for list operators like `in` / `not-in`).
305
- */
306
- function unwrapRelationFilterValue(value: unknown): unknown {
307
- if (Array.isArray(value)) return value.map(unwrapRelationFilterValue);
308
- const relation = normalizeToEntityRelation(value);
309
- return relation ? relation.id : value;
310
- }
311
-
312
- /**
313
- * The operand of `in`/`not-in`, as a list.
314
- *
315
- * A scalar is the one-element list, because that is what it means and because
316
- * the wire produces one: `?filter=id.in.5` parses to the string `"5"`, not to
317
- * `["5"]` — the REST dialect only builds an array when the value is
318
- * parenthesised. Treating that as malformed and dropping the condition turned
319
- * a perfectly ordinary query into an unfiltered read.
320
- *
321
- * The empty list stays empty. Callers must decide what "no candidates" means
322
- * for their operator — it is `FALSE` for `in` and `TRUE` for `not-in` — and
323
- * neither of those is "no condition at all".
324
- */
325
- function toMembershipList(value: unknown): unknown[] {
326
- return Array.isArray(value) ? value : [value];
327
- }
328
-
329
- /** Drizzle dynamic query builder — accepts innerJoin + where chaining */
330
-
331
- export interface DrizzleDynamicQuery {
332
- innerJoin(table: PgTable<any>, condition: SQL): this;
333
- where(condition: SQL | undefined): this;
334
- limit(limit: number): this;
335
- }
336
-
337
- /**
338
- * Unified condition builder for Drizzle/PostgreSQL queries.
339
- *
340
- * This class uses static methods and satisfies the ConditionBuilderStatic<SQL> type.
341
- * It translates Rebase filter conditions to Drizzle SQL conditions.
342
- *
343
- * @example
344
- * const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;
345
- */
346
- export class DrizzleConditionBuilder {
347
-
348
- /**
349
- * Express "reachable from this parent through this relation" as a plain
350
- * `WHERE` condition on the target table.
351
- *
352
- * This is the primitive that lets a relation be a *filter* rather than an
353
- * addressing scheme. A nested listing used to be served by its own query
354
- * builder — `fetchEntitiesUsingJoins`, which grew joins the root pipeline
355
- * did not have and lost the options the root pipeline did have (offset,
356
- * filter, orderBy, include). Reduced to a condition, the same listing runs
357
- * through the ordinary collection query, so it inherits all of them and
358
- * there is one read path instead of two.
359
- *
360
- * The shapes:
361
- * - inverse FK → `target.<fk> = :parentId`, a column comparison.
362
- * - `through` → `EXISTS (SELECT 1 FROM junction …)`, correlated on the
363
- * target's key, so the junction never multiplies rows the
364
- * way an `INNER JOIN` would.
365
- * - `joinPath` → the same `EXISTS`, with the path's steps joined inside
366
- * it and the final step correlating to the outer row.
367
- */
368
- static buildRelationScopeCondition(
369
- relation: ResolvedRelation,
370
- /**
371
- * Lazy: `via`, `belongsTo`, and a foreign key that points at a
372
- * `sourceKey` need the parent's own table. A junction and a plain
373
- * foreign key are expressible from the parent's *id* alone, and
374
- * requiring the table for them would make a child listing fail on a
375
- * parent whose table isn't registered.
376
- */
377
- parent: () => { table: PgTable<any>; idColumn: AnyPgColumn },
378
- parentId: string | number,
379
- targetTable: PgTable<any>,
380
- targetIdColumn: AnyPgColumn,
381
- registry: PostgresCollectionRegistry
382
- ): SQL {
383
- switch (relation.kind) {
384
- case "via": {
385
- const { table, idColumn } = parent();
386
- return this.buildJoinPathScopeCondition(
387
- relation.joinPath, table, idColumn, parentId, targetTable, registry
388
- );
389
- }
390
-
391
- case "manyToMany": {
392
- const { table: junctionName, sourceColumn, targetColumn } = relation.through;
393
- const junctionTable = registry.getTable(junctionName);
394
- if (!junctionTable) {
395
- throw new Error(`Junction table not found: ${junctionName}`);
396
- }
397
- const sourceCol = junctionTable[sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
398
- const targetCol = junctionTable[targetColumn as keyof typeof junctionTable] as AnyPgColumn;
399
- if (!sourceCol || !targetCol) {
400
- throw new Error(
401
- `Junction columns '${sourceColumn}'/'${targetColumn}' not found in '${junctionName}'`
402
- );
403
- }
404
- // Correlated, not joined: a join through a junction multiplies
405
- // the target rows by the number of matching links and silently
406
- // breaks `limit`/`offset`.
407
- //
408
- // The junction is aliased and referenced by identifier, never as a
409
- // Drizzle column. A column object carries no table qualifier of its
410
- // own — it is rendered against whatever the surrounding builder
411
- // thinks the current table is — so inside `db.query.findMany`, which
412
- // aliases the root table, `${sourceCol}` came out qualified with the
413
- // *target's* alias: `podcast.podcast_id`, a column that does not
414
- // exist. That aborts the transaction, and the fallback read then
415
- // fails on the poisoned transaction rather than on anything to do
416
- // with the relation. Only `targetIdColumn` stays a column object,
417
- // because that one *must* bind to the outer row to correlate.
418
- //
419
- // Aliasing also disambiguates a self-referential many-to-many, where
420
- // the junction and the target are the same table.
421
- const junctionAlias = "__rel_m2m";
422
- const junctionRef = (column: AnyPgColumn) =>
423
- sql`${sql.identifier(junctionAlias)}.${sql.identifier(column.name)}`;
424
- return sql`EXISTS (SELECT 1 FROM ${junctionTable} AS ${sql.identifier(junctionAlias)} WHERE ${junctionRef(targetCol)} = ${targetIdColumn} AND ${junctionRef(sourceCol)} = ${parentId})`;
425
- }
426
-
427
- case "hasOne":
428
- case "hasMany": {
429
- const fkColumn = relationColumn(targetTable, targetOf(relation), relation.foreignKeyOnTarget);
430
- if (!fkColumn) {
431
- throw new Error(
432
- `Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of ` +
433
- `relation '${relation.relationName}'.`
434
- );
435
- }
436
- if (!relation.sourceKey) return eq(fkColumn, parentId);
437
-
438
- // A link on a natural key: the foreign key holds a column of the
439
- // parent row, not its id, so the parent has to be read. As a
440
- // subquery rather than a prior SELECT, for the same reason
441
- // `belongsTo` below is — one statement sees one snapshot, and a
442
- // scope condition that read the key separately could be built
443
- // from a value the very next statement no longer agrees with.
444
- const { table, idColumn } = parent();
445
- return sql`${fkColumn} = (SELECT ${sql.identifier(relation.sourceKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
446
- }
447
-
448
- case "belongsTo": {
449
- // The single target row the parent's foreign key points at.
450
- const { table, idColumn } = parent();
451
- return sql`${targetIdColumn} = (SELECT ${sql.identifier(relation.localKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
452
- }
453
-
454
- default: {
455
- // Exhaustive. There is no "declares nothing" case to fall
456
- // through to any more — every kind names its own link.
457
- const exhaustive: never = relation;
458
- throw new Error(`Unknown relation kind: ${JSON.stringify(exhaustive)}`);
459
- }
460
- }
461
- }
462
-
463
- /**
464
- * `EXISTS` for an explicit `joinPath`.
465
- *
466
- * The path is declared source → target. The subquery replays every step but
467
- * the last from inside, and turns the last one into the correlation with the
468
- * outer target row — so the target table is never named twice and needs no
469
- * alias. Each intermediate table is aliased positionally, which keeps a path
470
- * that revisits a table (a self-referencing many-to-many) unambiguous.
471
- */
472
- private static buildJoinPathScopeCondition(
473
- joinPath: JoinStep[],
474
- parentTable: PgTable<any>,
475
- parentIdColumn: AnyPgColumn,
476
- parentId: string | number,
477
- targetTable: PgTable<any>,
478
- registry: PostgresCollectionRegistry
479
- ): SQL {
480
- const sourceAlias = "__rel_src";
481
- const aliasFor = (index: number) => `__rel_j${index}`;
482
-
483
- // Column reference against the previous hop: the aliased source for the
484
- // first step, the previous aliased join table after that.
485
- const fromRef = (stepIndex: number, column: string) =>
486
- sql`${sql.identifier(stepIndex === 0 ? sourceAlias : aliasFor(stepIndex - 1))}.${sql.identifier(getColumnName(column))}`;
487
-
488
- const pairs = (step: JoinStep): { from: string; to: string }[] => {
489
- const from = Array.isArray(step.on.from) ? step.on.from : [step.on.from];
490
- const to = Array.isArray(step.on.to) ? step.on.to : [step.on.to];
491
- if (from.length !== to.length) {
492
- throw new Error(`Join step on '${step.table}' has ${from.length} \`from\` columns and ${to.length} \`to\` columns`);
493
- }
494
- return from.map((f, i) => ({ from: f, to: to[i] }));
495
- };
496
-
497
- const inner = joinPath.slice(0, -1);
498
- const last = joinPath[joinPath.length - 1];
499
-
500
- const joins: SQL[] = inner.map((step, index) => {
501
- const table = registry.getTable(step.table);
502
- if (!table) throw new Error(`Join table not found: ${step.table}`);
503
- const on = pairs(step).map(({ from, to }) =>
504
- sql`${fromRef(index, from)} = ${sql.identifier(aliasFor(index))}.${sql.identifier(getColumnName(to))}`
505
- );
506
- return sql`JOIN ${table} AS ${sql.identifier(aliasFor(index))} ON ${sql.join(on, sql` AND `)}`;
507
- });
508
-
509
- // The last step correlates to the outer row instead of joining the
510
- // target table into the subquery.
511
- //
512
- // On the step's own `to` column, which is not always the target's
513
- // primary key: a one-step path like `{ table: "posts", on: { from:
514
- // "id", to: "author_id" } }` correlates through a foreign key, and
515
- // matching `authors.id = posts.id` there compares two unrelated
516
- // identifiers — which returns nothing, quietly. Referencing the Drizzle
517
- // column rather than a bare name also keeps it qualified, so it binds
518
- // to the outer target and not to a table joined inside the EXISTS.
519
- const targetColumn = (name: string): AnyPgColumn => {
520
- const column = targetTable[getColumnName(name) as keyof typeof targetTable] as AnyPgColumn;
521
- if (!column) {
522
- throw new Error(`Join step column '${name}' not found in the target table of this joinPath`);
523
- }
524
- return column;
525
- };
526
-
527
- const correlation = sql.join(
528
- pairs(last).map(({ from, to }) => sql`${fromRef(inner.length, from)} = ${targetColumn(to)}`),
529
- sql` AND `
530
- );
531
-
532
- const joinsSql = joins.length > 0 ? sql` ${sql.join(joins, sql` `)}` : sql``;
533
-
534
- return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
535
- }
536
-
537
- /**
538
- * What a filter field names, or `undefined` if it names nothing.
539
- *
540
- * Three ways a field resolves. It may address its column directly; it may
541
- * be an owning relation, whose foreign key is a column here; or it may be
542
- * a relation whose link lives on another table entirely, which compiles to
543
- * a subquery instead of a column. Only a field that resolves to *none* of
544
- * them is an error, and by default it is one: see
545
- * {@link UnknownFilterFieldsMode} for why silently dropping it is a
546
- * data-exposure primitive rather than a convenience.
547
- *
548
- * For an owning relation the relation's own `localKey` is the authority,
549
- * not `<field>_id`. The default local key is `generateForeignKeyName`,
550
- * which snake-cases *and singularises* — `userProfile` → `user_profile_id`,
551
- * `users` → `user_id` — and it can be overridden outright. Guessing
552
- * `<field>_id` therefore misses perfectly ordinary owning relations, and
553
- * with this resolution failing closed that miss is a 400 on a filter that
554
- * has nothing wrong with it. The guesses stay, last, for callers that hand
555
- * over no collection to resolve against.
556
- *
557
- * The subquery kinds need a registry and the source table's key column on
558
- * top of the collection. A caller that supplies neither gets the behaviour
559
- * it had before they were compilable — unresolvable, and so fail-closed —
560
- * rather than a half-built condition.
561
- */
562
- private static resolveFilterTarget(
563
- table: PgTable<any>,
564
- field: string,
565
- collectionPath: string,
566
- mode: UnknownFilterFieldsMode,
567
- options: FilterCompilationOptions
568
- ): FilterTarget | undefined {
569
- const { collection, registry, sourceIdColumn } = options;
570
-
571
- const columnAt = (key: string): AnyPgColumn | undefined =>
572
- (key in table ? table[key as keyof typeof table] as AnyPgColumn : undefined) || undefined;
573
-
574
- const direct = columnAt(field);
575
- if (direct) return { kind: "column", column: direct };
576
-
577
- // Checked after the direct lookup, so a column literally named with an
578
- // arrow — which Postgres permits, if someone quoted it — still wins.
579
- const jsonPath = parseJsonFieldPath(field);
580
- if (jsonPath) {
581
- const base = columnAt(jsonPath.columnKey);
582
- if (base) {
583
- const meta = getColumnMeta(base);
584
- // Refused rather than compiled: `->>` on a text column is a
585
- // Postgres error at execution time, which surfaces as a 500 on
586
- // a request whose only fault is a typo'd column name.
587
- if (meta.dataType !== "json" && meta.columnType !== "PgJsonb" && meta.columnType !== "PgJson") {
588
- throw ApiError.badRequest(
589
- `Cannot filter inside "${jsonPath.columnKey}" — it is not a json or jsonb column.`,
590
- "INVALID_FILTER_FIELD"
591
- );
592
- }
593
- return { kind: "json", column: base, path: jsonPath.path };
594
- }
595
- }
596
-
597
- if (collection) {
598
- const relation = resolveCollectionRelations(collection)[field];
599
-
600
- // Owning relation, resolved: the relation names its own local key.
601
- if (relation?.kind === "belongsTo") {
602
- const foreignKey = relationColumn(table, collection, relation.localKey);
603
- if (foreignKey) return { kind: "column", column: foreignKey };
604
- }
605
-
606
- // The link is on the target table or in a junction. `via` is left
607
- // out: its join path is authored source → target with no stated
608
- // inverse, so reversing it into a filter is a different problem
609
- // from the two shapes below rather than a third case of them.
610
- if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
611
- // The `EXISTS` correlates the target's foreign key with a column
612
- // on *this* table, and that is the primary key only when the
613
- // link joins on it. A `sourceKey` names a different one, and
614
- // correlating on the id anyway silently matches nothing —
615
- // "filter by this relation" would quietly return zero rows.
616
- const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey
617
- ? relationColumn(table, collection, relation.sourceKey)
618
- : sourceIdColumn;
619
- if (!correlationColumn) {
620
- throw new Error(
621
- `\`sourceKey: "${(relation as ResolvedForeignKeyOnTarget).sourceKey}"\` on relation ` +
622
- `'${relation.relationName}' is not a column on '${collectionPath}', so a filter on ` +
623
- "that relation has nothing to correlate against."
624
- );
625
- }
626
- return { kind: "relation", relation, registry, sourceIdColumn: correlationColumn };
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
- }
639
- }
640
-
641
- // No collection in hand — the shapes an owning relation's key takes by
642
- // default (e.g. `project` → `projectId`, `userProfile` →
643
- // `userProfileId`). The snake forms stay in the list because a project
644
- // may have authored the property under its column name, which is still
645
- // its wire name.
646
- for (const guess of [
647
- `${field}Id`,
648
- toWireKey(generateForeignKeyName(field)),
649
- `${field}_id`,
650
- generateForeignKeyName(field)
651
- ]) {
652
- const foreignKey = columnAt(guess);
653
- if (foreignKey) return { kind: "column", column: foreignKey };
654
- }
655
-
656
- if (mode === "warn") {
657
- logger.warn(`Filtering by field '${field}', but it does not exist in table for collection '${collectionPath}'`);
658
- return undefined;
659
- }
660
-
661
- let validFields: string[] = [];
662
- try {
663
- validFields = Object.keys(getTableColumns(table)).sort();
664
- } catch {
665
- // A table stand-in without Drizzle's column symbols — the message
666
- // is worth less without the list, but not worth failing over.
667
- }
668
-
669
- // Not `expected`: unlike an anonymous token refresh, this is never a
670
- // routine outcome. It means a filter key and the schema have drifted
671
- // apart, which used to widen results silently — exactly the thing an
672
- // operator wants in the log at warn.
673
- throw ApiError.badRequest(
674
- `Unknown filter field '${field}' on collection '${collectionPath}'` +
675
- (validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""),
676
- "UNKNOWN_FILTER_FIELD",
677
- { field, collection: collectionPath, ...(validFields.length > 0 && { validFields }) }
678
- );
679
- }
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
-
781
- /**
782
- * Build filter conditions from FilterValues
783
- */
784
- static buildFilterConditions<M extends Record<string, unknown>>(
785
- filter: FilterValues<Extract<keyof M, string>>,
786
- table: PgTable<any>,
787
- collectionPath: string,
788
- options: FilterCompilationOptions = {}
789
- ): SQL[] {
790
- const mode = options.unknownFields ?? defaultUnknownFilterFieldsMode;
791
- const conditions: SQL[] = [];
792
-
793
- for (const [field, filterParam] of Object.entries(filter)) {
794
- if (!filterParam) continue;
795
-
796
- const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
797
- if (!target) continue;
798
-
799
- // One tuple or an array of them — the grammar, read the same way by
800
- // every compiler. See `toFilterTuples`.
801
- for (const [op, value] of toFilterTuples(filterParam)) {
802
- const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
803
- if (condition) {
804
- conditions.push(condition);
805
- }
806
- }
807
- }
808
-
809
- return conditions;
810
- }
811
-
812
- /**
813
- * Build logical conditions recursively from LogicalCondition or FilterCondition
814
- */
815
- static buildLogicalConditions(
816
- cond: LogicalCondition | FilterCondition,
817
- table: PgTable<any>,
818
- collectionPath: string,
819
- options: FilterCompilationOptions = {}
820
- ): SQL | null {
821
- if ("type" in cond) {
822
- const subSQLs = cond.conditions
823
- .map(c => this.buildLogicalConditions(c, table, collectionPath, options))
824
- .filter((sql): sql is SQL => sql !== null);
825
- if (subSQLs.length === 0) return null;
826
- return (cond.type === "or" ? or(...subSQLs) : and(...subSQLs)) ?? null;
827
- } else {
828
- // A dropped leaf is worse here than in a flat filter: inside an
829
- // `or(...)` the disjunction loses a branch, so the surviving
830
- // branches match on their own and the result set widens by
831
- // everything the dropped leaf would have excluded.
832
- const target = this.resolveFilterTarget(
833
- table,
834
- cond.column,
835
- collectionPath,
836
- options.unknownFields ?? defaultUnknownFilterFieldsMode,
837
- options
838
- );
839
- if (!target) return null;
840
- return this.compileFilterTarget(
841
- target, cond.operator as WhereFilterOp, cond.value, cond.column, collectionPath
842
- );
843
- }
844
- }
845
-
846
- /** Dispatch a resolved filter field onto the shape it actually compiles to. */
847
- private static compileFilterTarget(
848
- target: FilterTarget,
849
- op: WhereFilterOp,
850
- value: unknown,
851
- field: string,
852
- collectionPath: string
853
- ): SQL | null {
854
- if (target.kind === "column") {
855
- return this.buildSingleFilterCondition(target.column, op, value);
856
- }
857
- if (target.kind === "json") {
858
- return this.buildJsonPathCondition(target.column, target.path, op, value);
859
- }
860
- if (target.kind === "relation-field") {
861
- return this.buildRelationFieldCondition(target, op, value, field, collectionPath);
862
- }
863
- return this.buildRelationFilterCondition(
864
- target.relation, op, value, target.sourceIdColumn, target.registry, field, collectionPath
865
- );
866
- }
867
-
868
- /**
869
- * A comparison against a value extracted from a json/jsonb column.
870
- *
871
- * The path is walked with `->` and the leaf taken with `->>`, so what comes
872
- * out is always **text**. That is the whole of the type story, and it is
873
- * the part worth being explicit about, because the alternatives are all
874
- * worse:
875
- *
876
- * - text comparison alone makes `["<", 100]` compare lexically, where
877
- * `"9"` is greater than `"100"`;
878
- * - casting unconditionally makes every filter on a non-numeric value a
879
- * runtime `invalid input syntax for type numeric` — a 500 on a row whose
880
- * JSON simply holds a string.
881
- *
882
- * So the *filter value* decides. A number on an ordering comparison casts
883
- * both sides to numeric; everything else compares as text, with booleans
884
- * rendered the way `->>` renders them (`"true"` / `"false"`). A row whose
885
- * JSON holds a non-numeric value at a path being compared numerically is
886
- * excluded rather than fatal, which is what `IS NOT NULL`-style filtering
887
- * means everywhere else in this file.
888
- *
889
- * The path segments are bound as parameters, never interpolated: they come
890
- * from a query string, and `->>` takes a text parameter perfectly well.
891
- */
892
- private static buildJsonPathCondition(
893
- column: AnyPgColumn,
894
- path: string[],
895
- op: WhereFilterOp,
896
- value: unknown
897
- ): SQL | null {
898
- // Every segment but the last with `->` (staying in json), the last
899
- // with `->>` (leaving as text).
900
- let expr: SQL = sql`${column}`;
901
- for (const key of path.slice(0, -1)) {
902
- expr = sql`${expr} -> ${key}`;
903
- }
904
- const leaf = sql`${expr} ->> ${path[path.length - 1]}`;
905
-
906
- const numericComparison = typeof value === "number" &&
907
- (op === ">" || op === ">=" || op === "<" || op === "<=");
908
-
909
- if (numericComparison) {
910
- // The guard is what keeps this from being a 500: rows whose value
911
- // at this path is not a number are excluded, not fatal.
912
- const numeric = sql`CASE WHEN ${leaf} ~ '^-?[0-9]+(\\.[0-9]+)?$' THEN (${leaf})::numeric END`;
913
- switch (op) {
914
- case ">": return sql`${numeric} > ${value}`;
915
- case ">=": return sql`${numeric} >= ${value}`;
916
- case "<": return sql`${numeric} < ${value}`;
917
- case "<=": return sql`${numeric} <= ${value}`;
918
- }
919
- }
920
-
921
- const asText = (v: unknown): string => typeof v === "boolean" ? String(v) : String(v);
922
-
923
- switch (op) {
924
- case "==":
925
- return value === null || value === undefined ? sql`${leaf} IS NULL` : sql`${leaf} = ${asText(value)}`;
926
- case "!=":
927
- return value === null || value === undefined ? sql`${leaf} IS NOT NULL` : sql`${leaf} != ${asText(value)}`;
928
- case ">": return sql`${leaf} > ${asText(value)}`;
929
- case ">=": return sql`${leaf} >= ${asText(value)}`;
930
- case "<": return sql`${leaf} < ${asText(value)}`;
931
- case "<=": return sql`${leaf} <= ${asText(value)}`;
932
- case "like": return sql`${leaf} LIKE ${asText(value)}`;
933
- case "ilike": return sql`${leaf} ILIKE ${asText(value)}`;
934
- case "not-like": return sql`${leaf} NOT LIKE ${asText(value)}`;
935
- case "not-ilike": return sql`${leaf} NOT ILIKE ${asText(value)}`;
936
- case "is-null": return sql`${leaf} IS NULL`;
937
- case "is-not-null": return sql`${leaf} IS NOT NULL`;
938
- case "in":
939
- case "not-in": {
940
- if (value === null || value === undefined) {
941
- return op === "in" ? sql`${leaf} IS NULL` : sql`${leaf} IS NOT NULL`;
942
- }
943
- const values = toMembershipList(value).map(asText);
944
- // Same inversion guard as the column path: an empty list
945
- // matches nothing, and dropping the condition would match
946
- // everything.
947
- if (values.length === 0) return op === "in" ? sql`FALSE` : sql`TRUE`;
948
- const list = sql.join(values.map(v => sql`${v}`), sql`, `);
949
- return op === "in" ? sql`${leaf} IN (${list})` : sql`${leaf} NOT IN (${list})`;
950
- }
951
- default:
952
- // `array-contains` and friends are about the column, not a
953
- // scalar inside it — `metadata @> '{"tags":["x"]}'` is the
954
- // question, and it is asked of the column directly.
955
- throw ApiError.badRequest(
956
- `Operator "${op}" is not supported on a JSON path. Use it on the column itself.`,
957
- "INVALID_FILTER_OPERATOR"
958
- );
959
- }
960
- }
961
-
962
- /**
963
- * A filter on a relation that owns no column on this row — `EXISTS` over
964
- * the rows it reaches.
965
- *
966
- * `posts` filtered by `tags == <tagId>` is not a comparison on `posts`; it
967
- * is a question about the junction:
968
- *
969
- * EXISTS (SELECT 1 FROM posts_tags AS j
970
- * WHERE j.post_id = posts.id AND j.tag_id = <tagId>)
971
- *
972
- * which is {@link buildRelationScopeCondition}'s many-to-many shape with
973
- * source and target swapped — there the junction's *target* column
974
- * correlates and the source is pinned; here the *source* column correlates
975
- * and the target is what the filter constrains.
976
- *
977
- * `hasMany`/`hasOne` are the same shape one table over: the target row
978
- * carries the foreign key, so the correlation is on that key and the
979
- * compared column is the target's own id.
980
- *
981
- * `EXISTS` and not a join, for the reason the scope condition gives: a join
982
- * through a junction multiplies the outer rows by the number of matching
983
- * links, which duplicates results and silently breaks `limit`/`offset`.
984
- *
985
- * Everything inside the subquery is referenced by identifier against a
986
- * local alias, and only `sourceIdColumn` stays a Drizzle column object —
987
- * again see {@link buildRelationScopeCondition}, which explains why a
988
- * column object renders against whatever table the surrounding builder
989
- * thinks is current and so cannot be used for the inner references. The
990
- * alias is also what keeps a self-referential relation unambiguous
991
- * (`categories.children`, or a many-to-many whose junction and target are
992
- * the same table), where the subquery's table and the outer one coincide.
993
- */
994
- static buildRelationFilterCondition(
995
- relation: ResolvedForeignKeyOnTarget | ResolvedManyToMany,
996
- op: WhereFilterOp,
997
- value: unknown,
998
- sourceIdColumn: AnyPgColumn,
999
- registry: PostgresCollectionRegistry,
1000
- field: string,
1001
- collectionPath: string
1002
- ): SQL {
1003
- const alias = "__rel_filter";
1004
- const ref = (column: AnyPgColumn) =>
1005
- sql`${sql.identifier(alias)}.${sql.identifier(column.name)}`;
1006
-
1007
- let scanTable: PgTable<any>;
1008
- let correlation: SQL;
1009
- let comparedColumn: AnyPgColumn;
1010
-
1011
- if (relation.kind === "manyToMany") {
1012
- const { table: junctionName, sourceColumn, targetColumn } = relation.through;
1013
- const junctionTable = registry.getTable(junctionName);
1014
- if (!junctionTable) {
1015
- throw new Error(`Junction table not found: ${junctionName}`);
1016
- }
1017
- const sourceCol = junctionTable[sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
1018
- const targetCol = junctionTable[targetColumn as keyof typeof junctionTable] as AnyPgColumn;
1019
- if (!sourceCol || !targetCol) {
1020
- throw new Error(
1021
- `Junction columns '${sourceColumn}'/'${targetColumn}' not found in '${junctionName}'`
1022
- );
1023
- }
1024
- scanTable = junctionTable;
1025
- correlation = sql`${ref(sourceCol)} = ${sourceIdColumn}`;
1026
- comparedColumn = targetCol;
1027
- } else {
1028
- const targetCollection = relation.target();
1029
- const targetTable = registry.getTable(getTableName(targetCollection));
1030
- if (!targetTable) {
1031
- throw new Error(
1032
- `Table not found for the target of relation '${relation.relationName}' ` +
1033
- `(collection '${targetCollection.slug}')`
1034
- );
1035
- }
1036
- const fkColumn = relationColumn(targetTable, targetCollection, relation.foreignKeyOnTarget);
1037
- if (!fkColumn) {
1038
- throw new Error(
1039
- `Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of ` +
1040
- `relation '${relation.relationName}'.`
1041
- );
1042
- }
1043
- // The filter value is a target row's id, so that is what the
1044
- // subquery compares — the foreign key is spent on the correlation.
1045
- const targetIdColumn = this.primaryKeyColumn(targetTable);
1046
- if (!targetIdColumn) {
1047
- throw new Error(
1048
- `No primary key or "id" column in the target table of relation '${relation.relationName}', ` +
1049
- `so a filter on it has nothing to match against.`
1050
- );
1051
- }
1052
- scanTable = targetTable;
1053
- correlation = sql`${ref(fkColumn)} = ${sourceIdColumn}`;
1054
- comparedColumn = targetIdColumn;
1055
- }
1056
-
1057
- const { predicate, negate } = this.buildRelationFilterPredicate(
1058
- ref(comparedColumn), op, value, field, collectionPath
1059
- );
1060
-
1061
- const where = predicate ? sql`${correlation} AND ${predicate}` : correlation;
1062
- const exists = sql`EXISTS (SELECT 1 FROM ${scanTable} AS ${sql.identifier(alias)} WHERE ${where})`;
1063
- return negate ? sql`NOT ${exists}` : exists;
1064
- }
1065
-
1066
- /**
1067
- * The inner predicate of a relation filter, and whether the `EXISTS`
1068
- * wrapping it is negated.
1069
- *
1070
- * Negation is `NOT EXISTS` of the *positive* predicate, never `EXISTS` of a
1071
- * negated one. On a many-valued relation the two are different questions:
1072
- * `EXISTS (… AND tag_id != X)` asks "does some tag differ from X", which is
1073
- * true of nearly every post with more than one tag and answers nothing
1074
- * anybody asked. `NOT EXISTS (… AND tag_id = X)` asks "is X absent", which
1075
- * is what unticking a value in a filter control means — and it makes `==`
1076
- * and `!=` partition the rows, the way a filter implies they do.
1077
- *
1078
- * `is-null`/`is-not-null` drop the predicate entirely: with nothing but the
1079
- * correlation left, they become "has no related row at all" and "has at
1080
- * least one", which is the only reading of null a link can have.
1081
- *
1082
- * Under RLS, "no related row" means *no row this reader can see*. A junction
1083
- * with row-level security but no `SELECT` policy for `rebase_user` is opaque
1084
- * to it, so every row comes back looking unlinked and `is-null` matches all
1085
- * of them. That is not a leak — the outer table's own policies still decide
1086
- * which rows exist at all, and the positive direction correctly returns
1087
- * nothing — but it over-reports, and the cause is a missing junction policy
1088
- * rather than anything here. Rebase derives one for a declared many-to-many;
1089
- * a hand-written schema has to supply it.
1090
- *
1091
- * `in`/`not-in` against a *null value* mean the same thing, rather than
1092
- * membership of an empty list. Membership against null is not a membership
1093
- * question, and the admin's "filter for null values" control emits the
1094
- * operator that happens to be selected — on a to-many relation that is
1095
- * always `in` or `not-in`, because those are the only ones the multi-select
1096
- * can produce. Reading `["in", null]` as an empty list would answer "posts
1097
- * with no tags" with no posts at all.
1098
- *
1099
- * An empty `in` list compiles to `FALSE` rather than being dropped. Dropped
1100
- * is what the column path does, and dropping a condition widens the result
1101
- * — the whole reason this resolution fails closed. `in []` matches nothing
1102
- * and `not-in []` matches everything, and `NOT EXISTS (… AND FALSE)` gives
1103
- * the second for free.
1104
- *
1105
- * Anything else is rejected. Returning `null` for an operator this cannot
1106
- * express would drop the condition, and the operators the admin offers for
1107
- * a relation are exactly the six below.
1108
- */
1109
- private static buildRelationFilterPredicate(
1110
- ref: SQL,
1111
- op: WhereFilterOp,
1112
- value: unknown,
1113
- field: string,
1114
- collectionPath: string
1115
- ): { predicate?: SQL; negate: boolean } {
1116
- value = unwrapRelationFilterValue(value);
1117
- const isNullish = value === null || value === undefined;
1118
-
1119
- const equals = () => sql`${ref} = ${value}`;
1120
- const inList = () => {
1121
- // Same reading as the column path: a scalar is the one-element
1122
- // list, an empty list is `FALSE`. Here `FALSE` also gives
1123
- // `not-in []` its answer for free — `NOT EXISTS (… AND FALSE)`
1124
- // is true of every row, which is what excluding nothing means.
1125
- const values = toMembershipList(value);
1126
- return values.length === 0
1127
- ? sql`FALSE`
1128
- : sql`${ref} IN (${sql.join(values.map(v => sql`${v}`), sql`, `)})`;
1129
- };
1130
-
1131
- switch (op) {
1132
- case "==":
1133
- return isNullish ? { negate: true } : { predicate: equals(), negate: false };
1134
- case "!=":
1135
- return isNullish ? { negate: false } : { predicate: equals(), negate: true };
1136
- case "in":
1137
- return isNullish ? { negate: true } : { predicate: inList(), negate: false };
1138
- case "not-in":
1139
- return isNullish ? { negate: false } : { predicate: inList(), negate: true };
1140
- case "is-null":
1141
- return { negate: true };
1142
- case "is-not-null":
1143
- return { negate: false };
1144
- // A to-many relation *is* the list, so the array operators ask the
1145
- // same two questions under different names: "contains X" is "some
1146
- // related row is X", and "contains any of [X, Y]" is `in`. They
1147
- // reach here because the admin offers them for a property that is
1148
- // an *array of* relations, and rejecting a question the shape
1149
- // answers perfectly well would put a 400 behind a working control.
1150
- case "array-contains":
1151
- return isNullish ? { negate: true } : { predicate: equals(), negate: false };
1152
- case "array-contains-any":
1153
- return isNullish ? { negate: true } : { predicate: inList(), negate: false };
1154
- default:
1155
- throw ApiError.badRequest(
1156
- `Operator '${op}' cannot be applied to relation field '${field}' on collection ` +
1157
- `'${collectionPath}'. A relation with no column on this row is filtered by ` +
1158
- "membership: ==, !=, in, not-in, array-contains, array-contains-any, is-null, " +
1159
- "is-not-null.",
1160
- "UNSUPPORTED_RELATION_FILTER_OPERATOR",
1161
- { field, collection: collectionPath, operator: op }
1162
- );
1163
- }
1164
- }
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
-
1587
- /** The column a table's rows are keyed by: its primary key, else `id`. */
1588
- private static primaryKeyColumn(table: PgTable<any>): AnyPgColumn | undefined {
1589
- return (Object.values(table).find((col: Record<string, unknown>) => col.primary)
1590
- ?? Object.values(table).find((col: Record<string, unknown>) => col.name === "id")) as AnyPgColumn | undefined;
1591
- }
1592
-
1593
- /**
1594
- * Build a single filter condition for a specific operator and value
1595
- */
1596
- static buildSingleFilterCondition(
1597
- column: AnyPgColumn,
1598
- op: WhereFilterOp,
1599
- value: unknown
1600
- ): SQL | null {
1601
- value = unwrapRelationFilterValue(value);
1602
- switch (op) {
1603
- case "==":
1604
- if (value === null || value === undefined) {
1605
- return sql`${column} IS NULL`;
1606
- }
1607
- return eq(column, value);
1608
- case "!=":
1609
- if (value === null || value === undefined) {
1610
- return sql`${column} IS NOT NULL`;
1611
- }
1612
- return sql`${column} != ${value}`;
1613
- case ">":
1614
- return sql`${column} > ${value}`;
1615
- case ">=":
1616
- return sql`${column} >= ${value}`;
1617
- case "<":
1618
- return sql`${column} < ${value}`;
1619
- case "<=":
1620
- return sql`${column} <= ${value}`;
1621
- case "in": {
1622
- // Membership against a null *value* is a null check, not an
1623
- // empty list — the admin's "filter for null values" control
1624
- // emits whichever operator is selected, so `["in", null]` is
1625
- // how it asks for a null foreign key when the user picked
1626
- // `in`. Reading it as an empty list dropped the condition
1627
- // outright, which widened the read to every row.
1628
- if (value === null || value === undefined) {
1629
- return sql`${column} IS NULL`;
1630
- }
1631
- const values = toMembershipList(value);
1632
- // An empty list matches nothing. Returning no condition — what
1633
- // this did — matches *everything*, which is the same inversion
1634
- // one layer down from the one `UnknownFilterFieldsMode` exists
1635
- // for. It is the dangerous shape too: `filter: { id: ["in",
1636
- // teamIds] }` with no teams is how a caller asks for nothing,
1637
- // and it answered with the whole table.
1638
- return values.length === 0 ? sql`FALSE` : inArray(column, values);
1639
- }
1640
- case "array-contains": {
1641
- const meta = getColumnMeta(column);
1642
- if (meta.dataType === "array" || meta.columnType === "PgArray") {
1643
- return sql`${column} @> ARRAY[${value}]`;
1644
- }
1645
- // For JSONB arrays: checks if the column contains the given value
1646
- return sql`${column} @> ${JSON.stringify([value])}`;
1647
- }
1648
- case "array-contains-any": {
1649
- const meta = getColumnMeta(column);
1650
- const isNativeArray = meta.dataType === "array" || meta.columnType === "PgArray";
1651
- // "Overlaps nothing" is false, not a licence to skip the
1652
- // condition. The single-value fallback below is for a *scalar*
1653
- // operand; an empty array fell into it and built
1654
- // `@> ARRAY[$1]` around an empty binding.
1655
- if (Array.isArray(value) && value.length === 0) {
1656
- return sql`FALSE`;
1657
- }
1658
- if (Array.isArray(value) && value.length > 0) {
1659
- if (isNativeArray) {
1660
- return sql`${column} && ARRAY[${sql.join(value.map(v => sql`${v}`), sql`, `)}]`;
1661
- } else {
1662
- // Use the ?| operator for JSONB overlap with text array
1663
- const textValues = value.map(v => String(v));
1664
- return sql`${column} ?| array[${sql.join(textValues.map(v => sql`${v}`), sql`, `)}]`;
1665
- }
1666
- }
1667
- // Single value fallback: treat as array-contains
1668
- if (isNativeArray) {
1669
- return sql`${column} @> ARRAY[${value}]`;
1670
- }
1671
- return sql`${column} @> ${JSON.stringify([value])}`;
1672
- }
1673
- case "not-in": {
1674
- // The mirror of `in` above, including the empty list — which
1675
- // excludes nothing, and so matches every row. Same condition
1676
- // the old code produced by accident, now on purpose and for
1677
- // the empty list only.
1678
- if (value === null || value === undefined) {
1679
- return sql`${column} IS NOT NULL`;
1680
- }
1681
- const values = toMembershipList(value);
1682
- if (values.length === 0) return sql`TRUE`;
1683
- return sql`${column} NOT IN (${sql.join(values.map(v => sql`${v}`), sql`, `)})`;
1684
- }
1685
- case "like":
1686
- return sql`${column} LIKE ${String(value)}`;
1687
- case "ilike":
1688
- return sql`${column} ILIKE ${String(value)}`;
1689
- case "not-like":
1690
- return sql`${column} NOT LIKE ${String(value)}`;
1691
- case "not-ilike":
1692
- return sql`${column} NOT ILIKE ${String(value)}`;
1693
- case "is-null":
1694
- return sql`${column} IS NULL`;
1695
- case "is-not-null":
1696
- return sql`${column} IS NOT NULL`;
1697
- default:
1698
- // The relation path five hundred lines up already refuses this,
1699
- // and says why: "returning `null` for an operator this cannot
1700
- // express would drop the condition", and a dropped condition
1701
- // widens the result. The column path is its twin and kept the
1702
- // warning — so `{ status: ["contains", "x"] }` filtered on
1703
- // nothing and answered 200 with every row, which reads as data
1704
- // that matched.
1705
- //
1706
- // The wire layer rejects operator-shaped unknowns before they
1707
- // arrive (`UnknownFilterOperatorError`, 400). What reaches here
1708
- // came from in-process `rebase.data`, a stored filter preset or
1709
- // a config — none of them typechecked at the call site, all of
1710
- // them able to name an operator that no longer exists.
1711
- throw ApiError.badRequest(
1712
- `Unknown filter operator '${op}'. Valid operators: ${ALL_WHERE_FILTER_OPS.join(", ")}.`,
1713
- "UNKNOWN_FILTER_OPERATOR",
1714
- { operator: op, validOperators: ALL_WHERE_FILTER_OPS }
1715
- );
1716
- }
1717
- }
1718
-
1719
- /**
1720
- * Build relation-based conditions for different relation types
1721
- */
1722
- /**
1723
- * Joins and where-conditions that reach a relation's target rows.
1724
- *
1725
- * One case per kind. This used to be a chain of six `else if`s over
1726
- * `cardinality`/`direction`/`through`, ending in
1727
- * `findCorrespondingJunctionTable` — a search through the *target's* own
1728
- * relations to work out whether an "inverse many" was a one-to-many or the
1729
- * far side of a junction. That search is gone: the kind says which it is.
1730
- *
1731
- * The owning/inverse split for junctions is gone too. Both variants built
1732
- * the identical condition — `through` is always written from the declaring
1733
- * side's point of view — so the second was a distinction without a
1734
- * difference and one of the places the two could drift apart.
1735
- */
1736
- static buildRelationConditions(
1737
- relation: ResolvedRelation,
1738
- parentId: string | number | (string | number)[],
1739
- targetTable: PgTable<any>,
1740
- parentTable: PgTable<any>,
1741
- parentIdColumn: AnyPgColumn,
1742
- targetIdColumn: AnyPgColumn,
1743
- registry: PostgresCollectionRegistry
1744
- ): {
1745
- joinConditions: { table: PgTable<any>; condition: SQL }[];
1746
- whereConditions: SQL[];
1747
- } {
1748
- const joinConditions: { table: PgTable<any>; condition: SQL }[] = [];
1749
- const whereConditions: SQL[] = [];
1750
-
1751
- switch (relation.kind) {
1752
- case "via": {
1753
- const { joins, finalCondition } = this.buildJoinPathConditions(
1754
- relation.joinPath, targetTable, parentTable, parentIdColumn, parentId, registry
1755
- );
1756
- joinConditions.push(...joins);
1757
- whereConditions.push(finalCondition);
1758
- break;
1759
- }
1760
-
1761
- case "manyToMany": {
1762
- const junctionResult = this.buildJunctionTableConditions(
1763
- relation.through, targetIdColumn, parentId, registry
1764
- );
1765
- joinConditions.push(junctionResult.join);
1766
- whereConditions.push(junctionResult.condition);
1767
- break;
1768
- }
1769
-
1770
- case "hasOne":
1771
- case "hasMany":
1772
- case "belongsTo":
1773
- whereConditions.push(
1774
- this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId)
1775
- );
1776
- break;
1777
-
1778
- default: {
1779
- const exhaustive: never = relation;
1780
- throw new Error(`Unknown relation kind: ${JSON.stringify(exhaustive)}`);
1781
- }
1782
- }
1783
-
1784
- return { joinConditions,
1785
- whereConditions };
1786
- }
1787
-
1788
- /**
1789
- * Build conditions for join path relations
1790
- */
1791
- private static buildJoinPathConditions(
1792
- joinPath: JoinStep[],
1793
- targetTable: PgTable<any>,
1794
- parentTable: PgTable<any>,
1795
- parentIdColumn: AnyPgColumn,
1796
- parentId: string | number | (string | number)[],
1797
- registry: PostgresCollectionRegistry
1798
- ): {
1799
- joins: { table: PgTable<any>; condition: SQL }[];
1800
- finalCondition: SQL;
1801
- } {
1802
- const joins: { table: PgTable<any>; condition: SQL }[] = [];
1803
- let currentTable = targetTable;
1804
-
1805
- // Process join steps in reverse order to build path back to parent
1806
- for (const joinStep of [...joinPath].reverse()) {
1807
- const fromTableName = this.getTableNamesFromColumns(joinStep.on.from)[0];
1808
- const toTableName = this.getTableNamesFromColumns(joinStep.on.to)[0];
1809
- const fromColName = this.getColumnNamesFromColumns(joinStep.on.from)[0];
1810
- const toColName = this.getColumnNamesFromColumns(joinStep.on.to)[0];
1811
-
1812
- const fromTable = registry.getTable(fromTableName);
1813
- const toTable = registry.getTable(toTableName);
1814
-
1815
- if (!fromTable || !toTable) {
1816
- throw new Error(`Join tables not found for step: from ${fromTableName} to ${toTableName}`);
1817
- }
1818
-
1819
- const {
1820
- joinTable,
1821
- condition,
1822
- additionalJoins
1823
- } = this.buildSingleJoinCondition(
1824
- currentTable,
1825
- fromTable,
1826
- toTable,
1827
- fromColName,
1828
- toColName,
1829
- fromTableName,
1830
- toTableName,
1831
- registry
1832
- );
1833
-
1834
- joins.push({
1835
- table: joinTable,
1836
- condition
1837
- });
1838
- currentTable = joinTable;
1839
-
1840
- // Add any additional joins needed for many-to-many relationships
1841
- if (additionalJoins && additionalJoins.length > 0) {
1842
- joins.push(...additionalJoins);
1843
- }
1844
- }
1845
-
1846
- // Ensure we've connected back to the parent table
1847
- // For junction tables, we might end up at the junction table instead of the parent table
1848
- if (currentTable !== parentTable) {
1849
- // Try to get table names from the Drizzle table objects
1850
- let currentTableName = "unknown";
1851
- let parentTableName = "unknown";
1852
-
1853
- // Try multiple ways to extract table names from Drizzle objects
1854
- if (currentTable && typeof currentTable === "object") {
1855
- // Check common Drizzle table name properties
1856
- currentTableName = (currentTable as unknown as Record<string | symbol, unknown>)[Symbol.for("drizzle:Name")] as string ||
1857
- ((currentTable as unknown as Record<string, unknown>)._ as Record<string, unknown>)?.name as string ||
1858
- (currentTable as unknown as Record<string, unknown>).tableName as string ||
1859
- (currentTable as unknown as Record<string, unknown>).name as string ||
1860
- "unknown";
1861
- }
1862
-
1863
- if (parentTable && typeof parentTable === "object") {
1864
- parentTableName = (parentTable as unknown as Record<string | symbol, unknown>)[Symbol.for("drizzle:Name")] as string ||
1865
- ((parentTable as unknown as Record<string, unknown>)._ as Record<string, unknown>)?.name as string ||
1866
- (parentTable as unknown as Record<string, unknown>).tableName as string ||
1867
- (parentTable as unknown as Record<string, unknown>).name as string ||
1868
- "unknown";
1869
- }
1870
-
1871
- // For junction table scenarios, be more lenient with validation
1872
- // If we can't determine table names reliably, or if this looks like a junction table scenario,
1873
- // we'll allow it and let the SQL execution validate the correctness
1874
- const couldBeJunctionScenario = currentTableName.includes("_") ||
1875
- currentTableName === "unknown" ||
1876
- parentTableName === "unknown";
1877
-
1878
- if (!couldBeJunctionScenario) {
1879
- throw new Error(`Join path did not result in connecting to parent table. Current: ${currentTableName}, Parent: ${parentTableName}`);
1880
- }
1881
- }
1882
-
1883
- // Handle both single ID and array of IDs
1884
- const finalCondition = Array.isArray(parentId)
1885
- ? inArray(parentIdColumn, parentId)
1886
- : eq(parentIdColumn, parentId);
1887
-
1888
- return {
1889
- joins,
1890
- finalCondition
1891
- };
1892
- }
1893
-
1894
- /**
1895
- * Build a single join condition between tables
1896
- */
1897
- private static buildSingleJoinCondition(
1898
- currentTable: PgTable<any>,
1899
- fromTable: PgTable<any>,
1900
- toTable: PgTable<any>,
1901
- fromColName: string,
1902
- toColName: string,
1903
- fromTableName: string,
1904
- toTableName: string,
1905
- registry?: PostgresCollectionRegistry
1906
- ): { joinTable: PgTable<any>; condition: SQL; additionalJoins?: { table: PgTable<any>; condition: SQL }[] } {
1907
- let joinTable: PgTable<any>;
1908
- let condition: SQL;
1909
- const additionalJoins: { table: PgTable<any>; condition: SQL }[] = [];
1910
-
1911
- if (currentTable === toTable) {
1912
- // current -> toTable, so join the fromTable
1913
- const left = fromTable[fromColName as keyof typeof fromTable] as AnyPgColumn;
1914
- const right = (currentTable as unknown as Record<string, unknown>)[toColName] as AnyPgColumn;
1915
-
1916
- if (!left || !right) {
1917
- // Check if this might be a many-to-many relationship requiring a junction table
1918
- if (registry) {
1919
- const junctionResult = this.tryBuildJunctionJoin(
1920
- currentTable,
1921
- fromTable,
1922
- fromColName,
1923
- toColName,
1924
- fromTableName,
1925
- toTableName,
1926
- registry
1927
- );
1928
- if (junctionResult) {
1929
- return junctionResult;
1930
- }
1931
- }
1932
- throw new Error(`Join columns not found: ${fromTableName}.${fromColName} = ${toTableName}.${toColName}`);
1933
- }
1934
-
1935
- joinTable = fromTable;
1936
- condition = eq(left, right);
1937
- } else if (currentTable === fromTable) {
1938
- // current -> fromTable, so join the toTable
1939
- const left = toTable[toColName as keyof typeof toTable] as AnyPgColumn;
1940
- const right = (currentTable as unknown as Record<string, unknown>)[fromColName] as AnyPgColumn;
1941
-
1942
- if (!left || !right) {
1943
- // Check if this might be a many-to-many relationship requiring a junction table
1944
- if (registry) {
1945
- const junctionResult = this.tryBuildJunctionJoin(
1946
- currentTable,
1947
- toTable,
1948
- fromColName,
1949
- toColName,
1950
- fromTableName,
1951
- toTableName,
1952
- registry
1953
- );
1954
- if (junctionResult) {
1955
- return junctionResult;
1956
- }
1957
- }
1958
- throw new Error(`Join columns not found: ${toTableName}.${toColName} = ${fromTableName}.${fromColName}`);
1959
- }
1960
-
1961
- joinTable = toTable;
1962
- condition = eq(left, right);
1963
- } else {
1964
- throw new Error(`Join step does not match current table. Current table does not match from: ${fromTableName} or to: ${toTableName}`);
1965
- }
1966
-
1967
- return {
1968
- joinTable,
1969
- condition,
1970
- additionalJoins
1971
- };
1972
- }
1973
-
1974
- /**
1975
- * Try to build a junction table join when direct foreign key relationship is not found
1976
- */
1977
- private static tryBuildJunctionJoin(
1978
- currentTable: PgTable<any>,
1979
- targetTable: PgTable<any>,
1980
- fromColName: string,
1981
- toColName: string,
1982
- fromTableName: string,
1983
- toTableName: string,
1984
- registry: PostgresCollectionRegistry
1985
- ): { joinTable: PgTable<any>; condition: SQL; additionalJoins: { table: PgTable<any>; condition: SQL }[] } | null {
1986
- // Try to find a junction table that connects these two tables
1987
- // Common naming patterns: table1_table2, table1Table2, etc.
1988
- const possibleJunctionNames = [
1989
- `${fromTableName}_${toTableName}`,
1990
- `${toTableName}_${fromTableName}`,
1991
- `${fromTableName}${toTableName.charAt(0).toUpperCase() + toTableName.slice(1)}`,
1992
- `${toTableName}${fromTableName.charAt(0).toUpperCase() + fromTableName.slice(1)}`
1993
- ];
1994
-
1995
- for (const junctionName of possibleJunctionNames) {
1996
- const junctionTable = registry.getTable(junctionName);
1997
- if (junctionTable) {
1998
- // Try to find the appropriate columns in the junction table
1999
- const sourceColName = `${fromTableName.slice(0, -1)}_id`; // Remove 's' and add '_id'
2000
- const targetColName = `${toTableName.slice(0, -1)}_id`;
2001
-
2002
- const junctionSourceCol = junctionTable[sourceColName as keyof typeof junctionTable] as AnyPgColumn;
2003
- const junctionTargetCol = junctionTable[targetColName as keyof typeof junctionTable] as AnyPgColumn;
2004
-
2005
- if (junctionSourceCol && junctionTargetCol) {
2006
- // Found a valid junction table setup
2007
- const currentTableIdCol = Object.values(currentTable).find((col: Record<string, unknown>) => col.primary) as AnyPgColumn;
2008
- const targetTableIdCol = Object.values(targetTable).find((col: Record<string, unknown>) => col.primary) as AnyPgColumn;
2009
-
2010
- if (!currentTableIdCol || !targetTableIdCol) {
2011
- continue; // Skip if we can't find primary keys
2012
- }
2013
-
2014
- // Determine which direction to join
2015
- if (currentTable === targetTable) {
2016
- // We're joining through junction to reach the other table
2017
- return {
2018
- joinTable: targetTable,
2019
- condition: eq(targetTableIdCol, junctionTargetCol),
2020
- additionalJoins: [
2021
- {
2022
- table: junctionTable,
2023
- condition: eq(currentTableIdCol, junctionSourceCol)
2024
- }
2025
- ]
2026
- };
2027
- } else {
2028
- // Standard junction join
2029
- return {
2030
- joinTable: junctionTable,
2031
- condition: eq(currentTableIdCol, junctionSourceCol),
2032
- additionalJoins: [
2033
- {
2034
- table: targetTable,
2035
- condition: eq(targetTableIdCol, junctionTargetCol)
2036
- }
2037
- ]
2038
- };
2039
- }
2040
- }
2041
- }
2042
- }
2043
-
2044
- return null; // No junction table found
2045
- }
2046
-
2047
- /**
2048
- * Build conditions for junction table (many-to-many) relations
2049
- */
2050
- private static buildJunctionTableConditions(
2051
- through: { table: string; sourceColumn: string; targetColumn: string },
2052
- targetIdColumn: AnyPgColumn,
2053
- parentId: string | number | (string | number)[],
2054
- registry: PostgresCollectionRegistry
2055
- ): { join: { table: PgTable<any>; condition: SQL }; condition: SQL } {
2056
- const junctionTable = registry.getTable(through.table);
2057
- if (!junctionTable) {
2058
- throw new Error(`Junction table not found: ${through.table}`);
2059
- }
2060
-
2061
- const junctionSourceCol = junctionTable[through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
2062
- const junctionTargetCol = junctionTable[through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
2063
-
2064
- if (!junctionSourceCol) {
2065
- throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
2066
- }
2067
- if (!junctionTargetCol) {
2068
- throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
2069
- }
2070
-
2071
- // Handle both single ID and array of IDs
2072
- const condition = Array.isArray(parentId)
2073
- ? inArray(junctionSourceCol, parentId)
2074
- : eq(junctionSourceCol, parentId);
2075
-
2076
- return {
2077
- join: {
2078
- table: junctionTable,
2079
- condition: eq(targetIdColumn, junctionTargetCol)
2080
- },
2081
- condition
2082
- };
2083
- }
2084
-
2085
-
2086
- /**
2087
- * The condition for a relation whose link is a single column.
2088
- *
2089
- * Two cases. It had five: two of them existed only to throw ("should not be
2090
- * called directly", "lacks proper configuration"), and one guessed a column
2091
- * name by appending `_id` to `inverseRelationName` when no foreign key had
2092
- * been resolved. All three were reachable only because the old type let a
2093
- * relation arrive here under-specified. It cannot now.
2094
- */
2095
- private static buildSimpleRelationCondition(
2096
- relation: ResolvedBelongsTo | ResolvedHasOne | ResolvedHasMany,
2097
- targetTable: PgTable<any>,
2098
- parentTable: PgTable<any>,
2099
- parentId: string | number | (string | number)[]
2100
- ): SQL {
2101
- const match = (column: AnyPgColumn) =>
2102
- Array.isArray(parentId) ? inArray(column, parentId) : eq(column, parentId);
2103
-
2104
- if (relation.kind === "belongsTo") {
2105
- // `parentId` is the foreign key's value, matched against the
2106
- // target's own key.
2107
- const targetIdCol = this.primaryKeyColumn(targetTable);
2108
- if (!targetIdCol) {
2109
- throw new Error(
2110
- `No primary key or "id" column in the target table of relation '${relation.relationName}'.`
2111
- );
2112
- }
2113
- return match(targetIdCol);
2114
- }
2115
-
2116
- const foreignKeyCol = relationColumn(targetTable, targetOf(relation), relation.foreignKeyOnTarget);
2117
- if (!foreignKeyCol) {
2118
- throw new Error(
2119
- `Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation ` +
2120
- `'${relation.relationName}'. A link through a junction is \`kind: "manyToMany"\`.`
2121
- );
2122
- }
2123
- return match(foreignKeyCol);
2124
- }
2125
-
2126
- /**
2127
- * Combine multiple conditions with AND operator
2128
- */
2129
- static combineConditionsWithAnd(conditions: SQL[]): SQL | undefined {
2130
- if (conditions.length === 0) return undefined;
2131
- if (conditions.length === 1) return conditions[0];
2132
- return and(...conditions);
2133
- }
2134
-
2135
- /**
2136
- * Combine multiple conditions with OR operator
2137
- */
2138
- static combineConditionsWithOr(conditions: SQL[]): SQL | undefined {
2139
- if (conditions.length === 0) return undefined;
2140
- if (conditions.length === 1) return conditions[0];
2141
- return or(...conditions);
2142
- }
2143
-
2144
- /**
2145
- * Build search conditions for text fields.
2146
- *
2147
- * Two shapes, chosen by whether the collection declared a `search` block:
2148
- *
2149
- * - **Declared** — one `@@ websearch_to_tsquery` against the generated
2150
- * `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
2151
- * inside JSONB and arrays, and uses the GIN index.
2152
- * - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level
2153
- * string properties, with the term escaped (see {@link escapeLikePattern})
2154
- * so it is matched as the literal text the user typed.
2155
- *
2156
- * The second is the default and stays the default. A collection that has
2157
- * not opted in compiles to exactly the SQL it compiled to before this
2158
- * branch existed, which is the only reason it is safe to have added it.
2159
- *
2160
- * `collection` is optional so that the callers which genuinely have no
2161
- * collection in hand — nested paths, derived views — keep working; without
2162
- * one there is no `search` block to read and the ILIKE path is correct.
2163
- */
2164
- static buildSearchConditions(
2165
- searchString: string,
2166
- properties: Record<string, unknown>,
2167
- table: PgTable<any>,
2168
- collection?: CollectionConfig
2169
- ): SQL[] {
2170
- const searchConditions: SQL[] = [];
2171
-
2172
- const ftsCondition = collection
2173
- ? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection)
2174
- : undefined;
2175
- if (ftsCondition) return [ftsCondition];
2176
-
2177
- let declaredStringProperties = 0;
2178
-
2179
- for (const [key, prop] of Object.entries(properties)) {
2180
- const p = prop as Record<string, unknown>;
2181
- // Only include string properties that don't have enum defined
2182
- // PostgreSQL enum and uuid columns don't support ILIKE, so we skip them
2183
- if (p.type === "string" && !p.enum && p.isId !== "uuid") {
2184
- declaredStringProperties++;
2185
- const fieldColumn = table[key as keyof typeof table] as AnyPgColumn;
2186
- if (fieldColumn && supportsILike(fieldColumn)) {
2187
- searchConditions.push(ilike(fieldColumn, `%${escapeLikePattern(searchString)}%`));
2188
- }
2189
- }
2190
- }
2191
-
2192
- // Every string property was rejected, so the caller is about to turn an
2193
- // empty condition list into "match nothing" — a 200 with an empty page,
2194
- // which reads as "no such row" rather than as the breakage it is. Say so
2195
- // once per query: this is how the `instanceof` version of
2196
- // {@link supportsILike} failed silently in the field for months.
2197
- if (declaredStringProperties > 0 && searchConditions.length === 0) {
2198
- logger.warn(
2199
- `[search] "${collection?.slug ?? "collection"}" declares ${declaredStringProperties} string ` +
2200
- "property(ies) but none compiled to a searchable column, so this search can only return nothing. " +
2201
- "Check that the generated schema's column types are text/varchar/char."
2202
- );
2203
- }
2204
-
2205
- return searchConditions;
2206
- }
2207
-
2208
- /**
2209
- * The `@@` predicate for a collection that declared a `search` block, or
2210
- * undefined for one that did not.
2211
- *
2212
- * The query is normalized exactly as the indexed content was — same text
2213
- * search configuration, same accent folding. Skipping that on the query
2214
- * side is the subtle way to get a search that matches nothing: the column
2215
- * would hold `gestion` while the query asked for `gestión`.
2216
- *
2217
- * `websearch_to_tsquery` rather than `plainto_tsquery` because it is the
2218
- * one that behaves the way a search box looks like it should — quoted
2219
- * phrases, `or`, and a leading `-` to exclude — and because it never throws
2220
- * on user input, which `to_tsquery` does on so much as a stray parenthesis.
2221
- */
2222
- static buildFullTextCondition(
2223
- searchString: string,
2224
- table: PgTable<any>,
2225
- collection: CollectionConfig
2226
- ): SQL | undefined {
2227
- let spec: SearchColumnSpec | undefined;
2228
- try {
2229
- spec = buildSearchColumnSpec(collection);
2230
- } catch {
2231
- // Reported at boot. Falling back to ILIKE here keeps reads serving.
2232
- return undefined;
2233
- }
2234
- if (!spec) return undefined;
2235
-
2236
- const column = table[spec.column as keyof typeof table] as AnyPgColumn | undefined;
2237
- if (!column) {
2238
- // The block is declared but the column is not on the table yet —
2239
- // a database that has not been migrated. ILIKE still answers.
2240
- return undefined;
2241
- }
2242
-
2243
- const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
2244
- const exact = sql`${column} @@ ${query}`;
2245
-
2246
- if (!spec.fuzzy) return exact;
2247
-
2248
- const fuzzyColumn = table[spec.fuzzy.column as keyof typeof table] as AnyPgColumn | undefined;
2249
- if (!fuzzyColumn) return exact;
2250
-
2251
- const needle = spec.unaccent
2252
- ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
2253
- : sql`${searchString}`;
2254
-
2255
- // `word_similarity(query, document)`, not `similarity`. `similarity`
2256
- // scores two strings as wholes, so a short query against a whole row's
2257
- // text scores near zero however well it matches part of it — measured:
2258
- // "iso 14001 auditor" against one candidate's concatenated fields
2259
- // scores 0.228 by `similarity` and 0.783 by `word_similarity`. The
2260
- // first is below any usable threshold, which would have made `fuzzy`
2261
- // a setting that quietly did nothing.
2262
- //
2263
- // Argument order matters: the first operand is the needle, and the
2264
- // score is its similarity to the best-matching extent of the second.
2265
- //
2266
- // Both the function and the operator are schema-qualified: pg_trgm is
2267
- // installed into `public`, and an unqualified reference resolves
2268
- // through `search_path`, which does not necessarily reach it.
2269
- const similar = sql`public.word_similarity(${needle}, ${fuzzyColumn}) >= ${spec.fuzzy.threshold}`;
2270
- // `<%` is the index-backed form, but it tests against the session's
2271
- // `pg_trgm.word_similarity_threshold` (0.6), not ours. Above that
2272
- // default the operator narrows using the trigram index and the explicit
2273
- // score refines; at or below it, the operator would exclude rows the
2274
- // declared threshold admits, so the score stands alone and the planner
2275
- // scans — correct either way, and only the faster path is conditional.
2276
- const fuzzy = spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT
2277
- ? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})`
2278
- : similar;
2279
-
2280
- return sql`(${exact} OR ${fuzzy})`;
2281
- }
2282
-
2283
- /**
2284
- * `websearch_to_tsquery(<config>, <normalized search string>)`.
2285
- *
2286
- * Split out because the ranking expression needs the identical query — a
2287
- * row ranked against a different tsquery than it was matched against is a
2288
- * ranking of something else.
2289
- */
2290
- static normalizedTsQuery(searchString: string, spec: SearchColumnSpec): SQL {
2291
- const normalized = spec.unaccent
2292
- ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
2293
- : sql`${searchString}`;
2294
- return sql`websearch_to_tsquery(${spec.language}, ${normalized})`;
2295
- }
2296
-
2297
- /**
2298
- * A JSONB array of `{ field, snippet }` naming which declared fields matched
2299
- * and showing the text around each hit — what backs `_matches`.
2300
- *
2301
- * A ranked list answers "which rows", never "why this row". For a talent
2302
- * pool that difference is the product: a candidate surfacing for
2303
- * "iso 14001" on a *certification* is a different candidate from one whose
2304
- * bio happens to mention the standard, and the score cannot tell them apart.
2305
- *
2306
- * Built as a correlated subquery over a `VALUES` list of the declared
2307
- * fields, rather than one `CASE` per field, so the shape does not change
2308
- * with the number of fields and the empty result is a plain `[]`.
2309
- *
2310
- * `ts_headline` runs over the same normalized text that was indexed. Over
2311
- * the *original* text it would find nothing to mark whenever `unaccent` is
2312
- * on — the query's lexemes are folded and the document's are not — and
2313
- * would return the text silently unhighlighted. Folded-but-marked beats
2314
- * pretty-but-inert.
2315
- *
2316
- * Undefined when the collection has not opted in, when the column is not on
2317
- * the table yet, or when the caller did not ask: this costs a `ts_headline`
2318
- * per field per row and `ts_headline` re-parses the document.
2319
- */
2320
- static buildSearchMatchesExpression(
2321
- searchString: string,
2322
- table: PgTable<any>,
2323
- collection: CollectionConfig
2324
- ): SQL | undefined {
2325
- let spec: SearchColumnSpec | undefined;
2326
- try {
2327
- spec = buildSearchColumnSpec(collection);
2328
- } catch {
2329
- return undefined;
2330
- }
2331
- if (!spec || spec.fields.length === 0) return undefined;
2332
- if (!table[spec.column as keyof typeof table]) return undefined;
2333
-
2334
- const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
2335
- const config = sql`${spec.language}`;
2336
-
2337
- // `ord` keeps the author's declared field order in the output, so the
2338
- // most important field they named reads first rather than whichever
2339
- // Postgres aggregated first.
2340
- const rows = spec.fields.map((f, i) =>
2341
- sql`(${i}, ${f.path}, ${sql.raw(f.textSql)})`
2342
- );
2343
-
2344
- return sql`(
2345
- SELECT coalesce(jsonb_agg(s.m ORDER BY f.ord), '[]'::jsonb)
2346
- FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt)
2347
- CROSS JOIN LATERAL (
2348
- SELECT jsonb_build_object(
2349
- 'field', f.path,
2350
- 'snippet', ts_headline(${config}::regconfig, f.txt, ${query},
2351
- 'StartSel=<mark>,StopSel=</mark>,MaxWords=14,MinWords=1,MaxFragments=1,FragmentDelimiter= … ')
2352
- ) AS m
2353
- WHERE to_tsvector(${config}::regconfig, f.txt) @@ ${query}
2354
- ) s
2355
- )`;
2356
- }
2357
-
2358
- /**
2359
- * `ts_rank(<column>, <query>)` for the collection, or undefined when it has
2360
- * not opted in. This is what backs `orderBy: ["_score", "desc"]`.
2361
- */
2362
- static buildSearchRankExpression(
2363
- searchString: string,
2364
- table: PgTable<any>,
2365
- collection: CollectionConfig
2366
- ): SQL | undefined {
2367
- let spec: SearchColumnSpec | undefined;
2368
- try {
2369
- spec = buildSearchColumnSpec(collection);
2370
- } catch {
2371
- return undefined;
2372
- }
2373
- if (!spec) return undefined;
2374
- const column = table[spec.column as keyof typeof table] as AnyPgColumn | undefined;
2375
- if (!column) return undefined;
2376
-
2377
- const rank = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
2378
- if (!spec.fuzzy) return rank;
2379
-
2380
- const fuzzyColumn = table[spec.fuzzy.column as keyof typeof table] as AnyPgColumn | undefined;
2381
- if (!fuzzyColumn) return rank;
2382
-
2383
- // With `fuzzy` on, `ts_rank` alone is not a ranking — it is zero for
2384
- // every row the trigram path matched and the exact path did not, which
2385
- // is the whole population of a typo'd query. Measured on the real
2386
- // sustentalent pool: "auditor de iso14000" matches four candidates,
2387
- // every one of them at ts_rank 0, so ordering by rank alone returned
2388
- // the best match in whatever order the table felt like.
2389
- //
2390
- // Summed rather than blended with tuned constants: a row that matched
2391
- // exactly contributes both terms, so it outranks a fuzzy-only row of
2392
- // equal similarity without needing a coefficient to say so. Both terms
2393
- // are non-negative and monotonic, which is all the ordering needs.
2394
- const needle = spec.unaccent
2395
- ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
2396
- : sql`${searchString}`;
2397
- return sql`(${rank} + public.word_similarity(${needle}, ${fuzzyColumn}))`;
2398
- }
2399
-
2400
- /**
2401
- * Build a unique field check condition
2402
- */
2403
- static buildUniqueFieldCondition(
2404
- fieldColumn: AnyPgColumn,
2405
- value: unknown,
2406
- idColumn?: AnyPgColumn,
2407
- excludeId?: string | number
2408
- ): SQL[] {
2409
- const conditions: SQL[] = [eq(fieldColumn, value)];
2410
-
2411
- if (excludeId && idColumn) {
2412
- conditions.push(sql`${idColumn} != ${excludeId}`);
2413
- }
2414
-
2415
- return conditions;
2416
- }
2417
-
2418
- /**
2419
- * Build relation-based query with joins and conditions
2420
- */
2421
- static buildRelationQuery<T extends DrizzleDynamicQuery>(
2422
- baseQuery: T,
2423
- relation: ResolvedRelation,
2424
- parentId: string | number | (string | number)[],
2425
- targetTable: PgTable<any>,
2426
- parentTable: PgTable<any>,
2427
- parentIdColumn: AnyPgColumn,
2428
- targetIdColumn: AnyPgColumn,
2429
- registry: PostgresCollectionRegistry,
2430
- additionalFilters?: SQL[]
2431
- ): T {
2432
- const { joinConditions, whereConditions } = this.buildRelationConditions(
2433
- relation,
2434
- parentId,
2435
- targetTable,
2436
- parentTable,
2437
- parentIdColumn,
2438
- targetIdColumn,
2439
- registry
2440
- );
2441
-
2442
- let query = baseQuery;
2443
-
2444
- // Apply joins
2445
- for (const { table, condition } of joinConditions) {
2446
- query = query.innerJoin(table, condition);
2447
- }
2448
-
2449
- // Combine all conditions
2450
- const allConditions = [...whereConditions];
2451
- if (additionalFilters) {
2452
- allConditions.push(...additionalFilters);
2453
- }
2454
-
2455
- // Apply where conditions
2456
- if (allConditions.length > 0) {
2457
- query = query.where(and(...allConditions));
2458
- }
2459
-
2460
- return query;
2461
- }
2462
-
2463
- /**
2464
- * A count over a relation's target rows.
2465
- *
2466
- * The junction case counts `distinct` because the caller's query joins the
2467
- * junction; the owning/inverse pair that used to sit here built the same
2468
- * query twice.
2469
- */
2470
- static buildRelationCountQuery<T extends DrizzleDynamicQuery>(
2471
- baseCountQuery: T,
2472
- relation: ResolvedRelation,
2473
- parentId: string | number,
2474
- targetTable: PgTable<any>,
2475
- parentTable: PgTable<any>,
2476
- parentIdColumn: AnyPgColumn,
2477
- targetIdColumn: AnyPgColumn,
2478
- registry: PostgresCollectionRegistry,
2479
- additionalFilters?: SQL[]
2480
- ): T {
2481
- switch (relation.kind) {
2482
- case "via":
2483
- return this.buildJoinPathCountQuery(
2484
- baseCountQuery, relation.joinPath, targetTable, parentTable,
2485
- parentIdColumn, parentId, registry, additionalFilters
2486
- );
2487
-
2488
- case "manyToMany":
2489
- return this.buildJunctionCountQuery(
2490
- baseCountQuery, relation.through, targetIdColumn, parentId, registry, additionalFilters
2491
- );
2492
-
2493
- case "belongsTo":
2494
- case "hasOne":
2495
- case "hasMany": {
2496
- const allConditions = [
2497
- this.buildSimpleRelationCondition(relation, targetTable, parentTable, parentId),
2498
- ...(additionalFilters ?? [])
2499
- ];
2500
- return baseCountQuery.where(and(...allConditions));
2501
- }
2502
-
2503
- default: {
2504
- const exhaustive: never = relation;
2505
- throw new Error(`Unknown relation kind: ${JSON.stringify(exhaustive)}`);
2506
- }
2507
- }
2508
- }
2509
-
2510
- /**
2511
- * Build join path conditions for count queries
2512
- */
2513
- private static buildJoinPathCountQuery<T extends DrizzleDynamicQuery>(
2514
- baseCountQuery: T,
2515
- joinPath: JoinStep[],
2516
- targetTable: PgTable<any>,
2517
- parentTable: PgTable<any>,
2518
- parentIdColumn: AnyPgColumn,
2519
- parentId: string | number,
2520
- registry: PostgresCollectionRegistry,
2521
- additionalFilters?: SQL[]
2522
- ): T {
2523
- let query = baseCountQuery;
2524
- let currentTable = targetTable;
2525
-
2526
- // Process join steps in reverse order
2527
- for (const joinStep of [...joinPath].reverse()) {
2528
- const fromTableName = this.getTableNamesFromColumns(joinStep.on.from)[0];
2529
- const toTableName = this.getTableNamesFromColumns(joinStep.on.to)[0];
2530
- const fromColName = this.getColumnNamesFromColumns(joinStep.on.from)[0];
2531
- const toColName = this.getColumnNamesFromColumns(joinStep.on.to)[0];
2532
-
2533
- const fromTable = registry.getTable(fromTableName);
2534
- const toTable = registry.getTable(toTableName);
2535
-
2536
- if (!fromTable || !toTable) {
2537
- throw new Error(`Join tables not found for step: from ${fromTableName} to ${toTableName}`);
2538
- }
2539
-
2540
- const { joinTable, condition } = this.buildSingleJoinCondition(
2541
- currentTable,
2542
- fromTable,
2543
- toTable,
2544
- fromColName,
2545
- toColName,
2546
- fromTableName,
2547
- toTableName
2548
- );
2549
-
2550
- query = query.innerJoin(joinTable, condition);
2551
- currentTable = joinTable;
2552
- }
2553
-
2554
- if (currentTable !== parentTable) {
2555
- throw new Error("Join path did not result in connecting to parent table");
2556
- }
2557
-
2558
- const allConditions = [eq(parentIdColumn, parentId)];
2559
- if (additionalFilters) {
2560
- allConditions.push(...additionalFilters);
2561
- }
2562
-
2563
- return query.where(and(...allConditions));
2564
- }
2565
-
2566
- /**
2567
- * Build junction table conditions for count queries
2568
- */
2569
- private static buildJunctionCountQuery<T extends DrizzleDynamicQuery>(
2570
- baseCountQuery: T,
2571
- through: { table: string; sourceColumn: string; targetColumn: string },
2572
- targetIdColumn: AnyPgColumn,
2573
- parentId: string | number,
2574
- registry: PostgresCollectionRegistry,
2575
- additionalFilters?: SQL[]
2576
- ): T {
2577
- const junctionTable = registry.getTable(through.table);
2578
- if (!junctionTable) {
2579
- throw new Error(`Junction table not found: ${through.table}`);
2580
- }
2581
-
2582
- const junctionSourceCol = junctionTable[through.sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
2583
- const junctionTargetCol = junctionTable[through.targetColumn as keyof typeof junctionTable] as AnyPgColumn;
2584
-
2585
- if (!junctionSourceCol) {
2586
- throw new Error(`Source column '${through.sourceColumn}' not found in junction table '${through.table}'`);
2587
- }
2588
- if (!junctionTargetCol) {
2589
- throw new Error(`Target column '${through.targetColumn}' not found in junction table '${through.table}'`);
2590
- }
2591
-
2592
- const baseConditions = [eq(junctionSourceCol, parentId)];
2593
- if (additionalFilters && additionalFilters.length > 0) {
2594
- baseConditions.push(...additionalFilters);
2595
- }
2596
-
2597
- return baseCountQuery
2598
- .innerJoin(junctionTable, eq(targetIdColumn, junctionTargetCol))
2599
- .where(and(...baseConditions));
2600
- }
2601
-
2602
-
2603
- /**
2604
- * Helper method to extract table names from columns
2605
- */
2606
- static getTableNamesFromColumns(columns: string | string[]): string[] {
2607
- if (Array.isArray(columns)) {
2608
- return columns.map(col => col.includes(".") ? col.split(".")[0] : "");
2609
- }
2610
- return [columns.includes(".") ? columns.split(".")[0] : ""];
2611
- }
2612
-
2613
- /**
2614
- * Helper method to extract column names from columns
2615
- */
2616
- static getColumnNamesFromColumns(columns: string | string[]): string[] {
2617
- if (Array.isArray(columns)) {
2618
- return columns.map(col => getColumnName(col));
2619
- }
2620
- return [getColumnName(columns)];
2621
- }
2622
-
2623
-
2624
- /**
2625
- * Build vector similarity search expressions for pgvector.
2626
- *
2627
- * Returns:
2628
- * - `orderBy`: SQL expression to ORDER BY distance (ascending = closest first)
2629
- * - `filter`: optional WHERE clause for distance threshold
2630
- * - `distanceSelect`: SQL expression for selecting the distance as `_distance`
2631
- *
2632
- * `property` is `?vector_search=` off the querystring, so it is an untrusted
2633
- * *name*, and it used to be looked up straight in the drizzle table object.
2634
- * Two ways that went wrong, both answering 500 to a malformed request:
2635
- * `?vector_search=title` built `"title" <=> '[1,2]'::vector`, which the
2636
- * database rejects with "operator does not exist"; and a table object also
2637
- * carries non-column keys (`_`, methods), which passed the `if (!column)`
2638
- * guard and compiled to nonsense. The name is resolved against the table's
2639
- * actual columns and required to be a `vector` — anything else is the
2640
- * caller's mistake and gets a 400 that says so.
2641
- */
2642
- static buildVectorSearchConditions(
2643
- table: PgTable<any>,
2644
- vectorSearch: {
2645
- property: string;
2646
- vector: number[];
2647
- distance?: "cosine" | "l2" | "inner_product";
2648
- threshold?: number;
2649
- }
2650
- ): { orderBy: SQL; filter?: SQL; distanceSelect: SQL } {
2651
- const column = DrizzleConditionBuilder.resolveVectorColumn(table, vectorSearch.property);
2652
-
2653
- // The vector is interpolated as a raw SQL literal below (pgvector has no
2654
- // bind form for the `::vector` cast), so every element must be a finite
2655
- // number. The REST query parser already enforces this, but this builder
2656
- // is a shared entry point — validate here too so no future caller can
2657
- // turn an unchecked value into SQL injection.
2658
- if (
2659
- !Array.isArray(vectorSearch.vector) ||
2660
- vectorSearch.vector.length === 0 ||
2661
- !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))
2662
- ) {
2663
- throw new Error("Vector search requires a non-empty array of finite numbers");
2664
- }
2665
-
2666
- const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
2667
- const distanceFn = vectorSearch.distance || "cosine";
2668
-
2669
- let operator: string;
2670
- switch (distanceFn) {
2671
- case "cosine":
2672
- operator = "<=>";
2673
- break;
2674
- case "l2":
2675
- operator = "<->";
2676
- break;
2677
- case "inner_product":
2678
- operator = "<#>";
2679
- break;
2680
- }
2681
-
2682
- const distanceExpr = sql`${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)}`;
2683
-
2684
- return {
2685
- orderBy: distanceExpr,
2686
- filter: vectorSearch.threshold != null
2687
- ? sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)}) < ${vectorSearch.threshold}`
2688
- : undefined,
2689
- distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})`
2690
- };
2691
- }
2692
-
2693
- /**
2694
- * The `vector` column a request named, or a 400 explaining what it named.
2695
- *
2696
- * `getTableColumns` rather than a key lookup: it returns only the columns,
2697
- * so `_`, `getSQL` and every other property of a drizzle table stop looking
2698
- * like candidates. The type check is on the *physical* column
2699
- * (`vector(1536)`) rather than on the declared property, so it holds for an
2700
- * introspected collection too, where the property carries no Rebase type.
2701
- */
2702
- private static resolveVectorColumn(table: PgTable<any>, property: string): AnyPgColumn {
2703
- const columns = getTableColumns(table) as Record<string, AnyPgColumn> | undefined;
2704
- const column = columns?.[property];
2705
- if (!column) {
2706
- const known = Object.entries(columns ?? {})
2707
- .filter(([, c]) => isVectorColumn(c))
2708
- .map(([name]) => name);
2709
- throw ApiError.badRequest(
2710
- `Unknown vector property "${property}". ` +
2711
- (known.length > 0
2712
- ? `This collection's vector properties are: ${known.join(", ")}.`
2713
- : "This collection declares no `vector` property to search."),
2714
- "UNKNOWN_VECTOR_PROPERTY"
2715
- );
2716
- }
2717
- if (!isVectorColumn(column)) {
2718
- throw ApiError.badRequest(
2719
- `Property "${property}" is not a vector column (it is \`${columnSqlType(column) || "unknown"}\`), ` +
2720
- "so it has no distance operator. Name the property declared as `{ type: \"vector\" }`.",
2721
- "UNKNOWN_VECTOR_PROPERTY"
2722
- );
2723
- }
2724
- return column;
2725
- }
2726
- }
2727
-
2728
- /** The column's SQL type, for a value that may not be a drizzle column at all. */
2729
- const columnSqlType = (column: unknown): string => {
2730
- const getSQLType = (column as { getSQLType?: () => string })?.getSQLType;
2731
- return typeof getSQLType === "function" ? getSQLType.call(column).toLowerCase() : "";
2732
- };
2733
-
2734
- /** True for `vector(1536)` and its pgvector siblings, whatever the width. */
2735
- const isVectorColumn = (column: unknown): boolean =>
2736
- /^(vector|halfvec|sparsevec)\b/.test(columnSqlType(column));
2737
-
2738
- /**
2739
- * Alias for DrizzleConditionBuilder for consistent naming with other database implementations.
2740
- * This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc.
2741
- */
2742
- export const PostgresConditionBuilder = DrizzleConditionBuilder;
2743
-