Skip to main content

nedb_engine/
relation.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//! Reading one relation out of the store, without going through a query
6//! language to do it.
7//!
8//! # Why this module exists
9//!
10//! The SQL evaluator used to obtain its rows by BUILDING AN NQL STRING and
11//! parsing it back:
12//!
13//! ```text
14//!     let mut q = format!("FROM {}", cname);
15//!     if let Some(seq) = temporal.get(&key) { q.push_str(&format!(" AS OF {}", seq)); }
16//!     ...
17//!     crate::nql::query(db, &q)
18//! ```
19//!
20//! That is a translation. It is the same translation the project spent months
21//! removing, pointed the other way — and it carried the same class of defect,
22//! because a clause that fails to make it into the string is a clause that
23//! silently does not happen. The retry path in `pgwire` did exactly that: it
24//! rebuilt a shorter string by hand and dropped `VALID AS OF` and `SEARCH`
25//! while carefully preserving `AS OF`, because someone had been bitten by
26//! `AS OF` specifically and fixed that one.
27//!
28//! A struct cannot forget a field. This module is the scan expressed as data,
29//! executed by calling the store directly.
30//!
31//! # This is the fold, not a second implementation
32//!
33//! `matches_valid_as_of` and `node_contains_text` LIVE HERE NOW. They were
34//! private to `nql`, and NQL's executor calls into this module for them rather
35//! than keeping a copy. That ordering matters: two copies of "what does VALID
36//! AS OF mean" is the exact failure mode being removed, and it would be absurd
37//! to create one while removing one.
38//!
39//! What remains in `nql` is its parser and its predicate evaluator. When the
40//! last caller of those is gone, so is the file.
41
42use serde_json::Value;
43
44use crate::db::Db;
45use crate::store::Node;
46
47/// One relation to read, and the qualifiers that shape it.
48///
49/// Every field is a question the store can answer directly. There is no
50/// rendering step and nothing to escape — `SEARCH 'o''brien'` was a quoting
51/// problem when this was a string, and is not one now.
52#[derive(Debug, Clone, Default)]
53pub struct Scan {
54    /// The collection name.
55    pub coll: String,
56    /// `AS OF SYSTEM TIME <seq>` — system time, a sequence.
57    pub as_of: Option<u64>,
58    /// `VALID AS OF '<date>'` — application time, a date string.
59    pub valid_as_of: Option<String>,
60    /// `SEARCH '<text>'` — substring over the document's rendered fields.
61    pub search: Option<String>,
62    /// `TRACE <edge> [REVERSE]` — replace each row with its causal chain.
63    pub trace: Option<String>,
64    /// Walk effects rather than causes.
65    pub trace_reverse: bool,
66    /// `TRAVERSE <rel>` — replace each row with its one-hop neighbours.
67    pub traverse: Option<String>,
68    /// Chain length cap for `TRACE`.
69    ///
70    /// Explicit rather than defaulted at the call site. NQL took this from the
71    /// query's `LIMIT` and fell back to 1000 — which silently conflated "how
72    /// many rows do I want back" with "how deep may a causal chain go", two
73    /// unrelated numbers. They are separate here, and a truncated chain is a
74    /// thing the caller chose.
75    pub trace_limit: usize,
76}
77
78impl Scan {
79    pub fn new(coll: impl Into<String>) -> Self {
80        Scan { coll: coll.into(), trace_limit: DEFAULT_TRACE_LIMIT, ..Default::default() }
81    }
82
83    /// True when this scan asks for anything beyond the live collection.
84    pub fn is_plain(&self) -> bool {
85        self.as_of.is_none()
86            && self.valid_as_of.is_none()
87            && self.search.is_none()
88            && self.trace.is_none()
89            && self.traverse.is_none()
90    }
91}
92
93/// The cap NQL used, preserved so a migrated query answers identically.
94pub const DEFAULT_TRACE_LIMIT: usize = 1000;
95
96/// Is `node` valid at `date`?
97///
98/// Moved here from `nql`, unchanged, and now the only definition.
99///
100/// `valid_from` is inclusive and `valid_to` is EXCLUSIVE, which is what makes
101/// two adjacent validity windows tile without overlapping — a row ending
102/// `2026-01-01` and the next beginning `2026-01-01` yields exactly one answer
103/// on that date, not two and not zero.
104pub fn matches_valid_as_of(node: &Node, date: &str) -> bool {
105    let from_ok = node.valid_from.as_deref().map(|f| f <= date).unwrap_or(true);
106    let to_ok = node.valid_to.as_deref().map(|t| t > date).unwrap_or(true);
107    from_ok && to_ok
108}
109
110/// Full-text over the document's rendered JSON, case-insensitively.
111///
112/// Moved here from `nql`, unchanged, and now the only definition. It searches
113/// the SERIALISED document, so it matches field names as well as values —
114/// long-standing behaviour, preserved deliberately rather than quietly
115/// improved, because changing what `SEARCH` matches is a semantic change and
116/// this module's job is to not be one.
117pub fn node_contains_text(node: &Node, text: &str) -> bool {
118    node.data.to_string().to_lowercase().contains(&text.to_lowercase())
119}
120
121/// Read the relation.
122///
123/// The order is NQL's execution order, and it is load-bearing:
124///
125/// 1. **candidates** — every row, at a sequence or at the tip
126/// 2. **filters** — `VALID AS OF`, then `SEARCH`
127/// 3. **row-set transforms** — `TRACE`, then `TRAVERSE`
128///
129/// Filters before transforms is the part worth stating. Tracing first and
130/// filtering after would apply `SEARCH` to the CHAIN rather than to the roots
131/// the chain was grown from, which is a different question with a
132/// plausible-looking answer.
133pub fn read(db: &Db, scan: &Scan) -> Vec<Node> {
134    // A sequence reaches through the graveyard: a row deleted after `seq` was
135    // alive AT `seq`, and `list` only knows about the living. That is why this
136    // goes id-by-id rather than filtering `list`.
137    let candidates: Vec<Node> = match scan.as_of {
138        Some(seq) => db
139            .list_ids_including_deleted(&scan.coll)
140            .into_iter()
141            .filter_map(|id| db.get_as_of(&scan.coll, &id, seq))
142            .collect(),
143        None => db.list(&scan.coll),
144    };
145
146    let mut rows: Vec<Node> = candidates
147        .into_iter()
148        .filter(|n| {
149            scan.valid_as_of
150                .as_deref()
151                .map(|d| matches_valid_as_of(n, d))
152                .unwrap_or(true)
153        })
154        .filter(|n| {
155            scan.search
156                .as_deref()
157                .map(|t| node_contains_text(n, t))
158                .unwrap_or(true)
159        })
160        .collect();
161
162    if scan.trace.is_some() {
163        let limit = if scan.trace_limit == 0 { DEFAULT_TRACE_LIMIT } else { scan.trace_limit };
164        let mut traced: Vec<Node> = Vec::new();
165        for root in &rows {
166            traced.extend(db.trace(&root.hash, scan.trace_reverse, limit));
167        }
168        rows = traced;
169    }
170
171    if let Some(rel) = &scan.traverse {
172        let mut hopped: Vec<Node> = Vec::new();
173        for root in &rows {
174            hopped.extend(db.neighbors(&format!("{}:{}", root.coll, root.id), rel));
175        }
176        rows = hopped;
177    }
178
179    rows
180}
181
182/// Read the relation as query rows.
183pub fn read_json(db: &Db, scan: &Scan) -> Vec<Value> {
184    read(db, scan).iter().map(crate::nql::node_to_json).collect()
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use serde_json::json;
191
192    fn db() -> (tempfile::TempDir, Db) {
193        let dir = tempfile::tempdir().unwrap();
194        let db = Db::open(dir.path(), None).unwrap();
195        (dir, db)
196    }
197
198    #[test]
199    fn a_plain_scan_is_the_live_collection() {
200        let (_d, db) = db();
201        db.put("orders", "1", json!({"who": "acme"}), vec![], None, None).unwrap();
202        db.put("orders", "2", json!({"who": "globex"}), vec![], None, None).unwrap();
203        let rows = read(&db, &Scan::new("orders"));
204        assert_eq!(rows.len(), 2);
205    }
206
207    #[test]
208    fn as_of_reaches_a_row_that_was_deleted_later() {
209        // The reason the candidate set is built from
210        // `list_ids_including_deleted` rather than from `list`. A scan that
211        // started from the living would answer "it was never there", which is
212        // a different and wrong claim about the past.
213        let (_d, db) = db();
214        db.put("orders", "1", json!({"who": "acme"}), vec![], None, None).unwrap();
215        let alive = db.seq.load(std::sync::atomic::Ordering::SeqCst) - 1;
216        db.delete("orders", "1").unwrap();
217
218        assert_eq!(read(&db, &Scan::new("orders")).len(), 0, "gone at the tip");
219        let past = Scan { as_of: Some(alive), ..Scan::new("orders") };
220        assert_eq!(read(&db, &past).len(), 1, "present at the sequence it was alive");
221    }
222
223    #[test]
224    fn search_filters_before_trace_grows_the_row_set() {
225        // Order matters: searching after the trace would test the CHAIN, not
226        // the roots, and quietly answer a different question.
227        let (_d, db) = db();
228        db.put("orders", "1", json!({"who": "acme"}), vec![], None, None).unwrap();
229        db.put("orders", "2", json!({"who": "globex"}), vec![], None, None).unwrap();
230
231        let s = Scan { search: Some("acme".into()), ..Scan::new("orders") };
232        let rows = read(&db, &s);
233        assert_eq!(rows.len(), 1);
234        assert_eq!(rows[0].id, "1");
235    }
236
237    #[test]
238    fn valid_to_is_exclusive_so_windows_tile() {
239        let (_d, db) = db();
240        db.put("p", "1", json!({"v": 1}), vec![],
241               Some("2026-01-01".into()), Some("2026-02-01".into())).unwrap();
242        db.put("p", "2", json!({"v": 2}), vec![],
243               Some("2026-02-01".into()), None).unwrap();
244
245        let on = |d: &str| {
246            let s = Scan { valid_as_of: Some(d.into()), ..Scan::new("p") };
247            read(&db, &s).into_iter().map(|n| n.id).collect::<Vec<_>>()
248        };
249        assert_eq!(on("2026-01-15"), vec!["1"]);
250        // The boundary: exactly one row, because `valid_to` is exclusive.
251        assert_eq!(on("2026-02-01"), vec!["2"]);
252    }
253
254    #[test]
255    fn a_scan_knows_whether_it_is_plain() {
256        assert!(Scan::new("t").is_plain());
257        assert!(!Scan { as_of: Some(1), ..Scan::new("t") }.is_plain());
258        assert!(!Scan { trace: Some("caused_by".into()), ..Scan::new("t") }.is_plain());
259    }
260}