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