use serde_json::Value;
use crate::db::Db;
use crate::store::Node;
#[derive(Debug, Clone, Default)]
pub struct Scan {
pub coll: String,
pub as_of: Option<u64>,
pub valid_as_of: Option<String>,
pub search: Option<String>,
pub trace: Option<String>,
pub trace_reverse: bool,
pub traverse: Option<String>,
pub trace_limit: usize,
}
impl Scan {
pub fn new(coll: impl Into<String>) -> Self {
Scan { coll: coll.into(), trace_limit: DEFAULT_TRACE_LIMIT, ..Default::default() }
}
pub fn is_plain(&self) -> bool {
self.as_of.is_none()
&& self.valid_as_of.is_none()
&& self.search.is_none()
&& self.trace.is_none()
&& self.traverse.is_none()
}
}
pub const DEFAULT_TRACE_LIMIT: usize = 1000;
pub fn matches_valid_as_of(node: &Node, date: &str) -> bool {
let from_ok = node.valid_from.as_deref().map(|f| f <= date).unwrap_or(true);
let to_ok = node.valid_to.as_deref().map(|t| t > date).unwrap_or(true);
from_ok && to_ok
}
pub fn node_contains_text(node: &Node, text: &str) -> bool {
node.data.to_string().to_lowercase().contains(&text.to_lowercase())
}
pub fn read(db: &Db, scan: &Scan) -> Vec<Node> {
let candidates: Vec<Node> = match scan.as_of {
Some(seq) => db
.list_ids_including_deleted(&scan.coll)
.into_iter()
.filter_map(|id| db.get_as_of(&scan.coll, &id, seq))
.collect(),
None => db.list(&scan.coll),
};
let mut rows: Vec<Node> = candidates
.into_iter()
.filter(|n| {
scan.valid_as_of
.as_deref()
.map(|d| matches_valid_as_of(n, d))
.unwrap_or(true)
})
.filter(|n| {
scan.search
.as_deref()
.map(|t| node_contains_text(n, t))
.unwrap_or(true)
})
.collect();
if scan.trace.is_some() {
let limit = if scan.trace_limit == 0 { DEFAULT_TRACE_LIMIT } else { scan.trace_limit };
let mut traced: Vec<Node> = Vec::new();
for root in &rows {
traced.extend(db.trace(&root.hash, scan.trace_reverse, limit));
}
rows = traced;
}
if let Some(rel) = &scan.traverse {
let mut hopped: Vec<Node> = Vec::new();
for root in &rows {
hopped.extend(db.neighbors(&format!("{}:{}", root.coll, root.id), rel));
}
rows = hopped;
}
rows
}
pub fn read_json(db: &Db, scan: &Scan) -> Vec<Value> {
read(db, scan).iter().map(crate::nql::node_to_json).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn db() -> (tempfile::TempDir, Db) {
let dir = tempfile::tempdir().unwrap();
let db = Db::open(dir.path(), None).unwrap();
(dir, db)
}
#[test]
fn a_plain_scan_is_the_live_collection() {
let (_d, db) = db();
db.put("orders", "1", json!({"who": "acme"}), vec![], None, None).unwrap();
db.put("orders", "2", json!({"who": "globex"}), vec![], None, None).unwrap();
let rows = read(&db, &Scan::new("orders"));
assert_eq!(rows.len(), 2);
}
#[test]
fn as_of_reaches_a_row_that_was_deleted_later() {
let (_d, db) = db();
db.put("orders", "1", json!({"who": "acme"}), vec![], None, None).unwrap();
let alive = db.seq.load(std::sync::atomic::Ordering::SeqCst) - 1;
db.delete("orders", "1").unwrap();
assert_eq!(read(&db, &Scan::new("orders")).len(), 0, "gone at the tip");
let past = Scan { as_of: Some(alive), ..Scan::new("orders") };
assert_eq!(read(&db, &past).len(), 1, "present at the sequence it was alive");
}
#[test]
fn search_filters_before_trace_grows_the_row_set() {
let (_d, db) = db();
db.put("orders", "1", json!({"who": "acme"}), vec![], None, None).unwrap();
db.put("orders", "2", json!({"who": "globex"}), vec![], None, None).unwrap();
let s = Scan { search: Some("acme".into()), ..Scan::new("orders") };
let rows = read(&db, &s);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].id, "1");
}
#[test]
fn valid_to_is_exclusive_so_windows_tile() {
let (_d, db) = db();
db.put("p", "1", json!({"v": 1}), vec![],
Some("2026-01-01".into()), Some("2026-02-01".into())).unwrap();
db.put("p", "2", json!({"v": 2}), vec![],
Some("2026-02-01".into()), None).unwrap();
let on = |d: &str| {
let s = Scan { valid_as_of: Some(d.into()), ..Scan::new("p") };
read(&db, &s).into_iter().map(|n| n.id).collect::<Vec<_>>()
};
assert_eq!(on("2026-01-15"), vec!["1"]);
assert_eq!(on("2026-02-01"), vec!["2"]);
}
#[test]
fn a_scan_knows_whether_it_is_plain() {
assert!(Scan::new("t").is_plain());
assert!(!Scan { as_of: Some(1), ..Scan::new("t") }.is_plain());
assert!(!Scan { trace: Some("caused_by".into()), ..Scan::new("t") }.is_plain());
}
}