import { type Column, isNotNull as drizzleIsNotNull, isNull as drizzleIsNull, eq, gt, gte, ilike, inArray, like, lt, lte, ne, notInArray, type SQL, } from "drizzle-orm"; import type { AllowedFilter, AllowedFilters, Filter } from "./filters.js"; import { checkFilter } from "./filters.js"; /** * Per-field column map. Mirrors the shape of `AllowedFilters`: every field * declared in `allowed` must have a matching `Column` in `columns`. Authors * write the map once and the helper handles operator dispatch internally. * * Type guarantees: * - **Key presence** is compile-time enforced — omit a declared field and * the call site fails to typecheck. * - **Data-type alignment** between an `AllowedFilter.type` and the * column's underlying type is NOT compile-time enforced (the `Column` * generic is intentionally loose). Mismatches are caught at validation * time by `checkFilter` and at runtime by `applyDrizzleFilters` as * defence in depth. * * The TODO at `matchOp` tracks tightening this end of the contract. */ export type FilterColumns = { [K in keyof A]: Column; }; /** * Drizzle helper. Use this from a carte entry's `query` function to turn * an array of validated `Filter`s into Drizzle `SQL` conditions ready to spread * into `.where(and(...))`. * * The `columns` map mirrors `allowed` one-for-one: each declared filter field * gets a Drizzle column reference. The LLM never sees column names — only the * filter `field` strings you authored. * * const conditions = applyDrizzleFilters( * { jobType: jobs.type, retryCount: jobs.retryCount, failedAt: jobs.failedAt }, * params.filters ?? [], * allowedFilters, * ); * await db.select().from(jobs).where(and(eq(jobs.status, 'failed'), ...conditions)); * * Throws on any field/operator/value mismatch — defence in depth even though * `validatePlan` already gated the plan. If `validatePlan` ran and accepted, * this helper will not throw at runtime. * * Skip this helper entirely if you're using a different ORM; the validation * still works (your `query` function just builds whatever conditions it * needs from the raw `params.filters`). */ export function applyDrizzleFilters( columns: FilterColumns, filters: ReadonlyArray, allowed: A, ): SQL[] { return filters.map((filter) => { const err = checkFilter(filter, allowed); if (err) { throw new Error(`applyDrizzleFilters: ${err}`); } const col = columns[filter.field as keyof A]; if (!col) { throw new Error( `applyDrizzleFilters: column for field "${filter.field}" is missing from the columns map`, ); } const def = allowed[filter.field]; if (!def) { // Unreachable: checkFilter above already verified the field is in `allowed`. // Re-asserting here keeps TS happy without a non-null assertion. throw new Error( `applyDrizzleFilters: invariant violated, no AllowedFilter for "${filter.field}"`, ); } return buildCondition(col, filter, def); }); } function buildCondition(col: Column, filter: Filter, def: AllowedFilter): SQL { const { op, value } = filter; switch (op) { case "isNull": return drizzleIsNull(col); case "isNotNull": return drizzleIsNotNull(col); case "eq": return eq(col, value as never); case "neq": return ne(col, value as never); case "gt": return gt(col, value as never); case "gte": return gte(col, value as never); case "lt": return lt(col, value as never); case "lte": return lte(col, value as never); case "in": if (!Array.isArray(value)) { throw new Error(`'in' requires an array value, got ${typeof value}`); } return inArray(col, value as never); case "nin": if (!Array.isArray(value)) { throw new Error(`'nin' requires an array value, got ${typeof value}`); } return notInArray(col, value as never); case "contains": if (typeof value !== "string") { throw new Error(`'contains' requires a string value, got ${typeof value}`); } return matchOp(def, col, `%${escapeLike(value)}%`); case "startsWith": if (typeof value !== "string") { throw new Error(`'startsWith' requires a string value, got ${typeof value}`); } return matchOp(def, col, `${escapeLike(value)}%`); } } /** * Picks `like` (case-sensitive) or `ilike` (case-insensitive) based on the * `caseSensitive` declaration on the AllowedFilter. Default is insensitive. * * TODO: per-type, per-column type-safe operator helpers — currently we pass * `value as never` to the Drizzle operators because the `Column` generic is * loose. A future revision should split the resolver per type and tighten * each operator helper to its column's data type. Tracked alongside the * nested-$bind-path issue. */ function matchOp(def: AllowedFilter, col: Column, pattern: string): SQL { return def.caseSensitive ? like(col, pattern) : ilike(col, pattern); } /** Escapes the LIKE wildcards in a literal value so user input can't widen the match. */ function escapeLike(value: string): string { return value.replace(/[\\%_]/g, (ch) => `\\${ch}`); }