use std::fmt;
use std::future::{Future, ready};
use std::marker::PhantomData;
use std::path::Path;
use serde::Serialize;
use crate::dataset::Dataset;
use crate::dataset::file::FileSink;
use crate::{Error, ErrorKind, Result};
pub struct CsvDataset<T> {
sink: FileSink<Columns>,
marker: PhantomData<fn(T)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Columns {
New,
Existing(Vec<u8>),
Header(Vec<u8>),
Fields(usize),
}
impl<T> CsvDataset<T> {
pub async fn create(path: impl AsRef<Path>) -> Result<Self> {
let sink = FileSink::create(path.as_ref(), Columns::New).await?;
Ok(Self::from_sink(sink))
}
pub async fn append(path: impl AsRef<Path>) -> Result<Self> {
let sink = FileSink::append(path.as_ref(), |first| {
if first.is_empty() {
Columns::New
} else {
Columns::Existing(first.to_vec())
}
})
.await?;
Ok(Self::from_sink(sink))
}
const fn from_sink(sink: FileSink<Columns>) -> Self {
Self {
sink,
marker: PhantomData,
}
}
#[must_use]
pub fn path(&self) -> &Path {
self.sink.path()
}
}
fn encode<T: Serialize>(item: &T, columns: &Columns) -> Result<(Vec<u8>, Columns)> {
let serialize_error = |error: csv::Error| {
Error::new(ErrorKind::Dataset)
.with_message("failed to serialize an item as a CSV row")
.with_source(error)
};
let row = serialize(item, false).map_err(serialize_error)?;
let full = serialize(item, true).map_err(serialize_error)?;
let header = full[..full.len() - row.len()].to_vec();
let fields = count_fields(&row);
let mismatch = || {
Error::new(ErrorKind::Dataset)
.with_message("the item's columns do not match the rows before it")
};
let next = match columns {
Columns::New if header.is_empty() => Some(Columns::Fields(fields)),
Columns::New => return Ok((full, Columns::Header(header))),
Columns::Existing(first) if header.is_empty() => {
(count_fields(first) == fields).then_some(Columns::Fields(fields))
}
Columns::Existing(first) => (*first == header).then_some(Columns::Header(header)),
Columns::Header(expected) => (*expected == header).then(|| columns.clone()),
Columns::Fields(expected) => {
(header.is_empty() && *expected == fields).then(|| columns.clone())
}
};
next.map(|next| (row, next)).ok_or_else(mismatch)
}
fn serialize<T: Serialize>(item: &T, header: bool) -> csv::Result<Vec<u8>> {
let mut rows = csv::WriterBuilder::new()
.has_headers(header)
.buffer_capacity(256)
.from_writer(Vec::new());
rows.serialize(item)?;
rows.into_inner().map_err(|error| error.into_error().into())
}
fn count_fields(row: &[u8]) -> usize {
csv::ReaderBuilder::new()
.has_headers(false)
.from_reader(row)
.records()
.next()
.and_then(Result::ok)
.map_or(0, |record| record.len())
}
impl<T> Clone for CsvDataset<T> {
fn clone(&self) -> Self {
Self::from_sink(self.sink.clone())
}
}
impl<T> fmt::Debug for CsvDataset<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CsvDataset")
.field("path", &self.path())
.field("written", &self.sink.written())
.finish()
}
}
impl<T> Dataset<T> for CsvDataset<T>
where
T: Serialize + Send + 'static,
{
async fn write(&self, item: T) -> Result<()> {
self.sink.write(move |columns| encode(&item, columns)).await
}
fn read(&self) -> impl Future<Output = Result<Option<T>>> + Send {
ready(Ok(None))
}
fn size_hint(&self) -> Option<usize> {
None
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[derive(Serialize)]
struct Product {
name: &'static str,
price: Option<f64>,
}
#[derive(Serialize)]
struct Nested {
name: &'static str,
tags: Vec<&'static str>,
}
fn temp_path(name: &str) -> PathBuf {
let unique = format!("spire-{name}-{}.csv", std::process::id());
std::env::temp_dir().join(unique)
}
#[tokio::test]
async fn writes_a_header_then_one_row_per_item_and_appends() {
let path = temp_path("csv");
let products = CsvDataset::<Product>::create(&path).await.unwrap();
let lamp = Product {
name: "Lamp, desk",
price: Some(20.5),
};
products.write(lamp).await.unwrap();
let chair = Product {
name: "Chair",
price: None,
};
products.clone().write(chair).await.unwrap();
assert!(products.read().await.unwrap().is_none());
drop(products);
let resumed = CsvDataset::<Product>::append(&path).await.unwrap();
let shelf = Product {
name: "Shelf",
price: Some(7.0),
};
resumed.write(shelf).await.unwrap();
let contents = tokio::fs::read_to_string(&path).await.unwrap();
tokio::fs::remove_file(&path).await.unwrap();
assert_eq!(
contents,
"name,price\n\"Lamp, desk\",20.5\nChair,\nShelf,7.0\n"
);
}
#[tokio::test]
async fn appending_to_a_new_file_writes_a_header() {
let path = temp_path("csv-new");
let _ = tokio::fs::remove_file(&path).await;
let products = CsvDataset::<Product>::append(&path).await.unwrap();
let lamp = Product {
name: "Lamp",
price: Some(1.0),
};
products.write(lamp).await.unwrap();
let contents = tokio::fs::read_to_string(&path).await.unwrap();
tokio::fs::remove_file(&path).await.unwrap();
assert_eq!(contents, "name,price\nLamp,1.0\n");
}
#[tokio::test]
async fn nested_items_fail_without_corrupting_the_file() {
let path = temp_path("csv-nested");
let items = CsvDataset::<Nested>::create(&path).await.unwrap();
let nested = Nested {
name: "Lamp",
tags: vec!["new"],
};
let error = items.write(nested).await.unwrap_err();
assert_eq!(error.kind(), ErrorKind::Dataset);
let contents = tokio::fs::read_to_string(&path).await.unwrap();
tokio::fs::remove_file(&path).await.unwrap();
assert_eq!(contents, "");
}
#[tokio::test]
async fn nested_or_different_items_after_the_first_fail() {
let path = temp_path("csv-mixed");
let items = CsvDataset::<Nested>::create(&path).await.unwrap();
let flat = CsvDataset::<Product>::from_sink(items.sink.clone());
let lamp = Product {
name: "Lamp",
price: Some(1.0),
};
flat.write(lamp).await.unwrap();
let nested = Nested {
name: "Chair",
tags: vec!["new", "old"],
};
let error = items.write(nested).await.unwrap_err();
assert_eq!(error.kind(), ErrorKind::Dataset);
let tuples = CsvDataset::<(&str, u32)>::from_sink(items.sink.clone());
assert!(tuples.write(("Desk", 2)).await.is_err());
let contents = tokio::fs::read_to_string(&path).await.unwrap();
tokio::fs::remove_file(&path).await.unwrap();
assert_eq!(contents, "name,price\nLamp,1.0\n");
}
#[tokio::test]
async fn appending_checks_the_existing_header() {
let path = temp_path("csv-header");
tokio::fs::write(&path, "title,words\nHome,3\n")
.await
.unwrap();
let products = CsvDataset::<Product>::append(&path).await.unwrap();
let lamp = Product {
name: "Lamp",
price: None,
};
assert!(products.write(lamp).await.is_err());
drop(products);
let pages = CsvDataset::<(&str, u32)>::append(&path).await.unwrap();
pages.write(("About", 5)).await.unwrap();
let wider = CsvDataset::<(&str, u32, u32)>::from_sink(pages.sink.clone());
assert!(wider.write(("About", 5, 1)).await.is_err());
let contents = tokio::fs::read_to_string(&path).await.unwrap();
tokio::fs::remove_file(&path).await.unwrap();
assert_eq!(contents, "title,words\nHome,3\nAbout,5\n");
}
}