@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.
- package/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/collection-index-C7LopMyX.js +247 -0
- package/dist/collection-index-C7LopMyX.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
- package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
- package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +19307 -3059
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
- package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js +0 -9044
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js +0 -277
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- 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
|
-
|