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}