Skip to main content

fraiseql_core/runtime/partial_period/
mod.rs

1//! Partial-period awareness for pre-aggregated time-series views.
2//!
3//! When a date filter is applied to a coarse-grain view (e.g. monthly aggregates),
4//! the lower-bound date may fall in the middle of a period. This module provides
5//! helpers to detect that situation and build UNION ALL queries that combine:
6//!
7//! - **Branch 1**: fine-grain rows for the partial leading period (when present)
8//! - **Branch 2**: coarse-grain rows for complete intermediate periods
9//! - **Branch 3**: fine-grain rows for the current in-progress period
10//!
11//! All period arithmetic functions are pure (no database calls, no side effects).
12
13use chrono::{Datelike, NaiveDate, TimeDelta};
14use fraiseql_db::{WhereClause, WhereOperator};
15
16use crate::compiler::fact_table::TemporalGrain;
17
18/// Determines whether a date falls exactly on a period boundary.
19///
20/// Period boundaries:
21/// - **Day**: every date is day-aligned
22/// - **Week**: Monday only (ISO week start)
23/// - **Month**: first day of month
24/// - **Quarter**: first day of a quarter month (Jan, Apr, Jul, Oct)
25/// - **Year**: January 1st
26///
27/// # Examples
28///
29/// ```
30/// use chrono::NaiveDate;
31/// use fraiseql_core::compiler::fact_table::TemporalGrain;
32/// use fraiseql_core::runtime::partial_period::is_period_aligned;
33///
34/// let jan1 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
35/// assert!(is_period_aligned(jan1, TemporalGrain::Month));
36/// assert!(is_period_aligned(jan1, TemporalGrain::Quarter));
37/// assert!(is_period_aligned(jan1, TemporalGrain::Year));
38///
39/// let jan15 = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
40/// assert!(!is_period_aligned(jan15, TemporalGrain::Month));
41/// ```
42#[must_use]
43pub fn is_period_aligned(date: NaiveDate, grain: TemporalGrain) -> bool {
44    match grain {
45        TemporalGrain::Day => true,
46        TemporalGrain::Week => date.weekday() == chrono::Weekday::Mon,
47        TemporalGrain::Month => date.day() == 1,
48        TemporalGrain::Quarter => date.day() == 1 && (date.month() - 1).is_multiple_of(3),
49        TemporalGrain::Year => date.ordinal() == 1,
50    }
51}
52
53/// Returns the start of the period containing the given date.
54///
55/// # Examples
56///
57/// ```
58/// use chrono::NaiveDate;
59/// use fraiseql_core::compiler::fact_table::TemporalGrain;
60/// use fraiseql_core::runtime::partial_period::period_start;
61///
62/// let d = NaiveDate::from_ymd_opt(2024, 2, 15).unwrap();
63/// assert_eq!(
64///     period_start(d, TemporalGrain::Month),
65///     NaiveDate::from_ymd_opt(2024, 2, 1).unwrap()
66/// );
67/// ```
68///
69/// # Panics
70///
71/// Panics if the computed date is outside the `NaiveDate` range, which cannot
72/// happen for practical calendar dates (years 0–9999).
73#[must_use]
74pub fn period_start(date: NaiveDate, grain: TemporalGrain) -> NaiveDate {
75    match grain {
76        TemporalGrain::Day => date,
77        TemporalGrain::Week => {
78            let days_since_monday = date.weekday().num_days_from_monday();
79            date - TimeDelta::days(i64::from(days_since_monday))
80        },
81        TemporalGrain::Month => NaiveDate::from_ymd_opt(date.year(), date.month(), 1)
82            .expect("day 1 of any month is valid"),
83        TemporalGrain::Quarter => {
84            let quarter_month = ((date.month() - 1) / 3) * 3 + 1;
85            NaiveDate::from_ymd_opt(date.year(), quarter_month, 1)
86                .expect("quarter start is always valid")
87        },
88        TemporalGrain::Year => {
89            NaiveDate::from_ymd_opt(date.year(), 1, 1).expect("Jan 1 is always valid")
90        },
91    }
92}
93
94/// Returns the start of the period immediately after the period containing the given date.
95///
96/// # Examples
97///
98/// ```
99/// use chrono::NaiveDate;
100/// use fraiseql_core::compiler::fact_table::TemporalGrain;
101/// use fraiseql_core::runtime::partial_period::next_period_start;
102///
103/// let d = NaiveDate::from_ymd_opt(2024, 12, 15).unwrap();
104/// assert_eq!(
105///     next_period_start(d, TemporalGrain::Month),
106///     NaiveDate::from_ymd_opt(2025, 1, 1).unwrap()
107/// );
108/// ```
109///
110/// # Panics
111///
112/// Panics if the computed date is outside the `NaiveDate` range, which cannot
113/// happen for practical calendar dates (years 0–9999).
114#[must_use]
115pub fn next_period_start(date: NaiveDate, grain: TemporalGrain) -> NaiveDate {
116    let start = period_start(date, grain);
117    match grain {
118        TemporalGrain::Day => start + TimeDelta::days(1),
119        TemporalGrain::Week => start + TimeDelta::weeks(1),
120        TemporalGrain::Month => {
121            if start.month() == 12 {
122                NaiveDate::from_ymd_opt(start.year() + 1, 1, 1).expect("next year Jan 1 is valid")
123            } else {
124                NaiveDate::from_ymd_opt(start.year(), start.month() + 1, 1)
125                    .expect("next month day 1 is valid")
126            }
127        },
128        TemporalGrain::Quarter => {
129            if start.month() == 10 {
130                NaiveDate::from_ymd_opt(start.year() + 1, 1, 1)
131                    .expect("next year Q1 start is valid")
132            } else {
133                NaiveDate::from_ymd_opt(start.year(), start.month() + 3, 1)
134                    .expect("next quarter start is valid")
135            }
136        },
137        TemporalGrain::Year => {
138            NaiveDate::from_ymd_opt(start.year() + 1, 1, 1).expect("next year Jan 1 is valid")
139        },
140    }
141}
142
143/// Plan describing which UNION ALL branches to generate.
144///
145/// A partial-period UNION ALL query has up to 3 branches:
146/// - **`partial_leading`**: fine-grain rows from the non-aligned lower bound to the next period
147///   boundary (omitted when the lower bound is period-aligned).
148/// - **`complete_middle`**: coarse-grain rows for fully completed periods between the leading
149///   partial period and the current period (omitted when there are no complete periods in range).
150/// - **`current_period`**: fine-grain rows for the in-progress period up to today (always present).
151///
152/// Date ranges are half-open intervals: `[gte, lt)`.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct BranchPlan {
155    /// Partial leading period: `[gte, lt)`. `None` when the lower bound is period-aligned.
156    pub partial_leading: Option<(NaiveDate, NaiveDate)>,
157    /// Complete middle periods: `[gte, lt)`. `None` when no complete periods exist.
158    pub complete_middle: Option<(NaiveDate, NaiveDate)>,
159    /// Current in-progress period: `[gte, lt)`. Always present.
160    pub current_period:  (NaiveDate, NaiveDate),
161}
162
163/// Computes which UNION ALL branches are needed given a lower bound, grain, and today's date.
164///
165/// # Arguments
166///
167/// * `lower_bound` — the effective inclusive lower-bound date extracted from the WHERE clause
168/// * `grain` — the temporal granularity of the coarse-grain view
169/// * `today` — today's date (injected for deterministic testing)
170///
171/// # Examples
172///
173/// ```
174/// use chrono::NaiveDate;
175/// use fraiseql_core::compiler::fact_table::TemporalGrain;
176/// use fraiseql_core::runtime::partial_period::determine_branches;
177///
178/// let lower = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
179/// let today = NaiveDate::from_ymd_opt(2024, 3, 20).unwrap();
180/// let plan = determine_branches(lower, TemporalGrain::Month, today);
181///
182/// // B1: Jan 15 – Feb 1 (partial leading)
183/// assert_eq!(plan.partial_leading, Some((
184///     NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
185///     NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
186/// )));
187/// // B2: Feb 1 – Mar 1 (complete middle)
188/// assert_eq!(plan.complete_middle, Some((
189///     NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
190///     NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
191/// )));
192/// // B3: Mar 1 – Mar 21 (current period, today+1 exclusive)
193/// assert_eq!(plan.current_period, (
194///     NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
195///     NaiveDate::from_ymd_opt(2024, 3, 21).unwrap(),
196/// ));
197/// ```
198#[must_use]
199pub fn determine_branches(
200    lower_bound: NaiveDate,
201    grain: TemporalGrain,
202    today: NaiveDate,
203) -> BranchPlan {
204    let aligned = is_period_aligned(lower_bound, grain);
205    let next_ps = next_period_start(lower_bound, grain);
206    let current_ps = period_start(today, grain);
207
208    // B2 starts at lower_bound when aligned, else at next_ps
209    let b2_start = if aligned { lower_bound } else { next_ps };
210    let include_b2 = b2_start < current_ps;
211
212    // B3 upper bound: exclusive tomorrow makes "date < tomorrow" ≡ "date <= today"
213    let today_exclusive = today + TimeDelta::days(1);
214
215    BranchPlan {
216        partial_leading: if aligned {
217            None
218        } else {
219            Some((lower_bound, next_ps))
220        },
221        complete_middle: if include_b2 {
222            Some((b2_start, current_ps))
223        } else {
224            None
225        },
226        current_period:  (current_ps, today_exclusive),
227    }
228}
229
230/// Checks whether partial-period UNION ALL dispatch should be used for this query.
231///
232/// Returns the extracted lower-bound date and partial-period config when all
233/// conditions are met:
234/// 1. The fact table has `partial_period` configuration
235/// 2. The WHERE clause contains a lower-bound date filter on the time-grain column
236/// 3. The resulting branch plan would produce more than one branch (otherwise UNION ALL of a single
237///    branch is overhead for no benefit)
238///
239/// Returns `None` when the standard aggregation path should be used instead.
240#[must_use]
241pub fn should_use_partial_period<'a>(
242    metadata: &'a crate::compiler::fact_table::FactTableMetadata,
243    where_clause: Option<&WhereClause>,
244    today: NaiveDate,
245) -> Option<(NaiveDate, &'a crate::compiler::fact_table::PartialPeriodConfig)> {
246    let config = metadata.partial_period.as_ref()?;
247    let wc = where_clause?;
248    let lower_bound = extract_lower_date_bound(wc, &config.time_grain_column)?;
249
250    // Short-circuit: if determine_branches produces only one branch (current_period
251    // with no partial_leading and no complete_middle), the standard path is equivalent.
252    let plan = determine_branches(lower_bound, config.time_grain_trunc, today);
253    if plan.partial_leading.is_none() && plan.complete_middle.is_none() {
254        return None;
255    }
256
257    Some((lower_bound, config))
258}
259
260/// Result of splitting a WHERE clause into its date lower-bound condition and
261/// the remaining (non-date) conditions.
262///
263/// Produced by [`split_where_clause`].
264#[derive(Debug, Clone, PartialEq)]
265pub struct SplitWhereResult {
266    /// The inclusive lower-bound date extracted from the WHERE clause.
267    pub lower_bound: NaiveDate,
268    /// Everything except the matched date condition. `None` when the entire
269    /// WHERE clause was just the date condition (nothing left).
270    pub remaining:   Option<WhereClause>,
271}
272
273/// Extracts an inclusive lower-bound date from a WHERE clause on the given column.
274///
275/// Scans the clause for a `Gte` or `Gt` condition on `column_name`. For `Gt`,
276/// the value is converted to the next day to produce an inclusive bound
277/// (`date > '2024-01-14'` → `date >= '2024-01-15'`).
278///
279/// Only AND-chained conditions are traversed. OR and NOT wrappers cause a safe
280/// fallback (`None`), since extracting a single branch from an OR is not
281/// semantically valid.
282///
283/// Returns `None` if no lower-bound condition is found on the target column.
284///
285/// # Arguments
286///
287/// * `where_clause` — the WHERE clause to inspect
288/// * `column_name` — the time-grain column to look for (e.g. `"period_start"`)
289#[must_use]
290pub fn extract_lower_date_bound(
291    where_clause: &WhereClause,
292    column_name: &str,
293) -> Option<NaiveDate> {
294    match where_clause {
295        WhereClause::Field {
296            path,
297            operator,
298            value,
299        } => extract_from_field(path, operator, value, column_name),
300
301        WhereClause::NativeField {
302            column,
303            operator,
304            value,
305            ..
306        } => {
307            if column == column_name {
308                extract_date_from_operator(operator, value)
309            } else {
310                None
311            }
312        },
313
314        WhereClause::And(children) => {
315            for child in children {
316                if let Some(d) = extract_lower_date_bound(child, column_name) {
317                    return Some(d);
318                }
319            }
320            None
321        },
322
323        // OR/NOT/unknown: cannot safely extract a lower bound — fall back to standard path.
324        _ => None,
325    }
326}
327
328/// Splits a WHERE clause into the extracted lower-bound date and remaining conditions.
329///
330/// Returns `None` if no lower-bound date condition is found on `column_name`.
331/// When found, the matched condition is removed from the clause:
332/// - If the clause was a single condition, `remaining` is `None`.
333/// - If the clause was an AND chain, the matched child is removed. If only one child remains, the
334///   AND wrapper is unwrapped.
335///
336/// # Arguments
337///
338/// * `where_clause` — the WHERE clause to split
339/// * `column_name` — the time-grain column to look for
340#[must_use]
341pub fn split_where_clause(
342    where_clause: &WhereClause,
343    column_name: &str,
344) -> Option<SplitWhereResult> {
345    match where_clause {
346        WhereClause::Field {
347            path,
348            operator,
349            value,
350        } => {
351            let date = extract_from_field(path, operator, value, column_name)?;
352            Some(SplitWhereResult {
353                lower_bound: date,
354                remaining:   None,
355            })
356        },
357
358        WhereClause::NativeField {
359            column,
360            operator,
361            value,
362            ..
363        } => {
364            if column != column_name {
365                return None;
366            }
367            let date = extract_date_from_operator(operator, value)?;
368            Some(SplitWhereResult {
369                lower_bound: date,
370                remaining:   None,
371            })
372        },
373
374        WhereClause::And(children) => {
375            // Find the first child that matches the date column.
376            let mut match_idx = None;
377            let mut matched_date = None;
378            for (i, child) in children.iter().enumerate() {
379                if let Some(d) = extract_lower_date_bound(child, column_name) {
380                    match_idx = Some(i);
381                    matched_date = Some(d);
382                    break;
383                }
384            }
385            let idx = match_idx?;
386            let date = matched_date?;
387
388            // Build remaining by filtering out the matched child.
389            let remaining: Vec<WhereClause> = children
390                .iter()
391                .enumerate()
392                .filter(|(i, _)| *i != idx)
393                .map(|(_, c)| c.clone())
394                .collect();
395
396            let remaining = match remaining.len() {
397                0 => None,
398                1 => remaining.into_iter().next(),
399                _ => Some(WhereClause::And(remaining)),
400            };
401
402            Some(SplitWhereResult {
403                lower_bound: date,
404                remaining,
405            })
406        },
407
408        // OR/NOT/unknown: cannot safely split.
409        _ => None,
410    }
411}
412
413/// Checks if a `Field` path matches the column name and extracts a date.
414fn extract_from_field(
415    path: &[String],
416    operator: &WhereOperator,
417    value: &serde_json::Value,
418    column_name: &str,
419) -> Option<NaiveDate> {
420    if path.len() == 1 && path[0] == column_name {
421        extract_date_from_operator(operator, value)
422    } else {
423        None
424    }
425}
426
427/// Converts a `Gte`/`Gt` operator + value into an inclusive `NaiveDate`.
428///
429/// `Gt` adds one day: `date > '2024-01-14'` → `date >= '2024-01-15'`.
430fn extract_date_from_operator(
431    operator: &WhereOperator,
432    value: &serde_json::Value,
433) -> Option<NaiveDate> {
434    let date_str = value.as_str()?;
435    let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok()?;
436
437    match operator {
438        WhereOperator::Gte => Some(date),
439        WhereOperator::Gt => Some(date + TimeDelta::days(1)),
440        _ => None,
441    }
442}
443
444#[cfg(test)]
445mod tests;