use chrono::{Datelike, NaiveDate, TimeDelta};
use fraiseql_db::{WhereClause, WhereOperator};
use crate::compiler::fact_table::TemporalGrain;
#[must_use]
pub fn is_period_aligned(date: NaiveDate, grain: TemporalGrain) -> bool {
match grain {
TemporalGrain::Day => true,
TemporalGrain::Week => date.weekday() == chrono::Weekday::Mon,
TemporalGrain::Month => date.day() == 1,
TemporalGrain::Quarter => date.day() == 1 && (date.month() - 1).is_multiple_of(3),
TemporalGrain::Year => date.ordinal() == 1,
}
}
#[must_use]
pub fn period_start(date: NaiveDate, grain: TemporalGrain) -> NaiveDate {
match grain {
TemporalGrain::Day => date,
TemporalGrain::Week => {
let days_since_monday = date.weekday().num_days_from_monday();
date - TimeDelta::days(i64::from(days_since_monday))
},
TemporalGrain::Month => NaiveDate::from_ymd_opt(date.year(), date.month(), 1)
.expect("day 1 of any month is valid"),
TemporalGrain::Quarter => {
let quarter_month = ((date.month() - 1) / 3) * 3 + 1;
NaiveDate::from_ymd_opt(date.year(), quarter_month, 1)
.expect("quarter start is always valid")
},
TemporalGrain::Year => {
NaiveDate::from_ymd_opt(date.year(), 1, 1).expect("Jan 1 is always valid")
},
}
}
#[must_use]
pub fn next_period_start(date: NaiveDate, grain: TemporalGrain) -> NaiveDate {
let start = period_start(date, grain);
match grain {
TemporalGrain::Day => start + TimeDelta::days(1),
TemporalGrain::Week => start + TimeDelta::weeks(1),
TemporalGrain::Month => {
if start.month() == 12 {
NaiveDate::from_ymd_opt(start.year() + 1, 1, 1).expect("next year Jan 1 is valid")
} else {
NaiveDate::from_ymd_opt(start.year(), start.month() + 1, 1)
.expect("next month day 1 is valid")
}
},
TemporalGrain::Quarter => {
if start.month() == 10 {
NaiveDate::from_ymd_opt(start.year() + 1, 1, 1)
.expect("next year Q1 start is valid")
} else {
NaiveDate::from_ymd_opt(start.year(), start.month() + 3, 1)
.expect("next quarter start is valid")
}
},
TemporalGrain::Year => {
NaiveDate::from_ymd_opt(start.year() + 1, 1, 1).expect("next year Jan 1 is valid")
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchPlan {
pub partial_leading: Option<(NaiveDate, NaiveDate)>,
pub complete_middle: Option<(NaiveDate, NaiveDate)>,
pub current_period: (NaiveDate, NaiveDate),
}
#[must_use]
pub fn determine_branches(
lower_bound: NaiveDate,
grain: TemporalGrain,
today: NaiveDate,
) -> BranchPlan {
let aligned = is_period_aligned(lower_bound, grain);
let next_ps = next_period_start(lower_bound, grain);
let current_ps = period_start(today, grain);
let b2_start = if aligned { lower_bound } else { next_ps };
let include_b2 = b2_start < current_ps;
let today_exclusive = today + TimeDelta::days(1);
BranchPlan {
partial_leading: if aligned {
None
} else {
Some((lower_bound, next_ps))
},
complete_middle: if include_b2 {
Some((b2_start, current_ps))
} else {
None
},
current_period: (current_ps, today_exclusive),
}
}
#[must_use]
pub fn should_use_partial_period<'a>(
metadata: &'a crate::compiler::fact_table::FactTableMetadata,
where_clause: Option<&WhereClause>,
today: NaiveDate,
) -> Option<(NaiveDate, &'a crate::compiler::fact_table::PartialPeriodConfig)> {
let config = metadata.partial_period.as_ref()?;
let wc = where_clause?;
let lower_bound = extract_lower_date_bound(wc, &config.time_grain_column)?;
let plan = determine_branches(lower_bound, config.time_grain_trunc, today);
if plan.partial_leading.is_none() && plan.complete_middle.is_none() {
return None;
}
Some((lower_bound, config))
}
#[derive(Debug, Clone, PartialEq)]
pub struct SplitWhereResult {
pub lower_bound: NaiveDate,
pub remaining: Option<WhereClause>,
}
#[must_use]
pub fn extract_lower_date_bound(
where_clause: &WhereClause,
column_name: &str,
) -> Option<NaiveDate> {
match where_clause {
WhereClause::Field {
path,
operator,
value,
} => extract_from_field(path, operator, value, column_name),
WhereClause::NativeField {
column,
operator,
value,
..
} => {
if column == column_name {
extract_date_from_operator(operator, value)
} else {
None
}
},
WhereClause::And(children) => {
for child in children {
if let Some(d) = extract_lower_date_bound(child, column_name) {
return Some(d);
}
}
None
},
_ => None,
}
}
#[must_use]
pub fn split_where_clause(
where_clause: &WhereClause,
column_name: &str,
) -> Option<SplitWhereResult> {
match where_clause {
WhereClause::Field {
path,
operator,
value,
} => {
let date = extract_from_field(path, operator, value, column_name)?;
Some(SplitWhereResult {
lower_bound: date,
remaining: None,
})
},
WhereClause::NativeField {
column,
operator,
value,
..
} => {
if column != column_name {
return None;
}
let date = extract_date_from_operator(operator, value)?;
Some(SplitWhereResult {
lower_bound: date,
remaining: None,
})
},
WhereClause::And(children) => {
let mut match_idx = None;
let mut matched_date = None;
for (i, child) in children.iter().enumerate() {
if let Some(d) = extract_lower_date_bound(child, column_name) {
match_idx = Some(i);
matched_date = Some(d);
break;
}
}
let idx = match_idx?;
let date = matched_date?;
let remaining: Vec<WhereClause> = children
.iter()
.enumerate()
.filter(|(i, _)| *i != idx)
.map(|(_, c)| c.clone())
.collect();
let remaining = match remaining.len() {
0 => None,
1 => remaining.into_iter().next(),
_ => Some(WhereClause::And(remaining)),
};
Some(SplitWhereResult {
lower_bound: date,
remaining,
})
},
_ => None,
}
}
fn extract_from_field(
path: &[String],
operator: &WhereOperator,
value: &serde_json::Value,
column_name: &str,
) -> Option<NaiveDate> {
if path.len() == 1 && path[0] == column_name {
extract_date_from_operator(operator, value)
} else {
None
}
}
fn extract_date_from_operator(
operator: &WhereOperator,
value: &serde_json::Value,
) -> Option<NaiveDate> {
let date_str = value.as_str()?;
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok()?;
match operator {
WhereOperator::Gte => Some(date),
WhereOperator::Gt => Some(date + TimeDelta::days(1)),
_ => None,
}
}
#[cfg(test)]
mod tests;