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}