Skip to main content

nedb_engine/
nql.rs

1// SPDX-FileCopyrightText: 2026 INTERCHAINED LLC
2// SPDX-License-Identifier: BUSL-1.1
3// NEDB · © 2026 INTERCHAINED LLC × Eth-Interchained × Vex (Claude Opus 5)
4
5//! NQL (NEDB Query Language) parser and executor for v2 DAG storage.
6//!
7//! Grammar:
8//!   FROM coll
9//!     [AS OF seq]
10//!     [VALID AS OF "date"]
11//!     [WHERE <predicate>]
12//!     [SEARCH "text"]
13//!     [ORDER BY field [ASC|DESC]]
14//!     [LIMIT n]
15//!     [GROUP BY field COUNT|SUM|AVG|MIN|MAX]
16//!     [TRACE caused_by [REVERSE]]
17//!
18//! where <predicate> is a full boolean expression:
19//!
20//!   <predicate> := <or>
21//!   <or>        := <and> [OR <and>]*
22//!   <and>       := <not> [AND <not>]*
23//!   <not>       := [NOT] <primary>
24//!   <primary>   := "(" <predicate> ")" | <comparison>
25//!   <comparison>:= field ( = | != | > | < | >= | <= ) value
26//!                | field [NOT] IN "(" value [, value]* ")"
27//!                | field [NOT] BETWEEN value AND value
28//!                | field [NOT] LIKE|ILIKE "pattern"
29//!                | field IS [NOT] NULL
30//!
31//! Until 3.3.0 the predicate surface was six operators wide (= != > < >= <=)
32//! joined by an implicit AND, with no grouping, no negation and no set/range/
33//! pattern tests. Every one of those is table stakes in SQL, and their absence
34//! is the single most visible gap against a SQL engine: the queries people
35//! actually type (`status IN ('open','pending')`, `height BETWEEN 100 AND 200`,
36//! `name LIKE 'ac%'`) had to be decomposed by hand or filtered client-side.
37//!
38//! NOTE ON STRICTNESS. The old parser ended its clause loop with
39//! `_ => { self.advance(); }` — "skip unrecognised". That is the same defect
40//! class as a swallowed write error: a query containing a clause the engine
41//! does not implement did not fail, it silently returned the results of a
42//! DIFFERENT query. `FROM x WHERE a = 1 OFFSET 5` dropped both tokens and
43//! answered without the offset; a misspelled `ORDRE BY height` answered
44//! unsorted. Unknown tokens are now a parse error. This is deliberately
45//! breaking for queries that were already being silently misread — there was
46//! no correct behaviour to preserve.
47
48use std::collections::HashMap;
49use anyhow::{bail, Result};
50use serde_json::{json, Value};
51
52use crate::db::Db;
53use crate::index::OrderedValue;
54use crate::store::Node;
55
56// ── Token types ──────────────────────────────────────────────────────────────
57
58#[derive(Debug, Clone, PartialEq)]
59enum Tok {
60    /// A reserved word: (UPPERCASED for matching, RAW as the user spelled it).
61    ///
62    /// The raw spelling has to survive. Field positions accept a keyword as a
63    /// field name -- a document may legitimately have a field called `count`,
64    /// `min`, `value` or `status` -- and using the uppercased form there looks
65    /// up a key that does not exist. That made `HAVING count > 1` and
66    /// `ORDER BY count DESC` silently match nothing, because they searched the
67    /// row for "COUNT".
68    Kw(String, String),
69    Ident(String),  // field name or collection name (lowercase/mixed)
70    Str(String),    // "quoted string"
71    Num(f64),       // numeric literal
72    Op(String),     // = != > < >= <=
73    Punct(char),    // ( ) ,
74    Eof,
75}
76
77struct Lexer<'a> {
78    src:  &'a str,
79    pos:  usize,
80}
81
82impl<'a> Lexer<'a> {
83    fn new(src: &'a str) -> Self { Self { src, pos: 0 } }
84
85    fn peek_char(&self) -> Option<char> { self.src[self.pos..].chars().next() }
86
87    fn skip_ws(&mut self) {
88        while let Some(c) = self.peek_char() {
89            if c.is_whitespace() { self.pos += c.len_utf8(); } else { break; }
90        }
91    }
92
93    fn next_tok(&mut self) -> Tok {
94        self.skip_ws();
95        if self.pos >= self.src.len() { return Tok::Eof; }
96
97        let c = self.peek_char().unwrap();
98
99        // Quoted string.
100        //
101        // A backslash escapes a following double-quote (\" -> a literal " that
102        // does NOT end the string). This is purely additive: a literal quote
103        // was previously impossible to express — the first " always closed the
104        // string — so no existing query can rely on the old meaning of \" and
105        // nothing breaks. Every OTHER backslash stays literal, so raw-backslash
106        // values (e.g. a Windows path) keep matching exactly as before; a
107        // regression test pins that. (A fully C-style scheme where \\ -> \
108        // would instead change the meaning of every existing backslash query,
109        // so it is deliberately NOT done here.)
110        if c == '"' {
111            self.pos += 1;
112            let mut s = String::new();
113            while let Some(ch) = self.peek_char() {
114                if ch == '"' {
115                    break;
116                }
117                if ch == '\\' {
118                    // Look at the next char: only \" collapses to ". A trailing
119                    // backslash (nothing after it) or \x for any other x stays
120                    // a literal backslash, preserving prior behavior.
121                    let next = self.src[self.pos + 1..].chars().next();
122                    if next == Some('"') {
123                        s.push('"');
124                        self.pos += 1 + 1; // consume the backslash and the quote
125                        continue;
126                    }
127                }
128                s.push(ch);
129                self.pos += ch.len_utf8();
130            }
131            if self.peek_char() == Some('"') {
132                self.pos += 1;
133            }
134            return Tok::Str(s);
135        }
136
137        // Three-char operators: the case-insensitive regex negation `!~*`.
138        // Longest match FIRST — checked before `!~` and before `!=`, or `!~*`
139        // would tokenise as `!~` followed by a stray `*`.
140        if self.pos + 2 < self.src.len() && &self.src[self.pos..self.pos + 3] == "!~*" {
141            self.pos += 3;
142            return Tok::Op("!~*".to_string());
143        }
144
145        // Two-char operators
146        if self.pos + 1 < self.src.len() {
147            let two = &self.src[self.pos..self.pos+2];
148            if matches!(two, "!=" | ">=" | "<=" | "!~" | "~*") {
149                self.pos += 2;
150                return Tok::Op(two.to_string());
151            }
152        }
153
154        // One-char operators
155        if matches!(c, '=' | '>' | '<' | '~') {
156            self.pos += 1;
157            return Tok::Op(c.to_string());
158        }
159
160        // Punctuation: grouping for boolean predicates and IN-list separators.
161        // These previously fell through to "skip unknown char", so `(`, `)` and
162        // `,` were invisible to the parser — which is why the grammar could not
163        // express either grouping or a value list.
164        if matches!(c, '(' | ')' | ',') {
165            self.pos += 1;
166            return Tok::Punct(c);
167        }
168
169        // Number
170        if c.is_ascii_digit() || (c == '-' && self.src[self.pos+1..].starts_with(|d: char| d.is_ascii_digit())) {
171            let start = self.pos;
172            if c == '-' { self.pos += 1; }
173            while let Some(d) = self.peek_char() {
174                if d.is_ascii_digit() || d == '.' { self.pos += 1; } else { break; }
175            }
176            let n: f64 = self.src[start..self.pos].parse().unwrap_or(0.0);
177            return Tok::Num(n);
178        }
179
180        // Keyword or identifier
181        if c.is_alphabetic() || c == '_' {
182            let start = self.pos;
183            while let Some(ch) = self.peek_char() {
184                if ch.is_alphanumeric() || ch == '_' || ch == '.' || ch == ':' {
185                    self.pos += ch.len_utf8();
186                } else { break; }
187            }
188            let word = &self.src[start..self.pos];
189            let upper = word.to_uppercase();
190            let keywords = ["FROM","AS","OF","VALID","WHERE","AND","OR","ORDER","BY",
191                            "ASC","DESC","LIMIT","OFFSET","GROUP","HAVING",
192                            "COUNT","SUM","AVG","MIN","MAX",
193                            "TRACE","TRAVERSE","REVERSE","SEARCH","NOT","NULL","TRUE","FALSE",
194                            "IN","BETWEEN","LIKE","ILIKE","IS"];
195            if keywords.contains(&upper.as_str()) {
196                return Tok::Kw(upper, word.to_string());
197            }
198            return Tok::Ident(word.to_string());
199        }
200
201        // Skip unknown char
202        self.pos += c.len_utf8();
203        self.next_tok()
204    }
205
206    fn tokenize(&mut self) -> Vec<Tok> {
207        let mut toks = vec![];
208        loop {
209            let t = self.next_tok();
210            if t == Tok::Eof { break; }
211            toks.push(t);
212        }
213        toks
214    }
215}
216
217// ── AST ──────────────────────────────────────────────────────────────────────
218
219/// A boolean predicate tree.
220///
221/// The old representation was `Vec<WhereClause>` evaluated with `.all()`, which
222/// can only ever express a conjunction of comparisons. A tree is required for
223/// OR, for NOT, and for parenthesised grouping — `WHERE (a = 1 OR b = 2) AND
224/// c != 3` has no encoding as a flat list.
225#[derive(Debug, Clone)]
226pub enum Pred {
227    /// field <op> value, for op in = != > < >= <=
228    Cmp { field: String, op: String, value: Value },
229    /// field [NOT] IN (v1, v2, ...)
230    In { field: String, values: Vec<Value>, negated: bool },
231    /// field [NOT] BETWEEN low AND high — inclusive on both ends, as in SQL.
232    Between { field: String, low: Value, high: Value, negated: bool },
233    /// field [NOT] LIKE "pat" — SQL wildcards: % = any run, _ = any one char.
234    /// `ci` is set by ILIKE (case-insensitive).
235    Like { field: String, pattern: String, negated: bool, ci: bool },
236    /// `field ~ "pat"` / `field !~ "pat"` — POSIX regex match, over a
237    /// DOCUMENTED SUBSET. `ci` is set by `~*` / `!~*`.
238    ///
239    /// Exists because Postgres catalogue introspection needs it: `psql`'s
240    /// `\dn` filters with `nspname !~ '^pg_'`, and `\dt` with
241    /// `nspname !~ '^pg_toast'`. Without the operator those queries cannot
242    /// run at all.
243    ///
244    /// The subset is `^`, `$`, `.`, and literal text — which is everything
245    /// those queries actually use. A pattern containing any other
246    /// metacharacter is REFUSED with an error naming it, rather than matched
247    /// approximately. Approximate regex matching on a catalogue filter would
248    /// silently include or exclude schemas, and a wrong schema list looks
249    /// exactly like a correct one.
250    ///
251    /// Deliberately no `regex` crate: it would add a dependency tree to an
252    /// engine whose small footprint is a selling point, to serve two anchored
253    /// prefix patterns.
254    Regex { field: String, pattern: String, negated: bool, ci: bool },
255    /// field IS [NOT] NULL — true when the field is JSON null OR absent.
256    IsNull { field: String, negated: bool },
257    And(Vec<Pred>),
258    Or(Vec<Pred>),
259    Not(Box<Pred>),
260}
261
262#[derive(Debug, Clone, PartialEq)]
263pub enum GroupAgg { Count, Sum, Avg, Min, Max }
264
265impl GroupAgg {
266    fn name(&self) -> &'static str {
267        match self {
268            GroupAgg::Count => "count", GroupAgg::Sum => "sum",
269            GroupAgg::Avg   => "avg",   GroupAgg::Min => "min",
270            GroupAgg::Max   => "max",
271        }
272    }
273}
274
275/// One `ORDER BY` key. A list of these replaces the old single
276/// `Option<String>` + `bool` pair, because `ORDER BY status, fee DESC` — sort
277/// by one column then break ties with another — has no encoding as a single
278/// field plus a single direction.
279#[derive(Debug, Clone, PartialEq)]
280pub struct OrderKey {
281    pub field: String,
282    pub desc:  bool,
283}
284
285/// An aggregate, grouped or ungrouped.
286///
287/// `group_field: None` is a whole-result aggregate — `FROM t COUNT`,
288/// `FROM t SUM fee` — which returns exactly one row. That was previously
289/// inexpressible: the aggregate keywords only existed after `GROUP BY`, so
290/// "how many rows match this?" had to fetch every row and count client-side.
291#[derive(Debug, Clone)]
292pub struct Aggregate {
293    pub group_field: Option<String>,
294    pub agg:         GroupAgg,
295    /// The field to aggregate. None for COUNT, which needs no target.
296    pub agg_field:   Option<String>,
297}
298
299#[derive(Debug, Clone)]
300pub struct Query {
301    pub coll:       String,
302    pub as_of:      Option<u64>,
303    pub valid_as_of: Option<String>,
304    pub where_:     Option<Pred>,
305    pub search:     Option<String>,
306    pub order_by:   Vec<OrderKey>,
307    pub limit:      Option<usize>,
308    pub offset:     Option<usize>,
309    pub aggregate:  Option<Aggregate>,
310    /// `HAVING <predicate>` — filters the AGGREGATED rows, so it can test
311    /// `count`, `sum_fee`, or the group key itself. Distinct from WHERE, which
312    /// filters input rows before they are grouped.
313    pub having:     Option<Pred>,
314    pub trace:      Option<String>,     // edge type (usually "caused_by")
315    pub trace_rev:  bool,
316    pub traverse:   Option<String>,     // named relation for TRAVERSE rel
317}
318
319// ── Parser ────────────────────────────────────────────────────────────────────
320
321struct Parser { toks: Vec<Tok>, pos: usize }
322
323impl Parser {
324    fn new(toks: Vec<Tok>) -> Self { Self { toks, pos: 0 } }
325
326    fn peek(&self) -> &Tok { self.toks.get(self.pos).unwrap_or(&Tok::Eof) }
327    fn advance(&mut self) -> Tok { let t = self.peek().clone(); self.pos += 1; t }
328
329    fn expect_kw(&mut self, kw: &str) -> Result<()> {
330        match self.advance() {
331            Tok::Kw(k, _) if k == kw => Ok(()),
332            other => bail!("expected keyword {}, got {:?}", kw, other),
333        }
334    }
335
336    /// Parse a literal.
337    ///
338    /// Returns `Result` rather than defaulting to `Value::Null`: the old arm
339    /// `_ => Value::Null` turned a syntax error into a comparison against null,
340    /// so `WHERE height > )` quietly answered "nothing is greater than null"
341    /// instead of reporting a malformed query.
342    fn parse_value(&mut self) -> Result<Value> {
343        Ok(match self.advance() {
344            Tok::Str(s)  => Value::String(s),
345            Tok::Num(n)  => json!(n),
346            Tok::Kw(k, _) if k == "NULL"  => Value::Null,
347            Tok::Kw(k, _) if k == "TRUE"  => Value::Bool(true),
348            Tok::Kw(k, _) if k == "FALSE" => Value::Bool(false),
349            Tok::Ident(s) => Value::String(s),
350            other => bail!("expected a value (string, number, TRUE, FALSE or NULL), got {:?}", other),
351        })
352    }
353
354    fn peek_kw(&self, kw: &str) -> bool {
355        matches!(self.peek(), Tok::Kw(k, _) if k == kw)
356    }
357
358    fn eat_kw(&mut self, kw: &str) -> bool {
359        if self.peek_kw(kw) { self.advance(); true } else { false }
360    }
361
362    fn expect_punct(&mut self, c: char) -> Result<()> {
363        match self.advance() {
364            Tok::Punct(p) if p == c => Ok(()),
365            other => bail!("expected '{}', got {:?}", c, other),
366        }
367    }
368
369    fn parse_agg_kw(&mut self) -> Result<GroupAgg> {
370        Ok(match self.advance() {
371            Tok::Kw(a, _) if a == "COUNT" => GroupAgg::Count,
372            Tok::Kw(a, _) if a == "SUM"   => GroupAgg::Sum,
373            Tok::Kw(a, _) if a == "AVG"   => GroupAgg::Avg,
374            Tok::Kw(a, _) if a == "MIN"   => GroupAgg::Min,
375            Tok::Kw(a, _) if a == "MAX"   => GroupAgg::Max,
376            other => bail!("expected an aggregate (COUNT/SUM/AVG/MIN/MAX), got {:?}", other),
377        })
378    }
379
380    fn parse_field(&mut self, ctx: &str) -> Result<String> {
381        match self.advance() {
382            Tok::Ident(s) | Tok::Kw(_, s) => Ok(s),
383            other => bail!("{}: expected field name, got {:?}", ctx, other),
384        }
385    }
386
387    // ── Predicate grammar: OR binds loosest, then AND, then NOT ──────────────
388
389    fn parse_pred(&mut self) -> Result<Pred> { self.parse_or() }
390
391    fn parse_or(&mut self) -> Result<Pred> {
392        let mut terms = vec![self.parse_and()?];
393        while self.eat_kw("OR") {
394            terms.push(self.parse_and()?);
395        }
396        Ok(if terms.len() == 1 { terms.pop().unwrap() } else { Pred::Or(terms) })
397    }
398
399    fn parse_and(&mut self) -> Result<Pred> {
400        let mut terms = vec![self.parse_not()?];
401        while self.peek_kw("AND") {
402            // `BETWEEN low AND high` owns its AND — it is consumed inside
403            // parse_comparison, so any AND reaching here is a real conjunction.
404            self.advance();
405            terms.push(self.parse_not()?);
406        }
407        Ok(if terms.len() == 1 { terms.pop().unwrap() } else { Pred::And(terms) })
408    }
409
410    fn parse_not(&mut self) -> Result<Pred> {
411        if self.eat_kw("NOT") {
412            return Ok(Pred::Not(Box::new(self.parse_not()?)));
413        }
414        self.parse_primary()
415    }
416
417    fn parse_primary(&mut self) -> Result<Pred> {
418        if matches!(self.peek(), Tok::Punct('(')) {
419            self.advance();
420            let inner = self.parse_pred()?;
421            self.expect_punct(')')?;
422            return Ok(inner);
423        }
424        self.parse_comparison()
425    }
426
427    fn parse_comparison(&mut self) -> Result<Pred> {
428        let field = self.parse_field("WHERE")?;
429
430        // field IS [NOT] NULL
431        if self.eat_kw("IS") {
432            let negated = self.eat_kw("NOT");
433            if !self.eat_kw("NULL") {
434                bail!("WHERE: expected NULL after IS{}", if negated { " NOT" } else { "" });
435            }
436            return Ok(Pred::IsNull { field, negated });
437        }
438
439        // A leading NOT applies to the operator that follows: IN / BETWEEN / LIKE.
440        let negated = self.eat_kw("NOT");
441
442        if self.eat_kw("IN") {
443            self.expect_punct('(')?;
444            let mut values = vec![];
445            loop {
446                values.push(self.parse_value()?);
447                if matches!(self.peek(), Tok::Punct(',')) { self.advance(); continue; }
448                break;
449            }
450            self.expect_punct(')')?;
451            if values.is_empty() {
452                bail!("WHERE: IN () needs at least one value");
453            }
454            return Ok(Pred::In { field, values, negated });
455        }
456
457        if self.eat_kw("BETWEEN") {
458            let low = self.parse_value()?;
459            if !self.eat_kw("AND") {
460                bail!("WHERE: BETWEEN expects AND between its bounds");
461            }
462            let high = self.parse_value()?;
463            return Ok(Pred::Between { field, low, high, negated });
464        }
465
466        let ci = self.peek_kw("ILIKE");
467        if ci || self.peek_kw("LIKE") {
468            self.advance();
469            let pattern = match self.advance() {
470                Tok::Str(s) => s,
471                Tok::Ident(s) => s,
472                other => bail!("WHERE: LIKE expects a pattern string, got {:?}", other),
473            };
474            return Ok(Pred::Like { field, pattern, negated, ci });
475        }
476
477        if negated {
478            bail!("WHERE: NOT must be followed by IN, BETWEEN, LIKE or ILIKE \
479                   (use `NOT (field = value)` or `field != value` to negate a comparison)");
480        }
481
482        let op = match self.advance() {
483            Tok::Op(s) => s,
484            other => bail!("WHERE: expected operator, got {:?}", other),
485        };
486
487        // `~` / `~*` / `!~` / `!~*` — POSIX regex match. Its argument is a
488        // PATTERN, not a value, so it is taken as text and validated here
489        // rather than passed through `parse_value` (which would happily
490        // interpret `^1` as something numeric).
491        if matches!(op.as_str(), "~" | "~*" | "!~" | "!~*") {
492            let pattern = match self.advance() {
493                Tok::Str(s) => s,
494                Tok::Ident(s) => s,
495                other => bail!("WHERE: {} expects a pattern string, got {:?}", op, other),
496            };
497            // Refuse an unsupported metacharacter HERE, at parse time, so the
498            // caller learns at the point of the mistake instead of receiving a
499            // confidently wrong row set.
500            if let Some(why) = regex_error(&pattern) {
501                bail!("WHERE: {} — in {:?}. The supported subset is ^ $ . | ( ) \
502                       [ ] * + ? and literal text. Matching the rest approximately \
503                       would silently include or exclude rows, so it is refused \
504                       instead", why, pattern);
505            }
506            return Ok(Pred::Regex {
507                field,
508                pattern,
509                negated: op.starts_with('!'),
510                ci: op.ends_with('*'),
511            });
512        }
513
514        let value = self.parse_value()?;
515        Ok(Pred::Cmp { field, op, value })
516    }
517
518    fn parse(&mut self) -> Result<Query> {
519        self.expect_kw("FROM")?;
520        let coll = match self.advance() {
521            Tok::Ident(s) | Tok::Kw(_, s) => s,
522            other => bail!("expected collection name, got {:?}", other),
523        };
524
525        let mut q = Query {
526            coll, as_of: None, valid_as_of: None,
527            where_: None, search: None,
528            order_by: vec![],
529            limit: None, offset: None,
530            aggregate: None, having: None,
531            trace: None, trace_rev: false,
532            traverse: None,
533        };
534
535        loop {
536            match self.peek() {
537                Tok::Eof => break,
538
539                Tok::Kw(k, _) if k == "AS" => {
540                    self.advance();
541                    self.expect_kw("OF")?;
542                    match self.advance() {
543                        Tok::Num(n) => q.as_of = Some(n as u64),
544                        // A quoted datetime, accepted here because the Python
545                        // reference engine has always accepted it and the two
546                        // are one dialect. It rides the same u64 slot with
547                        // `WALL_CLOCK_FLAG` set and is resolved to a real
548                        // sequence before execution -- see `resolve_as_of_in`.
549                        Tok::Str(sx) => {
550                            let moment = crate::wallclock::WallClock::parse(&sx)
551                                .map_err(|e| anyhow::anyhow!(
552                                    "AS OF could not read {:?} as a datetime: {}", sx, e))?;
553                            q.as_of = Some(moment.as_marker());
554                        }
555                        other => bail!(
556                            "AS OF expects a sequence number or a quoted datetime, got {:?}",
557                            other),
558                    }
559                }
560
561                Tok::Kw(k, _) if k == "VALID" => {
562                    self.advance();
563                    self.expect_kw("AS")?;
564                    self.expect_kw("OF")?;
565                    match self.advance() {
566                        Tok::Str(s) => q.valid_as_of = Some(s),
567                        other => bail!("VALID AS OF expects date string, got {:?}", other),
568                    }
569                }
570
571                Tok::Kw(k, _) if k == "WHERE" => {
572                    self.advance();
573                    let pred = self.parse_pred()?;
574                    // Repeating WHERE is a conjunction, matching the old
575                    // behaviour where every clause was ANDed together.
576                    q.where_ = Some(match q.where_.take() {
577                        None => pred,
578                        Some(prev) => Pred::And(vec![prev, pred]),
579                    });
580                }
581
582                Tok::Kw(k, _) if k == "SEARCH" => {
583                    self.advance();
584                    match self.advance() {
585                        Tok::Str(s) => q.search = Some(s),
586                        other => bail!("SEARCH expects quoted string, got {:?}", other),
587                    }
588                }
589
590                Tok::Kw(k, _) if k == "ORDER" => {
591                    self.advance();
592                    self.expect_kw("BY")?;
593                    // Comma-separated sort keys, each with its own direction:
594                    // ORDER BY status, fee DESC
595                    loop {
596                        let field = self.parse_field("ORDER BY")?;
597                        // ASC is a real keyword now. It used to lex as an Ident
598                        // and survive only because the clause loop silently
599                        // skipped tokens it did not recognise.
600                        let desc = if self.eat_kw("DESC") {
601                            true
602                        } else {
603                            self.eat_kw("ASC");
604                            false
605                        };
606                        q.order_by.push(OrderKey { field, desc });
607                        if matches!(self.peek(), Tok::Punct(',')) { self.advance(); continue; }
608                        break;
609                    }
610                }
611
612                Tok::Kw(k, _) if k == "LIMIT" => {
613                    self.advance();
614                    match self.advance() {
615                        Tok::Num(n) if n >= 0.0 => q.limit = Some(n as usize),
616                        other => bail!("LIMIT expects a non-negative number, got {:?}", other),
617                    }
618                }
619
620                Tok::Kw(k, _) if k == "OFFSET" => {
621                    self.advance();
622                    match self.advance() {
623                        Tok::Num(n) if n >= 0.0 => q.offset = Some(n as usize),
624                        other => bail!("OFFSET expects a non-negative number, got {:?}", other),
625                    }
626                }
627
628                Tok::Kw(k, _) if k == "HAVING" => {
629                    self.advance();
630                    let pred = self.parse_pred()?;
631                    q.having = Some(match q.having.take() {
632                        None => pred,
633                        Some(prev) => Pred::And(vec![prev, pred]),
634                    });
635                }
636
637                // A bare aggregate with no GROUP BY: `FROM t COUNT`,
638                // `FROM t SUM fee`. Returns exactly one row.
639                Tok::Kw(k, _) if matches!(k.as_str(), "COUNT" | "SUM" | "AVG" | "MIN" | "MAX") => {
640                    let agg = self.parse_agg_kw()?;
641                    let agg_field = match agg {
642                        GroupAgg::Count => None,
643                        _ => Some(self.parse_field("aggregate")?),
644                    };
645                    if q.aggregate.is_some() {
646                        bail!("only one aggregate per query");
647                    }
648                    q.aggregate = Some(Aggregate { group_field: None, agg, agg_field });
649                }
650
651                Tok::Kw(k, _) if k == "GROUP" => {
652                    self.advance();
653                    self.expect_kw("BY")?;
654                    let field = match self.advance() {
655                        Tok::Ident(s) | Tok::Kw(_, s) => s,
656                        other => bail!("GROUP BY: expected field, got {:?}", other),
657                    };
658                    // The aggregate is OPTIONAL, matching the Python reference
659                    // (query.py): `GROUP BY field` on its own yields per-group
660                    // counts. Rust previously REQUIRED the keyword, so a bare
661                    // GROUP BY was a parse error here and valid there.
662                    let agg = if matches!(self.peek(),
663                        Tok::Kw(a, _) if matches!(a.as_str(), "COUNT"|"SUM"|"AVG"|"MIN"|"MAX"))
664                    {
665                        self.parse_agg_kw()?
666                    } else {
667                        GroupAgg::Count
668                    };
669                    // SUM/AVG/MIN/MAX take the field to aggregate. Without it
670                    // the executor fell back to aggregating the GROUP BY field
671                    // itself, so `GROUP BY cat MAX price` reported the max
672                    // *cat* — and since a non-numeric value coerced to 1.0,
673                    // every group answered 1. The target field was lexed and
674                    // then silently dropped by the unknown-token skip.
675                    let agg_field = match agg {
676                        GroupAgg::Count => None,
677                        _ => Some(self.parse_field("GROUP BY aggregate")?),
678                    };
679                    if q.aggregate.is_some() {
680                        bail!("only one aggregate per query");
681                    }
682                    q.aggregate = Some(Aggregate {
683                        group_field: Some(field), agg, agg_field,
684                    });
685                }
686
687                Tok::Kw(k, _) if k == "TRACE" => {
688                    self.advance();
689                    let edge = match self.advance() {
690                        Tok::Ident(s) | Tok::Kw(_, s) => s,
691                        other => bail!("TRACE: expected edge type, got {:?}", other),
692                    };
693                    q.trace = Some(edge);
694                    if let Tok::Kw(k, _) = self.peek() {
695                        if k == "REVERSE" { self.advance(); q.trace_rev = true; }
696                    }
697                }
698
699                Tok::Kw(k, _) if k == "TRAVERSE" => {
700                    self.advance();
701                    let rel = match self.advance() {
702                        Tok::Ident(s) | Tok::Kw(_, s) => s,
703                        other => bail!("TRAVERSE: expected relation name, got {:?}", other),
704                    };
705                    q.traverse = Some(rel);
706                }
707
708                // Unknown token. This used to be `self.advance()` — a silent
709                // skip that answered a different query than the one asked.
710                other => bail!(
711                    "unexpected {:?} in query. Expected one of: AS OF, VALID AS OF, \
712                     WHERE, SEARCH, ORDER BY, LIMIT, OFFSET, GROUP BY, HAVING, \
713                     COUNT, SUM, AVG, MIN, MAX, TRACE, TRAVERSE",
714                    other
715                ),
716            }
717        }
718
719        Ok(q)
720    }
721}
722
723// ── Executor ──────────────────────────────────────────────────────────────────
724
725/// Resolve a field name against a node, including the `_`-prefixed metadata
726/// fields that live on the node rather than in its data payload.
727fn field_value(node: &Node, field: &str) -> Value {
728    match field {
729        "_id"   => Value::String(node.id.clone()),
730        "_coll" => Value::String(node.coll.clone()),
731        "_hash" => Value::String(node.hash.clone()),
732        "_seq"  => json!(node.seq),
733        _ => node.data.get(field).cloned().unwrap_or(Value::Null),
734    }
735}
736
737fn cmp_op(a: &Value, op: &str, b: &Value) -> bool {
738    // An ORDERING comparison against a null/missing field is never true.
739    //
740    // OrderedValue sorts Null below every number, so `<` and `<=` used to
741    // report that a document with NO `fee` field at all satisfied
742    // `WHERE fee < 5`. `>` and `>=` excluded it — the asymmetry was the tell.
743    //
744    // The Python reference has always excluded it (query.py: `if a is None:
745    // return False`, placed deliberately AFTER the = / != arms), so this was
746    // a live divergence between the two engines as well as a wrong answer:
747    // asking for cheap jobs should not return jobs with no price.
748    //
749    // `=` and `!=` keep operating on null, exactly as Python does, so
750    // `WHERE x != 5` still matches a row where x is absent and `WHERE x = NULL`
751    // still works. `BETWEEN` is built from `>=` and `<=` and so inherits this.
752    //
753    // This also makes the scan path and the sorted-index path agree. A
754    // document whose field is absent is not in that field's index, so an index
755    // range scan could never have returned it — without this fix the two paths
756    // answered the same query differently depending on whether an index
757    // happened to exist.
758    if matches!(op, "<" | "<=" | ">" | ">=") && a.is_null() {
759        return false;
760    }
761    let a = OrderedValue::from(a);
762    let b = OrderedValue::from(b);
763    match op {
764        "="  => a == b,
765        "!=" => a != b,
766        ">"  => a >  b,
767        "<"  => a <  b,
768        ">=" => a >= b,
769        "<=" => a <= b,
770        _    => false,
771    }
772}
773
774/// Render a JSON scalar for text matching. Strings pass through unquoted so a
775/// LIKE pattern is compared against the value a user sees, not against its
776/// JSON encoding (`"abc"` with the quotes included).
777fn as_text(v: &Value) -> String {
778    match v {
779        Value::String(s) => s.clone(),
780        Value::Null => String::new(),
781        other => other.to_string(),
782    }
783}
784
785/// SQL LIKE matching: `%` matches any run of characters (including empty),
786/// `_` matches exactly one. Implemented as an iterative two-pointer scan with
787/// backtracking to the last `%`, which is linear in practice and needs no
788/// regex dependency. Operates on chars, so multi-byte values match correctly.
789fn like_match(value: &str, pattern: &str, ci: bool) -> bool {
790    like_match_esc(value, pattern, ci, None)
791}
792
793/// `LIKE` with an optional escape character.
794///
795/// When `esc` is set, the character following it in the pattern is LITERAL —
796/// so `ESCAPE '/'` makes `/%` mean a real percent sign rather than a wildcard.
797/// That is the whole point of the clause: it lets a caller search for the
798/// wildcards themselves, which is exactly what an introspection query does
799/// when a schema name may contain `_`.
800///
801/// Case folding happens BEFORE escape resolution, which matters: folding the
802/// escape character itself would make `ESCAPE 'A'` and `ESCAPE 'a'` differ
803/// under ILIKE, so the escape char is folded to match.
804fn like_match_esc(value: &str, pattern: &str, ci: bool, esc: Option<char>) -> bool {
805    let (v, p): (Vec<char>, Vec<char>) = if ci {
806        (value.to_lowercase().chars().collect(), pattern.to_lowercase().chars().collect())
807    } else {
808        (value.chars().collect(), pattern.chars().collect())
809    };
810
811    let esc = esc.map(|c| if ci { c.to_lowercase().next().unwrap_or(c) } else { c });
812
813    // Resolve the pattern into (char, is_literal) pairs ONCE, so the matcher
814    // below is unchanged in shape and the escape rule cannot be applied
815    // inconsistently on a backtrack.
816    //
817    // A trailing escape character with nothing after it is treated as a
818    // literal occurrence of itself rather than an error: PostgreSQL rejects
819    // it, but this endpoint's job on a malformed catalogue pattern is to
820    // answer no-rows, not to fail a client's connection.
821    let pat: Vec<(char, bool)> = {
822        let mut out: Vec<(char, bool)> = Vec::with_capacity(p.len());
823        let mut i = 0usize;
824        while i < p.len() {
825            if Some(p[i]) == esc {
826                if i + 1 < p.len() {
827                    out.push((p[i + 1], true));
828                    i += 2;
829                } else {
830                    out.push((p[i], true));
831                    i += 1;
832                }
833            } else {
834                out.push((p[i], false));
835                i += 1;
836            }
837        }
838        out
839    };
840
841    let mut vi = 0usize;
842    let mut pi = 0usize;
843    // Position to resume from if the current `%` expansion turns out too short.
844    let mut star: Option<(usize, usize)> = None;
845
846    while vi < v.len() {
847        let wild_any = pi < pat.len() && pat[pi].0 == '%' && !pat[pi].1;
848        let wild_one = pi < pat.len() && pat[pi].0 == '_' && !pat[pi].1;
849        if pi < pat.len() && (wild_one || pat[pi].0 == v[vi]) {
850            vi += 1;
851            pi += 1;
852        } else if wild_any {
853            star = Some((pi, vi));
854            pi += 1;
855        } else if let Some((sp, sv)) = star {
856            // Backtrack: let the `%` swallow one more character.
857            pi = sp + 1;
858            vi = sv + 1;
859            star = Some((sp, vi));
860        } else {
861            return false;
862        }
863    }
864    // Trailing `%`s can still match the empty remainder — but an ESCAPED `%`
865    // is a real character and must not be skipped here.
866    while pi < pat.len() && pat[pi].0 == '%' && !pat[pi].1 { pi += 1; }
867    pi == pat.len()
868}
869
870// ── POSIX ERE, the subset psql actually writes ──────────────────────────────
871//
872// A hand-rolled backtracking matcher. Still deliberately no `regex` crate: it
873// would add a dependency tree to an engine whose small footprint is a selling
874// point. What changed is the SUBSET. It used to be `^ $ .` and literal text,
875// which was everything `\dn` and `\dt` needed — and then `\d orders` sent
876// `relname ~ '^(orders)$'` and the group was refused by name. A group, an
877// alternation and the three quantifiers are the whole of what psql generates
878// (`\d ord*` becomes `^(ord.*)$`), so that is the whole of what is added.
879//
880// Supported:  literals · `.` · `^` `$` · `|` · `( )` · `[abc]` `[^a-z]` ·
881//             `*` `+` `?` · `\x` as the literal x
882// Refused BY NAME, never approximated: `{n,m}` intervals, `[[:class:]]`
883// POSIX classes, `\1` back-references and `\d \w \s \b` shorthands. The
884// Python reference engine implements the identical grammar with the identical
885// algorithm — two engines accepting different regex languages is a divergence
886// in a FILTER, which silently includes or excludes rows.
887
888#[derive(Debug, Clone, PartialEq)]
889enum ReNode {
890    Char(char),
891    Any,
892    /// `[...]`: single chars and inclusive ranges, optionally negated.
893    Class { items: Vec<(char, char)>, negated: bool },
894    Start,
895    End,
896    Group(Vec<Vec<ReItem>>),
897}
898
899#[derive(Debug, Clone, Copy, PartialEq)]
900enum ReQuant { One, Opt, Star, Plus }
901
902#[derive(Debug, Clone, PartialEq)]
903struct ReItem { node: ReNode, quant: ReQuant }
904
905/// Compile a pattern, or say exactly which construct is outside the subset.
906fn regex_compile(pattern: &str) -> Result<Vec<Vec<ReItem>>, String> {
907    let p: Vec<char> = pattern.chars().collect();
908    let mut pos = 0usize;
909    let alt = regex_parse_alt(&p, &mut pos, 0)?;
910    if pos < p.len() {
911        // Only a stray `)` can stop the top-level parse early.
912        return Err(format!("regex {:?} has an unmatched ')'", pattern));
913    }
914    Ok(alt)
915}
916
917fn regex_parse_alt(p: &[char], pos: &mut usize, depth: usize) -> Result<Vec<Vec<ReItem>>, String> {
918    let mut branches = vec![];
919    loop {
920        let mut seq: Vec<ReItem> = vec![];
921        while *pos < p.len() {
922            let c = p[*pos];
923            if c == '|' || c == ')' {
924                break;
925            }
926            *pos += 1;
927            let node = match c {
928                '.' => ReNode::Any,
929                '^' => ReNode::Start,
930                '$' => ReNode::End,
931                '(' => {
932                    let inner = regex_parse_alt(p, pos, depth + 1)?;
933                    if *pos >= p.len() || p[*pos] != ')' {
934                        return Err("regex has an unmatched '('".to_string());
935                    }
936                    *pos += 1;
937                    ReNode::Group(inner)
938                }
939                '[' => {
940                    let negated = *pos < p.len() && p[*pos] == '^';
941                    if negated {
942                        *pos += 1;
943                    }
944                    let mut items = vec![];
945                    let mut first = true;
946                    loop {
947                        if *pos >= p.len() {
948                            return Err("regex has an unmatched '['".to_string());
949                        }
950                        let ch = p[*pos];
951                        if ch == ']' && !first {
952                            *pos += 1;
953                            break;
954                        }
955                        first = false;
956                        if ch == '[' && p.get(*pos + 1) == Some(&':') {
957                            return Err(
958                                "regex uses a POSIX character class like [[:alpha:]], \
959                                 which this engine does not implement".to_string(),
960                            );
961                        }
962                        let lo = if ch == '\\' {
963                            *pos += 1;
964                            *p.get(*pos).ok_or("regex ends inside an escape")?
965                        } else {
966                            ch
967                        };
968                        *pos += 1;
969                        // `a-z`, but a trailing `-` before `]` is a literal.
970                        if *pos + 1 < p.len() && p[*pos] == '-' && p[*pos + 1] != ']' {
971                            let hi = p[*pos + 1];
972                            *pos += 2;
973                            if hi < lo {
974                                return Err(format!("regex range {}-{} is reversed", lo, hi));
975                            }
976                            items.push((lo, hi));
977                        } else {
978                            items.push((lo, lo));
979                        }
980                    }
981                    ReNode::Class { items, negated }
982                }
983                '{' | '}' => {
984                    return Err(format!(
985                        "regex uses {:?} (an interval like a{{2,3}}), which this engine \
986                         does not implement", c
987                    ))
988                }
989                '*' | '+' | '?' => {
990                    return Err(format!("regex has a {:?} with nothing to repeat", c))
991                }
992                '\\' => {
993                    let e = *p.get(*pos).ok_or("regex ends inside an escape")?;
994                    *pos += 1;
995                    if e.is_ascii_digit() {
996                        return Err("regex uses a back-reference like \\1, which this \
997                                    engine does not implement".to_string());
998                    }
999                    if matches!(e, 'd' | 'D' | 'w' | 'W' | 's' | 'S' | 'b' | 'B') {
1000                        return Err(format!(
1001                            "regex uses the shorthand class \\{}, which this engine does \
1002                             not implement — write the [..] class out", e
1003                        ));
1004                    }
1005                    ReNode::Char(e)
1006                }
1007                other => ReNode::Char(other),
1008            };
1009            let quant = match p.get(*pos) {
1010                Some('*') => { *pos += 1; ReQuant::Star }
1011                Some('+') => { *pos += 1; ReQuant::Plus }
1012                Some('?') => { *pos += 1; ReQuant::Opt }
1013                _ => ReQuant::One,
1014            };
1015            if quant != ReQuant::One && matches!(node, ReNode::Start | ReNode::End) {
1016                return Err("regex repeats an anchor, which is meaningless".to_string());
1017            }
1018            seq.push(ReItem { node, quant });
1019        }
1020        branches.push(seq);
1021        if *pos < p.len() && p[*pos] == '|' {
1022            *pos += 1;
1023            continue;
1024        }
1025        if *pos < p.len() && p[*pos] == ')' && depth == 0 {
1026            return Err("regex has an unmatched ')'".to_string());
1027        }
1028        return Ok(branches);
1029    }
1030}
1031
1032fn re_class_hit(items: &[(char, char)], negated: bool, c: char) -> bool {
1033    items.iter().any(|(lo, hi)| *lo <= c && c <= *hi) != negated
1034}
1035
1036/// Match one atom at `pos`, then hand every possible continuation to `k`.
1037fn re_atom(node: &ReNode, t: &[char], pos: usize, k: &dyn Fn(usize) -> bool) -> bool {
1038    match node {
1039        ReNode::Char(c) => pos < t.len() && t[pos] == *c && k(pos + 1),
1040        ReNode::Any => pos < t.len() && k(pos + 1),
1041        ReNode::Class { items, negated } => {
1042            pos < t.len() && re_class_hit(items, *negated, t[pos]) && k(pos + 1)
1043        }
1044        ReNode::Start => pos == 0 && k(pos),
1045        ReNode::End => pos == t.len() && k(pos),
1046        ReNode::Group(alt) => alt.iter().any(|seq| re_seq(seq, t, pos, k)),
1047    }
1048}
1049
1050/// Greedy `*`: take one more, else fall through to the rest.
1051fn re_star(node: &ReNode, rest: &[ReItem], t: &[char], pos: usize, k: &dyn Fn(usize) -> bool) -> bool {
1052    // A repetition that consumed nothing must not recurse, or `()*` loops.
1053    re_atom(node, t, pos, &|p| p != pos && re_star(node, rest, t, p, k)) || re_seq(rest, t, pos, k)
1054}
1055
1056fn re_seq(seq: &[ReItem], t: &[char], pos: usize, k: &dyn Fn(usize) -> bool) -> bool {
1057    let Some(item) = seq.first() else { return k(pos) };
1058    let rest = &seq[1..];
1059    match item.quant {
1060        ReQuant::One => re_atom(&item.node, t, pos, &|p| re_seq(rest, t, p, k)),
1061        ReQuant::Opt => {
1062            re_atom(&item.node, t, pos, &|p| re_seq(rest, t, p, k)) || re_seq(rest, t, pos, k)
1063        }
1064        ReQuant::Star => re_star(&item.node, rest, t, pos, k),
1065        ReQuant::Plus => re_atom(&item.node, t, pos, &|p| re_star(&item.node, rest, t, p, k)),
1066    }
1067}
1068
1069/// Why `pattern` is outside the supported subset, or `None` when it compiles.
1070///
1071/// Refused rather than approximated — see the module comment above. Every
1072/// message names the construct, because "regex error" sends the reader to
1073/// fix the wrong thing.
1074fn regex_error(pattern: &str) -> Option<String> {
1075    regex_compile(pattern).err()
1076}
1077
1078/// POSIX ERE matching over the documented subset. Unanchored patterns search
1079/// for a match anywhere, exactly as `~` does in Postgres.
1080///
1081/// A pattern outside the subset matches NOTHING here; callers validate with
1082/// `regex_error` at parse time so that case is reported, never reached.
1083fn regex_match(value: &str, pattern: &str, ci: bool) -> bool {
1084    let (v, p) = if ci {
1085        (value.to_lowercase(), pattern.to_lowercase())
1086    } else {
1087        (value.to_string(), pattern.to_string())
1088    };
1089    let Ok(alt) = regex_compile(&p) else { return false };
1090    let t: Vec<char> = v.chars().collect();
1091    let done = |_: usize| true;
1092    (0..=t.len()).any(|start| alt.iter().any(|seq| re_seq(seq, &t, start, &done)))
1093}
1094
1095/// Public aliases so the SQL `SELECT` engine matches patterns with EXACTLY
1096/// the same code NQL does.
1097///
1098/// Two implementations of `LIKE` or of the regex subset would be two chances
1099/// for the SQL surface and the NQL surface to disagree about the same
1100/// operator on the same data — and a filter that disagrees with itself is the
1101/// silent-wrong-row class this engine keeps having to remove.
1102pub fn regex_match_pub(value: &str, pattern: &str, ci: bool) -> bool {
1103    regex_match(value, pattern, ci)
1104}
1105
1106/// Why a pattern is outside the supported regex subset, or `None`. See
1107/// `Pred::Regex`.
1108pub fn regex_error_pub(pattern: &str) -> Option<String> {
1109    regex_error(pattern)
1110}
1111
1112/// SQL `LIKE` matching — `%` any run, `_` exactly one char.
1113pub fn like_match_pub(value: &str, pattern: &str, ci: bool) -> bool {
1114    like_match_esc(value, pattern, ci, None)
1115}
1116
1117/// `LIKE ... ESCAPE 'c'` — the escaped form.
1118///
1119/// Added because SQLAlchemy 2.0.53 started emitting it in the catalogue query
1120/// its inspector runs on connect. 2.0.52 did not, so the endpoint went from
1121/// working to refusing with `expected ')', got ESCAPE` on a dependency bump
1122/// nobody here made. An introspection query is the FIRST thing a client sends,
1123/// so refusing it does not degrade one feature — the connection is unusable.
1124pub fn like_match_escape_pub(value: &str, pattern: &str, ci: bool, esc: char) -> bool {
1125    like_match_esc(value, pattern, ci, Some(esc))
1126}
1127
1128/// Evaluate a predicate against anything that can resolve a field name.
1129///
1130/// Generic over the row source so ONE implementation serves both `WHERE`
1131/// (over stored nodes) and `HAVING` (over aggregated rows, which are plain
1132/// JSON objects with no node behind them). Two copies would be two chances for
1133/// the operators to drift apart.
1134fn eval_pred_with(get: &dyn Fn(&str) -> Value, pred: &Pred) -> bool {
1135    match pred {
1136        Pred::Cmp { field, op, value } => cmp_op(&get(field), op, value),
1137
1138        Pred::In { field, values, negated } => {
1139            let fv = get(field);
1140            let hit = values.iter().any(|v| cmp_op(&fv, "=", v));
1141            hit != *negated
1142        }
1143
1144        Pred::Between { field, low, high, negated } => {
1145            let fv = get(field);
1146            // Inclusive on both ends, as in SQL.
1147            let hit = cmp_op(&fv, ">=", low) && cmp_op(&fv, "<=", high);
1148            hit != *negated
1149        }
1150
1151        Pred::Like { field, pattern, negated, ci } => {
1152            let fv = get(field);
1153            // A missing/null field matches no pattern, and NOT LIKE on a null
1154            // field stays false — mirroring SQL's three-valued logic, where a
1155            // predicate over NULL is never true in either polarity.
1156            if fv.is_null() { return false; }
1157            let hit = like_match(&as_text(&fv), pattern, *ci);
1158            hit != *negated
1159        }
1160
1161        Pred::Regex { field, pattern, negated, ci } => {
1162            let fv = get(field);
1163            // Same three-valued logic as LIKE: a predicate over NULL is never
1164            // true in EITHER polarity, so `x !~ 'p'` does not match a row
1165            // where x is absent. Postgres agrees, and psql's catalogue filters
1166            // depend on it.
1167            if fv.is_null() { return false; }
1168            let hit = regex_match(&as_text(&fv), pattern, *ci);
1169            hit != *negated
1170        }
1171
1172        Pred::IsNull { field, negated } => {
1173            // Absent and explicitly-null are both NULL here: a document store
1174            // has no schema, so "the field was never written" and "the field
1175            // holds null" are the same observable state.
1176            get(field).is_null() != *negated
1177        }
1178
1179        Pred::And(terms) => terms.iter().all(|t| eval_pred_with(get, t)),
1180        Pred::Or(terms)  => terms.iter().any(|t| eval_pred_with(get, t)),
1181        Pred::Not(inner) => !eval_pred_with(get, inner),
1182    }
1183}
1184
1185fn eval_pred(node: &Node, pred: &Pred) -> bool {
1186    eval_pred_with(&|f| field_value(node, f), pred)
1187}
1188
1189/// `HAVING` evaluation, over an aggregated row.
1190fn eval_pred_json(obj: &Value, pred: &Pred) -> bool {
1191    eval_pred_with(&|f| obj.get(f).cloned().unwrap_or(Value::Null), pred)
1192}
1193
1194/// Sort by a list of keys, each with its own direction. Earlier keys dominate;
1195/// later ones break ties.
1196fn sort_by_keys<T>(rows: &mut [T], keys: &[OrderKey], get: impl Fn(&T, &str) -> Value) {
1197    rows.sort_by(|a, b| {
1198        for k in keys {
1199            let av = OrderedValue::from(&get(a, &k.field));
1200            let bv = OrderedValue::from(&get(b, &k.field));
1201            let ord = if k.desc { bv.cmp(&av) } else { av.cmp(&bv) };
1202            if ord != std::cmp::Ordering::Equal {
1203                return ord;
1204            }
1205        }
1206        std::cmp::Ordering::Equal
1207    });
1208}
1209
1210/// Apply OFFSET then LIMIT, in that order.
1211///
1212/// SQL semantics: OFFSET skips rows of the RESULT, LIMIT caps what remains.
1213/// An offset past the end yields an empty page rather than an error.
1214fn paginate<T>(rows: Vec<T>, offset: Option<usize>, limit: Option<usize>) -> Vec<T> {
1215    let mut it = rows;
1216    if let Some(off) = offset {
1217        if off >= it.len() {
1218            return vec![];
1219        }
1220        it.drain(..off);
1221    }
1222    if let Some(n) = limit {
1223        it.truncate(n);
1224    }
1225    it
1226}
1227
1228/// Collapse rows into aggregate rows.
1229///
1230/// With `group_field: Some(f)` this yields one row per distinct value of `f`;
1231/// with `None` it yields exactly one row aggregating the whole result set.
1232///
1233/// `count` is the group size, while the aggregate considers ONLY rows whose
1234/// target field is numeric. That split matters and matches the Python
1235/// reference, which computes `count` from the group and the aggregate from
1236/// `[d[af] for d in gdocs if isinstance(d[af], (int, float))]`: a group of 5
1237/// rows where 2 carry a numeric `price` reports `count: 5` and averages over
1238/// 2. Folding non-numeric values in as 1.0 — the behaviour before 3.3.0 —
1239/// silently corrupted every SUM and AVG.
1240fn aggregate_rows(rows: &[Node], spec: &Aggregate) -> Vec<Value> {
1241    // `ints` tracks whether EVERY contributing value was an integer.
1242    //
1243    // Aggregating exclusively in f64 was both a type divergence from the
1244    // Python reference (which returns `66`, not `66.0`, for a sum of integers)
1245    // and a precision bug: f64 cannot represent integers above 2^53 exactly,
1246    // so a SUM over satoshi amounts or block heights silently rounded. SUM /
1247    // MIN / MAX now stay in i64 when the inputs are integral. AVG is always
1248    // fractional — Python's `sum(nums) / len(nums)` is true division — so it
1249    // stays f64 in both engines.
1250    struct Group { count: usize, nums: Vec<f64>, ints: Vec<i64>, all_int: bool }
1251
1252    // First-seen order, so results are stable run to run. HashMap iteration
1253    // order previously made the grouped output nondeterministic.
1254    let mut order: Vec<String> = vec![];
1255    let mut groups: HashMap<String, Group> = HashMap::new();
1256
1257    // The ungrouped case is one group under a fixed key, so a single code path
1258    // serves both and they cannot disagree about the aggregate itself.
1259    const WHOLE: &str = "";
1260
1261    for node in rows {
1262        // Resolved through `field_value`, not `node.data`, so the `_`-prefixed
1263        // metadata fields work here too. Reading the payload directly made
1264        // `SELECT MAX(_seq)` return NULL — with a 200 and a plausible-looking
1265        // single-row answer — even though `SELECT _seq` listed the values and
1266        // `WHERE _seq > 5` filtered on them. "What is the newest sequence?" is
1267        // the question replication and time travel are built on, so a silent
1268        // null there was the worst shape of wrong.
1269        let key = match spec.group_field {
1270            None => WHOLE.to_string(),
1271            Some(ref gf) => match field_value(node, gf) {
1272                Value::Null => "null".to_string(),
1273                v => as_text(&v),
1274            },
1275        };
1276        let entry = groups.entry(key.clone()).or_insert_with(|| {
1277            order.push(key.clone());
1278            Group { count: 0, nums: vec![], ints: vec![], all_int: true }
1279        });
1280        entry.count += 1;
1281        if let Some(ref af) = spec.agg_field {
1282            // A JSON bool is not a number here, matching Python's
1283            // `isinstance(x, (int, float)) and not isinstance(x, bool)`.
1284            if let Value::Number(n) = field_value(node, af) {
1285                if let Some(i) = n.as_i64() {
1286                    entry.ints.push(i);
1287                    entry.nums.push(i as f64);
1288                } else if let Some(f) = n.as_f64() {
1289                    entry.all_int = false;
1290                    entry.nums.push(f);
1291                }
1292            }
1293        }
1294    }
1295
1296    // An ungrouped aggregate over ZERO rows still returns one row — COUNT of
1297    // an empty set is 0, not "no answer". A grouped aggregate over zero rows
1298    // correctly returns no groups.
1299    if spec.group_field.is_none() && order.is_empty() {
1300        order.push(WHOLE.to_string());
1301        groups.insert(WHOLE.to_string(),
1302                      Group { count: 0, nums: vec![], ints: vec![], all_int: true });
1303    }
1304
1305    order.into_iter().map(|k| {
1306        let g = &groups[&k];
1307        let mut obj = serde_json::Map::new();
1308        if let Some(ref gf) = spec.group_field {
1309            obj.insert(gf.clone(), Value::String(k.clone()));
1310        }
1311        obj.insert("count".to_string(), json!(g.count));
1312
1313        // Empty aggregate input yields null, not 0 and not +/-infinity — the
1314        // old fold seeded MIN with f64::INFINITY, which serialises to null
1315        // anyway but would report INFINITY through any non-JSON path.
1316        let int_path = g.all_int && !g.ints.is_empty();
1317        let agg_val: Value = match spec.agg {
1318            GroupAgg::Count => json!(g.count),
1319            _ if g.nums.is_empty() => Value::Null,
1320            // checked_add: an i64 overflow falls back to f64 rather than
1321            // panicking in release or wrapping to a negative sum.
1322            GroupAgg::Sum if int_path => {
1323                match g.ints.iter().try_fold(0i64, |a, &b| a.checked_add(b)) {
1324                    Some(t) => json!(t),
1325                    None => json!(g.nums.iter().sum::<f64>()),
1326                }
1327            }
1328            GroupAgg::Min if int_path => json!(g.ints.iter().min().copied().unwrap()),
1329            GroupAgg::Max if int_path => json!(g.ints.iter().max().copied().unwrap()),
1330            GroupAgg::Sum => json!(g.nums.iter().sum::<f64>()),
1331            // AVG is true division in both engines, so always fractional.
1332            GroupAgg::Avg => json!(g.nums.iter().sum::<f64>() / g.nums.len() as f64),
1333            GroupAgg::Min => json!(g.nums.iter().cloned().fold(f64::INFINITY, f64::min)),
1334            GroupAgg::Max => json!(g.nums.iter().cloned().fold(f64::NEG_INFINITY, f64::max)),
1335        };
1336
1337        // Python-parity key: sum_price / avg_score / min_price / max_price.
1338        if let Some(ref af) = spec.agg_field {
1339            obj.insert(format!("{}_{}", spec.agg.name(), af), agg_val.clone());
1340        }
1341        // `value` is retained as an alias. It was this engine's only aggregate
1342        // key before 3.3.0, so Studio and any existing caller still read it;
1343        // dropping it would be a silent breakage on a client we do not
1344        // control from here.
1345        obj.insert("value".to_string(), agg_val);
1346        Value::Object(obj)
1347    }).collect()
1348}
1349
1350/// Find an `_id = "..."` equality usable as an O(1) index lookup.
1351///
1352/// Only descends through AND nodes. An equality sitting under an OR does not
1353/// constrain the result set — `WHERE _id = "a" OR height > 3` must still return
1354/// the height matches — so treating it as a point lookup would silently drop
1355/// rows. That is precisely the bug the pre-existing `where_order_limit` test
1356/// guards against in the ORDER BY path, one level up.
1357/// What an indexed field can be narrowed to, derived from the predicate.
1358#[derive(Debug, Clone)]
1359enum IndexPlan {
1360    /// A bounded (or half-bounded) range walk over the sorted index.
1361    Range {
1362        field: String,
1363        low: Option<Value>,
1364        high: Option<Value>,
1365        low_incl: bool,
1366        high_incl: bool,
1367    },
1368    /// A set of point lookups — `=` or `IN (...)`.
1369    Values { field: String, values: Vec<Value> },
1370}
1371
1372impl IndexPlan {
1373    fn field(&self) -> &str {
1374        match self {
1375            IndexPlan::Range { field, .. } => field,
1376            IndexPlan::Values { field, .. } => field,
1377        }
1378    }
1379}
1380
1381/// Collect every constraint an AND-reachable conjunct places on a field.
1382///
1383/// SAFETY PROPERTY that makes this whole path sound: the returned plan only
1384/// ever needs to describe a SUPERSET of the matching rows. The full predicate
1385/// is re-evaluated on whatever candidates come back, so an imprecise plan
1386/// costs time, never correctness. That is why it is fine to ignore constraints
1387/// this planner does not understand.
1388///
1389/// Only descends through `And`. A constraint under an `Or` does not restrict
1390/// the result set — `WHERE fee > 100 OR status = "open"` must still return the
1391/// status matches — so narrowing on one arm would silently drop rows. `Not` is
1392/// likewise never entered: a negated range is not a range.
1393fn collect_index_constraints(pred: &Pred, out: &mut Vec<IndexPlan>) {
1394    match pred {
1395        Pred::And(terms) => {
1396            for t in terms {
1397                collect_index_constraints(t, out);
1398            }
1399        }
1400
1401        Pred::Cmp { field, op, value } => {
1402            // `_id` has its own O(1) path and is not in the sorted index.
1403            if field == "_id" {
1404                return;
1405            }
1406            match op.as_str() {
1407                "=" => out.push(IndexPlan::Values {
1408                    field: field.clone(),
1409                    values: vec![value.clone()],
1410                }),
1411                ">" | ">=" => out.push(IndexPlan::Range {
1412                    field: field.clone(),
1413                    low: Some(value.clone()),
1414                    high: None,
1415                    low_incl: op == ">=",
1416                    high_incl: true,
1417                }),
1418                "<" | "<=" => out.push(IndexPlan::Range {
1419                    field: field.clone(),
1420                    low: None,
1421                    high: Some(value.clone()),
1422                    low_incl: true,
1423                    high_incl: op == "<=",
1424                }),
1425                // `!=` matches almost everything; a range walk would be
1426                // slower than the scan it replaces.
1427                _ => {}
1428            }
1429        }
1430
1431        Pred::Between { field, low, high, negated: false } => {
1432            out.push(IndexPlan::Range {
1433                field: field.clone(),
1434                low: Some(low.clone()),
1435                high: Some(high.clone()),
1436                low_incl: true,   // SQL BETWEEN is inclusive on both ends
1437                high_incl: true,
1438            });
1439        }
1440
1441        Pred::In { field, values, negated: false } => {
1442            out.push(IndexPlan::Values {
1443                field: field.clone(),
1444                values: values.clone(),
1445            });
1446        }
1447
1448        // NOT IN / NOT BETWEEN / LIKE / IS NULL cannot be served by a range
1449        // walk: they either match the complement of a range, or they are not
1450        // an ordering predicate at all. IS NULL specifically can NEVER use
1451        // this index — a document whose field is absent is not in the index,
1452        // so an index scan would return the exact opposite of the answer.
1453        _ => {}
1454    }
1455}
1456
1457/// Merge same-field constraints and choose the most selective indexed plan.
1458///
1459/// `fee > 10 AND fee < 100` becomes ONE bounded walk rather than a half-open
1460/// one, and when several fields are indexed the planner asks the index how
1461/// many rows each range covers and takes the narrowest — rather than
1462/// committing to whichever field it happened to see first.
1463fn choose_index_plan(db: &Db, coll: &str, pred: &Pred) -> Option<IndexPlan> {
1464    let mut raw = vec![];
1465    collect_index_constraints(pred, &mut raw);
1466    raw.retain(|p| db.has_sorted_index(coll, p.field()));
1467    if raw.is_empty() {
1468        return None;
1469    }
1470
1471    // Merge per field.
1472    let mut merged: Vec<IndexPlan> = vec![];
1473    for plan in raw {
1474        let field = plan.field().to_string();
1475        let existing = merged.iter().position(|m| m.field() == field);
1476        match (existing, plan) {
1477            (None, p) => merged.push(p),
1478
1479            // Two ranges on the same field: intersect the bounds.
1480            (Some(i), IndexPlan::Range { low, high, low_incl, high_incl, .. }) => {
1481                if let IndexPlan::Range {
1482                    low: ref mut elow, high: ref mut ehigh,
1483                    low_incl: ref mut eli, high_incl: ref mut ehi, ..
1484                } = merged[i] {
1485                    if let Some(l) = low {
1486                        let tighter = match elow {
1487                            None => true,
1488                            Some(cur) => OrderedValue::from(&l) > OrderedValue::from(&*cur),
1489                        };
1490                        if tighter { *elow = Some(l); *eli = low_incl; }
1491                    }
1492                    if let Some(h) = high {
1493                        let tighter = match ehigh {
1494                            None => true,
1495                            Some(cur) => OrderedValue::from(&h) < OrderedValue::from(&*cur),
1496                        };
1497                        if tighter { *ehigh = Some(h); *ehi = high_incl; }
1498                    }
1499                }
1500                // A Range arriving where a Values plan already sits is
1501                // ignored: the point lookups are already at least as
1502                // selective, and the predicate re-runs regardless.
1503            }
1504
1505            // An equality/IN beats a range on the same field.
1506            (Some(i), p @ IndexPlan::Values { .. }) => {
1507                if matches!(merged[i], IndexPlan::Range { .. }) {
1508                    merged[i] = p;
1509                }
1510            }
1511        }
1512    }
1513
1514    // Pick the narrowest, measured against the index rather than guessed.
1515    // A Values plan costs one point lookup per arm, so its cardinality is
1516    // the sum of those buckets.
1517    let mut best: Option<(usize, IndexPlan)> = None;
1518    for plan in merged {
1519        let card = match &plan {
1520            IndexPlan::Range { field, low, high, low_incl, high_incl } => db
1521                .range_cardinality(coll, field, low.as_ref(), high.as_ref(),
1522                                   *low_incl, *high_incl)
1523                .unwrap_or(usize::MAX),
1524            IndexPlan::Values { field, values } => values
1525                .iter()
1526                .map(|v| db.range_cardinality(coll, field, Some(v), Some(v), true, true)
1527                          .unwrap_or(usize::MAX))
1528                .fold(0usize, |a, b| a.saturating_add(b)),
1529        };
1530        if best.as_ref().map(|(c, _)| card < *c).unwrap_or(true) {
1531            best = Some((card, plan));
1532        }
1533    }
1534    best.map(|(_, p)| p)
1535}
1536
1537fn id_point_lookup(pred: &Pred) -> Option<String> {
1538    match pred {
1539        Pred::Cmp { field, op, value } if field == "_id" && op == "=" => {
1540            if let Value::String(s) = value { Some(s.clone()) } else { None }
1541        }
1542        Pred::And(terms) => terms.iter().find_map(id_point_lookup),
1543        _ => None,
1544    }
1545}
1546
1547// `matches_valid_as_of` and `node_contains_text` MOVED to `crate::relation`,
1548// which is now the single definition of what these two verbs mean. They are
1549// re-exported here rather than reimplemented: the SQL evaluator reads
1550// relations through `relation::read`, and a second copy of "is this row valid
1551// at this date" living in the query language is precisely the duplication that
1552// folding NQL into neSQL exists to remove.
1553use crate::relation::{matches_valid_as_of, node_contains_text};
1554
1555/// A node as a flat query row: data fields at the top level plus the `_`-prefixed
1556/// metadata. Public so the HTTP single-row GET returns the SAME shape a query row
1557/// has — one definition, so the two surfaces cannot drift apart.
1558pub fn node_to_json(node: &Node) -> Value {
1559    let mut obj = if let Value::Object(m) = &node.data {
1560        m.clone()
1561    } else {
1562        serde_json::Map::new()
1563    };
1564    obj.insert("_id".to_string(),   Value::String(node.id.clone()));
1565    obj.insert("_hash".to_string(), Value::String(node.hash.clone()));
1566    obj.insert("_seq".to_string(),  json!(node.seq));
1567    obj.insert("_coll".to_string(), Value::String(node.coll.clone()));
1568    if let Some(ref vf) = node.valid_from {
1569        obj.insert("_valid_from".to_string(), Value::String(vf.clone()));
1570    }
1571    if let Some(ref vt) = node.valid_to {
1572        obj.insert("_valid_to".to_string(), Value::String(vt.clone()));
1573    }
1574    if !node.caused_by.is_empty() {
1575        obj.insert("_caused_by".to_string(), Value::Array(
1576            node.caused_by.iter().map(|h| Value::String(h.clone())).collect()
1577        ));
1578    }
1579    Value::Object(obj)
1580}
1581
1582/// Execute a NQL query against the DAG database.
1583/// Parse NQL into a `Query` WITHOUT touching the database.
1584///
1585/// `execute` already does exactly this as its first step; exposing it separately
1586/// lets callers validate a query before deciding to run it. The natural-language
1587/// planner (`/v1/databases/:name/cast`) uses it to answer "is this runnable?"
1588/// without side effects — checking the text against the real grammar rather than
1589/// pattern-matching it, because the parser is the only authority on that.
1590pub fn parse(nql: &str) -> Result<Query> {
1591    let mut lexer = Lexer::new(nql);
1592    let toks = lexer.tokenize();
1593    let mut parser = Parser::new(toks);
1594    parser.parse()
1595}
1596
1597pub fn execute(db: &Db, nql: &str) -> Result<Vec<Value>> {
1598    // One parse path, shared with the public `parse()` above — so validation and
1599    // execution can never disagree about what is well-formed.
1600    let mut q = parse(nql)?;
1601
1602    // A wall-clock AS OF becomes a real sequence HERE, before any candidate
1603    // generation reads it. Placed at the top of execution rather than inside
1604    // the AS OF branch so no future fast path can pick up `q.as_of` while it
1605    // still holds a tagged marker.
1606    resolve_as_of_in(db, &mut q)?;
1607
1608    // ── Candidate generation ──────────────────────────────────────────────────
1609
1610    // Fast path: single equality filter on _id with no AS OF.
1611    // Skip the O(n) collection scan — go straight to the id index (O(1) file read).
1612    // This turns `FROM coll WHERE _id = "x" LIMIT 1` from a full-table-scan into
1613    // a single file read, giving orders-of-magnitude speedup for point lookups.
1614    let id_eq_fast_path: Option<String> = if q.as_of.is_none() && q.trace.is_none() {
1615        q.where_.as_ref().and_then(id_point_lookup)
1616    } else { None };
1617
1618    let candidates: Vec<Node> = if let Some(ref target_id) = id_eq_fast_path {
1619        // O(1) direct id-index lookup — skip full collection scan entirely
1620        db.get(&q.coll, target_id).into_iter().collect()
1621    } else if let Some(seq_target) = q.as_of {
1622        // AS OF: return each doc's version at or before target seq.
1623        //
1624        // Over the live ids PLUS the deleted ones. Walking only `id_index`
1625        // meant a DELETED document was invisible at every sequence, including
1626        // sequences before the delete where it demonstrably existed — so
1627        // `AS OF` contradicted the promise that a delete is a tombstone rather
1628        // than an erasure. `get_as_of` reaches the chain through the graveyard
1629        // pointer, and returns nothing for a sequence at or after the
1630        // tombstone, where the document really is gone.
1631        db.list_ids_including_deleted(&q.coll).into_iter()
1632            .filter_map(|id| db.get_as_of(&q.coll, &id, seq_target))
1633            .collect()
1634    } else if let Some(plan) = q.where_.as_ref()
1635        // An indexed range or point-set scan, when a sorted index covers a
1636        // field the predicate constrains.
1637        //
1638        // Deliberately NOT attempted for AS OF: the sorted index holds current
1639        // versions only (a superseded hash is dropped on overwrite), so an
1640        // index scan would answer a historical query with present-day rows.
1641        // AS OF is handled by the branch above, which walks the id index and
1642        // resolves each document at the target seq.
1643        .filter(|_| q.as_of.is_none())
1644        .and_then(|p| choose_index_plan(db, &q.coll, p))
1645    {
1646        // The full predicate re-runs on these candidates below, so the plan
1647        // only has to be a superset — it can never make the answer wrong.
1648        let got = match &plan {
1649            IndexPlan::Range { field, low, high, low_incl, high_incl } => db.range_scan(
1650                &q.coll, field, low.as_ref(), high.as_ref(), *low_incl, *high_incl),
1651            IndexPlan::Values { field, values } => db.index_lookup(&q.coll, field, values),
1652        };
1653        match got {
1654            Some(nodes) => nodes,
1655            // The index vanished between planning and execution. Fall back
1656            // rather than answering from nothing.
1657            None => db.list(&q.coll),
1658        }
1659    } else if q.order_by.len() == 1 && q.aggregate.is_none() {
1660        // ORDER BY with optional sorted index — get candidates in order.
1661        //
1662        // Single key only: the sorted index is per-field, so a multi-key sort
1663        // cannot be served from it and falls through to the post-filter sort
1664        // below. Never used when aggregating either, because the sort then
1665        // applies to the GROUPED rows, which do not exist yet.
1666        //
1667        // Push LIMIT down into the index scan ONLY when nothing filters rows
1668        // after candidate generation. WHERE / SEARCH / VALID AS OF all run on
1669        // the candidate set below, so truncating to the top-k FIRST returns
1670        // incomplete results: `WHERE n_tx > 100 ORDER BY height LIMIT 10`
1671        // would fetch the 10 lowest blocks by height and then filter — losing
1672        // matches past the top-k window. The Python reference filters → sorts
1673        // → limits (engine.py execute()); this keeps the engines in agreement.
1674        let key = &q.order_by[0];
1675        let has_post_filters = q.where_.is_some()
1676            || q.search.is_some()
1677            || q.valid_as_of.is_some();
1678        let limit = if has_post_filters {
1679            9_999_999
1680        } else {
1681            // OFFSET is applied AFTER the sort, so the pushdown has to fetch
1682            // offset + limit rows and discard the prefix later. Fetching only
1683            // `limit` would return the first page for every page.
1684            match q.limit {
1685                Some(n) => n.saturating_add(q.offset.unwrap_or(0)),
1686                None => 9_999_999,
1687            }
1688        };
1689        if key.desc {
1690            db.order_by_desc(&q.coll, &key.field, limit)
1691        } else {
1692            db.order_by_asc(&q.coll, &key.field, limit)
1693        }
1694    } else if let (Some(n), true) = (q.limit, q.where_.is_none()
1695            && q.search.is_none() && q.trace.is_none()
1696            && q.traverse.is_none() && q.aggregate.is_none()
1697            && q.order_by.is_empty() && q.offset.is_none()
1698            && q.valid_as_of.is_none()) {
1699        // LIMIT-only fast path: no filters, no ordering, no trace.
1700        // Take only the first N IDs from the id-index and fetch those docs.
1701        // This makes `FROM coll LIMIT 1` O(N) not O(total) — critical for
1702        // the Studio "Preparing…" phase which samples every collection.
1703        db.id_index
1704            .list_ids(&q.coll)
1705            .into_iter()
1706            .take(n)
1707            .filter_map(|id| db.get(&q.coll, &id))
1708            .collect()
1709    } else {
1710        // Default: all docs in collection
1711        db.list(&q.coll)
1712    };
1713
1714    // ── WHERE filter ──────────────────────────────────────────────────────────
1715
1716    let mut rows: Vec<Node> = candidates.into_iter()
1717        .filter(|n| q.where_.as_ref().map(|p| eval_pred(n, p)).unwrap_or(true))
1718        .filter(|n| q.valid_as_of.as_deref()
1719                       .map(|d| matches_valid_as_of(n, d))
1720                       .unwrap_or(true))
1721        .filter(|n| q.search.as_deref()
1722                       .map(|t| node_contains_text(n, t))
1723                       .unwrap_or(true))
1724        .collect();
1725
1726    // ── TRACE ─────────────────────────────────────────────────────────────────
1727
1728    if let Some(ref _edge_type) = q.trace {
1729        let limit = q.limit.unwrap_or(1000);
1730        let mut traced: Vec<Node> = vec![];
1731        for root in &rows {
1732            let chain = db.trace(&root.hash, q.trace_rev, limit);
1733            traced.extend(chain);
1734        }
1735        rows = traced;
1736    }
1737
1738    // ── TRAVERSE rel — one-hop named-relation lookup ──────────────────────────
1739
1740    if let Some(ref rel) = q.traverse {
1741        let mut traversed: Vec<Node> = vec![];
1742        for root in &rows {
1743            let frm = format!("{}:{}", root.coll, root.id);
1744            let neighbors = db.neighbors(&frm, rel);
1745            traversed.extend(neighbors);
1746        }
1747        rows = traversed;
1748    }
1749
1750    // ── Aggregate → HAVING → ORDER BY → OFFSET → LIMIT ───────────────────────
1751    //
1752    // This is the SQL pipeline order, and getting it wrong was a live source
1753    // of silently-wrong answers. The old order was ORDER BY → LIMIT → GROUP BY,
1754    // which means:
1755    //
1756    //   `LIMIT 5 GROUP BY status COUNT` truncated the INPUT to five rows and
1757    //   then grouped them, so with twelve rows across three statuses the
1758    //   counts summed to 5 instead of 12. A confident wrong aggregate.
1759    //
1760    //   `ORDER BY count DESC GROUP BY status COUNT` sorted the raw documents
1761    //   on a field none of them carry (`count` only exists after grouping),
1762    //   so the grouped output came back in arbitrary order and the clause was
1763    //   silently inert.
1764    //
1765    // In SQL, LIMIT and ORDER BY apply to the RESULT. They now do here.
1766
1767    if let Some(ref spec) = q.aggregate {
1768        let mut out = aggregate_rows(&rows, spec);
1769
1770        // HAVING filters the aggregated rows, so it can test `count`,
1771        // `sum_fee` or the group key — none of which exist before this point.
1772        if let Some(ref pred) = q.having {
1773            out.retain(|row| eval_pred_json(row, pred));
1774        }
1775
1776        if !q.order_by.is_empty() {
1777            sort_by_keys(&mut out, &q.order_by,
1778                         |row, f| row.get(f).cloned().unwrap_or(Value::Null));
1779        } else if let Some(ref gf) = spec.group_field {
1780            // Deterministic default: groups sorted by key.
1781            //
1782            // NOT first-seen order. The Python reference draws candidates from
1783            // a `set`, so its input row order is arbitrary; a first-seen
1784            // ordering would differ between the two engines even though both
1785            // are internally consistent. Sorting by the key gives one answer
1786            // they can agree on, which the cross-engine parity suite pins.
1787            let gf = gf.clone();
1788            out.sort_by(|a, b| {
1789                as_text(&a.get(&gf).cloned().unwrap_or(Value::Null))
1790                    .cmp(&as_text(&b.get(&gf).cloned().unwrap_or(Value::Null)))
1791            });
1792        }
1793
1794        return Ok(paginate(out, q.offset, q.limit));
1795    }
1796
1797    if q.having.is_some() {
1798        bail!("HAVING requires an aggregate — add GROUP BY <field>, or use WHERE \
1799               to filter individual rows");
1800    }
1801
1802    // ── ORDER BY (post-filter sort if no sorted index was used) ──────────────
1803
1804    if !q.order_by.is_empty() {
1805        // The single-key sorted-index path above already returned candidates
1806        // in order, but only when nothing filtered them afterwards. Re-sort
1807        // whenever a filter ran, or whenever the sort has more than one key.
1808        let index_path_held = q.order_by.len() == 1
1809            && q.as_of.is_none()
1810            && q.where_.is_none()
1811            && q.search.is_none()
1812            && q.valid_as_of.is_none()
1813            && q.trace.is_none()
1814            && q.traverse.is_none();
1815        if !index_path_held {
1816            sort_by_keys(&mut rows, &q.order_by,
1817                         |n, f| field_value(n, f));
1818        }
1819    }
1820
1821    // ── OFFSET then LIMIT ────────────────────────────────────────────────────
1822
1823    let rows = paginate(rows, q.offset, q.limit);
1824
1825    // ── Serialize ─────────────────────────────────────────────────────────────
1826
1827    Ok(rows.into_iter().map(|n| node_to_json(&n)).collect())
1828}
1829
1830/// Parse and execute NQL, returning (rows, count).
1831/// Turn a parsed `AS OF` marker into a real sequence, in place.
1832///
1833/// Called on every parsed query before execution. The NQL executor reads
1834/// `q.as_of` as a literal sequence and hands it to `Db::get_as_of`, so a
1835/// wall-clock marker that reached it unresolved would be read as a sequence
1836/// near 2^63 -- answering a question about the past from a point in history
1837/// that does not exist, silently and with full confidence. That is strictly
1838/// worse than the parse error this replaced, which is why the parser change
1839/// and this one are inseparable.
1840fn resolve_as_of_in(db: &Db, q: &mut Query) -> Result<()> {
1841    if let Some(marker) = q.as_of {
1842        q.as_of = Some(
1843            crate::relation::resolve_as_of(db, marker).map_err(|e| anyhow::anyhow!("{}", e))?,
1844        );
1845    }
1846    Ok(())
1847}
1848
1849pub fn query(db: &Db, nql: &str) -> Result<(Vec<Value>, usize)> {
1850    let rows = execute(db, nql)?;
1851    let count = rows.len();
1852    Ok((rows, count))
1853}
1854
1855/// Run a query's WHERE / ORDER BY / OFFSET / LIMIT against rows ALREADY IN
1856/// HAND, rather than against stored documents.
1857///
1858/// This is what makes `pg_catalog` and `information_schema` real queryable
1859/// tables instead of pattern-matched query strings. A catalogue row is
1860/// synthesised from the live database, never stored — but `psql` filters and
1861/// orders it with ordinary SQL, so it needs the ordinary predicate surface.
1862///
1863/// The alternative was to recognise psql's exact query text and answer it from
1864/// a fixed table. That breaks SILENTLY the moment psql changes its query, and
1865/// an empty table list is indistinguishable from "this database has no
1866/// tables" — the same class of confidently-wrong answer as everything else
1867/// this engine has had to fix. So the predicate engine is REUSED here rather
1868/// than a second, poorer copy being written: one `eval_pred_with`, one
1869/// `sort_by_keys`, one `paginate`, already tested.
1870///
1871/// Clauses that only mean something against the log — `AS OF`, `VALID AS OF`,
1872/// `TRACE`, `TRAVERSE`, `SEARCH`, and aggregates — are REFUSED by name. A
1873/// catalogue has no history and no causal edges; silently ignoring the clause
1874/// would answer a time-travel question with present-day rows.
1875pub fn query_rows(rows: Vec<Value>, nql: &str) -> Result<Vec<Value>> {
1876    let q = parse(nql)?;
1877
1878    for (unsupported, clause) in [
1879        (q.as_of.is_some(), "AS OF"),
1880        (q.valid_as_of.is_some(), "VALID AS OF"),
1881        (q.trace.is_some(), "TRACE"),
1882        (q.traverse.is_some(), "TRAVERSE"),
1883        (q.search.is_some(), "SEARCH"),
1884        (q.aggregate.is_some(), "an aggregate"),
1885        (q.having.is_some(), "HAVING"),
1886    ] {
1887        if unsupported {
1888            bail!("{} is not supported on the catalogue table {:?} — a catalogue \
1889                   is synthesised from the current database, so it has no history, \
1890                   no causal edges and nothing to aggregate. Query the collection \
1891                   itself for those", clause, q.coll);
1892        }
1893    }
1894
1895    let get = |row: &Value, field: &str| -> Value {
1896        row.get(field).cloned().unwrap_or(Value::Null)
1897    };
1898
1899    let mut kept: Vec<Value> = match &q.where_ {
1900        None => rows,
1901        Some(pred) => rows
1902            .into_iter()
1903            .filter(|r| eval_pred_with(&|f| get(r, f), pred))
1904            .collect(),
1905    };
1906
1907    if !q.order_by.is_empty() {
1908        sort_by_keys(&mut kept, &q.order_by, get);
1909    }
1910    Ok(paginate(kept, q.offset, q.limit))
1911}
1912
1913#[cfg(test)]
1914mod tests {
1915    use super::*;
1916    use tempfile::tempdir;
1917    use crate::db::Db;
1918
1919    // Returns (TempDir, Db) — the TempDir guard MUST be kept alive by the caller
1920    // (`let (_tmp, db) = setup();`). If it dropped here, its Drop would delete the
1921    // database directory out from under the live Db, and every objects.read()
1922    // (loose object files live on disk) would fail → queries return 0 rows.
1923    fn setup() -> (tempfile::TempDir, Db) {
1924        let dir = tempdir().unwrap();
1925        let db = Db::open(dir.path(), None).unwrap();
1926        db.create_sorted_index("blocks", "height");
1927        for h in 1u64..=5 {
1928            db.put("blocks", &h.to_string(),
1929                serde_json::json!({"height": h, "hash": format!("000{}", h), "n_tx": h * 2}),
1930                vec![], None, None).unwrap();
1931        }
1932        (dir, db)
1933    }
1934
1935    #[test]
1936    fn from_all() {
1937        let (_tmp, db) = setup();
1938        let (rows, count) = query(&db, "FROM blocks").unwrap();
1939        assert_eq!(count, 5);
1940        let _ = rows;
1941    }
1942
1943    #[test]
1944    fn where_eq() {
1945        let (_tmp, db) = setup();
1946        let (rows, count) = query(&db, r#"FROM blocks WHERE _id = "3""#).unwrap();
1947        assert_eq!(count, 1);
1948        assert_eq!(rows[0]["_id"], "3");
1949    }
1950
1951    #[test]
1952    fn order_by_limit() {
1953        let (_tmp, db) = setup();
1954        let (rows, count) = query(&db, "FROM blocks ORDER BY height ASC LIMIT 3").unwrap();
1955        assert_eq!(count, 3);
1956        assert_eq!(rows[0]["height"], 1);
1957        assert_eq!(rows[2]["height"], 3);
1958    }
1959
1960    #[test]
1961    fn order_by_desc() {
1962        let (_tmp, db) = setup();
1963        let (rows, _) = query(&db, "FROM blocks ORDER BY height DESC LIMIT 2").unwrap();
1964        assert_eq!(rows[0]["height"], 5);
1965    }
1966
1967    #[test]
1968    fn where_gt() {
1969        let (_tmp, db) = setup();
1970        let (rows, _) = query(&db, "FROM blocks WHERE height > 3").unwrap();
1971        assert_eq!(rows.len(), 2);
1972    }
1973
1974    /// Regression: WHERE + ORDER BY + LIMIT must not truncate candidates
1975    /// before the filter runs. setup() gives heights 1..=5 with n_tx = h*2;
1976    /// the predicate matches ONLY the two highest heights (4, 5). The old
1977    /// code passed LIMIT into the sorted-index top-k first: it fetched
1978    /// heights [1, 2], filtered on n_tx >= 8, and returned ZERO rows even
1979    /// though two matches exist. Python reference returns [4, 5].
1980    #[test]
1981    fn where_order_limit_does_not_truncate_before_filter() {
1982        let (_tmp, db) = setup();
1983        let (rows, count) =
1984            query(&db, "FROM blocks WHERE n_tx >= 8 ORDER BY height LIMIT 2").unwrap();
1985        assert_eq!(count, 2, "both matching rows must survive the limit");
1986        let heights: Vec<u64> = rows.iter()
1987            .filter_map(|r| r["height"].as_u64())
1988            .collect();
1989        assert_eq!(heights, vec![4, 5]);
1990        // And the same shape DESC — top match first.
1991        let (rows_d, _) =
1992            query(&db, "FROM blocks WHERE n_tx >= 8 ORDER BY height DESC LIMIT 1").unwrap();
1993        assert_eq!(rows_d.len(), 1);
1994        assert_eq!(rows_d[0]["height"], 5);
1995    }
1996
1997    // ── Predicate parity (3.3.0) ─────────────────────────────────────────────
1998    //
1999    // setup() gives blocks 1..=5 with height = h, hash = "000{h}",
2000    // n_tx = h * 2. Every test below asserts against that fixture.
2001
2002    /// A second fixture with string fields and a sparse column, for LIKE and
2003    /// IS NULL. `miner` is absent on one row on purpose.
2004    fn setup_text() -> (tempfile::TempDir, Db) {
2005        let dir = tempdir().unwrap();
2006        let db = Db::open(dir.path(), None).unwrap();
2007        let rows = [
2008            ("1", serde_json::json!({"status": "open",    "miner": "Acme Pool", "fee": 10})),
2009            ("2", serde_json::json!({"status": "pending", "miner": "acme solo", "fee": 20})),
2010            ("3", serde_json::json!({"status": "closed",  "miner": "Zenith",    "fee": 30})),
2011            ("4", serde_json::json!({"status": "open",    "fee": 40})),
2012            ("5", serde_json::json!({"status": "voided",  "miner": Value::Null, "fee": 50})),
2013        ];
2014        for (id, data) in rows {
2015            db.put("jobs", id, data, vec![], None, None).unwrap();
2016        }
2017        (dir, db)
2018    }
2019
2020    fn ids(rows: &[Value]) -> Vec<String> {
2021        let mut v: Vec<String> = rows.iter()
2022            .filter_map(|r| r["_id"].as_str().map(String::from))
2023            .collect();
2024        v.sort();
2025        v
2026    }
2027
2028    #[test]
2029    fn where_in_list() {
2030        let (_tmp, db) = setup();
2031        let (rows, _) = query(&db, "FROM blocks WHERE height IN (2, 4)").unwrap();
2032        assert_eq!(ids(&rows), vec!["2", "4"]);
2033    }
2034
2035    #[test]
2036    fn where_in_strings() {
2037        let (_tmp, db) = setup_text();
2038        let (rows, _) = query(&db, r#"FROM jobs WHERE status IN ("open", "closed")"#).unwrap();
2039        assert_eq!(ids(&rows), vec!["1", "3", "4"]);
2040    }
2041
2042    #[test]
2043    fn where_not_in() {
2044        let (_tmp, db) = setup();
2045        let (rows, _) = query(&db, "FROM blocks WHERE height NOT IN (1, 2, 3)").unwrap();
2046        assert_eq!(ids(&rows), vec!["4", "5"]);
2047    }
2048
2049    #[test]
2050    fn where_in_single_value_equals_eq() {
2051        let (_tmp, db) = setup();
2052        let (a, _) = query(&db, "FROM blocks WHERE height IN (3)").unwrap();
2053        let (b, _) = query(&db, "FROM blocks WHERE height = 3").unwrap();
2054        assert_eq!(ids(&a), ids(&b));
2055    }
2056
2057    #[test]
2058    fn where_between_is_inclusive() {
2059        let (_tmp, db) = setup();
2060        let (rows, _) = query(&db, "FROM blocks WHERE height BETWEEN 2 AND 4").unwrap();
2061        // SQL BETWEEN includes both bounds — 2 and 4 must be present.
2062        assert_eq!(ids(&rows), vec!["2", "3", "4"]);
2063    }
2064
2065    #[test]
2066    fn where_not_between() {
2067        let (_tmp, db) = setup();
2068        let (rows, _) = query(&db, "FROM blocks WHERE height NOT BETWEEN 2 AND 4").unwrap();
2069        assert_eq!(ids(&rows), vec!["1", "5"]);
2070    }
2071
2072    /// The AND inside BETWEEN belongs to BETWEEN, not to the conjunction
2073    /// parser. If parse_and grabbed it first, this query would fail to parse
2074    /// or silently lose the second bound.
2075    #[test]
2076    fn between_and_does_not_swallow_the_conjunction() {
2077        let (_tmp, db) = setup();
2078        let (rows, _) = query(
2079            &db, "FROM blocks WHERE height BETWEEN 2 AND 4 AND n_tx > 4").unwrap();
2080        // heights 2,3,4 then n_tx > 4 (n_tx = h*2) leaves 3 and 4.
2081        assert_eq!(ids(&rows), vec!["3", "4"]);
2082    }
2083
2084    #[test]
2085    fn where_like_prefix_suffix_and_infix() {
2086        let (_tmp, db) = setup_text();
2087        let (pre, _) = query(&db, r#"FROM jobs WHERE miner LIKE "Acme%""#).unwrap();
2088        assert_eq!(ids(&pre), vec!["1"]);
2089        let (suf, _) = query(&db, r#"FROM jobs WHERE miner LIKE "%Pool""#).unwrap();
2090        assert_eq!(ids(&suf), vec!["1"]);
2091        let (inf, _) = query(&db, r#"FROM jobs WHERE status LIKE "%pen%""#).unwrap();
2092        assert_eq!(ids(&inf), vec!["1", "2", "4"]);   // open, pending, open
2093    }
2094
2095    #[test]
2096    fn where_like_underscore_matches_exactly_one_char() {
2097        let (_tmp, db) = setup_text();
2098        let (rows, _) = query(&db, r#"FROM jobs WHERE status LIKE "open_""#).unwrap();
2099        assert!(rows.is_empty(), "`open_` must not match the 4-char value `open`");
2100        let (rows2, _) = query(&db, r#"FROM jobs WHERE status LIKE "ope_""#).unwrap();
2101        assert_eq!(ids(&rows2), vec!["1", "4"]);
2102    }
2103
2104    #[test]
2105    fn where_ilike_is_case_insensitive_and_like_is_not() {
2106        let (_tmp, db) = setup_text();
2107        let (ci, _) = query(&db, r#"FROM jobs WHERE miner ILIKE "acme%""#).unwrap();
2108        assert_eq!(ci.len(), 2, "ILIKE matches both `Acme Pool` and `acme solo`");
2109        let (cs, _) = query(&db, r#"FROM jobs WHERE miner LIKE "acme%""#).unwrap();
2110        assert_eq!(ids(&cs), vec!["2"], "LIKE stays case-sensitive");
2111    }
2112
2113    /// The backtracking path: multiple `%` with literals between them, where a
2114    /// greedy first match must be given back for the pattern to succeed.
2115    #[test]
2116    fn like_backtracks_across_multiple_wildcards() {
2117        assert!(like_match("abcabcabd", "%abc%abd", false));
2118        assert!(like_match("aaa", "%a", false));
2119        assert!(like_match("", "%", false));
2120        assert!(like_match("x", "%%%", false));
2121        assert!(!like_match("abc", "%abd", false));
2122        assert!(!like_match("ab", "ab_", false));
2123        assert!(like_match("héllo wörld", "h_llo w%d", false));
2124    }
2125
2126    // ── `~` / `!~` — the operator psql's catalogue filters need ─────────────
2127
2128    #[test]
2129    fn regex_anchors_behave_as_posix_says() {
2130        // THE case this exists for: psql's \dn sends `nspname !~ '^pg_'`.
2131        assert!(regex_match("pg_catalog", "^pg_", false));
2132        assert!(regex_match("pg_toast_1", "^pg_toast", false));
2133        assert!(!regex_match("public", "^pg_", false));
2134        // `^` only anchors at position 0 — a schema merely CONTAINING pg_ is
2135        // not a system schema, and treating it as one would hide a user's data.
2136        assert!(!regex_match("my_pg_stuff", "^pg_", false));
2137
2138        assert!(regex_match("report.sql", "sql$", false));
2139        assert!(!regex_match("sql_report", "sql$", false));
2140        // Anchored at both ends is an exact match.
2141        assert!(regex_match("public", "^public$", false));
2142        assert!(!regex_match("public2", "^public$", false));
2143        // Unanchored is a substring search.
2144        assert!(regex_match("xxpg_yy", "pg_", false));
2145        assert!(!regex_match("xxqg_yy", "pg_", false));
2146    }
2147
2148    #[test]
2149    fn regex_dot_matches_exactly_one_character() {
2150        assert!(regex_match("abc", "a.c", false));
2151        assert!(!regex_match("ac", "a.c", false), "`.` is one char, not zero");
2152        assert!(!regex_match("abbc", "a.c", false), "`.` is one char, not many");
2153        // Operates on chars, so a multi-byte value matches correctly.
2154        assert!(regex_match("héllo", "h.llo", false));
2155    }
2156
2157    #[test]
2158    fn regex_case_insensitivity_is_opt_in() {
2159        assert!(regex_match("PG_CATALOG", "^pg_", true));
2160        assert!(!regex_match("PG_CATALOG", "^pg_", false),
2161                "`~` is case SENSITIVE; only `~*` folds case");
2162    }
2163
2164    #[test]
2165    fn an_empty_regex_matches_anything() {
2166        // POSIX says so, and `$` alone is an empty anchored pattern.
2167        assert!(regex_match("anything", "", false));
2168        assert!(regex_match("", "", false));
2169        assert!(regex_match("x", "$", false));
2170    }
2171
2172    #[test]
2173    fn regex_groups_alternation_classes_and_quantifiers_match_as_ERE_says() {
2174        // THE case that forced the wider subset: `\d orders` sends
2175        // `relname ~ '^(orders)$'`, and `\d ord*` sends `^(ord.*)$`.
2176        assert!(regex_match("orders", "^(orders)$", false));
2177        assert!(!regex_match("orders2", "^(orders)$", false));
2178        assert!(regex_match("orders", "^(ord.*)$", false));
2179        assert!(regex_match("ord", "^(ord.*)$", false), "`.*` may match nothing");
2180        assert!(!regex_match("xord", "^(ord.*)$", false));
2181        // alternation, inside and outside a group
2182        assert!(regex_match("drivers", "^(orders|drivers)$", false));
2183        assert!(!regex_match("riders", "^(orders|drivers)$", false));
2184        assert!(regex_match("b", "a|b", false));
2185        // quantifiers are greedy and backtrack
2186        assert!(regex_match("aaab", "^a+b$", false));
2187        assert!(!regex_match("b", "^a+b$", false));
2188        assert!(regex_match("b", "^a*b$", false));
2189        assert!(regex_match("ab", "^a?b$", false));
2190        assert!(!regex_match("aab", "^a?b$", false));
2191        assert!(regex_match("aXb", "^a.+b$", false));
2192        // classes, ranges, negation, and a trailing literal `-`
2193        assert!(regex_match("pg_toast_9", "^pg_[a-z]+_[0-9]$", false));
2194        assert!(!regex_match("pg_toast_x", "^pg_[a-z]+_[0-9]$", false));
2195        assert!(regex_match("x", "^[^0-9]$", false));
2196        assert!(!regex_match("5", "^[^0-9]$", false));
2197        assert!(regex_match("a-b", "^a[-]b$", false));
2198        // an escape is the literal character, so `\.` is a dot and not "any"
2199        assert!(regex_match("a.b", "^a\\.b$", false));
2200        assert!(!regex_match("axb", "^a\\.b$", false));
2201        assert!(regex_match("(x)", "^\\(x\\)$", false));
2202        // `()*` must not loop forever: an empty repetition consumes nothing
2203        assert!(regex_match("q", "^()*q$", false));
2204        // operates on chars
2205        assert!(regex_match("héllo", "^h.l+o$", false));
2206    }
2207
2208    #[test]
2209    fn an_unsupported_regex_construct_is_REFUSED_BY_NAME_not_approximated() {
2210        // Matching `a{2,3}` approximately would silently include or exclude
2211        // rows, and a wrong catalogue listing looks exactly like a correct
2212        // one. So the parser refuses and names the construct.
2213        for (pat, needle) in [
2214            ("a{2}", "interval"),
2215            ("[[:alpha:]]", "POSIX character class"),
2216            ("(a)\\1", "back-reference"),
2217            ("\\d+", "shorthand class"),
2218            ("(ab", "unmatched '('"),
2219            ("ab)", "unmatched ')'"),
2220            ("[ab", "unmatched '['"),
2221            ("*a", "nothing to repeat"),
2222            ("[z-a]", "reversed"),
2223        ] {
2224            let why = regex_error(pat).unwrap_or_else(|| {
2225                panic!("{:?} must be refused, not matched approximately", pat)
2226            });
2227            assert!(why.contains(needle), "{:?}: {:?} should name {:?}", pat, why, needle);
2228            // And the matcher never answers for a pattern the parser refused.
2229            assert!(!regex_match("aa", pat, false));
2230        }
2231        for pat in ["^pg_", "sql$", "^public$", "a.c", "plain", "", "^(orders)$",
2232                    "a+b", "a*b", "a?b", "[ab]", "(a|b)", "a\\.b", "a[-]b"] {
2233            assert_eq!(regex_error(pat), None, "{:?} is in the subset", pat);
2234        }
2235    }
2236
2237    #[test]
2238    fn the_regex_operators_parse_in_all_four_spellings() {
2239        for (nql, negated, ci) in [
2240            (r#"FROM t WHERE nspname ~ "^pg_""#,   false, false),
2241            (r#"FROM t WHERE nspname ~* "^pg_""#,  false, true),
2242            (r#"FROM t WHERE nspname !~ "^pg_""#,  true,  false),
2243            (r#"FROM t WHERE nspname !~* "^pg_""#, true,  true),
2244        ] {
2245            let q = parse(nql).unwrap_or_else(|e| panic!("{}: {}", nql, e));
2246            match q.where_.expect("a predicate") {
2247                Pred::Regex { field, pattern, negated: n, ci: c } => {
2248                    assert_eq!(field, "nspname");
2249                    assert_eq!(pattern, "^pg_");
2250                    assert_eq!((n, c), (negated, ci), "{}", nql);
2251                }
2252                other => panic!("{} parsed as {:?}", nql, other),
2253            }
2254        }
2255    }
2256
2257    #[test]
2258    fn a_bad_regex_is_rejected_at_parse_time_with_the_offending_char() {
2259        let e = parse(r#"FROM t WHERE x ~ "a{2}""#).unwrap_err().to_string();
2260        assert!(e.contains("interval"), "the error must name the construct: {}", e);
2261        assert!(e.contains("refused"), "{}", e);
2262    }
2263
2264    #[test]
2265    fn a_regex_over_a_missing_field_is_false_in_both_polarities() {
2266        // SQL three-valued logic, matching LIKE and matching Postgres. psql's
2267        // catalogue filters depend on `!~` NOT resurrecting absent rows.
2268        let (_tmp, db) = setup_items();
2269        let (m, _) = query(&db, r#"FROM items WHERE nosuchfield ~ "x""#).unwrap();
2270        assert!(m.is_empty());
2271        let (n, _) = query(&db, r#"FROM items WHERE nosuchfield !~ "x""#).unwrap();
2272        assert!(n.is_empty(), "NOT over NULL must not match either");
2273    }
2274
2275    #[test]
2276    fn the_regex_operator_works_end_to_end_over_stored_documents() {
2277        let (_tmp, db) = setup_items();
2278        let (all, _) = query(&db, "FROM items").unwrap();
2279        let want: Vec<String> = all.iter()
2280            .filter_map(|r| r["_id"].as_str().map(str::to_string)).collect();
2281        // `_id` on every seeded item is non-empty, so an empty pattern matches
2282        // all of them — a control proving the operator reaches the executor.
2283        let (got, _) = query(&db, r#"FROM items WHERE _id ~ """#).unwrap();
2284        assert_eq!(got.len(), want.len());
2285        // And `!~` over the same pattern matches none.
2286        let (none, _) = query(&db, r#"FROM items WHERE _id !~ """#).unwrap();
2287        assert!(none.is_empty());
2288    }
2289
2290    #[test]
2291    fn where_not_like() {
2292        let (_tmp, db) = setup_text();
2293        let (rows, _) = query(&db, r#"FROM jobs WHERE status NOT LIKE "open""#).unwrap();
2294        assert_eq!(ids(&rows), vec!["2", "3", "5"]);
2295    }
2296
2297    /// NOT LIKE over a NULL/absent field stays false, as in SQL: a predicate
2298    /// over NULL is never true in either polarity. Rows 4 (absent) and 5
2299    /// (explicit null) must appear in NEITHER `LIKE` nor `NOT LIKE`.
2300    #[test]
2301    fn like_over_null_is_false_in_both_polarities() {
2302        let (_tmp, db) = setup_text();
2303        let (pos, _) = query(&db, r#"FROM jobs WHERE miner LIKE "%""#).unwrap();
2304        let (neg, _) = query(&db, r#"FROM jobs WHERE miner NOT LIKE "%""#).unwrap();
2305        assert!(!ids(&pos).contains(&"4".to_string()));
2306        assert!(!ids(&neg).contains(&"4".to_string()));
2307        assert!(!ids(&pos).contains(&"5".to_string()));
2308        assert!(!ids(&neg).contains(&"5".to_string()));
2309    }
2310
2311    /// Absent and explicitly-null are the same observable state in a
2312    /// schemaless store, so IS NULL must catch both.
2313    #[test]
2314    fn where_is_null_catches_absent_and_explicit_null() {
2315        let (_tmp, db) = setup_text();
2316        let (rows, _) = query(&db, "FROM jobs WHERE miner IS NULL").unwrap();
2317        assert_eq!(ids(&rows), vec!["4", "5"]);
2318    }
2319
2320    #[test]
2321    fn where_is_not_null() {
2322        let (_tmp, db) = setup_text();
2323        let (rows, _) = query(&db, "FROM jobs WHERE miner IS NOT NULL").unwrap();
2324        assert_eq!(ids(&rows), vec!["1", "2", "3"]);
2325    }
2326
2327    #[test]
2328    fn where_or() {
2329        let (_tmp, db) = setup();
2330        let (rows, _) = query(&db, "FROM blocks WHERE height = 1 OR height = 5").unwrap();
2331        assert_eq!(ids(&rows), vec!["1", "5"]);
2332    }
2333
2334    /// AND binds tighter than OR, so this is `a OR (b AND c)` and NOT
2335    /// `(a OR b) AND c`. With the wrong precedence the result would be [5].
2336    #[test]
2337    fn and_binds_tighter_than_or() {
2338        let (_tmp, db) = setup();
2339        let (rows, _) = query(
2340            &db, "FROM blocks WHERE height = 1 OR height = 5 AND n_tx = 10").unwrap();
2341        assert_eq!(ids(&rows), vec!["1", "5"]);
2342        let (rows2, _) = query(
2343            &db, "FROM blocks WHERE height = 1 OR height = 5 AND n_tx = 99").unwrap();
2344        assert_eq!(ids(&rows2), vec!["1"], "the AND arm must not match");
2345    }
2346
2347    /// Parentheses must be able to override that precedence.
2348    #[test]
2349    fn parens_override_precedence() {
2350        let (_tmp, db) = setup();
2351        let (rows, _) = query(
2352            &db, "FROM blocks WHERE (height = 1 OR height = 5) AND n_tx = 10").unwrap();
2353        assert_eq!(ids(&rows), vec!["5"]);
2354    }
2355
2356    #[test]
2357    fn nested_parens() {
2358        let (_tmp, db) = setup();
2359        let (rows, _) = query(
2360            &db,
2361            "FROM blocks WHERE ((height >= 2 AND height <= 4) OR height = 1) AND n_tx != 6",
2362        ).unwrap();
2363        assert_eq!(ids(&rows), vec!["1", "2", "4"]);
2364    }
2365
2366    #[test]
2367    fn not_negates_a_group() {
2368        let (_tmp, db) = setup();
2369        let (rows, _) = query(&db, "FROM blocks WHERE NOT (height > 2)").unwrap();
2370        assert_eq!(ids(&rows), vec!["1", "2"]);
2371    }
2372
2373    /// Prefix NOT before a bare comparison, as SQL allows. Distinct from the
2374    /// INFIX `field NOT <op>` form, which is a syntax error — only NOT IN /
2375    /// NOT BETWEEN / NOT LIKE exist in that position.
2376    #[test]
2377    fn prefix_not_before_a_comparison() {
2378        let (_tmp, db) = setup();
2379        let (rows, _) = query(&db, "FROM blocks WHERE NOT height = 1").unwrap();
2380        assert_eq!(ids(&rows), vec!["2", "3", "4", "5"]);
2381        let (double, _) = query(&db, "FROM blocks WHERE NOT NOT height = 1").unwrap();
2382        assert_eq!(ids(&double), vec!["1"]);
2383        let (mixed, _) = query(&db, "FROM blocks WHERE NOT height = 1 AND height < 4").unwrap();
2384        assert_eq!(ids(&mixed), vec!["2", "3"]);
2385    }
2386
2387    /// `_id = "x"` takes an O(1) index path. Under an OR it does not constrain
2388    /// the result set, so using it as a point lookup would drop every row the
2389    /// other arm matched. Guards the id_point_lookup AND-only descent.
2390    #[test]
2391    fn id_equality_under_or_does_not_become_a_point_lookup() {
2392        let (_tmp, db) = setup();
2393        let (rows, _) = query(&db, r#"FROM blocks WHERE _id = "1" OR height > 3"#).unwrap();
2394        assert_eq!(ids(&rows), vec!["1", "4", "5"],
2395                   "the OR arm must survive the id fast path");
2396    }
2397
2398    /// The fast path is still taken when the equality is a genuine conjunct.
2399    #[test]
2400    fn id_equality_under_and_still_point_looks_up() {
2401        let (_tmp, db) = setup();
2402        let (hit, _) = query(&db, r#"FROM blocks WHERE _id = "3" AND n_tx = 6"#).unwrap();
2403        assert_eq!(ids(&hit), vec!["3"]);
2404        let (miss, _) = query(&db, r#"FROM blocks WHERE _id = "3" AND n_tx = 999"#).unwrap();
2405        assert!(miss.is_empty(), "the second conjunct must still be applied");
2406    }
2407
2408    #[test]
2409    fn metadata_fields_are_filterable() {
2410        let (_tmp, db) = setup();
2411        let (rows, _) = query(&db, "FROM blocks WHERE _seq >= 0 AND _coll = blocks").unwrap();
2412        assert_eq!(rows.len(), 5);
2413
2414        // Cut above the FIRST row's seq rather than above a hardcoded 0. The
2415        // absolute value moved when collection registration became a real
2416        // write (seq 0 is now the `_nedb.collections` record), and a test that
2417        // pins absolute sequence numbers is testing the write order of the
2418        // engine's bookkeeping, not whether `_seq` is filterable.
2419        let first = rows.iter()
2420            .filter_map(|r| r.get("_seq").and_then(|v| v.as_u64()))
2421            .min()
2422            .expect("five rows carrying _seq");
2423        let (tail, _) = query(&db, &format!("FROM blocks WHERE _seq > {}", first)).unwrap();
2424        assert_eq!(tail.len(), 4);
2425    }
2426
2427    /// The engine's own bookkeeping is not part of anybody's query results.
2428    #[test]
2429    fn a_reserved_collection_never_leaks_into_a_user_query() {
2430        let (_tmp, db) = setup();
2431        let (rows, _) = query(&db, "FROM blocks").unwrap();
2432        assert!(
2433            rows.iter().all(|r| r.get("_coll").and_then(|v| v.as_str()) == Some("blocks")),
2434            "a query for one collection returned rows from another"
2435        );
2436        assert!(
2437            !db.collections().iter().any(|c| crate::namespace::is_reserved(c)),
2438            "the registry is not a user collection"
2439        );
2440    }
2441
2442    #[test]
2443    fn combined_with_order_and_limit() {
2444        let (_tmp, db) = setup();
2445        let (rows, _) = query(
2446            &db,
2447            "FROM blocks WHERE height IN (1, 3, 5) ORDER BY height DESC LIMIT 2",
2448        ).unwrap();
2449        let heights: Vec<u64> = rows.iter().filter_map(|r| r["height"].as_u64()).collect();
2450        assert_eq!(heights, vec![5, 3]);
2451    }
2452
2453    // ── Strictness: a query the engine cannot honour must FAIL, not lie ──────
2454
2455    /// The headline regression. `_ => { self.advance(); }` meant an
2456    /// unimplemented or misspelled clause was dropped and a DIFFERENT query
2457    /// was answered. Each of these previously returned rows.
2458    #[test]
2459    fn unknown_clauses_are_errors_not_silent_skips() {
2460        let (_tmp, db) = setup();
2461        for bad in [
2462            "FROM blocks ORDRE BY height",       // typo
2463            "FROM blocks WHERE height > 3 JUNK", // trailing garbage
2464            "FROM blocks SELECT height",         // wrong dialect
2465            "FROM blocks LIMIT",                 // missing count
2466            "FROM blocks OFFSET",                // missing count
2467            "FROM blocks ORDER BY",              // missing key
2468            "FROM blocks ORDER BY height,",      // trailing comma
2469        ] {
2470            assert!(query(&db, bad).is_err(), "`{}` must be rejected, not silently reinterpreted", bad);
2471        }
2472    }
2473
2474    #[test]
2475    fn malformed_predicates_are_errors() {
2476        let (_tmp, db) = setup();
2477        for bad in [
2478            "FROM blocks WHERE height >",            // missing value
2479            "FROM blocks WHERE height IN (",         // unterminated list
2480            "FROM blocks WHERE height IN ()",        // empty list
2481            "FROM blocks WHERE height BETWEEN 1",    // missing AND high
2482            "FROM blocks WHERE height BETWEEN 1 3",  // missing AND
2483            "FROM blocks WHERE (height = 1",         // unbalanced paren
2484            "FROM blocks WHERE height IS 3",         // IS without NULL
2485            "FROM blocks WHERE height NOT = 1",      // infix NOT before a comparison op
2486            "FROM blocks WHERE height LIKE",         // missing pattern
2487        ] {
2488            assert!(query(&db, bad).is_err(), "`{}` must be a parse error", bad);
2489        }
2490    }
2491
2492    /// ASC used to survive only because unknown tokens were skipped. Now that
2493    /// skipping is gone it has to be a real keyword, and the pre-existing
2494    /// `order_by_limit` test above depends on it.
2495    #[test]
2496    fn asc_is_accepted_explicitly() {
2497        let (_tmp, db) = setup();
2498        let (asc, _) = query(&db, "FROM blocks ORDER BY height ASC").unwrap();
2499        let (plain, _) = query(&db, "FROM blocks ORDER BY height").unwrap();
2500        assert_eq!(asc[0]["height"], 1);
2501        assert_eq!(plain[0]["height"], 1);
2502    }
2503
2504    /// Lowercase and mixed-case keywords must keep working — the lexer
2505    /// uppercases before matching, and the new keywords must be no different.
2506    #[test]
2507    fn new_keywords_are_case_insensitive() {
2508        let (_tmp, db) = setup();
2509        let (rows, _) = query(&db, "from blocks where height between 2 and 3").unwrap();
2510        assert_eq!(ids(&rows), vec!["2", "3"]);
2511        let (rows2, _) = query(&db, "FROM blocks Where height In (1) Or height In (2)").unwrap();
2512        assert_eq!(ids(&rows2), vec!["1", "2"]);
2513    }
2514
2515    #[test]
2516    fn group_by_count() {
2517        let (_tmp, db) = setup();
2518        let (rows, _) = query(&db, "FROM blocks GROUP BY n_tx COUNT").unwrap();
2519        assert_eq!(rows.len(), 5); // all unique n_tx values
2520    }
2521
2522    // ── Indexed range / point scans (3.3.0) ─────────────────────────────────
2523    //
2524    // The load-bearing property is EQUIVALENCE: an indexed query and the same
2525    // query without an index must return the same rows. The planner is allowed
2526    // to be imprecise (it only has to produce a superset — the full predicate
2527    // re-runs on the candidates) but it is never allowed to be wrong.
2528    //
2529    // Every test below therefore runs the same query against two databases
2530    // holding identical data, one indexed and one not, and compares.
2531
2532    /// Build two identical databases, one with sorted indexes on `fields`.
2533    fn twin(fields: &[&str]) -> (tempfile::TempDir, tempfile::TempDir, Db, Db) {
2534        let d1 = tempdir().unwrap();
2535        let d2 = tempdir().unwrap();
2536        let indexed = Db::open(d1.path(), None).unwrap();
2537        let plain = Db::open(d2.path(), None).unwrap();
2538        for f in fields {
2539            indexed.create_sorted_index("t", f);
2540        }
2541        // Deliberately messy: duplicate fees, a missing field, a null, a
2542        // string column, and an out-of-order insert sequence.
2543        let rows: Vec<(String, Value)> = (0..40u64).map(|i| {
2544            let mut o = serde_json::Map::new();
2545            if i % 7 != 0 {
2546                o.insert("fee".into(), json!(i % 13));
2547            }
2548            if i % 11 == 0 {
2549                o.insert("note".into(), Value::Null);
2550            } else {
2551                o.insert("note".into(), json!(format!("n{}", i % 5)));
2552            }
2553            o.insert("rank".into(), json!(40 - i));
2554            (i.to_string(), Value::Object(o))
2555        }).collect();
2556        for (id, doc) in &rows {
2557            indexed.put("t", id, doc.clone(), vec![], None, None).unwrap();
2558            plain.put("t", id, doc.clone(), vec![], None, None).unwrap();
2559        }
2560        (d1, d2, indexed, plain)
2561    }
2562
2563    fn same(a: &Db, b: &Db, nql: &str) -> (Vec<String>, Vec<String>) {
2564        let ga = {
2565            let (rows, _) = query(a, nql).unwrap();
2566            let mut v: Vec<String> = rows.iter()
2567                .filter_map(|r| r["_id"].as_str().map(String::from)).collect();
2568            v.sort(); v
2569        };
2570        let gb = {
2571            let (rows, _) = query(b, nql).unwrap();
2572            let mut v: Vec<String> = rows.iter()
2573                .filter_map(|r| r["_id"].as_str().map(String::from)).collect();
2574            v.sort(); v
2575        };
2576        (ga, gb)
2577    }
2578
2579    #[test]
2580    fn indexed_and_unindexed_agree_on_every_predicate_shape() {
2581        let (_t1, _t2, idx, plain) = twin(&["fee", "note", "rank"]);
2582        for nql in [
2583            // ranges — the shapes the index now serves
2584            "FROM t WHERE fee > 5",
2585            "FROM t WHERE fee >= 5",
2586            "FROM t WHERE fee < 5",
2587            "FROM t WHERE fee <= 5",
2588            "FROM t WHERE fee = 5",
2589            "FROM t WHERE fee BETWEEN 3 AND 8",
2590            "FROM t WHERE fee NOT BETWEEN 3 AND 8",
2591            "FROM t WHERE fee IN (1, 5, 9)",
2592            "FROM t WHERE fee NOT IN (1, 5, 9)",
2593            "FROM t WHERE fee != 5",
2594            // merged bounds on one field
2595            "FROM t WHERE fee > 3 AND fee < 9",
2596            "FROM t WHERE fee >= 3 AND fee <= 9",
2597            "FROM t WHERE fee > 3 AND fee < 9 AND fee != 5",
2598            "FROM t WHERE fee BETWEEN 2 AND 10 AND fee > 6",
2599            // two indexed fields — the planner must pick one and stay correct
2600            "FROM t WHERE fee > 5 AND rank < 20",
2601            "FROM t WHERE fee IN (2, 3) AND rank > 10",
2602            "FROM t WHERE fee = 4 AND rank = 8",
2603            // the absent-field cases, where a naive index scan inverts the answer
2604            "FROM t WHERE fee IS NULL",
2605            "FROM t WHERE fee IS NOT NULL",
2606            "FROM t WHERE note IS NULL",
2607            "FROM t WHERE note IS NOT NULL",
2608            "FROM t WHERE fee IS NULL AND rank > 20",
2609            // predicates the index cannot serve, mixed with ones it can
2610            r#"FROM t WHERE note LIKE "n_""#,
2611            r#"FROM t WHERE fee > 5 AND note LIKE "n1""#,
2612            r#"FROM t WHERE note NOT LIKE "n1" AND fee < 4"#,
2613            // disjunction — must NOT be narrowed on one arm
2614            "FROM t WHERE fee > 11 OR rank > 38",
2615            "FROM t WHERE fee = 1 OR note IS NULL",
2616            "FROM t WHERE (fee > 11 OR rank > 38) AND rank < 39",
2617            "FROM t WHERE fee IN (1) OR fee IN (2)",
2618            // negation
2619            "FROM t WHERE NOT (fee > 5)",
2620            "FROM t WHERE NOT (fee IN (1, 2))",
2621            "FROM t WHERE NOT (fee > 5) AND rank < 30",
2622            // with shaping on top
2623            "FROM t WHERE fee > 5 ORDER BY rank DESC LIMIT 5",
2624            "FROM t WHERE fee BETWEEN 2 AND 8 ORDER BY fee, rank DESC",
2625            "FROM t WHERE fee > 5 GROUP BY note COUNT",
2626            "FROM t WHERE fee > 5 COUNT",
2627            "FROM t WHERE fee > 5 ORDER BY rank LIMIT 3 OFFSET 2",
2628            // empty results
2629            "FROM t WHERE fee > 9999",
2630            "FROM t WHERE fee IN (9999)",
2631            "FROM t WHERE fee BETWEEN 100 AND 200",
2632        ] {
2633            let (a, b) = same(&idx, &plain, nql);
2634            assert_eq!(a, b, "indexed and unindexed disagree on `{}`", nql);
2635        }
2636    }
2637
2638    /// Ordering, not just membership, must survive the index path — the
2639    /// candidates arrive in index order of the PREDICATE field, which is not
2640    /// the requested sort order, so the post-filter sort has to still run.
2641    #[test]
2642    fn index_path_still_honours_order_by() {
2643        let (_t1, _t2, idx, plain) = twin(&["fee", "rank"]);
2644        for nql in [
2645            "FROM t WHERE fee > 4 ORDER BY rank",
2646            "FROM t WHERE fee > 4 ORDER BY rank DESC",
2647            "FROM t WHERE fee > 4 ORDER BY note, rank DESC",
2648            "FROM t WHERE fee BETWEEN 2 AND 9 ORDER BY rank LIMIT 4",
2649            "FROM t WHERE fee IN (3, 6) ORDER BY rank DESC LIMIT 2",
2650        ] {
2651            let ra = query(&idx, nql).unwrap().0;
2652            let rb = query(&plain, nql).unwrap().0;
2653            let ia: Vec<&str> = ra.iter().filter_map(|r| r["_id"].as_str()).collect();
2654            let ib: Vec<&str> = rb.iter().filter_map(|r| r["_id"].as_str()).collect();
2655            assert_eq!(ia, ib, "row ORDER differs on `{}`", nql);
2656        }
2657    }
2658
2659    /// An ordering comparison against a missing field is never true.
2660    ///
2661    /// OrderedValue sorts Null below every number, so `<` and `<=` reported
2662    /// that a document with NO `fee` field satisfied `WHERE fee < 5` — while
2663    /// `>` and `>=` excluded it. That asymmetry was the tell. The Python
2664    /// reference has always excluded it, so this was a cross-engine
2665    /// divergence as well as a wrong answer, and it meant the scan path and
2666    /// the index path disagreed depending on whether an index existed.
2667    #[test]
2668    fn an_ordering_comparison_against_a_missing_field_is_false() {
2669        let dir = tempdir().unwrap();
2670        let db = Db::open(dir.path(), None).unwrap();
2671        db.put("t", "has", json!({"fee": 1}), vec![], None, None).unwrap();
2672        db.put("t", "none", json!({"other": 1}), vec![], None, None).unwrap();
2673        db.put("t", "null", json!({"fee": Value::Null}), vec![], None, None).unwrap();
2674
2675        for nql in ["FROM t WHERE fee < 5", "FROM t WHERE fee <= 5"] {
2676            let (r, _) = query(&db, nql).unwrap();
2677            let ids: Vec<&str> = r.iter().filter_map(|x| x["_id"].as_str()).collect();
2678            assert_eq!(ids, vec!["has"],
2679                       "`{}` must not match a row whose fee is absent or null", nql);
2680        }
2681        for nql in ["FROM t WHERE fee > 0", "FROM t WHERE fee >= 0"] {
2682            let (r, _) = query(&db, nql).unwrap();
2683            let ids: Vec<&str> = r.iter().filter_map(|x| x["_id"].as_str()).collect();
2684            assert_eq!(ids, vec!["has"], "`{}`", nql);
2685        }
2686        // BETWEEN is built from >= and <=, so it inherits the rule.
2687        let (b, _) = query(&db, "FROM t WHERE fee BETWEEN 0 AND 9").unwrap();
2688        assert_eq!(b.len(), 1);
2689
2690        // = and != keep operating on null, exactly as the Python reference
2691        // does — its None guard sits deliberately AFTER those two arms.
2692        let (ne, _) = query(&db, "FROM t WHERE fee != 5").unwrap();
2693        assert_eq!(ne.len(), 3, "!= still matches absent and null fields");
2694        let (isnull, _) = query(&db, "FROM t WHERE fee = NULL").unwrap();
2695        assert_eq!(isnull.len(), 2, "absent and explicit-null both equal NULL");
2696
2697        // And the same answers with an index present — the two paths agreeing
2698        // is the reason this fix was required, not merely desirable.
2699        let d2 = tempdir().unwrap();
2700        let idx = Db::open(d2.path(), None).unwrap();
2701        idx.create_sorted_index("t", "fee");
2702        idx.put("t", "has", json!({"fee": 1}), vec![], None, None).unwrap();
2703        idx.put("t", "none", json!({"other": 1}), vec![], None, None).unwrap();
2704        idx.put("t", "null", json!({"fee": Value::Null}), vec![], None, None).unwrap();
2705        for nql in ["FROM t WHERE fee < 5", "FROM t WHERE fee <= 5",
2706                    "FROM t WHERE fee > 0", "FROM t WHERE fee BETWEEN 0 AND 9"] {
2707            let (a, _) = query(&db, nql).unwrap();
2708            let (b, _) = query(&idx, nql).unwrap();
2709            let ia: Vec<&str> = a.iter().filter_map(|x| x["_id"].as_str()).collect();
2710            let ib: Vec<&str> = b.iter().filter_map(|x| x["_id"].as_str()).collect();
2711            assert_eq!(ia, ib, "indexed and unindexed disagree on `{}`", nql);
2712        }
2713    }
2714
2715    /// `IS NULL` must never touch this index. A document whose field is absent
2716    /// is not in the index for that field, so an index scan would return
2717    /// exactly the complement of the right answer — the worst possible failure
2718    /// for a filter, since it looks like a plausible result set.
2719    #[test]
2720    fn is_null_never_uses_the_index() {
2721        let (_t1, _t2, idx, plain) = twin(&["fee"]);
2722        let (a, b) = same(&idx, &plain, "FROM t WHERE fee IS NULL");
2723        assert_eq!(a, b);
2724        // 40 docs, every 7th missing `fee`: ids 0,7,14,21,28,35.
2725        assert_eq!(a, vec!["0", "14", "21", "28", "35", "7"]);
2726        assert!(!a.is_empty(), "the fixture must actually contain absent fields");
2727    }
2728
2729    /// A constraint under an OR does not restrict the result set, so the
2730    /// planner must not narrow on it. Both arms have to survive.
2731    #[test]
2732    fn a_disjunct_is_never_used_to_narrow() {
2733        let (_t1, _t2, idx, plain) = twin(&["fee", "rank"]);
2734        let nql = "FROM t WHERE fee = 1 OR rank = 40";
2735        let (a, b) = same(&idx, &plain, nql);
2736        assert_eq!(a, b);
2737        // rank = 40 is doc 0, which has NO `fee` field at all — so if the
2738        // planner had narrowed on the `fee` arm it would have been dropped.
2739        assert!(a.contains(&"0".to_string()),
2740                "the OR arm matching a doc with no indexed field must survive: {:?}", a);
2741        assert!(a.len() > 1, "both arms must contribute: {:?}", a);
2742    }
2743
2744    /// AS OF must not use the index: it holds CURRENT versions only, because a
2745    /// superseded hash is removed on overwrite. An index scan would answer a
2746    /// historical query with present-day rows.
2747    #[test]
2748    fn as_of_does_not_use_the_current_version_index() {
2749        let dir = tempdir().unwrap();
2750        let db = Db::open(dir.path(), None).unwrap();
2751        db.create_sorted_index("t", "fee");
2752        db.put("t", "a", json!({"fee": 5}), vec![], None, None).unwrap();
2753        let snap = db.put("t", "b", json!({"fee": 5}), vec![], None, None).unwrap().seq;
2754        // Move both out of the range the query asks for.
2755        db.put("t", "a", json!({"fee": 999}), vec![], None, None).unwrap();
2756        db.put("t", "b", json!({"fee": 999}), vec![], None, None).unwrap();
2757
2758        // At HEAD nothing matches fee = 5 any more.
2759        let (now, _) = query(&db, "FROM t WHERE fee = 5").unwrap();
2760        assert!(now.is_empty(), "current versions have fee 999: {:?}", now);
2761
2762        // AS OF the snapshot, both still had fee = 5. If the index served
2763        // this, it would return nothing.
2764        let (then, _) = query(&db, &format!("FROM t AS OF {} WHERE fee = 5", snap)).unwrap();
2765        let mut ids: Vec<&str> = then.iter().filter_map(|r| r["_id"].as_str()).collect();
2766        ids.sort();
2767        assert_eq!(ids, vec!["a", "b"], "AS OF must see the historical values");
2768
2769        // Same for a range and an IN.
2770        let (r, _) = query(&db, &format!("FROM t AS OF {} WHERE fee BETWEEN 1 AND 9", snap)).unwrap();
2771        assert_eq!(r.len(), 2);
2772        let (i, _) = query(&db, &format!("FROM t AS OF {} WHERE fee IN (5)", snap)).unwrap();
2773        assert_eq!(i.len(), 2);
2774    }
2775
2776    /// An overwritten row must not come back from the index.
2777    #[test]
2778    fn the_index_path_returns_current_versions_only() {
2779        let dir = tempdir().unwrap();
2780        let db = Db::open(dir.path(), None).unwrap();
2781        db.create_sorted_index("t", "fee");
2782        for i in 0..5u64 {
2783            db.put("t", &i.to_string(), json!({"fee": i}), vec![], None, None).unwrap();
2784        }
2785        db.put("t", "0", json!({"fee": 100}), vec![], None, None).unwrap();
2786
2787        let (low, _) = query(&db, "FROM t WHERE fee BETWEEN 0 AND 4").unwrap();
2788        let mut ids: Vec<&str> = low.iter().filter_map(|r| r["_id"].as_str()).collect();
2789        ids.sort();
2790        assert_eq!(ids, vec!["1", "2", "3", "4"],
2791                   "doc 0 moved to fee 100 and must not appear in 0..4");
2792
2793        let (high, _) = query(&db, "FROM t WHERE fee = 100").unwrap();
2794        assert_eq!(high.len(), 1);
2795        assert_eq!(high[0]["_id"], "0");
2796        assert_eq!(high[0]["fee"], json!(100), "the CURRENT value, not the old one");
2797    }
2798
2799    /// Duplicate values must not produce duplicate rows, and a value repeated
2800    /// across IN arms must be returned once.
2801    #[test]
2802    fn index_scans_do_not_duplicate_rows() {
2803        let dir = tempdir().unwrap();
2804        let db = Db::open(dir.path(), None).unwrap();
2805        db.create_sorted_index("t", "fee");
2806        for i in 0..6u64 {
2807            db.put("t", &i.to_string(), json!({"fee": i % 2}), vec![], None, None).unwrap();
2808        }
2809        let (dup, _) = query(&db, "FROM t WHERE fee IN (0, 0, 1, 1)").unwrap();
2810        assert_eq!(dup.len(), 6, "each row once despite repeated IN arms");
2811        let (r, _) = query(&db, "FROM t WHERE fee BETWEEN 0 AND 1").unwrap();
2812        assert_eq!(r.len(), 6);
2813        let mut ids: Vec<&str> = dup.iter().filter_map(|r| r["_id"].as_str()).collect();
2814        ids.sort();
2815        ids.dedup();
2816        assert_eq!(ids.len(), 6, "no duplicate _ids");
2817    }
2818
2819    /// A range over a string column, to prove the index is not numeric-only.
2820    #[test]
2821    fn index_ranges_work_on_strings() {
2822        let dir = tempdir().unwrap();
2823        let db = Db::open(dir.path(), None).unwrap();
2824        db.create_sorted_index("t", "name");
2825        for (i, n) in ["alpha", "bravo", "charlie", "delta", "echo"].iter().enumerate() {
2826            db.put("t", &i.to_string(), json!({"name": n}), vec![], None, None).unwrap();
2827        }
2828        let (r, _) = query(&db, r#"FROM t WHERE name BETWEEN "bravo" AND "delta""#).unwrap();
2829        let mut got: Vec<&str> = r.iter().filter_map(|x| x["name"].as_str()).collect();
2830        got.sort();
2831        assert_eq!(got, vec!["bravo", "charlie", "delta"]);
2832        let (gt, _) = query(&db, r#"FROM t WHERE name > "charlie""#).unwrap();
2833        assert_eq!(gt.len(), 2);
2834    }
2835
2836    /// Bounds must be merged into one walk, and the tighter bound must win
2837    /// regardless of the order the conjuncts appear in.
2838    #[test]
2839    fn same_field_bounds_are_merged_tightest_wins() {
2840        let (_t1, _t2, idx, plain) = twin(&["fee"]);
2841        for (a_nql, b_nql) in [
2842            ("FROM t WHERE fee > 2 AND fee > 6", "FROM t WHERE fee > 6"),
2843            ("FROM t WHERE fee > 6 AND fee > 2", "FROM t WHERE fee > 6"),
2844            ("FROM t WHERE fee < 9 AND fee < 4", "FROM t WHERE fee < 4"),
2845            ("FROM t WHERE fee BETWEEN 0 AND 12 AND fee >= 5 AND fee <= 7",
2846             "FROM t WHERE fee >= 5 AND fee <= 7"),
2847        ] {
2848            let (ia, _) = same(&idx, &plain, a_nql);
2849            let (ib, _) = same(&idx, &plain, b_nql);
2850            assert_eq!(ia, ib, "`{}` should equal `{}`", a_nql, b_nql);
2851        }
2852    }
2853
2854    /// The index only helps where it exists; an unindexed field must still
2855    /// answer correctly through the scan path.
2856    #[test]
2857    fn a_predicate_on_an_unindexed_field_still_answers() {
2858        let (_t1, _t2, idx, plain) = twin(&["fee"]);   // `rank` is NOT indexed
2859        for nql in [
2860            "FROM t WHERE rank > 30",
2861            "FROM t WHERE rank BETWEEN 10 AND 20",
2862            "FROM t WHERE rank IN (40, 39)",
2863            "FROM t WHERE rank > 30 AND fee > 2",
2864        ] {
2865            let (a, b) = same(&idx, &plain, nql);
2866            assert_eq!(a, b, "`{}`", nql);
2867        }
2868    }
2869
2870    /// Cardinality is reported off the index without reading any rows, which
2871    /// is what lets the planner compare two candidate indexes.
2872    #[test]
2873    fn range_cardinality_counts_without_reading() {
2874        let dir = tempdir().unwrap();
2875        let db = Db::open(dir.path(), None).unwrap();
2876        db.create_sorted_index("t", "fee");
2877        for i in 0..20u64 {
2878            db.put("t", &i.to_string(), json!({"fee": i}), vec![], None, None).unwrap();
2879        }
2880        assert_eq!(db.range_cardinality("t", "fee", None, None, true, true), Some(20));
2881        assert_eq!(
2882            db.range_cardinality("t", "fee", Some(&json!(5)), Some(&json!(9)), true, true),
2883            Some(5), "5..=9 inclusive is five values");
2884        assert_eq!(
2885            db.range_cardinality("t", "fee", Some(&json!(5)), Some(&json!(9)), false, false),
2886            Some(3), "exclusive bounds drop both ends");
2887        assert_eq!(
2888            db.range_cardinality("t", "fee", Some(&json!(18)), None, true, true),
2889            Some(2));
2890        assert_eq!(
2891            db.range_cardinality("t", "fee", Some(&json!(999)), None, true, true),
2892            Some(0), "an empty range is 0, not an error");
2893        // No index on this field at all.
2894        assert_eq!(db.range_cardinality("t", "nope", None, None, true, true), None);
2895    }
2896
2897    /// With two usable indexes the planner should choose the narrower range.
2898    /// Asserted through cardinality rather than by inspecting the plan, so the
2899    /// test pins the observable behaviour and not the implementation.
2900    #[test]
2901    fn the_narrower_index_is_preferred() {
2902        let dir = tempdir().unwrap();
2903        let db = Db::open(dir.path(), None).unwrap();
2904        db.create_sorted_index("t", "wide");
2905        db.create_sorted_index("t", "narrow");
2906        for i in 0..100u64 {
2907            db.put("t", &i.to_string(),
2908                   json!({"wide": i % 2, "narrow": i}), vec![], None, None).unwrap();
2909        }
2910        // `wide = 0` covers 50 rows; `narrow = 7` covers 1.
2911        let wide = db.range_cardinality("t", "wide", Some(&json!(0)), Some(&json!(0)), true, true);
2912        let narrow = db.range_cardinality("t", "narrow", Some(&json!(7)), Some(&json!(7)), true, true);
2913        assert_eq!(wide, Some(50));
2914        assert_eq!(narrow, Some(1));
2915        // The answer must be right whichever index is chosen.
2916        let (r, _) = query(&db, "FROM t WHERE wide = 0 AND narrow = 7").unwrap();
2917        assert!(r.is_empty(), "narrow 7 has wide 1, so nothing matches");
2918        let (r2, _) = query(&db, "FROM t WHERE wide = 0 AND narrow = 8").unwrap();
2919        assert_eq!(r2.len(), 1);
2920        assert_eq!(r2[0]["_id"], "8");
2921    }
2922
2923    // ── Result shaping (3.3.0): OFFSET, multi-key ORDER BY, HAVING, ─────────
2924    // ── bare aggregates, and the SQL pipeline order ─────────────────────────
2925
2926    fn heights(rows: &[Value]) -> Vec<u64> {
2927        rows.iter().filter_map(|r| r["height"].as_u64()).collect()
2928    }
2929
2930    #[test]
2931    fn offset_skips_result_rows() {
2932        let (_tmp, db) = setup();
2933        let (rows, _) = query(&db, "FROM blocks ORDER BY height OFFSET 2").unwrap();
2934        assert_eq!(heights(&rows), vec![3, 4, 5]);
2935    }
2936
2937    #[test]
2938    fn offset_with_limit_pages() {
2939        let (_tmp, db) = setup();
2940        // Page through 5 rows two at a time. Each page must be disjoint and
2941        // in order — the bug to catch is a pushdown that fetches only `limit`
2942        // rows and therefore returns page 1 for every page.
2943        let mut seen = vec![];
2944        for page in 0..3 {
2945            let (rows, _) = query(
2946                &db,
2947                &format!("FROM blocks ORDER BY height LIMIT 2 OFFSET {}", page * 2),
2948            ).unwrap();
2949            seen.extend(heights(&rows));
2950        }
2951        assert_eq!(seen, vec![1, 2, 3, 4, 5]);
2952    }
2953
2954    #[test]
2955    fn offset_past_the_end_is_an_empty_page() {
2956        let (_tmp, db) = setup();
2957        let (rows, count) = query(&db, "FROM blocks OFFSET 99").unwrap();
2958        assert!(rows.is_empty());
2959        assert_eq!(count, 0);
2960        let (zero, _) = query(&db, "FROM blocks OFFSET 0").unwrap();
2961        assert_eq!(zero.len(), 5, "OFFSET 0 skips nothing");
2962    }
2963
2964    #[test]
2965    fn offset_applies_after_the_filter() {
2966        let (_tmp, db) = setup();
2967        // n_tx = h*2, so `>= 6` matches heights 3,4,5. Offsetting by one must
2968        // skip the first MATCH, not the first row of the collection.
2969        let (rows, _) = query(
2970            &db, "FROM blocks WHERE n_tx >= 6 ORDER BY height OFFSET 1").unwrap();
2971        assert_eq!(heights(&rows), vec![4, 5]);
2972    }
2973
2974    #[test]
2975    fn order_by_multiple_keys() {
2976        let dir = tempdir().unwrap();
2977        let db = Db::open(dir.path(), None).unwrap();
2978        // Two statuses, each with several fees, so the second key has to do
2979        // real work to break the first key's ties.
2980        for (i, (s, f)) in [("open", 30), ("open", 10), ("closed", 20),
2981                            ("open", 20), ("closed", 5)].iter().enumerate() {
2982            db.put("t", &i.to_string(),
2983                serde_json::json!({"status": s, "fee": f}), vec![], None, None).unwrap();
2984        }
2985        let (rows, _) = query(&db, "FROM t ORDER BY status, fee DESC").unwrap();
2986        let got: Vec<(String, u64)> = rows.iter()
2987            .map(|r| (r["status"].as_str().unwrap().to_string(), r["fee"].as_u64().unwrap()))
2988            .collect();
2989        assert_eq!(got, vec![
2990            ("closed".into(), 20), ("closed".into(), 5),
2991            ("open".into(), 30), ("open".into(), 20), ("open".into(), 10),
2992        ]);
2993    }
2994
2995    #[test]
2996    fn order_by_mixed_directions() {
2997        let dir = tempdir().unwrap();
2998        let db = Db::open(dir.path(), None).unwrap();
2999        for (i, (a, b)) in [(1, 1), (1, 2), (2, 1), (2, 2)].iter().enumerate() {
3000            db.put("t", &i.to_string(),
3001                serde_json::json!({"a": a, "b": b}), vec![], None, None).unwrap();
3002        }
3003        let (rows, _) = query(&db, "FROM t ORDER BY a DESC, b ASC").unwrap();
3004        let got: Vec<(u64, u64)> = rows.iter()
3005            .map(|r| (r["a"].as_u64().unwrap(), r["b"].as_u64().unwrap()))
3006            .collect();
3007        assert_eq!(got, vec![(2, 1), (2, 2), (1, 1), (1, 2)]);
3008    }
3009
3010    /// The headline pipeline-order bug. In SQL, LIMIT applies to the RESULT.
3011    /// The old order was ORDER BY -> LIMIT -> GROUP BY, so LIMIT truncated the
3012    /// INPUT and the aggregate was computed over a fraction of the rows —
3013    /// reporting counts that summed to the limit instead of the true total.
3014    #[test]
3015    fn limit_applies_to_grouped_rows_not_to_the_input() {
3016        let dir = tempdir().unwrap();
3017        let db = Db::open(dir.path(), None).unwrap();
3018        for i in 0..12 {
3019            // Hoisted: json! cannot parse an indexing expression inline.
3020            let status = ["open", "closed", "void"][i % 3];
3021            db.put("t", &i.to_string(),
3022                serde_json::json!({"status": status, "fee": i}),
3023                vec![], None, None).unwrap();
3024        }
3025        let (all, _) = query(&db, "FROM t GROUP BY status COUNT").unwrap();
3026        assert_eq!(all.len(), 3);
3027        let total: u64 = all.iter().filter_map(|r| r["count"].as_u64()).sum();
3028        assert_eq!(total, 12, "every input row must be counted");
3029
3030        // LIMIT 2 must return 2 GROUPS, each with its full count — not two
3031        // input rows regrouped.
3032        let (limited, _) = query(&db, "FROM t GROUP BY status COUNT LIMIT 2").unwrap();
3033        assert_eq!(limited.len(), 2, "LIMIT caps the number of groups");
3034        for r in &limited {
3035            assert_eq!(r["count"], json!(4),
3036                       "each group keeps its true count, got {:?}", r);
3037        }
3038    }
3039
3040    /// The second pipeline-order bug: ORDER BY ran before grouping, so it
3041    /// sorted the raw documents on a field that only exists AFTER grouping
3042    /// (`count`, `sum_fee`) and the grouped output came back unordered. The
3043    /// clause was silently inert.
3044    #[test]
3045    fn order_by_sorts_the_grouped_rows() {
3046        let dir = tempdir().unwrap();
3047        let db = Db::open(dir.path(), None).unwrap();
3048        // Deliberately uneven: 1 x "a", 3 x "b", 2 x "c".
3049        for (i, s) in ["a", "b", "b", "b", "c", "c"].iter().enumerate() {
3050            db.put("t", &i.to_string(),
3051                serde_json::json!({"g": s, "n": i}), vec![], None, None).unwrap();
3052        }
3053        let (rows, _) = query(&db, "FROM t GROUP BY g COUNT ORDER BY count DESC").unwrap();
3054        let got: Vec<(String, u64)> = rows.iter()
3055            .map(|r| (r["g"].as_str().unwrap().to_string(), r["count"].as_u64().unwrap()))
3056            .collect();
3057        assert_eq!(got, vec![("b".into(), 3), ("c".into(), 2), ("a".into(), 1)]);
3058
3059        // And the group key itself is sortable.
3060        let (by_key, _) = query(&db, "FROM t GROUP BY g COUNT ORDER BY g DESC").unwrap();
3061        let keys: Vec<&str> = by_key.iter().map(|r| r["g"].as_str().unwrap()).collect();
3062        assert_eq!(keys, vec!["c", "b", "a"]);
3063    }
3064
3065    #[test]
3066    fn order_by_an_aggregate_key() {
3067        let (_tmp, db) = setup_items();
3068        let (rows, _) = query(
3069            &db, "FROM items GROUP BY cat SUM price ORDER BY sum_price DESC").unwrap();
3070        let cats: Vec<&str> = rows.iter().map(|r| r["cat"].as_str().unwrap()).collect();
3071        assert_eq!(cats, vec!["y", "x"], "y sums to 60, x to 15");
3072    }
3073
3074    #[test]
3075    fn offset_and_limit_page_grouped_rows() {
3076        let dir = tempdir().unwrap();
3077        let db = Db::open(dir.path(), None).unwrap();
3078        for i in 0..9 {
3079            db.put("t", &i.to_string(),
3080                serde_json::json!({"g": format!("g{}", i % 3)}), vec![], None, None).unwrap();
3081        }
3082        let (page, _) = query(
3083            &db, "FROM t GROUP BY g COUNT ORDER BY g LIMIT 1 OFFSET 1").unwrap();
3084        assert_eq!(page.len(), 1);
3085        assert_eq!(page[0]["g"], "g1");
3086    }
3087
3088    // ── HAVING ──────────────────────────────────────────────────────────────
3089
3090    #[test]
3091    fn having_filters_groups_by_count() {
3092        let dir = tempdir().unwrap();
3093        let db = Db::open(dir.path(), None).unwrap();
3094        for (i, s) in ["a", "b", "b", "b", "c", "c"].iter().enumerate() {
3095            db.put("t", &i.to_string(),
3096                serde_json::json!({"g": s, "n": i}), vec![], None, None).unwrap();
3097        }
3098        let (rows, _) = query(&db, "FROM t GROUP BY g COUNT HAVING count > 1").unwrap();
3099        let mut keys: Vec<&str> = rows.iter().map(|r| r["g"].as_str().unwrap()).collect();
3100        keys.sort();
3101        assert_eq!(keys, vec!["b", "c"], "the single-row group `a` is filtered out");
3102    }
3103
3104    #[test]
3105    fn having_filters_on_the_aggregate_value() {
3106        let (_tmp, db) = setup_items();
3107        // x sums to 15, y to 60.
3108        let (rows, _) = query(
3109            &db, "FROM items GROUP BY cat SUM price HAVING sum_price > 20").unwrap();
3110        assert_eq!(rows.len(), 1);
3111        assert_eq!(rows[0]["cat"], "y");
3112    }
3113
3114    /// HAVING gets the full predicate surface, because it runs through the
3115    /// same evaluator as WHERE rather than a second copy.
3116    #[test]
3117    fn having_supports_the_whole_predicate_surface() {
3118        let (_tmp, db) = setup_items();
3119        let (in_, _) = query(
3120            &db, r#"FROM items GROUP BY cat COUNT HAVING cat IN ("x")"#).unwrap();
3121        assert_eq!(in_.len(), 1);
3122        assert_eq!(in_[0]["cat"], "x");
3123
3124        let (btw, _) = query(
3125            &db, "FROM items GROUP BY cat SUM price HAVING sum_price BETWEEN 10 AND 20").unwrap();
3126        assert_eq!(btw.len(), 1);
3127        assert_eq!(btw[0]["cat"], "x");
3128
3129        let (like, _) = query(
3130            &db, r#"FROM items GROUP BY cat COUNT HAVING cat LIKE "y""#).unwrap();
3131        assert_eq!(like.len(), 1);
3132
3133        let (or_, _) = query(
3134            &db, "FROM items GROUP BY cat SUM price HAVING sum_price < 20 OR count = 3").unwrap();
3135        assert_eq!(or_.len(), 2);
3136    }
3137
3138    /// WHERE filters input rows, HAVING filters groups. Confusing them gives
3139    /// different answers, so the distinction must hold.
3140    #[test]
3141    fn where_and_having_are_different_stages() {
3142        let (_tmp, db) = setup_items();
3143        // WHERE drops rows BEFORE grouping, shrinking the sums.
3144        let (w, _) = query(
3145            &db, "FROM items WHERE price > 10 GROUP BY cat SUM price").unwrap();
3146        let x = w.iter().find(|r| r["cat"] == "x");
3147        assert!(x.is_none(), "x's rows (0,5,10) are all filtered out by WHERE");
3148
3149        // HAVING keeps every row in the aggregate and filters the RESULT.
3150        let (h, _) = query(
3151            &db, "FROM items GROUP BY cat SUM price HAVING sum_price > 10").unwrap();
3152        assert_eq!(h.len(), 2, "both groups sum above 10 when nothing is pre-filtered");
3153    }
3154
3155    #[test]
3156    fn having_without_an_aggregate_is_an_error() {
3157        let (_tmp, db) = setup();
3158        // HAVING is meaningless without grouping, and silently treating it as
3159        // a second WHERE would be exactly the kind of reinterpretation this
3160        // parser no longer does.
3161        assert!(query(&db, "FROM blocks HAVING height > 3").is_err());
3162    }
3163
3164    // ── Bare aggregates, no GROUP BY ────────────────────────────────────────
3165
3166    /// `FROM t COUNT` — "how many rows match?" without fetching them. The
3167    /// July engine note recorded `SELECT COUNT(*)` returning `[]` silently;
3168    /// this is the capability that was missing behind that silence.
3169    #[test]
3170    fn bare_count_returns_one_row() {
3171        let (_tmp, db) = setup();
3172        let (rows, _) = query(&db, "FROM blocks COUNT").unwrap();
3173        assert_eq!(rows.len(), 1);
3174        assert_eq!(rows[0]["count"], json!(5));
3175        assert_eq!(rows[0]["value"], json!(5));
3176    }
3177
3178    #[test]
3179    fn bare_count_respects_the_filter() {
3180        let (_tmp, db) = setup();
3181        let (rows, _) = query(&db, "FROM blocks WHERE height > 3 COUNT").unwrap();
3182        assert_eq!(rows[0]["count"], json!(2));
3183    }
3184
3185    #[test]
3186    fn bare_sum_avg_min_max() {
3187        let (_tmp, db) = setup();
3188        // heights 1..=5, n_tx = h*2 -> 2,4,6,8,10
3189        let (s, _) = query(&db, "FROM blocks SUM n_tx").unwrap();
3190        assert_eq!(s[0]["sum_n_tx"], json!(30), "integer inputs give an integer sum");
3191        let (a, _) = query(&db, "FROM blocks AVG n_tx").unwrap();
3192        assert_eq!(a[0]["avg_n_tx"], json!(6.0));
3193        let (mn, _) = query(&db, "FROM blocks MIN n_tx").unwrap();
3194        assert_eq!(mn[0]["min_n_tx"], json!(2));
3195        let (mx, _) = query(&db, "FROM blocks MAX n_tx").unwrap();
3196        assert_eq!(mx[0]["max_n_tx"], json!(10));
3197    }
3198
3199    /// Integer inputs must produce integer aggregates.
3200    ///
3201    /// Aggregating exclusively in f64 was a type divergence from the Python
3202    /// reference (which returns `66`, not `66.0`) AND a precision bug: f64
3203    /// cannot represent integers above 2^53 exactly, so a SUM over satoshi
3204    /// amounts or block heights silently rounded. This engine stores exactly
3205    /// that kind of number.
3206    #[test]
3207    fn integer_aggregates_stay_integers_and_keep_full_precision() {
3208        let dir = tempdir().unwrap();
3209        let db = Db::open(dir.path(), None).unwrap();
3210        // Beyond 2^53 (9_007_199_254_740_992), where f64 starts skipping
3211        // integers. Their true sum ends in ...9, which a f64 round-trip loses.
3212        let big: [i64; 3] = [9_007_199_254_740_993, 9_007_199_254_740_995, 1];
3213        for (i, v) in big.iter().enumerate() {
3214            db.put("t", &i.to_string(), serde_json::json!({"v": v}),
3215                   vec![], None, None).unwrap();
3216        }
3217        let (s, _) = query(&db, "FROM t SUM v").unwrap();
3218        assert_eq!(s[0]["sum_v"], json!(18_014_398_509_481_989i64),
3219                   "exact i64 sum, not a rounded f64");
3220        assert!(s[0]["sum_v"].is_i64(), "must serialise as an integer");
3221
3222        let (mx, _) = query(&db, "FROM t MAX v").unwrap();
3223        assert_eq!(mx[0]["max_v"], json!(9_007_199_254_740_995i64));
3224        let (mn, _) = query(&db, "FROM t MIN v").unwrap();
3225        assert_eq!(mn[0]["min_v"], json!(1));
3226    }
3227
3228    /// A float anywhere in the column makes the whole aggregate fractional,
3229    /// which is what Python's arithmetic does too.
3230    #[test]
3231    fn a_single_float_makes_the_aggregate_fractional() {
3232        let dir = tempdir().unwrap();
3233        let db = Db::open(dir.path(), None).unwrap();
3234        db.put("t", "1", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
3235        db.put("t", "2", serde_json::json!({"v": 2.5}), vec![], None, None).unwrap();
3236        let (s, _) = query(&db, "FROM t SUM v").unwrap();
3237        assert_eq!(s[0]["sum_v"], json!(3.5));
3238        // AVG is true division, so it is fractional even over pure integers.
3239        let (a, _) = query(&db, "FROM t AVG v").unwrap();
3240        assert_eq!(a[0]["avg_v"], json!(1.75));
3241    }
3242
3243    /// A JSON bool is not a number, matching Python's explicit
3244    /// `not isinstance(x, bool)` guard.
3245    #[test]
3246    fn booleans_are_not_aggregated_as_numbers() {
3247        let dir = tempdir().unwrap();
3248        let db = Db::open(dir.path(), None).unwrap();
3249        db.put("t", "1", serde_json::json!({"v": true}), vec![], None, None).unwrap();
3250        db.put("t", "2", serde_json::json!({"v": 5}), vec![], None, None).unwrap();
3251        let (s, _) = query(&db, "FROM t SUM v").unwrap();
3252        assert_eq!(s[0]["sum_v"], json!(5), "the bool contributes nothing");
3253        assert_eq!(s[0]["count"], json!(2), "but it still counts toward the group");
3254    }
3255
3256    /// COUNT over an empty result is 0, not "no rows". A caller asking "how
3257    /// many?" must get a number.
3258    #[test]
3259    fn bare_count_of_nothing_is_zero_not_empty() {
3260        let (_tmp, db) = setup();
3261        let (rows, count) = query(&db, "FROM blocks WHERE height > 999 COUNT").unwrap();
3262        assert_eq!(count, 1, "still exactly one row");
3263        assert_eq!(rows[0]["count"], json!(0));
3264
3265        // A GROUPED aggregate over zero rows correctly has no groups.
3266        let (g, _) = query(&db, "FROM blocks WHERE height > 999 GROUP BY height COUNT").unwrap();
3267        assert!(g.is_empty());
3268    }
3269
3270    #[test]
3271    fn bare_aggregate_over_an_empty_collection() {
3272        let (_tmp, db) = setup();
3273        let (rows, _) = query(&db, "FROM nonexistent COUNT").unwrap();
3274        assert_eq!(rows[0]["count"], json!(0));
3275        let (s, _) = query(&db, "FROM nonexistent SUM n_tx").unwrap();
3276        assert_eq!(s[0]["sum_n_tx"], Value::Null, "sum of nothing is null, not 0");
3277    }
3278
3279    #[test]
3280    fn bare_aggregate_carries_no_group_key() {
3281        let (_tmp, db) = setup();
3282        let (rows, _) = query(&db, "FROM blocks COUNT").unwrap();
3283        if let Value::Object(m) = &rows[0] {
3284            let mut keys: Vec<&String> = m.keys().collect();
3285            keys.sort();
3286            assert_eq!(keys, vec!["count", "value"]);
3287        } else {
3288            panic!("expected an object");
3289        }
3290    }
3291
3292    /// A document field whose name collides with a reserved word must be
3293    /// addressable. The lexer uppercases keywords for matching, and field
3294    /// positions accept a keyword as a field name — but they used the
3295    /// UPPERCASED text, so `WHERE count > 1` searched the document for "COUNT"
3296    /// and matched nothing. Silent, and it hit real field names: count, min,
3297    /// max, sum, avg, value, search, group, order, limit, offset, trace.
3298    #[test]
3299    fn a_field_named_like_a_keyword_is_still_addressable() {
3300        let dir = tempdir().unwrap();
3301        let db = Db::open(dir.path(), None).unwrap();
3302        db.put("t", "1", serde_json::json!({
3303            "count": 5, "min": 1, "max": 9, "sum": 3, "avg": 2,
3304            "value": "keep", "limit": 7, "offset": 8, "group": "g1", "search": "s",
3305        }), vec![], None, None).unwrap();
3306        db.put("t", "2", serde_json::json!({
3307            "count": 1, "min": 0, "max": 2, "sum": 0, "avg": 0,
3308            "value": "drop", "limit": 0, "offset": 0, "group": "g2", "search": "t",
3309        }), vec![], None, None).unwrap();
3310
3311        for (nql, want) in [
3312            ("FROM t WHERE count > 3", "1"),
3313            ("FROM t WHERE min = 1", "1"),
3314            ("FROM t WHERE max >= 9", "1"),
3315            ("FROM t WHERE sum = 3", "1"),
3316            ("FROM t WHERE avg = 2", "1"),
3317            (r#"FROM t WHERE value = "keep""#, "1"),
3318            ("FROM t WHERE limit = 7", "1"),
3319            ("FROM t WHERE offset = 8", "1"),
3320            (r#"FROM t WHERE group = "g1""#, "1"),
3321        ] {
3322            let (rows, _) = query(&db, nql).unwrap();
3323            assert_eq!(rows.len(), 1, "`{}` matched {} rows", nql, rows.len());
3324            assert_eq!(rows[0]["_id"], want, "`{}`", nql);
3325        }
3326
3327        // Sorting and grouping on such a field too.
3328        let (ord, _) = query(&db, "FROM t ORDER BY count DESC").unwrap();
3329        assert_eq!(ord[0]["_id"], "1");
3330        let (grp, _) = query(&db, "FROM t GROUP BY group COUNT").unwrap();
3331        assert_eq!(grp.len(), 2);
3332        let keys: Vec<&str> = grp.iter().filter_map(|r| r["group"].as_str()).collect();
3333        assert!(keys.contains(&"g1") && keys.contains(&"g2"), "{:?}", grp);
3334    }
3335
3336    /// The raw spelling is preserved, so a mixed-case field name round-trips
3337    /// while the keyword it collides with still matches case-insensitively.
3338    #[test]
3339    fn keyword_matching_stays_case_insensitive() {
3340        let dir = tempdir().unwrap();
3341        let db = Db::open(dir.path(), None).unwrap();
3342        db.put("t", "1", serde_json::json!({"Count": 5, "n": 1}),
3343               vec![], None, None).unwrap();
3344        // Field spelled `Count`, clause keywords in lower case.
3345        let (rows, _) = query(&db, "from t where Count = 5 order by n").unwrap();
3346        assert_eq!(rows.len(), 1);
3347        // And a differently-cased field name does NOT collide with it.
3348        let (miss, _) = query(&db, "FROM t WHERE count = 5").unwrap();
3349        assert!(miss.is_empty(), "`count` and `Count` are distinct field names");
3350    }
3351
3352    #[test]
3353    fn two_aggregates_is_an_error() {
3354        let (_tmp, db) = setup();
3355        assert!(query(&db, "FROM blocks COUNT SUM n_tx").is_err());
3356        assert!(query(&db, "FROM blocks GROUP BY height COUNT SUM n_tx").is_err());
3357    }
3358
3359    #[test]
3360    fn bare_aggregate_with_having() {
3361        let (_tmp, db) = setup();
3362        let (keep, _) = query(&db, "FROM blocks COUNT HAVING count > 3").unwrap();
3363        assert_eq!(keep.len(), 1);
3364        let (drop, _) = query(&db, "FROM blocks COUNT HAVING count > 99").unwrap();
3365        assert!(drop.is_empty());
3366    }
3367
3368    // ── GROUP BY parity with the Python reference (query.py + engine.py) ────
3369
3370    /// Fixture mirroring tests/test_v050.py::test_group_by_min_max exactly:
3371    /// six items, cat x for 0..2 and y for 3..5, price = i * 5.
3372    fn setup_items() -> (tempfile::TempDir, Db) {
3373        let dir = tempdir().unwrap();
3374        let db = Db::open(dir.path(), None).unwrap();
3375        for i in 0..6 {
3376            db.put("items", &i.to_string(),
3377                serde_json::json!({"cat": if i < 3 {"x"} else {"y"}, "price": i * 5}),
3378                vec![], None, None).unwrap();
3379        }
3380        (dir, db)
3381    }
3382
3383    fn group(rows: &[Value], field: &str, key: &str) -> Value {
3384        rows.iter()
3385            .find(|r| r[field] == Value::String(key.to_string()))
3386            .unwrap_or_else(|| panic!("no group {:?} in {:?}", key, rows))
3387            .clone()
3388    }
3389
3390    /// The aggregate must read the TARGET field. Before 3.3.0 the executor
3391    /// aggregated the GROUP BY field itself and the target was silently
3392    /// dropped by the unknown-token skip, so `MAX price` returned the max of
3393    /// `cat` — a non-numeric value coerced to 1.0, making every group answer
3394    /// 1. Python returns x:0 and y:25 for MIN/MAX respectively.
3395    #[test]
3396    fn group_by_aggregates_the_target_field_not_the_group_field() {
3397        let (_tmp, db) = setup_items();
3398
3399        let (mins, _) = query(&db, "FROM items GROUP BY cat MIN price").unwrap();
3400        assert_eq!(group(&mins, "cat", "x")["min_price"], json!(0));
3401        assert_eq!(group(&mins, "cat", "y")["min_price"], json!(15));
3402
3403        let (maxs, _) = query(&db, "FROM items GROUP BY cat MAX price").unwrap();
3404        assert_eq!(group(&maxs, "cat", "y")["max_price"], json!(25));
3405        assert_eq!(group(&maxs, "cat", "x")["max_price"], json!(10));
3406
3407        let (sums, _) = query(&db, "FROM items GROUP BY cat SUM price").unwrap();
3408        assert_eq!(group(&sums, "cat", "x")["sum_price"], json!(15));  // 0+5+10
3409        assert_eq!(group(&sums, "cat", "y")["sum_price"], json!(60));  // 15+20+25
3410
3411        let (avgs, _) = query(&db, "FROM items GROUP BY cat AVG price").unwrap();
3412        assert_eq!(group(&avgs, "cat", "x")["avg_price"], json!(5.0));
3413        assert_eq!(group(&avgs, "cat", "y")["avg_price"], json!(20.0));
3414    }
3415
3416    /// An aggregate over a `_`-prefixed metadata field must see it.
3417    ///
3418    /// `_seq` lives on the node, not in its data payload, and the aggregator
3419    /// read the payload directly — so `MAX _seq` answered NULL while
3420    /// `SELECT _seq` listed the values and `WHERE _seq > 5` filtered on them.
3421    /// It was also a live divergence: the Python engine builds its groups from
3422    /// projected dicts that already carry `_seq`, and answers correctly.
3423    ///
3424    /// "What is the newest sequence?" is the question replication and time
3425    /// travel are built on, so a confident null there is the worst shape of
3426    /// wrong answer this engine can give.
3427    #[test]
3428    fn aggregates_see_node_metadata_not_only_the_payload() {
3429        let (_tmp, db) = setup_items();
3430        let (all, _) = query(&db, "FROM items").unwrap();
3431        let want = all.iter().filter_map(|r| r.get("_seq")?.as_i64()).max().unwrap();
3432
3433        let (rows, _) = query(&db, "FROM items MAX _seq").unwrap();
3434        assert_eq!(rows[0]["max__seq"], json!(want),
3435                   "MAX _seq must equal the highest sequence in the result");
3436        assert_ne!(rows[0]["max__seq"], Value::Null, "a null here is a silent wrong answer");
3437
3438        let (rows, _) = query(&db, "FROM items MIN _seq").unwrap();
3439        assert_eq!(rows[0]["min__seq"], json!(
3440            all.iter().filter_map(|r| r.get("_seq")?.as_i64()).min().unwrap()));
3441
3442        // Grouping by a metadata field works through the same resolver.
3443        let (rows, _) = query(&db, "FROM items GROUP BY _seq COUNT").unwrap();
3444        assert_eq!(rows.len(), all.len(), "one group per distinct sequence");
3445        assert!(rows.iter().all(|r| r["_seq"] != Value::Null),
3446                "the group key must be the sequence, not null");
3447    }
3448
3449    /// Output key parity: Python's engine.py emits `<agg>_<field>` and a
3450    /// `count`. This engine additionally keeps `value` as the alias it has
3451    /// always emitted, so existing callers keep working.
3452    #[test]
3453    fn group_by_emits_python_parity_keys_and_the_value_alias() {
3454        let (_tmp, db) = setup_items();
3455        let (rows, _) = query(&db, "FROM items GROUP BY cat SUM price").unwrap();
3456        let x = group(&rows, "cat", "x");
3457        assert_eq!(x["sum_price"], json!(15), "python-parity key");
3458        assert_eq!(x["value"], json!(15), "back-compat alias must agree");
3459        assert_eq!(x["count"], json!(3), "count is the group size");
3460    }
3461
3462    /// `count` is the group size; the aggregate only sees numeric targets.
3463    /// A group of 3 where one row has a non-numeric price must still report
3464    /// count=3 while averaging over 2 — matching Python's isinstance filter.
3465    #[test]
3466    fn count_is_group_size_while_aggregate_skips_non_numeric() {
3467        let dir = tempdir().unwrap();
3468        let db = Db::open(dir.path(), None).unwrap();
3469        db.put("t", "1", serde_json::json!({"g": "a", "n": 10}), vec![], None, None).unwrap();
3470        db.put("t", "2", serde_json::json!({"g": "a", "n": 20}), vec![], None, None).unwrap();
3471        db.put("t", "3", serde_json::json!({"g": "a", "n": "N/A"}), vec![], None, None).unwrap();
3472
3473        let (rows, _) = query(&db, "FROM t GROUP BY g AVG n").unwrap();
3474        let a = group(&rows, "g", "a");
3475        assert_eq!(a["count"], json!(3), "every row counts toward the group");
3476        assert_eq!(a["avg_n"], json!(15.0), "only the two numeric rows average");
3477    }
3478
3479    /// An aggregate with no numeric input is null, not 0 and not infinity.
3480    #[test]
3481    fn empty_aggregate_input_is_null() {
3482        let dir = tempdir().unwrap();
3483        let db = Db::open(dir.path(), None).unwrap();
3484        db.put("t", "1", serde_json::json!({"g": "a", "n": "x"}), vec![], None, None).unwrap();
3485        let (rows, _) = query(&db, "FROM t GROUP BY g MIN n").unwrap();
3486        assert_eq!(rows[0]["min_n"], Value::Null);
3487        assert_eq!(rows[0]["count"], json!(1));
3488    }
3489
3490    /// Python makes the aggregate keyword optional — `GROUP BY field` alone
3491    /// yields counts. Rust used to reject it as a parse error.
3492    #[test]
3493    fn bare_group_by_without_an_aggregate_counts() {
3494        let (_tmp, db) = setup_items();
3495        let (rows, _) = query(&db, "FROM items GROUP BY cat").unwrap();
3496        assert_eq!(rows.len(), 2);
3497        assert_eq!(group(&rows, "cat", "x")["count"], json!(3));
3498        assert_eq!(group(&rows, "cat", "y")["count"], json!(3));
3499    }
3500
3501    /// SUM/AVG/MIN/MAX require a target field, as in Python.
3502    #[test]
3503    fn aggregate_without_a_target_field_is_an_error() {
3504        let (_tmp, db) = setup_items();
3505        for bad in [
3506            "FROM items GROUP BY cat SUM",
3507            "FROM items GROUP BY cat AVG",
3508            "FROM items GROUP BY cat MIN",
3509        ] {
3510            assert!(query(&db, bad).is_err(), "`{}` must be rejected", bad);
3511        }
3512    }
3513
3514    /// Group output order is first-seen, so repeated runs agree. HashMap
3515    /// iteration order previously made this nondeterministic.
3516    #[test]
3517    fn group_order_is_stable_across_runs() {
3518        let (_tmp, db) = setup_items();
3519        let first = query(&db, "FROM items GROUP BY cat SUM price").unwrap().0;
3520        for _ in 0..8 {
3521            let again = query(&db, "FROM items GROUP BY cat SUM price").unwrap().0;
3522            assert_eq!(first, again);
3523        }
3524    }
3525
3526    /// GROUP BY composes with the new predicate surface.
3527    #[test]
3528    fn group_by_after_an_in_predicate() {
3529        let (_tmp, db) = setup_items();
3530        let (rows, _) = query(
3531            &db, "FROM items WHERE price IN (0, 5, 25) GROUP BY cat SUM price").unwrap();
3532        assert_eq!(group(&rows, "cat", "x")["sum_price"], json!(5));
3533        assert_eq!(group(&rows, "cat", "y")["sum_price"], json!(25));
3534    }
3535
3536    #[test]
3537    fn search() {
3538        let (_tmp, db) = setup();
3539        let (rows, _) = query(&db, r#"FROM blocks SEARCH "0003""#).unwrap();
3540        assert_eq!(rows.len(), 1);
3541    }
3542
3543    #[test]
3544    fn as_of() {
3545        let dir = tempdir().unwrap();
3546        let db = Db::open(dir.path(), None).unwrap();
3547        let v1 = db.put("docs", "x", serde_json::json!({"v": 1}), vec![], None, None).unwrap();
3548        db.put("docs", "x", serde_json::json!({"v": 2}), vec![], None, None).unwrap();
3549        let (rows, _) = query(&db, &format!("FROM docs AS OF {}", v1.seq)).unwrap();
3550        assert_eq!(rows[0]["v"], 1);
3551    }
3552
3553    #[test]
3554    fn valid_as_of() {
3555        let dir = tempdir().unwrap();
3556        let db = Db::open(dir.path(), None).unwrap();
3557        db.put("events", "e1", serde_json::json!({"type": "a"}), vec![],
3558               Some("2025-01-01".to_string()), Some("2025-06-01".to_string())).unwrap();
3559        db.put("events", "e2", serde_json::json!({"type": "b"}), vec![],
3560               Some("2026-01-01".to_string()), None).unwrap();
3561        let (rows, _) = query(&db, r#"FROM events VALID AS OF "2025-03-01""#).unwrap();
3562        assert_eq!(rows.len(), 1);
3563        assert_eq!(rows[0]["type"], "a");
3564    }
3565
3566    // ── String-literal escaping ──────────────────────────────────────────────
3567
3568    #[test]
3569    fn escaped_quote_matches_a_value_containing_a_quote() {
3570        let dir = tempdir().unwrap();
3571        let db = Db::open(dir.path(), None).unwrap();
3572        db.put("m", "q", serde_json::json!({ "name": "say \"hi\"" }), vec![], None, None)
3573            .unwrap();
3574        db.put("m", "p", serde_json::json!({ "name": "plain" }), vec![], None, None).unwrap();
3575
3576        // \" inside the literal is a literal quote; the string does not end there.
3577        let (rows, count) = query(&db, r#"FROM m WHERE name = "say \"hi\"""#).unwrap();
3578        assert_eq!(count, 1, "the escaped-quote literal matches exactly one row");
3579        assert_eq!(rows[0]["_id"], "q");
3580    }
3581
3582    #[test]
3583    fn raw_backslash_still_matches_literally() {
3584        // REGRESSION GUARD: a lone backslash stays literal, so pre-existing
3585        // backslash queries (Windows paths etc.) keep matching. This is the
3586        // property that makes the \" addition non-breaking.
3587        let dir = tempdir().unwrap();
3588        let db = Db::open(dir.path(), None).unwrap();
3589        db.put("m", "b", serde_json::json!({ "p": "back\\slash" }), vec![], None, None).unwrap();
3590
3591        let (rows, count) = query(&db, r#"FROM m WHERE p = "back\slash""#).unwrap();
3592        assert_eq!(count, 1, "a raw backslash literal matches as before");
3593        assert_eq!(rows[0]["_id"], "b");
3594    }
3595
3596    #[test]
3597    fn a_quote_can_no_longer_inject_trailing_clauses() {
3598        // The security motivation: previously a value of `x" LIMIT 1` would
3599        // terminate the literal and inject `LIMIT 1`. With \" the caller can
3600        // escape the quote so it stays part of the value and matches nothing
3601        // rogue. Here the escaped form matches the literal value verbatim.
3602        let dir = tempdir().unwrap();
3603        let db = Db::open(dir.path(), None).unwrap();
3604        db.put("m", "x", serde_json::json!({ "v": "a\"b" }), vec![], None, None).unwrap();
3605        let (rows, count) = query(&db, r#"FROM m WHERE v = "a\"b""#).unwrap();
3606        assert_eq!(count, 1);
3607        assert_eq!(rows[0]["_id"], "x");
3608    }
3609}
3610
3611#[cfg(test)]
3612mod tests_traverse {
3613    use super::*;
3614    use tempfile::tempdir;
3615    use crate::db::Db;
3616
3617    #[test]
3618    fn traverse_one_hop() {
3619        let db = Db::in_memory();
3620        db.put("driver", "d1", serde_json::json!({"name": "Bob"}),   vec![], None, None).unwrap();
3621        db.put("driver", "d2", serde_json::json!({"name": "Carol"}), vec![], None, None).unwrap();
3622        db.put("trip",   "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
3623        db.put("trip",   "t2", serde_json::json!({"status": "ok"}),  vec![], None, None).unwrap();
3624
3625        db.link("driver:d1", "handles", "trip:t1").unwrap();
3626        db.link("driver:d1", "handles", "trip:t2").unwrap();
3627
3628        let (rows, count) = query(&db, r#"FROM driver WHERE _id = "d1" TRAVERSE handles"#).unwrap();
3629        assert_eq!(count, 2);
3630        let ids: std::collections::HashSet<&str> = rows.iter()
3631            .filter_map(|r| r["_id"].as_str())
3632            .collect();
3633        assert!(ids.contains("t1") && ids.contains("t2"));
3634    }
3635
3636    #[test]
3637    fn traverse_returns_empty_when_no_links() {
3638        let db = Db::in_memory();
3639        db.put("driver", "d1", serde_json::json!({"name": "Bob"}), vec![], None, None).unwrap();
3640        let (rows, count) = query(&db, r#"FROM driver WHERE _id = "d1" TRAVERSE handles"#).unwrap();
3641        assert_eq!(count, 0);
3642        assert!(rows.is_empty());
3643    }
3644
3645    #[test]
3646    fn traverse_multi_source() {
3647        // When WHERE matches multiple rows, TRAVERSE unions all their neighbors
3648        let db = Db::in_memory();
3649        db.put("driver", "d1", serde_json::json!({"status": "active"}), vec![], None, None).unwrap();
3650        db.put("driver", "d2", serde_json::json!({"status": "active"}), vec![], None, None).unwrap();
3651        db.put("trip",   "t1", serde_json::json!({"n": 1}), vec![], None, None).unwrap();
3652        db.put("trip",   "t2", serde_json::json!({"n": 2}), vec![], None, None).unwrap();
3653        db.put("trip",   "t3", serde_json::json!({"n": 3}), vec![], None, None).unwrap();
3654
3655        db.link("driver:d1", "handles", "trip:t1").unwrap();
3656        db.link("driver:d1", "handles", "trip:t2").unwrap();
3657        db.link("driver:d2", "handles", "trip:t3").unwrap();
3658
3659        let (_rows, count) = query(&db, r#"FROM driver WHERE status = "active" TRAVERSE handles"#).unwrap();
3660        assert_eq!(count, 3);
3661    }
3662
3663    #[test]
3664    fn traverse_nql_keyword_case_insensitive() {
3665        // Parser normalises to uppercase — "traverse" and "TRAVERSE" both work
3666        let db = Db::in_memory();
3667        db.put("driver", "d1", serde_json::json!({}), vec![], None, None).unwrap();
3668        db.put("trip",   "t1", serde_json::json!({}), vec![], None, None).unwrap();
3669        db.link("driver:d1", "handles", "trip:t1").unwrap();
3670        // uppercase
3671        let (r1, c1) = query(&db, r#"FROM driver WHERE _id = "d1" TRAVERSE handles"#).unwrap();
3672        assert_eq!(c1, 1);
3673        // lowercase (lexer uppercases keywords)
3674        let (r2, c2) = query(&db, r#"FROM driver WHERE _id = "d1" traverse handles"#).unwrap();
3675        assert_eq!(c2, 1);
3676        assert_eq!(r1[0]["_id"], r2[0]["_id"]);
3677    }
3678
3679    #[test]
3680    fn traverse_durable() {
3681        let dir = tempdir().unwrap();
3682        {
3683            let db = Db::open(dir.path(), None).unwrap();
3684            db.put("driver", "d1", serde_json::json!({"name": "Bob"}),   vec![], None, None).unwrap();
3685            db.put("trip",   "t1", serde_json::json!({"status": "req"}), vec![], None, None).unwrap();
3686            db.link("driver:d1", "handles", "trip:t1").unwrap();
3687        }
3688        let db2 = Db::open(dir.path(), None).unwrap();
3689        db2.startup_ready.store(true, std::sync::atomic::Ordering::SeqCst);
3690        let (rows, count) = query(&db2, r#"FROM driver WHERE _id = "d1" TRAVERSE handles"#).unwrap();
3691        assert_eq!(count, 1);
3692        assert_eq!(rows[0]["_id"], "t1");
3693    }
3694}