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}`);
}