Rindle docs and package mapSkip to main content

rindle/
query.rs

1//! A small, loosely-typed **fluent query builder** that crafts [`Ast`]s — the
2//! Rust analogue of the TS `zql/src/query/query-impl.ts`, for use in tests and
3//! such. (The other "query builder", `query_builder`, is a different
4//! thing: the `FetchRequest` → SQL lowering, mirroring TS `zqlite/query-builder.ts`.)
5//!
6//! It is **schema-free**: unlike the TS builder, which looks up a relationship's
7//! correlation from a typed schema, this derives the correlation from the
8//! subquery closure itself. Instead of `issue.related("creator")` you write:
9//!
10//! ```
11//! use rindle::table;
12//!
13//! let q = table("issue")
14//!     .select("title")
15//!     .sub(|row| table("user")
16//!         .r#where("id", row.col("creatorID"))   // ← `id` (child) ↔ `creatorID` (parent)
17//!         .select("name"))
18//!     .build();
19//! ```
20//!
21//! The trick: inside a `sub` closure, `row.col("creatorID")` is a reference to a
22//! **parent** column. When such a reference is used as the value of a child
23//! `where`, it is *not* a filter — it defines the [`Correlation`] between parent
24//! and child (here `parentField = ["creatorID"]`, `childField = ["id"]`). Several
25//! such `where`s build a compound key. Everything else (`where` with a real
26//! literal, `select`, `limit`, …) behaves as you'd expect.
27//!
28//! The builder is move-chaining: each method takes `self` and returns `Self`;
29//! [`Query::build`] consumes it and yields the [`Ast`].
30//!
31//! ## Boolean groups (`AND` / `OR`, nestable)
32//!
33//! Chained `where`s `AND` together. For an `OR` — or any nested mix — use
34//! [`Query::where_any`] (an `OR` group) / [`Query::where_all`] (an explicit `AND`
35//! group). Each takes a closure handed a fresh [`Cond`]; inside it you chain the
36//! same `where`/`where_op`/`where_in`/`where_exists` clauses, plus [`Cond::any`] /
37//! [`Cond::all`] to nest to any depth:
38//!
39//! ```
40//! use rindle::table;
41//!
42//! // (priority > 3 OR kind = 'bug') AND NOT EXISTS(a blocking issue)
43//! let q = table("issue")
44//!     .where_any(|c| c.where_op("priority", ">", 3).r#where("kind", "bug"))
45//!     .where_not_exists(|row| table("link").r#where("blocks", row.col("id")))
46//!     .build();
47//!
48//! // (a = 1 AND b = 2) OR c = 3   — nesting an AND inside an OR
49//! let q = table("t")
50//!     .where_any(|c| c
51//!         .all(|c| c.r#where("a", 1).r#where("b", 2))
52//!         .r#where("c", 3))
53//!     .build();
54//! ```
55//!
56//! ## Surface
57//! - [`table`] — start a query.
58//! - [`Query::select`] — project a column (chain for several; omit for all).
59//! - [`Query::where`] — `field = value`, or a parent correlation if `value` is a
60//!   [`Parent`]; [`Query::where_op`] — explicit operator; [`Query::where_in`].
61//! - [`Query::sub`] / [`Query::sub_as`] — add a correlated child (a relationship).
62//! - [`Query::where_exists`] / [`Query::where_not_exists`] — `(NOT) EXISTS`.
63//! - [`Query::where_any`] / [`Query::where_all`] — an `OR` / `AND` group of
64//!   conditions, nestable via [`Cond::any`] / [`Cond::all`].
65//! - [`Query::limit`], [`Query::order_by`], [`Query::start_at`] /
66//!   [`Query::start_after`] / [`Query::start_row`], [`Query::alias`].
67
68use crate::ast::{
69    Aggregate, Ast, Bound, Condition, CorrelatedSubquery, CorrelatedSubqueryCondition, Correlation,
70    Dir, ExistsOp, Lit, Op, OrderPart, SimpleCondition, System, ValuePosition,
71};
72
73/// Start a query for `table`. The entry point: `table("issue").select("title")…`.
74pub fn table(name: &str) -> Query {
75    Query::new(name)
76}
77
78/// The fluent builder. Holds the [`Ast`] under construction plus a *pending
79/// correlation* — the `(child, parent)` field pairs siphoned from
80/// `where(child, row.col(parent))` calls, which the enclosing `sub` /
81/// `where_exists` drains.
82pub struct Query {
83    ast: Ast,
84    /// Parent-side correlation columns, parallel to `corr_child` (see module docs).
85    corr_parent: Vec<Box<str>>,
86    /// Child-side correlation columns, parallel to `corr_parent`.
87    corr_child: Vec<Box<str>>,
88}
89
90/// A handle to the **parent** row, handed to a `sub` / `where_exists` closure.
91/// `row.col("creatorID")` yields a [`Parent`] reference to that parent column.
92#[derive(Clone, Copy)]
93pub struct ParentRow;
94
95impl ParentRow {
96    /// Reference parent column `name`. Use it as a `where` value to correlate:
97    /// `child.r#where("id", row.col("creatorID"))`.
98    pub fn col(&self, name: &str) -> Parent {
99        Parent(name.into())
100    }
101}
102
103/// A reference to a parent-row column (see [`ParentRow::col`]). As a `where`
104/// value it defines a [`Correlation`], not a filter.
105pub struct Parent(Box<str>);
106
107/// The right-hand side of a `where`: a literal, or a [`Parent`] correlation ref.
108/// You rarely name this — pass a literal (`5`, `"bob"`, `true`, `None::<i64>`) or
109/// a `row.col(..)` and the [`IntoRhs`] conversions build it.
110pub enum Rhs {
111    Lit(Lit),
112    Parent(Box<str>),
113}
114
115// ---------------------------------------------------------------------------
116// Value / operator / direction conversions — the "loosely typed" surface
117// ---------------------------------------------------------------------------
118
119/// Convert a Rust value into an AST [`Lit`]. Implemented for the obvious scalars
120/// (`&str`, `String`, `bool`, `i32`, `i64`, `f64`, `Lit` itself) and for
121/// `Option<T>` (where `None` ⇒ [`Lit::Null`]).
122pub trait IntoLit {
123    fn into_lit(self) -> Lit;
124}
125
126/// Convert a Rust value into a [`Rhs`]. Every [`IntoLit`] type is an `Rhs::Lit`;
127/// a [`Parent`] is an `Rhs::Parent` (a correlation).
128pub trait IntoRhs {
129    fn into_rhs(self) -> Rhs;
130}
131
132/// Convert into an [`Op`]. Implemented for `Op` and for the usual SQL spellings
133/// as `&str` (`"="`, `"!="`, `"<"`, `">="`, `"LIKE"`, `"IN"`, …) — handy in tests;
134/// an unknown spelling panics.
135pub trait IntoOp {
136    fn into_op(self) -> Op;
137}
138
139/// Convert into a [`Dir`]. Implemented for `Dir`, and for `"asc"`/`"desc"`.
140pub trait IntoDir {
141    fn into_dir(self) -> Dir;
142}
143
144macro_rules! into_lit_scalar {
145    ($($t:ty => $ctor:expr),* $(,)?) => {
146        $(
147            impl IntoLit for $t {
148                fn into_lit(self) -> Lit { ($ctor)(self) }
149            }
150            impl IntoRhs for $t {
151                fn into_rhs(self) -> Rhs { Rhs::Lit(self.into_lit()) }
152            }
153        )*
154    };
155}
156
157into_lit_scalar! {
158    Lit    => |v: Lit| v,
159    bool   => Lit::Bool,
160    i32    => |v: i32| Lit::Int(v as i64),
161    // Exact (design 226 Stage B): the former `v as f64` rounded above 2^53.
162    i64    => Lit::Int,
163    f64    => Lit::Number,
164    &str   => |v: &str| Lit::Str(v.into()),
165    String => |v: String| Lit::Str(v.into_boxed_str()),
166}
167
168impl<T: IntoLit> IntoLit for Option<T> {
169    fn into_lit(self) -> Lit {
170        self.map_or(Lit::Null, IntoLit::into_lit)
171    }
172}
173impl<T: IntoLit> IntoRhs for Option<T> {
174    fn into_rhs(self) -> Rhs {
175        Rhs::Lit(self.into_lit())
176    }
177}
178
179impl IntoRhs for Rhs {
180    fn into_rhs(self) -> Rhs {
181        self
182    }
183}
184impl IntoRhs for Parent {
185    fn into_rhs(self) -> Rhs {
186        Rhs::Parent(self.0)
187    }
188}
189
190impl IntoOp for Op {
191    fn into_op(self) -> Op {
192        self
193    }
194}
195impl IntoOp for &str {
196    fn into_op(self) -> Op {
197        match self {
198            "=" | "==" => Op::Eq,
199            "!=" | "<>" => Op::Ne,
200            "<" => Op::Lt,
201            "<=" => Op::Le,
202            ">" => Op::Gt,
203            ">=" => Op::Ge,
204            "IS" => Op::Is,
205            "IS NOT" => Op::IsNot,
206            "LIKE" => Op::Like,
207            "NOT LIKE" => Op::NotLike,
208            "ILIKE" => Op::ILike,
209            "NOT ILIKE" => Op::NotILike,
210            "IN" => Op::In,
211            "NOT IN" => Op::NotIn,
212            other => panic!("unknown operator: {other:?}"),
213        }
214    }
215}
216
217impl IntoDir for Dir {
218    fn into_dir(self) -> Dir {
219        self
220    }
221}
222impl IntoDir for &str {
223    fn into_dir(self) -> Dir {
224        match self {
225            "asc" | "ASC" => Dir::Asc,
226            "desc" | "DESC" => Dir::Desc,
227            other => panic!("unknown sort direction: {other:?} (want \"asc\"/\"desc\")"),
228        }
229    }
230}
231
232// ---------------------------------------------------------------------------
233// The builder
234// ---------------------------------------------------------------------------
235
236/// Build the `Column <op> Literal` simple condition the fluent builder emits.
237fn col_op_lit(field: &str, op: Op, value: Lit) -> Condition {
238    Condition::Simple(SimpleCondition {
239        op,
240        left: ValuePosition::Column { name: field.into() },
241        right: ValuePosition::Literal { value },
242    })
243}
244
245/// Options for a `where_exists` / `where_not_exists` correlated subquery — an
246/// extensible struct so the common case stays `where_exists(f)` and opt-in behaviors
247/// ride a `..Default::default()` struct via the `_with` variants.
248#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
249pub struct ExistsOpts {
250    /// Fold this `EXISTS` as a build-time **scalar** subquery
251    /// (`SCALAR-SUBQUERY-DESIGN.md`): when the child binds a statically-unique key, the
252    /// resolver reads it once, inlines the correlation value as a literal, and deletes
253    /// the join. **Snapshot semantics** — the inlined value does not react to later
254    /// child changes (design §3). Default `false` (a live `EXISTS` join).
255    pub scalar: bool,
256}
257
258/// Build a `(NOT) EXISTS` condition over a correlated subquery.
259fn exists_cond(related: CorrelatedSubquery, op: ExistsOp, opts: ExistsOpts) -> Condition {
260    Condition::CorrelatedSubquery(CorrelatedSubqueryCondition {
261        related,
262        op,
263        flip: None,
264        scalar: opts.scalar.then_some(true),
265        plan_id: None,
266    })
267}
268
269/// Fold a group's clauses into one `Or` (a lone clause needs no wrapper). Empty is
270/// a build-time mistake — an `OR` of nothing is never true — so it fails loudly.
271fn fold_or(mut conditions: Vec<Condition>) -> Condition {
272    assert!(
273        !conditions.is_empty(),
274        "a `where_any`/`any` group is empty — add at least one condition"
275    );
276    if conditions.len() == 1 {
277        conditions.pop().expect("len checked == 1")
278    } else {
279        Condition::Or { conditions }
280    }
281}
282
283/// Fold a group's clauses into one `And` (a lone clause needs no wrapper). Empty is
284/// rejected for symmetry with [`fold_or`].
285fn fold_and(mut conditions: Vec<Condition>) -> Condition {
286    assert!(
287        !conditions.is_empty(),
288        "a `where_all`/`all` group is empty — add at least one condition"
289    );
290    if conditions.len() == 1 {
291        conditions.pop().expect("len checked == 1")
292    } else {
293        Condition::And { conditions }
294    }
295}
296
297impl Query {
298    fn new(table: &str) -> Query {
299        Query {
300            ast: Ast::new(table),
301            corr_parent: Vec::new(),
302            corr_child: Vec::new(),
303        }
304    }
305
306    /// Set this query's alias (the relationship name when it is a `sub`/`exists`
307    /// child). Usually left to [`Query::sub_as`].
308    pub fn alias(mut self, alias: &str) -> Query {
309        self.ast.alias = Some(alias.into());
310        self
311    }
312
313    /// Project a column. Chain for several (`.select("a").select("b")`); omit
314    /// entirely to select **all** columns.
315    pub fn select(mut self, col: &str) -> Query {
316        self.ast
317            .select
318            .get_or_insert_with(Vec::new)
319            .push(col.into());
320        self
321    }
322
323    /// `field = value` — **or**, if `value` is a `row.col(..)` [`Parent`] ref, a
324    /// correlation to the parent (child `field` ↔ parent column). See module docs.
325    #[allow(clippy::should_implement_trait)] // intentional: mirrors the TS `.where`
326    pub fn r#where(mut self, field: &str, value: impl IntoRhs) -> Query {
327        match value.into_rhs() {
328            Rhs::Parent(parent_col) => {
329                // Siphon into the pending correlation rather than the filter tree.
330                self.corr_child.push(field.into());
331                self.corr_parent.push(parent_col);
332            }
333            Rhs::Lit(value) => self.push_condition(col_op_lit(field, Op::Eq, value)),
334        }
335        self
336    }
337
338    /// `field <op> value` with an explicit operator (`"<"`, `">="`, `"LIKE"`, …
339    /// or an [`Op`]). The value is always a literal here — correlations go
340    /// through [`Query::where`].
341    pub fn where_op(mut self, field: &str, op: impl IntoOp, value: impl IntoLit) -> Query {
342        self.push_condition(col_op_lit(field, op.into_op(), value.into_lit()));
343        self
344    }
345
346    /// `field IN (values…)`.
347    pub fn where_in<V: IntoLit>(
348        mut self,
349        field: &str,
350        values: impl IntoIterator<Item = V>,
351    ) -> Query {
352        let arr = Lit::Array(values.into_iter().map(IntoLit::into_lit).collect());
353        self.push_condition(col_op_lit(field, Op::In, arr));
354        self
355    }
356
357    /// Add a correlated child relationship. The closure receives the parent
358    /// [`ParentRow`] and returns the child query; its `where(child, row.col(parent))`
359    /// calls define the [`Correlation`]. (Generic `related`; the closure picks the
360    /// child table.)
361    #[allow(clippy::should_implement_trait)] // intentional API name (not `std::ops::Sub`)
362    pub fn sub(self, f: impl FnOnce(ParentRow) -> Query) -> Query {
363        self.sub_inner(None, f)
364    }
365
366    /// Like [`Query::sub`], but names the relationship (sets the child's alias).
367    pub fn sub_as(self, alias: &str, f: impl FnOnce(ParentRow) -> Query) -> Query {
368        self.sub_inner(Some(alias.into()), f)
369    }
370
371    fn sub_inner(mut self, alias: Option<Box<str>>, f: impl FnOnce(ParentRow) -> Query) -> Query {
372        let mut child = f(ParentRow);
373        if alias.is_some() {
374            child.ast.alias = alias;
375        }
376        let csq = child.into_correlated("sub");
377        self.ast.related.push(csq);
378        self
379    }
380
381    /// Add a **relationship aggregate** — `issue { commentCount: count(comments) }`
382    /// (`REDUCE-DESIGN.md` §9). Same closure/correlation mechanism as [`Query::sub`]
383    /// (the child query relates to the parent via `row.col(..)`), but instead of
384    /// materializing the child rows the relationship surfaces a single scalar `count(*)`
385    /// of them, named `alias`. The builder lowers it to a grouped `reduce` + a
386    /// scalar-projected singular relationship; an empty (childless) parent reads `0`.
387    pub fn count_as(mut self, alias: &str, f: impl FnOnce(ParentRow) -> Query) -> Query {
388        let mut child = f(ParentRow);
389        child.ast.alias = Some(alias.into());
390        child.ast.aggregate = Some(Aggregate::Count);
391        let csq = child.into_correlated("count_as");
392        self.ast.related.push(csq);
393        self
394    }
395
396    /// Add a **relationship aggregate** surfacing `sum(col)` of the child rows —
397    /// `issue { totalEstimate: sum(subtasks.estimate) }` (`REDUCE-DESIGN.md` §9). Like
398    /// [`count_as`](Query::count_as) but the scalar is the Σ of the child column `col`
399    /// (non-`NULL` values); a childless parent reads `NULL` (SQL's `sum` of no rows).
400    pub fn sum_as(mut self, alias: &str, col: &str, f: impl FnOnce(ParentRow) -> Query) -> Query {
401        let mut child = f(ParentRow);
402        child.ast.alias = Some(alias.into());
403        child.ast.aggregate = Some(Aggregate::Sum(col.into()));
404        let csq = child.into_correlated("sum_as");
405        self.ast.related.push(csq);
406        self
407    }
408
409    /// Add a **relationship aggregate** surfacing `avg(col)` of the child rows —
410    /// `issue { avgEstimate: avg(subtasks.estimate) }` (`REDUCE-DESIGN.md` §9). Like
411    /// [`count_as`](Query::count_as) but the scalar is the mean of the child column `col`
412    /// over its non-`NULL` values; a childless parent reads `NULL`.
413    pub fn avg_as(mut self, alias: &str, col: &str, f: impl FnOnce(ParentRow) -> Query) -> Query {
414        let mut child = f(ParentRow);
415        child.ast.alias = Some(alias.into());
416        child.ast.aggregate = Some(Aggregate::Avg(col.into()));
417        let csq = child.into_correlated("avg_as");
418        self.ast.related.push(csq);
419        self
420    }
421
422    /// Aggregate **this** query's own rows into a top-level `count(*)`
423    /// (`REDUCE-DESIGN.md` §8) — the SQL `SELECT count(*) FROM table`. Without
424    /// [`group_by`](Query::group_by) it is a **global** count (one `[count]` row, value
425    /// `0` even on empty input); with it, one `[group…, count]` row per group. Distinct
426    /// from [`count_as`](Query::count_as), which counts a *child relationship*; this
427    /// reshapes the query itself into the aggregate. Combine with
428    /// [`having`](Query::having) to filter the post-aggregation rows.
429    pub fn count(mut self) -> Query {
430        self.ast.aggregate = Some(Aggregate::Count);
431        self
432    }
433
434    /// Aggregate **this** query's own rows into a top-level `sum(col)`
435    /// (`REDUCE-DESIGN.md` §8) — the SQL `SELECT sum(col) FROM table`. Global by default
436    /// (one `[sum]` row, `NULL` on empty input); with [`group_by`](Query::group_by), one
437    /// `[group…, sum]` row per group. Combine with [`having`](Query::having) to filter
438    /// the post-aggregation rows.
439    pub fn sum(mut self, col: &str) -> Query {
440        self.ast.aggregate = Some(Aggregate::Sum(col.into()));
441        self
442    }
443
444    /// Aggregate **this** query's own rows into a top-level `avg(col)`
445    /// (`REDUCE-DESIGN.md` §8) — the SQL `SELECT avg(col) FROM table`. Global by default
446    /// (one `[avg]` row, `NULL` on empty input); with [`group_by`](Query::group_by), one
447    /// `[group…, avg]` row per group.
448    pub fn avg(mut self, col: &str) -> Query {
449        self.ast.aggregate = Some(Aggregate::Avg(col.into()));
450        self
451    }
452
453    /// Add a top-level `GROUP BY` column; chain calls for a compound key. Use with
454    /// [`count`](Query::count), [`sum`](Query::sum), or [`avg`](Query::avg). Each group
455    /// produces one row with the group columns and its aggregate, keyed and sorted
456    /// by the group columns.
457    pub fn group_by(mut self, col: &str) -> Query {
458        self.ast.group_by.push(col.into());
459        self
460    }
461
462    /// Filter post-aggregation rows from [`count`](Query::count), [`sum`](Query::sum),
463    /// or [`avg`](Query::avg). The closure receives a fresh [`Cond`] over the output:
464    /// group columns plus the synthetic `count`, `sum`, or `avg` column. Clauses
465    /// combine with `AND`; nest with [`Cond::any`] / [`Cond::all`]. For example,
466    /// `.group_by("status").count().having(|c| c.where_op("count", ">", 3))`.
467    pub fn having(mut self, f: impl FnOnce(Cond) -> Cond) -> Query {
468        self.ast.having = Some(fold_and(f(Cond::new()).conditions));
469        self
470    }
471
472    /// Filter this parent by a **child relationship aggregate's count** —
473    /// `issue WHERE count(comments) > 10` (`PARENT-AGGREGATE-FILTER-DESIGN.md`). `alias`
474    /// must name a [`count_as`](Query::count_as) relationship already attached to this
475    /// query; this drops parents whose child count fails `<op> <val>`, maintained
476    /// incrementally (a child add/remove crossing the threshold adds/removes the parent).
477    /// The display `count_as` is untouched — the parent row still shows the real count.
478    ///
479    /// Distinct from [`having`](Query::having), which filters a **top-level**
480    /// [`count`](Query::count)'s own output rows; this gates a *parent* by a *child*
481    /// aggregate (lowered to an `EXISTS` over a `HAVING`-filtered reduce, design §3).
482    ///
483    /// **v1: high-pass predicates only.** A childless parent forms no group, so the engine
484    /// rejects (at build, [`BuildError::Unsupported`](crate::builder::BuildError)) a
485    /// predicate *true* at count 0 (`<= n`, `< n` for `n ≥ 1`, `= 0`, `>= 0`); those need
486    /// row-widening. Examples that pass are `> n` (`n ≥ 0`), `>= n`/`= n`
487    /// (`n ≥ 1`), and `!= 0`. `!= n` for nonzero `n` is rejected because it is true
488    /// at zero. Panics if `alias` is not a `count_as` relationship.
489    pub fn having_count(mut self, alias: &str, op: impl IntoOp, val: i64) -> Query {
490        let gate = {
491            let display = self
492                .ast
493                .related
494                .iter()
495                .find(|csq| {
496                    csq.subquery.aggregate == Some(Aggregate::Count)
497                        && csq.subquery.alias.as_deref() == Some(alias)
498                })
499                .unwrap_or_else(|| {
500                    panic!(
501                        "having_count({alias:?}, …): this query has no `count_as({alias:?}, …)` \
502                         relationship to filter on — attach the child count aggregate first"
503                    )
504                });
505            // Clone the display aggregate's {correlation, child, where, aggregate}, hide it
506            // under a slot-distinct alias (never colliding with the display `related`), and
507            // attach the post-aggregation `count <op> val` HAVING. The builder lowers this
508            // EXISTS over a HAVING-filtered reduce (design §4); the gate's reduce is a
509            // second fold over the same child rows (A1, design §9 — dedupe is a follow-up).
510            let mut gate = display.clone();
511            gate.subquery.alias = Some(format!("__having_{alias}").into());
512            gate.subquery.having = Some(col_op_lit("count", op.into_op(), val.into_lit()));
513            gate
514        };
515        self.push_condition(exists_cond(gate, ExistsOp::Exists, ExistsOpts::default()));
516        self
517    }
518
519    /// `WHERE EXISTS (<correlated child>)`. Same closure/correlation mechanism as
520    /// [`Query::sub`], but the child becomes an `EXISTS` filter rather than a
521    /// materialized relationship.
522    pub fn where_exists(self, f: impl FnOnce(ParentRow) -> Query) -> Query {
523        self.where_exists_with(f, ExistsOpts::default())
524    }
525
526    /// [`Query::where_exists`] with [`ExistsOpts`] — e.g. `ExistsOpts { scalar: true }`
527    /// to request a build-time scalar fold (`SCALAR-SUBQUERY-DESIGN.md`).
528    pub fn where_exists_with(
529        mut self,
530        f: impl FnOnce(ParentRow) -> Query,
531        opts: ExistsOpts,
532    ) -> Query {
533        let csq = f(ParentRow).into_correlated("where_exists");
534        self.push_condition(exists_cond(csq, ExistsOp::Exists, opts));
535        self
536    }
537
538    /// `WHERE NOT EXISTS (<correlated child>)`.
539    pub fn where_not_exists(self, f: impl FnOnce(ParentRow) -> Query) -> Query {
540        self.where_not_exists_with(f, ExistsOpts::default())
541    }
542
543    /// [`Query::where_not_exists`] with [`ExistsOpts`].
544    pub fn where_not_exists_with(
545        mut self,
546        f: impl FnOnce(ParentRow) -> Query,
547        opts: ExistsOpts,
548    ) -> Query {
549        let csq = f(ParentRow).into_correlated("where_not_exists");
550        self.push_condition(exists_cond(csq, ExistsOp::NotExists, opts));
551        self
552    }
553
554    /// `WHERE EXISTS (<correlated child>)` as a **server-only, non-syncing** gate
555    /// (`exists_noSync`, `EXISTS-NOSYNC-DESIGN.md`). Stamps the subquery `system:
556    /// Permissions`, which (a) gates parent visibility server-side exactly like
557    /// [`where_exists`](Query::where_exists), but (b) marks the gate so the normalized
558    /// serializer prunes its witnesses from the footprint — the permission table's rows are
559    /// never synced to the client, and the client never re-evaluates the gate. Build this on
560    /// the **server's** query; the client holds its own un-gated query.
561    pub fn where_exists_no_sync(mut self, f: impl FnOnce(ParentRow) -> Query) -> Query {
562        let mut csq = f(ParentRow).into_correlated("where_exists_no_sync");
563        csq.system = Some(System::Permissions);
564        self.push_condition(exists_cond(csq, ExistsOp::Exists, ExistsOpts::default()));
565        self
566    }
567
568    /// `WHERE NOT EXISTS (<correlated child>)` as a **server-only, non-syncing** gate — the
569    /// `NOT EXISTS` form of [`where_exists_no_sync`](Query::where_exists_no_sync) (a deny-style
570    /// permission rule). A `NOT EXISTS` gate passes on zero children, so it carries no
571    /// witnesses to sync; the `system: Permissions` stamp is recorded for symmetry and to keep
572    /// the gate off the client.
573    pub fn where_not_exists_no_sync(mut self, f: impl FnOnce(ParentRow) -> Query) -> Query {
574        let mut csq = f(ParentRow).into_correlated("where_not_exists_no_sync");
575        csq.system = Some(System::Permissions);
576        self.push_condition(exists_cond(csq, ExistsOp::NotExists, ExistsOpts::default()));
577        self
578    }
579
580    /// `WHERE (c1 OR c2 OR …)` — an **OR** group. The closure receives a fresh
581    /// [`Cond`] to which it adds clauses (`where`/`where_op`/`where_in`/
582    /// `where_exists`, or nested `any`/`all`). The group `AND`-combines with any
583    /// other top-level `where`s, exactly like the simple forms.
584    pub fn where_any(mut self, f: impl FnOnce(Cond) -> Cond) -> Query {
585        self.push_condition(fold_or(f(Cond::new()).conditions));
586        self
587    }
588
589    /// `WHERE (c1 AND c2 AND …)` — an explicit **AND** group. Redundant at the top
590    /// level (chained `where`s already `AND`), but the way to express a grouped
591    /// `AND` *nested inside* a [`Query::where_any`], e.g. `(a AND b) OR c`.
592    pub fn where_all(mut self, f: impl FnOnce(Cond) -> Cond) -> Query {
593        self.push_condition(fold_and(f(Cond::new()).conditions));
594        self
595    }
596
597    /// Cap the number of rows.
598    pub fn limit(mut self, n: u32) -> Query {
599        self.ast.limit = Some(n);
600        self
601    }
602
603    /// Return a **single** row: the result is presented as one object (or
604    /// `null`/absent) instead of an array. Records the intent on the AST
605    /// ([`Ast::one`]) and caps the query to one row (`limit = 1`). The engine stays
606    /// plural internally; the single-element unwrap happens at the result boundary.
607    /// Used on a `sub`/`sub_as` child query, it makes **that** relationship singular.
608    pub fn one(mut self) -> Query {
609        self.ast.one = true;
610        self.ast.limit = Some(1);
611        self
612    }
613
614    /// Append an ordering term (`"asc"`/`"desc"` or a [`Dir`]). Chain for a
615    /// compound sort.
616    pub fn order_by(mut self, field: &str, dir: impl IntoDir) -> Query {
617        self.ast
618            .order_by
619            .push(OrderPart(field.into(), dir.into_dir()));
620        self
621    }
622
623    /// Page from `col = val`, **inclusive** of that row.
624    pub fn start_at(self, col: &str, val: impl IntoLit) -> Query {
625        self.start_row(vec![(col.into(), val.into_lit())], false)
626    }
627
628    /// Page from `col = val`, **exclusive** of that row.
629    pub fn start_after(self, col: &str, val: impl IntoLit) -> Query {
630        self.start_row(vec![(col.into(), val.into_lit())], true)
631    }
632
633    /// Set a (possibly multi-column) paging bound directly. `exclusive` ⇒ skip the
634    /// bound row.
635    pub fn start_row(mut self, row: Vec<(Box<str>, Lit)>, exclusive: bool) -> Query {
636        self.ast.start = Some(Bound {
637            row: row.into_iter().collect(),
638            exclusive,
639        });
640        self
641    }
642
643    /// Finish building and yield the [`Ast`].
644    pub fn build(self) -> Ast {
645        debug_assert!(
646            self.corr_child.is_empty(),
647            "build() called on a query holding an unconsumed parent correlation — a \
648             `row.col(..)` reference escaped its `sub`/`where_exists` closure"
649        );
650        self.ast
651    }
652
653    /// AND a new condition into the `where` tree, flattening a top-level `And`.
654    fn push_condition(&mut self, cond: Condition) {
655        self.ast.r#where = Some(match self.ast.r#where.take() {
656            None => cond,
657            Some(Condition::And { mut conditions }) => {
658                conditions.push(cond);
659                Condition::And { conditions }
660            }
661            Some(existing) => Condition::And {
662                conditions: vec![existing, cond],
663            },
664        });
665    }
666
667    /// Drain the pending correlation into a [`CorrelatedSubquery`]. Panics if no
668    /// correlation was established (a `sub`/`exists` child must relate to its
669    /// parent) — a loud, test-time guard against the easy mistake of forgetting
670    /// the `row.col(..)` link.
671    fn into_correlated(self, what: &str) -> CorrelatedSubquery {
672        assert!(
673            !self.corr_child.is_empty(),
674            "{what}() child has no correlation — relate it to the parent with \
675             `.r#where(childCol, row.col(parentCol))`"
676        );
677        debug_assert_eq!(
678            self.corr_parent.len(),
679            self.corr_child.len(),
680            "correlation key halves must be the same length"
681        );
682        CorrelatedSubquery {
683            correlation: Correlation {
684                parent_field: self.corr_parent,
685                child_field: self.corr_child,
686            },
687            subquery: Box::new(self.ast),
688            system: None,
689        }
690    }
691}
692
693// ---------------------------------------------------------------------------
694// Cond — a nestable boolean group (the `AND`/`OR` tree builder)
695// ---------------------------------------------------------------------------
696
697/// A boolean **condition group** under construction — the building block for
698/// nested `AND`/`OR` filter trees. You don't make one directly; a fresh `Cond` is
699/// handed to the closure of [`Query::where_any`] / [`Query::where_all`] (and the
700/// nested [`Cond::any`] / [`Cond::all`]). Add clauses by chaining, just like
701/// [`Query`].
702///
703/// **No correlations here.** A correlation (`row.col(..)`) is a property of an
704/// EXISTS subquery, never of a free-standing condition, so [`Cond::where`] takes a
705/// plain literal (`impl IntoLit`). To correlate, nest a [`Cond::where_exists`]
706/// whose *child* query carries the `row.col(..)` link.
707pub struct Cond {
708    /// The clauses accumulated so far; folded into one `And`/`Or` by the enclosing
709    /// `where_all`/`where_any` (or `all`/`any`).
710    conditions: Vec<Condition>,
711}
712
713impl Cond {
714    fn new() -> Cond {
715        Cond {
716            conditions: Vec::new(),
717        }
718    }
719
720    /// `field = value` (a literal — see the type docs on why correlations don't
721    /// belong in a group).
722    #[allow(clippy::should_implement_trait)] // mirrors `Query::where`
723    pub fn r#where(mut self, field: &str, value: impl IntoLit) -> Cond {
724        self.conditions
725            .push(col_op_lit(field, Op::Eq, value.into_lit()));
726        self
727    }
728
729    /// `field <op> value` with an explicit operator (`"<"`, `">="`, `"LIKE"`, … or
730    /// an [`Op`]).
731    pub fn where_op(mut self, field: &str, op: impl IntoOp, value: impl IntoLit) -> Cond {
732        self.conditions
733            .push(col_op_lit(field, op.into_op(), value.into_lit()));
734        self
735    }
736
737    /// `field IN (values…)`.
738    pub fn where_in<V: IntoLit>(
739        mut self,
740        field: &str,
741        values: impl IntoIterator<Item = V>,
742    ) -> Cond {
743        let arr = Lit::Array(values.into_iter().map(IntoLit::into_lit).collect());
744        self.conditions.push(col_op_lit(field, Op::In, arr));
745        self
746    }
747
748    /// `EXISTS (<correlated subquery>)` — same closure/correlation mechanism as
749    /// [`Query::where_exists`].
750    pub fn where_exists(self, f: impl FnOnce(ParentRow) -> Query) -> Cond {
751        self.where_exists_with(f, ExistsOpts::default())
752    }
753
754    /// [`Cond::where_exists`] with [`ExistsOpts`] (e.g. `{ scalar: true }`).
755    pub fn where_exists_with(
756        mut self,
757        f: impl FnOnce(ParentRow) -> Query,
758        opts: ExistsOpts,
759    ) -> Cond {
760        let csq = f(ParentRow).into_correlated("where_exists");
761        self.conditions
762            .push(exists_cond(csq, ExistsOp::Exists, opts));
763        self
764    }
765
766    /// `NOT EXISTS (<correlated subquery>)`.
767    pub fn where_not_exists(self, f: impl FnOnce(ParentRow) -> Query) -> Cond {
768        self.where_not_exists_with(f, ExistsOpts::default())
769    }
770
771    /// [`Cond::where_not_exists`] with [`ExistsOpts`].
772    pub fn where_not_exists_with(
773        mut self,
774        f: impl FnOnce(ParentRow) -> Query,
775        opts: ExistsOpts,
776    ) -> Cond {
777        let csq = f(ParentRow).into_correlated("where_not_exists");
778        self.conditions
779            .push(exists_cond(csq, ExistsOp::NotExists, opts));
780        self
781    }
782
783    /// `EXISTS (<correlated subquery>)` as a **server-only, non-syncing** gate — the nestable
784    /// form of [`Query::where_exists_no_sync`].
785    pub fn where_exists_no_sync(mut self, f: impl FnOnce(ParentRow) -> Query) -> Cond {
786        let mut csq = f(ParentRow).into_correlated("where_exists_no_sync");
787        csq.system = Some(System::Permissions);
788        self.conditions
789            .push(exists_cond(csq, ExistsOp::Exists, ExistsOpts::default()));
790        self
791    }
792
793    /// `NOT EXISTS (<correlated subquery>)` as a **server-only, non-syncing** gate — the
794    /// nestable form of [`Query::where_not_exists_no_sync`].
795    pub fn where_not_exists_no_sync(mut self, f: impl FnOnce(ParentRow) -> Query) -> Cond {
796        let mut csq = f(ParentRow).into_correlated("where_not_exists_no_sync");
797        csq.system = Some(System::Permissions);
798        self.conditions
799            .push(exists_cond(csq, ExistsOp::NotExists, ExistsOpts::default()));
800        self
801    }
802
803    /// Nest an **OR** sub-group: `(… OR …)`.
804    pub fn any(mut self, f: impl FnOnce(Cond) -> Cond) -> Cond {
805        self.conditions.push(fold_or(f(Cond::new()).conditions));
806        self
807    }
808
809    /// Nest an **AND** sub-group: `(… AND …)`.
810    pub fn all(mut self, f: impl FnOnce(Cond) -> Cond) -> Cond {
811        self.conditions.push(fold_and(f(Cond::new()).conditions));
812        self
813    }
814}