Skip to main content

rich_data/
lib.rs

1//! # rich-data
2//!
3//! Tabular data for [rs-rich](https://github.com/buchochelliq-labs/rs-rich-cli):
4//! one row source contract with adapters behind it, so a file, a stream of
5//! records or a query result becomes a table without hand-written glue. Not
6//! a port of anything upstream; it builds on `rich-ext`'s public API only.
7//!
8//! - [`RowSource`]: column names, an optional [`Schema`] (`rich_ext`'s
9//!   format-neutral model) and rows of [`Value`]s, one at a time. [`Rows`]
10//!   holds them in memory and renders through `rich_ext`'s
11//!   [`TableData`](rich_ext::table::TableData).
12//! - Adapters: [`csv`] (CSV and TSV through the port of Python's `csv`
13//!   sniffer and reader that `rich --csv` uses), [`jsonl`] (JSON Lines),
14//!   [`serialize`] (any `serde::Serialize` rows), and `arrow` (Arrow
15//!   `RecordBatch`es, behind the off-by-default `arrow` feature).
16//! - `er`: an ER diagram of a [`Schema`] or of SQL DDL, through
17//!   `rs-rich-diagram`, behind the off-by-default `er` feature.
18//! - [`infer`]: column types (integers, floats, booleans, dates, timestamps,
19//!   nulls, text) with the evidence for each, on request.
20//! - [`stats`]: per-column count, nulls, distinct values, min, max, mean,
21//!   median, quantiles and top values, as a table or under each heading.
22//! - [`window`]: rows for `rich_ext`'s virtualised tables: [`Rows`] by
23//!   index, and [`RowWindow`](window::RowWindow), one window of a
24//!   forward-only source read in constant memory.
25//! - [`sql`]: a query result set ([`ResultSet`](sql::ResultSet)) with typed
26//!   alignment, `NULL` marked apart from empty text, and a row count.
27//! - [`profile`]: a bounded profile of any row source (types, nulls,
28//!   distinct values, statistics, histograms or top values, and a
29//!   missing-value map), sampled when the input is long, as JSON or drawn;
30//!   and of JSON records path by path, with presence and type mixes.
31//! - [`quality`]: data quality check results (pass, warn, fail, error, with
32//!   observed and expected values and failing rows) as a report.
33//!
34//! Conditional styles for the tables these rows become are
35//! `rich_ext::table::rules`.
36//!
37//! ```
38//! use rich::Console;
39//! use rich_data::{csv, infer::Inferrer, RowSource};
40//!
41//! let mut rows = csv::CsvReader::new()
42//!     .read("service,p99,up\nweb,120,true\napi,35.5,false\n")
43//!     .unwrap();
44//! assert_eq!(rows.columns(), ["service", "p99", "up"]);
45//!
46//! // Types are only inferred when asked, and every guess carries its evidence.
47//! let inference = Inferrer::new().infer(&rows);
48//! assert_eq!(inference.columns()[1].data_type().to_string(), "float");
49//! assert_eq!(inference.columns()[1].evidence().floats, 2);
50//! inference.apply(&mut rows);
51//!
52//! let console = Console::builder().width(40).build();
53//! let out = console.render_export(&rows.to_table_data());
54//! assert!(out.contains("│ api     │ 35.5 │ false │"));
55//! ```
56
57// A builder method that returns `Self` is `#[must_use]`: dropping its
58// result drops the value (CONTRIBUTING.md, "API conventions").
59#![warn(clippy::return_self_not_must_use)]
60
61pub mod csv;
62#[cfg(feature = "er")]
63pub mod er;
64pub mod infer;
65pub mod jsonl;
66pub mod profile;
67pub mod quality;
68mod record;
69pub mod serialize;
70pub mod sql;
71pub mod stats;
72pub mod window;
73
74#[cfg(feature = "arrow")]
75pub mod arrow;
76
77use std::fmt;
78
79use rich::Justify;
80use rich_ext::table::{Column, TableData};
81
82pub use record::RecordSource;
83pub use rich_ext::schema::{DataType, Field, Schema};
84pub use rich_ext::table::Value;
85
86/// One row of cells.
87pub type Row = Vec<Value>;
88
89/// Why rows could not be read: what went wrong, and where when the source
90/// knows (a 1-based line or record number).
91#[derive(Clone, Debug, PartialEq, Eq)]
92pub struct DataError {
93    line: Option<usize>,
94    message: String,
95}
96
97impl DataError {
98    /// An error with no position.
99    pub fn new(message: impl Into<String>) -> Self {
100        DataError {
101            line: None,
102            message: message.into(),
103        }
104    }
105
106    /// An error at a 1-based line or record.
107    pub fn at(line: usize, message: impl Into<String>) -> Self {
108        DataError {
109            line: Some(line),
110            message: message.into(),
111        }
112    }
113
114    /// The 1-based line or record, when known.
115    pub fn line(&self) -> Option<usize> {
116        self.line
117    }
118
119    /// The message, without the position.
120    pub fn message(&self) -> &str {
121        &self.message
122    }
123}
124
125impl fmt::Display for DataError {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match self.line {
128            Some(line) => write!(f, "line {line}: {}", self.message),
129            None => f.write_str(&self.message),
130        }
131    }
132}
133
134impl std::error::Error for DataError {}
135
136/// The row source contract: column names, an optional schema, and rows.
137///
138/// A source yields rows one at a time, so a renderer or a profile can stop
139/// early or hold one window of a long input; [`collect_rows`] reads the rest
140/// into [`Rows`]. Every row a source yields has exactly one cell per column.
141///
142/// [`collect_rows`]: RowSource::collect_rows
143pub trait RowSource {
144    /// The column names, in order.
145    fn columns(&self) -> &[String];
146
147    /// What the source knows about the columns' types, if anything. A
148    /// source's schema has one field per column, in the same order.
149    fn schema(&self) -> Option<&Schema> {
150        None
151    }
152
153    /// The next row, `None` at the end, or why it could not be read.
154    fn next_row(&mut self) -> Option<Result<Row, DataError>>;
155
156    /// Read every remaining row into memory.
157    fn collect_rows(mut self) -> Result<Rows, DataError>
158    where
159        Self: Sized,
160    {
161        let mut rows = Rows::new(self.columns().to_vec());
162        rows.schema = self.schema().cloned();
163        while let Some(row) = self.next_row() {
164            rows.push(row?);
165        }
166        Ok(rows)
167    }
168}
169
170/// Rows held in memory: column names, an optional schema, and the rows.
171#[derive(Clone, Debug, Default)]
172pub struct Rows {
173    columns: Vec<String>,
174    schema: Option<Schema>,
175    rows: Vec<Row>,
176}
177
178impl Rows {
179    /// No rows under `columns`.
180    pub fn new(columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
181        Rows {
182            columns: columns.into_iter().map(Into::into).collect(),
183            schema: None,
184            rows: Vec::new(),
185        }
186    }
187
188    /// Attach a schema (one field per column).
189    #[must_use]
190    pub fn with_schema(mut self, schema: Schema) -> Self {
191        self.schema = Some(schema);
192        self
193    }
194
195    /// Replace the schema.
196    pub fn set_schema(&mut self, schema: Option<Schema>) {
197        self.schema = schema;
198    }
199
200    /// Append a row: missing cells are null, and extra cells are dropped.
201    pub fn push(&mut self, row: impl IntoIterator<Item = Value>) -> &mut Self {
202        let mut row: Row = row.into_iter().take(self.columns.len()).collect();
203        row.resize(self.columns.len(), Value::Null);
204        self.rows.push(row);
205        self
206    }
207
208    /// The column names.
209    pub fn columns(&self) -> &[String] {
210        &self.columns
211    }
212
213    /// The schema, if any.
214    pub fn schema(&self) -> Option<&Schema> {
215        self.schema.as_ref()
216    }
217
218    /// The rows, in order.
219    pub fn rows(&self) -> &[Row] {
220        &self.rows
221    }
222
223    /// The rows, to change in place. Keep one cell per column.
224    pub fn rows_mut(&mut self) -> &mut [Row] {
225        &mut self.rows
226    }
227
228    /// The number of rows.
229    pub fn len(&self) -> usize {
230        self.rows.len()
231    }
232
233    /// Whether there are no rows.
234    pub fn is_empty(&self) -> bool {
235        self.rows.is_empty()
236    }
237
238    /// The position of the first column called `name`.
239    pub fn column_index(&self, name: &str) -> Option<usize> {
240        self.columns.iter().position(|c| c == name)
241    }
242
243    /// Every cell of the column at `index`, top to bottom.
244    pub fn column(&self, index: usize) -> impl Iterator<Item = &Value> {
245        self.rows.iter().filter_map(move |row| row.get(index))
246    }
247
248    /// The columns, schema and rows.
249    pub fn into_parts(self) -> (Vec<String>, Option<Schema>, Vec<Row>) {
250        (self.columns, self.schema, self.rows)
251    }
252
253    /// A row source over these rows.
254    pub fn into_source(self) -> RowsSource {
255        RowsSource {
256            columns: self.columns,
257            schema: self.schema,
258            rows: self.rows.into_iter(),
259        }
260    }
261
262    /// A table of these rows: one column per name, numeric columns (by the
263    /// schema) right-justified. Sort, group, total or style it with
264    /// `rich_ext::table`.
265    pub fn to_table_data(&self) -> TableData {
266        let columns = self.columns.iter().enumerate().map(|(index, name)| {
267            let numeric = self
268                .schema
269                .as_ref()
270                .and_then(|s| s.fields().get(index))
271                .is_some_and(|f| f.data_type().is_numeric());
272            let column = Column::new(name.clone());
273            if numeric {
274                column.justify(Justify::Right)
275            } else {
276                column
277            }
278        });
279        let mut data = TableData::new(columns);
280        data.extend(self.rows.iter().cloned());
281        data
282    }
283}
284
285/// [`Rows`] as a [`RowSource`], from [`Rows::into_source`].
286#[derive(Debug)]
287pub struct RowsSource {
288    columns: Vec<String>,
289    schema: Option<Schema>,
290    rows: std::vec::IntoIter<Row>,
291}
292
293impl RowSource for RowsSource {
294    fn columns(&self) -> &[String] {
295        &self.columns
296    }
297
298    fn schema(&self) -> Option<&Schema> {
299        self.schema.as_ref()
300    }
301
302    fn next_row(&mut self) -> Option<Result<Row, DataError>> {
303        self.rows.next().map(Ok)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn rows_pad_and_cut_to_their_columns() {
313        let mut rows = Rows::new(["a", "b"]);
314        rows.push([Value::Int(1)]);
315        rows.push([Value::Int(1), Value::Int(2), Value::Int(3)]);
316        assert_eq!(rows.rows()[0], [Value::Int(1), Value::Null]);
317        assert_eq!(rows.rows()[1], [Value::Int(1), Value::Int(2)]);
318        assert_eq!(rows.column_index("b"), Some(1));
319        assert_eq!(rows.column(0).count(), 2);
320    }
321
322    #[test]
323    fn a_source_collects_back_into_the_same_rows() {
324        let mut rows =
325            Rows::new(["x"]).with_schema(Schema::new([Field::new("x", DataType::Integer)]));
326        rows.push([Value::Int(7)]);
327        let back = rows.clone().into_source().collect_rows().unwrap();
328        assert_eq!(back.rows(), rows.rows());
329        assert_eq!(back.schema(), rows.schema());
330    }
331
332    #[test]
333    fn errors_name_their_line() {
334        assert_eq!(DataError::at(3, "bad").to_string(), "line 3: bad");
335        assert_eq!(DataError::new("bad").to_string(), "bad");
336    }
337}