Rindle docs and package mapSkip to main content

rindle/
ast.rs

1//! The query **AST** — the wire-format, target-agnostic query input the
2//! [`crate::query`] fluent builder crafts and the pipeline builder (spec `08`)
3//! lowers into an arena `Graph`.
4//!
5//! This is the **wire shape** of spec `02-ast.md`: the types deserialize from the
6//! *same JSON* the TypeScript `astSchema` (`zero-protocol/src/ast.ts`) produces,
7//! so a JS-emitted query can drive the Rust builder unchanged — the shared-AST
8//! differential corpus (spec `11` §3.6). The earlier trimmed spike
9//! (`Condition::Simple { field, op, value }`, separate `Exists`/`NotExists`,
10//! `Int`/`Float` literals) is gone; what replaced it:
11//!
12//! - **`SimpleCondition { op, left, right }`** over [`ValuePosition`] (a column or a
13//!   literal), so a column on either side is representable. The fluent builder still
14//!   only emits `Column <op> Literal`.
15//! - **One [`Condition::CorrelatedSubquery`]** carrying a [`CorrelatedSubqueryCondition`]
16//!   (`op: EXISTS | NOT EXISTS`, plus `flip`/`scalar`/`plan_id`) — replacing the two
17//!   `Exists`/`NotExists` variants — matching the wire `correlatedSubquery` condition.
18//! - **[`Lit::Number`] is one `f64`**, with an exact [`Lit::Int`] beside it (design
19//!   226 Stage B): an integer JSON token parses as `Int` (all 64 bits), a float
20//!   token as `Number`; both lower identically for integral values in ±2^53.
21//!
22//! **serde derives are feature-gated** behind `any(testkit, serde)`. The AST is the
23//! wire format, so two consumers need it: the test-only
24//! differential corpus (`testkit`) and — per productionization decision **D1** —
25//! the wasm client, which deserializes JS-emitted query JSON in the *shipping*
26//! artifact (via `serde-wasm-bindgen`). D1 deliberately overrides the spec `11`
27//! §1.2 "never in the shipping wasm artifact" rule for the wasm path. `testkit`
28//! depends on the `serde` feature transitively, so its behavior is unchanged. When
29//! neither feature is on, these are plain owned-data types with no serde.
30//!
31//! `ValuePosition` contains columns or literals, not static parameters. The builder
32//! validates supported shapes and completes ordering with primary keys. Native query
33//! planning can annotate join direction before pipeline construction.
34//!
35//! [`Ast::select`] extends the wire shape with projection: `None` selects all columns;
36//! `Some(cols)` selects those columns. It serializes when set. The builder derives
37//! the result schema while retaining keys and inputs required for query maintenance.
38//!
39//! Every type is `Clone + Debug + PartialEq` (so hand-written "expected" ASTs in
40//! tests compare with `assert_eq!`). [`Ast`] is `Default` for struct-update syntax:
41//! `Ast { table: "issue".into(), ..Default::default() }`.
42
43use std::collections::BTreeMap;
44
45use crate::canon::CanonVal;
46
47/// Sort direction for an [`OrderPart`]. Wire `'asc' | 'desc'` (`ast.ts:24`).
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49#[cfg_attr(
50    any(feature = "testkit", feature = "serde"),
51    derive(serde::Serialize, serde::Deserialize),
52    serde(rename_all = "lowercase")
53)]
54pub enum Dir {
55    Asc,
56    Desc,
57}
58
59/// A simple comparison operator — the wire `SimpleOperator` set (`ast.ts:211-215`).
60/// Serializes as the exact SQL-ish string (`"="`, `"!="`, `"IS NOT"`, `"NOT IN"`, …).
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62#[cfg_attr(
63    any(feature = "testkit", feature = "serde"),
64    derive(serde::Serialize, serde::Deserialize)
65)]
66pub enum Op {
67    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "="))]
68    Eq,
69    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "!="))]
70    Ne,
71    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "<"))]
72    Lt,
73    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "<="))]
74    Le,
75    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = ">"))]
76    Gt,
77    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = ">="))]
78    Ge,
79    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "IS"))]
80    Is,
81    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "IS NOT"))]
82    IsNot,
83    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "LIKE"))]
84    Like,
85    #[cfg_attr(
86        any(feature = "testkit", feature = "serde"),
87        serde(rename = "NOT LIKE")
88    )]
89    NotLike,
90    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "ILIKE"))]
91    ILike,
92    #[cfg_attr(
93        any(feature = "testkit", feature = "serde"),
94        serde(rename = "NOT ILIKE")
95    )]
96    NotILike,
97    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "IN"))]
98    In,
99    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "NOT IN"))]
100    NotIn,
101}
102
103/// An AST literal value (`LiteralValue`, `ast.ts:284-289`). Its own type — distinct
104/// from the runtime [`crate::value::OwnedValue`] — so the AST derives `PartialEq`
105/// (`OwnedValue` forbids derived comparison; you must pick `compare_values` vs
106/// `values_equal`). The two are bridged at lowering time (builder, `08`).
107///
108/// serde-`untagged`: a JSON `null`/`bool`/`number`/`string`/`array` round-trips to
109/// the matching variant. An **integer** JSON token deserializes into `Int` (exact,
110/// all 64 bits — design 226 Stage B); a float token into `Number` (`f64`). The two
111/// lower identically for every integral value in ±2^53 (`number_to_owned` already
112/// produced `OwnedValue::Int` there), so a JS client serializing `5.0` as `5`
113/// changes nothing; what `Int` adds is exactness ABOVE 2^53, where the old
114/// `Number(f64)` round-trip rounded (the fluent API's `i64` impl was lossy).
115#[derive(Clone, Debug, PartialEq)]
116#[cfg_attr(
117    any(feature = "testkit", feature = "serde"),
118    derive(serde::Serialize, serde::Deserialize),
119    serde(untagged)
120)]
121pub enum Lit {
122    /// JSON `null`.
123    Null,
124    Bool(bool),
125    /// An exact integer literal (a JSON integer token). Ordered BEFORE `Number` so
126    /// untagged deserialization tries i64 first; a non-integral or out-of-i64-range
127    /// number falls through to `Number`.
128    Int(i64),
129    /// JS `number` — a single `f64`.
130    Number(f64),
131    Str(Box<str>),
132    /// The right-hand side of an `IN` / `NOT IN` — a list of scalars.
133    Array(Vec<Lit>),
134}
135
136/// Re-key every [`Lit::Number`] in `ast` onto the wire-token rule — the identity a
137/// JSON home derives (design 226 §6). serde_json sees `JSON.stringify`'s
138/// SHORTEST-round-trip decimal token and parses an integer-form token in i64 range
139/// as [`Lit::Int`]; an `Ast` that entered through a non-JSON deserializer
140/// (serde-wasm-bindgen visits every non-safe-integer JS number as f64) instead
141/// carries the BINARY value. For `2**60` that is `Int(1152921504606846976)` where
142/// every JSON home parses `Int(1152921504606847000)` — two exact literals for one
143/// query text, so a predicate or resume cursor matches on one home and misses on
144/// the other. Rust's `Display` emits the same shortest-round-trip digits as
145/// `JSON.stringify` across the integer-form range, so formatting the f64 and
146/// re-parsing the token converges the homes on one identity. Non-integral and
147/// out-of-i64-token values stay [`Lit::Number`] — exactly serde_json's
148/// fallthrough — and NaN/±Infinity become [`Lit::Null`], because that IS their
149/// wire token (`JSON.stringify` emits `null` for non-finite numbers). Call at
150/// every non-JSON AST entry (the wasm boundary).
151pub fn canonicalize_wire_number_lits(ast: &mut Ast) {
152    if let Some(cond) = ast.r#where.as_mut() {
153        canonicalize_condition(cond);
154    }
155    if let Some(cond) = ast.having.as_mut() {
156        canonicalize_condition(cond);
157    }
158    if let Some(bound) = ast.start.as_mut() {
159        for lit in bound.row.values_mut() {
160            canonicalize_lit(lit);
161        }
162    }
163    for rel in &mut ast.related {
164        canonicalize_wire_number_lits(&mut rel.subquery);
165    }
166}
167
168fn canonicalize_condition(cond: &mut Condition) {
169    match cond {
170        Condition::Simple(s) => {
171            canonicalize_value_position(&mut s.left);
172            canonicalize_value_position(&mut s.right);
173        }
174        Condition::And { conditions } | Condition::Or { conditions } => {
175            conditions.iter_mut().for_each(canonicalize_condition)
176        }
177        Condition::CorrelatedSubquery(c) => canonicalize_wire_number_lits(&mut c.related.subquery),
178    }
179}
180
181fn canonicalize_value_position(vp: &mut ValuePosition) {
182    if let ValuePosition::Literal { value } = vp {
183        canonicalize_lit(value);
184    }
185}
186
187fn canonicalize_lit(lit: &mut Lit) {
188    match lit {
189        Lit::Number(f) => {
190            if !f.is_finite() {
191                // `JSON.stringify` has no token for NaN/±Infinity — it emits `null`,
192                // so every JSON home receives `Lit::Null`. Same identity here.
193                *lit = Lit::Null;
194            } else if f.fract() == 0.0 {
195                if let Ok(i) = format!("{f}").parse::<i64>() {
196                    *lit = Lit::Int(i);
197                }
198            }
199        }
200        Lit::Array(items) => items.iter_mut().for_each(canonicalize_lit),
201        _ => {}
202    }
203}
204
205/// The **binding-value class** of a scalar literal (design 310 §3.1 / impl plan §3.2):
206/// `Some` iff `lit` is a scalar a parameterized query family may bind on — `Bool`/`Int`/
207/// `Str` directly, `Number` through the same number coercion the predicate lowering
208/// applies (`lit_to_scalar` → `number_to_owned`) and then [`CanonVal::of`], so `Int(1)`
209/// and `Number(1.0)` are one binding exactly when `values_equal` says so, and a binding
210/// agrees cell-for-cell with the `col = lit` predicate it stands in for. `Null` (SQL
211/// never-match — the predicate folds it to `false`), `Array` (an `IN` list), and a
212/// non-finite `Number` (no wire token; [`canonicalize_wire_number_lits`] folds it to
213/// `Null`) are `None` — ineligible.
214pub fn canon_of_lit(lit: &Lit) -> Option<CanonVal> {
215    match lit {
216        Lit::Null | Lit::Array(_) => None,
217        Lit::Bool(b) => Some(CanonVal::Bool(*b)),
218        Lit::Int(i) => Some(CanonVal::Int(*i)),
219        Lit::Number(f) if !f.is_finite() => None,
220        Lit::Number(f) => Some(CanonVal::of(&crate::builder::number_to_owned(*f))),
221        Lit::Str(s) => Some(CanonVal::Str(std::sync::Arc::from(&**s))),
222    }
223}
224
225/// A value position in a [`SimpleCondition`] — a column reference or a literal
226/// (`ValuePosition`, `ast.ts:267`, minus the deprecated `static` parameter form).
227/// Wire-tagged by `"type"`.
228#[derive(Clone, Debug, PartialEq)]
229#[cfg_attr(
230    any(feature = "testkit", feature = "serde"),
231    derive(serde::Serialize, serde::Deserialize),
232    serde(tag = "type")
233)]
234pub enum ValuePosition {
235    /// `{ type: "literal", value }` (`ast.ts:279-282`).
236    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "literal"))]
237    Literal { value: Lit },
238    /// `{ type: "column", name }` (`ast.ts:269-277`). Name stays a `String` — name
239    /// → `ColId` lowering is the builder's job (`08` §5.5).
240    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "column"))]
241    Column { name: Box<str> },
242}
243
244/// The filter tree (`Condition`, `ast.ts:296-300`). Wire-tagged by `"type"`;
245/// recursive through the `Vec`s (heap-boxed elements) and the boxed subquery.
246#[derive(Clone, Debug, PartialEq)]
247#[cfg_attr(
248    any(feature = "testkit", feature = "serde"),
249    derive(serde::Serialize, serde::Deserialize),
250    serde(tag = "type")
251)]
252pub enum Condition {
253    /// `field <op> value`.
254    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "simple"))]
255    Simple(SimpleCondition),
256    /// All children must hold (`{ type: "and", conditions }`).
257    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "and"))]
258    And { conditions: Vec<Condition> },
259    /// At least one child must hold (`{ type: "or", conditions }`).
260    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "or"))]
261    Or { conditions: Vec<Condition> },
262    /// `(NOT) EXISTS (<correlated subquery>)`.
263    #[cfg_attr(
264        any(feature = "testkit", feature = "serde"),
265        serde(rename = "correlatedSubquery")
266    )]
267    CorrelatedSubquery(CorrelatedSubqueryCondition),
268}
269
270/// A single comparison (`SimpleCondition`, `ast.ts:302-312`). `right` is wire-typed
271/// to exclude a column (`Exclude<ValuePosition, ColumnReference>`); the builder
272/// validates "not a column" at its boundary.
273#[derive(Clone, Debug, PartialEq)]
274#[cfg_attr(
275    any(feature = "testkit", feature = "serde"),
276    derive(serde::Serialize, serde::Deserialize)
277)]
278pub struct SimpleCondition {
279    pub op: Op,
280    /// LHS — a value position (the fluent builder always emits a `Column`).
281    pub left: ValuePosition,
282    /// RHS — a `Literal`, never a `Column` (enforced in the builder).
283    pub right: ValuePosition,
284}
285
286/// A `(NOT) EXISTS` condition (`CorrelatedSubqueryCondition`, `ast.ts:324-331`).
287#[derive(Clone, Debug, PartialEq)]
288#[cfg_attr(
289    any(feature = "testkit", feature = "serde"),
290    derive(serde::Serialize, serde::Deserialize)
291)]
292pub struct CorrelatedSubqueryCondition {
293    /// The subquery + how it correlates to the parent.
294    pub related: CorrelatedSubquery,
295    /// `EXISTS` | `NOT EXISTS`.
296    pub op: ExistsOp,
297    /// Flipped-join routing flag (read by the builder / planner). Deferred path.
298    #[cfg_attr(
299        any(feature = "testkit", feature = "serde"),
300        serde(default, skip_serializing_if = "Option::is_none")
301    )]
302    pub flip: Option<bool>,
303    /// Scalar-subquery flag. Deferred path.
304    #[cfg_attr(
305        any(feature = "testkit", feature = "serde"),
306        serde(default, skip_serializing_if = "Option::is_none")
307    )]
308    pub scalar: Option<bool>,
309    /// Build-time planner annotation — **not** wire data (`#[serde(skip)]`), set by
310    /// the planner (deferred), and always `None` today. Once the planner sets it, it
311    /// must be excluded from `PartialEq`/canonical ordering (spec `02` §4.4); it is
312    /// `None`-only now, so the derived `PartialEq` is correct in the interim.
313    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(skip))]
314    pub plan_id: Option<u32>,
315}
316
317/// `'EXISTS' | 'NOT EXISTS'` (`CorrelatedSubqueryConditionOperator`, `ast.ts:333`).
318#[derive(Clone, Copy, Debug, PartialEq, Eq)]
319#[cfg_attr(
320    any(feature = "testkit", feature = "serde"),
321    derive(serde::Serialize, serde::Deserialize)
322)]
323pub enum ExistsOp {
324    #[cfg_attr(any(feature = "testkit", feature = "serde"), serde(rename = "EXISTS"))]
325    Exists,
326    #[cfg_attr(
327        any(feature = "testkit", feature = "serde"),
328        serde(rename = "NOT EXISTS")
329    )]
330    NotExists,
331}
332
333/// A child query joined to its parent by a key [`Correlation`]
334/// (`CorrelatedSubquery`, `ast.ts:250-265`). Used both as a materialized
335/// relationship ([`Ast::related`]) and inside a [`CorrelatedSubqueryCondition`].
336#[derive(Clone, Debug, PartialEq)]
337#[cfg_attr(
338    any(feature = "testkit", feature = "serde"),
339    derive(serde::Serialize, serde::Deserialize)
340)]
341pub struct CorrelatedSubquery {
342    /// How the child relates to the parent (the join key pair).
343    pub correlation: Correlation,
344    /// The child query. Boxed to break the `Ast`→`CorrelatedSubquery`→`Ast` cycle.
345    pub subquery: Box<Ast>,
346    /// Origin: `'client' | 'permissions' | 'test'`. Wire-optional; the builder
347    /// defaults absent ⇒ `client`.
348    #[cfg_attr(
349        any(feature = "testkit", feature = "serde"),
350        serde(default, skip_serializing_if = "Option::is_none")
351    )]
352    pub system: Option<System>,
353}
354
355/// Subquery provenance (`System`, `ast.ts:28`). Shared with the runtime layer.
356#[derive(Clone, Copy, Debug, PartialEq, Eq)]
357#[cfg_attr(
358    any(feature = "testkit", feature = "serde"),
359    derive(serde::Serialize, serde::Deserialize),
360    serde(rename_all = "lowercase")
361)]
362pub enum System {
363    Permissions,
364    Client,
365    Test,
366}
367
368/// The join key pair (`Correlation`, `ast.ts:245-248`). `parent_field[i]` on the
369/// parent correlates with `child_field[i]` on the child; both non-empty, same length.
370#[derive(Clone, Debug, PartialEq)]
371#[cfg_attr(
372    any(feature = "testkit", feature = "serde"),
373    derive(serde::Serialize, serde::Deserialize),
374    serde(rename_all = "camelCase")
375)]
376pub struct Correlation {
377    /// Column name(s) on the **parent** table.
378    pub parent_field: Vec<Box<str>>,
379    /// Column name(s) on the **child** (subquery) table.
380    pub child_field: Vec<Box<str>>,
381}
382
383/// One `(field, direction)` of an ordering (`OrderPart`, `ast.ts:208`). A wire
384/// 2-tuple — serializes as the JSON array `["field", "asc"]`.
385#[derive(Clone, Debug, PartialEq)]
386#[cfg_attr(
387    any(feature = "testkit", feature = "serde"),
388    derive(serde::Serialize, serde::Deserialize)
389)]
390pub struct OrderPart(pub Box<str>, pub Dir);
391
392impl OrderPart {
393    /// The order-by column name.
394    pub fn field(&self) -> &str {
395        &self.0
396    }
397    /// The sort direction.
398    pub fn dir(&self) -> Dir {
399        self.1
400    }
401}
402
403/// A paging lower bound (`Bound`, `ast.ts:199-202`). `row` is a *partial* wire row —
404/// the bound columns by name (a `BTreeMap` for deterministic key order, matching the
405/// wire object). `exclusive` maps to the runtime `Basis` (`false` ⇒ `At`/inclusive,
406/// `true` ⇒ `After`/exclusive) at lowering, in the builder/`Skip` (spec `03` §3.5).
407#[derive(Clone, Debug, PartialEq)]
408#[cfg_attr(
409    any(feature = "testkit", feature = "serde"),
410    derive(serde::Serialize, serde::Deserialize)
411)]
412pub struct Bound {
413    pub row: BTreeMap<Box<str>, Lit>,
414    pub exclusive: bool,
415}
416
417/// An aggregate over a (correlated) subquery's rows (`REDUCE-DESIGN.md`).
418///
419/// Set on a [`Ast`] that is a `related` subquery, it marks that relationship as a
420/// **relationship aggregate** — `issue { commentCount: count(comments) }` — which the
421/// builder (`08`) lowers to a grouped `reduce` + a scalar-projected singular relationship
422/// (§9) instead of a row-materializing join. Set on the **root** `Ast`, it is a top-level
423/// aggregate (`SELECT count(*) FROM …`, optionally with `group_by`/`having`).
424///
425/// `Sum`/`Avg` carry the **column name** they aggregate (resolved to a child/source
426/// [`ColId`](crate::value::ColId) by the builder). All three are *invertible*, so
427/// `reduce` maintains them from a pure delta stream. Serde is externally tagged: `Count`
428/// ⇒ the bare string `"count"`; `Sum(c)` ⇒ `{"sum": c}`; `Avg(c)` ⇒ `{"avg": c}`.
429#[derive(Clone, Debug, PartialEq, Eq)]
430#[cfg_attr(
431    any(feature = "testkit", feature = "serde"),
432    derive(serde::Serialize, serde::Deserialize),
433    serde(rename_all = "lowercase")
434)]
435pub enum Aggregate {
436    /// `count(*)` over the subquery's (filtered) rows.
437    Count,
438    /// `sum(col)` over the subquery's (filtered) rows — Σ of the non-`NULL` values.
439    Sum(Box<str>),
440    /// `avg(col)` over the subquery's (filtered) rows — `sum / count` of the non-`NULL`
441    /// values (`NULL` when there are none).
442    Avg(Box<str>),
443}
444
445/// `serde(skip_serializing_if)` predicate for a `bool` field that defaults to `false`
446/// (so the wire JSON omits it unless set) — used by [`Ast::one`].
447#[cfg(any(feature = "testkit", feature = "serde"))]
448fn is_false(b: &bool) -> bool {
449    !*b
450}
451
452/// The query AST (`Ast`, `ast.ts:217-243`). `table` is the only required field;
453/// every other wire field is optional. `Default` enables struct-update syntax for
454/// hand-written test expectations. Deserializes from the JS wire JSON (camelCase
455/// keys; absent ⇒ `None`/empty).
456#[derive(Clone, Debug, PartialEq, Default)]
457#[cfg_attr(
458    any(feature = "testkit", feature = "serde"),
459    derive(serde::Serialize, serde::Deserialize),
460    serde(rename_all = "camelCase")
461)]
462pub struct Ast {
463    /// Postgres schema namespace — opaque pass-through (`ast.ts:218`).
464    #[cfg_attr(
465        any(feature = "testkit", feature = "serde"),
466        serde(default, skip_serializing_if = "Option::is_none")
467    )]
468    pub schema: Option<Box<str>>,
469    /// Source table name. The only required field.
470    pub table: Box<str>,
471    /// Subquery alias (the relationship name, when this is a `sub`/`exists` child).
472    #[cfg_attr(
473        any(feature = "testkit", feature = "serde"),
474        serde(default, skip_serializing_if = "Option::is_none")
475    )]
476    pub alias: Option<Box<str>>,
477    /// Projection. `None` ⇒ **select all columns**; `Some(cols)` ⇒ just `cols`.
478    /// Sanctioned non-wire extension (see module docs); serialized only when set.
479    #[cfg_attr(
480        any(feature = "testkit", feature = "serde"),
481        serde(default, skip_serializing_if = "Option::is_none")
482    )]
483    pub select: Option<Vec<Box<str>>>,
484    /// Filter tree.
485    #[cfg_attr(
486        any(feature = "testkit", feature = "serde"),
487        serde(default, skip_serializing_if = "Option::is_none")
488    )]
489    pub r#where: Option<Condition>,
490    /// Child subqueries (materialized relationships). Empty ⇒ none.
491    #[cfg_attr(
492        any(feature = "testkit", feature = "serde"),
493        serde(default, skip_serializing_if = "Vec::is_empty")
494    )]
495    pub related: Vec<CorrelatedSubquery>,
496    /// Paging lower bound.
497    #[cfg_attr(
498        any(feature = "testkit", feature = "serde"),
499        serde(default, skip_serializing_if = "Option::is_none")
500    )]
501    pub start: Option<Bound>,
502    /// Row limit.
503    #[cfg_attr(
504        any(feature = "testkit", feature = "serde"),
505        serde(default, skip_serializing_if = "Option::is_none")
506    )]
507    pub limit: Option<u32>,
508    /// `.one()` — return a **single** row: the result is presented as one object (or
509    /// `null`/absent) instead of an array. Query *intent* recorded on the AST; the
510    /// engine stays plural internally and the single-element unwrap happens at the
511    /// result boundary (the builder lowers `one` onto the view
512    /// [`Schema`](crate::value::Schema)'s `singular` flag). A `.one()` query also sets
513    /// `limit = 1`. On a `related` subquery, `one` makes **that** relationship singular.
514    #[cfg_attr(
515        any(feature = "testkit", feature = "serde"),
516        serde(default, skip_serializing_if = "is_false")
517    )]
518    pub one: bool,
519    /// Aggregate this (sub)query's rows instead of materializing them
520    /// (`REDUCE-DESIGN.md`). `None` ⇒ ordinary row output; `Some` on a `related`
521    /// subquery ⇒ a relationship aggregate the builder lowers to a `reduce` + a
522    /// scalar-projected singular relationship (§9). Absent on the wire ⇒ `None`.
523    #[cfg_attr(
524        any(feature = "testkit", feature = "serde"),
525        serde(default, skip_serializing_if = "Option::is_none")
526    )]
527    pub aggregate: Option<Aggregate>,
528    /// The [`aggregate`](Ast::aggregate) is **precomputed** — its `(group_key…, value)`
529    /// rows are supplied as a (synthetic) source table rather than reduced from child
530    /// rows. Set by the normalized client's AST rewrite (`AGGREGATE-SYNC-DESIGN.md` §3.3):
531    /// the server ships the reduce's output as a base table, and the client reads it with
532    /// a plain singular join + the *same* scalar projection — **not** a `reduce`, which
533    /// would recount the already-aggregated rows. Only meaningful alongside `aggregate`;
534    /// absent on the wire ⇒ `false` (the ordinary reduce-backed relationship aggregate).
535    #[cfg_attr(
536        any(feature = "testkit", feature = "serde"),
537        serde(default, skip_serializing_if = "is_false")
538    )]
539    pub aggregate_precomputed: bool,
540    /// Top-level `GROUP BY` columns (names, not ColIds), meaningful only alongside a
541    /// root [`aggregate`](Ast::aggregate) (`REDUCE-DESIGN.md` §8). Empty + `aggregate`
542    /// set produces one global aggregate row; non-empty produces one
543    /// `[group…, aggregate]` row per distinct value-tuple. The builder lowers this to an
544    /// **eager** grouped `reduce` feeding the `View`. Distinct from a relationship
545    /// aggregate's grouping, which is implicit (the correlation child key). Absent on
546    /// the wire ⇒ empty.
547    #[cfg_attr(
548        any(feature = "testkit", feature = "serde"),
549        serde(default, skip_serializing_if = "Vec::is_empty")
550    )]
551    pub group_by: Vec<Box<str>>,
552    /// `HAVING` — a filter over the **post-aggregation** rows of a root
553    /// [`aggregate`](Ast::aggregate) (`REDUCE-DESIGN.md` §4: a `HAVING` filter sits
554    /// directly above the `reduce`). The condition addresses the aggregate's *output*
555    /// columns — the [`group_by`](Ast::group_by) columns and the synthetic `count`,
556    /// `sum`, or `avg` column — not base-table columns. The builder lowers it to a `Filter` sub-graph
557    /// **above** the reduce (whereas [`where`](Ast::where) filters rows *below* it);
558    /// the `Filter` edit-split turns a group crossing the predicate threshold into an
559    /// `Add`/`Remove`, so it is maintained incrementally for free. Absent ⇒ `None`.
560    #[cfg_attr(
561        any(feature = "testkit", feature = "serde"),
562        serde(default, skip_serializing_if = "Option::is_none")
563    )]
564    pub having: Option<Condition>,
565    /// Sort spec (names, not ColIds). Empty ⇒ none. Wire key `orderBy`.
566    #[cfg_attr(
567        any(feature = "testkit", feature = "serde"),
568        serde(default, skip_serializing_if = "Vec::is_empty")
569    )]
570    pub order_by: Vec<OrderPart>,
571}
572
573impl Ast {
574    /// A bare query over `table` with every other field absent. Equivalent to
575    /// `Ast { table: table.into(), ..Default::default() }`.
576    pub fn new(table: &str) -> Ast {
577        Ast {
578            table: table.into(),
579            ..Ast::default()
580        }
581    }
582}
583
584/// Design 226 Stage B: the untagged `Lit` wire behavior around the new `Int`
585/// variant. Serde-gated like the derives themselves (runs in the `testkit` lane).
586#[cfg(all(test, any(feature = "testkit", feature = "serde")))]
587mod lit_serde_tests {
588    use super::Lit;
589
590    #[test]
591    fn integer_tokens_parse_exact_and_float_tokens_stay_number() {
592        // An integer JSON token → Int, all 64 bits exact.
593        assert_eq!(serde_json::from_str::<Lit>("5").unwrap(), Lit::Int(5));
594        assert_eq!(
595            serde_json::from_str::<Lit>("9007199254740993").unwrap(),
596            Lit::Int(9_007_199_254_740_993) // 2^53 + 1 — unrepresentable as f64
597        );
598        assert_eq!(
599            serde_json::from_str::<Lit>("9223372036854775807").unwrap(),
600            Lit::Int(i64::MAX)
601        );
602        assert_eq!(
603            serde_json::from_str::<Lit>("-9223372036854775808").unwrap(),
604            Lit::Int(i64::MIN)
605        );
606        // A float token (or an out-of-i64 magnitude) falls through to Number.
607        assert_eq!(
608            serde_json::from_str::<Lit>("5.5").unwrap(),
609            Lit::Number(5.5)
610        );
611        assert_eq!(
612            serde_json::from_str::<Lit>("1e300").unwrap(),
613            Lit::Number(1e300)
614        );
615        assert_eq!(
616            serde_json::from_str::<Lit>("18446744073709551615").unwrap(),
617            Lit::Number(18_446_744_073_709_551_615.0) // > i64::MAX → f64
618        );
619        // Round-trips: Int serializes as a bare integer token, exactly.
620        assert_eq!(
621            serde_json::to_string(&Lit::Int(9_007_199_254_740_993)).unwrap(),
622            "9007199254740993"
623        );
624        // In an IN-list the elements behave identically.
625        assert_eq!(
626            serde_json::from_str::<Lit>("[1, 2.5]").unwrap(),
627            Lit::Array(vec![Lit::Int(1), Lit::Number(2.5)])
628        );
629    }
630
631    #[test]
632    fn int_and_number_spellings_lower_to_the_same_scalar_below_2_53() {
633        // `5` (now Int) and `5.0` (Number) must build the SAME pipeline scalar —
634        // `number_to_owned` already lowered integral f64s to `OwnedValue::Int`, so
635        // the new wire parse changes nothing an operator can observe.
636        let a = crate::builder::lit_to_scalar(&Lit::Int(5)).unwrap();
637        let b = crate::builder::lit_to_scalar(&Lit::Number(5.0)).unwrap();
638        assert!(matches!(a, crate::value::OwnedValue::Int(5)));
639        assert!(matches!(b, crate::value::OwnedValue::Int(5)));
640    }
641}