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}