use crate::data::column::ColumnMeta;
use crate::data::dataframe::DataFrame;
use crate::types::ColumnType;
use polars::prelude::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowFn {
RowNumber,
Rank,
DenseRank,
CumSum,
Lag,
Lead,
Sum,
Avg,
Min,
Max,
Count,
PctOfTotal,
}
impl WindowFn {
pub fn parse(name: &str) -> Result<Self, String> {
Ok(match name {
"row_number" => Self::RowNumber,
"rank" => Self::Rank,
"dense_rank" => Self::DenseRank,
"cum_sum" => Self::CumSum,
"lag" => Self::Lag,
"lead" => Self::Lead,
"sum" => Self::Sum,
"avg" => Self::Avg,
"min" => Self::Min,
"max" => Self::Max,
"count" => Self::Count,
"pct_of_total" => Self::PctOfTotal,
other => {
return Err(format!(
"Unknown window function '{}'. Available: row_number, rank, dense_rank, \
cum_sum, lag, lead, sum, avg, min, max, count, pct_of_total",
other
))
}
})
}
pub fn all() -> &'static [WindowFn] {
&[
Self::RowNumber,
Self::Rank,
Self::DenseRank,
Self::CumSum,
Self::Lag,
Self::Lead,
Self::Sum,
Self::Avg,
Self::Min,
Self::Max,
Self::Count,
Self::PctOfTotal,
]
}
pub fn describe(self) -> &'static str {
match self {
Self::RowNumber => "position in the group",
Self::Rank => "rank by value, ties share and leave a gap",
Self::DenseRank => "rank by value, ties share, no gap",
Self::CumSum => "running total in the current order",
Self::Lag => "the previous row's value",
Self::Lead => "the next row's value",
Self::Sum => "the group's total on every row",
Self::Avg => "the group's mean on every row",
Self::Min => "the group's smallest on every row",
Self::Max => "the group's largest on every row",
Self::Count => "how many rows in the group",
Self::PctOfTotal => "this row's share of the group",
}
}
pub fn name(self) -> &'static str {
match self {
Self::RowNumber => "row_number",
Self::Rank => "rank",
Self::DenseRank => "dense_rank",
Self::CumSum => "cum_sum",
Self::Lag => "lag",
Self::Lead => "lead",
Self::Sum => "sum",
Self::Avg => "avg",
Self::Min => "min",
Self::Max => "max",
Self::Count => "count",
Self::PctOfTotal => "pct_of_total",
}
}
pub fn uses_order_by(self) -> bool {
matches!(
self,
Self::RowNumber | Self::CumSum | Self::Lag | Self::Lead
)
}
pub fn uses_direction(self) -> bool {
self.uses_order_by() || matches!(self, Self::Rank | Self::DenseRank)
}
fn needs_a_column(self) -> bool {
self != Self::RowNumber
}
fn needs_numbers(self) -> bool {
matches!(
self,
Self::CumSum | Self::Sum | Self::Avg | Self::PctOfTotal
)
}
fn output_type(self, source: Option<ColumnType>) -> ColumnType {
match self {
Self::RowNumber | Self::Rank | Self::DenseRank | Self::Count => ColumnType::Integer,
Self::PctOfTotal => ColumnType::Percentage,
Self::Avg => match source {
Some(ColumnType::Integer) | None => ColumnType::Float,
Some(other) => other,
},
_ => source.unwrap_or(ColumnType::Float),
}
}
}
pub struct Spec {
pub function: WindowFn,
pub col: Option<String>,
pub over: Vec<String>,
pub order_by: Vec<String>,
pub as_name: Option<String>,
pub desc: bool,
pub offset: i64,
}
impl Spec {
fn output_name(&self) -> String {
if let Some(name) = &self.as_name {
return name.clone();
}
match &self.col {
Some(col) => format!("{}:{}", col, self.function.name()),
None => self.function.name().to_string(),
}
}
}
pub fn add_window_column(df: &DataFrame, spec: &Spec) -> Result<DataFrame, String> {
let source_meta = match &spec.col {
Some(name) => Some(df.columns[df.column_index(name)?].clone()),
None => {
if spec.function.needs_a_column() {
return Err(format!("'{}' needs a column to read", spec.function.name()));
}
None
}
};
if spec.function.needs_numbers() {
if let Some(meta) = &source_meta {
let numeric = matches!(
meta.col_type,
ColumnType::Integer
| ColumnType::Float
| ColumnType::Percentage
| ColumnType::Currency
);
if !numeric {
return Err(format!(
"Cannot compute {} over '{}': the column is {}, and {} needs a numeric one",
spec.function.name(),
meta.name,
meta.col_type.name(),
spec.function.name()
));
}
}
}
for name in &spec.over {
df.column_index(name)?;
}
for name in &spec.order_by {
df.column_index(name)?;
}
if !spec.order_by.is_empty() && !spec.function.uses_order_by() {
return Err(format!(
"'{}' does not read the rows in order, so order_by would change nothing — \
drop it, or use one of row_number, cum_sum, lag, lead",
spec.function.name()
));
}
let name = spec.output_name();
if df.column_index(&name).is_ok() {
return Err(format!(
"'{}' already exists — give the new column a different name with 'as'",
name
));
}
let target = spec
.col
.as_deref()
.map(crate::data::column_expr)
.unwrap_or_else(|| lit(1));
let computed = match spec.function {
WindowFn::RowNumber => {
let anchor = df
.columns
.first()
.ok_or("row_number needs a table with at least one column")?;
crate::data::column_expr(&anchor.name)
.is_null()
.cum_count(false)
}
WindowFn::Rank => target.clone().rank(
RankOptions {
method: RankMethod::Min,
descending: spec.desc,
},
None,
),
WindowFn::DenseRank => target.clone().rank(
RankOptions {
method: RankMethod::Dense,
descending: spec.desc,
},
None,
),
WindowFn::CumSum => target.clone().cast(DataType::Float64).cum_sum(false),
WindowFn::Lag => target.clone().shift(lit(spec.offset)),
WindowFn::Lead => target.clone().shift(lit(-spec.offset)),
WindowFn::Sum => target.clone().sum(),
WindowFn::Avg => target.clone().mean(),
WindowFn::Min => target.clone().min(),
WindowFn::Max => target.clone().max(),
WindowFn::Count => target.clone().count(),
WindowFn::PctOfTotal => {
target.clone().cast(DataType::Float64) / target.clone().sum().cast(DataType::Float64)
}
};
let windowed = if spec.over.is_empty() {
computed
} else {
let partitions: Vec<Expr> = spec
.over
.iter()
.map(|s| crate::data::column_expr(s))
.collect();
computed.over(partitions)
}
.alias(name.as_str());
let visible = df.get_visible_df()?;
let mut out = if spec.order_by.is_empty() {
visible
.lazy()
.with_column(windowed)
.collect()
.map_err(|e| format!("window function failed: {}", e))?
} else {
let mut marker = "__tuitab_window_pos".to_string();
while visible.column(&marker).is_ok() {
marker.push('_');
}
let mut staged = visible;
let positions: Vec<u32> = (0..staged.height() as u32).collect();
staged
.with_column(Series::new(marker.as_str().into(), positions).into())
.map_err(|e| e.to_string())?;
let ordered = staged
.sort(
spec.order_by.clone(),
SortMultipleOptions::new()
.with_order_descending(spec.desc)
.with_nulls_last(true)
.with_maintain_order(true),
)
.map_err(|e| format!("ordering the window failed: {}", e))?;
ordered
.lazy()
.with_column(windowed)
.collect()
.map_err(|e| format!("window function failed: {}", e))?
.sort([marker.as_str()], SortMultipleOptions::new())
.map_err(|e| format!("restoring the row order failed: {}", e))?
};
let mut metas = df.columns.clone();
let insert_at = spec
.col
.as_deref()
.and_then(|c| df.column_index(c).ok())
.map(|i| i + 1)
.unwrap_or(metas.len());
let mut meta = ColumnMeta::new(name.clone());
meta.col_type = spec
.function
.output_type(source_meta.as_ref().map(|m| m.col_type));
if matches!(meta.col_type, ColumnType::Percentage) {
meta.precision = 2;
} else if let Some(source) = &source_meta {
meta.currency = source.currency;
meta.precision = source.precision;
}
metas.insert(insert_at, meta);
let order: Vec<String> = metas.iter().map(|m| m.name.clone()).collect();
out = out.select(order).map_err(|e| e.to_string())?;
let mut result = DataFrame::from_parts(out, metas);
let mut new_index = vec![usize::MAX; df.df.height()];
for (position, &physical) in df.row_order.iter().enumerate() {
new_index[physical] = position;
}
let translate = |physical: usize| {
new_index
.get(physical)
.copied()
.filter(|i| *i != usize::MAX)
};
result.original_order = std::sync::Arc::new(
df.original_order
.iter()
.filter_map(|p| translate(*p))
.collect(),
);
result.selected_rows = df
.selected_rows
.iter()
.filter_map(|p| translate(*p))
.collect();
result.modified = df.modified;
result.transposed = df.transposed;
result.calc_widths(40, 1000);
Ok(result)
}