import { z } from "zod"; /** * Filter operator vocabulary the LLM may emit. Every operator listed here is * recognised by `applyDrizzleFilters`; carte authors restrict which ones a * given field accepts via `AllowedFilter.operators`. */ export type FilterOperator = | "eq" | "neq" | "in" | "nin" | "gt" | "gte" | "lt" | "lte" | "contains" | "startsWith" | "isNull" | "isNotNull"; export type FilterValue = string | number | boolean | string[] | number[] | null; export interface Filter { field: string; op: FilterOperator; /** * Value to compare against. Required for most operators; ignored for * `isNull` and `isNotNull` (which compare structurally). Pass `null` or * omit when using a null operator — the schema accepts either. */ value?: FilterValue; } /** * Carte-author-declared shape: which built-in filters are allowed on a given * query, what type their values must be, and which operators they support. * Typically supplied via `defineParams({ filters: ... })`. Used by both * `parsePlan` (to reject LLM-emitted filters that don't fit) and * `generatePrompt` (to teach the LLM what's available). */ export interface AllowedFilter { type: "string" | "number" | "datetime" | "boolean"; operators: FilterOperator[]; /** Optional human-readable description shown in the prompt. */ description?: string; /** * Case-sensitive matching for `contains` / `startsWith` on string fields. * Default `false` (case-insensitive — uses `ilike`). Has no effect on * non-string types. Worth setting `true` for case-sensitive identifier * fields like `id` columns where "ABC" and "abc" should not match. */ caseSensitive?: boolean; } export type AllowedFilters = Record; // ─── Zod schemas ──────────────────────────────────────────────────────────── const filterOperatorSchema = z.enum([ "eq", "neq", "in", "nin", "gt", "gte", "lt", "lte", "contains", "startsWith", "isNull", "isNotNull", ]); const filterValueSchema = z.union([ z.string(), z.number(), z.boolean(), z.array(z.string()), z.array(z.number()), z.null(), ]); export const filterSchema: z.ZodType = z.object({ field: z.string().min(1), op: filterOperatorSchema, value: filterValueSchema.optional(), }); /** * Sugar for `z.array(filterSchema)`. `defineParams({ filters: ... })` adds this * automatically for built-in filter support. You can still use it directly in * a plain params schema when you want to own filter semantics yourself. */ export const filtersParamSchema = z.array(filterSchema); export const allowedFilterSchema: z.ZodType = z.object({ type: z.enum(["string", "number", "datetime", "boolean"]), operators: z.array(filterOperatorSchema).min(1), description: z.string().optional(), caseSensitive: z.boolean().optional(), }); // ─── Pure validation helpers (no drizzle dep) ─────────────────────────────── /** * Returns a human-readable error message if `filter` is not allowed under * `allowed`, or `undefined` if it passes. Used by `validatePlan`; also handy * for any custom executor. * * Checks (in order): * 1. The field is in `allowed`. * 2. The operator is in `allowed[field].operators`. * 3. The value type matches `allowed[field].type` (with array-arity rules * for `in` / `nin`). */ export function checkFilter(filter: Filter, allowed: AllowedFilters): string | undefined { const def = allowed[filter.field]; if (!def) { return `field "${filter.field}" is not an allowed filter`; } if (!def.operators.includes(filter.op)) { return `operator "${filter.op}" is not allowed for field "${filter.field}" (allowed: ${def.operators.join(", ")})`; } // Null operators take no value; everything else requires one. if (filter.op === "isNull" || filter.op === "isNotNull") return undefined; if (filter.value === null) { return `operator "${filter.op}" on field "${filter.field}" cannot compare against null; use isNull / isNotNull instead`; } if (filter.value === undefined) { return `operator "${filter.op}" on field "${filter.field}" requires a value`; } return checkFilterValueType(filter.field, filter.op, filter.value, def); } /** * Strict ISO 8601 datetime validator used for both scalar `datetime` filter * values and array elements of `in`/`nin` over a datetime field. Stricter * than `Date.parse` (which accepts non-ISO formats like `"Mon Jan 01 2025"`). */ const isoDateTimeSchema = z.iso.datetime(); function isIsoDateTime(value: unknown): value is string { return typeof value === "string" && isoDateTimeSchema.safeParse(value).success; } function checkFilterValueType( field: string, op: FilterOperator, v: Exclude, def: AllowedFilter, ): string | undefined { const isArrayOp = op === "in" || op === "nin"; if (isArrayOp) { if (!Array.isArray(v)) { return `operator "${op}" requires an array value, got ${typeof v}`; } if (def.type === "string") { if (!v.every((x) => typeof x === "string")) { return `operator "${op}" on field "${field}" (string) requires an array of strings`; } return undefined; } if (def.type === "datetime") { if (!v.every((x) => typeof x === "string")) { return `operator "${op}" on field "${field}" (datetime) requires an array of ISO 8601 datetime strings`; } const bad = v.find((x) => !isIsoDateTime(x)); if (bad !== undefined) { return `array value "${String(bad)}" for field "${field}" must be an ISO 8601 datetime string`; } return undefined; } if (def.type === "number") { if (!v.every((x) => typeof x === "number")) { return `operator "${op}" on field "${field}" (number) requires an array of numbers`; } return undefined; } return `operator "${op}" is not supported for field type "${def.type}"`; } // Scalar operators switch (def.type) { case "string": if (typeof v !== "string") { return `field "${field}" requires a string value, got ${typeof v}`; } return undefined; case "number": if (typeof v !== "number") { return `field "${field}" requires a number value, got ${typeof v}`; } return undefined; case "boolean": if (typeof v !== "boolean") { return `field "${field}" requires a boolean value, got ${typeof v}`; } return undefined; case "datetime": if (!isIsoDateTime(v)) { return `filter value for field "${field}" must be an ISO 8601 datetime string, got "${String(v)}"`; } return undefined; } }