Rindle docs and package mapSkip to main content

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, &param_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}