@rebasepro/server-postgres 0.11.1-canary.gfd39654 → 0.12.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/dist/PostgresBootstrapper.d.ts +8 -0
- package/dist/collections/buildRegistry.d.ts +1 -1
- package/dist/{ensure-collection-tables-DGMYK0fr.js → ensure-collection-tables-CNTcZGvn.js} +3 -3
- package/dist/{ensure-collection-tables-DGMYK0fr.js.map → ensure-collection-tables-CNTcZGvn.js.map} +1 -1
- package/dist/history/HistoryService.d.ts +9 -29
- package/dist/index.es.js +397 -53
- package/dist/index.es.js.map +1 -1
- package/dist/schema/dynamic-tables.d.ts +1 -1
- package/dist/schema/introspect-runtime.d.ts +1 -1
- package/dist/services/FetchService.d.ts +36 -1
- package/dist/services/row-pipeline.d.ts +3 -1
- package/dist/{src-3VmUJ8Xn.js → src-BbFOPJ1S.js} +197 -18
- package/dist/src-BbFOPJ1S.js.map +1 -0
- package/dist/{src-D5xBTl32.js → src-Zqwaw3P5.js} +136 -90
- package/dist/src-Zqwaw3P5.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +157 -3
- package/dist/utils/pg-error-utils.d.ts +6 -3
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +23 -6
- package/src/collections/buildRegistry.ts +1 -1
- package/src/history/HistoryService.ts +13 -31
- package/src/schema/dynamic-tables.ts +1 -1
- package/src/schema/generate-drizzle-schema-logic.ts +10 -2
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/services/FetchService.ts +79 -11
- package/src/services/row-pipeline.ts +3 -1
- package/src/utils/drizzle-conditions.ts +509 -45
- package/src/utils/pg-error-utils.ts +52 -3
- package/dist/src-3VmUJ8Xn.js.map +0 -1
- package/dist/src-D5xBTl32.js.map +0 -1
|
@@ -1,15 +1,109 @@
|
|
|
1
|
-
import { and, eq, or, sql, SQL, ilike, inArray } from "drizzle-orm";
|
|
1
|
+
import { and, eq, or, sql, SQL, ilike, inArray, getTableColumns } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable, PgVarchar, PgText, PgChar } from "drizzle-orm/pg-core";
|
|
3
3
|
import {
|
|
4
|
-
FilterValues, WhereFilterOp, JoinStep, LogicalCondition, FilterCondition,
|
|
5
|
-
ResolvedRelation, ResolvedBelongsTo, ResolvedHasOne, ResolvedHasMany
|
|
4
|
+
CollectionConfig, FilterValues, WhereFilterOp, JoinStep, LogicalCondition, FilterCondition,
|
|
5
|
+
ResolvedRelation, ResolvedBelongsTo, ResolvedHasOne, ResolvedHasMany,
|
|
6
|
+
ResolvedForeignKeyOnTarget, ResolvedManyToMany, hasForeignKeyOnTarget, isManyToMany
|
|
6
7
|
} from "@rebasepro/types";
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getColumnName, getTableName, normalizeToEntityRelation, resolveCollectionRelations
|
|
10
|
+
} from "@rebasepro/common";
|
|
11
|
+
import { generateForeignKeyName } from "@rebasepro/utils";
|
|
8
12
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
9
13
|
import { ConditionBuilderStatic } from "../interfaces";
|
|
10
|
-
import { logger } from "@rebasepro/server";
|
|
14
|
+
import { ApiError, logger } from "@rebasepro/server";
|
|
11
15
|
import { getColumnMeta } from "../services/collection-helpers";
|
|
12
16
|
|
|
17
|
+
/**
|
|
18
|
+
* What to do with a filter field that resolves to no column at all.
|
|
19
|
+
*
|
|
20
|
+
* - `"error"` (default) — reject the request. A filter that cannot be
|
|
21
|
+
* compiled is *dropped*, and dropping a condition can only ever widen the
|
|
22
|
+
* result set. On a data plane where row-level security is the last line of
|
|
23
|
+
* defence, a typo'd or renamed filter key therefore runs the query without
|
|
24
|
+
* that condition and returns everything RLS happens to allow.
|
|
25
|
+
* - `"warn"` — the historical behaviour: log and silently drop the condition.
|
|
26
|
+
* Only for a deployment that knowingly sends filter keys the table does not
|
|
27
|
+
* have and has satisfied itself that widening is safe there.
|
|
28
|
+
*/
|
|
29
|
+
export type UnknownFilterFieldsMode = "error" | "warn";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Process-wide default, set once when the driver is constructed.
|
|
33
|
+
*
|
|
34
|
+
* The condition builder is a set of *static* methods reached from a dozen
|
|
35
|
+
* `FetchService` call sites, none of which carry the driver's config — the
|
|
36
|
+
* service is built from `(db, registry)` alone. Threading an option from
|
|
37
|
+
* `createPostgresAdapter` down to each of them would mean touching every
|
|
38
|
+
* intermediate signature to plumb a value that is a single deployment-wide
|
|
39
|
+
* switch. A module-level default set at adapter construction, plus an explicit
|
|
40
|
+
* per-call override for callers that have one (tests, mainly), buys the same
|
|
41
|
+
* control for none of the churn. It is safe by default, so the only reason to
|
|
42
|
+
* set it at all is to opt *out*.
|
|
43
|
+
*/
|
|
44
|
+
let defaultUnknownFilterFieldsMode: UnknownFilterFieldsMode = "error";
|
|
45
|
+
|
|
46
|
+
/** Set the process-wide behaviour for unresolvable filter fields. */
|
|
47
|
+
export function configureUnknownFilterFields(mode: UnknownFilterFieldsMode): void {
|
|
48
|
+
defaultUnknownFilterFieldsMode = mode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The process-wide behaviour for unresolvable filter fields. */
|
|
52
|
+
export function getUnknownFilterFieldsMode(): UnknownFilterFieldsMode {
|
|
53
|
+
return defaultUnknownFilterFieldsMode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Per-call context for compiling a filter into SQL. */
|
|
57
|
+
export interface FilterCompilationOptions {
|
|
58
|
+
/**
|
|
59
|
+
* Overrides the process-wide {@link UnknownFilterFieldsMode} for this call.
|
|
60
|
+
*/
|
|
61
|
+
unknownFields?: UnknownFilterFieldsMode;
|
|
62
|
+
/**
|
|
63
|
+
* The collection the filter is written against. Its resolved relations are
|
|
64
|
+
* what turn an owning-relation filter key into the foreign-key column it
|
|
65
|
+
* actually lives in; without it only the default key shapes can be guessed.
|
|
66
|
+
*/
|
|
67
|
+
collection?: CollectionConfig;
|
|
68
|
+
/**
|
|
69
|
+
* The driver's registry, for relations whose link is not on this row at
|
|
70
|
+
* all. A `manyToMany` compiles to an `EXISTS` over its junction and a
|
|
71
|
+
* `hasMany`/`hasOne` to one over the target table — neither of which this
|
|
72
|
+
* builder can reach from the collection alone.
|
|
73
|
+
*/
|
|
74
|
+
registry?: PostgresCollectionRegistry;
|
|
75
|
+
/**
|
|
76
|
+
* The key column of the table being filtered — what those `EXISTS`
|
|
77
|
+
* subqueries correlate back to.
|
|
78
|
+
*
|
|
79
|
+
* It has to be the Drizzle column object rather than a name: a column
|
|
80
|
+
* renders qualified with its own table, which is what binds it to the
|
|
81
|
+
* *outer* row instead of to the junction or target aliased inside the
|
|
82
|
+
* subquery. See {@link DrizzleConditionBuilder.buildRelationFilterCondition}.
|
|
83
|
+
*/
|
|
84
|
+
sourceIdColumn?: AnyPgColumn;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What a filter field turns out to name.
|
|
89
|
+
*
|
|
90
|
+
* A field naming a column compiles to a comparison on it. A field naming a
|
|
91
|
+
* relation that owns no column here compiles to a whole `EXISTS` condition
|
|
92
|
+
* instead, so there is no column to hand back — which is why resolution
|
|
93
|
+
* answers with a discriminated result rather than a column. The caller cannot
|
|
94
|
+
* tell the two apart from the field name, and the difference is not cosmetic:
|
|
95
|
+
* one is `column <op> value`, the other is a correlated subquery.
|
|
96
|
+
*/
|
|
97
|
+
type FilterTarget =
|
|
98
|
+
| { kind: "column"; column: AnyPgColumn }
|
|
99
|
+
| {
|
|
100
|
+
kind: "relation";
|
|
101
|
+
relation: ResolvedForeignKeyOnTarget | ResolvedManyToMany;
|
|
102
|
+
/** Bound here so the compile step cannot be reached without them. */
|
|
103
|
+
registry: PostgresCollectionRegistry;
|
|
104
|
+
sourceIdColumn: AnyPgColumn;
|
|
105
|
+
};
|
|
106
|
+
|
|
13
107
|
/**
|
|
14
108
|
* Filter values may arrive as relation wire objects — `EntityRelation`
|
|
15
109
|
* instances or their JSON form `{ __type: "relation", id, path }` — e.g. when
|
|
@@ -22,6 +116,23 @@ function unwrapRelationFilterValue(value: unknown): unknown {
|
|
|
22
116
|
return relation ? relation.id : value;
|
|
23
117
|
}
|
|
24
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The operand of `in`/`not-in`, as a list.
|
|
121
|
+
*
|
|
122
|
+
* A scalar is the one-element list, because that is what it means and because
|
|
123
|
+
* the wire produces one: `?filter=id.in.5` parses to the string `"5"`, not to
|
|
124
|
+
* `["5"]` — the REST dialect only builds an array when the value is
|
|
125
|
+
* parenthesised. Treating that as malformed and dropping the condition turned
|
|
126
|
+
* a perfectly ordinary query into an unfiltered read.
|
|
127
|
+
*
|
|
128
|
+
* The empty list stays empty. Callers must decide what "no candidates" means
|
|
129
|
+
* for their operator — it is `FALSE` for `in` and `TRUE` for `not-in` — and
|
|
130
|
+
* neither of those is "no condition at all".
|
|
131
|
+
*/
|
|
132
|
+
function toMembershipList(value: unknown): unknown[] {
|
|
133
|
+
return Array.isArray(value) ? value : [value];
|
|
134
|
+
}
|
|
135
|
+
|
|
25
136
|
/** Drizzle dynamic query builder — accepts innerJoin + where chaining */
|
|
26
137
|
|
|
27
138
|
export interface DrizzleDynamicQuery {
|
|
@@ -99,7 +210,24 @@ export class DrizzleConditionBuilder {
|
|
|
99
210
|
// Correlated, not joined: a join through a junction multiplies
|
|
100
211
|
// the target rows by the number of matching links and silently
|
|
101
212
|
// breaks `limit`/`offset`.
|
|
102
|
-
|
|
213
|
+
//
|
|
214
|
+
// The junction is aliased and referenced by identifier, never as a
|
|
215
|
+
// Drizzle column. A column object carries no table qualifier of its
|
|
216
|
+
// own — it is rendered against whatever the surrounding builder
|
|
217
|
+
// thinks the current table is — so inside `db.query.findMany`, which
|
|
218
|
+
// aliases the root table, `${sourceCol}` came out qualified with the
|
|
219
|
+
// *target's* alias: `podcast.podcast_id`, a column that does not
|
|
220
|
+
// exist. That aborts the transaction, and the fallback read then
|
|
221
|
+
// fails on the poisoned transaction rather than on anything to do
|
|
222
|
+
// with the relation. Only `targetIdColumn` stays a column object,
|
|
223
|
+
// because that one *must* bind to the outer row to correlate.
|
|
224
|
+
//
|
|
225
|
+
// Aliasing also disambiguates a self-referential many-to-many, where
|
|
226
|
+
// the junction and the target are the same table.
|
|
227
|
+
const junctionAlias = "__rel_m2m";
|
|
228
|
+
const junctionRef = (column: AnyPgColumn) =>
|
|
229
|
+
sql`${sql.identifier(junctionAlias)}.${sql.identifier(column.name)}`;
|
|
230
|
+
return sql`EXISTS (SELECT 1 FROM ${junctionTable} AS ${sql.identifier(junctionAlias)} WHERE ${junctionRef(targetCol)} = ${targetIdColumn} AND ${junctionRef(sourceCol)} = ${parentId})`;
|
|
103
231
|
}
|
|
104
232
|
|
|
105
233
|
case "hasOne":
|
|
@@ -203,40 +331,121 @@ export class DrizzleConditionBuilder {
|
|
|
203
331
|
return sql`EXISTS (SELECT 1 FROM ${parentTable} AS ${sql.identifier(sourceAlias)}${joinsSql} WHERE ${sql.identifier(sourceAlias)}.${sql.identifier(parentIdColumn.name)} = ${parentId} AND ${correlation})`;
|
|
204
332
|
}
|
|
205
333
|
|
|
334
|
+
/**
|
|
335
|
+
* What a filter field names, or `undefined` if it names nothing.
|
|
336
|
+
*
|
|
337
|
+
* Three ways a field resolves. It may address its column directly; it may
|
|
338
|
+
* be an owning relation, whose foreign key is a column here; or it may be
|
|
339
|
+
* a relation whose link lives on another table entirely, which compiles to
|
|
340
|
+
* a subquery instead of a column. Only a field that resolves to *none* of
|
|
341
|
+
* them is an error, and by default it is one: see
|
|
342
|
+
* {@link UnknownFilterFieldsMode} for why silently dropping it is a
|
|
343
|
+
* data-exposure primitive rather than a convenience.
|
|
344
|
+
*
|
|
345
|
+
* For an owning relation the relation's own `localKey` is the authority,
|
|
346
|
+
* not `<field>_id`. The default local key is `generateForeignKeyName`,
|
|
347
|
+
* which snake-cases *and singularises* — `userProfile` → `user_profile_id`,
|
|
348
|
+
* `users` → `user_id` — and it can be overridden outright. Guessing
|
|
349
|
+
* `<field>_id` therefore misses perfectly ordinary owning relations, and
|
|
350
|
+
* with this resolution failing closed that miss is a 400 on a filter that
|
|
351
|
+
* has nothing wrong with it. The guesses stay, last, for callers that hand
|
|
352
|
+
* over no collection to resolve against.
|
|
353
|
+
*
|
|
354
|
+
* The subquery kinds need a registry and the source table's key column on
|
|
355
|
+
* top of the collection. A caller that supplies neither gets the behaviour
|
|
356
|
+
* it had before they were compilable — unresolvable, and so fail-closed —
|
|
357
|
+
* rather than a half-built condition.
|
|
358
|
+
*/
|
|
359
|
+
private static resolveFilterTarget(
|
|
360
|
+
table: PgTable<any>,
|
|
361
|
+
field: string,
|
|
362
|
+
collectionPath: string,
|
|
363
|
+
mode: UnknownFilterFieldsMode,
|
|
364
|
+
options: FilterCompilationOptions
|
|
365
|
+
): FilterTarget | undefined {
|
|
366
|
+
const { collection, registry, sourceIdColumn } = options;
|
|
367
|
+
|
|
368
|
+
const columnAt = (key: string): AnyPgColumn | undefined =>
|
|
369
|
+
(key in table ? table[key as keyof typeof table] as AnyPgColumn : undefined) || undefined;
|
|
370
|
+
|
|
371
|
+
const direct = columnAt(field);
|
|
372
|
+
if (direct) return { kind: "column", column: direct };
|
|
373
|
+
|
|
374
|
+
if (collection) {
|
|
375
|
+
const relation = resolveCollectionRelations(collection)[field];
|
|
376
|
+
|
|
377
|
+
// Owning relation, resolved: the relation names its own local key.
|
|
378
|
+
if (relation?.kind === "belongsTo") {
|
|
379
|
+
const foreignKey = columnAt(relation.localKey);
|
|
380
|
+
if (foreignKey) return { kind: "column", column: foreignKey };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// The link is on the target table or in a junction. `via` is left
|
|
384
|
+
// out: its join path is authored source → target with no stated
|
|
385
|
+
// inverse, so reversing it into a filter is a different problem
|
|
386
|
+
// from the two shapes below rather than a third case of them.
|
|
387
|
+
if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
|
|
388
|
+
return { kind: "relation", relation, registry, sourceIdColumn };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// No collection in hand — the two shapes an owning relation's key takes
|
|
393
|
+
// by default (e.g. `project` → `project_id`, `userProfile` →
|
|
394
|
+
// `user_profile_id`).
|
|
395
|
+
for (const guess of [`${field}_id`, generateForeignKeyName(field)]) {
|
|
396
|
+
const foreignKey = columnAt(guess);
|
|
397
|
+
if (foreignKey) return { kind: "column", column: foreignKey };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (mode === "warn") {
|
|
401
|
+
logger.warn(`Filtering by field '${field}', but it does not exist in table for collection '${collectionPath}'`);
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let validFields: string[] = [];
|
|
406
|
+
try {
|
|
407
|
+
validFields = Object.keys(getTableColumns(table)).sort();
|
|
408
|
+
} catch {
|
|
409
|
+
// A table stand-in without Drizzle's column symbols — the message
|
|
410
|
+
// is worth less without the list, but not worth failing over.
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Not `expected`: unlike an anonymous token refresh, this is never a
|
|
414
|
+
// routine outcome. It means a filter key and the schema have drifted
|
|
415
|
+
// apart, which used to widen results silently — exactly the thing an
|
|
416
|
+
// operator wants in the log at warn.
|
|
417
|
+
throw ApiError.badRequest(
|
|
418
|
+
`Unknown filter field '${field}' on collection '${collectionPath}'` +
|
|
419
|
+
(validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""),
|
|
420
|
+
"UNKNOWN_FILTER_FIELD",
|
|
421
|
+
{ field, collection: collectionPath, ...(validFields.length > 0 && { validFields }) }
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
206
425
|
/**
|
|
207
426
|
* Build filter conditions from FilterValues
|
|
208
427
|
*/
|
|
209
428
|
static buildFilterConditions<M extends Record<string, unknown>>(
|
|
210
429
|
filter: FilterValues<Extract<keyof M, string>>,
|
|
211
430
|
table: PgTable<any>,
|
|
212
|
-
collectionPath: string
|
|
431
|
+
collectionPath: string,
|
|
432
|
+
options: FilterCompilationOptions = {}
|
|
213
433
|
): SQL[] {
|
|
434
|
+
const mode = options.unknownFields ?? defaultUnknownFilterFieldsMode;
|
|
214
435
|
const conditions: SQL[] = [];
|
|
215
436
|
|
|
216
437
|
for (const [field, filterParam] of Object.entries(filter)) {
|
|
217
438
|
if (!filterParam) continue;
|
|
218
439
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (!fieldColumn) {
|
|
222
|
-
// Fallback for relations (e.g. project -> project_id)
|
|
223
|
-
const relationKey = `${field}_id`;
|
|
224
|
-
if (relationKey in table) {
|
|
225
|
-
fieldColumn = table[relationKey as keyof typeof table] as AnyPgColumn;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (!fieldColumn) {
|
|
230
|
-
logger.warn(`Filtering by field '${field}', but it does not exist in table for collection '${collectionPath}'`);
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
440
|
+
const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
|
|
441
|
+
if (!target) continue;
|
|
233
442
|
|
|
234
443
|
const paramsList = Array.isArray(filterParam) && filterParam.length > 0 && Array.isArray(filterParam[0])
|
|
235
444
|
? (filterParam as [WhereFilterOp, any][])
|
|
236
445
|
: [filterParam as [WhereFilterOp, any]];
|
|
237
446
|
|
|
238
447
|
for (const [op, value] of paramsList) {
|
|
239
|
-
const condition = this.
|
|
448
|
+
const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
|
|
240
449
|
if (condition) {
|
|
241
450
|
conditions.push(condition);
|
|
242
451
|
}
|
|
@@ -252,28 +461,257 @@ export class DrizzleConditionBuilder {
|
|
|
252
461
|
static buildLogicalConditions(
|
|
253
462
|
cond: LogicalCondition | FilterCondition,
|
|
254
463
|
table: PgTable<any>,
|
|
255
|
-
collectionPath: string
|
|
464
|
+
collectionPath: string,
|
|
465
|
+
options: FilterCompilationOptions = {}
|
|
256
466
|
): SQL | null {
|
|
257
467
|
if ("type" in cond) {
|
|
258
468
|
const subSQLs = cond.conditions
|
|
259
|
-
.map(c => this.buildLogicalConditions(c, table, collectionPath))
|
|
469
|
+
.map(c => this.buildLogicalConditions(c, table, collectionPath, options))
|
|
260
470
|
.filter((sql): sql is SQL => sql !== null);
|
|
261
471
|
if (subSQLs.length === 0) return null;
|
|
262
472
|
return (cond.type === "or" ? or(...subSQLs) : and(...subSQLs)) ?? null;
|
|
263
473
|
} else {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
474
|
+
// A dropped leaf is worse here than in a flat filter: inside an
|
|
475
|
+
// `or(...)` the disjunction loses a branch, so the surviving
|
|
476
|
+
// branches match on their own and the result set widens by
|
|
477
|
+
// everything the dropped leaf would have excluded.
|
|
478
|
+
const target = this.resolveFilterTarget(
|
|
479
|
+
table,
|
|
480
|
+
cond.column,
|
|
481
|
+
collectionPath,
|
|
482
|
+
options.unknownFields ?? defaultUnknownFilterFieldsMode,
|
|
483
|
+
options
|
|
484
|
+
);
|
|
485
|
+
if (!target) return null;
|
|
486
|
+
return this.compileFilterTarget(
|
|
487
|
+
target, cond.operator as WhereFilterOp, cond.value, cond.column, collectionPath
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Dispatch a resolved filter field onto the shape it actually compiles to. */
|
|
493
|
+
private static compileFilterTarget(
|
|
494
|
+
target: FilterTarget,
|
|
495
|
+
op: WhereFilterOp,
|
|
496
|
+
value: unknown,
|
|
497
|
+
field: string,
|
|
498
|
+
collectionPath: string
|
|
499
|
+
): SQL | null {
|
|
500
|
+
return target.kind === "column"
|
|
501
|
+
? this.buildSingleFilterCondition(target.column, op, value)
|
|
502
|
+
: this.buildRelationFilterCondition(
|
|
503
|
+
target.relation, op, value, target.sourceIdColumn, target.registry, field, collectionPath
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* A filter on a relation that owns no column on this row — `EXISTS` over
|
|
509
|
+
* the rows it reaches.
|
|
510
|
+
*
|
|
511
|
+
* `posts` filtered by `tags == <tagId>` is not a comparison on `posts`; it
|
|
512
|
+
* is a question about the junction:
|
|
513
|
+
*
|
|
514
|
+
* EXISTS (SELECT 1 FROM posts_tags AS j
|
|
515
|
+
* WHERE j.post_id = posts.id AND j.tag_id = <tagId>)
|
|
516
|
+
*
|
|
517
|
+
* which is {@link buildRelationScopeCondition}'s many-to-many shape with
|
|
518
|
+
* source and target swapped — there the junction's *target* column
|
|
519
|
+
* correlates and the source is pinned; here the *source* column correlates
|
|
520
|
+
* and the target is what the filter constrains.
|
|
521
|
+
*
|
|
522
|
+
* `hasMany`/`hasOne` are the same shape one table over: the target row
|
|
523
|
+
* carries the foreign key, so the correlation is on that key and the
|
|
524
|
+
* compared column is the target's own id.
|
|
525
|
+
*
|
|
526
|
+
* `EXISTS` and not a join, for the reason the scope condition gives: a join
|
|
527
|
+
* through a junction multiplies the outer rows by the number of matching
|
|
528
|
+
* links, which duplicates results and silently breaks `limit`/`offset`.
|
|
529
|
+
*
|
|
530
|
+
* Everything inside the subquery is referenced by identifier against a
|
|
531
|
+
* local alias, and only `sourceIdColumn` stays a Drizzle column object —
|
|
532
|
+
* again see {@link buildRelationScopeCondition}, which explains why a
|
|
533
|
+
* column object renders against whatever table the surrounding builder
|
|
534
|
+
* thinks is current and so cannot be used for the inner references. The
|
|
535
|
+
* alias is also what keeps a self-referential relation unambiguous
|
|
536
|
+
* (`categories.children`, or a many-to-many whose junction and target are
|
|
537
|
+
* the same table), where the subquery's table and the outer one coincide.
|
|
538
|
+
*/
|
|
539
|
+
static buildRelationFilterCondition(
|
|
540
|
+
relation: ResolvedForeignKeyOnTarget | ResolvedManyToMany,
|
|
541
|
+
op: WhereFilterOp,
|
|
542
|
+
value: unknown,
|
|
543
|
+
sourceIdColumn: AnyPgColumn,
|
|
544
|
+
registry: PostgresCollectionRegistry,
|
|
545
|
+
field: string,
|
|
546
|
+
collectionPath: string
|
|
547
|
+
): SQL {
|
|
548
|
+
const alias = "__rel_filter";
|
|
549
|
+
const ref = (column: AnyPgColumn) =>
|
|
550
|
+
sql`${sql.identifier(alias)}.${sql.identifier(column.name)}`;
|
|
551
|
+
|
|
552
|
+
let scanTable: PgTable<any>;
|
|
553
|
+
let correlation: SQL;
|
|
554
|
+
let comparedColumn: AnyPgColumn;
|
|
555
|
+
|
|
556
|
+
if (relation.kind === "manyToMany") {
|
|
557
|
+
const { table: junctionName, sourceColumn, targetColumn } = relation.through;
|
|
558
|
+
const junctionTable = registry.getTable(junctionName);
|
|
559
|
+
if (!junctionTable) {
|
|
560
|
+
throw new Error(`Junction table not found: ${junctionName}`);
|
|
270
561
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
562
|
+
const sourceCol = junctionTable[sourceColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
563
|
+
const targetCol = junctionTable[targetColumn as keyof typeof junctionTable] as AnyPgColumn;
|
|
564
|
+
if (!sourceCol || !targetCol) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
`Junction columns '${sourceColumn}'/'${targetColumn}' not found in '${junctionName}'`
|
|
567
|
+
);
|
|
274
568
|
}
|
|
275
|
-
|
|
569
|
+
scanTable = junctionTable;
|
|
570
|
+
correlation = sql`${ref(sourceCol)} = ${sourceIdColumn}`;
|
|
571
|
+
comparedColumn = targetCol;
|
|
572
|
+
} else {
|
|
573
|
+
const targetCollection = relation.target();
|
|
574
|
+
const targetTable = registry.getTable(getTableName(targetCollection));
|
|
575
|
+
if (!targetTable) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`Table not found for the target of relation '${relation.relationName}' ` +
|
|
578
|
+
`(collection '${targetCollection.slug}')`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const fkColumn = targetTable[relation.foreignKeyOnTarget as keyof typeof targetTable] as AnyPgColumn;
|
|
582
|
+
if (!fkColumn) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of ` +
|
|
585
|
+
`relation '${relation.relationName}'.`
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
// The filter value is a target row's id, so that is what the
|
|
589
|
+
// subquery compares — the foreign key is spent on the correlation.
|
|
590
|
+
const targetIdColumn = this.primaryKeyColumn(targetTable);
|
|
591
|
+
if (!targetIdColumn) {
|
|
592
|
+
throw new Error(
|
|
593
|
+
`No primary key or "id" column in the target table of relation '${relation.relationName}', ` +
|
|
594
|
+
`so a filter on it has nothing to match against.`
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
scanTable = targetTable;
|
|
598
|
+
correlation = sql`${ref(fkColumn)} = ${sourceIdColumn}`;
|
|
599
|
+
comparedColumn = targetIdColumn;
|
|
276
600
|
}
|
|
601
|
+
|
|
602
|
+
const { predicate, negate } = this.buildRelationFilterPredicate(
|
|
603
|
+
ref(comparedColumn), op, value, field, collectionPath
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
const where = predicate ? sql`${correlation} AND ${predicate}` : correlation;
|
|
607
|
+
const exists = sql`EXISTS (SELECT 1 FROM ${scanTable} AS ${sql.identifier(alias)} WHERE ${where})`;
|
|
608
|
+
return negate ? sql`NOT ${exists}` : exists;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* The inner predicate of a relation filter, and whether the `EXISTS`
|
|
613
|
+
* wrapping it is negated.
|
|
614
|
+
*
|
|
615
|
+
* Negation is `NOT EXISTS` of the *positive* predicate, never `EXISTS` of a
|
|
616
|
+
* negated one. On a many-valued relation the two are different questions:
|
|
617
|
+
* `EXISTS (… AND tag_id != X)` asks "does some tag differ from X", which is
|
|
618
|
+
* true of nearly every post with more than one tag and answers nothing
|
|
619
|
+
* anybody asked. `NOT EXISTS (… AND tag_id = X)` asks "is X absent", which
|
|
620
|
+
* is what unticking a value in a filter control means — and it makes `==`
|
|
621
|
+
* and `!=` partition the rows, the way a filter implies they do.
|
|
622
|
+
*
|
|
623
|
+
* `is-null`/`is-not-null` drop the predicate entirely: with nothing but the
|
|
624
|
+
* correlation left, they become "has no related row at all" and "has at
|
|
625
|
+
* least one", which is the only reading of null a link can have.
|
|
626
|
+
*
|
|
627
|
+
* Under RLS, "no related row" means *no row this reader can see*. A junction
|
|
628
|
+
* with row-level security but no `SELECT` policy for `rebase_user` is opaque
|
|
629
|
+
* to it, so every row comes back looking unlinked and `is-null` matches all
|
|
630
|
+
* of them. That is not a leak — the outer table's own policies still decide
|
|
631
|
+
* which rows exist at all, and the positive direction correctly returns
|
|
632
|
+
* nothing — but it over-reports, and the cause is a missing junction policy
|
|
633
|
+
* rather than anything here. Rebase derives one for a declared many-to-many;
|
|
634
|
+
* a hand-written schema has to supply it.
|
|
635
|
+
*
|
|
636
|
+
* `in`/`not-in` against a *null value* mean the same thing, rather than
|
|
637
|
+
* membership of an empty list. Membership against null is not a membership
|
|
638
|
+
* question, and the admin's "filter for null values" control emits the
|
|
639
|
+
* operator that happens to be selected — on a to-many relation that is
|
|
640
|
+
* always `in` or `not-in`, because those are the only ones the multi-select
|
|
641
|
+
* can produce. Reading `["in", null]` as an empty list would answer "posts
|
|
642
|
+
* with no tags" with no posts at all.
|
|
643
|
+
*
|
|
644
|
+
* An empty `in` list compiles to `FALSE` rather than being dropped. Dropped
|
|
645
|
+
* is what the column path does, and dropping a condition widens the result
|
|
646
|
+
* — the whole reason this resolution fails closed. `in []` matches nothing
|
|
647
|
+
* and `not-in []` matches everything, and `NOT EXISTS (… AND FALSE)` gives
|
|
648
|
+
* the second for free.
|
|
649
|
+
*
|
|
650
|
+
* Anything else is rejected. Returning `null` for an operator this cannot
|
|
651
|
+
* express would drop the condition, and the operators the admin offers for
|
|
652
|
+
* a relation are exactly the six below.
|
|
653
|
+
*/
|
|
654
|
+
private static buildRelationFilterPredicate(
|
|
655
|
+
ref: SQL,
|
|
656
|
+
op: WhereFilterOp,
|
|
657
|
+
value: unknown,
|
|
658
|
+
field: string,
|
|
659
|
+
collectionPath: string
|
|
660
|
+
): { predicate?: SQL; negate: boolean } {
|
|
661
|
+
value = unwrapRelationFilterValue(value);
|
|
662
|
+
const isNullish = value === null || value === undefined;
|
|
663
|
+
|
|
664
|
+
const equals = () => sql`${ref} = ${value}`;
|
|
665
|
+
const inList = () => {
|
|
666
|
+
// Same reading as the column path: a scalar is the one-element
|
|
667
|
+
// list, an empty list is `FALSE`. Here `FALSE` also gives
|
|
668
|
+
// `not-in []` its answer for free — `NOT EXISTS (… AND FALSE)`
|
|
669
|
+
// is true of every row, which is what excluding nothing means.
|
|
670
|
+
const values = toMembershipList(value);
|
|
671
|
+
return values.length === 0
|
|
672
|
+
? sql`FALSE`
|
|
673
|
+
: sql`${ref} IN (${sql.join(values.map(v => sql`${v}`), sql`, `)})`;
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
switch (op) {
|
|
677
|
+
case "==":
|
|
678
|
+
return isNullish ? { negate: true } : { predicate: equals(), negate: false };
|
|
679
|
+
case "!=":
|
|
680
|
+
return isNullish ? { negate: false } : { predicate: equals(), negate: true };
|
|
681
|
+
case "in":
|
|
682
|
+
return isNullish ? { negate: true } : { predicate: inList(), negate: false };
|
|
683
|
+
case "not-in":
|
|
684
|
+
return isNullish ? { negate: false } : { predicate: inList(), negate: true };
|
|
685
|
+
case "is-null":
|
|
686
|
+
return { negate: true };
|
|
687
|
+
case "is-not-null":
|
|
688
|
+
return { negate: false };
|
|
689
|
+
// A to-many relation *is* the list, so the array operators ask the
|
|
690
|
+
// same two questions under different names: "contains X" is "some
|
|
691
|
+
// related row is X", and "contains any of [X, Y]" is `in`. They
|
|
692
|
+
// reach here because the admin offers them for a property that is
|
|
693
|
+
// an *array of* relations, and rejecting a question the shape
|
|
694
|
+
// answers perfectly well would put a 400 behind a working control.
|
|
695
|
+
case "array-contains":
|
|
696
|
+
return isNullish ? { negate: true } : { predicate: equals(), negate: false };
|
|
697
|
+
case "array-contains-any":
|
|
698
|
+
return isNullish ? { negate: true } : { predicate: inList(), negate: false };
|
|
699
|
+
default:
|
|
700
|
+
throw ApiError.badRequest(
|
|
701
|
+
`Operator '${op}' cannot be applied to relation field '${field}' on collection ` +
|
|
702
|
+
`'${collectionPath}'. A relation with no column on this row is filtered by ` +
|
|
703
|
+
"membership: ==, !=, in, not-in, array-contains, array-contains-any, is-null, " +
|
|
704
|
+
"is-not-null.",
|
|
705
|
+
"UNSUPPORTED_RELATION_FILTER_OPERATOR",
|
|
706
|
+
{ field, collection: collectionPath, operator: op }
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** The column a table's rows are keyed by: its primary key, else `id`. */
|
|
712
|
+
private static primaryKeyColumn(table: PgTable<any>): AnyPgColumn | undefined {
|
|
713
|
+
return (Object.values(table).find((col: Record<string, unknown>) => col.primary)
|
|
714
|
+
?? Object.values(table).find((col: Record<string, unknown>) => col.name === "id")) as AnyPgColumn | undefined;
|
|
277
715
|
}
|
|
278
716
|
|
|
279
717
|
/**
|
|
@@ -304,11 +742,25 @@ export class DrizzleConditionBuilder {
|
|
|
304
742
|
return sql`${column} < ${value}`;
|
|
305
743
|
case "<=":
|
|
306
744
|
return sql`${column} <= ${value}`;
|
|
307
|
-
case "in":
|
|
308
|
-
|
|
309
|
-
|
|
745
|
+
case "in": {
|
|
746
|
+
// Membership against a null *value* is a null check, not an
|
|
747
|
+
// empty list — the admin's "filter for null values" control
|
|
748
|
+
// emits whichever operator is selected, so `["in", null]` is
|
|
749
|
+
// how it asks for a null foreign key when the user picked
|
|
750
|
+
// `in`. Reading it as an empty list dropped the condition
|
|
751
|
+
// outright, which widened the read to every row.
|
|
752
|
+
if (value === null || value === undefined) {
|
|
753
|
+
return sql`${column} IS NULL`;
|
|
310
754
|
}
|
|
311
|
-
|
|
755
|
+
const values = toMembershipList(value);
|
|
756
|
+
// An empty list matches nothing. Returning no condition — what
|
|
757
|
+
// this did — matches *everything*, which is the same inversion
|
|
758
|
+
// one layer down from the one `UnknownFilterFieldsMode` exists
|
|
759
|
+
// for. It is the dangerous shape too: `filter: { id: ["in",
|
|
760
|
+
// teamIds] }` with no teams is how a caller asks for nothing,
|
|
761
|
+
// and it answered with the whole table.
|
|
762
|
+
return values.length === 0 ? sql`FALSE` : inArray(column, values);
|
|
763
|
+
}
|
|
312
764
|
case "array-contains": {
|
|
313
765
|
const meta = getColumnMeta(column);
|
|
314
766
|
if (meta.dataType === "array" || meta.columnType === "PgArray") {
|
|
@@ -320,6 +772,13 @@ export class DrizzleConditionBuilder {
|
|
|
320
772
|
case "array-contains-any": {
|
|
321
773
|
const meta = getColumnMeta(column);
|
|
322
774
|
const isNativeArray = meta.dataType === "array" || meta.columnType === "PgArray";
|
|
775
|
+
// "Overlaps nothing" is false, not a licence to skip the
|
|
776
|
+
// condition. The single-value fallback below is for a *scalar*
|
|
777
|
+
// operand; an empty array fell into it and built
|
|
778
|
+
// `@> ARRAY[$1]` around an empty binding.
|
|
779
|
+
if (Array.isArray(value) && value.length === 0) {
|
|
780
|
+
return sql`FALSE`;
|
|
781
|
+
}
|
|
323
782
|
if (Array.isArray(value) && value.length > 0) {
|
|
324
783
|
if (isNativeArray) {
|
|
325
784
|
return sql`${column} && ARRAY[${sql.join(value.map(v => sql`${v}`), sql`, `)}]`;
|
|
@@ -335,11 +794,18 @@ export class DrizzleConditionBuilder {
|
|
|
335
794
|
}
|
|
336
795
|
return sql`${column} @> ${JSON.stringify([value])}`;
|
|
337
796
|
}
|
|
338
|
-
case "not-in":
|
|
339
|
-
|
|
340
|
-
|
|
797
|
+
case "not-in": {
|
|
798
|
+
// The mirror of `in` above, including the empty list — which
|
|
799
|
+
// excludes nothing, and so matches every row. Same condition
|
|
800
|
+
// the old code produced by accident, now on purpose and for
|
|
801
|
+
// the empty list only.
|
|
802
|
+
if (value === null || value === undefined) {
|
|
803
|
+
return sql`${column} IS NOT NULL`;
|
|
341
804
|
}
|
|
342
|
-
|
|
805
|
+
const values = toMembershipList(value);
|
|
806
|
+
if (values.length === 0) return sql`TRUE`;
|
|
807
|
+
return sql`${column} NOT IN (${sql.join(values.map(v => sql`${v}`), sql`, `)})`;
|
|
808
|
+
}
|
|
343
809
|
case "like":
|
|
344
810
|
return sql`${column} LIKE ${String(value)}`;
|
|
345
811
|
case "ilike":
|
|
@@ -746,9 +1212,7 @@ whereConditions };
|
|
|
746
1212
|
if (relation.kind === "belongsTo") {
|
|
747
1213
|
// `parentId` is the foreign key's value, matched against the
|
|
748
1214
|
// target's own key.
|
|
749
|
-
const targetIdCol =
|
|
750
|
-
(Object.values(targetTable).find((col: Record<string, unknown>) => col.primary)
|
|
751
|
-
?? Object.values(targetTable).find((col: Record<string, unknown>) => col.name === "id")) as AnyPgColumn | undefined;
|
|
1215
|
+
const targetIdCol = this.primaryKeyColumn(targetTable);
|
|
752
1216
|
if (!targetIdCol) {
|
|
753
1217
|
throw new Error(
|
|
754
1218
|
`No primary key or "id" column in the target table of relation '${relation.relationName}'.`
|