Rindle docs and package mapSkip to main content

rindle/
scalar.rs

1//! Build-time **scalar-subquery resolution** (`SCALAR-SUBQUERY-DESIGN.md`).
2//!
3//! A correlated `EXISTS` / `NOT EXISTS` subquery whose matched row is **statically
4//! unique** — the subquery's `WHERE` binds every column of a unique key of the child
5//! table to a build-time-known literal — can be resolved *before lowering*: we read
6//! the one row from the source, inline its correlation value into the parent query as
7//! a literal, and **delete the join entirely**. The parent pipeline then never
8//! subscribes to the child table.
9//!
10//! This is an opt-in, snapshot operation. It fires only on a condition the user
11//! flagged `scalar: true` ([`CorrelatedSubqueryCondition::scalar`](crate::ast)), and
12//! the inlined value is frozen for the life of the pipeline (design §3). The pass is
13//! a pure `Ast → Ast` transform, like the planner, but with one extra capability: a
14//! read seam over the source ([`ScalarCatalog`]).
15//!
16//! **Cross-level recursion (design §6).** Resolution is **inner-first**: before
17//! folding a scalar condition, its child subquery is resolved first, so an inner fold
18//! that turns the child `WHERE` into a unique-key binding can *unlock* the outer fold
19//! (`project ← issue ← comment` collapses to a single `project.id = …`). The recursion
20//! is the fixpoint — a chain of nested scalar `EXISTS` resolves bottom-up in one
21//! traversal. It stays **user-driven**: only conditions the user flagged `scalar` are
22//! folded, so each fold (and the staleness hop it adds, §3) is opt-in per level.
23//!
24//! **Precondition is exact (design §4).** A scalar child's `WHERE` must bind *exactly*
25//! a unique key — no extra filters. A `WHERE` like `id = 7 AND status = 'open'` is
26//! **not** folded (returns [`BuildError::Unsupported`]): folding to the correlation
27//! value would silently drop the `status` filter. The slice's canonical shape (a child
28//! `WHERE` that is exactly the unique-key equality, including the one an inner fold
29//! produces) satisfies this.
30
31use std::collections::BTreeMap;
32
33use crate::ast::{
34    Ast, Condition, CorrelatedSubqueryCondition, ExistsOp, Lit, Op, SimpleCondition, ValuePosition,
35};
36use crate::builder::{lit_to_scalar, BuildError};
37use crate::value::{ColId, OwnedRow, OwnedValue, Schema};
38
39/// The per-table read seam the resolver uses to fold a statically-unique subquery.
40/// One impl per source backend; [`MemorySource`](crate::memory_source::MemorySource)
41/// provides the PK-only slice impl.
42pub trait ScalarSource {
43    /// The child table's schema (column names → [`ColId`], primary key).
44    fn schema(&self) -> &Schema;
45
46    /// The statically-unique key column sets, **PK first**. Empty ⇒ nothing here is
47    /// foldable. (Slice: PK only; SQLite `pragma_index_xinfo` discovery is a
48    /// follow-up — design §4.1.)
49    fn unique_keys(&self) -> Vec<Vec<ColId>>;
50
51    /// The single row in which every `(ColId, value)` of `bound` holds. The caller
52    /// guarantees `bound` covers one of [`Self::unique_keys`], so the result is
53    /// unique. `None` ⇒ no row matches (the empty-result fold).
54    fn lookup_unique(&self, bound: &[(ColId, OwnedValue)]) -> Option<OwnedRow>;
55}
56
57/// Maps a table name to its [`ScalarSource`]. The resolver only ever looks up the
58/// **child** (subquery) table — the parent is never read.
59pub trait ScalarCatalog {
60    /// The source for `table`, or `None` if this catalog does not back it.
61    fn source(&self, table: &str) -> Option<&dyn ScalarSource>;
62}
63
64/// Resolve every `scalar`-flagged correlated subquery in `ast` (its `WHERE` tree and,
65/// recursively, every nested subquery and `related` child) against `catalog`,
66/// returning a rewritten AST in which each fold has replaced its `EXISTS`/`NOT EXISTS`
67/// condition with a plain `Simple` condition (so the builder emits no join for it).
68/// Conditions without the flag are untouched, so an AST with no `scalar: true`
69/// anywhere round-trips unchanged.
70///
71/// Errors (design §7: a flagged scalar that cannot be proven single-row is loud, not
72/// a silent live fallback):
73/// - [`BuildError::UnknownTable`] — the child table is not in `catalog`.
74/// - [`BuildError::Unsupported`] — the precondition fails (the child `WHERE` does not
75///   bind *exactly* a unique key), or this shape is not yet folded (e.g. a
76///   compound-correlation `NOT EXISTS`).
77/// - [`BuildError::UnknownColumn`] — a correlation/key column is absent from the schema.
78pub fn resolve_scalars(ast: &Ast, catalog: &dyn ScalarCatalog) -> Result<Ast, BuildError> {
79    resolve_ast(ast, catalog)
80}
81
82/// True if `ast` contains any `scalar`-flagged correlated subquery (anywhere in its
83/// `WHERE` tree or a nested/`related` subquery). A cheap gate so a caller can skip the
84/// [`resolve_scalars`] clone when there is nothing to fold — the peer of the planner's
85/// `has_flippable_exists`.
86pub fn has_scalar_subquery(ast: &Ast) -> bool {
87    ast.r#where.as_ref().is_some_and(cond_has_scalar)
88        || ast.related.iter().any(|r| has_scalar_subquery(&r.subquery))
89}
90
91fn cond_has_scalar(cond: &Condition) -> bool {
92    match cond {
93        Condition::Simple(_) => false,
94        Condition::And { conditions } | Condition::Or { conditions } => {
95            conditions.iter().any(cond_has_scalar)
96        }
97        Condition::CorrelatedSubquery(csq) => {
98            csq.scalar == Some(true) || has_scalar_subquery(&csq.related.subquery)
99        }
100    }
101}
102
103/// Resolve scalars throughout one AST level: its `WHERE` tree and each materialized
104/// `related` child (recursively). Pure clone-and-rewrite.
105fn resolve_ast(ast: &Ast, catalog: &dyn ScalarCatalog) -> Result<Ast, BuildError> {
106    let mut out = ast.clone();
107    if let Some(w) = &ast.r#where {
108        out.r#where = Some(resolve_condition(w, catalog)?);
109    }
110    out.related = ast
111        .related
112        .iter()
113        .map(|csq| {
114            let mut c = csq.clone();
115            c.subquery = Box::new(resolve_ast(&csq.subquery, catalog)?);
116            Ok(c)
117        })
118        .collect::<Result<_, BuildError>>()?;
119    Ok(out)
120}
121
122/// Recurse the `WHERE` tree, folding each `scalar`-flagged correlated subquery. The
123/// `And`/`Or` arms recurse structurally; a `Simple` is inert; a `CorrelatedSubquery`
124/// is resolved **inner-first** (its child subquery is resolved before this level is
125/// folded — the cross-level unlock, §6).
126fn resolve_condition(
127    cond: &Condition,
128    catalog: &dyn ScalarCatalog,
129) -> Result<Condition, BuildError> {
130    match cond {
131        Condition::Simple(_) => Ok(cond.clone()),
132        Condition::And { conditions } => Ok(Condition::And {
133            conditions: conditions
134                .iter()
135                .map(|c| resolve_condition(c, catalog))
136                .collect::<Result<_, _>>()?,
137        }),
138        Condition::Or { conditions } => Ok(Condition::Or {
139            conditions: conditions
140                .iter()
141                .map(|c| resolve_condition(c, catalog))
142                .collect::<Result<_, _>>()?,
143        }),
144        Condition::CorrelatedSubquery(csq) => {
145            // Inner-first: resolve the child subquery so an inner scalar fold can turn
146            // this child's WHERE into a unique-key binding that unlocks the fold here.
147            let child = resolve_ast(&csq.related.subquery, catalog)?;
148            if csq.scalar == Some(true) {
149                fold_scalar(csq, &child, catalog)
150            } else {
151                // A non-flagged EXISTS stays a join, but carries its resolved child
152                // (deeper scalars are still folded).
153                let mut c = csq.clone();
154                c.related.subquery = Box::new(child);
155                Ok(Condition::CorrelatedSubquery(c))
156            }
157        }
158    }
159}
160
161/// Fold one statically-unique `scalar` subquery into a parent-side `Simple`
162/// condition (or a constant when the row is absent). `child` is the **already
163/// inner-resolved** subquery (§6). See the design §5 table.
164fn fold_scalar(
165    csq: &CorrelatedSubqueryCondition,
166    child: &Ast,
167    catalog: &dyn ScalarCatalog,
168) -> Result<Condition, BuildError> {
169    // Inner resolution may have collapsed the child WHERE to a constant. A
170    // constant-**false** child ⇒ the subquery is empty ⇒ decide without a lookup (the
171    // inner-absent cross-level cascade, §6). A constant-**true** child ⇒ "all rows",
172    // which is not single-row — it falls through to the unique-key precondition, which
173    // fails loudly (correct: a `WHERE true` EXISTS is a real semijoin, not scalar).
174    if child.r#where.as_ref().and_then(const_bool) == Some(false) {
175        return Ok(empty_fold(csq.op));
176    }
177
178    let corr = &csq.related.correlation;
179    let source = catalog
180        .source(&child.table)
181        .ok_or_else(|| BuildError::UnknownTable(child.table.clone()))?;
182
183    // 1. The child's WHERE must be a conjunction of `col = <literal>` bindings.
184    let bindings =
185        child
186            .r#where
187            .as_ref()
188            .and_then(collect_eq_bindings)
189            .ok_or(BuildError::Unsupported(
190                "scalar subquery: child WHERE must be a conjunction of `column = literal`",
191            ))?;
192
193    // 2. The bindings must cover **exactly** a unique key (no extra filters — folding
194    //    away an extra filter would be unsound, §4).
195    let bound = bind_unique_key(source, &bindings)?.ok_or(BuildError::Unsupported(
196        "scalar subquery: child WHERE does not bind exactly a unique key",
197    ))?;
198
199    // 3. Read the (at most one) row and rewrite the parent condition.
200    match source.lookup_unique(&bound) {
201        Some(row) => present_fold(csq.op, corr, source.schema(), &row),
202        None => Ok(empty_fold(csq.op)),
203    }
204}
205
206/// The matched row exists: the `EXISTS` holds exactly for parents whose
207/// `parent_field` equals the row's `child_field` value. Rewrite to that equality
208/// (`EXISTS`) or its null-aware negation (`NOT EXISTS`, the `field IS NOT literal`
209/// rewrite — design §5).
210fn present_fold(
211    op: ExistsOp,
212    corr: &crate::ast::Correlation,
213    child_schema: &Schema,
214    row: &OwnedRow,
215) -> Result<Condition, BuildError> {
216    // Each correlation pair `parent_field[i] (=) child_field[i]` becomes an equality
217    // of the parent column against the row's child-field value, read as a literal.
218    let mut eqs: Vec<SimpleCondition> = Vec::with_capacity(corr.parent_field.len());
219    for (pf, cf) in corr.parent_field.iter().zip(&corr.child_field) {
220        let col = child_schema
221            .col_id(cf)
222            .ok_or_else(|| BuildError::UnknownColumn(cf.clone()))?;
223        let lit = scalar_to_lit(&row.col(col).to_owned())?;
224        eqs.push(SimpleCondition {
225            op: Op::Eq,
226            left: ValuePosition::Column { name: pf.clone() },
227            right: ValuePosition::Literal { value: lit },
228        });
229    }
230
231    match op {
232        ExistsOp::Exists => Ok(and_of(eqs)),
233        // `NOT EXISTS` over a single correlation column is `parent IS NOT literal`.
234        // Compound correlation would be `OR` of `IS NOT`s — deferred (slice).
235        ExistsOp::NotExists => {
236            let [eq] = <[SimpleCondition; 1]>::try_from(eqs).map_err(|_| {
237                BuildError::Unsupported(
238                    "scalar NOT EXISTS with a compound correlation is not yet folded",
239                )
240            })?;
241            Ok(Condition::Simple(SimpleCondition {
242                op: Op::IsNot,
243                ..eq
244            }))
245        }
246    }
247}
248
249/// No matching row: `EXISTS` is constant-false, `NOT EXISTS` constant-true. Encoded
250/// as a literal-vs-literal `Simple`, which the builder folds to
251/// `CompiledPredicate::Const` (`builder.rs:490`) — no new AST variant needed.
252fn empty_fold(op: ExistsOp) -> Condition {
253    let always = matches!(op, ExistsOp::NotExists);
254    // `1 = 1` ⇒ true, `0 = 1` ⇒ false.
255    Condition::Simple(SimpleCondition {
256        op: Op::Eq,
257        left: ValuePosition::Literal {
258            value: Lit::Number(if always { 1.0 } else { 0.0 }),
259        },
260        right: ValuePosition::Literal {
261            value: Lit::Number(1.0),
262        },
263    })
264}
265
266/// Wrap a list of equalities as a single condition: the bare `Simple` for one,
267/// an `And` for several. (Always non-empty — a correlation has ≥1 pair.)
268fn and_of(mut eqs: Vec<SimpleCondition>) -> Condition {
269    if eqs.len() == 1 {
270        Condition::Simple(eqs.pop().unwrap())
271    } else {
272        Condition::And {
273            conditions: eqs.into_iter().map(Condition::Simple).collect(),
274        }
275    }
276}
277
278/// A literal-vs-literal `Eq` `Simple` is a constant (the shape [`empty_fold`] emits,
279/// and what an inner fold leaves behind). Returns its truth value, else `None`.
280fn const_bool(cond: &Condition) -> Option<bool> {
281    if let Condition::Simple(SimpleCondition {
282        op: Op::Eq,
283        left: ValuePosition::Literal { value: a },
284        right: ValuePosition::Literal { value: b },
285    }) = cond
286    {
287        return Some(lit_eq(a, b));
288    }
289    None
290}
291
292/// Structural literal equality over the scalar arms (enough for the constants the
293/// resolver produces and re-reads).
294fn lit_eq(a: &Lit, b: &Lit) -> bool {
295    use crate::value::compare_int_f64;
296    use std::cmp::Ordering;
297    match (a, b) {
298        (Lit::Null, Lit::Null) => true,
299        (Lit::Bool(x), Lit::Bool(y)) => x == y,
300        (Lit::Int(x), Lit::Int(y)) => x == y,
301        (Lit::Number(x), Lit::Number(y)) => x == y,
302        // Numerics compare exactly across the Int/Number spelling (design 226 Stage B):
303        // the resolver may have inlined `Int(5)` where an older constant said `5.0`.
304        (Lit::Int(x), Lit::Number(y)) | (Lit::Number(y), Lit::Int(x)) => {
305            compare_int_f64(*x, *y) == Ordering::Equal
306        }
307        (Lit::Str(x), Lit::Str(y)) => x == y,
308        _ => false,
309    }
310}
311
312/// Collect a `column = literal` conjunction into a `name → literal` map. Returns
313/// `None` (⇒ not a foldable shape) on any other condition — an `Or`, a non-`Eq` op,
314/// a column RHS, a nested subquery, etc.
315fn collect_eq_bindings(cond: &Condition) -> Option<BTreeMap<&str, &Lit>> {
316    let mut out = BTreeMap::new();
317    if collect_eq_into(cond, &mut out) {
318        Some(out)
319    } else {
320        None
321    }
322}
323
324fn collect_eq_into<'a>(cond: &'a Condition, out: &mut BTreeMap<&'a str, &'a Lit>) -> bool {
325    match cond {
326        Condition::And { conditions } => conditions.iter().all(|c| collect_eq_into(c, out)),
327        Condition::Simple(SimpleCondition {
328            op: Op::Eq,
329            left: ValuePosition::Column { name },
330            right: ValuePosition::Literal { value },
331        }) => {
332            out.insert(name, value);
333            true
334        }
335        _ => false,
336    }
337}
338
339/// If `bindings` covers **exactly** some unique key of `source` (the same columns, no
340/// more), return that key's `(ColId, value)` lookup pairs (first match wins — PK is
341/// first). `None` ⇒ no unique key is matched exactly. The exact-cover requirement is a
342/// soundness rule: a binding *beyond* the key is an extra filter that folding to the
343/// correlation value would silently drop (§4).
344fn bind_unique_key(
345    source: &dyn ScalarSource,
346    bindings: &BTreeMap<&str, &Lit>,
347) -> Result<Option<Vec<(ColId, OwnedValue)>>, BuildError> {
348    let schema = source.schema();
349    for key in source.unique_keys() {
350        // Same arity + every key column bound ⇒ the binding set equals the key set.
351        if key.len() != bindings.len() {
352            continue;
353        }
354        let mut bound = Vec::with_capacity(key.len());
355        let mut covered = true;
356        for &col in &key {
357            let name: &str = &schema.columns[col];
358            match bindings.get(name) {
359                Some(lit) => bound.push((col, lit_to_scalar(lit)?)),
360                None => {
361                    covered = false;
362                    break;
363                }
364            }
365        }
366        if covered {
367            return Ok(Some(bound));
368        }
369    }
370    Ok(None)
371}
372
373/// Bridge a runtime [`OwnedValue`](crate::value::OwnedValue) (read from the source row) back to an AST [`Lit`]
374/// for inlining. The inverse of [`lit_to_scalar`] over the scalar arms; `Int` keeps
375/// all 64 bits via `Lit::Int` (design 226 Stage B — the old `as f64` collapse
376/// rounded above 2^53), and a `Json` cell — never a correlation key — is unsupported.
377fn scalar_to_lit(v: &OwnedValue) -> Result<Lit, BuildError> {
378    Ok(match v {
379        OwnedValue::Null => Lit::Null,
380        OwnedValue::Bool(b) => Lit::Bool(*b),
381        OwnedValue::Int(i) => Lit::Int(*i),
382        OwnedValue::Float(f) => Lit::Number(*f),
383        OwnedValue::Str(s) => Lit::Str(s.as_ref().into()),
384        OwnedValue::Json(_) => {
385            return Err(BuildError::Unsupported(
386                "scalar subquery: a JSON correlation value cannot be inlined",
387            ))
388        }
389        // A correlation key is read from a full engine row, so the projection sentinel should
390        // never reach here; if it does, it cannot be inlined as a literal.
391        OwnedValue::Absent => {
392            return Err(BuildError::Unsupported(
393                "scalar subquery: an absent (unprojected) correlation value cannot be inlined",
394            ))
395        }
396    })
397}