import { SQL } from "drizzle-orm"; import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core"; import { CollectionConfig, FilterValues, WhereFilterOp, LogicalCondition, FilterCondition, ResolvedRelation, ResolvedBelongsTo, ResolvedForeignKeyOnTarget, ResolvedManyToMany, type RelationAggregateSort } from "@rebasepro/types"; import { type SearchColumnSpec } from "../schema/search-column"; import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry"; /** * What to do with a filter field that resolves to no column at all. * * - `"error"` (default) — reject the request. A filter that cannot be * compiled is *dropped*, and dropping a condition can only ever widen the * result set. On a data plane where row-level security is the last line of * defence, a typo'd or renamed filter key therefore runs the query without * that condition and returns everything RLS happens to allow. * - `"warn"` — the historical behaviour: log and silently drop the condition. * Only for a deployment that knowingly sends filter keys the table does not * have and has satisfied itself that widening is safe there. */ export type UnknownFilterFieldsMode = "error" | "warn"; /** * A user's search term, made safe to drop inside a `%…%` LIKE pattern. * * The term is already a bind parameter, so this is not about injection. It is * about the two things a LIKE metacharacter does when it arrives from a search * box: * * 1. **It changes the query.** `%` and `_` are wildcards, so searching for * `50%` returned every row and `a_c` matched `abc`. Nothing the caller * could type would find a literal `%`. * 2. **It is a cost the caller chooses.** Postgres matches LIKE by * backtracking: each `%` re-tries every remaining offset, so * `?searchString=a%a%a%a%a%a%a%b` is polynomial with an attacker-chosen * exponent — evaluated per row, OR-ed across every string property of the * collection, on a sequential scan (a leading `%` cannot use an index), and * the page limit does not bound it because the scan happens first. * * This is the server-side half of the pattern `like-pattern-redos.test.ts` * hardened the offline evaluator against; that test's own note ("the same * translation in the Mongo driver hands the expression to the database, where * it occupies a server thread instead") describes this call site. * * Backslash is the default `ESCAPE` character for LIKE, and the pattern is * bound rather than interpolated, so a single backslash here reaches the * matcher as one. Escaping the escape character first is what keeps a term * ending in `\` from swallowing the closing `%`. * * Note this is a *substring search*, not the `like` filter operator: a caller * who wants wildcards has `?title=like.foo%` for that, where the pattern is the * documented input. */ export declare const escapeLikePattern: (value: string) => string; /** Set the process-wide behaviour for unresolvable filter fields. */ export declare function configureUnknownFilterFields(mode: UnknownFilterFieldsMode): void; /** The process-wide behaviour for unresolvable filter fields. */ export declare function getUnknownFilterFieldsMode(): UnknownFilterFieldsMode; /** Per-call context for compiling a filter into SQL. */ export interface FilterCompilationOptions { /** * Overrides the process-wide {@link UnknownFilterFieldsMode} for this call. */ unknownFields?: UnknownFilterFieldsMode; /** * The collection the filter is written against. Its resolved relations are * what turn an owning-relation filter key into the foreign-key column it * actually lives in; without it only the default key shapes can be guessed. */ collection?: CollectionConfig; /** * The driver's registry, for relations whose link is not on this row at * all. A `manyToMany` compiles to an `EXISTS` over its junction and a * `hasMany`/`hasOne` to one over the target table — neither of which this * builder can reach from the collection alone. */ registry?: PostgresCollectionRegistry; /** * The key column of the table being filtered — what those `EXISTS` * subqueries correlate back to. * * It has to be the Drizzle column object rather than a name: a column * renders qualified with its own table, which is what binds it to the * *outer* row instead of to the junction or target aliased inside the * subquery. See {@link DrizzleConditionBuilder.buildRelationFilterCondition}. */ sourceIdColumn?: AnyPgColumn; } /** * What a filter field turns out to name. * * A field naming a column compiles to a comparison on it. A field naming a * relation that owns no column here compiles to a whole `EXISTS` condition * instead, so there is no column to hand back — which is why resolution * answers with a discriminated result rather than a column. The caller cannot * tell the two apart from the field name, and the difference is not cosmetic: * one is `column value`, the other is a correlated subquery. */ type FilterTarget = { kind: "column"; column: AnyPgColumn; } | { /** A path *inside* a json/jsonb column — `metadata->>country`. */ kind: "json"; column: AnyPgColumn; /** The keys to walk, outermost first. Always at least one. */ path: string[]; } | { kind: "relation"; relation: ResolvedForeignKeyOnTarget | ResolvedManyToMany; /** Bound here so the compile step cannot be reached without them. */ registry: PostgresCollectionRegistry; sourceIdColumn: AnyPgColumn; } | { /** * A *column of the related row* — `applications.status`. Same `EXISTS` * as `relation`, with the predicate moved off the target's id and onto * one of its columns. */ kind: "relation-field"; /** * `via` is not among them: it is refused at resolution, and leaving it * in the type would let a later edit reach the compile step with a * relation there is no correlation for. */ relation: ResolvedBelongsTo | ResolvedForeignKeyOnTarget | ResolvedManyToMany; registry: PostgresCollectionRegistry; /** The column on *this* table the subquery correlates back to. */ sourceIdColumn: AnyPgColumn; /** The table the predicate is asked of, already resolved. */ targetTable: PgTable; /** The column on {@link targetTable} the predicate compares. */ targetColumn: AnyPgColumn; }; /** Drizzle dynamic query builder — accepts innerJoin + where chaining */ export interface DrizzleDynamicQuery { innerJoin(table: PgTable, condition: SQL): this; where(condition: SQL | undefined): this; limit(limit: number): this; } /** * Unified condition builder for Drizzle/PostgreSQL queries. * * This class uses static methods and satisfies the ConditionBuilderStatic type. * It translates Rebase filter conditions to Drizzle SQL conditions. * * @example * const builder: ConditionBuilderStatic = DrizzleConditionBuilder; */ export declare class DrizzleConditionBuilder { /** * Express "reachable from this parent through this relation" as a plain * `WHERE` condition on the target table. * * This is the primitive that lets a relation be a *filter* rather than an * addressing scheme. A nested listing used to be served by its own query * builder — `fetchEntitiesUsingJoins`, which grew joins the root pipeline * did not have and lost the options the root pipeline did have (offset, * filter, orderBy, include). Reduced to a condition, the same listing runs * through the ordinary collection query, so it inherits all of them and * there is one read path instead of two. * * The shapes: * - inverse FK → `target. = :parentId`, a column comparison. * - `through` → `EXISTS (SELECT 1 FROM junction …)`, correlated on the * target's key, so the junction never multiplies rows the * way an `INNER JOIN` would. * - `joinPath` → the same `EXISTS`, with the path's steps joined inside * it and the final step correlating to the outer row. */ static buildRelationScopeCondition(relation: ResolvedRelation, /** * Lazy: `via`, `belongsTo`, and a foreign key that points at a * `sourceKey` need the parent's own table. A junction and a plain * foreign key are expressible from the parent's *id* alone, and * requiring the table for them would make a child listing fail on a * parent whose table isn't registered. */ parent: () => { table: PgTable; idColumn: AnyPgColumn; }, parentId: string | number, targetTable: PgTable, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry): SQL; /** * `EXISTS` for an explicit `joinPath`. * * The path is declared source → target. The subquery replays every step but * the last from inside, and turns the last one into the correlation with the * outer target row — so the target table is never named twice and needs no * alias. Each intermediate table is aliased positionally, which keeps a path * that revisits a table (a self-referencing many-to-many) unambiguous. */ private static buildJoinPathScopeCondition; /** * What a filter field names, or `undefined` if it names nothing. * * Three ways a field resolves. It may address its column directly; it may * be an owning relation, whose foreign key is a column here; or it may be * a relation whose link lives on another table entirely, which compiles to * a subquery instead of a column. Only a field that resolves to *none* of * them is an error, and by default it is one: see * {@link UnknownFilterFieldsMode} for why silently dropping it is a * data-exposure primitive rather than a convenience. * * For an owning relation the relation's own `localKey` is the authority, * not `_id`. The default local key is `generateForeignKeyName`, * which snake-cases *and singularises* — `userProfile` → `user_profile_id`, * `users` → `user_id` — and it can be overridden outright. Guessing * `_id` therefore misses perfectly ordinary owning relations, and * with this resolution failing closed that miss is a 400 on a filter that * has nothing wrong with it. The guesses stay, last, for callers that hand * over no collection to resolve against. * * The subquery kinds need a registry and the source table's key column on * top of the collection. A caller that supplies neither gets the behaviour * it had before they were compilable — unresolvable, and so fail-closed — * rather than a half-built condition. */ private static resolveFilterTarget; /** * `applications.status` — the relation, and the column of the target it * addresses. * * `undefined` when the first segment names no relation: the field simply is * not a relation path, and resolution carries on to the guesses and then to * the unknown-field answer, which is where a typo belongs. A segment that * *does* name a relation is a different matter — the author plainly meant * this shape — so everything after that point throws rather than returning, * naming what went wrong. Falling through would report "unknown filter * field 'applications.status'" and list the columns of the wrong table. * * `via` is refused for the reason it is absent from * `filterableRelationKinds`: its join path is authored source → target with * no stated inverse, so there is nothing to correlate a subquery back to. */ private static resolveRelationFieldTarget; /** * Build filter conditions from FilterValues */ static buildFilterConditions>(filter: FilterValues>, table: PgTable, collectionPath: string, options?: FilterCompilationOptions): SQL[]; /** * Build logical conditions recursively from LogicalCondition or FilterCondition */ static buildLogicalConditions(cond: LogicalCondition | FilterCondition, table: PgTable, collectionPath: string, options?: FilterCompilationOptions): SQL | null; /** Dispatch a resolved filter field onto the shape it actually compiles to. */ private static compileFilterTarget; /** * A comparison against a value extracted from a json/jsonb column. * * The path is walked with `->` and the leaf taken with `->>`, so what comes * out is always **text**. That is the whole of the type story, and it is * the part worth being explicit about, because the alternatives are all * worse: * * - text comparison alone makes `["<", 100]` compare lexically, where * `"9"` is greater than `"100"`; * - casting unconditionally makes every filter on a non-numeric value a * runtime `invalid input syntax for type numeric` — a 500 on a row whose * JSON simply holds a string. * * So the *filter value* decides. A number on an ordering comparison casts * both sides to numeric; everything else compares as text, with booleans * rendered the way `->>` renders them (`"true"` / `"false"`). A row whose * JSON holds a non-numeric value at a path being compared numerically is * excluded rather than fatal, which is what `IS NOT NULL`-style filtering * means everywhere else in this file. * * The path segments are bound as parameters, never interpolated: they come * from a query string, and `->>` takes a text parameter perfectly well. */ private static buildJsonPathCondition; /** * A filter on a relation that owns no column on this row — `EXISTS` over * the rows it reaches. * * `posts` filtered by `tags == ` is not a comparison on `posts`; it * is a question about the junction: * * EXISTS (SELECT 1 FROM posts_tags AS j * WHERE j.post_id = posts.id AND j.tag_id = ) * * which is {@link buildRelationScopeCondition}'s many-to-many shape with * source and target swapped — there the junction's *target* column * correlates and the source is pinned; here the *source* column correlates * and the target is what the filter constrains. * * `hasMany`/`hasOne` are the same shape one table over: the target row * carries the foreign key, so the correlation is on that key and the * compared column is the target's own id. * * `EXISTS` and not a join, for the reason the scope condition gives: a join * through a junction multiplies the outer rows by the number of matching * links, which duplicates results and silently breaks `limit`/`offset`. * * Everything inside the subquery is referenced by identifier against a * local alias, and only `sourceIdColumn` stays a Drizzle column object — * again see {@link buildRelationScopeCondition}, which explains why a * column object renders against whatever table the surrounding builder * thinks is current and so cannot be used for the inner references. The * alias is also what keeps a self-referential relation unambiguous * (`categories.children`, or a many-to-many whose junction and target are * the same table), where the subquery's table and the outer one coincide. */ static buildRelationFilterCondition(relation: ResolvedForeignKeyOnTarget | ResolvedManyToMany, op: WhereFilterOp, value: unknown, sourceIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry, field: string, collectionPath: string): SQL; /** * The inner predicate of a relation filter, and whether the `EXISTS` * wrapping it is negated. * * Negation is `NOT EXISTS` of the *positive* predicate, never `EXISTS` of a * negated one. On a many-valued relation the two are different questions: * `EXISTS (… AND tag_id != X)` asks "does some tag differ from X", which is * true of nearly every post with more than one tag and answers nothing * anybody asked. `NOT EXISTS (… AND tag_id = X)` asks "is X absent", which * is what unticking a value in a filter control means — and it makes `==` * and `!=` partition the rows, the way a filter implies they do. * * `is-null`/`is-not-null` drop the predicate entirely: with nothing but the * correlation left, they become "has no related row at all" and "has at * least one", which is the only reading of null a link can have. * * Under RLS, "no related row" means *no row this reader can see*. A junction * with row-level security but no `SELECT` policy for `rebase_user` is opaque * to it, so every row comes back looking unlinked and `is-null` matches all * of them. That is not a leak — the outer table's own policies still decide * which rows exist at all, and the positive direction correctly returns * nothing — but it over-reports, and the cause is a missing junction policy * rather than anything here. Rebase derives one for a declared many-to-many; * a hand-written schema has to supply it. * * `in`/`not-in` against a *null value* mean the same thing, rather than * membership of an empty list. Membership against null is not a membership * question, and the admin's "filter for null values" control emits the * operator that happens to be selected — on a to-many relation that is * always `in` or `not-in`, because those are the only ones the multi-select * can produce. Reading `["in", null]` as an empty list would answer "posts * with no tags" with no posts at all. * * An empty `in` list compiles to `FALSE` rather than being dropped. Dropped * is what the column path does, and dropping a condition widens the result * — the whole reason this resolution fails closed. `in []` matches nothing * and `not-in []` matches everything, and `NOT EXISTS (… AND FALSE)` gives * the second for free. * * Anything else is rejected. Returning `null` for an operator this cannot * express would drop the condition, and the operators the admin offers for * a relation are exactly the six below. */ private static buildRelationFilterPredicate; /** * A filter on a *column of the related row* — `applications.status`. * * The same `EXISTS` {@link buildRelationFilterCondition} builds, with the * predicate moved off the target's id and onto one of its columns: * * EXISTS (SELECT 1 FROM talent_applications AS t * WHERE t.talent_id = talents.id * AND t.status IN ('applied', 'reviewing', 'interview')) * * which is the shape every "who is waiting" queue is written in. Without * it the only way to ask is to fetch every row and filter in the browser, * and a filter the client applies after paging is not a filter — the page * was already chosen without it. * * A many-to-many needs one more table than the id filter does. That one * stops at the junction, because the junction already holds the value it * compares; a column of the target is a table further out, so the subquery * joins the target to the junction and correlates from the junction. The * join is inside `EXISTS`, so it cannot multiply the outer rows the way a * top-level join through a junction would. * * `belongsTo` is included even though its foreign key is a column here: * `author.name` is a column of another table either way, and refusing the * one relation kind that reads most naturally would be a rule about * implementation rather than about meaning. * * Under RLS the subquery runs as the reader, so it sees the target rows * that reader's policies allow and no others. On the positive direction * that is exactly right. On the negative — `!=`, `not-in`, and any * `NOT EXISTS` — "no related row satisfies this" and "no related row this * reader can see satisfies this" are the same sentence, so a target table * with row-level security and no `SELECT` policy for `rebase_user` makes * every row look unmatched and the negative filter over-reports. Nothing is * leaked: the outer table's own policies still decide which rows exist. The * cause is a missing policy on the target rather than anything here, and it * is the same caveat the id-filter path carries. */ static buildRelationFieldCondition(target: Extract, op: WhereFilterOp, value: unknown, field: string, collectionPath: string): SQL; /** * The inner predicate of a relation *column* filter, and whether the * `EXISTS` wrapping it is negated. * * The negation rule is the one {@link buildRelationFilterPredicate} states * and holds for exactly the same reason, one column over. A negative * operator is `NOT EXISTS` of the **positive** predicate, never `EXISTS` of * a negated one: `EXISTS (… AND status != 'hired')` asks "does some * application differ from hired", which is true of nearly every candidate * with more than one application and answers nothing anybody asked. * `NOT EXISTS (… AND status = 'hired')` asks "is there no hired * application", which is what unticking a value means — and it makes `==` * and `!=` partition the rows, the way a filter implies they do. * * `is-null` and `is-not-null` are the exception, and deliberately not a * complementary pair here. On a column they compile to `EXISTS (… AND col * IS NULL)` and `EXISTS (… AND col IS NOT NULL)` — "has a related row whose * column is unset" and "has one where it is set" — which is the plain * reading of `applications.status is-not-null` and the useful one. They are * both true of a candidate with two applications, one of each. Making * `is-not-null` the negation instead would make it "no application has an * unset status", which is true of a candidate with no applications at all * and so answers a queue with the very rows the queue exists to exclude. * * Unlike the id path, every operator is available: the compared value is an * ordinary column, so `>=` on a date and `ilike` on a name mean here what * they mean anywhere else. Only an operator that does not exist is refused, * and it throws rather than returning `null` — a dropped condition widens * the read, which is the whole reason this file fails closed. */ private static buildRelationColumnPredicate; /** * An aggregate over the rows a relation reaches, as a scalar expression — * what `orderBy: [{ relation: "applications", field: "created_at", agg: * "min" }, "asc"]` compiles to. * * (SELECT min(t.created_at) FROM talent_applications AS t * WHERE t.talent_id = talents.id) * * A correlated scalar subquery rather than a `LEFT JOIN LATERAL`: the join * would have to be threaded into a query the relational query builder * assembles, while a scalar expression drops straight into `ORDER BY` and * into the keyset comparison behind cursor paging — which has to be the * *same* expression, or paging and ordering disagree and rows are skipped. * * `correlateTo` is what the subquery is pinned against. Left out, it is the * outer row's key column and the expression is correlated in the ordinary * way. Given a literal — the cursor row's id — the subquery stops being * correlated at all, so Postgres evaluates it once for the whole statement * rather than per row. That is how a cursor pages over an aggregate it has * no stored value for: the value is recomputed from the id it does have. * * Over zero related rows `count` is 0 and every other function is NULL, * which is what puts "nobody waiting" at a defined end of the order rather * than wherever a missing value would land. See `buildOrderExpressions` for * where that end is pinned. * * Under RLS the subquery runs as the reader, so a related row the reader * cannot see does not contribute — an aggregate is over the rows that * reader can see, which is the only total it could honestly report. */ static buildRelationAggregateExpression(spec: RelationAggregateSort, table: PgTable, collection: CollectionConfig, registry: PostgresCollectionRegistry, sourceIdColumn: AnyPgColumn, collectionPath: string, correlateTo?: unknown): SQL; /** The column a table's rows are keyed by: its primary key, else `id`. */ private static primaryKeyColumn; /** * Build a single filter condition for a specific operator and value */ static buildSingleFilterCondition(column: AnyPgColumn, op: WhereFilterOp, value: unknown): SQL | null; /** * Build relation-based conditions for different relation types */ /** * Joins and where-conditions that reach a relation's target rows. * * One case per kind. This used to be a chain of six `else if`s over * `cardinality`/`direction`/`through`, ending in * `findCorrespondingJunctionTable` — a search through the *target's* own * relations to work out whether an "inverse many" was a one-to-many or the * far side of a junction. That search is gone: the kind says which it is. * * The owning/inverse split for junctions is gone too. Both variants built * the identical condition — `through` is always written from the declaring * side's point of view — so the second was a distinction without a * difference and one of the places the two could drift apart. */ static buildRelationConditions(relation: ResolvedRelation, parentId: string | number | (string | number)[], targetTable: PgTable, parentTable: PgTable, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry): { joinConditions: { table: PgTable; condition: SQL; }[]; whereConditions: SQL[]; }; /** * Build conditions for join path relations */ private static buildJoinPathConditions; /** * Build a single join condition between tables */ private static buildSingleJoinCondition; /** * Try to build a junction table join when direct foreign key relationship is not found */ private static tryBuildJunctionJoin; /** * Build conditions for junction table (many-to-many) relations */ private static buildJunctionTableConditions; /** * The condition for a relation whose link is a single column. * * Two cases. It had five: two of them existed only to throw ("should not be * called directly", "lacks proper configuration"), and one guessed a column * name by appending `_id` to `inverseRelationName` when no foreign key had * been resolved. All three were reachable only because the old type let a * relation arrive here under-specified. It cannot now. */ private static buildSimpleRelationCondition; /** * Combine multiple conditions with AND operator */ static combineConditionsWithAnd(conditions: SQL[]): SQL | undefined; /** * Combine multiple conditions with OR operator */ static combineConditionsWithOr(conditions: SQL[]): SQL | undefined; /** * Build search conditions for text fields. * * Two shapes, chosen by whether the collection declared a `search` block: * * - **Declared** — one `@@ websearch_to_tsquery` against the generated * `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches * inside JSONB and arrays, and uses the GIN index. * - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level * string properties, with the term escaped (see {@link escapeLikePattern}) * so it is matched as the literal text the user typed. * * The second is the default and stays the default. A collection that has * not opted in compiles to exactly the SQL it compiled to before this * branch existed, which is the only reason it is safe to have added it. * * `collection` is optional so that the callers which genuinely have no * collection in hand — nested paths, derived views — keep working; without * one there is no `search` block to read and the ILIKE path is correct. */ static buildSearchConditions(searchString: string, properties: Record, table: PgTable, collection?: CollectionConfig): SQL[]; /** * The `@@` predicate for a collection that declared a `search` block, or * undefined for one that did not. * * The query is normalized exactly as the indexed content was — same text * search configuration, same accent folding. Skipping that on the query * side is the subtle way to get a search that matches nothing: the column * would hold `gestion` while the query asked for `gestión`. * * `websearch_to_tsquery` rather than `plainto_tsquery` because it is the * one that behaves the way a search box looks like it should — quoted * phrases, `or`, and a leading `-` to exclude — and because it never throws * on user input, which `to_tsquery` does on so much as a stray parenthesis. */ static buildFullTextCondition(searchString: string, table: PgTable, collection: CollectionConfig): SQL | undefined; /** * `websearch_to_tsquery(, )`. * * Split out because the ranking expression needs the identical query — a * row ranked against a different tsquery than it was matched against is a * ranking of something else. */ static normalizedTsQuery(searchString: string, spec: SearchColumnSpec): SQL; /** * A JSONB array of `{ field, snippet }` naming which declared fields matched * and showing the text around each hit — what backs `_matches`. * * A ranked list answers "which rows", never "why this row". For a talent * pool that difference is the product: a candidate surfacing for * "iso 14001" on a *certification* is a different candidate from one whose * bio happens to mention the standard, and the score cannot tell them apart. * * Built as a correlated subquery over a `VALUES` list of the declared * fields, rather than one `CASE` per field, so the shape does not change * with the number of fields and the empty result is a plain `[]`. * * `ts_headline` runs over the same normalized text that was indexed. Over * the *original* text it would find nothing to mark whenever `unaccent` is * on — the query's lexemes are folded and the document's are not — and * would return the text silently unhighlighted. Folded-but-marked beats * pretty-but-inert. * * Undefined when the collection has not opted in, when the column is not on * the table yet, or when the caller did not ask: this costs a `ts_headline` * per field per row and `ts_headline` re-parses the document. */ static buildSearchMatchesExpression(searchString: string, table: PgTable, collection: CollectionConfig): SQL | undefined; /** * `ts_rank(, )` for the collection, or undefined when it has * not opted in. This is what backs `orderBy: ["_score", "desc"]`. */ static buildSearchRankExpression(searchString: string, table: PgTable, collection: CollectionConfig): SQL | undefined; /** * Build a unique field check condition */ static buildUniqueFieldCondition(fieldColumn: AnyPgColumn, value: unknown, idColumn?: AnyPgColumn, excludeId?: string | number): SQL[]; /** * Build relation-based query with joins and conditions */ static buildRelationQuery(baseQuery: T, relation: ResolvedRelation, parentId: string | number | (string | number)[], targetTable: PgTable, parentTable: PgTable, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry, additionalFilters?: SQL[]): T; /** * A count over a relation's target rows. * * The junction case counts `distinct` because the caller's query joins the * junction; the owning/inverse pair that used to sit here built the same * query twice. */ static buildRelationCountQuery(baseCountQuery: T, relation: ResolvedRelation, parentId: string | number, targetTable: PgTable, parentTable: PgTable, parentIdColumn: AnyPgColumn, targetIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry, additionalFilters?: SQL[]): T; /** * Build join path conditions for count queries */ private static buildJoinPathCountQuery; /** * Build junction table conditions for count queries */ private static buildJunctionCountQuery; /** * Helper method to extract table names from columns */ static getTableNamesFromColumns(columns: string | string[]): string[]; /** * Helper method to extract column names from columns */ static getColumnNamesFromColumns(columns: string | string[]): string[]; /** * Build vector similarity search expressions for pgvector. * * Returns: * - `orderBy`: SQL expression to ORDER BY distance (ascending = closest first) * - `filter`: optional WHERE clause for distance threshold * - `distanceSelect`: SQL expression for selecting the distance as `_distance` * * `property` is `?vector_search=` off the querystring, so it is an untrusted * *name*, and it used to be looked up straight in the drizzle table object. * Two ways that went wrong, both answering 500 to a malformed request: * `?vector_search=title` built `"title" <=> '[1,2]'::vector`, which the * database rejects with "operator does not exist"; and a table object also * carries non-column keys (`_`, methods), which passed the `if (!column)` * guard and compiled to nonsense. The name is resolved against the table's * actual columns and required to be a `vector` — anything else is the * caller's mistake and gets a 400 that says so. */ static buildVectorSearchConditions(table: PgTable, vectorSearch: { property: string; vector: number[]; distance?: "cosine" | "l2" | "inner_product"; threshold?: number; }): { orderBy: SQL; filter?: SQL; distanceSelect: SQL; }; /** * The `vector` column a request named, or a 400 explaining what it named. * * `getTableColumns` rather than a key lookup: it returns only the columns, * so `_`, `getSQL` and every other property of a drizzle table stop looking * like candidates. The type check is on the *physical* column * (`vector(1536)`) rather than on the declared property, so it holds for an * introspected collection too, where the property carries no Rebase type. */ private static resolveVectorColumn; } /** * Alias for DrizzleConditionBuilder for consistent naming with other database implementations. * This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc. */ export declare const PostgresConditionBuilder: typeof DrizzleConditionBuilder; export {};