@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g06dbe5b
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/PostgresBackendDriver.d.ts +48 -1
- package/dist/auth/services.d.ts +19 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
- package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
- package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
- package/dist/index.es.js +1365 -338
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +23 -25
- package/dist/schema/ensure-collection-tables.d.ts +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- package/dist/services/RelationService.d.ts +30 -0
- package/dist/services/collection-helpers.d.ts +26 -0
- package/dist/services/dataService.d.ts +5 -0
- package/dist/services/realtimeService.d.ts +131 -21
- package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
- package/dist/websocket-D1qbmLZ2.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +55 -6
- package/src/auth/ensure-tables.ts +207 -86
- package/src/auth/services.ts +69 -5
- package/src/cli-helpers.ts +129 -10
- package/src/cli.ts +232 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +120 -17
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +12 -63
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +135 -76
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +132 -42
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +66 -34
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/rls-enforcement.ts +149 -6
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +175 -108
- package/src/services/PersistService.ts +38 -2
- package/src/services/RelationService.ts +110 -67
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +54 -1
- package/src/services/dataService.ts +5 -0
- package/src/services/realtimeService.ts +344 -79
- package/src/utils/drizzle-conditions.ts +365 -23
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { and, eq, or, sql, SQL, ilike, inArray, getTableColumns } from "drizzle-orm";
|
|
2
|
-
import { AnyPgColumn, PgTable
|
|
2
|
+
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
import {
|
|
4
4
|
CollectionConfig, FilterValues, WhereFilterOp, JoinStep, LogicalCondition, FilterCondition,
|
|
5
5
|
ResolvedRelation, ResolvedBelongsTo, ResolvedHasOne, ResolvedHasMany,
|
|
6
6
|
ResolvedForeignKeyOnTarget, ResolvedManyToMany, hasForeignKeyOnTarget, isManyToMany
|
|
7
7
|
} from "@rebasepro/types";
|
|
8
8
|
import {
|
|
9
|
-
getColumnName, getTableName, normalizeToEntityRelation, resolveCollectionRelations
|
|
9
|
+
getColumnName, getTableName, normalizeToEntityRelation, resolveCollectionRelations, toFilterTuples
|
|
10
10
|
} from "@rebasepro/common";
|
|
11
11
|
import { generateForeignKeyName } from "@rebasepro/utils";
|
|
12
|
+
/**
|
|
13
|
+
* Postgres's own default for `pg_trgm.word_similarity_threshold`. Named here
|
|
14
|
+
* because the fuzzy predicate has to know when the index-backed operator agrees
|
|
15
|
+
* with the collection's declared threshold and when it would narrow too far.
|
|
16
|
+
*/
|
|
17
|
+
const PG_TRGM_WORD_SIMILARITY_DEFAULT = 0.6;
|
|
18
|
+
import { buildSearchColumnSpec, SEARCH_UNACCENT_FN, type SearchColumnSpec } from "../schema/search-column";
|
|
12
19
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
13
20
|
import { ConditionBuilderStatic } from "../interfaces";
|
|
14
21
|
import { ApiError, logger } from "@rebasepro/server";
|
|
@@ -28,6 +35,69 @@ import { getColumnMeta } from "../services/collection-helpers";
|
|
|
28
35
|
*/
|
|
29
36
|
export type UnknownFilterFieldsMode = "error" | "warn";
|
|
30
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A user's search term, made safe to drop inside a `%…%` LIKE pattern.
|
|
40
|
+
*
|
|
41
|
+
* The term is already a bind parameter, so this is not about injection. It is
|
|
42
|
+
* about the two things a LIKE metacharacter does when it arrives from a search
|
|
43
|
+
* box:
|
|
44
|
+
*
|
|
45
|
+
* 1. **It changes the query.** `%` and `_` are wildcards, so searching for
|
|
46
|
+
* `50%` returned every row and `a_c` matched `abc`. Nothing the caller
|
|
47
|
+
* could type would find a literal `%`.
|
|
48
|
+
* 2. **It is a cost the caller chooses.** Postgres matches LIKE by
|
|
49
|
+
* backtracking: each `%` re-tries every remaining offset, so
|
|
50
|
+
* `?searchString=a%a%a%a%a%a%a%b` is polynomial with an attacker-chosen
|
|
51
|
+
* exponent — evaluated per row, OR-ed across every string property of the
|
|
52
|
+
* collection, on a sequential scan (a leading `%` cannot use an index), and
|
|
53
|
+
* the page limit does not bound it because the scan happens first.
|
|
54
|
+
*
|
|
55
|
+
* This is the server-side half of the pattern `like-pattern-redos.test.ts`
|
|
56
|
+
* hardened the offline evaluator against; that test's own note ("the same
|
|
57
|
+
* translation in the Mongo driver hands the expression to the database, where
|
|
58
|
+
* it occupies a server thread instead") describes this call site.
|
|
59
|
+
*
|
|
60
|
+
* Backslash is the default `ESCAPE` character for LIKE, and the pattern is
|
|
61
|
+
* bound rather than interpolated, so a single backslash here reaches the
|
|
62
|
+
* matcher as one. Escaping the escape character first is what keeps a term
|
|
63
|
+
* ending in `\` from swallowing the closing `%`.
|
|
64
|
+
*
|
|
65
|
+
* Note this is a *substring search*, not the `like` filter operator: a caller
|
|
66
|
+
* who wants wildcards has `?title=like.foo%` for that, where the pattern is the
|
|
67
|
+
* documented input.
|
|
68
|
+
*/
|
|
69
|
+
export const escapeLikePattern = (value: string): string =>
|
|
70
|
+
value.replace(/[\\%_]/g, ch => `\\${ch}`);
|
|
71
|
+
|
|
72
|
+
/** Column types `ILIKE '%…%'` is defined on. */
|
|
73
|
+
const ILIKE_SQL_TYPES = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Can this column be matched with `ILIKE`?
|
|
77
|
+
*
|
|
78
|
+
* Asked of the column's *declared SQL type*, never with `instanceof`. The
|
|
79
|
+
* previous version tested `column instanceof PgVarchar || … PgText || … PgChar`,
|
|
80
|
+
* and `instanceof` compares class identity: it is only true when the column was
|
|
81
|
+
* constructed by the very same copy of `drizzle-orm` that this module imported.
|
|
82
|
+
*
|
|
83
|
+
* An application's generated schema builds its tables with the app's own
|
|
84
|
+
* `drizzle-orm`, and this driver declares its own dependency on one. When the
|
|
85
|
+
* two ranges do not overlap — an app scaffolded against `^0.44` with a driver
|
|
86
|
+
* asking for `^0.45` — a strict installer gives the driver a second copy, every
|
|
87
|
+
* check returns false, no condition is produced, and the caller compiles that
|
|
88
|
+
* into an impossible `WHERE`. The result is a 200 with an empty page for every
|
|
89
|
+
* search on every collection without a `search` block: the failure looks
|
|
90
|
+
* exactly like "nothing matched". Observed in production, not theorised.
|
|
91
|
+
*
|
|
92
|
+
* `getSQLType()` is a value the column reports about itself, so it crosses
|
|
93
|
+
* module instances the way a class identity cannot. It also happens to fix
|
|
94
|
+
* `citext`, which the `instanceof` list never covered.
|
|
95
|
+
*/
|
|
96
|
+
const supportsILike = (column: AnyPgColumn): boolean => {
|
|
97
|
+
const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
|
|
98
|
+
return ILIKE_SQL_TYPES.test(sqlType);
|
|
99
|
+
};
|
|
100
|
+
|
|
31
101
|
/**
|
|
32
102
|
* Process-wide default, set once when the driver is constructed.
|
|
33
103
|
*
|
|
@@ -465,11 +535,9 @@ export class DrizzleConditionBuilder {
|
|
|
465
535
|
const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
|
|
466
536
|
if (!target) continue;
|
|
467
537
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
for (const [op, value] of paramsList) {
|
|
538
|
+
// One tuple or an array of them — the grammar, read the same way by
|
|
539
|
+
// every compiler. See `toFilterTuples`.
|
|
540
|
+
for (const [op, value] of toFilterTuples(filterParam)) {
|
|
473
541
|
const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
|
|
474
542
|
if (condition) {
|
|
475
543
|
conditions.push(condition);
|
|
@@ -1275,38 +1343,261 @@ whereConditions };
|
|
|
1275
1343
|
}
|
|
1276
1344
|
|
|
1277
1345
|
/**
|
|
1278
|
-
* Build search conditions for text fields
|
|
1346
|
+
* Build search conditions for text fields.
|
|
1347
|
+
*
|
|
1348
|
+
* Two shapes, chosen by whether the collection declared a `search` block:
|
|
1349
|
+
*
|
|
1350
|
+
* - **Declared** — one `@@ websearch_to_tsquery` against the generated
|
|
1351
|
+
* `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
|
|
1352
|
+
* inside JSONB and arrays, and uses the GIN index.
|
|
1353
|
+
* - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level
|
|
1354
|
+
* string properties, with the term escaped (see {@link escapeLikePattern})
|
|
1355
|
+
* so it is matched as the literal text the user typed.
|
|
1356
|
+
*
|
|
1357
|
+
* The second is the default and stays the default. A collection that has
|
|
1358
|
+
* not opted in compiles to exactly the SQL it compiled to before this
|
|
1359
|
+
* branch existed, which is the only reason it is safe to have added it.
|
|
1360
|
+
*
|
|
1361
|
+
* `collection` is optional so that the callers which genuinely have no
|
|
1362
|
+
* collection in hand — nested paths, derived views — keep working; without
|
|
1363
|
+
* one there is no `search` block to read and the ILIKE path is correct.
|
|
1279
1364
|
*/
|
|
1280
1365
|
static buildSearchConditions(
|
|
1281
1366
|
searchString: string,
|
|
1282
1367
|
properties: Record<string, unknown>,
|
|
1283
|
-
table: PgTable<any
|
|
1368
|
+
table: PgTable<any>,
|
|
1369
|
+
collection?: CollectionConfig
|
|
1284
1370
|
): SQL[] {
|
|
1285
1371
|
const searchConditions: SQL[] = [];
|
|
1286
1372
|
|
|
1373
|
+
const ftsCondition = collection
|
|
1374
|
+
? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection)
|
|
1375
|
+
: undefined;
|
|
1376
|
+
if (ftsCondition) return [ftsCondition];
|
|
1377
|
+
|
|
1378
|
+
let declaredStringProperties = 0;
|
|
1379
|
+
|
|
1287
1380
|
for (const [key, prop] of Object.entries(properties)) {
|
|
1288
1381
|
const p = prop as Record<string, unknown>;
|
|
1289
1382
|
// Only include string properties that don't have enum defined
|
|
1290
1383
|
// PostgreSQL enum and uuid columns don't support ILIKE, so we skip them
|
|
1291
1384
|
if (p.type === "string" && !p.enum && p.isId !== "uuid") {
|
|
1385
|
+
declaredStringProperties++;
|
|
1292
1386
|
const fieldColumn = table[key as keyof typeof table] as AnyPgColumn;
|
|
1293
|
-
if (fieldColumn) {
|
|
1294
|
-
|
|
1295
|
-
const supportsILike =
|
|
1296
|
-
fieldColumn instanceof PgVarchar ||
|
|
1297
|
-
fieldColumn instanceof PgText ||
|
|
1298
|
-
fieldColumn instanceof PgChar ||
|
|
1299
|
-
(fieldColumn && typeof fieldColumn === "object" && !("columnType" in fieldColumn));
|
|
1300
|
-
if (supportsILike) {
|
|
1301
|
-
searchConditions.push(ilike(fieldColumn, `%${searchString}%`));
|
|
1302
|
-
}
|
|
1387
|
+
if (fieldColumn && supportsILike(fieldColumn)) {
|
|
1388
|
+
searchConditions.push(ilike(fieldColumn, `%${escapeLikePattern(searchString)}%`));
|
|
1303
1389
|
}
|
|
1304
1390
|
}
|
|
1305
1391
|
}
|
|
1306
1392
|
|
|
1393
|
+
// Every string property was rejected, so the caller is about to turn an
|
|
1394
|
+
// empty condition list into "match nothing" — a 200 with an empty page,
|
|
1395
|
+
// which reads as "no such row" rather than as the breakage it is. Say so
|
|
1396
|
+
// once per query: this is how the `instanceof` version of
|
|
1397
|
+
// {@link supportsILike} failed silently in the field for months.
|
|
1398
|
+
if (declaredStringProperties > 0 && searchConditions.length === 0) {
|
|
1399
|
+
logger.warn(
|
|
1400
|
+
`[search] "${collection?.slug ?? "collection"}" declares ${declaredStringProperties} string ` +
|
|
1401
|
+
"property(ies) but none compiled to a searchable column, so this search can only return nothing. " +
|
|
1402
|
+
"Check that the generated schema's column types are text/varchar/char."
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1307
1406
|
return searchConditions;
|
|
1308
1407
|
}
|
|
1309
1408
|
|
|
1409
|
+
/**
|
|
1410
|
+
* The `@@` predicate for a collection that declared a `search` block, or
|
|
1411
|
+
* undefined for one that did not.
|
|
1412
|
+
*
|
|
1413
|
+
* The query is normalized exactly as the indexed content was — same text
|
|
1414
|
+
* search configuration, same accent folding. Skipping that on the query
|
|
1415
|
+
* side is the subtle way to get a search that matches nothing: the column
|
|
1416
|
+
* would hold `gestion` while the query asked for `gestión`.
|
|
1417
|
+
*
|
|
1418
|
+
* `websearch_to_tsquery` rather than `plainto_tsquery` because it is the
|
|
1419
|
+
* one that behaves the way a search box looks like it should — quoted
|
|
1420
|
+
* phrases, `or`, and a leading `-` to exclude — and because it never throws
|
|
1421
|
+
* on user input, which `to_tsquery` does on so much as a stray parenthesis.
|
|
1422
|
+
*/
|
|
1423
|
+
static buildFullTextCondition(
|
|
1424
|
+
searchString: string,
|
|
1425
|
+
table: PgTable<any>,
|
|
1426
|
+
collection: CollectionConfig
|
|
1427
|
+
): SQL | undefined {
|
|
1428
|
+
let spec: SearchColumnSpec | undefined;
|
|
1429
|
+
try {
|
|
1430
|
+
spec = buildSearchColumnSpec(collection);
|
|
1431
|
+
} catch {
|
|
1432
|
+
// Reported at boot. Falling back to ILIKE here keeps reads serving.
|
|
1433
|
+
return undefined;
|
|
1434
|
+
}
|
|
1435
|
+
if (!spec) return undefined;
|
|
1436
|
+
|
|
1437
|
+
const column = table[spec.column as keyof typeof table] as AnyPgColumn | undefined;
|
|
1438
|
+
if (!column) {
|
|
1439
|
+
// The block is declared but the column is not on the table yet —
|
|
1440
|
+
// a database that has not been migrated. ILIKE still answers.
|
|
1441
|
+
return undefined;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
|
|
1445
|
+
const exact = sql`${column} @@ ${query}`;
|
|
1446
|
+
|
|
1447
|
+
if (!spec.fuzzy) return exact;
|
|
1448
|
+
|
|
1449
|
+
const fuzzyColumn = table[spec.fuzzy.column as keyof typeof table] as AnyPgColumn | undefined;
|
|
1450
|
+
if (!fuzzyColumn) return exact;
|
|
1451
|
+
|
|
1452
|
+
const needle = spec.unaccent
|
|
1453
|
+
? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
|
|
1454
|
+
: sql`${searchString}`;
|
|
1455
|
+
|
|
1456
|
+
// `word_similarity(query, document)`, not `similarity`. `similarity`
|
|
1457
|
+
// scores two strings as wholes, so a short query against a whole row's
|
|
1458
|
+
// text scores near zero however well it matches part of it — measured:
|
|
1459
|
+
// "iso 14001 auditor" against one candidate's concatenated fields
|
|
1460
|
+
// scores 0.228 by `similarity` and 0.783 by `word_similarity`. The
|
|
1461
|
+
// first is below any usable threshold, which would have made `fuzzy`
|
|
1462
|
+
// a setting that quietly did nothing.
|
|
1463
|
+
//
|
|
1464
|
+
// Argument order matters: the first operand is the needle, and the
|
|
1465
|
+
// score is its similarity to the best-matching extent of the second.
|
|
1466
|
+
//
|
|
1467
|
+
// Both the function and the operator are schema-qualified: pg_trgm is
|
|
1468
|
+
// installed into `public`, and an unqualified reference resolves
|
|
1469
|
+
// through `search_path`, which does not necessarily reach it.
|
|
1470
|
+
const similar = sql`public.word_similarity(${needle}, ${fuzzyColumn}) >= ${spec.fuzzy.threshold}`;
|
|
1471
|
+
// `<%` is the index-backed form, but it tests against the session's
|
|
1472
|
+
// `pg_trgm.word_similarity_threshold` (0.6), not ours. Above that
|
|
1473
|
+
// default the operator narrows using the trigram index and the explicit
|
|
1474
|
+
// score refines; at or below it, the operator would exclude rows the
|
|
1475
|
+
// declared threshold admits, so the score stands alone and the planner
|
|
1476
|
+
// scans — correct either way, and only the faster path is conditional.
|
|
1477
|
+
const fuzzy = spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT
|
|
1478
|
+
? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})`
|
|
1479
|
+
: similar;
|
|
1480
|
+
|
|
1481
|
+
return sql`(${exact} OR ${fuzzy})`;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* `websearch_to_tsquery(<config>, <normalized search string>)`.
|
|
1486
|
+
*
|
|
1487
|
+
* Split out because the ranking expression needs the identical query — a
|
|
1488
|
+
* row ranked against a different tsquery than it was matched against is a
|
|
1489
|
+
* ranking of something else.
|
|
1490
|
+
*/
|
|
1491
|
+
static normalizedTsQuery(searchString: string, spec: SearchColumnSpec): SQL {
|
|
1492
|
+
const normalized = spec.unaccent
|
|
1493
|
+
? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
|
|
1494
|
+
: sql`${searchString}`;
|
|
1495
|
+
return sql`websearch_to_tsquery(${spec.language}, ${normalized})`;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* A JSONB array of `{ field, snippet }` naming which declared fields matched
|
|
1500
|
+
* and showing the text around each hit — what backs `_matches`.
|
|
1501
|
+
*
|
|
1502
|
+
* A ranked list answers "which rows", never "why this row". For a talent
|
|
1503
|
+
* pool that difference is the product: a candidate surfacing for
|
|
1504
|
+
* "iso 14001" on a *certification* is a different candidate from one whose
|
|
1505
|
+
* bio happens to mention the standard, and the score cannot tell them apart.
|
|
1506
|
+
*
|
|
1507
|
+
* Built as a correlated subquery over a `VALUES` list of the declared
|
|
1508
|
+
* fields, rather than one `CASE` per field, so the shape does not change
|
|
1509
|
+
* with the number of fields and the empty result is a plain `[]`.
|
|
1510
|
+
*
|
|
1511
|
+
* `ts_headline` runs over the same normalized text that was indexed. Over
|
|
1512
|
+
* the *original* text it would find nothing to mark whenever `unaccent` is
|
|
1513
|
+
* on — the query's lexemes are folded and the document's are not — and
|
|
1514
|
+
* would return the text silently unhighlighted. Folded-but-marked beats
|
|
1515
|
+
* pretty-but-inert.
|
|
1516
|
+
*
|
|
1517
|
+
* Undefined when the collection has not opted in, when the column is not on
|
|
1518
|
+
* the table yet, or when the caller did not ask: this costs a `ts_headline`
|
|
1519
|
+
* per field per row and `ts_headline` re-parses the document.
|
|
1520
|
+
*/
|
|
1521
|
+
static buildSearchMatchesExpression(
|
|
1522
|
+
searchString: string,
|
|
1523
|
+
table: PgTable<any>,
|
|
1524
|
+
collection: CollectionConfig
|
|
1525
|
+
): SQL | undefined {
|
|
1526
|
+
let spec: SearchColumnSpec | undefined;
|
|
1527
|
+
try {
|
|
1528
|
+
spec = buildSearchColumnSpec(collection);
|
|
1529
|
+
} catch {
|
|
1530
|
+
return undefined;
|
|
1531
|
+
}
|
|
1532
|
+
if (!spec || spec.fields.length === 0) return undefined;
|
|
1533
|
+
if (!table[spec.column as keyof typeof table]) return undefined;
|
|
1534
|
+
|
|
1535
|
+
const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
|
|
1536
|
+
const config = sql`${spec.language}`;
|
|
1537
|
+
|
|
1538
|
+
// `ord` keeps the author's declared field order in the output, so the
|
|
1539
|
+
// most important field they named reads first rather than whichever
|
|
1540
|
+
// Postgres aggregated first.
|
|
1541
|
+
const rows = spec.fields.map((f, i) =>
|
|
1542
|
+
sql`(${i}, ${f.path}, ${sql.raw(f.textSql)})`
|
|
1543
|
+
);
|
|
1544
|
+
|
|
1545
|
+
return sql`(
|
|
1546
|
+
SELECT coalesce(jsonb_agg(s.m ORDER BY f.ord), '[]'::jsonb)
|
|
1547
|
+
FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt)
|
|
1548
|
+
CROSS JOIN LATERAL (
|
|
1549
|
+
SELECT jsonb_build_object(
|
|
1550
|
+
'field', f.path,
|
|
1551
|
+
'snippet', ts_headline(${config}::regconfig, f.txt, ${query},
|
|
1552
|
+
'StartSel=<mark>,StopSel=</mark>,MaxWords=14,MinWords=1,MaxFragments=1,FragmentDelimiter= … ')
|
|
1553
|
+
) AS m
|
|
1554
|
+
WHERE to_tsvector(${config}::regconfig, f.txt) @@ ${query}
|
|
1555
|
+
) s
|
|
1556
|
+
)`;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* `ts_rank(<column>, <query>)` for the collection, or undefined when it has
|
|
1561
|
+
* not opted in. This is what backs `orderBy: ["_score", "desc"]`.
|
|
1562
|
+
*/
|
|
1563
|
+
static buildSearchRankExpression(
|
|
1564
|
+
searchString: string,
|
|
1565
|
+
table: PgTable<any>,
|
|
1566
|
+
collection: CollectionConfig
|
|
1567
|
+
): SQL | undefined {
|
|
1568
|
+
let spec: SearchColumnSpec | undefined;
|
|
1569
|
+
try {
|
|
1570
|
+
spec = buildSearchColumnSpec(collection);
|
|
1571
|
+
} catch {
|
|
1572
|
+
return undefined;
|
|
1573
|
+
}
|
|
1574
|
+
if (!spec) return undefined;
|
|
1575
|
+
const column = table[spec.column as keyof typeof table] as AnyPgColumn | undefined;
|
|
1576
|
+
if (!column) return undefined;
|
|
1577
|
+
|
|
1578
|
+
const rank = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
|
|
1579
|
+
if (!spec.fuzzy) return rank;
|
|
1580
|
+
|
|
1581
|
+
const fuzzyColumn = table[spec.fuzzy.column as keyof typeof table] as AnyPgColumn | undefined;
|
|
1582
|
+
if (!fuzzyColumn) return rank;
|
|
1583
|
+
|
|
1584
|
+
// With `fuzzy` on, `ts_rank` alone is not a ranking — it is zero for
|
|
1585
|
+
// every row the trigram path matched and the exact path did not, which
|
|
1586
|
+
// is the whole population of a typo'd query. Measured on the real
|
|
1587
|
+
// sustentalent pool: "auditor de iso14000" matches four candidates,
|
|
1588
|
+
// every one of them at ts_rank 0, so ordering by rank alone returned
|
|
1589
|
+
// the best match in whatever order the table felt like.
|
|
1590
|
+
//
|
|
1591
|
+
// Summed rather than blended with tuned constants: a row that matched
|
|
1592
|
+
// exactly contributes both terms, so it outranks a fuzzy-only row of
|
|
1593
|
+
// equal similarity without needing a coefficient to say so. Both terms
|
|
1594
|
+
// are non-negative and monotonic, which is all the ordering needs.
|
|
1595
|
+
const needle = spec.unaccent
|
|
1596
|
+
? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
|
|
1597
|
+
: sql`${searchString}`;
|
|
1598
|
+
return sql`(${rank} + public.word_similarity(${needle}, ${fuzzyColumn}))`;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1310
1601
|
/**
|
|
1311
1602
|
* Build a unique field check condition
|
|
1312
1603
|
*/
|
|
@@ -1538,6 +1829,16 @@ whereConditions };
|
|
|
1538
1829
|
* - `orderBy`: SQL expression to ORDER BY distance (ascending = closest first)
|
|
1539
1830
|
* - `filter`: optional WHERE clause for distance threshold
|
|
1540
1831
|
* - `distanceSelect`: SQL expression for selecting the distance as `_distance`
|
|
1832
|
+
*
|
|
1833
|
+
* `property` is `?vector_search=` off the querystring, so it is an untrusted
|
|
1834
|
+
* *name*, and it used to be looked up straight in the drizzle table object.
|
|
1835
|
+
* Two ways that went wrong, both answering 500 to a malformed request:
|
|
1836
|
+
* `?vector_search=title` built `"title" <=> '[1,2]'::vector`, which the
|
|
1837
|
+
* database rejects with "operator does not exist"; and a table object also
|
|
1838
|
+
* carries non-column keys (`_`, methods), which passed the `if (!column)`
|
|
1839
|
+
* guard and compiled to nonsense. The name is resolved against the table's
|
|
1840
|
+
* actual columns and required to be a `vector` — anything else is the
|
|
1841
|
+
* caller's mistake and gets a 400 that says so.
|
|
1541
1842
|
*/
|
|
1542
1843
|
static buildVectorSearchConditions(
|
|
1543
1844
|
table: PgTable<any>,
|
|
@@ -1548,10 +1849,7 @@ whereConditions };
|
|
|
1548
1849
|
threshold?: number;
|
|
1549
1850
|
}
|
|
1550
1851
|
): { orderBy: SQL; filter?: SQL; distanceSelect: SQL } {
|
|
1551
|
-
const column = table
|
|
1552
|
-
if (!column) {
|
|
1553
|
-
throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
1554
|
-
}
|
|
1852
|
+
const column = DrizzleConditionBuilder.resolveVectorColumn(table, vectorSearch.property);
|
|
1555
1853
|
|
|
1556
1854
|
// The vector is interpolated as a raw SQL literal below (pgvector has no
|
|
1557
1855
|
// bind form for the `::vector` cast), so every element must be a finite
|
|
@@ -1592,8 +1890,52 @@ whereConditions };
|
|
|
1592
1890
|
distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})`
|
|
1593
1891
|
};
|
|
1594
1892
|
}
|
|
1893
|
+
|
|
1894
|
+
/**
|
|
1895
|
+
* The `vector` column a request named, or a 400 explaining what it named.
|
|
1896
|
+
*
|
|
1897
|
+
* `getTableColumns` rather than a key lookup: it returns only the columns,
|
|
1898
|
+
* so `_`, `getSQL` and every other property of a drizzle table stop looking
|
|
1899
|
+
* like candidates. The type check is on the *physical* column
|
|
1900
|
+
* (`vector(1536)`) rather than on the declared property, so it holds for an
|
|
1901
|
+
* introspected collection too, where the property carries no Rebase type.
|
|
1902
|
+
*/
|
|
1903
|
+
private static resolveVectorColumn(table: PgTable<any>, property: string): AnyPgColumn {
|
|
1904
|
+
const columns = getTableColumns(table) as Record<string, AnyPgColumn> | undefined;
|
|
1905
|
+
const column = columns?.[property];
|
|
1906
|
+
if (!column) {
|
|
1907
|
+
const known = Object.entries(columns ?? {})
|
|
1908
|
+
.filter(([, c]) => isVectorColumn(c))
|
|
1909
|
+
.map(([name]) => name);
|
|
1910
|
+
throw ApiError.badRequest(
|
|
1911
|
+
`Unknown vector property "${property}". ` +
|
|
1912
|
+
(known.length > 0
|
|
1913
|
+
? `This collection's vector properties are: ${known.join(", ")}.`
|
|
1914
|
+
: "This collection declares no `vector` property to search."),
|
|
1915
|
+
"UNKNOWN_VECTOR_PROPERTY"
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
if (!isVectorColumn(column)) {
|
|
1919
|
+
throw ApiError.badRequest(
|
|
1920
|
+
`Property "${property}" is not a vector column (it is \`${columnSqlType(column) || "unknown"}\`), ` +
|
|
1921
|
+
"so it has no distance operator. Name the property declared as `{ type: \"vector\" }`.",
|
|
1922
|
+
"UNKNOWN_VECTOR_PROPERTY"
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
return column;
|
|
1926
|
+
}
|
|
1595
1927
|
}
|
|
1596
1928
|
|
|
1929
|
+
/** The column's SQL type, for a value that may not be a drizzle column at all. */
|
|
1930
|
+
const columnSqlType = (column: unknown): string => {
|
|
1931
|
+
const getSQLType = (column as { getSQLType?: () => string })?.getSQLType;
|
|
1932
|
+
return typeof getSQLType === "function" ? getSQLType.call(column).toLowerCase() : "";
|
|
1933
|
+
};
|
|
1934
|
+
|
|
1935
|
+
/** True for `vector(1536)` and its pgvector siblings, whatever the width. */
|
|
1936
|
+
const isVectorColumn = (column: unknown): boolean =>
|
|
1937
|
+
/^(vector|halfvec|sparsevec)\b/.test(columnSqlType(column));
|
|
1938
|
+
|
|
1597
1939
|
/**
|
|
1598
1940
|
* Alias for DrizzleConditionBuilder for consistent naming with other database implementations.
|
|
1599
1941
|
* This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc.
|
|
@@ -314,9 +314,14 @@ export function sanitizeErrorForClient(error: unknown, context: string): { messa
|
|
|
314
314
|
column: pgError.column,
|
|
315
315
|
table: pgError.table,
|
|
316
316
|
constraint: pgError.constraint,
|
|
317
|
-
dataType: pgError.dataType
|
|
318
|
-
//
|
|
319
|
-
|
|
317
|
+
dataType: pgError.dataType
|
|
318
|
+
// The outer Drizzle wrapper message used to be logged here "for
|
|
319
|
+
// full context": it is `Failed query: <sql>\nparams: <values>`, so
|
|
320
|
+
// it published the statement and every bound value (an email, a
|
|
321
|
+
// password hash) on every realtime data failure. The SQLSTATE,
|
|
322
|
+
// detail, table, column and constraint above are the diagnostic
|
|
323
|
+
// value; the wrapper added only the leak. `logger` strips the
|
|
324
|
+
// wrapper as well, but the field itself carried nothing else.
|
|
320
325
|
});
|
|
321
326
|
return pgErrorToFriendlyMessage(pgError, context);
|
|
322
327
|
}
|