use serde::{Deserialize, Serialize};
use crate::{
backend::where_clause::{WhereClause, WhereOperator},
compiler::{
aggregate_types::{AggregateFunction, TemporalBucket},
aggregation::{AggregateSelection, AggregationRequest, GroupBySelection},
fact_table::{Additivity, FactTableMetadata, SemiAdditiveReduction, SqlType},
window_functions::{WindowFunctionSpec, WindowRequest},
},
error::{FraiseQLError, Result},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TimeReduction {
pub over: String,
pub over_date: bool,
pub entity: Vec<String>,
pub bucket: TemporalBucket,
pub bucket_alias: String,
pub reduction: Reduction,
pub measures: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Reduction {
Last,
First,
Avg,
Min,
Max,
Delta,
}
impl Reduction {
#[must_use]
pub const fn carries_forward(self) -> bool {
match self {
Self::Last | Self::First => true,
Self::Avg | Self::Min | Self::Max | Self::Delta => false,
}
}
#[must_use]
pub const fn of(additivity: &Additivity) -> Option<Self> {
match additivity {
Additivity::SemiAdditive { using, .. } => Some(match using {
SemiAdditiveReduction::Last => Self::Last,
SemiAdditiveReduction::First => Self::First,
SemiAdditiveReduction::Avg => Self::Avg,
SemiAdditiveReduction::Min => Self::Min,
SemiAdditiveReduction::Max => Self::Max,
}),
Additivity::Delta { .. } => Some(Self::Delta),
Additivity::Additive | Additivity::NonAdditive => None,
}
}
}
fn additivity_of<'a>(metadata: &'a FactTableMetadata, name: &str) -> &'a Additivity {
const ADDITIVE: &Additivity = &Additivity::Additive;
metadata
.measures
.iter()
.find(|m| m.name == name)
.map_or(ADDITIVE, |m| &m.additivity)
}
const fn refusal(message: String) -> FraiseQLError {
FraiseQLError::Validation {
message,
path: None,
}
}
fn measure_read(selection: &AggregateSelection) -> Option<&str> {
match selection {
AggregateSelection::MeasureAggregate { measure, .. } => Some(measure),
AggregateSelection::CountDistinct { field, .. } => Some(field),
AggregateSelection::Count { .. } | AggregateSelection::BoolAggregate { .. } => None,
}
}
pub fn plan_time_reduction(
request: &AggregationRequest,
metadata: &FactTableMetadata,
) -> Result<Option<TimeReduction>> {
let selections: Vec<&AggregateSelection> = request
.aggregates
.iter()
.chain(request.having.iter().map(|h| &h.aggregate))
.collect();
let mut declared: Option<(&str, &Additivity)> = None;
for selection in &selections {
let Some(measure) = measure_read(selection) else {
continue;
};
let additivity = additivity_of(metadata, measure);
match additivity {
Additivity::Additive => {},
Additivity::NonAdditive => {
return Err(refusal(format!(
"measure `{measure}` of fact table `{}` is declared non-additive: no \
aggregate over it is meaningful",
metadata.table_name
)));
},
Additivity::SemiAdditive { .. } | Additivity::Delta { .. } => {
declared.get_or_insert((measure, additivity));
},
}
}
let Some((measure, additivity)) = declared else {
return Ok(None);
};
let (
Some(reduction),
Additivity::SemiAdditive { over, entity, .. } | Additivity::Delta { over, entity },
) = (Reduction::of(additivity), additivity)
else {
return Ok(None);
};
let mut measures: Vec<String> = Vec::new();
for selection in &selections {
let reduced = match selection {
AggregateSelection::MeasureAggregate {
measure: name,
function,
..
} => {
if additivity_of(metadata, name) != additivity {
return Err(refusal(format!(
"`{name}` cannot be aggregated with `{measure}`, which is reduced per \
`{}` and per bucket of `{over}` first; request it in a separate \
aggregate",
entity.join("`, `")
)));
}
if matches!(
function,
AggregateFunction::ArrayAgg
| AggregateFunction::JsonAgg
| AggregateFunction::JsonbAgg
| AggregateFunction::StringAgg
) {
return Err(refusal(format!(
"`{measure}` is reduced per bucket of `{over}`; {function:?} over it is \
not supported"
)));
}
name
},
other => {
return Err(refusal(format!(
"`{}` cannot be combined with `{measure}`, which is reduced per `{}` and \
per bucket of `{over}` first; request it in a separate aggregate",
other.alias(),
entity.join("`, `")
)));
},
};
if !measures.iter().any(|m| m == reduced) {
measures.push(reduced.clone());
}
}
let mut temporal = request.group_by.iter().filter(|g| {
matches!(
g,
GroupBySelection::TemporalBucket { .. } | GroupBySelection::CalendarDimension { .. }
)
});
let (bucket, bucket_alias) = match (temporal.next(), temporal.next()) {
(
Some(GroupBySelection::TemporalBucket {
column,
bucket,
alias,
}),
None,
) if column == over => (*bucket, alias.clone()),
_ => {
return Err(refusal(format!(
"`{measure}` is reduced per bucket of `{over}`: group by exactly one time \
bucket of `{over}` (a day, week, month, quarter or year of it)"
)));
},
};
let over_date = metadata
.denormalized_filters
.iter()
.find(|f| f.name == *over)
.is_some_and(|f| f.sql_type == SqlType::Date);
if reduction.carries_forward() {
if let Some(clause) = &request.where_clause {
time_bounds(clause, over).ok_or_else(|| {
refusal(format!(
"`{measure}` carries each `{}`'s last value forward over `{over}`: filter \
`{over}` only by comparisons (eq, gt, gte, lt, lte) joined by and, never \
under or / not",
entity.join("`, `")
))
})?;
}
}
Ok(Some(TimeReduction {
over: over.clone(),
over_date,
entity: entity.clone(),
bucket,
bucket_alias,
reduction,
measures,
}))
}
#[derive(Debug, Clone, PartialEq)]
pub struct TimeBound<'a> {
pub operator: &'a WhereOperator,
pub value: &'a serde_json::Value,
}
#[derive(Debug, Default)]
pub struct SplitFilter<'a> {
pub bounds: Vec<TimeBound<'a>>,
pub rest: Vec<&'a WhereClause>,
}
#[must_use]
pub fn time_bounds<'a>(clause: &'a WhereClause, over: &str) -> Option<SplitFilter<'a>> {
let mut split = SplitFilter::default();
let mut conjuncts = vec![clause];
while let Some(conjunct) = conjuncts.pop() {
match conjunct {
WhereClause::And(clauses) => conjuncts.extend(clauses.iter()),
WhereClause::Typed { inner, .. } => conjuncts.push(inner),
WhereClause::NativeField {
column,
operator,
value,
..
} if column == over => split.bounds.push(bound(operator, value)?),
WhereClause::Field {
path,
operator,
value,
} if matches!(path.as_slice(), [only] if only == over) => {
split.bounds.push(bound(operator, value)?);
},
other if mentions(other, over) => return None,
other => split.rest.push(other),
}
}
Some(split)
}
fn bound<'a>(operator: &'a WhereOperator, value: &'a serde_json::Value) -> Option<TimeBound<'a>> {
match operator {
WhereOperator::Eq
| WhereOperator::Gt
| WhereOperator::Gte
| WhereOperator::Lt
| WhereOperator::Lte
if !value.is_null() =>
{
Some(TimeBound { operator, value })
},
_ => None,
}
}
fn mentions(clause: &WhereClause, over: &str) -> bool {
match clause {
WhereClause::Field { path, .. } => path.first().is_some_and(|p| p == over),
WhereClause::NativeField { column, .. } => column == over,
WhereClause::And(clauses) | WhereClause::Or(clauses) => {
clauses.iter().any(|c| mentions(c, over))
},
WhereClause::Not(inner)
| WhereClause::Typed { inner, .. }
| WhereClause::InHierarchy { inner, .. }
| WhereClause::Localized { inner, .. } => mentions(inner, over),
WhereClause::Guarded { guard, inner, .. } => mentions(guard, over) || mentions(inner, over),
_ => true,
}
}
pub fn refuse_window_over_non_additive(
request: &WindowRequest,
metadata: &FactTableMetadata,
) -> Result<()> {
for window in &request.windows {
let measure = match &window.function {
WindowFunctionSpec::RunningSum { measure }
| WindowFunctionSpec::RunningAvg { measure }
| WindowFunctionSpec::RunningMin { measure }
| WindowFunctionSpec::RunningMax { measure }
| WindowFunctionSpec::RunningStddev { measure }
| WindowFunctionSpec::RunningVariance { measure } => measure,
WindowFunctionSpec::RowNumber
| WindowFunctionSpec::Rank
| WindowFunctionSpec::DenseRank
| WindowFunctionSpec::Ntile { .. }
| WindowFunctionSpec::PercentRank
| WindowFunctionSpec::CumeDist
| WindowFunctionSpec::Lag { .. }
| WindowFunctionSpec::Lead { .. }
| WindowFunctionSpec::FirstValue { .. }
| WindowFunctionSpec::LastValue { .. }
| WindowFunctionSpec::NthValue { .. }
| WindowFunctionSpec::RunningCount
| WindowFunctionSpec::RunningCountField { .. } => continue,
};
if *additivity_of(metadata, measure) != Additivity::Additive {
return Err(refusal(format!(
"measure `{measure}` of fact table `{}` is not additive over time: a window \
aggregate runs over fact rows and would combine it across them; use the \
aggregate query, which reduces it per entity and per bucket first",
metadata.table_name
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests;