Skip to main content

quarb_csv/
lib.rs

1//! CSV adapter for Quarb.
2//!
3//! Maps tabular data onto the arbor model the way a DataFrame maps
4//! onto a table: one node per record, one property per column.
5//!
6//! - The unnamed root has one `row` child per record, in file
7//!   order, so `/row` iterates the table, `/row[5]` is the fifth
8//!   record, and `//row @| count` is the row count.
9//! - Columns are properties, named by the header: `::age` reads the
10//!   `age` cell. Numeric-looking cells participate in comparisons
11//!   and arithmetic via the numeric reading (`::price * ::qty`).
12//! - An empty cell is a *missing value*: the property projects as
13//!   null, which numeric aggregates skip and null propagation
14//!   respects — pandas' `NaN` behavior without a sentinel.
15//! - Rows are leaves; there are no traits and no crosslinks.
16//! - `;;;columns` on the root lists the column names; `;;;n-rows`
17//!   counts records; `;;;n-fields` on a row counts its cells.
18//!
19//! The first record must be the header. Parsing is strict about
20//! ragged rows (a record with the wrong field count is an error).
21
22use quarb::{AstAdapter, NodeId, Value};
23
24/// A Quarb adapter over a parsed CSV table.
25pub struct CsvAdapter {
26    /// Column names, from the header record.
27    columns: Vec<String>,
28    /// One entry per record, each with one cell per column.
29    rows: Vec<Vec<String>>,
30}
31
32impl CsvAdapter {
33    /// Parse comma-separated `text` (header row required) and build
34    /// the adapter.
35    pub fn parse(text: &str) -> Result<Self, csv::Error> {
36        Self::parse_with_delimiter(text, b',')
37    }
38
39    /// Parse with an explicit field delimiter (e.g. `b'\t'` for TSV).
40    pub fn parse_with_delimiter(text: &str, delimiter: u8) -> Result<Self, csv::Error> {
41        let mut reader = csv::ReaderBuilder::new()
42            .delimiter(delimiter)
43            .from_reader(text.as_bytes());
44        let columns: Vec<String> = reader
45            .headers()?
46            .iter()
47            .map(|h| h.trim().to_string())
48            .collect();
49        let mut rows = Vec::new();
50        for record in reader.records() {
51            let record = record?;
52            rows.push(record.iter().map(|c| c.to_string()).collect());
53        }
54        Ok(CsvAdapter { columns, rows })
55    }
56
57    /// A locator path to `node`, like `/row[3]`, for rendering.
58    pub fn locator(&self, node: NodeId) -> String {
59        if node.0 == 0 {
60            "/".to_string()
61        } else {
62            format!("/row[{}]", node.0)
63        }
64    }
65
66    fn cell(&self, node: NodeId, column: &str) -> Option<&str> {
67        // The root (id 0) has no cells; guard the 1-based index so
68        // it cannot underflow when property() reaches here at root.
69        let row = self.rows.get((node.0 as usize).checked_sub(1)?)?;
70        let idx = self.columns.iter().position(|c| c == column)?;
71        // An empty cell is a missing value, not an empty string.
72        row.get(idx).map(|c| c.as_str()).filter(|c| !c.is_empty())
73    }
74}
75
76impl AstAdapter for CsvAdapter {
77    fn root(&self) -> NodeId {
78        NodeId(0)
79    }
80
81    fn children(&self, node: NodeId) -> Vec<NodeId> {
82        if node.0 == 0 {
83            (1..=self.rows.len() as u64).map(NodeId).collect()
84        } else {
85            Vec::new()
86        }
87    }
88
89    fn name(&self, node: NodeId) -> Option<String> {
90        if node.0 == 0 {
91            None
92        } else {
93            Some("row".to_string())
94        }
95    }
96
97    fn parent(&self, node: NodeId) -> Option<NodeId> {
98        if node.0 == 0 { None } else { Some(NodeId(0)) }
99    }
100
101    /// A column cell, by header name. Empty cells are missing.
102    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
103        self.cell(node, name).map(|c| Value::Str(c.to_string()))
104    }
105
106    /// `;;;columns` (root), `;;;n-rows` (root), `;;;n-fields` (row),
107    /// and any column by name.
108    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
109        if node.0 == 0 {
110            return match key {
111                "columns" => Some(Value::List(
112                    self.columns.iter().map(|c| Value::Str(c.clone())).collect(),
113                )),
114                "n-rows" => Some(Value::Int(self.rows.len() as i64)),
115                _ => None,
116            };
117        }
118        match key {
119            "n-fields" => self
120                .rows
121                .get(node.0 as usize - 1)
122                .map(|r| Value::Int(r.len() as i64)),
123            other => self.property(node, other),
124        }
125    }
126}