use std::collections::HashMap;
use crate::core::data::{
BarDataset, ChordData, DataPoint, Dataset, GridData, SankeyData, SankeyLink, SankeyNode,
Series, StripGroup,
};
use crate::core::encoding::Encoding;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AggregateFunction {
Sum,
Avg,
Min,
Max,
Count,
}
#[derive(Debug, Clone, PartialEq)]
pub enum FieldValue {
Numeric(f64),
Text(String),
Timestamp(f64),
Bool(bool),
Null,
}
impl FieldValue {
pub fn as_f64(&self) -> Option<f64> {
match self {
Self::Numeric(v) => Some(*v),
Self::Timestamp(v) => Some(*v),
Self::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
Self::Text(_) | Self::Null => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Text(s) => Some(s.as_str()),
_ => None,
}
}
pub fn as_timestamp(&self) -> Option<f64> {
match self {
Self::Timestamp(v) | Self::Numeric(v) => Some(*v),
_ => None,
}
}
pub fn is_null(&self) -> bool {
matches!(self, Self::Null)
}
}
impl std::fmt::Display for FieldValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Numeric(v) => write!(f, "{}", v),
Self::Text(s) => write!(f, "{}", s),
Self::Timestamp(v) => write!(f, "{}", v),
Self::Bool(b) => write!(f, "{}", b),
Self::Null => write!(f, "null"),
}
}
}
impl From<f64> for FieldValue {
fn from(v: f64) -> Self {
Self::Numeric(v)
}
}
impl From<i64> for FieldValue {
fn from(v: i64) -> Self {
Self::Numeric(v as f64)
}
}
impl From<i32> for FieldValue {
fn from(v: i32) -> Self {
Self::Numeric(f64::from(v))
}
}
impl From<String> for FieldValue {
fn from(s: String) -> Self {
Self::Text(s)
}
}
impl From<&str> for FieldValue {
fn from(s: &str) -> Self {
Self::Text(s.to_owned())
}
}
impl From<bool> for FieldValue {
fn from(b: bool) -> Self {
Self::Bool(b)
}
}
pub type DataRow = HashMap<String, FieldValue>;
#[derive(Debug, Clone, Default)]
pub struct DataTable {
rows: Vec<DataRow>,
}
impl DataTable {
pub fn new(rows: Vec<DataRow>) -> Self {
Self { rows }
}
pub fn from_rows(rows: Vec<DataRow>) -> Self {
Self::new(rows)
}
pub fn push(&mut self, row: DataRow) {
self.rows.push(row);
}
pub fn len(&self) -> usize {
self.rows.len()
}
pub fn is_empty(&self) -> bool {
self.rows.is_empty()
}
pub fn rows(&self) -> &[DataRow] {
&self.rows
}
pub fn extract_numeric(&self, col: &str) -> Vec<f64> {
self.rows
.iter()
.filter_map(|row| row.get(col)?.as_f64())
.collect()
}
pub fn extract_text(&self, col: &str) -> Vec<String> {
self.rows
.iter()
.filter_map(|row| row.get(col)?.as_str().map(ToOwned::to_owned))
.collect()
}
pub fn aggregate(&self, col: &str, agg_fn: AggregateFunction) -> Option<f64> {
let values = self.extract_numeric(col);
if values.is_empty() {
return None;
}
match agg_fn {
AggregateFunction::Sum => Some(values.iter().sum()),
AggregateFunction::Avg => {
let sum: f64 = values.iter().sum();
Some(sum / values.len() as f64)
}
AggregateFunction::Min => values.iter().copied().fold(f64::INFINITY, f64::min).into(),
AggregateFunction::Max => values
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max)
.into(),
AggregateFunction::Count => Some(values.len() as f64),
}
}
pub fn add_calculated_column(
&mut self,
name: impl Into<String>,
formula: &str,
) -> Result<(), String> {
use crate::core::formulas::eval_formula;
let col_name = name.into();
if !self.rows.is_empty() && self.rows[0].contains_key(&col_name) {
return Err(format!("Column '{}' already exists", col_name));
}
for row in &mut self.rows {
let value =
eval_formula(formula, row).map_err(|e| format!("Formula error in row: {}", e))?;
row.insert(col_name.clone(), value);
}
Ok(())
}
pub fn group_by(&self, col: &str) -> Vec<(String, Self)> {
let mut order: Vec<String> = Vec::new();
let mut groups: HashMap<String, Vec<DataRow>> = HashMap::new();
for row in &self.rows {
let key = match row.get(col) {
Some(FieldValue::Text(s)) => s.clone(),
Some(FieldValue::Numeric(v)) => v.to_string(),
Some(FieldValue::Timestamp(v)) => v.to_string(),
Some(FieldValue::Bool(b)) => b.to_string(),
Some(FieldValue::Null) | None => String::from("__null__"),
};
if !groups.contains_key(&key) {
order.push(key.clone());
}
groups.entry(key).or_default().push(row.clone());
}
order
.into_iter()
.map(|key| {
let rows = groups.remove(&key).unwrap_or_default();
(key, Self::new(rows))
})
.collect()
}
pub fn to_dataset(&self, encoding: &Encoding) -> Dataset {
let validation = self.validate_for_encoding(encoding);
if !validation.is_valid {
for error in &validation.errors {
log::error!("Data validation failed: {}", error);
}
return Dataset::new();
}
for warning in &validation.warnings {
log::warn!("Data validation warning: {}", warning);
}
let color_col = encoding.color.as_ref().map(|f| f.name.as_str());
if let Some(col) = color_col {
let groups = self.group_by(col);
let mut dataset = Dataset::new();
for (name, sub) in groups {
let points = sub.extract_xy(&encoding.x.name, &encoding.y.name);
dataset.add_series(Series::new(name, points));
}
dataset
} else {
let points = self.extract_xy(&encoding.x.name, &encoding.y.name);
Dataset::from_series(Series::new("default", points))
}
}
pub fn to_bar_dataset(&self, encoding: &Encoding) -> BarDataset {
let cat_col = &encoding.x.name;
let val_col = &encoding.y.name;
let series_col = encoding.color.as_ref().map(|f| f.name.as_str());
let categories: Vec<String> = {
let mut seen = std::collections::HashSet::new();
let mut ordered = Vec::new();
for row in &self.rows {
if let Some(FieldValue::Text(s)) = row.get(cat_col.as_str()) {
if seen.insert(s.clone()) {
ordered.push(s.clone());
}
}
}
ordered
};
let mut bar_dataset = BarDataset::new(categories.clone());
if let Some(scol) = series_col {
let groups = self.group_by(scol);
for (name, sub) in groups {
let values: Vec<f64> = categories
.iter()
.map(|cat| {
sub.rows
.iter()
.find(|row| {
row.get(cat_col.as_str())
.and_then(FieldValue::as_str)
.map(|s| s == cat.as_str())
.unwrap_or(false)
})
.and_then(|row| row.get(val_col.as_str())?.as_f64())
.unwrap_or(0.0)
})
.collect();
bar_dataset.add_series(name, values);
}
} else {
let values: Vec<f64> = categories
.iter()
.map(|cat| {
self.rows
.iter()
.find(|row| {
row.get(cat_col.as_str())
.and_then(FieldValue::as_str)
.map(|s| s == cat.as_str())
.unwrap_or(false)
})
.and_then(|row| row.get(val_col.as_str())?.as_f64())
.unwrap_or(0.0)
})
.collect();
bar_dataset.add_series("default", values);
}
bar_dataset
}
pub fn to_strip_groups(&self, group_col: &str, value_col: &str) -> Vec<StripGroup> {
self.group_by(group_col)
.into_iter()
.map(|(name, sub)| StripGroup {
name,
values: sub.extract_numeric(value_col),
})
.collect()
}
pub fn to_sankey(
&self,
source_col: &str,
target_col: &str,
value_col: &str,
color_col: Option<&str>,
) -> SankeyData {
let mut label_index: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut nodes: Vec<SankeyNode> = Vec::new();
let mut links: Vec<SankeyLink> = Vec::new();
for row in &self.rows {
let Some(src_label) = row
.get(source_col)
.and_then(FieldValue::as_str)
.map(str::to_owned)
else {
continue;
};
let Some(dst_label) = row
.get(target_col)
.and_then(FieldValue::as_str)
.map(str::to_owned)
else {
continue;
};
let Some(value) = row.get(value_col).and_then(FieldValue::as_f64) else {
continue;
};
let color = color_col
.and_then(|col| row.get(col))
.and_then(FieldValue::as_str)
.map(str::to_owned);
let source = *label_index.entry(src_label.clone()).or_insert_with(|| {
let idx = nodes.len();
nodes.push(SankeyNode {
label: src_label,
color: None,
});
idx
});
let target = *label_index.entry(dst_label.clone()).or_insert_with(|| {
let idx = nodes.len();
nodes.push(SankeyNode {
label: dst_label,
color: None,
});
idx
});
links.push(SankeyLink {
source,
target,
value,
color,
});
}
SankeyData { nodes, links }
}
pub fn to_chord_matrix(&self, label_col: &str, value_cols: &[&str]) -> ChordData {
let labels: Vec<String> = self
.rows
.iter()
.filter_map(|row| row.get(label_col)?.as_str().map(str::to_owned))
.collect();
let matrix: Vec<Vec<f64>> = self
.rows
.iter()
.map(|row| {
value_cols
.iter()
.map(|col| row.get(*col).and_then(FieldValue::as_f64).unwrap_or(0.0))
.collect()
})
.collect();
ChordData {
matrix,
labels,
colors: None,
}
}
pub fn to_grid_wide(&self, label_col: Option<&str>, value_cols: &[&str]) -> GridData {
let row_labels = label_col.map(|col| {
self.rows
.iter()
.filter_map(|row| row.get(col)?.as_str().map(str::to_owned))
.collect()
});
let values: Vec<Vec<f64>> = self
.rows
.iter()
.map(|row| {
value_cols
.iter()
.map(|col| row.get(*col).and_then(FieldValue::as_f64).unwrap_or(0.0))
.collect()
})
.collect();
let col_labels = Some(value_cols.iter().map(|s| (*s).to_owned()).collect());
GridData {
values,
row_labels,
col_labels,
}
}
pub fn to_grid_long(
&self,
row_col: &str,
col_col: &str,
value_col: &str,
fill_value: f64,
) -> GridData {
let mut n_rows = 0usize;
let mut n_cols = 0usize;
let mut cells: Vec<(usize, usize, f64)> = Vec::new();
for row in &self.rows {
let Some(r) = row.get(row_col).and_then(FieldValue::as_f64) else {
continue;
};
let Some(c) = row.get(col_col).and_then(FieldValue::as_f64) else {
continue;
};
let Some(v) = row.get(value_col).and_then(FieldValue::as_f64) else {
continue;
};
let (ri, ci) = (r as usize, c as usize);
n_rows = n_rows.max(ri + 1);
n_cols = n_cols.max(ci + 1);
cells.push((ri, ci, v));
}
let mut values = vec![vec![fill_value; n_cols]; n_rows];
for (ri, ci, v) in cells {
values[ri][ci] = v;
}
GridData {
values,
row_labels: None,
col_labels: None,
}
}
fn extract_xy(&self, x_col: &str, y_col: &str) -> Vec<DataPoint> {
let mut success_count = 0;
let mut missing_x = 0;
let mut missing_y = 0;
let mut non_numeric_x = 0;
let mut non_numeric_y = 0;
let points: Vec<DataPoint> = self
.rows
.iter()
.filter_map(|row| {
let x_val = match row.get(x_col) {
Some(val) => val,
None => {
missing_x += 1;
return None;
}
};
let y_val = match row.get(y_col) {
Some(val) => val,
None => {
missing_y += 1;
return None;
}
};
let x = match x_val.as_f64() {
Some(n) => n,
None => {
non_numeric_x += 1;
return None;
}
};
let y = match y_val.as_f64() {
Some(n) => n,
None => {
non_numeric_y += 1;
return None;
}
};
success_count += 1;
Some(DataPoint::new(x, y))
})
.collect();
if points.is_empty() && !self.rows.is_empty() {
log::warn!(
"extract_xy returned 0 points from {} rows. X column: '{}', Y column: '{}'. \
Missing X: {}, Missing Y: {}, Non-numeric X: {}, Non-numeric Y: {}",
self.rows.len(),
x_col,
y_col,
missing_x,
missing_y,
non_numeric_x,
non_numeric_y
);
} else if success_count < self.rows.len() {
log::debug!(
"extract_xy: {}/{} rows extracted successfully for X='{}', Y='{}'",
success_count,
self.rows.len(),
x_col,
y_col
);
}
points
}
}
#[macro_export]
macro_rules! data_row {
($($key:expr => $val:expr),* $(,)?) => {{
let mut row = $crate::core::field_value::DataRow::new();
$(
row.insert($key.to_owned(), $crate::core::field_value::FieldValue::from($val));
)*
row
}};
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::encoding::{Encoding, Field};
fn make_table() -> DataTable {
let mut t = DataTable::default();
for (x, y, cat) in [
(1.0_f64, 10.0_f64, "A"),
(2.0, 20.0, "B"),
(3.0, 30.0, "A"),
(4.0, 40.0, "B"),
] {
let mut row = DataRow::new();
row.insert("x".into(), FieldValue::Numeric(x));
row.insert("y".into(), FieldValue::Numeric(y));
row.insert("cat".into(), FieldValue::Text(cat.into()));
t.push(row);
}
t
}
#[test]
fn test_field_value_as_f64() {
assert_eq!(FieldValue::Numeric(42.5).as_f64(), Some(42.5));
assert_eq!(FieldValue::Timestamp(1000.0).as_f64(), Some(1000.0));
assert_eq!(FieldValue::Bool(true).as_f64(), Some(1.0));
assert_eq!(FieldValue::Bool(false).as_f64(), Some(0.0));
assert_eq!(FieldValue::Text("hi".into()).as_f64(), None);
assert_eq!(FieldValue::Null.as_f64(), None);
}
#[test]
fn test_field_value_as_str() {
assert_eq!(FieldValue::Text("hello".into()).as_str(), Some("hello"));
assert_eq!(FieldValue::Numeric(1.0).as_str(), None);
}
#[test]
fn test_extract_numeric() {
let table = make_table();
let xs = table.extract_numeric("x");
assert_eq!(xs, vec![1.0, 2.0, 3.0, 4.0]);
}
#[test]
fn test_extract_text() {
let table = make_table();
let cats = table.extract_text("cat");
assert_eq!(cats, vec!["A", "B", "A", "B"]);
}
#[test]
fn test_group_by() {
let table = make_table();
let groups = table.group_by("cat");
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].0, "A");
assert_eq!(groups[0].1.len(), 2);
assert_eq!(groups[1].0, "B");
assert_eq!(groups[1].1.len(), 2);
}
#[test]
fn test_to_dataset_no_color() {
let table = make_table();
let enc = Encoding::new(Field::quantitative("x"), Field::quantitative("y"));
let dataset = table.to_dataset(&enc);
assert_eq!(dataset.series.len(), 1);
assert_eq!(dataset.series[0].data.len(), 4);
assert_eq!(dataset.series[0].data[0], DataPoint::new(1.0, 10.0));
}
#[test]
fn test_to_dataset_with_color() {
let table = make_table();
let enc = Encoding::new(Field::quantitative("x"), Field::quantitative("y"))
.with_color(Field::nominal("cat"));
let dataset = table.to_dataset(&enc);
assert_eq!(dataset.series.len(), 2);
assert_eq!(dataset.series[0].name, "A");
assert_eq!(dataset.series[0].data.len(), 2);
assert_eq!(dataset.series[1].name, "B");
assert_eq!(dataset.series[1].data.len(), 2);
}
#[test]
fn test_to_bar_dataset() {
let mut t = DataTable::default();
for (cat, val) in [("Q1", 100.0_f64), ("Q2", 150.0), ("Q3", 120.0)] {
let mut row = DataRow::new();
row.insert("period".into(), FieldValue::Text(cat.into()));
row.insert("revenue".into(), FieldValue::Numeric(val));
t.push(row);
}
let enc = Encoding::new(Field::nominal("period"), Field::quantitative("revenue"));
let bd = t.to_bar_dataset(&enc);
assert_eq!(bd.categories, vec!["Q1", "Q2", "Q3"]);
assert_eq!(bd.series.len(), 1);
assert_eq!(bd.series[0].values, vec![100.0, 150.0, 120.0]);
}
#[test]
fn test_to_bar_dataset_multi_series() {
let mut t = DataTable::default();
for (cat, val, product) in [
("Q1", 100.0_f64, "A"),
("Q1", 80.0, "B"),
("Q2", 120.0, "A"),
("Q2", 90.0, "B"),
] {
let mut row = DataRow::new();
row.insert("period".into(), FieldValue::Text(cat.into()));
row.insert("revenue".into(), FieldValue::Numeric(val));
row.insert("product".into(), FieldValue::Text(product.into()));
t.push(row);
}
let enc = Encoding::new(Field::nominal("period"), Field::quantitative("revenue"))
.with_color(Field::nominal("product"));
let bd = t.to_bar_dataset(&enc);
assert_eq!(bd.categories, vec!["Q1", "Q2"]);
assert_eq!(bd.series.len(), 2);
let series_a = bd.series.iter().find(|s| s.name == "A").expect("series A");
assert_eq!(series_a.values, vec![100.0, 120.0]);
}
#[test]
fn test_from_rows_macro() {
let row = crate::data_row! {
"x" => 1.0_f64,
"label" => "hello",
"flag" => true,
};
assert_eq!(row.get("x"), Some(&FieldValue::Numeric(1.0)));
assert_eq!(row.get("label"), Some(&FieldValue::Text("hello".into())));
assert_eq!(row.get("flag"), Some(&FieldValue::Bool(true)));
}
#[test]
fn test_empty_table() {
let t = DataTable::default();
assert!(t.is_empty());
assert_eq!(t.len(), 0);
let enc = Encoding::new(Field::quantitative("x"), Field::quantitative("y"));
let ds = t.to_dataset(&enc);
assert!(ds.series.is_empty() || ds.series[0].data.is_empty());
}
fn make_strip_table() -> DataTable {
let mut t = DataTable::default();
for (group, value) in [("A", 1.0), ("A", 2.0), ("B", 3.0), ("B", 4.0), ("B", 5.0)] {
let mut row = DataRow::new();
row.insert("group".into(), FieldValue::Text(group.into()));
row.insert("value".into(), FieldValue::Numeric(value));
t.push(row);
}
t
}
#[test]
fn test_to_strip_groups_count() {
let t = make_strip_table();
let groups = t.to_strip_groups("group", "value");
assert_eq!(groups.len(), 2);
}
#[test]
fn test_to_strip_groups_values() {
let t = make_strip_table();
let groups = t.to_strip_groups("group", "value");
let a = groups.iter().find(|g| g.name == "A").unwrap();
assert_eq!(a.values, vec![1.0, 2.0]);
let b = groups.iter().find(|g| g.name == "B").unwrap();
assert_eq!(b.values, vec![3.0, 4.0, 5.0]);
}
#[test]
fn test_to_sankey_infers_nodes() {
let mut t = DataTable::default();
for (src, dst, val) in [("Coal", "Electricity", 40.0), ("Gas", "Electricity", 30.0)] {
let mut row = DataRow::new();
row.insert("src".into(), FieldValue::Text(src.into()));
row.insert("dst".into(), FieldValue::Text(dst.into()));
row.insert("val".into(), FieldValue::Numeric(val));
t.push(row);
}
let sankey = t.to_sankey("src", "dst", "val", None);
assert_eq!(sankey.nodes.len(), 3);
assert_eq!(sankey.links.len(), 2);
assert_eq!(sankey.links[0].value, 40.0);
}
#[test]
fn test_to_sankey_link_indices() {
let mut t = DataTable::default();
let mut row = DataRow::new();
row.insert("src".into(), FieldValue::Text("A".into()));
row.insert("dst".into(), FieldValue::Text("B".into()));
row.insert("val".into(), FieldValue::Numeric(10.0));
t.push(row);
let sankey = t.to_sankey("src", "dst", "val", None);
assert_eq!(sankey.links[0].source, 0);
assert_eq!(sankey.links[0].target, 1);
}
#[test]
fn test_to_chord_matrix_shape() {
let mut t = DataTable::default();
for (label, v0, v1) in [("X", 0.0, 5.0), ("Y", 3.0, 0.0)] {
let mut row = DataRow::new();
row.insert("label".into(), FieldValue::Text(label.into()));
row.insert("X".into(), FieldValue::Numeric(v0));
row.insert("Y".into(), FieldValue::Numeric(v1));
t.push(row);
}
let chord = t.to_chord_matrix("label", &["X", "Y"]);
assert_eq!(chord.labels, vec!["X", "Y"]);
assert_eq!(chord.matrix.len(), 2);
assert_eq!(chord.matrix[0], vec![0.0, 5.0]);
assert_eq!(chord.matrix[1], vec![3.0, 0.0]);
}
#[test]
fn test_to_grid_wide_shape() {
let mut t = DataTable::default();
for (name, a, b) in [("R1", 1.0, 2.0), ("R2", 3.0, 4.0)] {
let mut row = DataRow::new();
row.insert("name".into(), FieldValue::Text(name.into()));
row.insert("A".into(), FieldValue::Numeric(a));
row.insert("B".into(), FieldValue::Numeric(b));
t.push(row);
}
let grid = t.to_grid_wide(Some("name"), &["A", "B"]);
assert_eq!(grid.values.len(), 2);
assert_eq!(grid.values[0], vec![1.0, 2.0]);
assert_eq!(
grid.row_labels,
Some(vec!["R1".to_owned(), "R2".to_owned()])
);
assert_eq!(grid.col_labels, Some(vec!["A".to_owned(), "B".to_owned()]));
}
#[test]
fn test_to_grid_long_fills() {
let mut t = DataTable::default();
for (r, c, v) in [(0.0, 0.0, 1.0), (1.0, 1.0, 2.0)] {
let mut row = DataRow::new();
row.insert("row".into(), FieldValue::Numeric(r));
row.insert("col".into(), FieldValue::Numeric(c));
row.insert("val".into(), FieldValue::Numeric(v));
t.push(row);
}
let grid = t.to_grid_long("row", "col", "val", 0.0);
assert_eq!(grid.values.len(), 2);
assert_eq!(grid.values[0], vec![1.0, 0.0]); assert_eq!(grid.values[1], vec![0.0, 2.0]); }
#[test]
fn test_add_calculated_column() {
let mut table = DataTable::default();
for i in 0..3 {
let mut row = DataRow::new();
row.insert("price".into(), FieldValue::Numeric((i + 1) as f64 * 10.0));
row.insert("quantity".into(), FieldValue::Numeric((i + 1) as f64));
table.push(row);
}
let result = table.add_calculated_column("total", "price * quantity");
assert!(result.is_ok());
assert_eq!(
table.rows()[0].get("total").unwrap().as_f64().unwrap(),
10.0
); assert_eq!(
table.rows()[1].get("total").unwrap().as_f64().unwrap(),
40.0
); assert_eq!(
table.rows()[2].get("total").unwrap().as_f64().unwrap(),
90.0
); }
#[test]
fn test_add_calculated_column_string() {
let mut table = DataTable::default();
let mut row = DataRow::new();
row.insert("first".into(), FieldValue::Text("John".into()));
row.insert("last".into(), FieldValue::Text("Doe".into()));
table.push(row);
let result = table.add_calculated_column("full_name", "first + \" \" + last");
assert!(result.is_ok());
assert_eq!(
table.rows()[0].get("full_name").unwrap().as_str().unwrap(),
"John Doe"
);
}
#[test]
fn test_add_calculated_column_conditional() {
let mut table = DataTable::default();
for val in [5.0, 15.0, 25.0] {
let mut row = DataRow::new();
row.insert("value".into(), FieldValue::Numeric(val));
table.push(row);
}
let result = table.add_calculated_column("tier", "if(value > 10, \"High\", \"Low\")");
assert!(result.is_ok());
assert_eq!(
table.rows()[0].get("tier").unwrap().as_str().unwrap(),
"Low"
);
assert_eq!(
table.rows()[1].get("tier").unwrap().as_str().unwrap(),
"High"
);
assert_eq!(
table.rows()[2].get("tier").unwrap().as_str().unwrap(),
"High"
);
}
#[test]
fn test_add_calculated_column_error_duplicate() {
let mut table = DataTable::default();
let mut row = DataRow::new();
row.insert("value".into(), FieldValue::Numeric(10.0));
table.push(row);
let result = table.add_calculated_column("value", "value * 2");
assert!(result.is_err());
assert!(result.unwrap_err().contains("already exists"));
}
#[test]
fn test_add_calculated_column_error_invalid_formula() {
let mut table = DataTable::default();
let mut row = DataRow::new();
row.insert("value".into(), FieldValue::Numeric(10.0));
table.push(row);
let result = table.add_calculated_column("result", "value * *");
assert!(result.is_err());
}
}