Rindle docs and package mapSkip to main content

rindle_sqlite/
query_builder.rs

1//! `build_select_query` — the `FetchRequest` → parameterized `SELECT` lowering
2//! (spec `05` §4.4). A faithful port of `packages/zqlite/src/query-builder.ts`.
3//!
4//! **All identifiers come from the schema** (resolved from a [`ColId`] to a
5//! [`ColumnDef::name`]); **all values are bound `?` params**, never interpolated
6//! — the JS uses `@databases/sql` tagged templates, we emit `?` placeholders and a
7//! parallel [`SqliteParam`] vec. The `?` slots and the param vec are built in
8//! **lockstep**, so the emission order IS the bind order (`05` §4.4): constraint →
9//! multiConstraints → start → filters (ORDER BY has no params).
10//!
11//! Every cell value crosses the `to_sqlite_param` boundary (`toSQLiteType`,
12//! query-builder.ts:278): `boolean`→0/1 (a null boolean stays NULL), `json`→
13//! serialized TEXT (a null json becomes the 4-char TEXT `"null"` — the three SQL
14//! nulls are distinct, `05` §3.13 / §13 Q11). Strings/numbers/null pass through.
15//!
16//! Scope (per `05` §1.2): this lowers the *already-subquery-stripped*
17//! `SqlCondition`. Statics must have been substituted upstream — a `static`
18//! operand is a builder bug, not a runtime path (`debug_assert!`-style
19//! `unreachable`, foundations §10). Here statics simply don't exist in the
20//! [`Operand`] enum, which makes that invariant structural.
21
22use rusqlite::types::{ToSqlOutput, ValueRef};
23use rusqlite::ToSql;
24
25use rindle::change::{Basis, Constraint, MultiConstraint, Start};
26use rindle::source_common::{Operand, SqlCondition, SqlOp};
27use rindle::value::{ColId, OwnedValue, Sort, Value, ValueType};
28
29/// Static, build-time column metadata (spec `05` §4.1). The ONLY place a column
30/// *name* lives; the index into a `&[ColumnDef]` IS the [`ColId`]. `optional`
31/// drives the start-bound nullable-aware `=` vs `IS` / `<`,`>` vs `(… IS NULL OR
32/// …)` lowering (§3.7); `ty` drives the value boundary (`to_sqlite_param` and the
33/// leaf's `col()` conversion).
34#[derive(Clone, Debug)]
35pub struct ColumnDef {
36    pub name: Box<str>,
37    pub ty: ValueType,
38    pub optional: bool,
39}
40
41/// A value already converted to its SQLite storage form (`toSQLiteType`, §3.13):
42/// `bool`→`Int(0|1)`, `json`→`Text(serialized)`, else passthrough. Carries a
43/// distinct `Null` (the SQL NULL) separate from a `Text("null")` (a serialized
44/// json null) — the two SQL nulls the JS keeps distinct (`05` §13 Q11).
45#[derive(Clone, Debug)]
46pub enum SqliteParam {
47    Null,
48    Int(i64),
49    Real(f64),
50    /// Strings AND serialized json (both TEXT).
51    Text(Box<str>),
52}
53
54impl ToSql for SqliteParam {
55    #[inline]
56    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
57        Ok(match self {
58            SqliteParam::Null => ToSqlOutput::Borrowed(ValueRef::Null),
59            SqliteParam::Int(i) => ToSqlOutput::Borrowed(ValueRef::Integer(*i)),
60            SqliteParam::Real(f) => ToSqlOutput::Borrowed(ValueRef::Real(*f)),
61            SqliteParam::Text(s) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
62        })
63    }
64}
65
66/// The compiled statement: SQL text (the statement-cache key) + ordered bind
67/// params (the `?` slots, in textual order).
68#[derive(Debug)]
69pub struct CompiledQuery {
70    pub sql: String,
71    pub params: Vec<SqliteParam>,
72}
73
74// ---------------------------------------------------------------------------
75// Identifier quoting + value conversion
76// ---------------------------------------------------------------------------
77
78/// Quote a SQL identifier (`sql.ident`): wrap in double quotes, doubling any
79/// embedded quote. The schema's column/table names, never user values. Shared
80/// with `table_source` for the write-statement (INSERT/DELETE/UPDATE) SQL.
81pub(crate) fn ident(name: &str) -> String {
82    let mut s = String::with_capacity(name.len() + 2);
83    s.push('"');
84    for ch in name.chars() {
85        if ch == '"' {
86            s.push('"');
87        }
88        s.push(ch);
89    }
90    s.push('"');
91    s
92}
93
94/// `toSQLiteType` (query-builder.ts:278): convert a cell value to its
95/// SQLite bind form, ty-directed. `boolean` → `null` stays NULL else `1`/`0`;
96/// `number`/`string`/`null` pass through; `json` → serialized TEXT (a null json
97/// serializes to the TEXT `"null"`). Takes the borrowed [`Value`] (a flat row's
98/// cell form); owned callers bridge with `.as_ref()`.
99pub fn to_sqlite_param(value: Value<'_>, ty: ValueType) -> SqliteParam {
100    use Value as V;
101    match ty {
102        ValueType::Boolean => match value {
103            // The server holds full rows and never constructs `Absent`
104            // (PROJECTION-SUPPORT-DESIGN.md §3.1 / OQ-2); reaching the bind boundary with
105            // one is a bug. Assert in debug, bind NULL defensively in release.
106            V::Absent => {
107                debug_assert!(false, "Value::Absent must never reach SQLite bind");
108                SqliteParam::Null
109            }
110            V::Null => SqliteParam::Null, // a null boolean stays NULL (NOT coerced to 0)
111            V::Bool(b) => SqliteParam::Int(if b { 1 } else { 0 }),
112            // A boolean column only holds Bool/Null; mirror JS truthiness defensively.
113            V::Int(i) => SqliteParam::Int(if i != 0 { 1 } else { 0 }),
114            V::Float(f) => SqliteParam::Int(if f != 0.0 { 1 } else { 0 }),
115            V::Str(s) => SqliteParam::Int(if s.is_empty() { 0 } else { 1 }),
116            V::Json(_) => SqliteParam::Int(1),
117        },
118        // number/string/null all "pass through" in the JS (`return v`); the value
119        // already carries the storage class. `int64` binds the same way — an exact
120        // `Int` stays INTEGER; fidelity of what lands in the column is enforced at
121        // the capture and hydrate/scan boundaries (design 226 §4.2), not at bind.
122        ValueType::Number | ValueType::Int | ValueType::String | ValueType::Null => match value {
123            V::Absent => {
124                debug_assert!(false, "Value::Absent must never reach SQLite bind");
125                SqliteParam::Null
126            }
127            V::Null => SqliteParam::Null,
128            V::Bool(b) => SqliteParam::Int(if b { 1 } else { 0 }),
129            V::Int(i) => SqliteParam::Int(i),
130            V::Float(f) => SqliteParam::Real(f),
131            // Row text is UTF-8-validated at construction (value.rs), so the lossy
132            // decode is a straight copy.
133            V::Str(b) => SqliteParam::Text(String::from_utf8_lossy(b).into()),
134            V::Json(b) => SqliteParam::Text(String::from_utf8_lossy(b).into()),
135        },
136        // `json` → `JSON.stringify(v)`. Our json cell is ALREADY the raw text, so
137        // bind it verbatim (more faithful than JS's parse→re-stringify, which can
138        // re-normalize whitespace/key-order). A null json → TEXT `"null"`.
139        ValueType::Json => SqliteParam::Text(json_serialize(value)),
140    }
141}
142
143/// `JSON.stringify` for an owned value. In practice a json column only ever holds
144/// `Json(text)` (bound verbatim) or `Null` (→ `"null"`); the scalar arms exist for
145/// totality (e.g. a json *literal* in a filter, though `getJsType` only classifies
146/// objects/arrays as json).
147fn json_serialize(value: Value<'_>) -> Box<str> {
148    use Value as V;
149    match value {
150        V::Absent => {
151            debug_assert!(false, "Value::Absent must never reach SQLite json bind");
152            "null".into()
153        }
154        V::Null => "null".into(),
155        V::Bool(b) => if b { "true" } else { "false" }.into(),
156        V::Int(i) => i.to_string().into(),
157        V::Float(f) => f.to_string().into(),
158        V::Json(b) => String::from_utf8_lossy(b).into(),
159        V::Str(b) => {
160            let s = String::from_utf8_lossy(b);
161            let mut out = String::with_capacity(s.len() + 2);
162            out.push('"');
163            for ch in s.chars() {
164                match ch {
165                    '"' => out.push_str("\\\""),
166                    '\\' => out.push_str("\\\\"),
167                    '\n' => out.push_str("\\n"),
168                    '\r' => out.push_str("\\r"),
169                    '\t' => out.push_str("\\t"),
170                    c if (c as u32) < 0x20 => {
171                        out.push_str(&format!("\\u{:04x}", c as u32));
172                    }
173                    c => out.push(c),
174                }
175            }
176            out.push('"');
177            out.into()
178        }
179    }
180}
181
182/// Resolve a literal operand's logical type the way the JS `getJsType` does — from
183/// the *value's own* runtime shape, NOT a column type (query-builder.ts:265).
184fn js_type_of(value: &OwnedValue) -> ValueType {
185    match value {
186        OwnedValue::Absent => {
187            debug_assert!(false, "OwnedValue::Absent has no SQLite logical type");
188            ValueType::Null
189        }
190        OwnedValue::Null => ValueType::Null,
191        OwnedValue::Str(_) => ValueType::String,
192        OwnedValue::Int(_) | OwnedValue::Float(_) => ValueType::Number,
193        OwnedValue::Bool(_) => ValueType::Boolean,
194        OwnedValue::Json(_) => ValueType::Json,
195    }
196}
197
198// ---------------------------------------------------------------------------
199// The top-level builder
200// ---------------------------------------------------------------------------
201
202/// Lower a `FetchRequest`'s pushed-down parts (+ connection filter + sort) to a
203/// parameterized `SELECT`. The arg grouping is regrouped from the JS for
204/// readability; the **clause-emission order is fixed** (constraint →
205/// multiConstraints → start → filters → ORDER BY) so the `?` slots and `params`
206/// stay aligned (`05` §4.4).
207#[allow(clippy::too_many_arguments)]
208pub fn build_select_query(
209    table: &str,
210    columns: &[ColumnDef],
211    constraint: Option<&Constraint>,
212    multi_constraints: &[MultiConstraint],
213    filters: Option<&SqlCondition>,
214    order: Option<&Sort>,
215    reverse: bool,
216    start: Option<&Start>,
217) -> CompiledQuery {
218    // SELECT <declared cols> FROM <table> — column order == ColId order, which is
219    // what makes the leaf `col(i)` an O(1) array index (§8.1).
220    let mut sql = String::from("SELECT ");
221    for (i, c) in columns.iter().enumerate() {
222        if i > 0 {
223            sql.push_str(", ");
224        }
225        sql.push_str(&ident(&c.name));
226    }
227    sql.push_str(" FROM ");
228    sql.push_str(&ident(table));
229
230    // WHERE fragments, each `(text, params)`, joined by AND in emission order.
231    let mut terms: Vec<(String, Vec<SqliteParam>)> = Vec::new();
232    if let Some(c) = constraint {
233        terms.extend(constraints_to_sql(c, columns));
234    }
235    for mc in multi_constraints {
236        if !mc.is_empty() {
237            terms.push(multi_constraint_to_sql(mc, columns));
238        }
239    }
240    if let Some(s) = start {
241        let order = order.expect("start requires ordering (query-builder.ts:51)");
242        terms.push(gather_start_constraints(s, reverse, order, columns));
243    }
244    if let Some(f) = filters {
245        terms.push(filters_to_sql(f, columns));
246    }
247
248    let mut params: Vec<SqliteParam> = Vec::new();
249    if !terms.is_empty() {
250        sql.push_str(" WHERE ");
251        for (i, (text, ps)) in terms.into_iter().enumerate() {
252            if i > 0 {
253                sql.push_str(" AND ");
254            }
255            sql.push_str(&text);
256            params.extend(ps);
257        }
258    }
259
260    if let Some(order) = order {
261        if !order.is_empty() {
262            sql.push(' ');
263            sql.push_str(&order_by_to_sql(order, reverse, columns));
264        }
265    }
266
267    CompiledQuery { sql, params }
268}
269
270// ---------------------------------------------------------------------------
271// constraint / multiConstraint
272// ---------------------------------------------------------------------------
273
274/// `constraintsToSQL` (query-builder.ts:69): one bare `"col" = ?` per
275/// `(ColId, value)`, returned as separate AND terms. **Always `=`** (NOT
276/// nullable-aware — §3.3): constraints come from join keys, never null.
277fn constraints_to_sql(c: &Constraint, columns: &[ColumnDef]) -> Vec<(String, Vec<SqliteParam>)> {
278    c.iter()
279        .map(|(col, v)| {
280            let text = format!("{} = ?", ident(&columns[*col].name));
281            (text, vec![to_sqlite_param(v.as_ref(), columns[*col].ty)])
282        })
283        .collect()
284}
285
286/// `multiConstraintToSQL` (query-builder.ts:98): a batched `IN`. Single-column →
287/// `"col" IN (?, …)`; compound → `("a","b",…) IN (VALUES (?,…), …)`. The key
288/// shape is taken from the first entry (entries share it, an upstream invariant).
289fn multi_constraint_to_sql(
290    mc: &MultiConstraint,
291    columns: &[ColumnDef],
292) -> (String, Vec<SqliteParam>) {
293    debug_assert!(!mc.is_empty(), "multiConstraint must be non-empty");
294    // Key columns, in the first entry's order.
295    let keys: Vec<ColId> = mc[0].iter().map(|(col, _)| *col).collect();
296    debug_assert!(!keys.is_empty(), "multiConstraint entries need >=1 key");
297
298    // Look up an entry's value for a key column (entries are keyed by ColId).
299    let value_for = |entry: &Constraint, key: ColId| -> SqliteParam {
300        let v = entry
301            .iter()
302            .find(|(c, _)| *c == key)
303            .map(|(_, v)| v)
304            .expect("multiConstraint entries share the first entry's keys");
305        to_sqlite_param(v.as_ref(), columns[key].ty)
306    };
307
308    let mut params = Vec::with_capacity(mc.len() * keys.len());
309
310    if keys.len() == 1 {
311        let key = keys[0];
312        let mut text = format!("{} IN (", ident(&columns[key].name));
313        for (i, entry) in mc.iter().enumerate() {
314            if i > 0 {
315                text.push_str(", ");
316            }
317            text.push('?');
318            params.push(value_for(entry, key));
319        }
320        text.push(')');
321        return (text, params);
322    }
323
324    // Compound: (a, b, …) IN (VALUES (?, ?, …), …)
325    let col_list = keys
326        .iter()
327        .map(|k| ident(&columns[*k].name))
328        .collect::<Vec<_>>()
329        .join(", ");
330    let mut text = format!("({col_list}) IN (VALUES ");
331    for (i, entry) in mc.iter().enumerate() {
332        if i > 0 {
333            text.push_str(", ");
334        }
335        text.push('(');
336        for (j, key) in keys.iter().enumerate() {
337            if j > 0 {
338                text.push_str(", ");
339            }
340            text.push('?');
341            params.push(value_for(entry, *key));
342        }
343        text.push(')');
344    }
345    text.push(')');
346    (text, params)
347}
348
349// ---------------------------------------------------------------------------
350// start bound (the only nullable-aware part — §3.7)
351// ---------------------------------------------------------------------------
352
353/// `nullableAwareEquality` (query-builder.ts:291): `"col" IS ?` for an optional
354/// column (so `IS NULL` matches), else bare `"col" = ?` (avoids the NULL+OR
355/// full-scan gotcha on a column that can't be null). One bound param.
356fn nullable_aware_equality(col: &ColumnDef, value: SqliteParam) -> (String, Vec<SqliteParam>) {
357    let op = if col.optional { "IS" } else { "=" };
358    (format!("{} {op} ?", ident(&col.name)), vec![value])
359}
360
361/// `nullableAwareRangeComparison` (query-builder.ts:303). Non-optional → bare
362/// `"col" <op> ?`. Optional `>` → `(? IS NULL OR "col" > ?)` (value bound TWICE).
363/// Optional `<` → `("col" IS NULL OR "col" < ?)` (value bound once). The asymmetry
364/// (which side gets `IS NULL`) encodes Zero's `null < everything` ordering.
365fn nullable_aware_range_comparison(
366    col: &ColumnDef,
367    value: SqliteParam,
368    op_gt: bool, // true => '>', false => '<'
369) -> (String, Vec<SqliteParam>) {
370    let op = if op_gt { ">" } else { "<" };
371    let id = ident(&col.name);
372    let comparison = format!("{id} {op} ?");
373    if !col.optional {
374        return (comparison, vec![value]);
375    }
376    if op_gt {
377        // value appears in BOTH `? IS NULL` and `"col" > ?` → two params.
378        (
379            format!("(? IS NULL OR {comparison})"),
380            vec![value.clone(), value],
381        )
382    } else {
383        (format!("({id} IS NULL OR {comparison})"), vec![value])
384    }
385}
386
387/// `gatherStartConstraints` (query-builder.ts:341): the OR-of-ANDs lexicographic
388/// start bound. For order `o[0..n]`, OR over `i` of (AND over `j<i` of `o[j] = v_j`
389/// (nullable-aware equality), then `o[i] <op> v_i` (nullable-aware range)), where
390/// `<op>` = `>` for asc / `<` for desc, **flipped by `reverse`**. `basis = At`
391/// appends a final all-equality term so the start row itself is included.
392///
393/// **Deviation from JS (perf):** when the leading sort column is non-nullable we
394/// prepend a redundant, sargable `o[0] <op>= v0` term so SQLite can seek the index
395/// instead of filter-scanning the partition. It is entailed by the disjunction (so
396/// result-preserving); see the inline note at the emission site.
397fn gather_start_constraints(
398    start: &Start,
399    reverse: bool,
400    order: &Sort,
401    columns: &[ColumnDef],
402) -> (String, Vec<SqliteParam>) {
403    let mut groups: Vec<(String, Vec<SqliteParam>)> = Vec::new();
404
405    for i in 0..order.len() {
406        let mut frags: Vec<String> = Vec::new();
407        let mut params: Vec<SqliteParam> = Vec::new();
408        for (j, &(j_col, _j_asc)) in order.iter().enumerate().take(i + 1) {
409            let col = &columns[j_col];
410            let value = to_sqlite_param(start.row.col(j_col), col.ty);
411            if j == i {
412                let (_, i_asc) = order[i];
413                // asc ? (reverse ? '<' : '>') : (reverse ? '>' : '<')
414                let op_gt = if i_asc { !reverse } else { reverse };
415                let (text, ps) = nullable_aware_range_comparison(col, value, op_gt);
416                frags.push(text);
417                params.extend(ps);
418            } else {
419                let (text, ps) = nullable_aware_equality(col, value);
420                frags.push(text);
421                params.extend(ps);
422            }
423        }
424        groups.push((format!("({})", frags.join(" AND ")), params));
425    }
426
427    if matches!(start.basis, Basis::At) {
428        let mut frags: Vec<String> = Vec::new();
429        let mut params: Vec<SqliteParam> = Vec::new();
430        for &(col_id, _) in order {
431            let col = &columns[col_id];
432            let value = to_sqlite_param(start.row.col(col_id), col.ty);
433            let (text, ps) = nullable_aware_equality(col, value);
434            frags.push(text);
435            params.extend(ps);
436        }
437        groups.push((format!("({})", frags.join(" AND ")), params));
438    }
439
440    let mut disj = String::from("(");
441    let mut disj_params: Vec<SqliteParam> = Vec::new();
442    for (i, (frag, ps)) in groups.into_iter().enumerate() {
443        if i > 0 {
444            disj.push_str(" OR ");
445        }
446        disj.push_str(&frag);
447        disj_params.extend(ps);
448    }
449    disj.push(')');
450
451    // Redundant sargable leading-column bound (§3.7 perf). Every disjunct pins the
452    // leading sort column `o[0]` to either `<op> v0` (group i=0) or `= v0` (every
453    // later group + the `At` all-equality term), so the inclusive bound
454    // `o[0] <op>= v0` is *entailed* by the whole OR-of-ANDs — ANDing it in removes no
455    // rows. But SQLite can **seek** an index on it, turning a keyset start-fetch from
456    // a full-partition filter-scan (O(partition)) into an index range (O(log n)):
457    // the OR-of-ANDs alone is not sargable, so a `Take` displacement fetch anchored
458    // at the window boundary scans the whole partition up to the boundary (measured
459    // ~35k VM steps on a 3k-row partition vs ~62 with this term).
460    //
461    // Only emitted when the leading column is NOT NULL: a nullable one needs the
462    // `(… IS NULL OR …)` wrap (which defeats the seek anyway), and an ascending NULL
463    // *bound value* makes group 0 match every row (`? IS NULL OR …`), so there is no
464    // sound bare bound to add. `op_gt = asc XOR reverse` (matches group 0's operator);
465    // the inclusive form (`>=`/`<=`) keeps the boundary row, which every disjunct
466    // admits.
467    if let Some(&(col_id, asc)) = order.first() {
468        let col = &columns[col_id];
469        if !col.optional {
470            let op = if asc != reverse { ">=" } else { "<=" };
471            let value = to_sqlite_param(start.row.col(col_id), col.ty);
472            let text = format!("{} {op} ? AND {disj}", ident(&col.name));
473            let mut params = Vec::with_capacity(disj_params.len() + 1);
474            params.push(value);
475            params.extend(disj_params);
476            return (text, params);
477        }
478    }
479
480    (disj, disj_params)
481}
482
483// ---------------------------------------------------------------------------
484// ORDER BY
485// ---------------------------------------------------------------------------
486
487/// `orderByToSQL` (query-builder.ts:144): per-column direction, flipped under
488/// `reverse`. Effective direction = `asc XOR reverse`.
489fn order_by_to_sql(order: &Sort, reverse: bool, columns: &[ColumnDef]) -> String {
490    let mut s = String::from("ORDER BY ");
491    for (i, &(col, asc)) in order.iter().enumerate() {
492        if i > 0 {
493            s.push_str(", ");
494        }
495        let asc_eff = asc != reverse; // XOR
496        s.push_str(&ident(&columns[col].name));
497        s.push(' ');
498        s.push_str(if asc_eff { "asc" } else { "desc" });
499    }
500    s
501}
502
503// ---------------------------------------------------------------------------
504// filters (the residual is owned by 07; here we lower the pushed-down condition)
505// ---------------------------------------------------------------------------
506
507/// `filtersToSQL` (query-builder.ts:169): `and`/`or` recurse (empty `and` → `TRUE`,
508/// empty `or` → `FALSE`); `simple` → `simpleConditionToSQL`.
509fn filters_to_sql(c: &SqlCondition, columns: &[ColumnDef]) -> (String, Vec<SqliteParam>) {
510    match c {
511        SqlCondition::Simple { left, op, right } => {
512            simple_condition_to_sql(left, *op, right, columns)
513        }
514        SqlCondition::And(conds) => {
515            if conds.is_empty() {
516                return ("TRUE".to_string(), Vec::new());
517            }
518            join_bool(conds, " AND ", columns)
519        }
520        SqlCondition::Or(conds) => {
521            if conds.is_empty() {
522                return ("FALSE".to_string(), Vec::new());
523            }
524            join_bool(conds, " OR ", columns)
525        }
526    }
527}
528
529fn join_bool(
530    conds: &[SqlCondition],
531    sep: &str,
532    columns: &[ColumnDef],
533) -> (String, Vec<SqliteParam>) {
534    let mut text = String::from("(");
535    let mut params: Vec<SqliteParam> = Vec::new();
536    for (i, c) in conds.iter().enumerate() {
537        if i > 0 {
538            text.push_str(sep);
539        }
540        let (t, ps) = filters_to_sql(c, columns);
541        text.push_str(&t);
542        params.extend(ps);
543    }
544    text.push(')');
545    (text, params)
546}
547
548/// `simpleConditionToSQL` (query-builder.ts:194). `IN`/`NOT IN` → a
549/// `json_each(?)` subquery over the JSON-array literal; the `LIKE` family →
550/// `likeConditionToSQL`; everything else emits the op **verbatim**.
551fn simple_condition_to_sql(
552    left: &Operand,
553    op: SqlOp,
554    right: &Operand,
555    columns: &[ColumnDef],
556) -> (String, Vec<SqliteParam>) {
557    match op {
558        SqlOp::In | SqlOp::NotIn => {
559            let (ltext, mut params) = value_position_to_sql(left, columns);
560            // The right side is a JSON-array literal; bind its text and expand it
561            // via json_each (query-builder.ts:196-205).
562            let arr = match right {
563                Operand::Literal(v) => json_array_param(v),
564                Operand::Column(_) => panic!("IN right side must be a literal array"),
565            };
566            params.push(arr);
567            let op_str = if matches!(op, SqlOp::In) {
568                "IN"
569            } else {
570                "NOT IN"
571            };
572            (
573                format!("{ltext} {op_str} (SELECT value FROM json_each(?))"),
574                params,
575            )
576        }
577        SqlOp::Like | SqlOp::NotLike | SqlOp::Ilike | SqlOp::NotIlike => {
578            like_condition_to_sql(left, op, right, columns)
579        }
580        _ => {
581            let (ltext, mut params) = value_position_to_sql(left, columns);
582            let (rtext, rparams) = value_position_to_sql(right, columns);
583            params.extend(rparams);
584            (format!("{ltext} {} {rtext}", sql_op_raw(op)), params)
585        }
586    }
587}
588
589/// `likeConditionToSQL` (query-builder.ts:226): `ILIKE` lowers both sides through
590/// `lower(...)` (ICU, matching the in-memory matcher); the bare `LIKE` relies on
591/// the connection's `PRAGMA case_sensitive_like = ON`. Always `ESCAPE '\'`.
592fn like_condition_to_sql(
593    left: &Operand,
594    op: SqlOp,
595    right: &Operand,
596    columns: &[ColumnDef],
597) -> (String, Vec<SqliteParam>) {
598    let case_insensitive = matches!(op, SqlOp::Ilike | SqlOp::NotIlike);
599    let negated = matches!(op, SqlOp::NotLike | SqlOp::NotIlike);
600    let like_op = if negated { "NOT LIKE" } else { "LIKE" };
601
602    let (ltext, mut params) = value_position_to_sql(left, columns);
603    let (rtext, rparams) = value_position_to_sql(right, columns);
604    params.extend(rparams);
605
606    let text = if case_insensitive {
607        format!("lower({ltext}) {like_op} lower({rtext}) ESCAPE '\\'")
608    } else {
609        format!("{ltext} {like_op} {rtext} ESCAPE '\\'")
610    };
611    (text, params)
612}
613
614/// `valuePositionToSQL` (query-builder.ts:252): a column → its quoted identifier;
615/// a literal → a bound `?` param typed by its own runtime shape (`getJsType`).
616fn value_position_to_sql(operand: &Operand, columns: &[ColumnDef]) -> (String, Vec<SqliteParam>) {
617    match operand {
618        Operand::Column(col) => (ident(&columns[*col].name), Vec::new()),
619        Operand::Literal(v) => (
620            "?".to_string(),
621            vec![to_sqlite_param(v.as_ref(), js_type_of(v))],
622        ),
623    }
624}
625
626/// Bind the JSON-array literal for an `IN` list (the JS `JSON.stringify(right.value)`).
627/// A `Json` operand already carries the array text; anything else is serialized.
628fn json_array_param(v: &OwnedValue) -> SqliteParam {
629    SqliteParam::Text(json_serialize(v.as_ref()))
630}
631
632/// The raw SQL spelling for the verbatim-emitted operators
633/// (`sql.__dangerous__rawValue(filter.op)`). A free function rather than an inherent
634/// method: `SqlOp` is defined in the core `rindle` crate, so an inherent `impl` here is
635/// not allowed (orphan rule).
636fn sql_op_raw(op: SqlOp) -> &'static str {
637    match op {
638        SqlOp::Eq => "=",
639        SqlOp::Ne => "!=",
640        SqlOp::Lt => "<",
641        SqlOp::Le => "<=",
642        SqlOp::Gt => ">",
643        SqlOp::Ge => ">=",
644        SqlOp::Is => "IS",
645        SqlOp::IsNot => "IS NOT",
646        SqlOp::In => "IN",
647        SqlOp::NotIn => "NOT IN",
648        SqlOp::Like => "LIKE",
649        SqlOp::NotLike => "NOT LIKE",
650        SqlOp::Ilike => "ILIKE",
651        SqlOp::NotIlike => "NOT ILIKE",
652    }
653}