rindle/builder.rs
1//! The pipeline builder (spec `08`) — lowers an [`Ast`] into a wired arena
2//! [`Graph`](crate::graph). This module holds the build-time **AST passes** the
3//! builder runs *before* lowering, the recursive `build_pipeline` spine (which walks
4//! the AST, allocates the arena nodes, and calls [`create_predicate`] per `where`
5//! leaf), and [`create_predicate`] itself (the leaf-condition →
6//! [`CompiledPredicate`](crate::predicate) compiler).
7//!
8//! What lives here today:
9//! - [`complete_ordering`] — the always-includes-PK invariant (foundations §4).
10//! - [`normalize_pipeline_ast`] — the builder's pre-lowering AST normalization: the
11//! `flattened` structural subset of `normalizeAST` (splice same-op AND/OR, drop
12//! empties, unwrap singletons) followed by JS-parity correlated-subquery alias
13//! uniquification.
14//! - [`transform_filters`] — strip correlated-subquery conditions so the source
15//! connection sees only a leaf-condition tree, and report whether anything was
16//! removed (gates the in-memory filter sub-graph, spec `08` §5.4).
17//! - [`create_predicate`] — lower one AST [`SimpleCondition`] to a
18//! [`CompiledPredicate`] (name→`ColId`, literal→`OwnedValue` under the
19//! number-coercion rule, three-valued-null parity with JS `createPredicate`).
20//! AND/OR/NOT are the Filter sub-graph's job, so this lowers a single *leaf*.
21//! - [`BuildError`] — the lowering error type (first used by [`create_predicate`]).
22//! - [`schema_primary_key_names`] — the `getPrimaryKey` bridge from a `Schema`
23//! (whose PK is `ColId`s) to the column *names* the name-based passes need.
24
25use crate::ast::{
26 Aggregate, Ast, Bound, Condition, CorrelatedSubquery, CorrelatedSubqueryCondition, Dir,
27 ExistsOp, Lit, Op, OrderPart, SimpleCondition, System, ValuePosition,
28};
29use crate::change::{Basis, Constraint, OutEdge, Port, Start};
30use crate::family::{BindingSet, FamilyPipeline};
31use crate::graph::{Graph, NodeId};
32use crate::predicate::{CmpOp, CompiledPredicate, LikeMatcher, ValueSet};
33use crate::push_index::PushGuard;
34use crate::source_common::{ConnectionFilters, Operand, RowPredicate, SqlCondition, SqlOp};
35use crate::value::{
36 owned_row, values_identical, ColId, OwnedRow, OwnedValue, RelDef, Schema, Sort, SourceSchema,
37 Value, ValueType,
38};
39use crate::{metric_build_err, metric_inc, metric_timer};
40use std::rc::Rc;
41use std::sync::Arc;
42
43/// The row bound EXISTS child pipelines are built with (`builder.ts:224`). Exists
44/// only needs "`> 0`" vs "`== 0`", so the counted size never needs to exceed this.
45const EXISTS_LIMIT: u32 = 3;
46/// The tighter bound for permission-system subqueries (`builder.ts:225`).
47const PERMISSIONS_EXISTS_LIMIT: u32 = 1;
48
49/// A table's primary-key column **names**, in primary-key order. Bridges a source
50/// [`Schema`] (whose `primary_key` is resolved `ColId`s) back to the names the
51/// name-based AST passes ([`complete_ordering`]) operate on — the analogue of the
52/// JS `getPrimaryKey(tableName)` (`complete-ordering.ts:8`).
53pub fn schema_primary_key_names(schema: &Schema) -> Vec<Box<str>> {
54 schema
55 .primary_key
56 .iter()
57 .map(|&c| schema.columns[c].clone())
58 .collect()
59}
60
61/// Derive the production-[`View`](crate::view::View) **hierarchical schema** for `ast`,
62/// resolving table names through the same `resolve` closure that [`build_pipeline`] uses.
63/// `build_pipeline` returns only the top `NodeId`, so the View's tree shape is
64/// reconstructed here from the `Ast`. The schema *is* the view shape: a relationship slot
65/// carries a child schema iff it is in view (a join-only `RelDef::new` is out of view).
66///
67/// - the **slot order** is the source schema's declared relationship order (the same
68/// order `build_pipeline` resolves aliases against via `rel_slot`), so dataflow slots
69/// line up with the view's;
70/// - a slot whose name is a `related` alias of *this* frame is **in view**: its child
71/// schema is built recursively (its sort PK-completed by `resolve_sort`) — a
72/// `RelDef::related`. On a duplicate alias the **last** writer wins, matching the
73/// dataflow's `dedup_related_by_alias`, so the View's child schema/sort is derived from
74/// the same subquery the Join is built from;
75/// - every other declared slot — an EXISTS gating relationship, or one unused by this
76/// `Ast` — is **out of view**: a join-only `RelDef::new` (no child schema), which the
77/// View's `apply_change` skips (`rel_child(slot)` is `None`).
78///
79/// The top frame's sort is the resolved `order_by`; top-level is plural.
80///
81/// This is the production seam graduated out of `testkit`: the wasm client (WS01) and the
82/// testkit runners both derive the view shape here, so there is one derivation, not two.
83pub fn view_schema(
84 ast: &Ast,
85 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
86) -> Result<Schema, BuildError> {
87 let ast = normalize_pipeline_ast(ast);
88 let base = resolve(&ast.table)
89 .map(|(_, s)| s.into_schema())
90 .ok_or_else(|| BuildError::UnknownTable(ast.table.clone()))?;
91
92 // A top-level aggregate's view shape is the reduce's synthetic output row
93 // (`[group…, count]`), not the base table — a `having` filter is schema-preserving,
94 // so it does not change this. Mirror `Reduce`'s output schema (§8).
95 if ast.aggregate.is_some() {
96 return aggregate_view_schema(&ast, &base);
97 }
98
99 let sort = resolve_sort(&ast.order_by, &base)?;
100
101 // The slot layout is **query-derived** (see [`query_local_slot_names`]), identical to
102 // the order `build_pipeline` resolves against, so the View's `RelId`s line up with the
103 // dataflow's by construction (the load-bearing "one tree, three consumers" invariant —
104 // not the source schema's *declared* relationships, which a query cannot pre-declare
105 // for synthesized EXISTS-gate aliases like `comments_0`).
106 let slot_names = query_local_slot_names(&ast);
107 let mut rel_defs: Vec<RelDef> = Vec::with_capacity(slot_names.len());
108 for name in &slot_names {
109 // In view iff this slot's name is a `related` alias of THIS frame's `Ast`.
110 // LAST-writer-wins on a duplicate alias (`.rev().find`), matching the dataflow's
111 // `dedup_related_by_alias` so the child schema + sort come from the SAME subquery
112 // the Join is built from.
113 let related = ast
114 .related
115 .iter()
116 .rev()
117 .find(|c| c.subquery.alias.as_deref() == Some(name.as_ref()));
118 match related {
119 // A relationship **aggregate** (`count(child)`): a scalar-projected singular
120 // relationship over the synthetic `[group…, count]` row (§9), NOT a recursive
121 // row-materializing child schema.
122 Some(sub) if sub.subquery.aggregate.is_some() => {
123 let agg = sub.subquery.aggregate.as_ref().expect("aggregate present");
124 // Resolve the child SOURCE (the rows the reduce folds). For a `sum`/`avg`
125 // this is mandatory — its column must exist there (mirroring
126 // `apply_related`'s `agg_spec_for`). For a `count` it is best-effort,
127 // types only: this schema-only path never resolved a count's child
128 // before, and an unresolvable one keeps the legacy `Number` default
129 // rather than growing a new error.
130 let child_source: Option<Schema> =
131 resolve(&sub.subquery.table).map(|(_, s)| s.into_schema());
132 if matches!(agg, Aggregate::Sum(_) | Aggregate::Avg(_)) {
133 let cs = child_source
134 .as_ref()
135 .ok_or_else(|| BuildError::UnknownTable(sub.subquery.table.clone()))?;
136 let _ = agg_spec_for(agg, cs)?;
137 }
138 rel_defs.push(agg_relationship_reldef(
139 name,
140 &sub.correlation.child_field,
141 agg,
142 child_source.as_ref(),
143 ));
144 }
145 Some(sub) => {
146 let child_schema = view_schema(&sub.subquery, resolve)?;
147 rel_defs.push(RelDef::related(name, child_schema));
148 }
149 None => {
150 // Gating (EXISTS) or unused: declared join-only, excluded from the view.
151 rel_defs.push(RelDef::new(name));
152 }
153 }
154 }
155
156 // Lower the query's projection (`Ast::select`) onto this level (§6). `None` ⇒ the
157 // view reports every column; `Some(names)` ⇒ resolve to base `ColId`s, in select
158 // order. `columns` stays the full positional list (§2.1) — only the *reported* set
159 // narrows. An unknown selected column is a build error, like any name lowering.
160 let projection: Option<Vec<ColId>> = match &ast.select {
161 None => None,
162 Some(names) => Some(
163 names
164 .iter()
165 .map(|n| col_id(&base, n))
166 .collect::<Result<Vec<_>, _>>()?,
167 ),
168 };
169
170 Ok(Schema {
171 sort,
172 relationships: rel_defs,
173 // Lower the query's `.one()` intent onto this level's view shape. The child
174 // levels get theirs from the recursive `view_schema(&sub.subquery, ..)` above
175 // (each reads its own subquery's `one`).
176 singular: ast.one,
177 projection,
178 ..base
179 })
180}
181
182/// The synthetic output column name and empty-group **identity** for an aggregate
183/// (`REDUCE-DESIGN.md` §9). The name mirrors [`AggSpec::col_name`](crate::op::AggSpec) so
184/// the reduce's output schema and the view schema agree; the identity is what the scalar
185/// projection substitutes for a childless parent — `0` for `count` (SQL `LEFT JOIN …
186/// count → 0`), `NULL` for `sum`/`avg`.
187fn agg_output(agg: &Aggregate) -> (&'static str, OwnedValue) {
188 match agg {
189 Aggregate::Count => ("count", OwnedValue::Int(0)),
190 Aggregate::Sum(_) => ("sum", OwnedValue::Null),
191 Aggregate::Avg(_) => ("avg", OwnedValue::Null),
192 }
193}
194
195/// Resolve an [`Aggregate`] (column *names*) to an [`AggSpec`] (column *indices*) against
196/// the schema the reduce folds over (the child source for a relationship aggregate, the
197/// table source for a top-level one). An unknown summed column is a build error.
198fn agg_spec_for(agg: &Aggregate, schema: &Schema) -> Result<crate::op::AggSpec, BuildError> {
199 Ok(match agg {
200 Aggregate::Count => crate::op::AggSpec::Count,
201 Aggregate::Sum(col) => crate::op::AggSpec::Sum(col_id(schema, col)?),
202 Aggregate::Avg(col) => crate::op::AggSpec::Avg(col_id(schema, col)?),
203 })
204}
205
206/// The view child schema + scalar projection for a relationship aggregate
207/// (`REDUCE-DESIGN.md` §9). The synthetic aggregate row is `[child_field…, <agg>]`,
208/// keyed and sorted by the group (correlation child) columns and marked `singular`; the
209/// trailing aggregate column is **scalar-projected** with the aggregate's empty-group
210/// identity. This MUST mirror the reduce's output schema
211/// ([`Reduce::grouped_agg`](crate::op::Reduce)) so the production `View` differ locates
212/// the aggregate row by the same sort/PK.
213fn agg_relationship_reldef(
214 name: &str,
215 child_field: &[Box<str>],
216 agg: &Aggregate,
217 child_source: Option<&Schema>,
218) -> RelDef {
219 let (col_name, identity) = agg_output(agg);
220 let k = child_field.len();
221 let mut cols: Vec<&str> = child_field.iter().map(|c| &**c).collect();
222 cols.push(col_name);
223 let key: Vec<ColId> = (0..k).collect();
224 let sort: Sort = (0..k).map(|i| (i, true)).collect();
225 // Column types (design 226 §4.1): each group column preserves the child source's
226 // declared type; the aggregate column carries `AggSpec::output_type` — the same
227 // derivation `Reduce::with_input_types` applies to the dataflow twin. An
228 // unresolvable child source / column keeps the legacy `Number` default.
229 let mut types: Vec<ValueType> = child_field
230 .iter()
231 .map(|f| {
232 child_source
233 .and_then(|cs| cs.col_id(f).and_then(|c| cs.column_types.get(c).copied()))
234 .unwrap_or(ValueType::Number)
235 })
236 .collect();
237 types.push(
238 child_source
239 .and_then(|cs| agg_spec_for(agg, cs).ok().map(|s| s.output_type(cs)))
240 .unwrap_or(ValueType::Number),
241 );
242 let mut child = Schema::new(cols, key, sort).with_column_types(types);
243 child.singular = true;
244 // The aggregate column sits just past the k group columns; empty group → identity.
245 RelDef::related(name, child).project_scalar(k, identity)
246}
247
248/// The View result schema for a **top-level aggregate** (`REDUCE-DESIGN.md` §8): the
249/// reduce's synthetic output row. Global (no `group_by`) ⇒ `[count]` with empty PK/sort
250/// (a singleton, §3); grouped ⇒ `[group…, count]` keyed and sorted by the group
251/// columns. MUST mirror [`Reduce::count`](crate::op::Reduce::count) /
252/// [`Reduce::count_by`](crate::op::Reduce::count_by) so the production `View` differ
253/// locates rows by the same sort/PK and a `having` Filter (built against the reduce's
254/// schema) resolves identical `ColId`s. Group-by names are validated against the source.
255fn aggregate_view_schema(ast: &Ast, base: &Schema) -> Result<Schema, BuildError> {
256 // Validate the group-by names exist in the source (unknown column → BuildError) — the
257 // same columns the reduce's partition key resolves.
258 let group_cols = resolve_cols(&ast.group_by, base)?;
259 let agg = ast
260 .aggregate
261 .as_ref()
262 .expect("aggregate_view_schema on a non-aggregate AST");
263 // Validate the summed column (Sum/Avg) exists in the source, mirroring the reduce.
264 let spec = agg_spec_for(agg, base)?;
265 let (agg_col, _identity) = agg_output(agg);
266 let k = ast.group_by.len();
267 let mut cols: Vec<&str> = ast.group_by.iter().map(|c| &**c).collect();
268 cols.push(agg_col);
269 let (key, sort): (Vec<ColId>, Sort) = if k == 0 {
270 (Vec::new(), Vec::new())
271 } else {
272 ((0..k).collect(), (0..k).map(|i| (i, true)).collect())
273 };
274 // Column types (design 226 §4.1): group columns preserve the source's declared
275 // types; the aggregate column carries `AggSpec::output_type` — mirroring
276 // `Reduce::with_input_types` on the dataflow twin.
277 let mut types: Vec<ValueType> = group_cols
278 .iter()
279 .map(|&c| {
280 base.column_types
281 .get(c)
282 .copied()
283 .unwrap_or(ValueType::Number)
284 })
285 .collect();
286 types.push(spec.output_type(base));
287 Ok(Schema::new(cols, key, sort).with_column_types(types))
288}
289
290/// `completeOrdering` (`complete-ordering.ts:6`). Append every missing primary-key
291/// column (as `asc`) to `order_by`, recursively — the root, **every** `related`
292/// subquery, and **every** correlated-subquery's subquery — so each `order_by`
293/// ends with the full PK (foundations §4; the builder later lowers it to a `Sort`).
294/// `get_pk(table)` yields a table's PK column names in PK order
295/// ([`schema_primary_key_names`]). Mutates in place — the builder owns the `Ast`.
296///
297/// The whole-tree recursion is load-bearing: a `related`-Join child fetches in its
298/// *own* completed order, so skipping a subquery here would mis-sort the child.
299pub fn complete_ordering(ast: &mut Ast, get_pk: &impl Fn(&str) -> Vec<Box<str>>) {
300 let pk = get_pk(&ast.table);
301 for csq in &mut ast.related {
302 complete_ordering(&mut csq.subquery, get_pk);
303 }
304 if let Some(cond) = &mut ast.r#where {
305 complete_ordering_in_condition(cond, get_pk);
306 }
307 add_primary_keys(&pk, &mut ast.order_by);
308}
309
310/// Recurse `completeOrdering` into a condition tree's subqueries
311/// (`completeOrderingInCondition`, `complete-ordering.ts:46`). A `Simple` is
312/// untouched; a correlated subquery and `and`/`or` branches recurse.
313fn complete_ordering_in_condition(cond: &mut Condition, get_pk: &impl Fn(&str) -> Vec<Box<str>>) {
314 match cond {
315 Condition::Simple(_) => {}
316 Condition::CorrelatedSubquery(c) => complete_ordering(&mut c.related.subquery, get_pk),
317 Condition::And { conditions } | Condition::Or { conditions } => {
318 for c in conditions {
319 complete_ordering_in_condition(c, get_pk);
320 }
321 }
322 }
323}
324
325/// `addPrimaryKeys` (`complete-ordering.ts:74`). Append each PK column **not
326/// already present** in `order_by` (matched by name), as `asc`, in PK order.
327/// Already-present PK columns keep their existing position and direction.
328fn add_primary_keys(pk: &[Box<str>], order_by: &mut Vec<OrderPart>) {
329 for pk_col in pk {
330 if !order_by.iter().any(|op| op.field() == pk_col.as_ref()) {
331 order_by.push(OrderPart(pk_col.clone(), Dir::Asc));
332 }
333 }
334}
335
336/// Builder pre-lowering normalization.
337///
338/// Two passes:
339/// 1. `flatten_condition` — the structural `flattened` subset (`ast.ts:564`): splice
340/// same-op AND/OR inline, drop empty conjunctions, unwrap singletons. This is a
341/// **Rust-side pre-pass the JS builder itself does not run** — it is the analogue of
342/// the JS *fluent* layer's `simplifyCondition` (`expression.ts:249`, run by every
343/// `.where(...)`), which the JS builder assumes has already shaped the AST. We run a
344/// pass here because the builder accepts raw (un-simplified) ASTs directly. The
345/// `cmp_condition` sort + `sortedRelated` are deferred (byte-identical canonical
346/// output only; they would reorder the conditions the alias pass numbers — see
347/// PRODUCTIONIZATION 05.4 / spec `02` §3.2). NB `flattened` is a *one-level* splice
348/// (not the bottom-up full flatten of `simplifyCondition`), so a residual same-op
349/// nest can survive linear 3+-deep input; that is handled downstream by recursion.
350/// 2. `uniquifyCorrelatedSubqueryConditionAliases` (`builder.ts:763`): when the
351/// *flattened* top-level `where` is an `and`/`or`, every correlated subquery
352/// condition nested inside that tree has its subquery alias suffixed in pre-order
353/// (`comments` -> `comments_0`, next -> `_1`, ...). A bare top-level EXISTS — or a
354/// singleton `and[EXISTS]` that flattening unwrapped to one — is left unchanged.
355/// (This deliberately differs from the JS builder run on a *raw* singleton AST,
356/// which would still see `and[…]` and number it `_0`; it instead matches the JS
357/// fluent path, which `simplifyCondition`-unwraps the singleton before the builder.
358/// The alias only names an EXISTS gate's operator/storage — it is excluded from the
359/// view — so this is row-output-invisible.)
360///
361/// Flattening here is what unblocks the AND-within-AND flipped-EXISTS shape (the
362/// nesting it removes was the only reason that shape was rejected) without reordering
363/// the existing EXISTS-under-OR / union-fan layouts (flatten preserves left-to-right
364/// leaf order, so the alias numbers are stable).
365///
366/// This is frame-local. Child ASTs are normalized when their own
367/// `build_pipeline_internal` frame runs.
368pub fn normalize_pipeline_ast(ast: &Ast) -> Ast {
369 let Some(where_clause) = ast.r#where.as_ref() else {
370 return ast.clone();
371 };
372
373 let Some(flattened) = flatten_condition(where_clause) else {
374 // The whole `where` flattened to empty (e.g. `and[]`) → drop it.
375 let mut out = ast.clone();
376 out.r#where = None;
377 return out;
378 };
379
380 if !matches!(flattened, Condition::And { .. } | Condition::Or { .. }) {
381 let mut out = ast.clone();
382 out.r#where = Some(flattened);
383 return out;
384 }
385
386 let mut next_alias = 0u32;
387 let mut out = ast.clone();
388 out.r#where = Some(uniquify_condition_aliases(&flattened, &mut next_alias));
389 out
390}
391
392/// `flattened` (`ast.ts:564`) — the structural subset of `normalizeAST` the builder
393/// needs. For an `and`/`or` node: splice each **same-op** child's conditions inline
394/// (one level, mirroring the JS `c.conditions.map(flattened)`), recurse into
395/// different-op / leaf children, drop the children that flatten away, then collapse
396/// (`case 0 → None`, `case 1 → the sole child`, else the rebuilt node). A `Simple` /
397/// `CorrelatedSubquery` returns unchanged.
398///
399/// Faithful port, quirk included: the splice maps `flattened` over a same-op child's
400/// *children*, so a same-op grandchild surfaced by that map is **not** re-spliced —
401/// linear nesting deeper than two levels (`and[a, and[b, and[c,d]]]`) flattens to
402/// `and[a, b, and[c,d]]`, not fully flat. That residual same-op nest is handled
403/// downstream by recursion (`apply_flips_and` → [`apply_where_with_flips`] →
404/// `apply_flips_and`, exactly as JS `applyFilterWithFlips` recurses), so the result is
405/// correctness-preserving regardless.
406fn flatten_condition(cond: &Condition) -> Option<Condition> {
407 let (is_and, conditions) = match cond {
408 Condition::Simple(_) | Condition::CorrelatedSubquery(_) => return Some(cond.clone()),
409 Condition::And { conditions } => (true, conditions),
410 Condition::Or { conditions } => (false, conditions),
411 };
412
413 let mut flat: Vec<Condition> = Vec::with_capacity(conditions.len());
414 for c in conditions {
415 let same_op = matches!(
416 (is_and, c),
417 (true, Condition::And { .. }) | (false, Condition::Or { .. })
418 );
419 if same_op {
420 // Splice the same-op child's children inline, each flattened once
421 // (`c.conditions.map(flattened)`), dropping any that flatten to empty
422 // (the JS `defined(...)`).
423 let (Condition::And { conditions: kids } | Condition::Or { conditions: kids }) = c
424 else {
425 unreachable!("same_op implies and/or")
426 };
427 flat.extend(kids.iter().filter_map(flatten_condition));
428 } else if let Some(f) = flatten_condition(c) {
429 flat.push(f);
430 }
431 }
432
433 match flat.len() {
434 0 => None,
435 1 => Some(flat.into_iter().next().expect("len == 1")),
436 _ if is_and => Some(Condition::And { conditions: flat }),
437 _ => Some(Condition::Or { conditions: flat }),
438 }
439}
440
441fn uniquify_condition_aliases(cond: &Condition, next_alias: &mut u32) -> Condition {
442 match cond {
443 Condition::Simple(c) => Condition::Simple(c.clone()),
444 Condition::CorrelatedSubquery(c) => {
445 let mut c = c.clone();
446 let base = c.related.subquery.alias.as_deref().unwrap_or("");
447 c.related.subquery.alias = Some(format!("{base}_{}", *next_alias).into_boxed_str());
448 *next_alias += 1;
449 Condition::CorrelatedSubquery(c)
450 }
451 Condition::And { conditions } => Condition::And {
452 conditions: conditions
453 .iter()
454 .map(|c| uniquify_condition_aliases(c, next_alias))
455 .collect(),
456 },
457 Condition::Or { conditions } => Condition::Or {
458 conditions: conditions
459 .iter()
460 .map(|c| uniquify_condition_aliases(c, next_alias))
461 .collect(),
462 },
463 }
464}
465
466/// `transformFilters` (`filter.ts:171`) — the **core**: strip every
467/// correlated-subquery condition so what remains is a leaf-only condition tree the
468/// source connection can apply, and report whether anything was removed.
469///
470/// Returns `(stripped, conditions_removed)`. The builder threads `conditions_removed`
471/// into `fully_applied = !conditions_removed` (spec `08` §5.4) and feeds `stripped`
472/// to the in-memory predicate / SQL lowering.
473///
474/// The rules (ported verbatim):
475/// - `None` ⇒ `(None, false)`; a bare `Simple` ⇒ `(Some(clone), false)`.
476/// - a `CorrelatedSubquery` condition ⇒ `(None, true)` (removed).
477/// - `and`/`or` recurse each branch; **a vanished branch under an `or` collapses
478/// the whole `or`** to `(None, true)` (`filter.ts:191`) — the surviving branches
479/// would otherwise admit rows the original rejected.
480///
481/// **Post-strip simplify, intentionally skipped here:** the JS wraps the rebuilt tree
482/// in `simplifyCondition` (flatten / singleton-unwrap / constant-fold). The structural
483/// flatten now runs in [`normalize_pipeline_ast`] on the *whole* `where` before this
484/// strip; re-flattening the stripped leaf tree would only collapse a single-branch
485/// `and`/`or` left behind by a removed subquery, which is downstream-equivalent (one
486/// `Filter` either way) and would churn the pushed-down SQL text the source-connection
487/// tests pin. So the stripped tree is returned un-unwrapped, by design.
488pub fn transform_filters(filters: Option<&Condition>) -> (Option<Condition>, bool) {
489 let Some(cond) = filters else {
490 return (None, false);
491 };
492 match cond {
493 Condition::Simple(_) => (Some(cond.clone()), false),
494 Condition::CorrelatedSubquery(_) => (None, true),
495 Condition::And { conditions } | Condition::Or { conditions } => {
496 let is_or = matches!(cond, Condition::Or { .. });
497 let mut transformed = Vec::with_capacity(conditions.len());
498 let mut removed = false;
499 for c in conditions {
500 let (t, r) = transform_filters(Some(c));
501 if t.is_none() && is_or {
502 // A removed OR branch collapses the whole OR (filter.ts:191).
503 return (None, true);
504 }
505 removed |= r;
506 if let Some(t) = t {
507 transformed.push(t);
508 }
509 }
510 let rebuilt = if is_or {
511 Condition::Or {
512 conditions: transformed,
513 }
514 } else {
515 Condition::And {
516 conditions: transformed,
517 }
518 };
519 (Some(rebuilt), removed)
520 }
521 }
522}
523
524// ---------------------------------------------------------------------------
525// create_predicate — one AST `SimpleCondition` → one `CompiledPredicate`
526// ---------------------------------------------------------------------------
527
528// The lowering error type moved to `rindle-value` (its payload is `Box<str>`/`&'static str`
529// — pure data, nothing engine-shaped), so a crate can name a build failure without linking
530// the engine. Re-exported here so `builder::BuildError` and `rindle::BuildError` are both
531// unchanged.
532pub use rindle_value::BuildError;
533
534/// Lower one AST [`SimpleCondition`] to a [`CompiledPredicate`] — the port of JS
535/// `createPredicate` for a single **leaf** (`filter.ts:27`). AND/OR/NOT are *not*
536/// here: they are realized in the Filter sub-graph, so the `build_pipeline` spine
537/// walks the condition tree and calls this once per leaf.
538///
539/// Faithful to `createPredicate`'s shape and its three-valued-null rule:
540/// - **`IS` / `IS NOT`** (`filter.ts:64-72`, `createIsPredicate`): identity
541/// equality, including `null`. A `null` RHS takes the compact
542/// [`IsNull`](CompiledPredicate::IsNull) path; other RHS values lower to
543/// [`Is`](CompiledPredicate::Is).
544/// - **A `null` RHS on any other op** (`filter.ts:75`) folds to
545/// [`Const(false)`](CompiledPredicate::Const) — `col = null` never matches; that
546/// is `IS NULL`'s job. So a `Cmp`/`In`/`Like` is *never* built over a null
547/// literal, which is exactly the invariant [`CompiledPredicate::eval`] relies on.
548/// - **A literal LHS** (`filter.ts:80-86`) is row-independent and folds to a
549/// `Const`: the column-form predicate is built over a one-cell row and evaluated
550/// at build time, so the fold matches runtime semantics exactly. (The fluent
551/// builder only emits a column LHS; this exists for wire-AST faithfulness.)
552/// - **A column LHS** resolves the name to a [`ColId`] against `schema` (→
553/// [`BuildError::UnknownColumn`]) and builds the per-row predicate.
554///
555/// **Number coercion** (JS's single `number` vs the runtime `Int`/`Float` split):
556/// an integral, in-`i64`-range literal lowers to `Int`, else `Float`
557/// (`number_to_owned`). Since design 226 Stage B the exact comparators place
558/// `Int(5)` and `Float(5.0)` in ONE equivalence class, so the lit-vs-cell plane no
559/// longer decides equality below 2^53; the shared rule still matters because it
560/// fixes WHICH exact value a >2^53 literal denotes (`number_to_owned` truncates the
561/// f64's binary value — the wasm entry canonicalizes to the wire token first,
562/// `canonicalize_wire_number_lits`).
563///
564/// `LIKE` is case-sensitive and byte-level; `ILIKE` folds ASCII case only. Both
565/// recognize `\%`, `\_`, and `\\` escapes. `_` matches one byte, not one Unicode
566/// character. An ordering const-fold between different literal storage classes
567/// (including `Int` versus `Float`) returns [`BuildError::Invalid`].
568pub fn create_predicate(
569 cond: &SimpleCondition,
570 schema: &Schema,
571) -> Result<CompiledPredicate, BuildError> {
572 // RHS is always a literal in a `SimpleCondition` (the wire type excludes a
573 // column on the right); read it, erroring if a column slipped through.
574 let right = match &cond.right {
575 ValuePosition::Literal { value } => value,
576 ValuePosition::Column { .. } => {
577 return Err(BuildError::Invalid(
578 "right-hand side of a condition must be a literal",
579 ))
580 }
581 };
582
583 // IS / IS NOT first (matching `createPredicate`'s switch order): identity
584 // equality, including null.
585 if let Op::Is | Op::IsNot = cond.op {
586 let negated = matches!(cond.op, Op::IsNot);
587 let rhs = lit_to_scalar(right)?;
588 return match &cond.left {
589 ValuePosition::Column { name } => {
590 let col = col_id(schema, name)?;
591 if matches!(right, Lit::Null) {
592 Ok(CompiledPredicate::IsNull { col, negated })
593 } else {
594 Ok(CompiledPredicate::Is {
595 col,
596 value: rhs,
597 negated,
598 })
599 }
600 }
601 // `<lit> IS [NOT] <lit>` is constant (`filter.ts:67-70`).
602 ValuePosition::Literal { value } => {
603 let lhs = lit_to_scalar(value)?;
604 Ok(CompiledPredicate::Const(
605 values_identical(lhs.as_ref(), rhs.as_ref()) ^ negated,
606 ))
607 }
608 };
609 }
610
611 // Any other op with a null RHS is `UNKNOWN` for every row → drop
612 // (`filter.ts:75`). Folded here so a `Cmp`/`In`/`Like` never carries a null.
613 if matches!(right, Lit::Null) {
614 return Ok(CompiledPredicate::Const(false));
615 }
616
617 match &cond.left {
618 // Constant condition (literal LHS) — fold by evaluating the column-form
619 // predicate against a one-cell row (`filter.ts:80-86`).
620 ValuePosition::Literal { value } => {
621 // A null literal LHS is `false` for every non-IS op (`filter.ts:81`).
622 if matches!(value, Lit::Null) {
623 return Ok(CompiledPredicate::Const(false));
624 }
625 let lhs = lit_to_scalar(value)?;
626 guard_ordering_const(cond.op, &lhs, right)?;
627 let probe = lower_op(cond.op, 0, right)?;
628 Ok(CompiledPredicate::Const(probe.eval(&owned_row(vec![lhs]))))
629 }
630 ValuePosition::Column { name } => lower_op(cond.op, col_id(schema, name)?, right),
631 }
632}
633
634/// Resolve a column name to its [`ColId`], or [`BuildError::UnknownColumn`].
635fn col_id(schema: &Schema, name: &str) -> Result<ColId, BuildError> {
636 schema
637 .col_id(name)
638 .ok_or_else(|| BuildError::UnknownColumn(name.into()))
639}
640
641/// Build the per-row predicate for a **column** LHS (non-`IS` op, non-null RHS).
642fn lower_op(op: Op, col: ColId, right: &Lit) -> Result<CompiledPredicate, BuildError> {
643 let cmp = |o| -> Result<CompiledPredicate, BuildError> {
644 Ok(CompiledPredicate::Cmp {
645 col,
646 op: o,
647 value: lit_to_scalar(right)?,
648 })
649 };
650 match op {
651 Op::Eq => cmp(CmpOp::Eq),
652 Op::Ne => cmp(CmpOp::Ne),
653 Op::Lt => cmp(CmpOp::Lt),
654 Op::Le => cmp(CmpOp::Le),
655 Op::Gt => cmp(CmpOp::Gt),
656 Op::Ge => cmp(CmpOp::Ge),
657 Op::Like => like_pred(col, right, false),
658 Op::NotLike => like_pred(col, right, true),
659 Op::ILike => ilike_pred(col, right, false),
660 Op::NotILike => ilike_pred(col, right, true),
661 Op::In => in_pred(col, right, false),
662 Op::NotIn => in_pred(col, right, true),
663 Op::Is | Op::IsNot => unreachable!("IS / IS NOT handled before lower_op"),
664 }
665}
666
667/// `col LIKE pattern` / `col NOT LIKE pattern`. The pattern must be a string
668/// literal. The matcher is byte-level and case-sensitive, with escapes for `%`,
669/// `_`, and `\`. See [`create_predicate`] for collation limits.
670fn like_pred(col: ColId, right: &Lit, negated: bool) -> Result<CompiledPredicate, BuildError> {
671 match right {
672 Lit::Str(pat) => Ok(CompiledPredicate::Like {
673 col,
674 matcher: LikeMatcher::compile(pat.as_bytes()),
675 negated,
676 }),
677 _ => Err(BuildError::Invalid("LIKE pattern must be a string literal")),
678 }
679}
680
681/// `col ILIKE pattern` / `col NOT ILIKE pattern`. ASCII-case-insensitive variant
682/// of [`like_pred`].
683fn ilike_pred(col: ColId, right: &Lit, negated: bool) -> Result<CompiledPredicate, BuildError> {
684 match right {
685 Lit::Str(pat) => Ok(CompiledPredicate::Like {
686 col,
687 matcher: LikeMatcher::compile_case_insensitive(pat.as_bytes()),
688 negated,
689 }),
690 _ => Err(BuildError::Invalid(
691 "ILIKE pattern must be a string literal",
692 )),
693 }
694}
695
696/// `col IN (list)` / `col NOT IN (list)`. The RHS must be an array literal of
697/// scalars (each coerced with the number rule, like any other literal).
698fn in_pred(col: ColId, right: &Lit, negated: bool) -> Result<CompiledPredicate, BuildError> {
699 let Lit::Array(elems) = right else {
700 return Err(BuildError::Invalid("IN / NOT IN requires an array literal"));
701 };
702 let mut values = Vec::with_capacity(elems.len());
703 for e in elems {
704 values.push(lit_to_scalar(e)?);
705 }
706 Ok(CompiledPredicate::In {
707 col,
708 set: ValueSet::new(values),
709 negated,
710 })
711}
712
713/// A literal-LHS ordering const-fold (`5 < 6`) reaches [`compare_values`], which
714/// **panics** on a cross-type pair (as JS `compareValues` throws). Surface that as
715/// a build error rather than a panic. Only ordering ops are at risk; `=`/`!=`/`IN`/
716/// `LIKE` are total. Both operands are already known non-null here.
717///
718/// [`compare_values`]: crate::value::compare_values
719fn guard_ordering_const(op: Op, lhs: &OwnedValue, right: &Lit) -> Result<(), BuildError> {
720 if !matches!(op, Op::Lt | Op::Le | Op::Gt | Op::Ge) {
721 return Ok(());
722 }
723 let rhs = lit_to_scalar(right)?;
724 if same_class(lhs.as_ref(), rhs.as_ref()) {
725 Ok(())
726 } else {
727 Err(BuildError::Invalid(
728 "ordering comparison between mismatched literal types",
729 ))
730 }
731}
732
733/// True if two **non-null** values share a storage class (so [`compare_values`]
734/// won't panic on them). Used only to pre-screen ordering const-folds.
735///
736/// [`compare_values`]: crate::value::compare_values
737fn same_class(a: Value<'_>, b: Value<'_>) -> bool {
738 use Value::*;
739 matches!(
740 (a, b),
741 (Bool(_), Bool(_))
742 | (Int(_), Int(_))
743 | (Float(_), Float(_))
744 | (Str(_), Str(_))
745 | (Json(_), Json(_))
746 )
747}
748
749/// Lower an AST scalar [`Lit`] to a runtime [`OwnedValue`](crate::value::OwnedValue). `pub(crate)` because
750/// the row loader must use the SAME number coercion (see [`create_predicate`]). An
751/// array is not a scalar — callers handle `IN`/`NOT IN` lists element-by-element.
752pub(crate) fn lit_to_scalar(lit: &Lit) -> Result<OwnedValue, BuildError> {
753 Ok(match lit {
754 Lit::Null => OwnedValue::Null,
755 Lit::Bool(b) => OwnedValue::Bool(*b),
756 Lit::Int(i) => OwnedValue::Int(*i),
757 Lit::Number(n) => number_to_owned(*n),
758 Lit::Str(s) => OwnedValue::str(s),
759 Lit::Array(_) => {
760 return Err(BuildError::Invalid(
761 "expected a scalar literal, found an array",
762 ))
763 }
764 })
765}
766
767/// The number-coercion rule (`create_predicate` docs): an integral, in-`i64`-range
768/// `f64` → [`OwnedValue::Int`](crate::value::OwnedValue::Int), else [`OwnedValue::Float`](crate::value::OwnedValue::Float). The bound is `[-2^63,
769/// 2^63)` — `i64::MIN` is exact as `f64`, and `2^63` (`-(i64::MIN as f64)`) is the
770/// first value too large for `i64`, so `n as i64` never saturates-then-misclassifies.
771///
772/// `-0.0` note: `fract() == 0.0` admits `-0.0` → `Int(0)`, which sits in the
773/// `0`/`0.0` class while `float_int_class` deliberately EXCLUDES `-0.0` (§5.1's
774/// total order keeps `-0.0 < 0`). Consequence: an embedded literal `= -0.0` matches
775/// `Int(0)`/`Float(0.0)` cells but not `Float(-0.0)` cells, where SQLite says all
776/// three are equal — the §9 pinned `-0.0` oracle divergence. A JSON home can never
777/// send the literal (`JSON.stringify(-0)` is `"0"`).
778pub(crate) fn number_to_owned(n: f64) -> OwnedValue {
779 let lo = i64::MIN as f64; // -2^63, exact
780 let hi = -(i64::MIN as f64); // 2^63, one past i64::MAX
781 if n.fract() == 0.0 && n >= lo && n < hi {
782 OwnedValue::Int(n as i64)
783 } else {
784 OwnedValue::Float(n)
785 }
786}
787
788// ---------------------------------------------------------------------------
789// build_pipeline — lower an `Ast` into a wired arena `Graph` (spec 08)
790// ---------------------------------------------------------------------------
791
792/// Lower an [`Ast`] into a wired pipeline in `graph`, returning the **top**
793/// operator (the one a sink attaches to via [`Graph::set_sink_edge`](crate::graph::Graph::set_sink_edge)). The port of
794/// JS `buildPipeline`/`buildPipelineInternal` (`builder.ts:256`) for the **built
795/// operator subset**: a source connection carrying its pushed-down `where`
796/// ([`ConnectionFilters`]) plus a chain of parent-driven relationship joins.
797///
798/// `resolve` maps a table name to its already-created source [`NodeId`] and a clone
799/// of that source's [`Schema`] — the builder seeds no sources (the test harness /
800/// view layer owns them, mirroring JS `delegate.getSource`). A relationship child
801/// is resolved the same way.
802///
803/// **In scope:** `table`; `where` (AND/OR/leaves lowered to one connection
804/// [`RowPredicate`] via [`create_predicate`], the memory leaf's filter — no Filter
805/// operators are built because a subquery-free `where` is fully applied at the
806/// source, matching JS `fullyAppliedFilters`); `order_by` (PK-completed at lowering
807/// so the source's connect assertion holds); and `related` relationships —
808/// **sibling** (multiple relationships on one row, each a [`Join`](crate::graph)
809/// stacked on the prior) and **nested** (a relationship whose child has its own
810/// `related`, lowered to a Join feeding the parent join's child port). Joins carry
811/// a port-tagged [`OutEdge`] so they can feed each other
812/// ([`Graph::set_out_edge`](crate::graph::Graph::set_out_edge)).
813///
814/// `start` lowers to a [`Skip`](crate::op::Skip) and `limit` to a
815/// [`Take`](crate::op::Take) (after `start`, before `related`), each carried on a
816/// port-tagged edge so it can feed a relationship join.
817///
818/// EXISTS (`where` correlated subqueries) is fully lowered — flipped + non-flipped,
819/// nested, under `OR` (the union fan), and AND-within-AND (the `where` is structurally
820/// flattened by [`normalize_pipeline_ast`] first) — as is `related`, `limit`/`start`,
821/// and nested `Child` pushes. `select` determines the output projection through
822/// [`view_schema`]; required keys and query inputs remain available to the pipeline.
823///
824/// **Out of scope → [`BuildError::Unsupported`]:** flipped `NOT EXISTS`, an EXISTS
825/// subquery carrying `start` or nested `related`, and a bare EXISTS subquery alias
826/// colliding with a materialized `related` of the same name (a genuine one-slot-per-name
827/// limitation, not a normalization artifact — see `normalize_pipeline_ast` / WS05.4).
828pub fn build_pipeline(
829 graph: &mut Graph,
830 ast: &Ast,
831 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
832) -> Result<NodeId, BuildError> {
833 // Query compile latency (208.2): time the whole build, dropped on every return path.
834 let _timer = metric_timer!(query_build);
835 // Design 226 §8: the int64 sync-boundary gate, at the one chokepoint every IVM
836 // query passes (sync registration, one-shot daemon-client queries, and mutator
837 // `tx.query` alike). Runs on the raw AST — normalization restructures the
838 // condition tree and aliases but never changes which tables/columns a query
839 // touches — and before any node is allocated, so a refusal leaves no debris.
840 if let Err(err) = reject_int64_footprint(ast, resolve) {
841 metric_build_err!(&err);
842 return Err(err);
843 }
844 // A top-level aggregate (a bare `count(table)` feeding the View as a scalar/grouped
845 // row) is a different result shape than the §9 relationship aggregate (a reduce as a
846 // join child): the reduce feeds the View directly, optionally through a `HAVING`
847 // filter. `build_aggregate_pipeline` lowers it; everything else takes the row spine.
848 // Count build outcomes so a rejection *rate* (`rindle.build.errors{kind}` over
849 // `rindle.build.ok`) is computable; no-op when the `metrics` feature is off.
850 let result = if ast.aggregate.is_some() {
851 build_aggregate_pipeline(graph, ast, resolve)
852 } else {
853 build_pipeline_internal(graph, ast, None, false, None, resolve)
854 };
855 match result {
856 Ok(node) => {
857 metric_inc!(build_ok);
858 Ok(node)
859 }
860 Err(err) => {
861 metric_build_err!(&err);
862 Err(err)
863 }
864 }
865}
866
867/// The family builder's in-flight state (design 310 §4.2): the inputs the root frame
868/// reads (`params`, `bindings`) and the outputs it records as it lowers the spine.
869pub(crate) struct FamilyBuild<'a> {
870 params: &'a [Box<str>],
871 bindings: Rc<BindingSet>,
872 root_conn: Option<NodeId>,
873 root_take: Option<NodeId>,
874 param_cols: Vec<ColId>,
875 spine_tail: Option<NodeId>,
876 spine_joins: Vec<NodeId>,
877}
878
879/// Lower a **parameterized query family** (design 310 §4) into `graph`: one pipeline
880/// over the family's `stripped` template (`rindle-wire`'s `FamilyTemplate::stripped` —
881/// the number-canonicalized AST with its holed root-equality conjuncts removed) whose
882/// `params` columns are a partition dimension. Compared with [`build_pipeline`] over a
883/// concrete member, the compiled pipeline differs in exactly three places:
884///
885/// - the root connection's predicate is the residual `where` AND-ed with a **membership
886/// test** — *row's `params` tuple ∈ `bindings`* — evaluated through the shared
887/// [`BindingSet`] handle; membership is never lowered to SQL (§4.4), so every
888/// family-root fetch is constrained by construction;
889/// - the root `limit` compiles to a `Take` **partitioned by `params`** (per-binding
890/// top-N) that keeps drained-to-empty partitions (impl plan D4);
891/// - the parameter columns join the root connection's split-edit keys, so an edit that
892/// moves a row across partitions arrives as `Remove(old)` + `Add(new)`, each side
893/// membership-checked (§4.3).
894///
895/// Everything else — `related` / EXISTS subqueries, `start`, projection — builds from
896/// the stripped AST exactly as from a concrete one; no operator below the root learns
897/// about partitions (§4.2's disjointness argument). Drive the result with
898/// `Graph::bind_family_partition` / `unbind_family_partition` / `hydrate_family`.
899///
900/// Refused with a [`BuildError`]: an aggregate template (`aggregate` / `group_by` /
901/// `having`; the extraction already excludes them), no parameters, or a column bound
902/// twice. Anything the concrete member's build would refuse is refused here the same way.
903pub fn build_family_pipeline(
904 graph: &mut Graph,
905 stripped: &Ast,
906 params: &[Box<str>],
907 bindings: Rc<BindingSet>,
908 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
909) -> Result<FamilyPipeline, BuildError> {
910 let _timer = metric_timer!(query_build);
911 let result = build_family_pipeline_inner(graph, stripped, params, bindings, resolve);
912 match &result {
913 Ok(_) => {
914 metric_inc!(build_ok);
915 }
916 Err(err) => metric_build_err!(err),
917 }
918 result
919}
920
921fn build_family_pipeline_inner(
922 graph: &mut Graph,
923 stripped: &Ast,
924 params: &[Box<str>],
925 bindings: Rc<BindingSet>,
926 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
927) -> Result<FamilyPipeline, BuildError> {
928 if params.is_empty() {
929 return Err(BuildError::Invalid(
930 "a query family needs at least one parameter column",
931 ));
932 }
933 if params
934 .iter()
935 .enumerate()
936 .any(|(i, p)| params[..i].contains(p))
937 {
938 return Err(BuildError::Unsupported(
939 "a query family cannot bind the same column twice",
940 ));
941 }
942 if stripped.aggregate.is_some() || !stripped.group_by.is_empty() || stripped.having.is_some() {
943 return Err(BuildError::Unsupported(
944 "a query family cannot be an aggregate query (design 310 §3.2)",
945 ));
946 }
947 reject_int64_footprint(stripped, resolve)?;
948 let mut fb = FamilyBuild {
949 params,
950 bindings,
951 root_conn: None,
952 root_take: None,
953 param_cols: Vec::new(),
954 spine_tail: None,
955 spine_joins: Vec::new(),
956 };
957 let top =
958 build_pipeline_internal(graph, stripped, Some(params), false, Some(&mut fb), resolve)?;
959 Ok(FamilyPipeline {
960 top,
961 root_conn: fb
962 .root_conn
963 .expect("the family root frame connects the source"),
964 root_take: fb.root_take,
965 param_cols: fb.param_cols,
966 bindings: fb.bindings,
967 spine_tail: fb
968 .spine_tail
969 .expect("the family root frame records its spine tail"),
970 spine_joins: fb.spine_joins,
971 })
972}
973
974/// The family root connection's filters (design 310 §4.1): the residual `where`'s
975/// predicate AND-ed with the membership test, `sql_condition` untouched (residual
976/// only), and a push guard on the **first parameter column with EMPTY values** — the
977/// exact encoding of "a family with zero bindings receives no pushes" (the index
978/// registers it nowhere). `Graph::bind_family_partition` grows it one dynamic value per
979/// binding (the 205 extension), so a write to an unbound value never enters the
980/// family at all. Multi-column parameters guard on the first column only — 205's
981/// one-column limit — still a superset, still exact.
982fn family_root_filters(
983 base: Option<ConnectionFilters>,
984 fully_applied: bool,
985 bindings: Rc<BindingSet>,
986 param_cols: Vec<ColId>,
987) -> ConnectionFilters {
988 let push_guard = Some(PushGuard {
989 col: param_cols[0],
990 values: Vec::new(),
991 });
992 let membership: RowPredicate =
993 Rc::new(move |row: &OwnedRow| bindings.contains_row(row, ¶m_cols));
994 match base {
995 None => ConnectionFilters {
996 predicate: membership,
997 pk_constraint: None,
998 fully_applied,
999 sql_condition: None,
1000 push_guard,
1001 },
1002 Some(f) => {
1003 let base = f.predicate;
1004 ConnectionFilters {
1005 predicate: Rc::new(move |row: &OwnedRow| base(row) && membership(row)),
1006 pk_constraint: f.pk_constraint,
1007 fully_applied: f.fully_applied,
1008 sql_condition: f.sql_condition,
1009 push_guard,
1010 }
1011 }
1012 }
1013}
1014
1015/// Design 226 §8 — the whole-footprint `int64` gate (see
1016/// [`BuildError::Int64ColumnUnsupported`]). Walks every frame of the query (the
1017/// root, each `related` child, and each `where`-embedded EXISTS child,
1018/// recursively), collects the columns that frame requires of its own table, and
1019/// refuses if any is a declared [`ValueType::Int`] column.
1020///
1021/// A frame's required set: the projection (`select: None` ⇒ **every** column),
1022/// PK auto-inclusion, `order_by` keys, predicate operand columns, `group_by`
1023/// keys, a `sum`/`avg` input column, paging-bound (`start`) columns, and the
1024/// correlation fields of every attached subquery (parent side on this frame,
1025/// child side on the child frame). `having` addresses the reduce's *output*
1026/// columns, whose base inputs are already counted. Unknown tables and columns
1027/// are skipped here — their own lowering surfaces the right error downstream.
1028fn reject_int64_footprint(
1029 ast: &Ast,
1030 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
1031) -> Result<(), BuildError> {
1032 // Column names this frame's own `where` tree references (Simple operands and
1033 // EXISTS parent-side correlation fields); subquery frames are walked separately.
1034 fn cond_cols<'a>(cond: &'a Condition, out: &mut Vec<&'a str>) {
1035 match cond {
1036 Condition::Simple(s) => {
1037 if let ValuePosition::Column { name } = &s.left {
1038 out.push(name);
1039 }
1040 }
1041 Condition::And { conditions } | Condition::Or { conditions } => {
1042 for c in conditions {
1043 cond_cols(c, out);
1044 }
1045 }
1046 Condition::CorrelatedSubquery(csq) => {
1047 out.extend(csq.related.correlation.parent_field.iter().map(|f| &**f));
1048 }
1049 }
1050 }
1051
1052 fn check_frame(
1053 ast: &Ast,
1054 extra: &[Box<str>],
1055 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
1056 ) -> Result<(), BuildError> {
1057 if let Some((_, schema)) = resolve(&ast.table) {
1058 let refuse = |col: ColId| -> Result<(), BuildError> {
1059 Err(BuildError::Int64ColumnUnsupported {
1060 table: ast.table.clone(),
1061 column: schema.columns[col].clone(),
1062 })
1063 };
1064 match &ast.select {
1065 // No projection ⇒ the full row is in the footprint.
1066 None => {
1067 if let Some(c) = schema
1068 .column_types
1069 .iter()
1070 .position(|t| *t == ValueType::Int)
1071 {
1072 return refuse(c);
1073 }
1074 }
1075 Some(sel) => {
1076 let mut names: Vec<&str> = sel.iter().map(|s| &**s).collect();
1077 names.extend(extra.iter().map(|f| &**f));
1078 names.extend(ast.order_by.iter().map(|op| op.field()));
1079 names.extend(ast.group_by.iter().map(|g| &**g));
1080 match &ast.aggregate {
1081 Some(Aggregate::Sum(col)) | Some(Aggregate::Avg(col)) => names.push(col),
1082 _ => {}
1083 }
1084 if let Some(bound) = &ast.start {
1085 names.extend(bound.row.keys().map(|k| &**k));
1086 }
1087 if let Some(w) = &ast.r#where {
1088 cond_cols(w, &mut names);
1089 }
1090 for csq in &ast.related {
1091 names.extend(csq.correlation.parent_field.iter().map(|f| &**f));
1092 }
1093 for c in names.into_iter().filter_map(|n| schema.col_id(n)) {
1094 if schema.column_types.get(c) == Some(&ValueType::Int) {
1095 return refuse(c);
1096 }
1097 }
1098 // PK auto-inclusion: row identity/ordering always crosses the
1099 // boundary, projected or not (§2.1).
1100 for &pk in &schema.primary_key {
1101 if schema.column_types.get(pk) == Some(&ValueType::Int) {
1102 return refuse(pk);
1103 }
1104 }
1105 }
1106 }
1107 }
1108
1109 for csq in &ast.related {
1110 check_frame(&csq.subquery, &csq.correlation.child_field, resolve)?;
1111 }
1112 for csq in gather_csq_conditions(ast.r#where.as_ref()) {
1113 check_frame(
1114 &csq.related.subquery,
1115 &csq.related.correlation.child_field,
1116 resolve,
1117 )?;
1118 }
1119 Ok(())
1120 }
1121
1122 check_frame(ast, &[], resolve)
1123}
1124
1125/// Lower a **top-level aggregate** query (`ast.aggregate` set on the root) to
1126/// `source → reduce → [HAVING filter] → View` (`REDUCE-DESIGN.md` §4/§8). The reduce
1127/// is **eager** — the `View` hydrates with one unconstrained fetch that folds every
1128/// group — and is **global** when [`group_by`](Ast::group_by) is empty (one immortal
1129/// `[count]` row, §3) or **grouped** otherwise (one `[group…, count]` row per group,
1130/// born/dying with its count, §8).
1131///
1132/// `where` filters rows **below** the reduce (which rows are counted), pushed into the
1133/// source leaf exactly as for a non-aggregate query; [`having`](Ast::having) filters
1134/// groups **above** it. The grouped input connection split-edits on the group-by
1135/// columns, so a row changing its group arrives as Remove(old)+Add(new) before the
1136/// reduce (§8); the global case never splits (§7).
1137///
1138/// **v1 scope.** Rejects, on an aggregate root: `related` (the aggregate row has no
1139/// relationships), `order_by` / `limit` / `start` (ordering or limiting *groups* is
1140/// Tier 2 — ORDER BY agg + Take, §9), and a correlated subquery in either `where` or
1141/// `having`. The `having` predicate addresses the reduce's **output** columns
1142/// (`[group…, count]`), so it is built against the reduce's own [`Schema`].
1143fn build_aggregate_pipeline(
1144 graph: &mut Graph,
1145 ast: &Ast,
1146 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
1147) -> Result<NodeId, BuildError> {
1148 let ast = normalize_pipeline_ast(ast);
1149 if !ast.related.is_empty() {
1150 return Err(BuildError::Unsupported(
1151 "a top-level aggregate must not carry `related` (the aggregate row has no relationships)",
1152 ));
1153 }
1154 // The aggregate's output is the synthetic `[group…, count]` row, so a base-column
1155 // `select` projection has nothing to project — reject it rather than silently
1156 // dropping it (the row spine honours `select`; this path cannot).
1157 if ast.select.is_some() {
1158 return Err(BuildError::Unsupported(
1159 "`select` does not apply to a top-level aggregate (its output is [group…, count])",
1160 ));
1161 }
1162 // `.one()` (singular output) sets both `one` and `limit = 1`; check `one` first so
1163 // its own message wins over the limit guard below, and so a wire AST carrying
1164 // `one: true` without a `limit` is still rejected rather than silently ignored.
1165 if ast.one {
1166 return Err(BuildError::Unsupported(
1167 "`.one()` on a top-level aggregate is not yet supported \
1168 (a global count is already a single row; Tier 2)",
1169 ));
1170 }
1171 if !ast.order_by.is_empty() || ast.limit.is_some() || ast.start.is_some() {
1172 return Err(BuildError::Unsupported(
1173 "ordering/limiting/paging a top-level aggregate is not yet supported \
1174 (Tier 2: ORDER BY an aggregate + Take)",
1175 ));
1176 }
1177 if !gather_csq_conditions(ast.r#where.as_ref()).is_empty() {
1178 return Err(BuildError::Unsupported(
1179 "a correlated subquery in a top-level aggregate's `where` is not yet supported",
1180 ));
1181 }
1182
1183 let (source_id, source_schema) =
1184 resolve(&ast.table).ok_or_else(|| BuildError::UnknownTable(ast.table.clone()))?;
1185 let schema = source_schema.into_schema();
1186
1187 // Group-by columns in input (source) coordinates; empty ⇒ a global aggregate.
1188 let group_cols = resolve_cols(&ast.group_by, &schema)?;
1189 // The aggregate spec in source coordinates (a `Sum`/`Avg` column must exist).
1190 let spec = agg_spec_for(ast.aggregate.as_ref().expect("aggregate present"), &schema)?;
1191
1192 // The reduce folds regardless of input order, but the connection still needs a
1193 // valid ordering — the source's PK sort (`resolve_sort` of an empty `order_by`).
1194 let sort = resolve_sort(&[], &schema)?;
1195 // `where` is pushed into the source leaf (fully applied — no subqueries here, so the
1196 // `fully_applied` flag is always true and no Filter sub-graph is needed below).
1197 let (filters, _fully) = build_connection_filters(ast.r#where.as_ref(), None, &schema)?;
1198 // Split-edit on the group-by columns so a group-changing edit decomposes into
1199 // Remove(old)+Add(new) before the reduce (§8). Global (empty) never splits (§7).
1200 let conn = graph.connect(source_id, Some(sort), filters, group_cols.clone());
1201
1202 // The reduce: global (single row), or grouped **eager** (top-level GROUP BY).
1203 let storage = graph.alloc_storage();
1204 let reduce_op = if group_cols.is_empty() {
1205 crate::op::Reduce::global_agg(conn, storage, spec).with_input_types(&schema)
1206 } else {
1207 let key_cols: Vec<&str> = ast.group_by.iter().map(|c| &**c).collect();
1208 crate::op::Reduce::grouped_agg(conn, storage, group_cols, key_cols, spec)
1209 .with_input_types(&schema)
1210 };
1211 // The reduce's output schema (`[group…, count]`) is what a `having` filter and the
1212 // View resolve against; clone it before the op is moved into the graph.
1213 let reduce_schema = reduce_op.schema.clone();
1214 let reduce = graph.add_reduce(reduce_op);
1215 graph.set_conn_output(
1216 conn,
1217 OutEdge {
1218 node: reduce,
1219 port: Port::Single,
1220 },
1221 );
1222
1223 // `having` → a Filter sub-graph ABOVE the reduce, predicating on its output columns.
1224 // The Filter edit-split (graph.rs `filter_chain_push`) turns a group crossing the
1225 // predicate threshold into an `Add`/`Remove`, maintaining `HAVING` incrementally.
1226 let end = match &ast.having {
1227 None => reduce,
1228 Some(having) => {
1229 if !gather_csq_conditions(Some(having)).is_empty() {
1230 return Err(BuildError::Unsupported(
1231 "a correlated subquery in `having` is not supported \
1232 (HAVING predicates over the aggregate's output columns)",
1233 ));
1234 }
1235 build_filter_pipeline(graph, reduce, having, &reduce_schema)?
1236 }
1237 };
1238 Ok(end)
1239}
1240
1241/// The recursive spine. `partition_key` is the correlation **child** field names
1242/// when this AST is a relationship child (it seeds `split_edit_keys`, mirroring JS
1243/// `buildPipelineInternal`'s `partitionKey`); `None` at the root. `is_exists_child`
1244/// is true when this AST is a non-flipped EXISTS subquery (`isNonFlippedExistsChild`)
1245/// — it makes the frame's `limit` lower to an unordered [`Cap`](crate::op::Cap)
1246/// instead of a [`Take`](crate::op::Take).
1247fn build_pipeline_internal(
1248 graph: &mut Graph,
1249 ast: &Ast,
1250 partition_key: Option<&[Box<str>]>,
1251 is_exists_child: bool,
1252 family: Option<&mut FamilyBuild<'_>>,
1253 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
1254) -> Result<NodeId, BuildError> {
1255 // `Some` only on the ROOT frame of a family build (design 310 §4): every recursive
1256 // child frame passes `None`. Threaded alongside `partition_key`, which the family
1257 // root sets to its parameter names — the same thing one level up (design §2).
1258 let mut family = family;
1259 let ast = normalize_pipeline_ast(ast);
1260 let (source_id, schema) =
1261 resolve(&ast.table).ok_or_else(|| BuildError::UnknownTable(ast.table.clone()))?;
1262
1263 // Gather the non-flipped EXISTS/NOT EXISTS conditions of `where`: each becomes a
1264 // relationship Join (built below) + an `Exists` gate (in `apply_where_exists`).
1265 // Flipped NOT EXISTS needs an anti-join operator and is still rejected in the
1266 // flipped lowering path.
1267 let csq_conditions = gather_csq_conditions(ast.r#where.as_ref());
1268 // A `where` carrying a **flipped** EXISTS anywhere lowers its `applyWhere` through
1269 // `apply_where_with_flips` (the `applyFilterWithFlips` port, `builder.ts:414`): the
1270 // recursion builds the flipped `FlippedJoin`s + the non-flipped `Exists` gates (which
1271 // count the SPINE Joins, built unconditionally below, through the rel-preserving
1272 // broadcast). A flip-free `where` takes the spine filter / gate-chain path instead.
1273 let where_has_flip = ast
1274 .r#where
1275 .as_ref()
1276 .is_some_and(condition_has_flipped_subquery);
1277
1278 // EXISTS aliases that build a Join (everything except a `limit 0` subquery — a
1279 // constant-false gate, see `apply_where_exists`) must be distinct from each other
1280 // and from `related` aliases after JS-parity alias normalization: the slot model
1281 // attaches one relationship per slot. Reject a collision rather than mis-count it.
1282 //
1283 // WS05.4 verified this is a GENUINE one-slot-per-name limitation, NOT an artifact
1284 // of un-flattened nesting: `flatten_condition` cannot remove it (a bare top-level
1285 // EXISTS isn't uniquified, by JS-parity guard, so an EXISTS sharing a `related`
1286 // alias still clashes). Two EXISTS under a top-level and/or are uniquified to
1287 // distinct `_N` slots and never reach here; only a bare EXISTS vs a materialized
1288 // `related` of the same name does. Kept (narrowed), with a covering test.
1289 {
1290 let mut join_aliases: Vec<&str> = ast
1291 .related
1292 .iter()
1293 .filter_map(|c| c.subquery.alias.as_deref())
1294 .collect();
1295 for c in &csq_conditions {
1296 if c.related.subquery.limit == Some(0) {
1297 continue;
1298 }
1299 // A FLIPPED EXISTS over an aggregate — a `having_count` parent gate
1300 // (`PARENT-AGGREGATE-FILTER-DESIGN.md` §3) — is not lowerable: a flipped join is
1301 // child-driven and cannot re-derive the group's count, so it degrades the gate to a
1302 // plain existence test and silently DROPS the `HAVING` (`count > 1` starts admitting
1303 // parents with exactly one child). Neither producer reaches this — `Query` only ever
1304 // emits `flip: None`, and the planner's flippability rule excludes an aggregate
1305 // subquery — so this is the backstop for a hand-written or wire-supplied AST: fail
1306 // loudly rather than answer it wrongly.
1307 if c.flip == Some(true) && c.related.subquery.aggregate.is_some() {
1308 return Err(BuildError::Unsupported(
1309 "a flipped EXISTS over an aggregate (a `having_count` gate) is not \
1310 supported — a flipped join cannot re-derive the group's count",
1311 ));
1312 }
1313 let alias = c.related.subquery.alias.as_deref().unwrap_or("");
1314 if join_aliases.contains(&alias) {
1315 return Err(BuildError::Unsupported(
1316 "an EXISTS subquery alias collides with a materialized `related` of \
1317 the same name (one relationship per slot)",
1318 ));
1319 }
1320 join_aliases.push(alias);
1321 }
1322 }
1323
1324 // Decouple the **slot layout** from the source schema's declared relationships: the
1325 // slot tree is a pure function of THIS query (see [`query_local_slot_names`]), so a
1326 // production-shaped source schema (which declares only its real relationship names,
1327 // or none) builds a multi-EXISTS `where` whose uniquifier mints synthesized gate
1328 // aliases (`comments_0`, …) the source never declared. Every downstream `rel_slot`
1329 // resolution, the union fan's `add_empty_relationships` count (it clones this
1330 // schema), and [`view_schema`] all resolve against this one tree, so the Join
1331 // output tag, the `Exists` count, and the View materialization share identical
1332 // `RelId`s by construction. (Columns / PK / sort are untouched — only `relationships`
1333 // is replaced — so the column/PK-based helpers below are unaffected.)
1334 let schema = Schema {
1335 relationships: query_local_slot_names(&ast)
1336 .into_iter()
1337 .map(|name| RelDef {
1338 name,
1339 child: None,
1340 project: None,
1341 })
1342 .collect(),
1343 ..schema.into_schema()
1344 };
1345
1346 let sort = resolve_sort(&ast.order_by, &schema)?;
1347 let split_edit_keys =
1348 compute_split_edit_keys(partition_key, &ast.related, &csq_conditions, &schema)?;
1349 // A projected query (`select` set) gets a connection presence predicate over the
1350 // columns it structurally reads (§3.2–3.3); a `'*'` query gets `None` and is
1351 // unchanged (§7).
1352 let presence: Option<Vec<ColId>> = if ast.select.is_some() {
1353 Some(required_cols(&ast, &schema, &sort, &split_edit_keys)?)
1354 } else {
1355 None
1356 };
1357 let (filters, fully_applied) =
1358 build_connection_filters(ast.r#where.as_ref(), presence.as_deref(), &schema)?;
1359 // A family root (design 310 §4.1): the holed conjuncts are already gone from
1360 // `where` (the template is the stripped AST), so `filters` carries only the
1361 // residual predicate; AND the membership test onto it. `sql_condition` stays the
1362 // residual only — membership is never lowered to SQL (§4.4).
1363 let filters = match family.as_deref_mut() {
1364 None => filters,
1365 Some(fb) => {
1366 fb.param_cols = resolve_cols(fb.params, &schema)?;
1367 Some(family_root_filters(
1368 filters,
1369 fully_applied,
1370 fb.bindings.clone(),
1371 fb.param_cols.clone(),
1372 ))
1373 }
1374 };
1375 let conn = graph.connect(source_id, Some(sort.clone()), filters, split_edit_keys);
1376 if let Some(fb) = family.as_deref_mut() {
1377 graph.set_family_root(conn, fb.bindings.clone(), fb.param_cols.clone());
1378 fb.root_conn = Some(conn);
1379 }
1380
1381 // `start` → a `Skip` over the connection (JS `buildPipelineInternal:323`).
1382 let mut end = conn;
1383 if let Some(bound) = &ast.start {
1384 let skip = graph.add_skip(crate::op::Skip::new(end, lower_start(bound, &schema)?));
1385 graph.set_conn_output(
1386 conn,
1387 OutEdge {
1388 node: skip,
1389 port: Port::Single,
1390 },
1391 );
1392 end = skip;
1393 }
1394
1395 // Non-flipped EXISTS relationship joins on the **spine** (`builder.ts:329-350`,
1396 // before `applyWhere`) — built **regardless of whether `where` carries a flip**, the
1397 // JS layout. Each attaches the child relationship (limited to `EXISTS_LIMIT` via a
1398 // `Cap`) that the matching `Exists` gate counts; the gate may live in a fan-out
1399 // branch above (the rel-preserving union broadcast carries the relationship down to
1400 // it). A `limit 0` EXISTS builds no Join (constant-false `Filter` in the gate).
1401 for csq in &csq_conditions {
1402 if csq.flip == Some(true) || csq.related.subquery.limit == Some(0) {
1403 continue;
1404 }
1405 // A child-aggregate parent filter (`issue WHERE count(comments) > 10`,
1406 // `PARENT-AGGREGATE-FILTER-DESIGN.md`) is an EXISTS whose subquery carries a
1407 // relationship `aggregate`. It must fold the child **uncapped** (the count is the
1408 // thing being filtered), so it takes a dedicated lowering rather than
1409 // `apply_exists_join`'s EXISTS_LIMIT-capped child (design §4).
1410 end = if csq.related.subquery.aggregate.is_some() {
1411 apply_agg_exists_join(graph, end, csq, &schema, resolve)?
1412 } else {
1413 apply_exists_join(graph, end, csq, &schema, resolve)?
1414 };
1415 if let Some(fb) = family.as_deref_mut() {
1416 fb.spine_joins.push(end);
1417 }
1418 }
1419
1420 // `applyWhere` (`builder.ts:352`), dispatched by whether `where` carries a flip.
1421 if let Some(w) = &ast.r#where {
1422 if where_has_flip {
1423 // A flipped EXISTS anywhere → `applyFilterWithFlips` (`builder.ts:414`): the
1424 // flipped `FlippedJoin`s + the non-flipped `Exists` gates (which count the
1425 // spine Joins above, reached through the rel-preserving fan-out). Returns the
1426 // branch head edge the spine (connection/skip/Join) pushes the change into.
1427 let (head, tail) = apply_where_with_flips(graph, end, w, &schema, &sort, resolve)?;
1428 graph.set_out_edge(end, head);
1429 end = tail;
1430 } else if has_subquery_under_or(w) {
1431 // A non-flipped subquery under `OR` (or nested AND/OR). The source could not
1432 // push the disjunction (`transform_filters` collapses an `or` carrying a
1433 // subquery), so the **full** `where` tree is applied here as a filter
1434 // sub-graph: a `FanOut` of leaf `Filter`s / `Exists` gates, k-way-collapsed
1435 // by a `FanIn`. The non-flipped EXISTS rels the spine loop attached are read
1436 // by those gates.
1437 end = build_filter_pipeline(graph, end, w, &schema)?;
1438 } else if !fully_applied {
1439 // Pure AND of leaves + EXISTS: the source fully applied the leaves, so
1440 // `applyWhere` is only the non-flipped `Exists` gate chain.
1441 let non_flipped: Vec<&CorrelatedSubqueryCondition> = csq_conditions
1442 .iter()
1443 .copied()
1444 .filter(|c| c.flip != Some(true))
1445 .collect();
1446 if !non_flipped.is_empty() {
1447 end = apply_where_exists(graph, end, &non_flipped, &schema)?;
1448 }
1449 }
1450 }
1451
1452 // `limit` → a `Take` (root / `related` child) or a `Cap` (EXISTS child), after
1453 // `start`/`applyWhere`, before `related` (`builder.ts:356`). The `Cap` needs an
1454 // unordered source connect, but a flipped `where` builds a `UnionFanIn` (ordered
1455 // merge) — so a flipped-`where` EXISTS child falls back to the ordered `Take`,
1456 // mirroring JS `useCap` (`builder.ts:306-308`).
1457 if let Some(limit) = ast.limit {
1458 let use_cap = is_exists_child && !where_has_flip;
1459 // A family root's `Take` is partitioned by the parameter columns (per-binding
1460 // top-N, design §4.2) and keeps drained-to-empty partitions (impl plan D4).
1461 let retain_empty = family.is_some();
1462 end = lower_limit(
1463 graph,
1464 end,
1465 limit,
1466 partition_key,
1467 use_cap,
1468 retain_empty,
1469 &schema,
1470 &sort,
1471 )?;
1472 if let Some(fb) = family.as_deref_mut() {
1473 fb.root_take = Some(end);
1474 }
1475 }
1476 if let Some(fb) = family {
1477 fb.spine_tail = Some(end);
1478 }
1479
1480 // Chain one Join per `related` relationship, deduped by alias (last-writer-wins,
1481 // `builder.ts:385-393`).
1482 for csq in dedup_related_by_alias(&ast.related) {
1483 end = apply_related(graph, end, csq, &schema, false, resolve)?;
1484 }
1485 Ok(end)
1486}
1487
1488/// Stack one relationship [`Join`](crate::graph) on `parent_top` (the current
1489/// pipeline end — the connection or the previous join), mirroring JS
1490/// `applyCorrelatedSubQuery` (`builder.ts:650`). Builds the child sub-pipeline
1491/// (itself a bare connection, or a *nested* Join when the child has its own
1492/// `related`), resolves the correlation keys against each side's schema, wires the
1493/// parent→join (`JoinParent`) and child→join (`JoinChild`) edges with explicit
1494/// ports, and returns the Join as the new pipeline end.
1495fn apply_related(
1496 graph: &mut Graph,
1497 parent_top: NodeId,
1498 csq: &CorrelatedSubquery,
1499 schema: &Schema,
1500 is_exists_child: bool,
1501 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
1502) -> Result<NodeId, BuildError> {
1503 let rel_name = csq
1504 .subquery
1505 .alias
1506 .as_deref()
1507 .ok_or(BuildError::Invalid("a related subquery must have an alias"))?;
1508 // The relationship name resolves against the frame's **query-local** slot tree
1509 // (`build_pipeline_internal` installed it as `schema.relationships`), regardless of
1510 // how many sibling joins are already stacked on `parent_top`. The slot is resolved
1511 // HERE and handed to the graph, so the join is not re-resolved against the shared
1512 // source node's declared relationships.
1513 let rel_slot = schema
1514 .rel_slot(rel_name)
1515 .ok_or_else(|| BuildError::UnknownRelationship(rel_name.into()))?;
1516 let child_top = build_pipeline_internal(
1517 graph,
1518 &csq.subquery,
1519 Some(&csq.correlation.child_field),
1520 is_exists_child,
1521 None,
1522 resolve,
1523 )?;
1524
1525 let parent_key = resolve_cols(&csq.correlation.parent_field, schema)?;
1526 let (_, child_source) = resolve(&csq.subquery.table)
1527 .ok_or_else(|| BuildError::UnknownTable(csq.subquery.table.clone()))?;
1528 let child_schema = child_source.into_schema();
1529 // The correlation **child** columns in child-source coordinates (the reduce's
1530 // partition key, and the row-join's child key).
1531 let child_corr = resolve_cols(&csq.correlation.child_field, &child_schema)?;
1532 // The correlation key pair must be non-empty and equal-length (`ast.rs:249`;
1533 // JS `Join` asserts it). Otherwise `build_join_constraint` zips mismatched
1534 // keys and panics at push time — reject it here as a build error instead.
1535 if parent_key.is_empty() || parent_key.len() != child_corr.len() {
1536 return Err(BuildError::Invalid(
1537 "relationship correlation parent/child fields must be non-empty and equal-length",
1538 ));
1539 }
1540
1541 // The child of the join is one of three shapes:
1542 // - an ordinary materialized child (`None`);
1543 // - a **reduce-backed** aggregate (`count`/`sum`/`avg` of a child, §9): a grouped
1544 // lazy `reduce` partitioned by the correlation child key, whose group columns
1545 // (output positions `0..k`) become the join's child key (the reduce reshapes the
1546 // row);
1547 // - a **precomputed** aggregate (`AGGREGATE-SYNC-DESIGN.md` §3.3): the synthetic
1548 // `(group…, count)` rows already exist as a source table, so the child is joined
1549 // directly like an ordinary child and surfaced by the scalar projection
1550 // `view_schema` attaches — reducing it would recount the aggregated rows.
1551 let (join_child, child_key) = match &csq.subquery.aggregate {
1552 Some(agg) if !csq.subquery.aggregate_precomputed => {
1553 // An aggregate child reduces its rows away; nested `related` would be
1554 // materialized into a child the aggregate discards — reject it as confusing.
1555 if !csq.subquery.related.is_empty() {
1556 return Err(BuildError::Unsupported(
1557 "a relationship aggregate must not carry nested `related`",
1558 ));
1559 }
1560 // The aggregate column (Sum/Avg) resolves against the child source schema.
1561 let spec = agg_spec_for(agg, &child_schema)?;
1562 let storage = graph.alloc_storage();
1563 let key_cols: Vec<&str> = csq.correlation.child_field.iter().map(|c| &**c).collect();
1564 let reduce = graph.add_reduce(
1565 crate::op::Reduce::grouped_agg(
1566 child_top,
1567 storage,
1568 child_corr.clone(),
1569 key_cols,
1570 spec,
1571 )
1572 .lazy()
1573 .with_input_types(&child_schema),
1574 );
1575 // child rows → reduce (Single), reduce → join child.
1576 graph.set_out_edge(
1577 child_top,
1578 OutEdge {
1579 node: reduce,
1580 port: Port::Single,
1581 },
1582 );
1583 let group_key: Vec<ColId> = (0..child_corr.len()).collect();
1584 (reduce, group_key)
1585 }
1586 // Ordinary child OR a precomputed aggregate: join the child source directly on the
1587 // correlation child columns (resolved against that source's schema). For a
1588 // precomputed aggregate the source schema is `[group…, count]`, so `child_corr`
1589 // is the group columns and `view_schema` scalar-projects the trailing value.
1590 _ => (child_top, child_corr),
1591 };
1592
1593 let join = graph.add_join_slot(parent_top, join_child, parent_key, child_key, rel_slot);
1594 graph.set_out_edge(
1595 parent_top,
1596 OutEdge {
1597 node: join,
1598 port: Port::JoinParent,
1599 },
1600 );
1601 graph.set_out_edge(
1602 join_child,
1603 OutEdge {
1604 node: join,
1605 port: Port::JoinChild,
1606 },
1607 );
1608 Ok(join)
1609}
1610
1611/// Build the relationship [`Join`](crate::graph) for one EXISTS condition — the
1612/// analogue of the `csqConditions` loop's `applyCorrelatedSubQuery` with the
1613/// subquery limit forced to `EXISTS_LIMIT` and `isNonFlippedExistsChild = true`
1614/// (`builder.ts:329-350`). The child's `limit` therefore lowers to an unordered
1615/// [`Cap`](crate::op::Cap). EXISTS subqueries must not carry `start`/`related`.
1616fn apply_exists_join(
1617 graph: &mut Graph,
1618 parent_top: NodeId,
1619 csq: &CorrelatedSubqueryCondition,
1620 schema: &Schema,
1621 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
1622) -> Result<NodeId, BuildError> {
1623 if csq.related.subquery.start.is_some() {
1624 return Err(BuildError::Unsupported(
1625 "EXISTS subquery must not have `start`",
1626 ));
1627 }
1628 if !csq.related.subquery.related.is_empty() {
1629 return Err(BuildError::Unsupported(
1630 "EXISTS subquery must not have nested `related`",
1631 ));
1632 }
1633 let limit = match csq.related.system {
1634 Some(System::Permissions) => PERMISSIONS_EXISTS_LIMIT,
1635 _ => EXISTS_LIMIT,
1636 };
1637 let mut related = csq.related.clone();
1638 related.subquery.limit = Some(limit);
1639 apply_related(graph, parent_top, &related, schema, true, resolve)
1640}
1641
1642/// Lower a **child-aggregate parent filter** — `issue WHERE count(comments) <op> n`
1643/// (`PARENT-AGGREGATE-FILTER-DESIGN.md`). The synthesized EXISTS whose subquery carries a
1644/// relationship `aggregate` (branched in the spine loop on `aggregate.is_some()`): build
1645/// the correlated child **uncapped**, fold it with a **lazy grouped** `count_by` reduce
1646/// (one `[child_key…, count]` group per parent), drop the groups failing the `having`
1647/// predicate, and attach the survivor to the parent join's `rel_slot`. The matching
1648/// [`Exists`](crate::op::Exists) gate ([`apply_where_exists`]) then counts the ≤1
1649/// surviving group per parent and keeps the parent iff present.
1650///
1651/// **Why not [`apply_exists_join`].** That forces `limit = EXISTS_LIMIT` and builds the
1652/// child capped (`is_exists_child = true`), truncating the very rows the count folds — a
1653/// silent miscount (design §4). This path builds the child uncapped.
1654///
1655/// **v1 guard.** The `having` must be a **high-pass** count predicate (false at count 0):
1656/// a childless parent forms no group, so a count-0-satisfying predicate (`<= n`, `= 0`,
1657/// `>= 0`) would wrongly drop it (design §5). See [`reject_count_zero_satisfiable`].
1658fn apply_agg_exists_join(
1659 graph: &mut Graph,
1660 parent_top: NodeId,
1661 csq: &CorrelatedSubqueryCondition,
1662 schema: &Schema,
1663 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
1664) -> Result<NodeId, BuildError> {
1665 let sub = &csq.related.subquery;
1666 // v1 scope guards.
1667 if sub.aggregate != Some(Aggregate::Count) {
1668 return Err(BuildError::Unsupported(
1669 "only a `count` child aggregate can gate a parent (sum/avg parent filters deferred)",
1670 ));
1671 }
1672 if !matches!(csq.op, ExistsOp::Exists) {
1673 return Err(BuildError::Unsupported(
1674 "a child-aggregate parent filter must be EXISTS (NOT EXISTS over a count is deferred)",
1675 ));
1676 }
1677 if !sub.related.is_empty() {
1678 return Err(BuildError::Unsupported(
1679 "a `count` child-aggregate filter must not carry nested `related`",
1680 ));
1681 }
1682 if sub.start.is_some() || sub.limit.is_some() || !sub.order_by.is_empty() {
1683 return Err(BuildError::Unsupported(
1684 "a child-aggregate parent filter subquery must not page/order/limit the children \
1685 (the count folds all of them)",
1686 ));
1687 }
1688
1689 let rel_name = sub.alias.as_deref().ok_or(BuildError::Invalid(
1690 "an aggregate EXISTS subquery must have an alias",
1691 ))?;
1692 let rel_slot = schema
1693 .rel_slot(rel_name)
1694 .ok_or_else(|| BuildError::UnknownRelationship(rel_name.into()))?;
1695
1696 // The child is built UNCAPPED (`is_exists_child = false`): the count must fold every
1697 // child row, not the `EXISTS_LIMIT` cap `apply_exists_join` would impose (design §4).
1698 // The child `where` (e.g. `count(comments WHERE …)`) lowers below the reduce here.
1699 let child_top = build_pipeline_internal(
1700 graph,
1701 sub,
1702 Some(&csq.related.correlation.child_field),
1703 false,
1704 None,
1705 resolve,
1706 )?;
1707
1708 let parent_key = resolve_cols(&csq.related.correlation.parent_field, schema)?;
1709 let (_, child_schema) =
1710 resolve(&sub.table).ok_or_else(|| BuildError::UnknownTable(sub.table.clone()))?;
1711 let child_schema = child_schema.into_schema();
1712 let child_corr = resolve_cols(&csq.related.correlation.child_field, &child_schema)?;
1713 if parent_key.is_empty() || parent_key.len() != child_corr.len() {
1714 return Err(BuildError::Invalid(
1715 "aggregate EXISTS correlation parent/child fields must be non-empty and equal-length",
1716 ));
1717 }
1718
1719 // The `[child_key…, count]` groups the `having` predicates over — one per parent — from
1720 // one of two places, exactly as [`apply_relationship_join`] chooses for the display
1721 // aggregate:
1722 //
1723 // - **reduce-backed** (the ordinary case): a lazy grouped `reduce` over the child rows,
1724 // so the parent join folds one group at a time on a constrained fetch (design §3).
1725 // - **precomputed** (`AGGREGATE-SYNC-DESIGN.md` §3.3): the synthetic `(group…, count)`
1726 // rows ARE that output — the server reduced them and shipped them as a base table, one
1727 // row per group — so the source feeds the gate directly.
1728 //
1729 // Reducing a precomputed source would count ROWS PER GROUP, which is always exactly 1,
1730 // discarding the shipped count. `HAVING count > n` would then reject every group for any
1731 // `n >= 1` and the query would render empty. That is not hypothetical: the normalized
1732 // client rewrites BOTH the display aggregate and this gate onto the synthetic table
1733 // (`rewrite_aggregates`), so every `having_count` a synced client runs takes this path.
1734 // It escaped notice because the first shipped query used `having(count, ">", 0)`, where
1735 // a recount of 1 still passes and the gate degenerates to a bare EXISTS.
1736 let (gate_src, gate_schema) = if sub.aggregate_precomputed {
1737 (child_top, child_schema.clone())
1738 } else {
1739 let storage = graph.alloc_storage();
1740 let key_cols: Vec<&str> = csq
1741 .related
1742 .correlation
1743 .child_field
1744 .iter()
1745 .map(|c| &**c)
1746 .collect();
1747 let reduce_op =
1748 crate::op::Reduce::count_by(child_top, storage, child_corr.clone(), key_cols)
1749 .lazy()
1750 .with_input_types(&child_schema);
1751 let reduce_schema = reduce_op.schema.clone();
1752 let reduce = graph.add_reduce(reduce_op);
1753 graph.set_out_edge(
1754 child_top,
1755 OutEdge {
1756 node: reduce,
1757 port: Port::Single,
1758 },
1759 );
1760 (reduce, reduce_schema)
1761 };
1762
1763 // `having` → a Filter sub-graph ABOVE the groups, predicating on their `count` output.
1764 // The Filter edit-split turns a group crossing the predicate threshold into an
1765 // `Add`/`Remove`, so the gate is maintained incrementally for free (design §8). Absent
1766 // `having` ⇒ "EXISTS any group" ⇒ `count ≥ 1` (high-pass), so no filter is needed.
1767 let gate_in = match &sub.having {
1768 None => gate_src,
1769 Some(having) => {
1770 if !gather_csq_conditions(Some(having)).is_empty() {
1771 return Err(BuildError::Unsupported(
1772 "a correlated subquery in a child-aggregate `having` is not supported",
1773 ));
1774 }
1775 reject_count_zero_satisfiable(having, &gate_schema)?;
1776 build_filter_pipeline(graph, gate_src, having, &gate_schema)?
1777 }
1778 };
1779
1780 // Attach the (HAVING-filtered) group to the parent's relationship slot. The HAVING
1781 // Filter is schema-preserving, so the group columns are still `0..k` — the join's child
1782 // key, matching `parent_key`. The `Exists` gate then counts this slot.
1783 let group_key: Vec<ColId> = (0..child_corr.len()).collect();
1784 let join = graph.add_join_slot(parent_top, gate_in, parent_key, group_key, rel_slot);
1785 graph.set_out_edge(
1786 parent_top,
1787 OutEdge {
1788 node: join,
1789 port: Port::JoinParent,
1790 },
1791 );
1792 graph.set_out_edge(
1793 gate_in,
1794 OutEdge {
1795 node: join,
1796 port: Port::JoinChild,
1797 },
1798 );
1799 Ok(join)
1800}
1801
1802/// Reject a child-aggregate `having` that is **satisfied at count 0** (design §5). A lazy
1803/// reduce never fabricates a count-0 group for a childless parent, so the `Exists` gate
1804/// cannot see "count = 0"; a predicate true at 0 (`<= n`, `= 0`, `>= 0`, `!= n` for `n>0`)
1805/// would silently drop such parents from the view. v1 therefore accepts only a **single
1806/// high-pass `count <op> n` comparison** — false at 0 — and rejects everything else (a
1807/// group-column `having`, a compound tree, a non-numeric RHS) as out of scope.
1808fn reject_count_zero_satisfiable(
1809 having: &Condition,
1810 reduce_schema: &Schema,
1811) -> Result<(), BuildError> {
1812 let Condition::Simple(sc) = having else {
1813 return Err(BuildError::Unsupported(
1814 "a child-aggregate `having` v1 supports only a single `count <op> n` comparison",
1815 ));
1816 };
1817 let ValuePosition::Column { name } = &sc.left else {
1818 return Err(BuildError::Unsupported(
1819 "a child-aggregate `having` must compare the `count` column on its left",
1820 ));
1821 };
1822 // It must address the reduce's trailing synthetic `count` column, not a group column
1823 // (group-column HAVING is deferred Tier 2).
1824 let count_id = reduce_schema.columns.len() - 1;
1825 if col_id(reduce_schema, name)? != count_id {
1826 return Err(BuildError::Unsupported(
1827 "a child-aggregate `having` v1 filters only on `count` (group-column HAVING deferred)",
1828 ));
1829 }
1830 // Both numeric spellings: a JSON integer literal parses as `Lit::Int` as of
1831 // design 226 Stage B (`having count > 5` must keep working). The f64 widening is
1832 // safe here — the threshold compares against a row COUNT, far below 2^53.
1833 let n = match &sc.right {
1834 ValuePosition::Literal {
1835 value: Lit::Number(n),
1836 } => *n,
1837 ValuePosition::Literal { value: Lit::Int(i) } => *i as f64,
1838 _ => {
1839 return Err(BuildError::Unsupported(
1840 "a child-aggregate `having` right-hand side must be a numeric literal",
1841 ))
1842 }
1843 };
1844 let true_at_zero =
1845 match sc.op {
1846 Op::Gt => 0.0 > n,
1847 Op::Ge => 0.0 >= n,
1848 Op::Lt => 0.0 < n,
1849 Op::Le => 0.0 <= n,
1850 Op::Eq => 0.0 == n,
1851 Op::Ne => (0.0 - n).abs() > f64::EPSILON,
1852 _ => return Err(BuildError::Unsupported(
1853 "a child-aggregate `having` op must be a numeric comparison (=, !=, <, <=, >, >=)",
1854 )),
1855 };
1856 if true_at_zero {
1857 return Err(BuildError::Unsupported(
1858 "a count-0-satisfying child-aggregate filter (e.g. `<= n`, `= 0`, `>= 0`) needs \
1859 row-widening; only high-pass predicates like `> n` are supported (design §5)",
1860 ));
1861 }
1862 Ok(())
1863}
1864
1865/// Build the [`FlippedJoin`](crate::op::FlippedJoin) for one **flipped** EXISTS
1866/// condition (`builder.ts:488-516`) — the child sub-pipeline (no `Cap`,
1867/// `isExistsChild=false`: it may carry its own `start`/`limit`/`related`), the
1868/// resolved keys, the `add_flipped_join`, and the **child** input edge — but **not**
1869/// the parent input edge. The flipped join is a child-driven inner join whose
1870/// inner-join drop *is* the EXISTS gate (no separate `Exists` operator). The caller
1871/// wires the parent: [`apply_where_with_flips`] returns `(parent_top → fj, fj)` so the
1872/// connection (AND spine) or the `UnionFanOut` broadcast (OR branch) reaches `fj` on
1873/// its `JoinParent` port.
1874fn build_flipped_join(
1875 graph: &mut Graph,
1876 parent_top: NodeId,
1877 csq: &CorrelatedSubqueryCondition,
1878 schema: &Schema,
1879 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
1880) -> Result<NodeId, BuildError> {
1881 let rel_name = csq
1882 .related
1883 .subquery
1884 .alias
1885 .as_deref()
1886 .ok_or(BuildError::Invalid(
1887 "a flipped EXISTS subquery must have an alias",
1888 ))?;
1889 let rel_slot = schema
1890 .rel_slot(rel_name)
1891 .ok_or_else(|| BuildError::UnknownRelationship(rel_name.into()))?;
1892
1893 // The flipped child is a normal sub-pipeline (NOT an exists-child → no `Cap`):
1894 // the FlippedJoin fetches all children first and its inner-join drop gates.
1895 let child_top = build_pipeline_internal(
1896 graph,
1897 &csq.related.subquery,
1898 Some(&csq.related.correlation.child_field),
1899 false,
1900 None,
1901 resolve,
1902 )?;
1903
1904 let parent_key = resolve_cols(&csq.related.correlation.parent_field, schema)?;
1905 let (_, child_schema) = resolve(&csq.related.subquery.table)
1906 .ok_or_else(|| BuildError::UnknownTable(csq.related.subquery.table.clone()))?;
1907 let child_key = resolve_cols(
1908 &csq.related.correlation.child_field,
1909 &child_schema.into_schema(),
1910 )?;
1911 if parent_key.is_empty() || parent_key.len() != child_key.len() {
1912 return Err(BuildError::Invalid(
1913 "flipped EXISTS correlation parent/child fields must be non-empty and equal-length",
1914 ));
1915 }
1916
1917 let fj = graph.add_flipped_join_slot(parent_top, child_top, parent_key, child_key, rel_slot);
1918 graph.set_out_edge(
1919 child_top,
1920 OutEdge {
1921 node: fj,
1922 port: Port::JoinChild,
1923 },
1924 );
1925 Ok(fj)
1926}
1927
1928/// `applyFilterWithFlips` (`builder.ts:414`): lower a `where` (sub)tree that carries a
1929/// **flipped** EXISTS at some level. Returns `(head_edge, tail)` — the upstream
1930/// (connection/skip, or a `UnionFanOut` broadcast, or a sibling join) pushes the source
1931/// change into `head_edge` (node + port), and `tail` is the node the downstream / a
1932/// fan-in consumes. The caller owns the `upstream → head_edge` wiring (it differs by
1933/// upstream: `set_out_edge` for a port-aware op, `set_output`/`set_union_fan` otherwise),
1934/// so this never wires its own input.
1935///
1936/// Three cases (a bare `Simple` never reaches here: it has no flip, so
1937/// [`condition_has_flipped_subquery`] is false and it is never partitioned into a
1938/// with-flip branch):
1939/// - **`Or`** → [`apply_flips_or`]: a [`UnionFanOut`](crate::op::UnionFanOut) with one
1940/// branch per OR condition (flipped → recurse, flip-free → a local filter branch).
1941/// - **`And`** → [`apply_flips_and`]: the conjunctive gate chain.
1942/// - **`CorrelatedSubquery`** (flipped) → a bare [`FlippedJoin`](crate::op::FlippedJoin).
1943fn apply_where_with_flips(
1944 graph: &mut Graph,
1945 input: NodeId,
1946 cond: &Condition,
1947 schema: &Schema,
1948 sort: &Sort,
1949 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
1950) -> Result<(OutEdge, NodeId), BuildError> {
1951 match cond {
1952 Condition::Or { conditions } => {
1953 apply_flips_or(graph, input, conditions, schema, sort, resolve)
1954 }
1955 Condition::And { conditions } => {
1956 apply_flips_and(graph, input, conditions, schema, sort, resolve)
1957 }
1958 Condition::CorrelatedSubquery(csq) => {
1959 debug_assert!(
1960 csq.flip == Some(true),
1961 "apply_where_with_flips on a non-flipped CSQ"
1962 );
1963 if matches!(csq.op, ExistsOp::NotExists) {
1964 return Err(BuildError::Unsupported("flipped NOT EXISTS is not lowered"));
1965 }
1966 let fj = build_flipped_join(graph, input, csq, schema, resolve)?;
1967 Ok((
1968 OutEdge {
1969 node: fj,
1970 port: Port::JoinParent,
1971 },
1972 fj,
1973 ))
1974 }
1975 // A `Simple` has no flip, so it is never a `withFlipped` branch.
1976 Condition::Simple(_) => unreachable!("apply_where_with_flips on a Simple (no flip)"),
1977 }
1978}
1979
1980/// The `or` case of `applyFilterWithFlips` (`builder.ts:448`): a node-level
1981/// [`UnionFanOut`](crate::op::UnionFanOut) over `parent_top`, collapsed by a
1982/// [`UnionFanIn`](crate::op::UnionFanIn). Partition the OR conditions (by
1983/// [`condition_has_flipped_subquery`]) into with-flip and flip-free, then build
1984/// **the JS branch set** (`builder.ts:459-483`):
1985/// - **branch 0** (if any flip-free condition) — ONE combined `withoutFlipped` branch:
1986/// `FilterStart → applyOr(withoutFlipped) → FilterEnd` over the fan-out. Its `Exists`
1987/// gates count the **spine** non-flipped EXISTS Joins (below the fan-out), reached
1988/// through the rel-preserving broadcast — no local Joins;
1989/// - **branch i** — one per with-flip condition → [`apply_where_with_flips`] (a
1990/// `FlippedJoin`, or a nested AND-chain / union fan) over the fan-out.
1991///
1992/// The combined-`withoutFlipped` branch comes **first**, so the fan-in's PK dedup keeps
1993/// its relationship attachment over a later flipped branch's (the JS `mergeFetches`
1994/// first-wins). Returns `(parent_top → ufo, ufi)`.
1995fn apply_flips_or(
1996 graph: &mut Graph,
1997 parent_top: NodeId,
1998 or_conditions: &[Condition],
1999 schema: &Schema,
2000 sort: &Sort,
2001 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
2002) -> Result<(OutEdge, NodeId), BuildError> {
2003 let mut with_flipped: Vec<&Condition> = Vec::new();
2004 let mut without_flipped: Vec<Condition> = Vec::new();
2005 for c in or_conditions {
2006 if condition_has_flipped_subquery(c) {
2007 with_flipped.push(c);
2008 } else {
2009 without_flipped.push(c.clone());
2010 }
2011 }
2012 debug_assert!(
2013 !with_flipped.is_empty(),
2014 "apply_flips_or with no flipped branch"
2015 );
2016
2017 let ufo = graph.add_union_fan_out(parent_top);
2018 let mut branch_edges: Vec<OutEdge> = Vec::new();
2019 let mut branch_tails: Vec<NodeId> = Vec::new();
2020 // The per-branch pushable constraint (parallel to `branch_tails`): the fan-in merges
2021 // it into each branch fetch so an `eq(pk)` branch SEEKS the shared connection rather
2022 // than full-scanning it (see [`pushable_constraint`] / `UnionFanIn::fetch_branch`).
2023 let mut branch_constraints: Vec<Constraint> = Vec::new();
2024
2025 // Branch 0: the combined `withoutFlipped` filter pipeline (FIRST → wins the merge).
2026 if !without_flipped.is_empty() {
2027 let or_cond = Condition::Or {
2028 conditions: without_flipped,
2029 };
2030 let (edge, tail) = build_filter_branch(graph, ufo, &or_cond, schema)?;
2031 branch_edges.push(edge);
2032 branch_tails.push(tail);
2033 branch_constraints.push(pushable_constraint(&or_cond, schema));
2034 }
2035
2036 // One branch per with-flip condition.
2037 for c in with_flipped {
2038 let (edge, tail) = apply_where_with_flips(graph, ufo, c, schema, sort, resolve)?;
2039 branch_edges.push(edge);
2040 branch_tails.push(tail);
2041 branch_constraints.push(pushable_constraint(c, schema));
2042 }
2043
2044 // The fan-in owns the merged schema — the root schema (its declared relationships
2045 // already cover the branches' slots) but carrying the connection's **resolved
2046 // `order_by` sort**, NOT the base table sort: every branch fetch streams in the
2047 // connection's order, so the k-way merge (and its PK adjacency dedup) must compare in
2048 // that same order (`union-fan-in.ts:34` reads the connection schema's resolved sort).
2049 // The resolved sort is PK-completed, so equal-PK rows stay adjacent.
2050 let fan_in_schema = Schema {
2051 sort: sort.clone(),
2052 ..schema.clone()
2053 };
2054 let ufi = graph.add_union_fan_in(ufo, branch_tails.clone(), branch_constraints, fan_in_schema);
2055 for tail in &branch_tails {
2056 graph.set_output(*tail, ufi);
2057 }
2058 graph.set_union_fan(ufo, branch_edges, ufi);
2059 Ok((
2060 OutEdge {
2061 node: ufo,
2062 port: Port::Single,
2063 },
2064 ufi,
2065 ))
2066}
2067
2068/// The `and` case of `applyFilterWithFlips` (`builder.ts:424`): the conjunctive chain
2069/// over `input`, laid out **exactly as the JS**. Partition (by
2070/// [`condition_has_flipped_subquery`]) into with-flip and flip-free conditions. After
2071/// [`flatten_condition`] a with-flip condition is normally a bare flipped EXISTS or an
2072/// `OR` carrying a flip; a residual same-op `AND` (from the flatten quirk on
2073/// linear 3+-deep nesting) is handled by recursion through [`apply_where_with_flips`],
2074/// just as the JS `for (const cond of withFlipped) end = applyFilterWithFlips(end, …)`
2075/// (`builder.ts:443-445`) recurses — no special case.
2076///
2077/// `input → [withoutFlipped filter pipeline] → withFlipped₀ → withFlipped₁ → … → tail`
2078///
2079/// The flip-free conjuncts form ONE filter pipeline at the **bottom** (its `Exists` gates
2080/// count the spine non-flipped EXISTS Joins built below `input`); each with-flip condition
2081/// is then stacked on top via [`apply_where_with_flips`] (a `FlippedJoin`, a nested
2082/// union fan, or a recursive AND chain). Gap B lets a `FilterEnd` / `UnionFanIn` tail
2083/// present a `JoinParent` port, so a `FlippedJoin` can sit directly over the filter
2084/// pipeline — no port-aware reordering.
2085fn apply_flips_and(
2086 graph: &mut Graph,
2087 input: NodeId,
2088 and_conditions: &[Condition],
2089 schema: &Schema,
2090 sort: &Sort,
2091 resolve: &impl Fn(&str) -> Option<(NodeId, SourceSchema)>,
2092) -> Result<(OutEdge, NodeId), BuildError> {
2093 let mut with_flipped: Vec<&Condition> = Vec::new();
2094 let mut without_flipped: Vec<Condition> = Vec::new();
2095 for c in and_conditions {
2096 if condition_has_flipped_subquery(c) {
2097 with_flipped.push(c);
2098 } else {
2099 without_flipped.push(c.clone());
2100 }
2101 }
2102 debug_assert!(
2103 !with_flipped.is_empty(),
2104 "apply_flips_and with no flipped branch"
2105 );
2106
2107 let mut head: Option<OutEdge> = None;
2108 let mut cur = input; // the running pipeline tail (port-capable: a spine op, a
2109 // `FilterEnd`, a `FlippedJoin`, or a nested `UnionFanIn`).
2110
2111 // BOTTOM: the flip-free conjuncts as one filter pipeline (`FilterStart → applyAnd →
2112 // FilterEnd`). Its `Exists` gates count the spine EXISTS Joins (built below `input`).
2113 if !without_flipped.is_empty() {
2114 let and_cond = Condition::And {
2115 conditions: without_flipped,
2116 };
2117 let (lf_head, lf_tail) = build_filter_branch(graph, input, &and_cond, schema)?;
2118 head = Some(lf_head);
2119 cur = lf_tail;
2120 }
2121
2122 // Each with-flip condition stacked on top (`builder.ts:443-445`).
2123 for c in &with_flipped {
2124 let (edge, tail) = apply_where_with_flips(graph, cur, c, schema, sort, resolve)?;
2125 match head {
2126 None => head = Some(edge), // first part → the overall head
2127 Some(_) => graph.set_out_edge(cur, edge), // wire cur → this head (Gap B tail OK)
2128 }
2129 cur = tail;
2130 }
2131
2132 Ok((head.expect("apply_flips_and head set"), cur))
2133}
2134
2135/// The **pushable constraint** of a `where` (sub)condition: the `col = literal`
2136/// equalities that every row the condition keeps must satisfy. Used by the OR union
2137/// fan ([`apply_flips_or`]) to give each branch a fetch constraint, so a branch like
2138/// `eq(pk)` **seeks** the shared source connection instead of full-scanning it (the OR
2139/// branches all fetch the *same* connection — without a per-branch constraint, a
2140/// `eq(pk) OR exists` query scans the whole table on the eq branch even though it
2141/// resolves to a single PK).
2142///
2143/// A constraint here is a *necessary* condition (every kept row satisfies it), so it
2144/// only narrows the fetch — the branch's own filter chain still applies the full
2145/// predicate, leaving results unchanged:
2146/// - `Simple(col = literal)` (non-null literal) → `[(col, value)]`; any other operator
2147/// or a literal/array LHS → empty (nothing seekable);
2148/// - `And[..]` → the **union** of the conjuncts' constraints (each is necessary),
2149/// skipping a later pair that repeats a column already taken (left wins — an
2150/// unsatisfiable AND still yields no rows via the predicate);
2151/// - `Or[..]` → the **intersection** across disjuncts (a pair is necessary only if
2152/// *every* disjunct forces the same `col = value`); a singleton `And`/`Or` thus
2153/// reduces to its one child, and any non-pushable disjunct empties the whole `Or`;
2154/// - `CorrelatedSubquery` → empty.
2155fn pushable_constraint(cond: &Condition, schema: &Schema) -> Constraint {
2156 match cond {
2157 Condition::Simple(sc) => simple_eq_constraint(sc, schema),
2158 Condition::And { conditions } => {
2159 let mut out: Constraint = Vec::new();
2160 for c in conditions {
2161 for (col, v) in pushable_constraint(c, schema) {
2162 if !out.iter().any(|(cc, _)| *cc == col) {
2163 out.push((col, v));
2164 }
2165 }
2166 }
2167 out
2168 }
2169 Condition::Or { conditions } => {
2170 let mut iter = conditions.iter();
2171 let Some(first) = iter.next() else {
2172 return Vec::new(); // empty OR is `Const(false)` — nothing to push
2173 };
2174 let mut acc = pushable_constraint(first, schema);
2175 for c in iter {
2176 if acc.is_empty() {
2177 break;
2178 }
2179 let next = pushable_constraint(c, schema);
2180 // Keep a pair only if this disjunct forces the SAME (col, value).
2181 acc.retain(|(col, v)| {
2182 next.iter()
2183 .any(|(c2, v2)| c2 == col && values_identical(v.as_ref(), v2.as_ref()))
2184 });
2185 }
2186 acc
2187 }
2188 Condition::CorrelatedSubquery(_) => Vec::new(),
2189 }
2190}
2191
2192/// The single seekable equality of a leaf condition, or empty. Only `col = <non-null
2193/// literal>` is pushable; the value is lowered with the SAME number coercion
2194/// ([`lit_to_scalar`]) the connection predicate and row loader use, so a constraint
2195/// value compares identical to the stored cell.
2196fn simple_eq_constraint(sc: &SimpleCondition, schema: &Schema) -> Constraint {
2197 if !matches!(sc.op, Op::Eq) {
2198 return Vec::new();
2199 }
2200 let ValuePosition::Column { name } = &sc.left else {
2201 return Vec::new();
2202 };
2203 let ValuePosition::Literal { value } = &sc.right else {
2204 return Vec::new();
2205 };
2206 if matches!(value, Lit::Null) {
2207 return Vec::new(); // `col = null` never matches (folds to `Const(false)`)
2208 }
2209 match (schema.col_id(name), lit_to_scalar(value).ok()) {
2210 (Some(col), Some(v)) => vec![(col, v)],
2211 _ => Vec::new(),
2212 }
2213}
2214
2215/// True if `cond` contains a flipped (`flip == Some(true)`) correlated subquery at any
2216/// level — the `conditionIncludesFlippedSubqueryAtAnyLevel` classifier
2217/// (`builder.ts:811`).
2218fn condition_has_flipped_subquery(cond: &Condition) -> bool {
2219 match cond {
2220 Condition::CorrelatedSubquery(csq) => csq.flip == Some(true),
2221 Condition::Simple(_) => false,
2222 Condition::And { conditions } | Condition::Or { conditions } => {
2223 conditions.iter().any(condition_has_flipped_subquery)
2224 }
2225 }
2226}
2227
2228/// `buildFilterPipeline` (`filter-operators.ts:148`) for the **non-flipped**
2229/// `applyWhere` (`builder.ts:399`): bracket `input` in a `FilterStart … FilterEnd`
2230/// sub-graph whose chain is [`apply_filter`]`(where_tree)` — leaf `Filter`s,
2231/// [`Exists`](crate::op::Exists) gates, and `FanOut`/`FanIn` OR fans, mirroring the
2232/// JS Filter pipeline. The **full** `where` tree is applied: the source could not
2233/// push a disjunction carrying a subquery (`transform_filters` collapsed it), so this
2234/// is the only place that `OR` is evaluated; a leaf that the source *did* apply (a
2235/// pure-AND conjunct) is re-applied here as a harmless pass-through gate, matching the
2236/// JS. Returns the `FilterEnd` (the new pipeline `end`).
2237fn build_filter_pipeline(
2238 graph: &mut Graph,
2239 input: NodeId,
2240 where_tree: &Condition,
2241 schema: &Schema,
2242) -> Result<NodeId, BuildError> {
2243 let (head, tail) = build_filter_branch(graph, input, where_tree, schema)?;
2244 graph.set_out_edge(input, head);
2245 Ok(tail)
2246}
2247
2248/// Like [`build_filter_pipeline`] but **does not wire its own input** — it returns the
2249/// `FilterStart` head edge (always `Port::Single`) and the `FilterEnd` tail, leaving the
2250/// `input → head` wiring to the caller. Used for an `OR`/`AND`'s combined `withoutFlipped`
2251/// branch over a [`UnionFanOut`](crate::op::UnionFanOut) broadcast (the fan-out wires the
2252/// edge via [`Graph::set_union_fan`](crate::graph::Graph::set_union_fan)) and the `AND` chain's bottom filter pipeline.
2253fn build_filter_branch(
2254 graph: &mut Graph,
2255 input: NodeId,
2256 cond: &Condition,
2257 schema: &Schema,
2258) -> Result<(OutEdge, NodeId), BuildError> {
2259 let fs = graph.add_filter_start(input);
2260 let (head, tail) = apply_filter(graph, fs, cond, schema)?;
2261 let fe = graph.add_filter_end(fs);
2262 graph.set_chain_head(fs, head);
2263 graph.set_output(tail, fe);
2264 Ok((
2265 OutEdge {
2266 node: fs,
2267 port: Port::Single,
2268 },
2269 fe,
2270 ))
2271}
2272
2273/// `applyFilter` (`builder.ts:523`): dispatch one `where` condition into the filter
2274/// chain over `input`. Returns `(head, tail)` — `head` is the link the upstream wires
2275/// **into** (a `FilterStart`'s chain head, or a `FanOut` branch), `tail` is the link
2276/// whose downstream wires to the **next** chain link (a `FanIn` / `FilterEnd`). For a
2277/// single-link condition (a leaf `Filter` or an `Exists` gate) `head == tail`. (The
2278/// caller owns the upstream→`head` wiring, which differs by upstream kind:
2279/// `set_chain_head` / `set_fan` / `set_output`.)
2280fn apply_filter(
2281 graph: &mut Graph,
2282 input: NodeId,
2283 cond: &Condition,
2284 schema: &Schema,
2285) -> Result<(NodeId, NodeId), BuildError> {
2286 match cond {
2287 // `applySimpleCondition` (`builder.ts:625`): one leaf `Filter`.
2288 Condition::Simple(sc) => {
2289 let f = graph.add_filter(input, create_predicate(sc, schema)?);
2290 Ok((f, f))
2291 }
2292 Condition::CorrelatedSubquery(csq) => apply_csq_condition(graph, input, csq, schema),
2293 Condition::And { conditions } => apply_and(graph, input, conditions, schema),
2294 Condition::Or { conditions } => apply_or(graph, input, conditions, schema),
2295 }
2296}
2297
2298/// `applyAnd` (`builder.ts:541`): chain each sub-condition's filter **in series** —
2299/// the AND is the chain (every link must pass). Returns the first link's head and the
2300/// last link's tail. An empty `AND` is `true` (a `Const(true)` pass-through).
2301fn apply_and(
2302 graph: &mut Graph,
2303 input: NodeId,
2304 conditions: &[Condition],
2305 schema: &Schema,
2306) -> Result<(NodeId, NodeId), BuildError> {
2307 let mut iter = conditions.iter();
2308 let Some(first) = iter.next() else {
2309 let f = graph.add_filter(input, CompiledPredicate::Const(true));
2310 return Ok((f, f));
2311 };
2312 let (head, mut tail) = apply_filter(graph, input, first, schema)?;
2313 for c in iter {
2314 let (h, t) = apply_filter(graph, tail, c, schema)?;
2315 graph.set_output(tail, h); // wire the previous tail → this head
2316 tail = t;
2317 }
2318 Ok((head, tail))
2319}
2320
2321/// `applyOr` (`builder.ts:553`): a `FanOut` over `input`, **one branch per
2322/// condition**, k-way-collapsed by a `FanIn` — the disjunction (the `FanOut` ORs the
2323/// branches, `chain_filter` / `fan_out_push`). The JS groups the pure-predicate
2324/// branches into a single `Filter` over their `Or`; the flat [`CompiledPredicate`]
2325/// cannot hold an `OR`, so each branch fans separately instead — equivalent, since the
2326/// `FanOut` is the disjunction. Returns `(fan_out, fan_in)`. An empty `OR` is `false`.
2327fn apply_or(
2328 graph: &mut Graph,
2329 input: NodeId,
2330 conditions: &[Condition],
2331 schema: &Schema,
2332) -> Result<(NodeId, NodeId), BuildError> {
2333 if conditions.is_empty() {
2334 let f = graph.add_filter(input, CompiledPredicate::Const(false));
2335 return Ok((f, f));
2336 }
2337 let fan_out = graph.add_fan_out(input);
2338 let fan_in = graph.add_fan_in(fan_out);
2339 let mut branch_heads: Vec<NodeId> = Vec::with_capacity(conditions.len());
2340 for c in conditions {
2341 let (head, tail) = apply_filter(graph, fan_out, c, schema)?;
2342 graph.set_output(tail, fan_in); // each branch tail → the fan-in
2343 branch_heads.push(head);
2344 }
2345 graph.set_fan(fan_out, branch_heads, fan_in);
2346 Ok((fan_out, fan_in))
2347}
2348
2349/// `applyCorrelatedSubqueryCondition` (`builder.ts:689`) for a **non-flipped** EXISTS:
2350/// one [`Exists`](crate::op::Exists) gate counting the relationship its `Join` (built
2351/// in the `csq_conditions` loop) attached. An EXISTS over a `limit 0` (empty) subquery
2352/// is constant-false (`builder.ts:699`) — constant-true for `NOT EXISTS` — so it
2353/// becomes a `Const` `Filter`, no Join. Returns `(node, node)` (a single chain link).
2354/// A flipped subquery never reaches here.
2355fn apply_csq_condition(
2356 graph: &mut Graph,
2357 input: NodeId,
2358 csq: &CorrelatedSubqueryCondition,
2359 schema: &Schema,
2360) -> Result<(NodeId, NodeId), BuildError> {
2361 let node = if csq.related.subquery.limit == Some(0) {
2362 let pass = matches!(csq.op, ExistsOp::NotExists);
2363 graph.add_filter(input, CompiledPredicate::Const(pass))
2364 } else {
2365 let rel_name = csq
2366 .related
2367 .subquery
2368 .alias
2369 .as_deref()
2370 .ok_or(BuildError::Invalid("an EXISTS subquery must have an alias"))?;
2371 let rel_slot = schema
2372 .rel_slot(rel_name)
2373 .ok_or_else(|| BuildError::UnknownRelationship(rel_name.into()))?;
2374 let parent_key = resolve_cols(&csq.related.correlation.parent_field, schema)?;
2375 let not = matches!(csq.op, ExistsOp::NotExists);
2376 graph.add_exists(crate::op::Exists::new(
2377 input,
2378 rel_slot,
2379 parent_key,
2380 not,
2381 &schema.primary_key,
2382 ))
2383 };
2384 Ok((node, node))
2385}
2386
2387/// `applyWhere` for the EXISTS subset (`builder.ts:399`): bracket `end` in a
2388/// `FilterStart … FilterEnd` filter sub-graph whose chain is one
2389/// [`Exists`](crate::op::Exists) gate per gathered condition (in tree order). The
2390/// leaf filters are not re-applied here — the source already applied them fully — so
2391/// the chain is purely the EXISTS gates (AND-composed by chaining; OR-with-subquery
2392/// is rejected upstream). Returns the `FilterEnd` (the new pipeline `end`).
2393fn apply_where_exists(
2394 graph: &mut Graph,
2395 end: NodeId,
2396 csq_conditions: &[&CorrelatedSubqueryCondition],
2397 schema: &Schema,
2398) -> Result<NodeId, BuildError> {
2399 debug_assert!(
2400 !csq_conditions.is_empty(),
2401 "apply_where_exists with no EXISTS"
2402 );
2403 let fs = graph.add_filter_start(end);
2404 let fe = graph.add_filter_end(fs);
2405
2406 let mut chain: Vec<NodeId> = Vec::with_capacity(csq_conditions.len());
2407 let mut prev = fs;
2408 for csq in csq_conditions {
2409 let node = if csq.related.subquery.limit == Some(0) {
2410 // EXISTS over a `limit 0` (empty) subquery is constant false; NOT EXISTS
2411 // over the same empty subquery is constant true. No Join is built for
2412 // either case (`applyCorrelatedSubqueryCondition`'s `limit === 0`
2413 // short-circuit, `builder.ts:699-704`).
2414 let pass = matches!(csq.op, ExistsOp::NotExists);
2415 graph.add_filter(prev, CompiledPredicate::Const(pass))
2416 } else {
2417 let rel_name = csq
2418 .related
2419 .subquery
2420 .alias
2421 .as_deref()
2422 .ok_or(BuildError::Invalid("an EXISTS subquery must have an alias"))?;
2423 let rel_slot = schema
2424 .rel_slot(rel_name)
2425 .ok_or_else(|| BuildError::UnknownRelationship(rel_name.into()))?;
2426 let parent_key = resolve_cols(&csq.related.correlation.parent_field, schema)?;
2427 let not = matches!(csq.op, ExistsOp::NotExists);
2428 graph.add_exists(crate::op::Exists::new(
2429 prev,
2430 rel_slot,
2431 parent_key,
2432 not,
2433 &schema.primary_key,
2434 ))
2435 };
2436 chain.push(node);
2437 prev = node;
2438 }
2439
2440 // Wire FilterStart → Exists* → FilterEnd.
2441 graph.set_chain_head(fs, chain[0]);
2442 for (i, &ex) in chain.iter().enumerate() {
2443 let downstream = chain.get(i + 1).copied().unwrap_or(fe);
2444 graph.set_output(ex, downstream);
2445 }
2446 graph.set_out_edge(
2447 end,
2448 OutEdge {
2449 node: fs,
2450 port: Port::Single,
2451 },
2452 );
2453 Ok(fe)
2454}
2455
2456/// Gather every correlated-subquery condition in `where`, in tree order
2457/// (`gatherCorrelatedSubqueryQueryConditions`, `builder.ts:720`).
2458fn gather_csq_conditions(where_clause: Option<&Condition>) -> Vec<&CorrelatedSubqueryCondition> {
2459 fn go<'a>(cond: &'a Condition, out: &mut Vec<&'a CorrelatedSubqueryCondition>) {
2460 match cond {
2461 Condition::CorrelatedSubquery(c) => out.push(c),
2462 Condition::And { conditions } | Condition::Or { conditions } => {
2463 for c in conditions {
2464 go(c, out);
2465 }
2466 }
2467 Condition::Simple(_) => {}
2468 }
2469 }
2470 let mut out = Vec::new();
2471 if let Some(w) = where_clause {
2472 go(w, &mut out);
2473 }
2474 out
2475}
2476
2477/// The **query-local relationship slot layout** for one pipeline frame: the ordered,
2478/// de-duplicated relationship names this query references, in the order that backs each
2479/// [`RelId`](crate::value::RelId) (slot = list index). The slot layout is a pure function
2480/// of the (already **normalized**) query AST — *not* the source [`Schema`]'s declared
2481/// relationships, which the source cannot pre-declare for the synthesized EXISTS-gate
2482/// aliases (`comments_0`, …) the alias-uniquifier mints for a multi-EXISTS `where`.
2483///
2484/// Order (the convention the oracle-backed `diff_fixtures::collect_rel_aliases` test
2485/// harness already encodes):
2486/// 1. **materialized `related`** aliases first, in `ast.related` order (first-seen),
2487/// 2. then **EXISTS gating** aliases in `where`-tree pre-order (`gather_csq_conditions` —
2488/// the same order `uniquify_condition_aliases` assigns the `_N` suffixes), skipping a
2489/// `limit 0` subquery (a constant-false gate that builds **no** Join, so it claims no
2490/// slot).
2491///
2492/// De-duped by name across both passes. The result is the single source of truth shared
2493/// by the three slot consumers — the build path (`build_pipeline_internal` resolves
2494/// `RelId`s against it), the `View` shape ([`view_schema`]), and
2495/// the union fan's `add_empty_relationships` count — so they agree on `RelId`/order by
2496/// construction.
2497///
2498/// **Input must be normalized** ([`normalize_pipeline_ast`]); calling it on a raw AST
2499/// would read un-uniquified aliases and could double-uniquify on re-normalization.
2500pub fn query_local_slot_names(normalized: &Ast) -> Vec<Box<str>> {
2501 let mut names: Vec<Box<str>> = Vec::new();
2502 let push_unique = |names: &mut Vec<Box<str>>, alias: &str| {
2503 if !names.iter().any(|n| n.as_ref() == alias) {
2504 names.push(alias.into());
2505 }
2506 };
2507 // (1) materialized `related` (in-view), first-seen order.
2508 for csq in &normalized.related {
2509 if let Some(a) = csq.subquery.alias.as_deref() {
2510 push_unique(&mut names, a);
2511 }
2512 }
2513 // (2) EXISTS gating (out-of-view), where-tree pre-order; a `limit 0` gate builds no
2514 // Join, so it claims no slot.
2515 for c in gather_csq_conditions(normalized.r#where.as_ref()) {
2516 if c.related.subquery.limit == Some(0) {
2517 continue;
2518 }
2519 if let Some(a) = c.related.subquery.alias.as_deref() {
2520 push_unique(&mut names, a);
2521 }
2522 }
2523 names
2524}
2525
2526/// True if any correlated subquery sits under an `or` — the UnionFanOut path.
2527fn has_subquery_under_or(cond: &Condition) -> bool {
2528 fn go(cond: &Condition, under_or: bool) -> bool {
2529 match cond {
2530 Condition::CorrelatedSubquery(_) => under_or,
2531 Condition::Simple(_) => false,
2532 Condition::And { conditions } => conditions.iter().any(|c| go(c, under_or)),
2533 Condition::Or { conditions } => conditions.iter().any(|c| go(c, true)),
2534 }
2535 }
2536 go(cond, false)
2537}
2538
2539/// Dedup `related` by subquery alias, last-writer-wins (`builder.ts:385-388`).
2540fn dedup_related_by_alias(related: &[CorrelatedSubquery]) -> Vec<&CorrelatedSubquery> {
2541 let mut order: Vec<&str> = Vec::new();
2542 let mut chosen: Vec<&CorrelatedSubquery> = Vec::new();
2543 for csq in related {
2544 let alias = csq.subquery.alias.as_deref().unwrap_or("");
2545 match order.iter().position(|a| *a == alias) {
2546 Some(i) => chosen[i] = csq,
2547 None => {
2548 order.push(alias);
2549 chosen.push(csq);
2550 }
2551 }
2552 }
2553 chosen
2554}
2555
2556/// Lower `ast.limit` to a [`Take`](crate::op::Take) over `parent` (the current
2557/// pipeline end) — the analogue of JS `applyLimit`'s ordered branch
2558/// (`builder.ts:373`). `partition_key` (names) is this frame's correlation **child**
2559/// field when this AST is a limited relationship child, or `None` at the root; it
2560/// resolves to the Take's partition columns against `schema`. The Take carries the
2561/// connection's resolved `sort` (PK-completed) and a fresh [`Graph::alloc_storage`](crate::graph::Graph::alloc_storage)
2562/// slot, and forwards on a port-carrying [`OutEdge`] so a limited relationship can
2563/// feed a parent join's parent port.
2564#[allow(clippy::too_many_arguments)]
2565fn lower_limit(
2566 graph: &mut Graph,
2567 parent: NodeId,
2568 limit: u32,
2569 partition_key: Option<&[Box<str>]>,
2570 use_cap: bool,
2571 retain_empty_partitions: bool,
2572 schema: &Schema,
2573 sort: &Sort,
2574) -> Result<NodeId, BuildError> {
2575 let pk_cols = match partition_key {
2576 Some(names) => Some(resolve_cols(names, schema)?),
2577 None => None,
2578 };
2579 let storage = graph.alloc_storage();
2580 // An EXISTS child whose `where` carries no flip uses an unordered `Cap` (count-only,
2581 // PK-set membership, JS `useCap`); every other limit — and a flipped-`where` EXISTS
2582 // child, whose union-fan tail needs the ordered merge — uses an ordered `Take`.
2583 let node = if use_cap {
2584 graph.add_cap(crate::op::Cap::new(
2585 parent,
2586 storage,
2587 limit,
2588 pk_cols,
2589 schema.primary_key.clone(),
2590 ))
2591 } else {
2592 graph.add_take(
2593 crate::op::Take::new(parent, storage, limit, pk_cols, sort.clone())
2594 .with_retain_empty_partitions(retain_empty_partitions),
2595 )
2596 };
2597 // `wire_single`, not `set_out_edge`: a flipped-`where` lowering ends in a
2598 // `UnionFanIn`/`FilterEnd` tail (not port-aware), over which the `Take`/`Cap` sits.
2599 graph.wire_single(parent, node);
2600 Ok(node)
2601}
2602
2603/// Lower the AST `start` bound — a **name-keyed** partial row + `exclusive` flag —
2604/// into the positional [`Start`] the [`Skip`](crate::op::Skip) operator wants. Each
2605/// `(name, lit)` is placed at its [`ColId`]; unmentioned columns stay `Null` (the
2606/// Skip comparator only reads the sort columns, which a start bound carries).
2607/// `exclusive` ⇒ [`Basis::After`] (drop the bound row), else [`Basis::At`] (keep it).
2608fn lower_start(bound: &Bound, schema: &Schema) -> Result<Start, BuildError> {
2609 let mut row = vec![OwnedValue::Null; schema.columns.len()];
2610 for (name, lit) in &bound.row {
2611 row[col_id(schema, name)?] = lit_to_scalar(lit)?;
2612 }
2613 Ok(Start {
2614 row: owned_row(row),
2615 basis: if bound.exclusive {
2616 Basis::After
2617 } else {
2618 Basis::At
2619 },
2620 })
2621}
2622
2623/// Resolve `order_by` to a [`Sort`], appending every missing primary-key column
2624/// (asc) so the result includes the PK — `complete_ordering`'s invariant applied at
2625/// `ColId` level (which the source's connect asserts). Done per frame, so the whole
2626/// tree (root + each child) is completed.
2627pub(crate) fn resolve_sort(order_by: &[OrderPart], schema: &Schema) -> Result<Sort, BuildError> {
2628 let mut sort: Sort = Vec::with_capacity(order_by.len() + schema.primary_key.len());
2629 for op in order_by {
2630 sort.push((col_id(schema, op.field())?, matches!(op.dir(), Dir::Asc)));
2631 }
2632 for &pk in &schema.primary_key {
2633 if !sort.iter().any(|&(c, _)| c == pk) {
2634 sort.push((pk, true));
2635 }
2636 }
2637 Ok(sort)
2638}
2639
2640/// Resolve a list of column names to [`ColId`]s.
2641fn resolve_cols(names: &[Box<str>], schema: &Schema) -> Result<Vec<ColId>, BuildError> {
2642 names.iter().map(|n| col_id(schema, n)).collect()
2643}
2644
2645/// Assemble the connection's `split_edit_keys` (`builder.ts:275-292`):
2646/// `partition_key` (the correlation **child** field, when this AST is a
2647/// relationship child) plus every `related` correlation **parent** field. No PK
2648/// (matching JS). Names resolve against THIS source's schema; deduped.
2649fn compute_split_edit_keys(
2650 partition_key: Option<&[Box<str>]>,
2651 related: &[CorrelatedSubquery],
2652 csq_conditions: &[&CorrelatedSubqueryCondition],
2653 schema: &Schema,
2654) -> Result<Vec<ColId>, BuildError> {
2655 let mut cols: Vec<ColId> = Vec::new();
2656 let add = |cols: &mut Vec<ColId>, names: &[Box<str>]| -> Result<(), BuildError> {
2657 for n in names {
2658 let c = col_id(schema, n)?;
2659 if !cols.contains(&c) {
2660 cols.push(c);
2661 }
2662 }
2663 Ok(())
2664 };
2665 if let Some(pk) = partition_key {
2666 add(&mut cols, pk)?;
2667 }
2668 // The EXISTS conditions' parent fields (`builder.ts:280-285`)…
2669 for csq in csq_conditions {
2670 add(&mut cols, &csq.related.correlation.parent_field)?;
2671 }
2672 // …and the `related` parent fields (`builder.ts:287-291`).
2673 for csq in related {
2674 add(&mut cols, &csq.correlation.parent_field)?;
2675 }
2676 Ok(cols)
2677}
2678
2679/// The set of columns a built query structurally touches — the input to the
2680/// connection presence predicate (`PROJECTION-SUPPORT-DESIGN.md` §3.2). It is the
2681/// query's projection plus every column it reads to resolve a row:
2682/// `select` (or **all** columns when `None`) ∪ `where`-leaf columns ∪ the resolved
2683/// `sort` (order_by + completed PK) ∪ `start`-bound columns ∪ correlation parent
2684/// fields (already gathered as `split_edit_keys`). A partial union row missing any of
2685/// these is dropped from this query by the presence predicate (§3.3). A pure function
2686/// of the `Ast`, computed once at build time; deduped, output order is incidental.
2687fn required_cols(
2688 ast: &Ast,
2689 schema: &Schema,
2690 sort: &Sort,
2691 split_edit_keys: &[ColId],
2692) -> Result<Vec<ColId>, BuildError> {
2693 let mut cols: Vec<ColId> = Vec::new();
2694 // `select` (or every column when select-all).
2695 match &ast.select {
2696 Some(names) => {
2697 for n in names {
2698 push_unique(&mut cols, col_id(schema, n)?);
2699 }
2700 }
2701 None => {
2702 for c in 0..schema.columns.len() {
2703 push_unique(&mut cols, c);
2704 }
2705 }
2706 }
2707 // `where`-leaf columns (a column on either side of a `Simple`).
2708 if let Some(w) = &ast.r#where {
2709 where_leaf_columns(w, schema, &mut cols)?;
2710 }
2711 // Resolved sort (order_by + PK-completion).
2712 for &(c, _) in sort {
2713 push_unique(&mut cols, c);
2714 }
2715 // `start`-bound columns (the cursor's named cells; `b.row` is a positional list of
2716 // `(name, literal)`, not a map).
2717 if let Some(b) = &ast.start {
2718 for cell in &b.row {
2719 push_unique(&mut cols, col_id(schema, cell.0.as_ref())?);
2720 }
2721 }
2722 // Correlation parent fields a `related`/EXISTS child reads (already resolved).
2723 for &c in split_edit_keys {
2724 push_unique(&mut cols, c);
2725 }
2726 Ok(cols)
2727}
2728
2729/// Push `c` into `cols` iff absent (small set; linear scan is fine).
2730fn push_unique(cols: &mut Vec<ColId>, c: ColId) {
2731 if !cols.contains(&c) {
2732 cols.push(c);
2733 }
2734}
2735
2736/// Collect the leaf column [`ColId`]s referenced by a `where` tree (either side of a
2737/// `Simple` comparison; recursing `and`/`or`). A `CorrelatedSubquery`'s parent fields
2738/// are *not* gathered here — they arrive via `split_edit_keys` — so this stays a pure
2739/// scan of the leaf comparisons.
2740fn where_leaf_columns(
2741 cond: &Condition,
2742 schema: &Schema,
2743 out: &mut Vec<ColId>,
2744) -> Result<(), BuildError> {
2745 match cond {
2746 Condition::Simple(sc) => {
2747 for vp in [&sc.left, &sc.right] {
2748 if let ValuePosition::Column { name } = vp {
2749 push_unique(out, col_id(schema, name)?);
2750 }
2751 }
2752 Ok(())
2753 }
2754 Condition::And { conditions } | Condition::Or { conditions } => {
2755 for c in conditions {
2756 where_leaf_columns(c, schema, out)?;
2757 }
2758 Ok(())
2759 }
2760 Condition::CorrelatedSubquery(_) => Ok(()),
2761 }
2762}
2763
2764/// Deduplicate an owned-value set under **predicate identity**
2765/// ([`values_identical`]) — the same equality the guard's buckets key on, so a
2766/// literal repeated as `IN (1, 1)` or unioned across OR branches indexes once. O(n²)
2767/// over a tiny per-guard set.
2768fn dedup_guard_values(values: Vec<OwnedValue>) -> Vec<OwnedValue> {
2769 let mut out: Vec<OwnedValue> = Vec::with_capacity(values.len());
2770 for v in values {
2771 if !out.iter().any(|u| values_identical(u.as_ref(), v.as_ref())) {
2772 out.push(v);
2773 }
2774 }
2775 out
2776}
2777
2778/// Extract an equality [`PushGuard`] from a connection's **stripped** `where` tree —
2779/// a single-column finite implication `predicate(row) ⇒ row[col] ∈ values` used to
2780/// prune the source push fan-out (`designs/205-GUARDED-PUSH-FANOUT-DESIGN.md`).
2781///
2782/// **Fail closed:** any shape not listed yields `None` (→ the connection joins the
2783/// always-visited scan list, today's behavior). The guard is only ever a *weakening*
2784/// of the predicate, so `None` and any over-approximation are safe; the exact
2785/// predicate still runs on every candidate.
2786///
2787/// Extraction mirrors [`create_predicate`]'s lowering exactly — literals go through
2788/// [`lit_to_scalar`] (the same number coercion), so a guard value lands in the same
2789/// [`GuardKey`](crate::push_index) bucket the matching row cell will look up:
2790///
2791/// - `col = <non-null lit>` → `{lit}`. `col = NULL` folds to never-matching
2792/// (SQL `= NULL` is UNKNOWN, as [`create_predicate`] const-folds to false) →
2793/// **empty** values (indexed nowhere, exact).
2794/// - `col IS <lit>` (incl. `col IS NULL`, non-negated) → `{lit}` — the NULL-aware
2795/// identity the predicate uses.
2796/// - `col IN (l₁…lₖ)` (non-negated) → `{l₁…lₖ}`; empty `IN ()` → empty values.
2797/// - `And(children)` → the child guard with the **smallest** value set (ties → first);
2798/// a conjunction only narrows, so any one child's guard is a valid weakening. A
2799/// never-matching child (empty values) wins as smallest → the whole `And` indexes
2800/// nowhere, which is exact.
2801/// - `Or(children)` → a guard only if **every** child yields one; never-matching
2802/// children are dropped (a false disjunct cannot change the result), and the
2803/// survivors must guard the **same** column — `values` is their union. One
2804/// guard-less or different-column survivor ⇒ `None`.
2805/// - Everything else (`!=`/`IS NOT`/`NOT IN`, ordering, LIKE family, correlated
2806/// subqueries, a literal LHS, a column RHS) ⇒ `None`.
2807fn extract_push_guard(cond: &Condition, schema: &Schema) -> Option<PushGuard> {
2808 match cond {
2809 Condition::Simple(sc) => extract_simple_guard(sc, schema),
2810 Condition::And { conditions } => conditions
2811 .iter()
2812 .filter_map(|c| extract_push_guard(c, schema))
2813 .min_by_key(|g| g.values.len()),
2814 Condition::Or { conditions } => {
2815 // Every branch must be guardable; never-matching branches (empty values)
2816 // are dropped, and the rest must share one column.
2817 let mut col: Option<ColId> = None;
2818 let mut union: Vec<OwnedValue> = Vec::new();
2819 for c in conditions {
2820 let g = extract_push_guard(c, schema)?; // any None branch ⇒ no guard
2821 if g.values.is_empty() {
2822 continue; // false disjunct: cannot broaden the result
2823 }
2824 match col {
2825 None => col = Some(g.col),
2826 Some(existing) if existing != g.col => return None,
2827 Some(_) => {}
2828 }
2829 union.extend(g.values);
2830 }
2831 // All branches never-matching ⇒ the whole OR is never-matching. Emit an
2832 // empty guard (indexed nowhere) on any branch's column; `col` is unused
2833 // when values is empty, so fall back to column 0.
2834 Some(PushGuard {
2835 col: col.unwrap_or(0),
2836 values: dedup_guard_values(union),
2837 })
2838 }
2839 Condition::CorrelatedSubquery(_) => None,
2840 }
2841}
2842
2843/// The leaf case of [`extract_push_guard`]: a `col <op> lit` comparison.
2844fn extract_simple_guard(sc: &SimpleCondition, schema: &Schema) -> Option<PushGuard> {
2845 // Guard only a `Column <op> Literal` shape (the dominant `where col = ?`). A
2846 // literal LHS is a constant fold, a column RHS is invalid — neither guards.
2847 let ValuePosition::Column { name } = &sc.left else {
2848 return None;
2849 };
2850 let ValuePosition::Literal { value: rhs } = &sc.right else {
2851 return None;
2852 };
2853 let col = schema.col_id(name)?;
2854 let one = |v: OwnedValue| {
2855 Some(PushGuard {
2856 col,
2857 values: vec![v],
2858 })
2859 };
2860 match sc.op {
2861 // `col = NULL` is UNKNOWN for every row (`create_predicate` folds to false):
2862 // never-matching, so an empty guard (indexed nowhere) is exact.
2863 Op::Eq if matches!(rhs, Lit::Null) => Some(PushGuard {
2864 col,
2865 values: Vec::new(),
2866 }),
2867 // `col = lit` / `col IS lit` (incl. `IS NULL`): identity on one literal.
2868 Op::Eq | Op::Is => one(lit_to_scalar(rhs).ok()?),
2869 // `col IN (list)`: identity membership over the (deduped) scalar list.
2870 Op::In => {
2871 let Lit::Array(elems) = rhs else {
2872 return None;
2873 };
2874 let mut values = Vec::with_capacity(elems.len());
2875 for e in elems {
2876 values.push(lit_to_scalar(e).ok()?);
2877 }
2878 Some(PushGuard {
2879 col,
2880 values: dedup_guard_values(values),
2881 })
2882 }
2883 // Negated equality, ordering, and the LIKE family imply no finite value set.
2884 _ => None,
2885 }
2886}
2887
2888/// Build the connection's pushed-down filter from `where` (+ an optional projection
2889/// **presence** clause). Runs [`transform_filters`] (strip subqueries); for the built
2890/// subset nothing is removed, so `fully_applied = true` and no Filter operators are
2891/// needed above. A stripped subquery ⇒ [`BuildError::Unsupported`] (EXISTS not yet
2892/// built).
2893///
2894/// `presence` is `Some(required_cols)` **only for a projected query** (§3.3): a clause
2895/// requiring every column the query reads to be present is AND-ed into the predicate,
2896/// so a partial union row missing one is dropped (turned into an `Add`/`Remove` by
2897/// `filter_push` as the union widens/narrows). For a `'*'` query it is `None` and the
2898/// behavior is byte-identical to before (§7). The presence test is **client-only** —
2899/// it is never lowered to `sql_condition` (the server holds full rows and never
2900/// constructs `Absent`).
2901fn build_connection_filters(
2902 where_clause: Option<&Condition>,
2903 presence: Option<&[ColId]>,
2904 schema: &Schema,
2905) -> Result<(Option<ConnectionFilters>, bool), BuildError> {
2906 let (stripped, removed) = transform_filters(where_clause);
2907 // `fullyAppliedFilters` is false iff a subquery was stripped — the source then
2908 // applies only the surviving leaf tree, and the builder adds an `Exists` gate
2909 // for each stripped subquery (`builder.ts:321`,`:352`).
2910 let fully_applied = !removed;
2911 let presence_pred: Option<RowPredicate> = presence.map(|cols| {
2912 let cols = cols.to_vec();
2913 Rc::new(move |row: &OwnedRow| cols.iter().all(|&c| !row.col(c).is_absent())) as RowPredicate
2914 });
2915 match stripped {
2916 None => match presence_pred {
2917 None => Ok((None, fully_applied)),
2918 // A projected query with no `where`: a presence-only connection filter.
2919 // No `where` tree ⇒ no equality guard, so this connection lands in the
2920 // push index's always-visited scan list (`push_guard: None`).
2921 Some(pres) => Ok((
2922 Some(ConnectionFilters {
2923 predicate: pres,
2924 pk_constraint: None,
2925 fully_applied,
2926 sql_condition: None,
2927 push_guard: None,
2928 }),
2929 fully_applied,
2930 )),
2931 },
2932 Some(cond) => {
2933 let base = create_row_predicate(&cond, schema)?;
2934 // Guard derives from the *stripped* leaf tree — exactly what `predicate`
2935 // (below) evaluates — so `predicate ⇒ guard` holds; the presence AND-clause
2936 // only narrows further (`designs/205` §1 soundness).
2937 let push_guard = extract_push_guard(&cond, schema);
2938 let predicate: RowPredicate = match presence_pred {
2939 None => base,
2940 Some(pres) => Rc::new(move |row: &OwnedRow| pres(row) && base(row)),
2941 };
2942 Ok((
2943 Some(ConnectionFilters {
2944 predicate,
2945 // Hoisting the PK constraint from the filters is a deferred fetch
2946 // optimization (the predicate already filters correctly).
2947 pk_constraint: None,
2948 fully_applied,
2949 // SQL pushdown is the sqlite leaf's path; the memory leaf ignores this
2950 // and filters via `predicate`. Presence is intentionally NOT lowered.
2951 sql_condition: Some(create_sql_condition(&cond, schema)?),
2952 push_guard,
2953 }),
2954 fully_applied,
2955 ))
2956 }
2957 }
2958}
2959
2960/// Lower the same leaf-only condition tree used for [`create_row_predicate`] into
2961/// the backend-neutral SQL condition consumed by the SQLite `TableSource` (in the
2962/// `rindle-sqlite` crate). Column names are resolved to [`ColId`] once here; literal
2963/// values are bound later by the SQL query builder, never interpolated into SQL text.
2964fn create_sql_condition(cond: &Condition, schema: &Schema) -> Result<SqlCondition, BuildError> {
2965 match cond {
2966 Condition::Simple(sc) => create_simple_sql_condition(sc, schema),
2967 Condition::And { conditions } => Ok(SqlCondition::And(
2968 conditions
2969 .iter()
2970 .map(|c| create_sql_condition(c, schema))
2971 .collect::<Result<Vec<_>, _>>()?,
2972 )),
2973 Condition::Or { conditions } => Ok(SqlCondition::Or(
2974 conditions
2975 .iter()
2976 .map(|c| create_sql_condition(c, schema))
2977 .collect::<Result<Vec<_>, _>>()?,
2978 )),
2979 Condition::CorrelatedSubquery(_) => Err(BuildError::Unsupported(
2980 "correlated subquery in source SQL condition",
2981 )),
2982 }
2983}
2984
2985fn create_simple_sql_condition(
2986 cond: &SimpleCondition,
2987 schema: &Schema,
2988) -> Result<SqlCondition, BuildError> {
2989 Ok(SqlCondition::Simple {
2990 left: value_position_to_sql_operand(&cond.left, schema)?,
2991 op: sql_op(cond.op),
2992 right: match &cond.right {
2993 ValuePosition::Literal { .. } => value_position_to_sql_operand(&cond.right, schema)?,
2994 ValuePosition::Column { .. } => {
2995 return Err(BuildError::Invalid(
2996 "right-hand side of a condition must be a literal",
2997 ))
2998 }
2999 },
3000 })
3001}
3002
3003fn value_position_to_sql_operand(
3004 value: &ValuePosition,
3005 schema: &Schema,
3006) -> Result<Operand, BuildError> {
3007 match value {
3008 ValuePosition::Column { name } => Ok(Operand::Column(col_id(schema, name)?)),
3009 ValuePosition::Literal { value } => Ok(Operand::Literal(lit_to_sql_value(value)?)),
3010 }
3011}
3012
3013fn lit_to_sql_value(lit: &Lit) -> Result<OwnedValue, BuildError> {
3014 Ok(match lit {
3015 Lit::Array(_) => OwnedValue::Json(Arc::from(lit_to_json(lit))),
3016 _ => lit_to_scalar(lit)?,
3017 })
3018}
3019
3020fn sql_op(op: Op) -> SqlOp {
3021 match op {
3022 Op::Eq => SqlOp::Eq,
3023 Op::Ne => SqlOp::Ne,
3024 Op::Lt => SqlOp::Lt,
3025 Op::Le => SqlOp::Le,
3026 Op::Gt => SqlOp::Gt,
3027 Op::Ge => SqlOp::Ge,
3028 Op::Is => SqlOp::Is,
3029 Op::IsNot => SqlOp::IsNot,
3030 Op::In => SqlOp::In,
3031 Op::NotIn => SqlOp::NotIn,
3032 Op::Like => SqlOp::Like,
3033 Op::NotLike => SqlOp::NotLike,
3034 Op::ILike => SqlOp::Ilike,
3035 Op::NotILike => SqlOp::NotIlike,
3036 }
3037}
3038
3039fn lit_to_json(lit: &Lit) -> String {
3040 match lit {
3041 Lit::Null => "null".to_string(),
3042 Lit::Bool(b) => b.to_string(),
3043 Lit::Int(i) => i.to_string(),
3044 Lit::Number(n) => {
3045 if n.fract() == 0.0 {
3046 format!("{n:.0}")
3047 } else {
3048 n.to_string()
3049 }
3050 }
3051 Lit::Str(s) => json_quote(s),
3052 Lit::Array(values) => {
3053 let mut out = String::from("[");
3054 for (i, v) in values.iter().enumerate() {
3055 if i > 0 {
3056 out.push(',');
3057 }
3058 out.push_str(&lit_to_json(v));
3059 }
3060 out.push(']');
3061 out
3062 }
3063 }
3064}
3065
3066fn json_quote(s: &str) -> String {
3067 let mut out = String::with_capacity(s.len() + 2);
3068 out.push('"');
3069 for ch in s.chars() {
3070 match ch {
3071 '"' => out.push_str("\\\""),
3072 '\\' => out.push_str("\\\\"),
3073 '\n' => out.push_str("\\n"),
3074 '\r' => out.push_str("\\r"),
3075 '\t' => out.push_str("\\t"),
3076 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
3077 c => out.push(c),
3078 }
3079 }
3080 out.push('"');
3081 out
3082}
3083
3084/// Lower a leaf-only `where` condition tree to one [`RowPredicate`] — the
3085/// connection's in-memory filter. AND/OR fold to `all`/`any` over the child
3086/// predicates (an empty AND ⇒ `true`, empty OR ⇒ `false`); each leaf is one
3087/// [`create_predicate`]. This is the memory analogue of building the Filter
3088/// sub-graph: same gate, evaluated inside the source instead of as operators.
3089fn create_row_predicate(cond: &Condition, schema: &Schema) -> Result<RowPredicate, BuildError> {
3090 match cond {
3091 Condition::Simple(sc) => {
3092 let pred = create_predicate(sc, schema)?;
3093 Ok(Rc::new(move |row: &OwnedRow| pred.eval(row)))
3094 }
3095 Condition::And { conditions } => {
3096 let preds = conditions
3097 .iter()
3098 .map(|c| create_row_predicate(c, schema))
3099 .collect::<Result<Vec<_>, _>>()?;
3100 Ok(Rc::new(move |row: &OwnedRow| preds.iter().all(|p| p(row))))
3101 }
3102 Condition::Or { conditions } => {
3103 let preds = conditions
3104 .iter()
3105 .map(|c| create_row_predicate(c, schema))
3106 .collect::<Result<Vec<_>, _>>()?;
3107 Ok(Rc::new(move |row: &OwnedRow| preds.iter().any(|p| p(row))))
3108 }
3109 Condition::CorrelatedSubquery(_) => Err(BuildError::Unsupported(
3110 "correlated subquery in `where` (EXISTS not yet built)",
3111 )),
3112 }
3113}
3114
3115#[cfg(test)]
3116mod tests {
3117 use super::*;
3118 use crate::ast::{
3119 CorrelatedSubqueryCondition, ExistsOp, Lit, Op, SimpleCondition, ValuePosition,
3120 };
3121 use crate::query::table;
3122 use crate::value::{RelId, Schema, SourceSchema};
3123
3124 // --- complete_ordering ---------------------------------------------------
3125
3126 /// PK names keyed by table — a stub source registry for `get_pk`.
3127 fn pk_of(table: &str) -> Vec<Box<str>> {
3128 match table {
3129 "issue" => vec!["id".into()],
3130 "comment" => vec!["id".into()],
3131 "member" => vec!["org".into(), "id".into()], // compound PK
3132 other => panic!("no PK for table {other:?}"),
3133 }
3134 }
3135
3136 fn fields(ast: &Ast) -> Vec<(String, Dir)> {
3137 ast.order_by
3138 .iter()
3139 .map(|op| (op.field().to_string(), op.dir()))
3140 .collect()
3141 }
3142
3143 #[test]
3144 fn appends_missing_pk_as_asc() {
3145 let mut ast = table("issue").order_by("created", "desc").build();
3146 complete_ordering(&mut ast, &pk_of);
3147 assert_eq!(
3148 fields(&ast),
3149 vec![("created".into(), Dir::Desc), ("id".into(), Dir::Asc)]
3150 );
3151 }
3152
3153 #[test]
3154 fn empty_order_by_becomes_the_pk() {
3155 let mut ast = table("member").build();
3156 complete_ordering(&mut ast, &pk_of);
3157 assert_eq!(
3158 fields(&ast),
3159 vec![("org".into(), Dir::Asc), ("id".into(), Dir::Asc)]
3160 );
3161 }
3162
3163 #[test]
3164 fn present_pk_keeps_its_position_and_direction() {
3165 // `id` is the PK and is already in order_by (descending) — keep it as-is,
3166 // append nothing.
3167 let mut ast = table("issue").order_by("id", "desc").build();
3168 complete_ordering(&mut ast, &pk_of);
3169 assert_eq!(fields(&ast), vec![("id".into(), Dir::Desc)]);
3170 }
3171
3172 #[test]
3173 fn compound_pk_appends_only_the_missing_half_in_pk_order() {
3174 // PK = [org, id]; order_by already has `id` → append only `org`, but in PK
3175 // order it would come first; addPrimaryKeys appends missing ones AFTER the
3176 // existing order_by entries (it only ever appends).
3177 let mut ast = table("member").order_by("id", "asc").build();
3178 complete_ordering(&mut ast, &pk_of);
3179 assert_eq!(
3180 fields(&ast),
3181 vec![("id".into(), Dir::Asc), ("org".into(), Dir::Asc)]
3182 );
3183 }
3184
3185 #[test]
3186 fn completes_related_and_where_subqueries_whole_tree() {
3187 let mut ast = table("issue")
3188 .order_by("created", "desc")
3189 .sub_as("comments", |row| {
3190 table("comment").r#where("issueID", row.col("id"))
3191 })
3192 .where_exists(|row| table("comment").r#where("issueID", row.col("id")))
3193 .build();
3194 complete_ordering(&mut ast, &pk_of);
3195
3196 // root
3197 assert_eq!(
3198 fields(&ast),
3199 vec![("created".into(), Dir::Desc), ("id".into(), Dir::Asc)]
3200 );
3201 // related[0].subquery (comment) — PK appended
3202 assert_eq!(
3203 fields(&ast.related[0].subquery),
3204 vec![("id".into(), Dir::Asc)]
3205 );
3206 // where EXISTS subquery (comment) — PK appended
3207 match ast.r#where.as_ref().unwrap() {
3208 Condition::CorrelatedSubquery(c) => {
3209 assert_eq!(fields(&c.related.subquery), vec![("id".into(), Dir::Asc)]);
3210 }
3211 other => panic!("expected EXISTS, got {other:?}"),
3212 }
3213 }
3214
3215 // --- transform_filters ---------------------------------------------------
3216
3217 fn simple(field: &str) -> Condition {
3218 Condition::Simple(SimpleCondition {
3219 op: Op::Eq,
3220 left: ValuePosition::Column { name: field.into() },
3221 right: ValuePosition::Literal {
3222 value: Lit::Bool(true),
3223 },
3224 })
3225 }
3226
3227 fn exists() -> Condition {
3228 // A bare correlated-subquery condition (the subquery shape is irrelevant to
3229 // stripping); built directly since the fluent builder only emits it inside
3230 // a where.
3231 Condition::CorrelatedSubquery(CorrelatedSubqueryCondition {
3232 related: crate::ast::CorrelatedSubquery {
3233 correlation: crate::ast::Correlation {
3234 parent_field: vec!["id".into()],
3235 child_field: vec!["issueID".into()],
3236 },
3237 subquery: Box::new(Ast::new("comment")),
3238 system: None,
3239 },
3240 op: ExistsOp::Exists,
3241 flip: None,
3242 scalar: None,
3243 plan_id: None,
3244 })
3245 }
3246 fn exists_alias(alias: &str, table: &str) -> Condition {
3247 let mut cond = exists();
3248 if let Condition::CorrelatedSubquery(c) = &mut cond {
3249 c.related.subquery.table = table.into();
3250 c.related.subquery.alias = Some(alias.into());
3251 }
3252 cond
3253 }
3254 fn csq_alias(cond: &Condition) -> &str {
3255 match cond {
3256 Condition::CorrelatedSubquery(c) => c
3257 .related
3258 .subquery
3259 .alias
3260 .as_deref()
3261 .expect("normalized EXISTS has an alias"),
3262 other => panic!("expected EXISTS condition, got {other:?}"),
3263 }
3264 }
3265
3266 #[test]
3267 fn normalize_pipeline_ast_uniquifies_exists_aliases_under_top_level_and_or() {
3268 let ast = Ast {
3269 table: "issue".into(),
3270 r#where: Some(Condition::Or {
3271 conditions: vec![
3272 simple("a"),
3273 Condition::And {
3274 conditions: vec![
3275 exists_alias("comments", "comment"),
3276 exists_alias("labels", "label"),
3277 ],
3278 },
3279 ],
3280 }),
3281 ..Default::default()
3282 };
3283 let got = normalize_pipeline_ast(&ast);
3284 let Some(Condition::Or { conditions }) = got.r#where.as_ref() else {
3285 panic!("top-level OR preserved");
3286 };
3287 let Condition::And { conditions: nested } = &conditions[1] else {
3288 panic!("nested AND preserved");
3289 };
3290 assert_eq!(csq_alias(&nested[0]), "comments_0");
3291 assert_eq!(csq_alias(&nested[1]), "labels_1");
3292 }
3293
3294 #[test]
3295 fn normalize_pipeline_ast_leaves_bare_top_level_exists_alias_unchanged() {
3296 let ast = Ast {
3297 table: "issue".into(),
3298 r#where: Some(exists_alias("comments", "comment")),
3299 ..Default::default()
3300 };
3301 let got = normalize_pipeline_ast(&ast);
3302 assert_eq!(got, ast);
3303 }
3304
3305 // --- flatten_condition (the `flattened` subset of normalizeAST) -----------
3306
3307 fn and(conditions: Vec<Condition>) -> Condition {
3308 Condition::And { conditions }
3309 }
3310 fn or(conditions: Vec<Condition>) -> Condition {
3311 Condition::Or { conditions }
3312 }
3313
3314 #[test]
3315 fn flatten_leaf_is_identity() {
3316 assert_eq!(flatten_condition(&simple("a")), Some(simple("a")));
3317 assert_eq!(flatten_condition(&exists()), Some(exists()));
3318 }
3319
3320 #[test]
3321 fn flatten_splices_same_op_one_level() {
3322 // and[a, and[b, c]] → and[a, b, c]
3323 let cond = and(vec![simple("a"), and(vec![simple("b"), simple("c")])]);
3324 assert_eq!(
3325 flatten_condition(&cond),
3326 Some(and(vec![simple("a"), simple("b"), simple("c")]))
3327 );
3328 }
3329
3330 #[test]
3331 fn flatten_keeps_different_op_nested() {
3332 // and[a, or[b, c]] → and[a, or[b, c]] (or is a different op → stays nested)
3333 let cond = and(vec![simple("a"), or(vec![simple("b"), simple("c")])]);
3334 assert_eq!(flatten_condition(&cond), Some(cond));
3335 }
3336
3337 #[test]
3338 fn flatten_unwraps_singleton_regardless_of_operator() {
3339 // and[a] → a ; or[and[a]] → a (singleton unwrap at both levels)
3340 assert_eq!(
3341 flatten_condition(&and(vec![simple("a")])),
3342 Some(simple("a"))
3343 );
3344 assert_eq!(
3345 flatten_condition(&or(vec![and(vec![simple("a")])])),
3346 Some(simple("a"))
3347 );
3348 }
3349
3350 #[test]
3351 fn flatten_drops_empty_conjunctions() {
3352 assert_eq!(flatten_condition(&and(vec![])), None);
3353 // and[ or[] ] → an empty child is dropped → and[] → None.
3354 assert_eq!(flatten_condition(&and(vec![or(vec![])])), None);
3355 }
3356
3357 #[test]
3358 fn flatten_is_one_level_splice_not_a_fixpoint() {
3359 // The faithful JS quirk (ast.ts:564): the splice maps `flattened` over a
3360 // same-op child's *children*, so a same-op grandchild surfaced by that map is
3361 // NOT re-spliced. Linear 3-deep nesting flattens only one level:
3362 // and[a, and[b, and[c, d]]] → and[a, b, and[c, d]] (NOT fully flat).
3363 let cond = and(vec![
3364 simple("a"),
3365 and(vec![simple("b"), and(vec![simple("c"), simple("d")])]),
3366 ]);
3367 assert_eq!(
3368 flatten_condition(&cond),
3369 Some(and(vec![
3370 simple("a"),
3371 simple("b"),
3372 and(vec![simple("c"), simple("d")]),
3373 ]))
3374 );
3375 }
3376
3377 #[test]
3378 fn flatten_matches_the_spec_mixed_example() {
3379 // flatten: ((a AND b) AND (c AND (d OR (e OR f))))
3380 // → (a AND b AND c AND (d OR e OR f))
3381 let cond = and(vec![
3382 and(vec![simple("a"), simple("b")]),
3383 and(vec![
3384 simple("c"),
3385 or(vec![simple("d"), or(vec![simple("e"), simple("f")])]),
3386 ]),
3387 ]);
3388 assert_eq!(
3389 flatten_condition(&cond),
3390 Some(and(vec![
3391 simple("a"),
3392 simple("b"),
3393 simple("c"),
3394 or(vec![simple("d"), simple("e"), simple("f")]),
3395 ]))
3396 );
3397 }
3398
3399 #[test]
3400 fn normalize_flattens_and_within_and_before_uniquify() {
3401 // and[ simple(a), and[ EXISTS(comments), EXISTS(labels) ] ]
3402 // flatten → and[ simple(a), EXISTS(comments), EXISTS(labels) ]
3403 // uniquify (top-level AND) → comments_0, labels_1
3404 let ast = Ast {
3405 table: "issue".into(),
3406 r#where: Some(and(vec![
3407 simple("a"),
3408 and(vec![
3409 exists_alias("comments", "comment"),
3410 exists_alias("labels", "label"),
3411 ]),
3412 ])),
3413 ..Default::default()
3414 };
3415 let got = normalize_pipeline_ast(&ast);
3416 let Some(Condition::And { conditions }) = got.r#where.as_ref() else {
3417 panic!("flattened to a single top-level AND");
3418 };
3419 assert_eq!(conditions.len(), 3, "inner AND spliced inline");
3420 assert_eq!(csq_alias(&conditions[1]), "comments_0");
3421 assert_eq!(csq_alias(&conditions[2]), "labels_1");
3422 }
3423
3424 #[test]
3425 fn normalize_drops_a_fully_empty_where() {
3426 let ast = Ast {
3427 table: "issue".into(),
3428 r#where: Some(and(vec![])),
3429 ..Default::default()
3430 };
3431 assert_eq!(normalize_pipeline_ast(&ast).r#where, None);
3432 }
3433
3434 #[test]
3435 fn normalize_unwraps_singleton_and_then_leaves_the_bare_exists_alias() {
3436 // and[ EXISTS(comments) ] → (flatten) bare EXISTS → (guard) NOT uniquified
3437 // (alias stays `comments`). This matches the JS *fluent* path (which
3438 // simplifyCondition-unwraps the singleton before the builder), and is a
3439 // deliberate, row-invisible divergence from the JS builder on a *raw*
3440 // singleton AST (which would number it `comments_0`). See normalize_pipeline_ast.
3441 let ast = Ast {
3442 table: "issue".into(),
3443 r#where: Some(and(vec![exists_alias("comments", "comment")])),
3444 ..Default::default()
3445 };
3446 let got = normalize_pipeline_ast(&ast);
3447 assert_eq!(csq_alias(got.r#where.as_ref().unwrap()), "comments");
3448 }
3449
3450 #[test]
3451 fn none_and_simple_pass_through() {
3452 assert_eq!(transform_filters(None), (None, false));
3453 let (t, r) = transform_filters(Some(&simple("a")));
3454 assert_eq!((t, r), (Some(simple("a")), false));
3455 }
3456
3457 #[test]
3458 fn bare_subquery_is_removed() {
3459 let (t, r) = transform_filters(Some(&exists()));
3460 assert_eq!((t, r), (None, true));
3461 }
3462
3463 #[test]
3464 fn and_drops_subquery_branches_keeps_simples() {
3465 let cond = Condition::And {
3466 conditions: vec![simple("a"), exists(), simple("b")],
3467 };
3468 let (t, r) = transform_filters(Some(&cond));
3469 assert_eq!(
3470 (t, r),
3471 (
3472 Some(Condition::And {
3473 conditions: vec![simple("a"), simple("b")]
3474 }),
3475 true
3476 )
3477 );
3478 }
3479
3480 #[test]
3481 fn or_with_a_removed_branch_collapses_entirely() {
3482 // An OR branch that vanishes makes the whole OR unsound to keep.
3483 let cond = Condition::Or {
3484 conditions: vec![simple("a"), exists()],
3485 };
3486 assert_eq!(transform_filters(Some(&cond)), (None, true));
3487 }
3488
3489 #[test]
3490 fn or_of_simples_is_unchanged() {
3491 let cond = Condition::Or {
3492 conditions: vec![simple("a"), simple("b")],
3493 };
3494 let (t, r) = transform_filters(Some(&cond));
3495 assert_eq!((t, r), (Some(cond), false));
3496 }
3497
3498 #[test]
3499 fn nested_and_inside_and_strips_subqueries() {
3500 // and[ simple(a), and[ exists, simple(b) ] ] → and[ simple(a), and[ simple(b) ] ]
3501 let cond = Condition::And {
3502 conditions: vec![
3503 simple("a"),
3504 Condition::And {
3505 conditions: vec![exists(), simple("b")],
3506 },
3507 ],
3508 };
3509 let (t, r) = transform_filters(Some(&cond));
3510 assert_eq!(
3511 (t, r),
3512 (
3513 Some(Condition::And {
3514 conditions: vec![
3515 simple("a"),
3516 Condition::And {
3517 conditions: vec![simple("b")]
3518 }
3519 ]
3520 }),
3521 true
3522 )
3523 );
3524 }
3525
3526 // --- schema_primary_key_names --------------------------------------------
3527
3528 #[test]
3529 fn primary_key_names_from_schema() {
3530 // columns id=0, org=1, name=2; PK = [org, id] (ColIds 1, 0) in PK order.
3531 let schema = Schema::new(
3532 vec!["id", "org", "name"],
3533 vec![1, 0],
3534 vec![(1, true), (0, true)],
3535 );
3536 assert_eq!(
3537 schema_primary_key_names(&schema),
3538 vec!["org".into(), "id".into()] as Vec<Box<str>>
3539 );
3540 }
3541
3542 // --- create_predicate ----------------------------------------------------
3543
3544 /// columns: id=0 (Int PK), name=1 (Str), age=2 (Int).
3545 fn t_schema() -> Schema {
3546 Schema::new(vec!["id", "name", "age"], vec![0], vec![(0, true)])
3547 }
3548
3549 fn t_row(id: i64, name: &str, age: i64) -> crate::value::OwnedRow {
3550 owned_row(vec![
3551 OwnedValue::Int(id),
3552 OwnedValue::str(name),
3553 OwnedValue::Int(age),
3554 ])
3555 }
3556
3557 fn colp(name: &str) -> ValuePosition {
3558 ValuePosition::Column { name: name.into() }
3559 }
3560 fn litp(value: Lit) -> ValuePosition {
3561 ValuePosition::Literal { value }
3562 }
3563 fn sc(op: Op, left: ValuePosition, right: ValuePosition) -> SimpleCondition {
3564 SimpleCondition { op, left, right }
3565 }
3566
3567 // --- extract_push_guard (design 205 §Testing.1) --------------------------
3568
3569 fn gsimp(op: Op, col: &str, rhs: Lit) -> Condition {
3570 Condition::Simple(sc(op, colp(col), litp(rhs)))
3571 }
3572 /// Assert the guard's column and that its values equal `vals` as a set under
3573 /// predicate identity (union/dedup do not preserve a fixed order).
3574 fn assert_guard(g: Option<PushGuard>, col: ColId, vals: &[OwnedValue]) {
3575 let g = g.expect("expected a guard");
3576 assert_eq!(g.col, col, "guard column");
3577 assert_eq!(g.values.len(), vals.len(), "value count: {:?}", g.values);
3578 for want in vals {
3579 assert!(
3580 g.values
3581 .iter()
3582 .any(|got| values_identical(got.as_ref(), want.as_ref())),
3583 "missing guard value {want:?} in {:?}",
3584 g.values
3585 );
3586 }
3587 }
3588
3589 #[test]
3590 fn guard_eq_column_literal() {
3591 let s = t_schema();
3592 // age = 30 -> {2: [30]} (Number(30.0) coerces to Int(30), like the predicate)
3593 assert_guard(
3594 extract_push_guard(&gsimp(Op::Eq, "age", Lit::Number(30.0)), &s),
3595 2,
3596 &[OwnedValue::Int(30)],
3597 );
3598 }
3599
3600 #[test]
3601 fn guard_eq_null_is_never_matching() {
3602 let s = t_schema();
3603 // name = NULL folds to false -> empty guard (indexed nowhere).
3604 let g = extract_push_guard(&gsimp(Op::Eq, "name", Lit::Null), &s).unwrap();
3605 assert_eq!(g.col, 1);
3606 assert!(g.values.is_empty());
3607 }
3608
3609 #[test]
3610 fn guard_is_null_matches_null_cell() {
3611 let s = t_schema();
3612 // name IS NULL -> {1: [Null]} (NULL-aware identity, not never-matching).
3613 assert_guard(
3614 extract_push_guard(&gsimp(Op::Is, "name", Lit::Null), &s),
3615 1,
3616 &[OwnedValue::Null],
3617 );
3618 }
3619
3620 #[test]
3621 fn guard_is_value() {
3622 let s = t_schema();
3623 assert_guard(
3624 extract_push_guard(&gsimp(Op::Is, "name", Lit::Str("x".into())), &s),
3625 1,
3626 &[OwnedValue::str("x")],
3627 );
3628 }
3629
3630 #[test]
3631 fn guard_in_list_deduped() {
3632 let s = t_schema();
3633 // id IN (1, 2, 1) -> {0: [1, 2]} (dedup under predicate identity)
3634 let list = Lit::Array(vec![Lit::Number(1.0), Lit::Number(2.0), Lit::Number(1.0)]);
3635 assert_guard(
3636 extract_push_guard(&gsimp(Op::In, "id", list), &s),
3637 0,
3638 &[OwnedValue::Int(1), OwnedValue::Int(2)],
3639 );
3640 }
3641
3642 #[test]
3643 fn guard_in_int_float_dedup() {
3644 let s = t_schema();
3645 // IN (1, 1.0): the two literals are predicate-identical -> one bucket.
3646 let list = Lit::Array(vec![Lit::Number(1.0), Lit::Number(1.0)]);
3647 let g = extract_push_guard(&gsimp(Op::In, "id", list), &s).unwrap();
3648 assert_eq!(g.values.len(), 1);
3649 }
3650
3651 #[test]
3652 fn guard_empty_in_is_never_matching() {
3653 let s = t_schema();
3654 let g = extract_push_guard(&gsimp(Op::In, "id", Lit::Array(vec![])), &s).unwrap();
3655 assert!(g.values.is_empty());
3656 }
3657
3658 #[test]
3659 fn no_guard_for_unguardable_ops() {
3660 let s = t_schema();
3661 for cond in [
3662 gsimp(Op::Ne, "age", Lit::Number(1.0)),
3663 gsimp(Op::IsNot, "name", Lit::Null),
3664 gsimp(Op::Lt, "age", Lit::Number(1.0)),
3665 gsimp(Op::Ge, "age", Lit::Number(1.0)),
3666 gsimp(Op::Like, "name", Lit::Str("a%".into())),
3667 gsimp(Op::ILike, "name", Lit::Str("a%".into())),
3668 gsimp(Op::NotIn, "id", Lit::Array(vec![Lit::Number(1.0)])),
3669 ] {
3670 assert!(
3671 extract_push_guard(&cond, &s).is_none(),
3672 "expected None for {cond:?}"
3673 );
3674 }
3675 }
3676
3677 #[test]
3678 fn no_guard_for_literal_lhs_or_column_rhs() {
3679 let s = t_schema();
3680 // literal = literal (constant fold) -> None
3681 let lit_lhs = Condition::Simple(sc(Op::Eq, litp(Lit::Number(1.0)), litp(Lit::Number(1.0))));
3682 assert!(extract_push_guard(&lit_lhs, &s).is_none());
3683 // column = column -> None
3684 let col_rhs = Condition::Simple(sc(Op::Eq, colp("id"), colp("age")));
3685 assert!(extract_push_guard(&col_rhs, &s).is_none());
3686 }
3687
3688 #[test]
3689 fn no_guard_for_unknown_column() {
3690 let s = t_schema();
3691 assert!(extract_push_guard(&gsimp(Op::Eq, "missing", Lit::Number(1.0)), &s).is_none());
3692 }
3693
3694 #[test]
3695 fn and_picks_smallest_value_set() {
3696 let s = t_schema();
3697 // (id IN (1,2,3)) AND (age = 30) -> the age=30 child (1 value < 3).
3698 let cond = Condition::And {
3699 conditions: vec![
3700 gsimp(
3701 Op::In,
3702 "id",
3703 Lit::Array(vec![Lit::Number(1.0), Lit::Number(2.0), Lit::Number(3.0)]),
3704 ),
3705 gsimp(Op::Eq, "age", Lit::Number(30.0)),
3706 ],
3707 };
3708 assert_guard(extract_push_guard(&cond, &s), 2, &[OwnedValue::Int(30)]);
3709 }
3710
3711 #[test]
3712 fn and_ignores_unguardable_child() {
3713 let s = t_schema();
3714 // (age = 30) AND (name LIKE 'a%') -> guard on age (LIKE child yields None).
3715 let cond = Condition::And {
3716 conditions: vec![
3717 gsimp(Op::Eq, "age", Lit::Number(30.0)),
3718 gsimp(Op::Like, "name", Lit::Str("a%".into())),
3719 ],
3720 };
3721 assert_guard(extract_push_guard(&cond, &s), 2, &[OwnedValue::Int(30)]);
3722 }
3723
3724 #[test]
3725 fn and_all_unguardable_is_none() {
3726 let s = t_schema();
3727 let cond = Condition::And {
3728 conditions: vec![
3729 gsimp(Op::Lt, "age", Lit::Number(30.0)),
3730 gsimp(Op::Like, "name", Lit::Str("a%".into())),
3731 ],
3732 };
3733 assert!(extract_push_guard(&cond, &s).is_none());
3734 }
3735
3736 #[test]
3737 fn or_same_column_unions() {
3738 let s = t_schema();
3739 // id = 1 OR id = 2 -> {0: [1, 2]}
3740 let cond = Condition::Or {
3741 conditions: vec![
3742 gsimp(Op::Eq, "id", Lit::Number(1.0)),
3743 gsimp(Op::Eq, "id", Lit::Number(2.0)),
3744 ],
3745 };
3746 assert_guard(
3747 extract_push_guard(&cond, &s),
3748 0,
3749 &[OwnedValue::Int(1), OwnedValue::Int(2)],
3750 );
3751 }
3752
3753 #[test]
3754 fn or_different_columns_is_none() {
3755 let s = t_schema();
3756 let cond = Condition::Or {
3757 conditions: vec![
3758 gsimp(Op::Eq, "id", Lit::Number(1.0)),
3759 gsimp(Op::Eq, "age", Lit::Number(2.0)),
3760 ],
3761 };
3762 assert!(extract_push_guard(&cond, &s).is_none());
3763 }
3764
3765 #[test]
3766 fn or_with_unguardable_branch_is_none() {
3767 let s = t_schema();
3768 let cond = Condition::Or {
3769 conditions: vec![
3770 gsimp(Op::Eq, "id", Lit::Number(1.0)),
3771 gsimp(Op::Like, "name", Lit::Str("a%".into())),
3772 ],
3773 };
3774 assert!(extract_push_guard(&cond, &s).is_none());
3775 }
3776
3777 #[test]
3778 fn or_drops_never_matching_branch() {
3779 let s = t_schema();
3780 // id = 1 OR id = NULL -> {0: [1]} (id = NULL is a false disjunct)
3781 let cond = Condition::Or {
3782 conditions: vec![
3783 gsimp(Op::Eq, "id", Lit::Number(1.0)),
3784 gsimp(Op::Eq, "id", Lit::Null),
3785 ],
3786 };
3787 assert_guard(extract_push_guard(&cond, &s), 0, &[OwnedValue::Int(1)]);
3788 }
3789
3790 #[test]
3791 fn or_of_and_unions_same_column() {
3792 let s = t_schema();
3793 // (id = 1 AND age = 5) OR (id = 2): the AND picks its first smallest (id=1,
3794 // col 0); the other branch is id=2 (col 0) -> union {1, 2} on col 0.
3795 let cond = Condition::Or {
3796 conditions: vec![
3797 Condition::And {
3798 conditions: vec![
3799 gsimp(Op::Eq, "id", Lit::Number(1.0)),
3800 gsimp(Op::Eq, "age", Lit::Number(5.0)),
3801 ],
3802 },
3803 gsimp(Op::Eq, "id", Lit::Number(2.0)),
3804 ],
3805 };
3806 assert_guard(
3807 extract_push_guard(&cond, &s),
3808 0,
3809 &[OwnedValue::Int(1), OwnedValue::Int(2)],
3810 );
3811 }
3812
3813 #[test]
3814 fn eq_resolves_column_and_coerces_integral_number() {
3815 // `age = 30`: Number(30.0) coerces to Int(30); matches an Int(30) cell.
3816 let s = t_schema();
3817 let p = create_predicate(&sc(Op::Eq, colp("age"), litp(Lit::Number(30.0))), &s).unwrap();
3818 assert!(matches!(
3819 &p,
3820 CompiledPredicate::Cmp {
3821 col: 2,
3822 op: CmpOp::Eq,
3823 value: OwnedValue::Int(30)
3824 }
3825 ));
3826 assert!(p.eval(&t_row(1, "a", 30)));
3827 assert!(!p.eval(&t_row(1, "a", 31)));
3828 }
3829
3830 #[test]
3831 fn non_integral_number_stays_float() {
3832 let s = t_schema();
3833 let p = create_predicate(&sc(Op::Eq, colp("age"), litp(Lit::Number(30.5))), &s).unwrap();
3834 assert!(matches!(
3835 &p,
3836 CompiledPredicate::Cmp {
3837 value: OwnedValue::Float(_),
3838 ..
3839 }
3840 ));
3841 }
3842
3843 #[test]
3844 fn null_rhs_folds_to_const_false() {
3845 // `name = null` is UNKNOWN for every row → drop (use IS NULL instead).
3846 let s = t_schema();
3847 let p = create_predicate(&sc(Op::Eq, colp("name"), litp(Lit::Null)), &s).unwrap();
3848 assert!(matches!(p, CompiledPredicate::Const(false)));
3849 }
3850
3851 #[test]
3852 fn is_null_and_is_not_null() {
3853 let s = t_schema();
3854 let isn = create_predicate(&sc(Op::Is, colp("name"), litp(Lit::Null)), &s).unwrap();
3855 assert!(matches!(
3856 isn,
3857 CompiledPredicate::IsNull {
3858 col: 1,
3859 negated: false
3860 }
3861 ));
3862 let isnn = create_predicate(&sc(Op::IsNot, colp("name"), litp(Lit::Null)), &s).unwrap();
3863 assert!(matches!(
3864 isnn,
3865 CompiledPredicate::IsNull {
3866 col: 1,
3867 negated: true
3868 }
3869 ));
3870 }
3871
3872 #[test]
3873 fn is_against_non_null_uses_identity() {
3874 let s = t_schema();
3875 let p =
3876 create_predicate(&sc(Op::Is, colp("name"), litp(Lit::Str("a".into()))), &s).unwrap();
3877 assert!(matches!(
3878 &p,
3879 CompiledPredicate::Is {
3880 col: 1,
3881 negated: false,
3882 ..
3883 }
3884 ));
3885 assert!(p.eval(&t_row(1, "a", 0)));
3886 assert!(!p.eval(&t_row(1, "b", 0)));
3887
3888 let np =
3889 create_predicate(&sc(Op::IsNot, colp("name"), litp(Lit::Str("a".into()))), &s).unwrap();
3890 assert!(!np.eval(&t_row(1, "a", 0)));
3891 assert!(np.eval(&t_row(1, "b", 0)));
3892 }
3893
3894 #[test]
3895 fn unknown_column_errors() {
3896 let s = t_schema();
3897 let r = create_predicate(&sc(Op::Eq, colp("missing"), litp(Lit::Number(1.0))), &s);
3898 assert_eq!(r.err(), Some(BuildError::UnknownColumn("missing".into())));
3899 }
3900
3901 #[test]
3902 fn in_and_not_in() {
3903 let s = t_schema();
3904 let set = Lit::Array(vec![Lit::Number(1.0), Lit::Number(3.0)]);
3905 let p = create_predicate(&sc(Op::In, colp("id"), litp(set.clone())), &s).unwrap();
3906 assert!(matches!(
3907 &p,
3908 CompiledPredicate::In {
3909 col: 0,
3910 negated: false,
3911 ..
3912 }
3913 ));
3914 assert!(p.eval(&t_row(1, "a", 0)));
3915 assert!(!p.eval(&t_row(2, "a", 0)));
3916
3917 let np = create_predicate(&sc(Op::NotIn, colp("id"), litp(set)), &s).unwrap();
3918 assert!(np.eval(&t_row(2, "a", 0)));
3919 assert!(!np.eval(&t_row(3, "a", 0)));
3920 }
3921
3922 #[test]
3923 fn in_requires_array() {
3924 let s = t_schema();
3925 let r = create_predicate(&sc(Op::In, colp("id"), litp(Lit::Number(1.0))), &s);
3926 assert!(matches!(r, Err(BuildError::Invalid(_))));
3927 }
3928
3929 #[test]
3930 fn like_and_not_like() {
3931 let s = t_schema();
3932 let p =
3933 create_predicate(&sc(Op::Like, colp("name"), litp(Lit::Str("a%".into()))), &s).unwrap();
3934 assert!(matches!(
3935 &p,
3936 CompiledPredicate::Like {
3937 col: 1,
3938 negated: false,
3939 ..
3940 }
3941 ));
3942 assert!(p.eval(&t_row(1, "abc", 0)));
3943 assert!(!p.eval(&t_row(1, "xyz", 0)));
3944
3945 let np = create_predicate(
3946 &sc(Op::NotLike, colp("name"), litp(Lit::Str("a%".into()))),
3947 &s,
3948 )
3949 .unwrap();
3950 assert!(np.eval(&t_row(1, "xyz", 0)));
3951 assert!(!np.eval(&t_row(1, "abc", 0)));
3952 }
3953
3954 #[test]
3955 fn ilike_and_not_ilike() {
3956 let s = t_schema();
3957 let p = create_predicate(
3958 &sc(Op::ILike, colp("name"), litp(Lit::Str("a%".into()))),
3959 &s,
3960 )
3961 .unwrap();
3962 assert!(p.eval(&t_row(1, "abc", 0)));
3963 assert!(p.eval(&t_row(1, "ABC", 0)));
3964 assert!(!p.eval(&t_row(1, "xbc", 0)));
3965
3966 let np = create_predicate(
3967 &sc(Op::NotILike, colp("name"), litp(Lit::Str("a%".into()))),
3968 &s,
3969 )
3970 .unwrap();
3971 assert!(!np.eval(&t_row(1, "ABC", 0)));
3972 assert!(np.eval(&t_row(1, "xbc", 0)));
3973 }
3974
3975 #[test]
3976 fn ordering_uses_compare_semantics() {
3977 let s = t_schema();
3978 let p = create_predicate(&sc(Op::Gt, colp("age"), litp(Lit::Number(18.0))), &s).unwrap();
3979 assert!(p.eval(&t_row(1, "a", 21)));
3980 assert!(!p.eval(&t_row(1, "a", 18)));
3981 assert!(!p.eval(&t_row(1, "a", 10)));
3982 }
3983
3984 #[test]
3985 fn null_cell_drops_for_every_op() {
3986 // A null cell is UNKNOWN → dropped, even for the negated ops (parity with
3987 // JS createPredicate's LHS null guard).
3988 let s = t_schema();
3989 let ne =
3990 create_predicate(&sc(Op::Ne, colp("name"), litp(Lit::Str("x".into()))), &s).unwrap();
3991 let null_name = owned_row(vec![
3992 OwnedValue::Int(1),
3993 OwnedValue::Null,
3994 OwnedValue::Int(0),
3995 ]);
3996 assert!(!ne.eval(&null_name)); // null != "x" → UNKNOWN → drop
3997 }
3998
3999 #[test]
4000 fn literal_lhs_folds_to_const() {
4001 let s = t_schema();
4002 // `5 = 5` → true; `5 = 6` → false.
4003 let t = create_predicate(
4004 &sc(Op::Eq, litp(Lit::Number(5.0)), litp(Lit::Number(5.0))),
4005 &s,
4006 )
4007 .unwrap();
4008 assert!(matches!(t, CompiledPredicate::Const(true)));
4009 let f = create_predicate(
4010 &sc(Op::Eq, litp(Lit::Number(5.0)), litp(Lit::Number(6.0))),
4011 &s,
4012 )
4013 .unwrap();
4014 assert!(matches!(f, CompiledPredicate::Const(false)));
4015 // `5 < 6` → true (same numeric class folds via compare).
4016 let lt = create_predicate(
4017 &sc(Op::Lt, litp(Lit::Number(5.0)), litp(Lit::Number(6.0))),
4018 &s,
4019 )
4020 .unwrap();
4021 assert!(matches!(lt, CompiledPredicate::Const(true)));
4022 // `'a' IN ['a','b']` → true.
4023 let inset = Lit::Array(vec![Lit::Str("a".into()), Lit::Str("b".into())]);
4024 let m = create_predicate(&sc(Op::In, litp(Lit::Str("a".into())), litp(inset)), &s).unwrap();
4025 assert!(matches!(m, CompiledPredicate::Const(true)));
4026 // null literal LHS → false for non-IS ops.
4027 let n = create_predicate(&sc(Op::Eq, litp(Lit::Null), litp(Lit::Number(5.0))), &s).unwrap();
4028 assert!(matches!(n, CompiledPredicate::Const(false)));
4029 }
4030
4031 #[test]
4032 fn literal_lhs_is_null_folds() {
4033 let s = t_schema();
4034 let yes = create_predicate(&sc(Op::Is, litp(Lit::Null), litp(Lit::Null)), &s).unwrap();
4035 assert!(matches!(yes, CompiledPredicate::Const(true))); // null IS null
4036 let no = create_predicate(&sc(Op::IsNot, litp(Lit::Null), litp(Lit::Null)), &s).unwrap();
4037 assert!(matches!(no, CompiledPredicate::Const(false))); // null IS NOT null
4038 let nn =
4039 create_predicate(&sc(Op::IsNot, litp(Lit::Number(5.0)), litp(Lit::Null)), &s).unwrap();
4040 assert!(matches!(nn, CompiledPredicate::Const(true))); // 5 IS NOT null
4041 }
4042
4043 #[test]
4044 fn mismatched_ordering_const_is_invalid() {
4045 // `5 < 5.5` would need Int/Float coercion in compare (deferred) → build
4046 // error instead of a panic.
4047 let s = t_schema();
4048 let r = create_predicate(
4049 &sc(Op::Lt, litp(Lit::Number(5.0)), litp(Lit::Number(5.5))),
4050 &s,
4051 );
4052 assert!(matches!(r, Err(BuildError::Invalid(_))));
4053 }
4054
4055 #[test]
4056 fn column_on_right_is_invalid() {
4057 let s = t_schema();
4058 let r = create_predicate(&sc(Op::Eq, colp("id"), colp("age")), &s);
4059 assert!(matches!(r, Err(BuildError::Invalid(_))));
4060 }
4061
4062 #[test]
4063 fn bool_vs_number_ordering_const_is_invalid() {
4064 // `5 < true` — Bool and Int are different storage classes; guarded to a
4065 // build error rather than a compare_values panic.
4066 let s = t_schema();
4067 let r = create_predicate(
4068 &sc(Op::Lt, litp(Lit::Number(5.0)), litp(Lit::Bool(true))),
4069 &s,
4070 );
4071 assert!(matches!(r, Err(BuildError::Invalid(_))));
4072 }
4073
4074 #[test]
4075 fn in_list_with_null_element_drops_null_cell() {
4076 // `id IN [1, null]`: a null in the set is harmless — a null CELL is
4077 // UNKNOWN and dropped before membership is checked (parity with JS's
4078 // lhs-null guard), and a non-null cell matches only the real entries.
4079 let s = t_schema();
4080 let list = Lit::Array(vec![Lit::Number(1.0), Lit::Null]);
4081 let p = create_predicate(&sc(Op::In, colp("id"), litp(list)), &s).unwrap();
4082 assert!(p.eval(&t_row(1, "a", 0))); // 1 ∈ {1, null}
4083 assert!(!p.eval(&t_row(2, "a", 0))); // 2 ∉ {1, null}
4084 let null_id = owned_row(vec![
4085 OwnedValue::Null,
4086 OwnedValue::str("a"),
4087 OwnedValue::Int(0),
4088 ]);
4089 assert!(!p.eval(&null_id)); // null cell → UNKNOWN → drop
4090 }
4091
4092 #[test]
4093 fn literal_lhs_like_folds_to_const() {
4094 // `'abc' LIKE 'a%'` → true; `'xyz' LIKE 'a%'` → false (folded at build).
4095 let s = t_schema();
4096 let yes = create_predicate(
4097 &sc(
4098 Op::Like,
4099 litp(Lit::Str("abc".into())),
4100 litp(Lit::Str("a%".into())),
4101 ),
4102 &s,
4103 )
4104 .unwrap();
4105 assert!(matches!(yes, CompiledPredicate::Const(true)));
4106 let no = create_predicate(
4107 &sc(
4108 Op::Like,
4109 litp(Lit::Str("xyz".into())),
4110 litp(Lit::Str("a%".into())),
4111 ),
4112 &s,
4113 )
4114 .unwrap();
4115 assert!(matches!(no, CompiledPredicate::Const(false)));
4116 }
4117
4118 // --- build_pipeline ------------------------------------------------------
4119
4120 /// issue(id=0, priority=1) source schema. A source carries no relationships
4121 /// (the `comments` slot is query-derived); a view that materializes `comments`
4122 /// builds its own `Schema` from [`issue_view_schema`].
4123 fn issue_schema() -> SourceSchema {
4124 SourceSchema::new(vec!["id", "priority"], vec![0], vec![(0, true)])
4125 }
4126 /// comment(id=0, issueID=1) source schema.
4127 fn comment_schema() -> SourceSchema {
4128 SourceSchema::new(vec!["id", "issueID"], vec![0], vec![(0, true)])
4129 }
4130 /// issue view schema declaring the `comments` relationship (its leaf child
4131 /// schema lets the production `View` sort/build the materialized comments).
4132 fn issue_view_schema() -> Schema {
4133 Schema::new(vec!["id", "priority"], vec![0], vec![(0, true)]).with_relationships(vec![
4134 crate::value::RelDef::related("comments", comment_schema().into_schema()),
4135 ])
4136 }
4137 fn order_id() -> Vec<OrderPart> {
4138 vec![OrderPart("id".into(), Dir::Asc)]
4139 }
4140 fn irow(a: i64, b: i64) -> crate::value::OwnedRow {
4141 owned_row(vec![OwnedValue::Int(a), OwnedValue::Int(b)])
4142 }
4143 /// Column-0 (`id`) of a caught/owned row as `i64` — for asserting nested trees.
4144 fn col0(r: &OwnedRow) -> i64 {
4145 match r.col(0) {
4146 crate::value::Value::Int(i) => i,
4147 other => panic!("expected Int in col 0, got {other:?}"),
4148 }
4149 }
4150 fn related_csq(alias: &str, table: &str, parent_f: &str, child_f: &str) -> CorrelatedSubquery {
4151 related_csq2(alias, table, &[parent_f], &[child_f])
4152 }
4153 fn related_csq2(
4154 alias: &str,
4155 table: &str,
4156 parent_f: &[&str],
4157 child_f: &[&str],
4158 ) -> CorrelatedSubquery {
4159 CorrelatedSubquery {
4160 correlation: crate::ast::Correlation {
4161 parent_field: parent_f.iter().map(|s| (*s).into()).collect(),
4162 child_field: child_f.iter().map(|s| (*s).into()).collect(),
4163 },
4164 subquery: Box::new(Ast {
4165 table: table.into(),
4166 alias: Some(alias.into()),
4167 // No explicit order — resolve_sort completes it from the child's PK
4168 // (comment PK is `id`, so this matches the prior `order_by("id")`).
4169 ..Default::default()
4170 }),
4171 system: None,
4172 }
4173 }
4174 /// issue ⋈ comments (issue.id = comment.issueID).
4175 fn issue_with_comments() -> Ast {
4176 Ast {
4177 table: "issue".into(),
4178 order_by: order_id(),
4179 related: vec![related_csq("comments", "comment", "id", "issueID")],
4180 ..Default::default()
4181 }
4182 }
4183
4184 #[test]
4185 fn pipeline_source_where_filters_on_fetch() {
4186 let mut g = Graph::new();
4187 let schema = issue_schema();
4188 let issue = g.add_source(schema.clone(), vec![irow(1, 5), irow(2, 1), irow(3, 9)]);
4189 let resolve = |t: &str| (t == "issue").then(|| (issue, schema.clone()));
4190 let ast = Ast {
4191 table: "issue".into(),
4192 r#where: Some(Condition::Simple(sc(
4193 Op::Gt,
4194 colp("priority"),
4195 litp(Lit::Number(2.0)),
4196 ))),
4197 order_by: order_id(),
4198 ..Default::default()
4199 };
4200 let top = build_pipeline(&mut g, &ast, &resolve).unwrap();
4201 let view = g.add_view(top, schema.clone().into_schema());
4202 g.hydrate(view);
4203 // priority > 2 keeps id 1 (5) and 3 (9), drops id 2 (1); sorted by id.
4204 assert_eq!(
4205 g.dump_view_rows(view),
4206 vec![(vec![1, 5], vec![]), (vec![3, 9], vec![])]
4207 );
4208 }
4209
4210 // --- projection (PROJECTION-SUPPORT-DESIGN.md §3, §6) --------------------
4211
4212 #[test]
4213 fn view_schema_lowers_select_to_projection() {
4214 let src = issue_schema();
4215 let resolve = |t: &str| (t == "issue").then(|| (NodeId::new(0, 0), src.clone()));
4216
4217 // `'*'` → no projection.
4218 let star = table("issue").order_by("id", "asc").build();
4219 let s = view_schema(&star, &resolve).unwrap();
4220 assert_eq!(s.projection, None);
4221 assert_eq!(s.columns.len(), 2); // columns stay full width
4222
4223 // `.select("priority")` → projection over base ColId 1, columns still full.
4224 let proj = table("issue")
4225 .select("priority")
4226 .order_by("id", "asc")
4227 .build();
4228 let s = view_schema(&proj, &resolve).unwrap();
4229 assert_eq!(s.projection, Some(vec![1]));
4230 assert_eq!(s.columns.len(), 2);
4231
4232 // Selection order is preserved; an unknown column is a build error.
4233 let proj2 = table("issue").select("priority").select("id").build();
4234 let s = view_schema(&proj2, &resolve).unwrap();
4235 assert_eq!(s.projection, Some(vec![1, 0]));
4236 let bad = table("issue").select("nope").build();
4237 assert!(matches!(
4238 view_schema(&bad, &resolve),
4239 Err(BuildError::UnknownColumn(_))
4240 ));
4241 }
4242
4243 #[test]
4244 fn required_cols_unions_select_where_and_sort() {
4245 let schema = issue_schema().into_schema(); // cols: id=0, priority=1
4246 // select id; where priority > 2; order by id → required = {id, priority}.
4247 let ast = table("issue")
4248 .select("id")
4249 .where_op("priority", ">", 2)
4250 .order_by("id", "asc")
4251 .build();
4252 let sort = resolve_sort(&ast.order_by, &schema).unwrap();
4253 let mut req = required_cols(&ast, &schema, &sort, &[]).unwrap();
4254 req.sort_unstable();
4255 assert_eq!(req, vec![0, 1]);
4256
4257 // `'*'`-style required (select all) is every column.
4258 let star = table("issue").order_by("id", "asc").build();
4259 let sort = resolve_sort(&star.order_by, &schema).unwrap();
4260 let mut req = required_cols(&star, &schema, &sort, &[]).unwrap();
4261 req.sort_unstable();
4262 assert_eq!(req, vec![0, 1]);
4263 }
4264
4265 #[test]
4266 fn projection_presence_predicate_drops_partial_rows() {
4267 // A projected query drops a shared row whose *required* column is `Absent`,
4268 // while keeping rows that carry it (§3.3). Row id=2 has an Absent `priority`.
4269 let mut g = Graph::new();
4270 let schema = issue_schema();
4271 let issue = g.add_source(
4272 schema.clone(),
4273 vec![
4274 owned_row(vec![OwnedValue::Int(1), OwnedValue::Int(5)]),
4275 owned_row(vec![OwnedValue::Int(2), OwnedValue::Absent]),
4276 owned_row(vec![OwnedValue::Int(3), OwnedValue::Int(9)]),
4277 ],
4278 );
4279 let resolve = |t: &str| (t == "issue").then(|| (issue, schema.clone()));
4280 let ast = table("issue")
4281 .select("priority")
4282 .order_by("id", "asc")
4283 .build();
4284 let top = build_pipeline(&mut g, &ast, &resolve).unwrap();
4285 let view = g.add_view(top, view_schema(&ast, &resolve).unwrap());
4286 g.hydrate(view);
4287 // id=2 is dropped (its required `priority` is Absent); id=1 and id=3 remain.
4288 assert_eq!(
4289 g.dump_view_rows(view),
4290 vec![(vec![1, 5], vec![]), (vec![3, 9], vec![])]
4291 );
4292 }
4293
4294 #[test]
4295 fn star_query_keeps_full_rows_unchanged() {
4296 // A `'*'` query installs no presence predicate (§7): behavior is identical to
4297 // before projection existed — every full row surfaces.
4298 let mut g = Graph::new();
4299 let schema = issue_schema();
4300 let issue = g.add_source(schema.clone(), vec![irow(1, 5), irow(2, 1), irow(3, 9)]);
4301 let resolve = |t: &str| (t == "issue").then(|| (issue, schema.clone()));
4302 let ast = table("issue").order_by("id", "asc").build();
4303 let top = build_pipeline(&mut g, &ast, &resolve).unwrap();
4304 let view = g.add_view(top, view_schema(&ast, &resolve).unwrap());
4305 g.hydrate(view);
4306 assert_eq!(
4307 g.dump_view_rows(view),
4308 vec![
4309 (vec![1, 5], vec![]),
4310 (vec![2, 1], vec![]),
4311 (vec![3, 9], vec![])
4312 ]
4313 );
4314 }
4315
4316 #[test]
4317 fn pipeline_source_where_and_or() {
4318 let mut g = Graph::new();
4319 let schema = issue_schema();
4320 let issue = g.add_source(schema.clone(), vec![irow(1, 5), irow(2, 1), irow(3, 9)]);
4321 let resolve = |t: &str| (t == "issue").then(|| (issue, schema.clone()));
4322
4323 // (priority > 2) AND (id < 3) → only id 1.
4324 let and = Condition::And {
4325 conditions: vec![
4326 Condition::Simple(sc(Op::Gt, colp("priority"), litp(Lit::Number(2.0)))),
4327 Condition::Simple(sc(Op::Lt, colp("id"), litp(Lit::Number(3.0)))),
4328 ],
4329 };
4330 let ast = Ast {
4331 table: "issue".into(),
4332 r#where: Some(and),
4333 order_by: order_id(),
4334 ..Default::default()
4335 };
4336 let top = build_pipeline(&mut g, &ast, &resolve).unwrap();
4337 let v1 = g.add_view(top, schema.clone().into_schema());
4338 g.hydrate(v1);
4339 assert_eq!(g.dump_view_rows(v1), vec![(vec![1, 5], vec![])]);
4340
4341 // (priority > 8) OR (id = 2) → id 2 and id 3.
4342 let or = Condition::Or {
4343 conditions: vec![
4344 Condition::Simple(sc(Op::Gt, colp("priority"), litp(Lit::Number(8.0)))),
4345 Condition::Simple(sc(Op::Eq, colp("id"), litp(Lit::Number(2.0)))),
4346 ],
4347 };
4348 let ast2 = Ast {
4349 table: "issue".into(),
4350 r#where: Some(or),
4351 order_by: order_id(),
4352 ..Default::default()
4353 };
4354 let top2 = build_pipeline(&mut g, &ast2, &resolve).unwrap();
4355 let v2 = g.add_view(top2, schema.clone().into_schema());
4356 g.hydrate(v2);
4357 assert_eq!(
4358 g.dump_view_rows(v2),
4359 vec![(vec![2, 1], vec![]), (vec![3, 9], vec![])]
4360 );
4361 }
4362
4363 #[test]
4364 fn pipeline_source_where_push_gates() {
4365 let mut g = Graph::new();
4366 let schema = issue_schema();
4367 let issue = g.add_source(schema.clone(), vec![irow(1, 5), irow(2, 1), irow(3, 9)]);
4368 let resolve = |t: &str| (t == "issue").then(|| (issue, schema.clone()));
4369 let ast = Ast {
4370 table: "issue".into(),
4371 r#where: Some(Condition::Simple(sc(
4372 Op::Gt,
4373 colp("priority"),
4374 litp(Lit::Number(2.0)),
4375 ))),
4376 order_by: order_id(),
4377 ..Default::default()
4378 };
4379 let top = build_pipeline(&mut g, &ast, &resolve).unwrap();
4380 let view = g.add_view(top, schema.clone().into_schema());
4381 g.set_sink_edge(top, view);
4382 g.hydrate(view);
4383 assert_eq!(
4384 g.dump_view_rows(view),
4385 vec![(vec![1, 5], vec![]), (vec![3, 9], vec![])]
4386 );
4387
4388 // A passing add (priority 7 > 2) appears; a filtered add (priority 1) does not.
4389 g.source_push(issue, crate::change::SourceChange::Add(irow(4, 7)));
4390 g.source_push(issue, crate::change::SourceChange::Add(irow(5, 1)));
4391 assert_eq!(
4392 g.dump_view_rows(view),
4393 vec![
4394 (vec![1, 5], vec![]),
4395 (vec![3, 9], vec![]),
4396 (vec![4, 7], vec![])
4397 ]
4398 );
4399 }
4400
4401 #[test]
4402 fn pipeline_single_relationship_join_on_fetch() {
4403 let mut g = Graph::new();
4404 let issue_s = issue_schema();
4405 let comment_s = comment_schema();
4406 let issue = g.add_source(issue_s.clone(), vec![irow(1, 5), irow(2, 1)]);
4407 let comment = g.add_source(
4408 comment_s.clone(),
4409 vec![irow(10, 1), irow(11, 1), irow(12, 2)],
4410 );
4411 let resolve = |t: &str| match t {
4412 "issue" => Some((issue, issue_s.clone())),
4413 "comment" => Some((comment, comment_s.clone())),
4414 _ => None,
4415 };
4416 let top = build_pipeline(&mut g, &issue_with_comments(), &resolve).unwrap();
4417 let view = g.add_view(top, issue_view_schema());
4418 g.hydrate(view);
4419 assert_eq!(
4420 g.dump_view_rows(view),
4421 vec![
4422 (vec![1, 5], vec![vec![10, 1], vec![11, 1]]),
4423 (vec![2, 1], vec![vec![12, 2]]),
4424 ]
4425 );
4426 }
4427
4428 #[test]
4429 fn pipeline_join_push_adds_child() {
4430 let mut g = Graph::new();
4431 let issue_s = issue_schema();
4432 let comment_s = comment_schema();
4433 let issue = g.add_source(issue_s.clone(), vec![irow(1, 5), irow(2, 1)]);
4434 let comment = g.add_source(
4435 comment_s.clone(),
4436 vec![irow(10, 1), irow(11, 1), irow(12, 2)],
4437 );
4438 let resolve = |t: &str| match t {
4439 "issue" => Some((issue, issue_s.clone())),
4440 "comment" => Some((comment, comment_s.clone())),
4441 _ => None,
4442 };
4443 let top = build_pipeline(&mut g, &issue_with_comments(), &resolve).unwrap();
4444 let view = g.add_view(top, issue_view_schema());
4445 g.set_sink_edge(top, view);
4446 g.hydrate(view);
4447
4448 // A new comment for issue 2 lands as a child via the join's child port.
4449 g.source_push(comment, crate::change::SourceChange::Add(irow(13, 2)));
4450 assert_eq!(
4451 g.dump_view_rows(view),
4452 vec![
4453 (vec![1, 5], vec![vec![10, 1], vec![11, 1]]),
4454 (vec![2, 1], vec![vec![12, 2], vec![13, 2]]),
4455 ]
4456 );
4457 }
4458
4459 #[test]
4460 fn pipeline_lowers_start() {
4461 // `start_after id 2` → a Skip over the connection. The bound is pushed into
4462 // the source's fetch start (hydrate drops ids 1,2) and gates pushes (an add
4463 // before the bound is dropped, one after is kept).
4464 let mut g = Graph::new();
4465 let schema = issue_schema(); // (id, priority); PK id
4466 let issue = g.add_source(
4467 schema.clone(),
4468 vec![irow(1, 5), irow(2, 1), irow(3, 9), irow(4, 2)],
4469 );
4470 let resolve = |t: &str| (t == "issue").then(|| (issue, schema.clone()));
4471 let ast = table("issue").start_after("id", 2).build();
4472
4473 let top = build_pipeline(&mut g, &ast, &resolve).unwrap();
4474 let view = g.add_view(top, schema.clone().into_schema());
4475 g.set_sink_edge(top, view);
4476 g.hydrate(view);
4477 // ids after 2 survive; sorted by the PK-completed sort.
4478 assert_eq!(
4479 g.dump_view_rows(view),
4480 vec![(vec![3, 9], vec![]), (vec![4, 2], vec![])]
4481 );
4482
4483 // a push before the bound is dropped; one after is kept.
4484 g.source_push(issue, crate::change::SourceChange::Add(irow(0, 7)));
4485 g.source_push(issue, crate::change::SourceChange::Add(irow(5, 7)));
4486 assert_eq!(
4487 g.dump_view_rows(view),
4488 vec![
4489 (vec![3, 9], vec![]),
4490 (vec![4, 2], vec![]),
4491 (vec![5, 7], vec![])
4492 ]
4493 );
4494 }
4495
4496 #[test]
4497 fn pipeline_lowers_limit() {
4498 // `.limit(3)` → a Take over the connection. Hydrate keeps the first 3 by the
4499 // PK-completed sort; an add that displaces the boundary emits a
4500 // Remove(bound) + Add(new); an add past the (full) window is dropped.
4501 let mut g = Graph::new();
4502 let schema = issue_schema(); // (id, priority); PK id; sort id asc
4503 let issue = g.add_source(
4504 schema.clone(),
4505 vec![irow(1, 5), irow(2, 1), irow(3, 9), irow(4, 2)],
4506 );
4507 let resolve = |t: &str| (t == "issue").then(|| (issue, schema.clone()));
4508 let ast = table("issue").limit(3).build();
4509
4510 let top = build_pipeline(&mut g, &ast, &resolve).unwrap();
4511 let view = g.add_view(top, schema.clone().into_schema());
4512 g.set_sink_edge(top, view);
4513 g.hydrate(view);
4514 assert_eq!(
4515 g.dump_view_rows(view),
4516 vec![
4517 (vec![1, 5], vec![]),
4518 (vec![2, 1], vec![]),
4519 (vec![3, 9], vec![])
4520 ]
4521 );
4522
4523 // Add id 0 (sorts before the bound, id 3): displaces the boundary out.
4524 g.source_push(issue, crate::change::SourceChange::Add(irow(0, 7)));
4525 assert_eq!(
4526 g.dump_view_rows(view),
4527 vec![
4528 (vec![0, 7], vec![]),
4529 (vec![1, 5], vec![]),
4530 (vec![2, 1], vec![])
4531 ]
4532 );
4533
4534 // Add id 5 (after the new bound, id 2): the window is full → dropped.
4535 g.source_push(issue, crate::change::SourceChange::Add(irow(5, 7)));
4536 assert_eq!(
4537 g.dump_view_rows(view),
4538 vec![
4539 (vec![0, 7], vec![]),
4540 (vec![1, 5], vec![]),
4541 (vec![2, 1], vec![])
4542 ]
4543 );
4544 }
4545
4546 #[test]
4547 fn pipeline_rejects_unsupported_shapes() {
4548 let mut g = Graph::new();
4549 let schema = issue_schema();
4550 let issue = g.add_source(schema.clone(), vec![]);
4551 let resolve = |t: &str| (t == "issue").then(|| (issue, schema.clone()));
4552 let base = || Ast {
4553 table: "issue".into(),
4554 order_by: order_id(),
4555 ..Default::default()
4556 };
4557
4558 // (`start`/`limit`/`where`-EXISTS now lower — see `pipeline_lowers_*` and the
4559 // EXISTS end-to-end tests.) Flipped NOT EXISTS still needs an anti-join.
4560 let flipped_not_exists = match exists() {
4561 Condition::CorrelatedSubquery(mut c) => {
4562 c.op = ExistsOp::NotExists;
4563 c.flip = Some(true);
4564 Condition::CorrelatedSubquery(c)
4565 }
4566 other => other,
4567 };
4568 let with_flipped_not_exists = Ast {
4569 r#where: Some(flipped_not_exists),
4570 ..base()
4571 };
4572 assert!(matches!(
4573 build_pipeline(&mut g, &with_flipped_not_exists, &resolve),
4574 Err(BuildError::Unsupported(_))
4575 ));
4576
4577 let unknown = Ast {
4578 table: "nope".into(),
4579 ..Default::default()
4580 };
4581 assert!(matches!(
4582 build_pipeline(&mut g, &unknown, &resolve),
4583 Err(BuildError::UnknownTable(_))
4584 ));
4585 }
4586
4587 #[test]
4588 fn pipeline_multiple_relationships_fetch_and_push() {
4589 // issue { comments, labels } — two SIBLING relationships, lowered to two
4590 // stacked joins: join(labels, parent = join(comments, parent = conn)). The
4591 // inner join's output edge carries `Port::JoinParent` so parent changes flow
4592 // up the chain (each join attaching its own relationship); a child add on
4593 // either relationship routes a Child change to the view.
4594 let mut g = Graph::new();
4595 let comment_s = comment_schema(); // (id, issueID)
4596 let label_s = SourceSchema::new(vec!["id", "issueID"], vec![0], vec![(0, true)]);
4597 let issue_s = SourceSchema::new(vec!["id", "priority"], vec![0], vec![(0, true)]);
4598 // The View materializes both relationships, so the view schema's RelDefs
4599 // carry their (leaf) child schemas (the comment/label sort + pk).
4600 let issue_view = Schema::new(vec!["id", "priority"], vec![0], vec![(0, true)])
4601 .with_relationships(vec![
4602 crate::value::RelDef::related("comments", comment_s.clone().into_schema()),
4603 crate::value::RelDef::related("labels", label_s.clone().into_schema()),
4604 ]);
4605 let issue = g.add_source(issue_s.clone(), vec![irow(1, 5), irow(2, 1)]);
4606 let comment = g.add_source(comment_s.clone(), vec![irow(10, 1), irow(11, 2)]);
4607 let label = g.add_source(label_s.clone(), vec![irow(20, 1), irow(21, 2)]);
4608 let resolve = |t: &str| match t {
4609 "issue" => Some((issue, issue_s.clone())),
4610 "comment" => Some((comment, comment_s.clone())),
4611 "label" => Some((label, label_s.clone())),
4612 _ => None,
4613 };
4614 let ast = Ast {
4615 table: "issue".into(),
4616 order_by: order_id(),
4617 related: vec![
4618 related_csq("comments", "comment", "id", "issueID"),
4619 related_csq("labels", "label", "id", "issueID"),
4620 ],
4621 ..Default::default()
4622 };
4623 let top = build_pipeline(&mut g, &ast, &resolve).unwrap();
4624 let view = g.add_view(top, issue_view);
4625 g.set_sink_edge(top, view);
4626 g.hydrate(view);
4627 // `dump_view` flattens BOTH relationships into `children`: comments first
4628 // (its join is innermost), then labels. issue 1 → [10, 20]; issue 2 → [11, 21].
4629 assert_eq!(
4630 g.dump_view(view),
4631 vec![(1, vec![10, 20]), (2, vec![11, 21])]
4632 );
4633
4634 // Add a comment for issue 1: a Child(comments) on the inner join's child
4635 // port that the labels join forwards untouched (parent-port passthrough).
4636 g.source_push(comment, crate::change::SourceChange::Add(irow(12, 1)));
4637 assert_eq!(
4638 g.dump_view(view),
4639 vec![(1, vec![10, 12, 20]), (2, vec![11, 21])]
4640 );
4641
4642 // Add a label for issue 2: a Child(labels) on the OUTER join's own child
4643 // port, routed straight to the view.
4644 g.source_push(label, crate::change::SourceChange::Add(irow(22, 2)));
4645 assert_eq!(
4646 g.dump_view(view),
4647 vec![(1, vec![10, 12, 20]), (2, vec![11, 21, 22])]
4648 );
4649 }
4650
4651 #[test]
4652 fn pipeline_nested_relationship_on_fetch() {
4653 // issue { comments { authors } } — a NESTED relationship: the comments
4654 // child sub-pipeline is itself a Join (authors), wired into the issue join's
4655 // child port (the inner join's output carries `Port::JoinChild`). The
4656 // pipeline builds and fetches the full three-level tree. (The deepest *push*
4657 // path — an author change — stays item-4, so this asserts fetch only.)
4658 let mut g = Graph::new();
4659 let issue_s = issue_schema(); // "comments" is query-local (slot 0)
4660 let comment_s = SourceSchema::new(vec!["id", "issueID"], vec![0], vec![(0, true)]);
4661 let author_s = SourceSchema::new(vec!["id", "commentID"], vec![0], vec![(0, true)]);
4662 let issue = g.add_source(issue_s.clone(), vec![irow(1, 5)]);
4663 let comment = g.add_source(comment_s.clone(), vec![irow(10, 1)]);
4664 let author = g.add_source(author_s.clone(), vec![irow(100, 10)]);
4665 let resolve = |t: &str| match t {
4666 "issue" => Some((issue, issue_s.clone())),
4667 "comment" => Some((comment, comment_s.clone())),
4668 "author" => Some((author, author_s.clone())),
4669 _ => None,
4670 };
4671 // issue ⋈ comments(issue.id = comment.issueID) ⋈ authors(comment.id = author.commentID)
4672 let mut ast = issue_with_comments();
4673 ast.related[0].subquery.related = vec![related_csq("authors", "author", "id", "commentID")];
4674
4675 let top = build_pipeline(&mut g, &ast, &resolve).unwrap();
4676 let catch = g.add_catch(top, false);
4677 let tree = g.catch_fetch(catch, &crate::change::FetchRequest::all());
4678
4679 // One issue → one comment → one author, nested. Slots are query-local: the
4680 // issue frame's only related is "comments" (slot 0); the comment frame's only
4681 // related is "authors" (slot 0).
4682 let comments_slot = RelId(0);
4683 let authors_slot = RelId(0);
4684 assert_eq!(tree.len(), 1);
4685 let issue_node = &tree[0];
4686 assert_eq!(col0(&issue_node.row), 1);
4687 let comments = &issue_node.relationships[&comments_slot];
4688 assert_eq!(comments.len(), 1);
4689 assert_eq!(col0(&comments[0].row), 10);
4690 let authors = &comments[0].relationships[&authors_slot];
4691 assert_eq!(authors.len(), 1);
4692 assert_eq!(col0(&authors[0].row), 100);
4693 }
4694
4695 #[test]
4696 fn pipeline_compound_key_join() {
4697 // issue(org, id) ⋈ comment(org, issueID, cid) on (org, id)=(org, issueID).
4698 let mut g = Graph::new();
4699 let comment_s = SourceSchema::new(vec!["org", "issueID", "cid"], vec![2], vec![(2, true)]);
4700 let issue_s = SourceSchema::new(vec!["org", "id"], vec![0, 1], vec![(0, true), (1, true)]);
4701 // The View materializes "comments", so the view schema carries its child schema.
4702 let issue_view = Schema::new(vec!["org", "id"], vec![0, 1], vec![(0, true), (1, true)])
4703 .with_relationships(vec![crate::value::RelDef::related(
4704 "comments",
4705 comment_s.clone().into_schema(),
4706 )]);
4707 let issue = g.add_source(issue_s.clone(), vec![irow(1, 10), irow(1, 20), irow(2, 10)]);
4708 let comment = g.add_source(
4709 comment_s.clone(),
4710 vec![
4711 owned_row(vec![
4712 OwnedValue::Int(1),
4713 OwnedValue::Int(10),
4714 OwnedValue::Int(100),
4715 ]),
4716 owned_row(vec![
4717 OwnedValue::Int(1),
4718 OwnedValue::Int(10),
4719 OwnedValue::Int(101),
4720 ]),
4721 owned_row(vec![
4722 OwnedValue::Int(1),
4723 OwnedValue::Int(20),
4724 OwnedValue::Int(102),
4725 ]),
4726 owned_row(vec![
4727 OwnedValue::Int(2),
4728 OwnedValue::Int(10),
4729 OwnedValue::Int(103),
4730 ]),
4731 ],
4732 );
4733 let resolve = |t: &str| match t {
4734 "issue" => Some((issue, issue_s.clone())),
4735 "comment" => Some((comment, comment_s.clone())),
4736 _ => None,
4737 };
4738 let ast = Ast {
4739 table: "issue".into(),
4740 related: vec![related_csq2(
4741 "comments",
4742 "comment",
4743 &["org", "id"],
4744 &["org", "issueID"],
4745 )],
4746 ..Default::default()
4747 };
4748 let top = build_pipeline(&mut g, &ast, &resolve).unwrap();
4749 let view = g.add_view(top, issue_view);
4750 g.hydrate(view);
4751 assert_eq!(
4752 g.dump_view_rows(view),
4753 vec![
4754 (vec![1, 10], vec![vec![1, 10, 100], vec![1, 10, 101]]),
4755 (vec![1, 20], vec![vec![1, 20, 102]]),
4756 (vec![2, 10], vec![vec![2, 10, 103]]),
4757 ]
4758 );
4759 }
4760
4761 #[test]
4762 fn pipeline_rejects_mismatched_key_lengths() {
4763 let mut g = Graph::new();
4764 let issue_s = issue_schema();
4765 let comment_s = comment_schema();
4766 let issue = g.add_source(issue_s.clone(), vec![]);
4767 let comment = g.add_source(comment_s.clone(), vec![]);
4768 let resolve = |t: &str| match t {
4769 "issue" => Some((issue, issue_s.clone())),
4770 "comment" => Some((comment, comment_s.clone())),
4771 _ => None,
4772 };
4773 // parent_field has 1 column, child_field has 2 → invalid correlation.
4774 let mut ast = issue_with_comments();
4775 ast.related[0].correlation.child_field = vec!["issueID".into(), "id".into()];
4776 assert!(matches!(
4777 build_pipeline(&mut g, &ast, &resolve),
4778 Err(BuildError::Invalid(_))
4779 ));
4780 }
4781
4782 #[test]
4783 fn pipeline_query_local_relationship_needs_no_schema_declaration() {
4784 // Query-local slots: a relationship defined BY THE QUERY (a `related`/EXISTS CSQ)
4785 // builds even when the source schema does NOT declare it — the slot layout is
4786 // derived from the AST, not the source's declared `relationships`. This is the
4787 // production payoff (the wasm `schema_from_js` declares no synthesized gate slots).
4788 // Pre-refactor this raised `BuildError::UnknownRelationship("comments")`; that
4789 // "schema must pre-declare the relationship" contract is retired here.
4790 let mut g = Graph::new();
4791 // issue source schema (a source never declares relationships anyway).
4792 let issue_s = SourceSchema::new(vec!["id", "priority"], vec![0], vec![(0, true)]);
4793 let comment_s = comment_schema();
4794 let issue = g.add_source(issue_s.clone(), vec![]);
4795 let comment = g.add_source(comment_s.clone(), vec![]);
4796 let resolve = |t: &str| match t {
4797 "issue" => Some((issue, issue_s.clone())),
4798 "comment" => Some((comment, comment_s.clone())),
4799 _ => None,
4800 };
4801 assert!(build_pipeline(&mut g, &issue_with_comments(), &resolve).is_ok());
4802 }
4803
4804 #[test]
4805 fn bare_exists_alias_colliding_with_a_materialized_related_is_rejected() {
4806 // WS05.4: a GENUINE one-slot-per-name clash that `flatten_condition` does not
4807 // remove. A bare top-level EXISTS('comments') is not uniquified (JS-parity
4808 // guard), so its alias collides with the materialized related('comments').
4809 let mut g = Graph::new();
4810 let issue_s = issue_schema();
4811 let comment_s = comment_schema();
4812 let issue = g.add_source(issue_s.clone(), vec![]);
4813 let comment = g.add_source(comment_s.clone(), vec![]);
4814 let resolve = |t: &str| match t {
4815 "issue" => Some((issue, issue_s.clone())),
4816 "comment" => Some((comment, comment_s.clone())),
4817 _ => None,
4818 };
4819 // The query both materializes `related("comments", …)` and gates on a bare
4820 // EXISTS('comments') → a one-slot-per-name clash.
4821 let ast = Ast {
4822 table: "issue".into(),
4823 order_by: order_id(),
4824 related: vec![related_csq("comments", "comment", "id", "issueID")],
4825 r#where: Some(exists_alias("comments", "comment")),
4826 ..Default::default()
4827 };
4828 assert!(matches!(
4829 build_pipeline(&mut g, &ast, &resolve),
4830 Err(BuildError::Unsupported(_))
4831 ));
4832 }
4833}