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;