use std::fmt;
use std::rc::Rc;
use serde_json::{Map, Value};
use crate::tabular::{Column, FlatDataSpec};
type DerivedCell = Rc<dyn Fn(&Value, &Value) -> Value>;
type SyntheticRow = Rc<dyn Fn(&Value) -> Option<Value>>;
#[derive(Clone)]
pub struct StructuredOutputProjection {
csv: CsvProjection,
}
impl StructuredOutputProjection {
pub fn csv(projection: CsvProjection) -> Self {
Self { csv: projection }
}
pub fn csv_projection(&self) -> &CsvProjection {
&self.csv
}
}
impl fmt::Debug for StructuredOutputProjection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StructuredOutputProjection")
.field("csv", &self.csv)
.finish()
}
}
#[derive(Clone)]
pub struct CsvProjection {
row_source: String,
columns: Vec<ProjectionColumn>,
synthetic_rows: Vec<SyntheticRow>,
}
impl CsvProjection {
pub fn builder(row_source: impl Into<String>) -> CsvProjectionBuilder {
CsvProjectionBuilder {
row_source: row_source.into(),
columns: Vec::new(),
synthetic_rows: Vec::new(),
}
}
pub fn render(&self, root: &Value) -> Result<String, ProjectionError> {
let rows = value_at_path(root, &self.row_source).ok_or_else(|| {
ProjectionError::MissingRowSource {
path: self.row_source.clone(),
}
})?;
let rows = rows
.as_array()
.ok_or_else(|| ProjectionError::RowSourceNotArray {
path: self.row_source.clone(),
})?;
let spec = FlatDataSpec::new(
self.columns
.iter()
.enumerate()
.map(|(index, projection)| {
let column = projection.column();
let header = column
.header
.as_deref()
.or(column.key.as_deref())
.unwrap_or("");
column.clone().header(header).key(index.to_string())
})
.collect(),
);
let mut wtr = csv::Writer::from_writer(Vec::new());
wtr.write_record(spec.extract_header())?;
for row in rows {
wtr.write_record(self.extract_row(&spec, row, root))?;
}
for row in self
.synthetic_rows
.iter()
.filter_map(|synthetic| synthetic(root))
{
wtr.write_record(self.extract_row(&spec, &row, root))?;
}
let bytes = wtr.into_inner().map_err(|error| error.into_error())?;
Ok(String::from_utf8(bytes)?)
}
fn extract_row(&self, spec: &FlatDataSpec, row: &Value, root: &Value) -> Vec<String> {
let values = self
.columns
.iter()
.enumerate()
.map(|(index, projection)| {
let value = match projection {
ProjectionColumn::Direct(column) => column
.key
.as_deref()
.and_then(|path| value_at_path(row, path))
.cloned()
.unwrap_or(Value::Null),
ProjectionColumn::Derived { derive, .. } => derive(row, root),
};
(index.to_string(), value)
})
.collect::<Map<_, _>>();
spec.extract_row(&Value::Object(values))
}
}
impl fmt::Debug for CsvProjection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CsvProjection")
.field("row_source", &self.row_source)
.field("column_count", &self.columns.len())
.field("synthetic_row_count", &self.synthetic_rows.len())
.finish()
}
}
#[derive(Clone)]
enum ProjectionColumn {
Direct(Column),
Derived { column: Column, derive: DerivedCell },
}
impl ProjectionColumn {
fn column(&self) -> &Column {
match self {
Self::Direct(column) | Self::Derived { column, .. } => column,
}
}
}
pub struct CsvProjectionBuilder {
row_source: String,
columns: Vec<ProjectionColumn>,
synthetic_rows: Vec<SyntheticRow>,
}
impl CsvProjectionBuilder {
pub fn column(mut self, column: Column) -> Self {
self.columns.push(ProjectionColumn::Direct(column));
self
}
pub fn derived_column<F>(mut self, column: Column, derive: F) -> Self
where
F: Fn(&Value, &Value) -> Value + 'static,
{
self.columns.push(ProjectionColumn::Derived {
column,
derive: Rc::new(derive),
});
self
}
pub fn synthetic_row<F>(self, row: F) -> Self
where
F: Fn(&Value) -> Value + 'static,
{
self.conditional_row(move |root| Some(row(root)))
}
pub fn conditional_row<F>(mut self, row: F) -> Self
where
F: Fn(&Value) -> Option<Value> + 'static,
{
self.synthetic_rows.push(Rc::new(row));
self
}
pub fn build(self) -> CsvProjection {
CsvProjection {
row_source: self.row_source,
columns: self.columns,
synthetic_rows: self.synthetic_rows,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ProjectionError {
#[error("projection row source `{path}` was not found")]
MissingRowSource { path: String },
#[error("projection row source `{path}` is not an array")]
RowSourceNotArray { path: String },
#[error(transparent)]
Csv(#[from] csv::Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Utf8(#[from] std::string::FromUtf8Error),
}
fn value_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
path.split('.')
.try_fold(value, |current, segment| match current {
Value::Object(object) => object.get(segment),
_ => None,
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::tabular::{Column, Width};
fn column(key: &str, header: &str) -> Column {
Column::new(Width::default()).key(key).header(header)
}
fn projection() -> CsvProjection {
CsvProjection::builder("report.items")
.column(column("language", "LANGUAGE"))
.column(column("counts.code", "CODE"))
.derived_column(column("net", "NET"), |row, root| {
json!(
row["counts"]["code"].as_i64().unwrap_or(0)
- row["counts"]["comments"].as_i64().unwrap_or(0)
+ root["adjustment"].as_i64().unwrap()
)
})
.synthetic_row(|root| {
json!({
"language": "TOTAL",
"counts": {
"code": root["totals"]["code"],
"comments": root["totals"]["comments"]
}
})
})
.conditional_row(|root| {
(root["skipped"].as_u64().unwrap() > 0).then(|| {
json!({
"language": "SKIPPED",
"counts": { "code": root["skipped"], "comments": 0 }
})
})
})
.build()
}
#[test]
fn selects_nested_rows_derives_cells_and_appends_synthetic_rows() {
let root = json!({
"report": { "items": [
{ "language": "Rust", "counts": { "code": 12, "comments": 2 } },
{ "language": "Python", "counts": { "code": 8, "comments": 1 } }
] },
"totals": { "code": 20, "comments": 3 },
"skipped": 2,
"adjustment": 1
});
assert_eq!(
projection().render(&root).unwrap(),
"LANGUAGE,CODE,NET\nRust,12,11\nPython,8,8\nTOTAL,20,18\nSKIPPED,2,3\n"
);
}
#[test]
fn conditional_row_disappears_and_null_repr_matches_flat_data_spec() {
let root = json!({
"report": { "items": [{ "language": "Rust", "counts": { "comments": 2 } }] },
"totals": { "code": null, "comments": 2 },
"skipped": 0,
"adjustment": 0
});
assert_eq!(
projection().render(&root).unwrap(),
"LANGUAGE,CODE,NET\nRust,-,-2\nTOTAL,-,-2\n"
);
}
#[test]
fn preserves_key_based_header_fallback_for_direct_and_derived_columns() {
let projection = CsvProjection::builder("items")
.column(Column::new(Width::default()).key("language"))
.derived_column(Column::new(Width::default()).key("net"), |row, _root| {
json!(row["code"].as_i64().unwrap() - row["comments"].as_i64().unwrap())
})
.build();
assert_eq!(
projection
.render(&json!({
"items": [{ "language": "Rust", "code": 12, "comments": 2 }]
}))
.unwrap(),
"language,net\nRust,10\n"
);
}
}