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;