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}