From 5ed0b543aca46d3d830ffd4040eb959355789b03 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Mon, 1 Jun 2026 11:47:31 +0200 Subject: [PATCH 1/7] feat(tesseract): native time-series generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add QueryTimeSeries to planner/time_dimension with two entry points: * generate_predefined(granularity, range, precision) — snaps the start to the bucket boundary (day / week ISO-Mon / month / quarter / year / hour / minute / second) and emits buckets until past the end. Sub-second part padded with '0' / '9' to the requested precision. * generate_custom(interval, range, origin, precision) — aligns to origin, walks by parsed SqlInterval (calendar-aware for month / quarter / year via chrono::Months, fixed Duration for sub-day), end = next - 1 second. Iteration capped at 50k buckets. physical_plan::TimeSeries and MockBaseTools now route through the Rust impl for drivers without generated-series support; the bridge generate_time_series / generate_custom_time_series calls disappear from the planner path. The mock-only helper test_fixtures/cube_bridge/ time_series.rs is deleted — the new module covers it with 15 inline tests for predefined / custom shapes. --- .../src/physical_plan/time_series.rs | 25 +- .../processors/multi_stage_time_series.rs | 7 +- .../src/planner/time_dimension/mod.rs | 2 + .../src/planner/time_dimension/time_series.rs | 618 ++++++++++++++++++ .../cube_bridge/mock_base_tools.rs | 22 +- .../src/test_fixtures/cube_bridge/mod.rs | 1 - .../test_fixtures/cube_bridge/time_series.rs | 228 ------- 7 files changed, 650 insertions(+), 253 deletions(-) create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs delete mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/time_series.rs diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/time_series.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/time_series.rs index 9e66d029c4b9a..5205e40a00d9e 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/time_series.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/time_series.rs @@ -1,12 +1,9 @@ use super::{Schema, SchemaColumn}; -use crate::planner::{ - query_tools::QueryTools, sql_templates::PlanSqlTemplates, Granularity, MemberSymbol, -}; +use crate::planner::{sql_templates::PlanSqlTemplates, Granularity, MemberSymbol, QueryTimeSeries}; use cubenativeutils::CubeError; use std::rc::Rc; pub struct TimeSeries { - query_tools: Rc, #[allow(dead_code)] time_dimension_name: String, date_range: TimeSeriesDateRange, @@ -21,7 +18,6 @@ pub enum TimeSeriesDateRange { impl TimeSeries { pub fn new( - query_tools: Rc, time_dimension: &Rc, date_range: TimeSeriesDateRange, granularity: Granularity, @@ -29,7 +25,6 @@ impl TimeSeries { let column = SchemaColumn::new(format!("date_from"), Some(time_dimension.clone())); let schema = Rc::new(Schema::new(vec![column])); Self { - query_tools, time_dimension_name: time_dimension.full_name(), granularity, date_range, @@ -101,16 +96,20 @@ impl TimeSeries { )); } }; + let precision = templates.timestamp_precision()?; + let range = [raw_from_date.clone(), raw_to_date.clone()]; let series = if self.granularity.is_predefined_granularity() { - self.query_tools.base_tools().generate_time_series( - self.granularity.granularity().clone(), - vec![raw_from_date.clone(), raw_to_date.clone()], + QueryTimeSeries::generate_predefined( + self.granularity.granularity(), + &range, + precision, )? } else { - self.query_tools.base_tools().generate_custom_time_series( - self.granularity.granularity_interval().to_sql(), - vec![raw_from_date.clone(), raw_to_date.clone()], - self.granularity.origin_local_formatted(), + QueryTimeSeries::generate_custom( + &self.granularity.granularity_interval().to_sql(), + &range, + &self.granularity.origin_local_formatted(), + precision, )? }; templates.time_series_select(from_date.clone(), to_date.clone(), series) diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/multi_stage_time_series.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/multi_stage_time_series.rs index d8ff40360b921..d801d51e50170 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/multi_stage_time_series.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan_builder/processors/multi_stage_time_series.rs @@ -60,12 +60,7 @@ impl<'a> LogicalNodeProcessor<'a, MultiStageTimeSeries> for MultiStageTimeSeries } }; - let time_series = TimeSeries::new( - query_tools.clone(), - &time_dimension, - ts_date_range, - granularity_obj, - ); + let time_series = TimeSeries::new(&time_dimension, ts_date_range, granularity_obj); let query_plan = QueryPlan::TimeSeries(Rc::new(time_series)); Ok(query_plan) } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/mod.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/mod.rs index 78bf440b206e4..55bc8a2ab0851 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/mod.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/mod.rs @@ -3,9 +3,11 @@ mod date_time_helper; mod granularity; mod granularity_helper; mod sql_interval; +mod time_series; pub use date_time::*; pub use date_time_helper::*; pub use granularity::*; pub use granularity_helper::*; pub use sql_interval::*; +pub use time_series::*; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs new file mode 100644 index 0000000000000..b12a224989947 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs @@ -0,0 +1,618 @@ +use chrono::{Datelike, Duration, Months, NaiveDate, NaiveDateTime, Timelike}; +use cubenativeutils::CubeError; +use std::str::FromStr; + +use super::date_time_helper::QueryDateTimeHelper; +use super::sql_interval::SqlInterval; + +const PREDEFINED_GRANULARITIES: &[&str] = &[ + "second", "minute", "hour", "day", "week", "month", "quarter", "year", +]; + +pub fn is_predefined_granularity(name: &str) -> bool { + PREDEFINED_GRANULARITIES.contains(&name) +} + +pub struct QueryTimeSeries; + +impl QueryTimeSeries { + /// Snaps the range start to the bucket boundary for the given granularity, + /// then emits one `[start, end]` pair per bucket until the bucket boundary + /// passes the range end. The sub-second part of each timestamp is padded + /// with literal `'0'`/`'9'` characters to the requested precision. + pub fn generate_predefined( + granularity: &str, + date_range: &[String; 2], + timestamp_precision: u32, + ) -> Result>, CubeError> { + check_precision(timestamp_precision)?; + if !is_predefined_granularity(granularity) { + return Err(CubeError::user(format!( + "Unsupported time granularity: {granularity}" + ))); + } + let range_start = QueryDateTimeHelper::parse_native_date_time(&date_range[0])?; + let range_end = QueryDateTimeHelper::parse_native_date_time(&date_range[1])?; + let mut current = range_start; + let mut buckets = Vec::new(); + loop { + let window = predefined_bucket(granularity, current, timestamp_precision)?; + if window.bucket_start > range_end { + break; + } + buckets.push(vec![window.start_str, window.end_str]); + current = window.next; + } + Ok(buckets) + } + + /// Walks buckets by repeatedly adding the parsed interval starting from the + /// position aligned to `origin`. Each bucket's end is `next_start - 1s`, + /// formatted with the sub-second `'9'` padding. + pub fn generate_custom( + interval_str: &str, + date_range: &[String; 2], + origin_str: &str, + timestamp_precision: u32, + ) -> Result>, CubeError> { + check_precision(timestamp_precision)?; + let interval = SqlInterval::from_str(interval_str)?; + if is_zero_interval(&interval) { + return Err(CubeError::user("Custom interval can't be zero".to_string())); + } + let range_start = QueryDateTimeHelper::parse_native_date_time(&date_range[0])?; + let range_end = QueryDateTimeHelper::parse_native_date_time(&date_range[1])?; + let origin = QueryDateTimeHelper::parse_native_date_time(origin_str)?; + let zeros = "0".repeat(timestamp_precision as usize); + let nines = "9".repeat(timestamp_precision as usize); + let mut aligned = align_to_origin(range_start, &interval, origin)?; + let mut buckets = Vec::new(); + // Guard against pathologically tiny intervals, expressed as an + // iteration limit since calendar units have no fixed length in seconds. + const MAX_BUCKETS: usize = 50_000; + while aligned < range_end { + if buckets.len() >= MAX_BUCKETS { + return Err(CubeError::user(format!( + "Custom time series exceeded {MAX_BUCKETS} buckets; \ + reduce date range or use a larger interval" + ))); + } + let next = add_interval_to_dt(aligned, &interval)?; + if next <= aligned { + return Err(CubeError::user( + "Custom interval did not advance the cursor".to_string(), + )); + } + let bucket_end = next - Duration::seconds(1); + buckets.push(vec![ + format_with_padding(aligned, &zeros), + format_with_padding(bucket_end, &nines), + ]); + aligned = next; + } + Ok(buckets) + } +} + +struct BucketWindow { + bucket_start: NaiveDateTime, + start_str: String, + end_str: String, + next: NaiveDateTime, +} + +fn check_precision(precision: u32) -> Result<(), CubeError> { + match precision { + 3 | 6 => Ok(()), + other => Err(CubeError::user(format!( + "Unsupported timestamp precision: {other}" + ))), + } +} + +fn predefined_bucket( + granularity: &str, + pos: NaiveDateTime, + precision: u32, +) -> Result { + let zeros = "0".repeat(precision as usize); + let nines = "9".repeat(precision as usize); + let res = match granularity { + "second" => { + let start = pos + .date() + .and_hms_opt(pos.hour(), pos.minute(), pos.second()) + .unwrap(); + let next = start + Duration::seconds(1); + BucketWindow { + bucket_start: start, + start_str: format!( + "{}T{:02}:{:02}:{:02}.{zeros}", + start.date(), + start.hour(), + start.minute(), + start.second() + ), + end_str: format!( + "{}T{:02}:{:02}:{:02}.{nines}", + start.date(), + start.hour(), + start.minute(), + start.second() + ), + next, + } + } + "minute" => { + let start = pos.date().and_hms_opt(pos.hour(), pos.minute(), 0).unwrap(); + let next = start + Duration::minutes(1); + BucketWindow { + bucket_start: start, + start_str: format!( + "{}T{:02}:{:02}:00.{zeros}", + start.date(), + start.hour(), + start.minute() + ), + end_str: format!( + "{}T{:02}:{:02}:59.{nines}", + start.date(), + start.hour(), + start.minute() + ), + next, + } + } + "hour" => { + let start = pos.date().and_hms_opt(pos.hour(), 0, 0).unwrap(); + let next = start + Duration::hours(1); + BucketWindow { + bucket_start: start, + start_str: format!("{}T{:02}:00:00.{zeros}", start.date(), start.hour()), + end_str: format!("{}T{:02}:59:59.{nines}", start.date(), start.hour()), + next, + } + } + "day" => { + let start = pos.date().and_hms_opt(0, 0, 0).unwrap(); + let next = start + Duration::days(1); + BucketWindow { + bucket_start: start, + start_str: format!("{}T00:00:00.{zeros}", start.date()), + end_str: format!("{}T23:59:59.{nines}", start.date()), + next, + } + } + "week" => { + // ISO week: Monday-anchored. + let weekday = pos.date().weekday(); + // Monday=0 .. Sunday=6 (chrono uses num_days_from_monday()). + let from_monday = weekday.num_days_from_monday() as i64; + let monday = pos.date() - Duration::days(from_monday); + let sunday = monday + Duration::days(6); + let start = monday.and_hms_opt(0, 0, 0).unwrap(); + let next = start + Duration::weeks(1); + BucketWindow { + bucket_start: start, + start_str: format!("{}T00:00:00.{zeros}", monday), + end_str: format!("{}T23:59:59.{nines}", sunday), + next, + } + } + "month" => { + let first = NaiveDate::from_ymd_opt(pos.year(), pos.month(), 1) + .ok_or_else(|| CubeError::user(format!("Invalid date in bucket for {pos}")))?; + let next_first = add_months(first, 1)?; + let last = next_first - Duration::days(1); + let start = first.and_hms_opt(0, 0, 0).unwrap(); + BucketWindow { + bucket_start: start, + start_str: format!("{}T00:00:00.{zeros}", first), + end_str: format!("{}T23:59:59.{nines}", last), + next: next_first.and_hms_opt(0, 0, 0).unwrap(), + } + } + "quarter" => { + // Quarter starts: Jan, Apr, Jul, Oct. + let qmonth = ((pos.month() - 1) / 3) * 3 + 1; + let first = NaiveDate::from_ymd_opt(pos.year(), qmonth, 1) + .ok_or_else(|| CubeError::user(format!("Invalid quarter start for {pos}")))?; + let next_first = add_months(first, 3)?; + let last = next_first - Duration::days(1); + let start = first.and_hms_opt(0, 0, 0).unwrap(); + BucketWindow { + bucket_start: start, + start_str: format!("{}T00:00:00.{zeros}", first), + end_str: format!("{}T23:59:59.{nines}", last), + next: next_first.and_hms_opt(0, 0, 0).unwrap(), + } + } + "year" => { + let first = NaiveDate::from_ymd_opt(pos.year(), 1, 1).unwrap(); + let next_first = NaiveDate::from_ymd_opt(pos.year() + 1, 1, 1).unwrap(); + let last = next_first - Duration::days(1); + let start = first.and_hms_opt(0, 0, 0).unwrap(); + BucketWindow { + bucket_start: start, + start_str: format!("{}T00:00:00.{zeros}", first), + end_str: format!("{}T23:59:59.{nines}", last), + next: next_first.and_hms_opt(0, 0, 0).unwrap(), + } + } + other => { + return Err(CubeError::user(format!( + "Unsupported time granularity: {other}" + ))) + } + }; + Ok(res) +} + +fn is_zero_interval(interval: &SqlInterval) -> bool { + interval.year == 0 + && interval.quarter == 0 + && interval.month == 0 + && interval.week == 0 + && interval.day == 0 + && interval.hour == 0 + && interval.minute == 0 + && interval.second == 0 +} + +fn add_months(date: NaiveDate, months: u32) -> Result { + date.checked_add_months(Months::new(months)).ok_or_else(|| { + CubeError::user(format!( + "Date overflow when adding {months} months to {date}" + )) + }) +} + +fn sub_months(date: NaiveDate, months: u32) -> Result { + date.checked_sub_months(Months::new(months)).ok_or_else(|| { + CubeError::user(format!( + "Date overflow when subtracting {months} months from {date}" + )) + }) +} + +fn add_interval_to_dt( + dt: NaiveDateTime, + interval: &SqlInterval, +) -> Result { + let mut date = dt.date(); + let mut time = dt.time(); + // Calendar parts first (year/quarter/month) — they're not fixed in seconds. + let total_months = interval.year * 12 + interval.quarter * 3 + interval.month; + date = apply_months(date, total_months)?; + let extra_days = interval.week * 7 + interval.day; + if extra_days != 0 { + date = date + .checked_add_signed(Duration::days(extra_days as i64)) + .ok_or_else(|| CubeError::user(format!("Date overflow adding days to {dt}")))?; + } + // Sub-day part: lower into a Duration and add to the combined dt. + let sub_day = Duration::hours(interval.hour as i64) + + Duration::minutes(interval.minute as i64) + + Duration::seconds(interval.second as i64); + let combined = date.and_time(time); + let result = combined + .checked_add_signed(sub_day) + .ok_or_else(|| CubeError::user(format!("Date overflow adding sub-day to {dt}")))?; + // Re-extract time in case sub_day arithmetic rolled days. + date = result.date(); + time = result.time(); + Ok(date.and_time(time)) +} + +fn sub_interval_from_dt( + dt: NaiveDateTime, + interval: &SqlInterval, +) -> Result { + let neg = SqlInterval::new( + -interval.year, + -interval.quarter, + -interval.month, + -interval.week, + -interval.day, + -interval.hour, + -interval.minute, + -interval.second, + ); + add_interval_to_dt(dt, &neg) +} + +fn apply_months(date: NaiveDate, months_signed: i32) -> Result { + if months_signed >= 0 { + add_months(date, months_signed as u32) + } else { + sub_months(date, (-months_signed) as u32) + } +} + +fn align_to_origin( + start: NaiveDateTime, + interval: &SqlInterval, + origin: NaiveDateTime, +) -> Result { + let mut aligned = start; + let mut offset = origin; + if start < origin { + // Pull origin back until it sits at or below start. + while offset > start { + offset = sub_interval_from_dt(offset, interval)?; + } + aligned = offset; + } else { + // Push origin forward; remember the last step that didn't overshoot. + while offset < start { + aligned = offset; + offset = add_interval_to_dt(offset, interval)?; + } + if offset == start { + aligned = offset; + } + } + Ok(aligned) +} + +fn format_with_padding(dt: NaiveDateTime, sub_second: &str) -> String { + format!( + "{}T{:02}:{:02}:{:02}.{sub_second}", + dt.date(), + dt.hour(), + dt.minute(), + dt.second() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dr(a: &str, b: &str) -> [String; 2] { + [a.to_string(), b.to_string()] + } + + // ---- predefined ---- + + #[test] + fn day_buckets_basic() { + let result = + QueryTimeSeries::generate_predefined("day", &dr("2024-01-10", "2024-01-12"), 3) + .unwrap(); + assert_eq!(result.len(), 3); + assert_eq!( + result[0], + vec!["2024-01-10T00:00:00.000", "2024-01-10T23:59:59.999"] + ); + assert_eq!( + result[2], + vec!["2024-01-12T00:00:00.000", "2024-01-12T23:59:59.999"] + ); + } + + #[test] + fn day_buckets_partial_start_snaps_down() { + let result = QueryTimeSeries::generate_predefined( + "day", + &dr("2024-01-10T15:30:00", "2024-01-11T03:00:00"), + 3, + ) + .unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0][0], "2024-01-10T00:00:00.000"); + assert_eq!(result[1][1], "2024-01-11T23:59:59.999"); + } + + #[test] + fn month_buckets_cross_year_boundary() { + let result = + QueryTimeSeries::generate_predefined("month", &dr("2023-11-15", "2024-02-05"), 3) + .unwrap(); + // Buckets: 2023-11, 2023-12, 2024-01, 2024-02 + assert_eq!(result.len(), 4); + assert_eq!(result[0][0], "2023-11-01T00:00:00.000"); + assert_eq!(result[0][1], "2023-11-30T23:59:59.999"); + assert_eq!(result[1][1], "2023-12-31T23:59:59.999"); + assert_eq!(result[2][0], "2024-01-01T00:00:00.000"); + assert_eq!(result[3][0], "2024-02-01T00:00:00.000"); + assert_eq!(result[3][1], "2024-02-29T23:59:59.999"); // 2024 is leap + } + + #[test] + fn quarter_buckets_align_to_quarter_start() { + let result = + QueryTimeSeries::generate_predefined("quarter", &dr("2024-02-15", "2024-08-01"), 3) + .unwrap(); + // Buckets: Q1 (Jan-Mar), Q2 (Apr-Jun), Q3 (Jul-Sep) + assert_eq!(result.len(), 3); + assert_eq!(result[0][0], "2024-01-01T00:00:00.000"); + assert_eq!(result[0][1], "2024-03-31T23:59:59.999"); + assert_eq!(result[1][0], "2024-04-01T00:00:00.000"); + assert_eq!(result[1][1], "2024-06-30T23:59:59.999"); + assert_eq!(result[2][0], "2024-07-01T00:00:00.000"); + assert_eq!(result[2][1], "2024-09-30T23:59:59.999"); + } + + #[test] + fn year_buckets() { + let result = + QueryTimeSeries::generate_predefined("year", &dr("2022-06-15", "2024-01-01"), 3) + .unwrap(); + assert_eq!(result.len(), 3); + assert_eq!(result[0][0], "2022-01-01T00:00:00.000"); + assert_eq!(result[0][1], "2022-12-31T23:59:59.999"); + assert_eq!(result[2][0], "2024-01-01T00:00:00.000"); + assert_eq!(result[2][1], "2024-12-31T23:59:59.999"); + } + + #[test] + fn week_iso_monday_anchored() { + // 2024-01-10 was a Wednesday. ISO week starts Mon 2024-01-08. + let result = + QueryTimeSeries::generate_predefined("week", &dr("2024-01-10", "2024-01-22"), 3) + .unwrap(); + // Buckets: week of Jan 8 (Mon)–Jan 14 (Sun), week of Jan 15 (Mon)–Jan 21 (Sun), week of Jan 22 (Mon)–Jan 28 (Sun) + assert_eq!(result.len(), 3); + assert_eq!(result[0][0], "2024-01-08T00:00:00.000"); + assert_eq!(result[0][1], "2024-01-14T23:59:59.999"); + assert_eq!(result[1][0], "2024-01-15T00:00:00.000"); + assert_eq!(result[1][1], "2024-01-21T23:59:59.999"); + assert_eq!(result[2][0], "2024-01-22T00:00:00.000"); + } + + #[test] + fn hour_minute_second() { + let h = QueryTimeSeries::generate_predefined( + "hour", + &dr("2024-01-10T10:30:00", "2024-01-10T12:00:00"), + 3, + ) + .unwrap(); + assert_eq!(h.len(), 3); + assert_eq!( + h[0], + vec!["2024-01-10T10:00:00.000", "2024-01-10T10:59:59.999"] + ); + assert_eq!( + h[2], + vec!["2024-01-10T12:00:00.000", "2024-01-10T12:59:59.999"] + ); + + let m = QueryTimeSeries::generate_predefined( + "minute", + &dr("2024-01-10T10:30:15", "2024-01-10T10:32:00"), + 3, + ) + .unwrap(); + assert_eq!(m.len(), 3); + assert_eq!( + m[0], + vec!["2024-01-10T10:30:00.000", "2024-01-10T10:30:59.999"] + ); + + let s = QueryTimeSeries::generate_predefined( + "second", + &dr("2024-01-10T10:30:15", "2024-01-10T10:30:17"), + 3, + ) + .unwrap(); + assert_eq!(s.len(), 3); + assert_eq!( + s[0], + vec!["2024-01-10T10:30:15.000", "2024-01-10T10:30:15.999"] + ); + } + + #[test] + fn precision_six_pads_to_microseconds() { + let result = + QueryTimeSeries::generate_predefined("day", &dr("2024-01-10", "2024-01-10"), 6) + .unwrap(); + assert_eq!( + result[0], + vec!["2024-01-10T00:00:00.000000", "2024-01-10T23:59:59.999999"] + ); + } + + #[test] + fn unsupported_granularity_errors() { + let err = + QueryTimeSeries::generate_predefined("fortnight", &dr("2024-01-10", "2024-01-11"), 3) + .unwrap_err(); + assert!(err.message.contains("Unsupported time granularity")); + } + + #[test] + fn unsupported_precision_errors() { + let err = QueryTimeSeries::generate_predefined("day", &dr("2024-01-10", "2024-01-11"), 4) + .unwrap_err(); + assert!(err.message.contains("Unsupported timestamp precision")); + } + + // ---- custom ---- + + #[test] + fn custom_two_day_interval_aligned_at_origin() { + // Origin 2024-01-01, interval = 2 days. Range starts mid-bucket. + let result = QueryTimeSeries::generate_custom( + "2 days", + &dr("2024-01-04", "2024-01-10"), + "2024-01-01", + 3, + ) + .unwrap(); + // Buckets aligned to origin: Jan 1-2, Jan 3-4, Jan 5-6, Jan 7-8, Jan 9-10 + // range_start = Jan 4 → aligned start = Jan 3 (since Jan 1 + 2d = Jan 3 < Jan 4). + // Iterate while aligned < range_end (Jan 10): Jan 3, Jan 5, Jan 7, Jan 9 + assert_eq!(result.len(), 4); + assert_eq!(result[0][0], "2024-01-03T00:00:00.000"); + assert_eq!(result[0][1], "2024-01-04T23:59:59.999"); // next=Jan 5, -1s = Jan 4 23:59:59 + assert_eq!(result[1][0], "2024-01-05T00:00:00.000"); + assert_eq!(result[3][0], "2024-01-09T00:00:00.000"); + assert_eq!(result[3][1], "2024-01-10T23:59:59.999"); + } + + #[test] + fn custom_origin_after_start_walks_backwards() { + // Origin Jan 10, interval = 3 days, range starts Jan 5. + // Pull origin back: Jan 10 - 3d = Jan 7, - 3d = Jan 4 (≤ Jan 5). Aligned = Jan 4. + let result = QueryTimeSeries::generate_custom( + "3 days", + &dr("2024-01-05", "2024-01-12"), + "2024-01-10", + 3, + ) + .unwrap(); + // Buckets: Jan 4, Jan 7, Jan 10 + assert_eq!(result.len(), 3); + assert_eq!(result[0][0], "2024-01-04T00:00:00.000"); + assert_eq!(result[0][1], "2024-01-06T23:59:59.999"); + assert_eq!(result[1][0], "2024-01-07T00:00:00.000"); + assert_eq!(result[2][0], "2024-01-10T00:00:00.000"); + } + + #[test] + fn custom_calendar_interval_month() { + let result = QueryTimeSeries::generate_custom( + "1 month", + &dr("2024-01-15", "2024-04-01"), + "2024-01-15", + 3, + ) + .unwrap(); + // Aligned = 2024-01-15. Steps: Jan 15→Feb 15→Mar 15→Apr 15. While aligned < Apr 1 → 3 buckets. + assert_eq!(result.len(), 3); + assert_eq!(result[0][0], "2024-01-15T00:00:00.000"); + assert_eq!(result[0][1], "2024-02-14T23:59:59.999"); + assert_eq!(result[1][0], "2024-02-15T00:00:00.000"); + assert_eq!(result[1][1], "2024-03-14T23:59:59.999"); + assert_eq!(result[2][0], "2024-03-15T00:00:00.000"); + // Apr 15 - 1 second = Apr 14 23:59:59 + assert_eq!(result[2][1], "2024-04-14T23:59:59.999"); + } + + #[test] + fn custom_zero_interval_errors() { + let err = QueryTimeSeries::generate_custom( + "0 days", + &dr("2024-01-01", "2024-01-10"), + "2024-01-01", + 3, + ) + .unwrap_err(); + assert!(err.message.contains("can't be zero")); + } + + #[test] + fn custom_invalid_interval_errors() { + let err = QueryTimeSeries::generate_custom( + "garbage", + &dr("2024-01-01", "2024-01-10"), + "2024-01-01", + 3, + ) + .unwrap_err(); + assert!(!err.message.is_empty()); + } +} diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs index c5bc69e4c706c..2d297fec651ac 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs @@ -69,16 +69,28 @@ impl BaseTools for MockBaseTools { granularity: String, date_range: Vec, ) -> Result>, CubeError> { - super::time_series::generate_time_series(&granularity, &date_range) + if date_range.len() != 2 { + return Err(CubeError::internal( + "date_range must have exactly 2 elements".to_string(), + )); + } + let range = [date_range[0].clone(), date_range[1].clone()]; + crate::planner::QueryTimeSeries::generate_predefined(&granularity, &range, 3) } fn generate_custom_time_series( &self, - _granularity: String, - _date_range: Vec, - _origin: String, + granularity_interval: String, + date_range: Vec, + origin: String, ) -> Result>, CubeError> { - todo!("generate_custom_time_series not implemented in mock") + if date_range.len() != 2 { + return Err(CubeError::internal( + "date_range must have exactly 2 elements".to_string(), + )); + } + let range = [date_range[0].clone(), date_range[1].clone()]; + crate::planner::QueryTimeSeries::generate_custom(&granularity_interval, &range, &origin, 3) } fn get_allocated_params(&self) -> Result, CubeError> { diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs index e2fca83a923d6..e3329aab14ee3 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mod.rs @@ -39,7 +39,6 @@ mod mock_sql_utils; mod mock_struct_with_sql_member; mod mock_timeshift_definition; mod mock_view_filter_definition; -pub mod time_series; pub use base_query_options::{members_from_strings, MockBaseQueryOptions}; pub use mock_base_tools::MockBaseTools; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/time_series.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/time_series.rs deleted file mode 100644 index 39f7a200493bd..0000000000000 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/time_series.rs +++ /dev/null @@ -1,228 +0,0 @@ -use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; -use cubenativeutils::CubeError; - -const TIMESTAMP_PRECISION: usize = 3; - -/// Generates a time series for a given granularity and date range. -/// Matches `timeSeries()` from `@cubejs-backend/shared/src/time.ts`. -pub fn generate_time_series( - granularity: &str, - date_range: &[String], -) -> Result>, CubeError> { - if date_range.len() != 2 { - return Err(CubeError::internal( - "date_range must have exactly 2 elements".to_string(), - )); - } - - let start = parse_date(&date_range[0])?; - let end = parse_date(&date_range[1])?; - - let snap = snap_fn(granularity) - .ok_or_else(|| CubeError::user(format!("Unsupported time granularity: {granularity}")))?; - let advance = advance_fn(granularity).unwrap(); - let period_end = period_end_fn(granularity).unwrap(); - - let mut current = snap(start); - let mut result = Vec::new(); - while current <= end { - let to = period_end(current); - result.push(vec![format_from(current), format_to(to)]); - current = advance(current); - } - - Ok(result) -} - -type DateFn = fn(NaiveDateTime) -> NaiveDateTime; - -/// Snap datetime to the start of its granularity period -fn snap_fn(g: &str) -> Option { - Some(match g { - "second" => |dt| dt.with_nanosecond(0).unwrap(), - "minute" => |dt| make(dt.date(), dt.hour(), dt.minute(), 0), - "hour" => |dt| make(dt.date(), dt.hour(), 0, 0), - "day" => |dt| day_start(dt.date()), - "week" => |dt| { - let days_from_mon = dt.date().weekday().num_days_from_monday(); - day_start(dt.date() - Duration::days(days_from_mon as i64)) - }, - "month" => |dt| day_start(NaiveDate::from_ymd_opt(dt.year(), dt.month(), 1).unwrap()), - "quarter" => |dt| { - let q_month = (dt.month() - 1) / 3 * 3 + 1; - day_start(NaiveDate::from_ymd_opt(dt.year(), q_month, 1).unwrap()) - }, - "year" => |dt| day_start(NaiveDate::from_ymd_opt(dt.year(), 1, 1).unwrap()), - _ => return None, - }) -} - -/// Advance to the next period -fn advance_fn(g: &str) -> Option { - Some(match g { - "second" => |dt| dt + Duration::seconds(1), - "minute" => |dt| dt + Duration::minutes(1), - "hour" => |dt| dt + Duration::hours(1), - "day" => |dt| dt + Duration::days(1), - "week" => |dt| dt + Duration::weeks(1), - "month" => |dt| add_months(dt, 1), - "quarter" => |dt| add_months(dt, 3), - "year" => |dt| add_months(dt, 12), - _ => return None, - }) -} - -/// Get the end of the current period -fn period_end_fn(g: &str) -> Option { - Some(match g { - "second" => |dt| dt, // same second - "minute" => |dt| make(dt.date(), dt.hour(), dt.minute(), 59), - "hour" => |dt| make(dt.date(), dt.hour(), 59, 59), - "day" => |dt| day_end(dt.date()), - "week" => |dt| day_end(dt.date() + Duration::days(6)), - "month" => |dt| day_end(last_day_of_month(dt.year(), dt.month())), - "quarter" => |dt| { - let last_month = (dt.month() - 1) / 3 * 3 + 3; - day_end(last_day_of_month(dt.year(), last_month)) - }, - "year" => |dt| day_end(NaiveDate::from_ymd_opt(dt.year(), 12, 31).unwrap()), - _ => return None, - }) -} - -fn make(date: NaiveDate, h: u32, m: u32, s: u32) -> NaiveDateTime { - date.and_time(NaiveTime::from_hms_opt(h, m, s).unwrap()) -} - -fn day_start(d: NaiveDate) -> NaiveDateTime { - make(d, 0, 0, 0) -} - -fn day_end(d: NaiveDate) -> NaiveDateTime { - make(d, 23, 59, 59) -} - -fn add_months(dt: NaiveDateTime, months: u32) -> NaiveDateTime { - let total = dt.month0() + months; - let new_year = dt.year() + (total / 12) as i32; - let new_month = total % 12 + 1; - day_start(NaiveDate::from_ymd_opt(new_year, new_month, 1).unwrap()) -} - -fn last_day_of_month(year: i32, month: u32) -> NaiveDate { - let (y, m) = if month == 12 { - (year + 1, 1) - } else { - (year, month + 1) - }; - NaiveDate::from_ymd_opt(y, m, 1).unwrap() - Duration::days(1) -} - -fn parse_date(s: &str) -> Result { - NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") - .or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S")) - .or_else(|_| { - NaiveDate::parse_from_str(s, "%Y-%m-%d").map(|d| d.and_hms_opt(0, 0, 0).unwrap()) - }) - .map_err(|_| CubeError::internal(format!("Cannot parse date: '{s}'"))) -} - -fn format_from(dt: NaiveDateTime) -> String { - format!( - "{}.{}", - dt.format("%Y-%m-%dT%H:%M:%S"), - "0".repeat(TIMESTAMP_PRECISION) - ) -} - -fn format_to(dt: NaiveDateTime) -> String { - format!( - "{}.{}", - dt.format("%Y-%m-%dT%H:%M:%S"), - "9".repeat(TIMESTAMP_PRECISION) - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_day() { - let r = generate_time_series("day", &["2025-10-07".into(), "2025-10-09".into()]).unwrap(); - assert_eq!(r.len(), 3); - assert_eq!( - r[0], - vec!["2025-10-07T00:00:00.000", "2025-10-07T23:59:59.999"] - ); - assert_eq!( - r[2], - vec!["2025-10-09T00:00:00.000", "2025-10-09T23:59:59.999"] - ); - } - - #[test] - fn test_month() { - let r = generate_time_series("month", &["2025-01-15".into(), "2025-03-10".into()]).unwrap(); - assert_eq!(r.len(), 3); - assert_eq!(r[0][0], "2025-01-01T00:00:00.000"); - assert_eq!(r[0][1], "2025-01-31T23:59:59.999"); - assert_eq!(r[1][1], "2025-02-28T23:59:59.999"); - } - - #[test] - fn test_week() { - // 2025-10-07 is Tuesday, snaps to Monday 2025-10-06 - let r = generate_time_series("week", &["2025-10-07".into(), "2025-10-14".into()]).unwrap(); - assert_eq!(r.len(), 2); - assert_eq!(r[0][0], "2025-10-06T00:00:00.000"); - assert_eq!(r[0][1], "2025-10-12T23:59:59.999"); - } - - #[test] - fn test_quarter() { - let r = - generate_time_series("quarter", &["2025-01-15".into(), "2025-07-10".into()]).unwrap(); - assert_eq!(r.len(), 3); - assert_eq!(r[0][0], "2025-01-01T00:00:00.000"); - assert_eq!(r[0][1], "2025-03-31T23:59:59.999"); - assert_eq!(r[2][0], "2025-07-01T00:00:00.000"); - } - - #[test] - fn test_year() { - let r = generate_time_series("year", &["2024-06-15".into(), "2025-03-10".into()]).unwrap(); - assert_eq!(r.len(), 2); - assert_eq!( - r[0], - vec!["2024-01-01T00:00:00.000", "2024-12-31T23:59:59.999"] - ); - assert_eq!( - r[1], - vec!["2025-01-01T00:00:00.000", "2025-12-31T23:59:59.999"] - ); - } - - #[test] - fn test_hour() { - let r = generate_time_series( - "hour", - &[ - "2025-10-07T10:30:00.000".into(), - "2025-10-07T12:15:00.000".into(), - ], - ) - .unwrap(); - assert_eq!(r.len(), 3); - assert_eq!(r[0][0], "2025-10-07T10:00:00.000"); - assert_eq!(r[0][1], "2025-10-07T10:59:59.999"); - } - - #[test] - fn test_unsupported() { - assert!( - generate_time_series("millennium", &["2025-01-01".into(), "2025-01-02".into()]) - .is_err() - ); - } -} From 323dbacfd54fdf232b1fb5d28a1126187dd0a62c Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Mon, 1 Jun 2026 11:47:42 +0200 Subject: [PATCH 2/7] fix(tesseract): align week-only custom granularity origin to ISO Monday A custom granularity with a week-only interval (e.g. "2 weeks") and no explicit origin/offset fell back to the default origin (start of the current year) without snapping to the start of the week. That produced bucket boundaries offset from the expected Monday-aligned grid. Snap the default origin to the ISO Monday when the interval is week-only, applied in both the default and offset branches of Granularity. Adds SqlInterval::is_week_only and QueryDateTime::start_of_iso_week, covered by deterministic unit tests in granularity.rs. --- .../src/planner/time_dimension/date_time.rs | 11 +++ .../src/planner/time_dimension/granularity.rs | 76 ++++++++++++++++++- .../planner/time_dimension/sql_interval.rs | 11 +++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/date_time.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/date_time.rs index d615c3f48e644..190e12d88c340 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/date_time.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/date_time.rs @@ -43,6 +43,17 @@ impl QueryDateTime { ) } + pub fn start_of_iso_week(&self) -> Self { + let tz = self.date_time.timezone(); + let date = self.date_time.date_naive(); + let from_monday = date.weekday().num_days_from_monday() as i64; + let monday = date - Duration::days(from_monday); + Self::new( + tz.with_ymd_and_hms(monday.year(), monday.month(), monday.day(), 0, 0, 0) + .unwrap(), + ) + } + pub fn date_time(&self) -> DateTime { self.date_time } diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs index 046a69d1b9238..ec4685fde7968 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/granularity.rs @@ -57,11 +57,18 @@ impl Granularity { let origin = if let Some(origin) = origin { QueryDateTime::from_date_str(timezone, &origin)? } else if let Some(offset) = &granularity_offset { - let origin = Self::default_origin(timezone)?; + // Week-based intervals expect the offset relative to the start of a week. + let origin = Self::fix_origin_for_weeks_if_needed( + Self::default_origin(timezone)?, + &granularity_interval, + ); let interval = SqlInterval::from_str(offset)?; origin.add_interval(&interval)? } else { - Self::default_origin(timezone)? + Self::fix_origin_for_weeks_if_needed( + Self::default_origin(timezone)?, + &granularity_interval, + ) }; let is_natural_aligned = granularity_interval.is_trivial(); @@ -161,6 +168,17 @@ impl Granularity { Ok(QueryDateTime::now(timezone)?.start_of_year()) } + fn fix_origin_for_weeks_if_needed( + origin: QueryDateTime, + interval: &SqlInterval, + ) -> QueryDateTime { + if interval.is_week_only() { + origin.start_of_iso_week() + } else { + origin + } + } + pub fn apply_to_input_sql( &self, templates: &PlanSqlTemplates, @@ -220,3 +238,57 @@ impl Granularity { Ok(true) } } + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Datelike, NaiveDate, Weekday}; + + fn origin_date(g: &Granularity) -> NaiveDate { + NaiveDate::parse_from_str(&g.origin_local_formatted()[..10], "%Y-%m-%d").unwrap() + } + + fn custom(interval: &str, origin: Option<&str>, offset: Option<&str>) -> Granularity { + Granularity::try_new_custom( + "UTC".parse::().unwrap(), + "test_granularity".to_string(), + origin.map(str::to_string), + interval.to_string(), + offset.map(str::to_string), + None, + ) + .unwrap() + } + + #[test] + fn week_only_default_origin_snaps_to_iso_monday() { + assert_eq!( + origin_date(&custom("2 weeks", None, None)).weekday(), + Weekday::Mon + ); + } + + #[test] + fn non_week_default_origin_stays_at_year_start() { + let d = origin_date(&custom("2 days", None, None)); + assert_eq!((d.month(), d.day()), (1, 1)); + } + + #[test] + fn week_with_offset_aligns_to_monday_then_offsets() { + // Monday-of-year-start + 2 days => Wednesday. + assert_eq!( + origin_date(&custom("2 weeks", None, Some("2 days"))).weekday(), + Weekday::Wed + ); + } + + #[test] + fn explicit_origin_is_not_snapped_for_week_interval() { + // 2024-01-03 is a Wednesday; an explicit origin must be preserved verbatim. + assert_eq!( + origin_date(&custom("2 weeks", Some("2024-01-03"), None)), + NaiveDate::from_ymd_opt(2024, 1, 3).unwrap() + ); + } +} diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/sql_interval.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/sql_interval.rs index c9700da3ba06b..c5423817c867c 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/sql_interval.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/sql_interval.rs @@ -63,6 +63,17 @@ impl SqlInterval { Ok(res.to_string()) } + pub fn is_week_only(&self) -> bool { + self.week != 0 + && self.year == 0 + && self.quarter == 0 + && self.month == 0 + && self.day == 0 + && self.hour == 0 + && self.minute == 0 + && self.second == 0 + } + pub fn is_trivial(&self) -> bool { let fields = [ self.year, From 678685b8a5ce8844667cf01f736606d0b828cf31 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Mon, 1 Jun 2026 11:47:52 +0200 Subject: [PATCH 3/7] test(tesseract): cover custom granularity as query granularity Enable the previously-ignored half_year / bi_weekly / to_date rolling window tests now that the Rust time-series path generates the buckets. Pin an explicit origin on the bi_weekly fixture granularity so its bucket alignment no longer depends on the current year, keeping the snapshot stable. Commit the reviewed insta baselines (verified against the seed data). --- ...tegration_rolling_window_custom_granularity.yaml | 1 + .../rolling_window/custom_granularities.rs | 8 -------- ...olling_sum_with_bi_weekly_query_granularity.snap | 13 +++++++++++++ ...olling_sum_with_half_year_query_granularity.snap | 10 ++++++++++ ...ties__to_date_with_custom_query_granularity.snap | 10 ++++++++++ 5 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__rolling_sum_with_bi_weekly_query_granularity.snap create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__rolling_sum_with_half_year_query_granularity.snap create mode 100644 rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__to_date_with_custom_query_granularity.snap diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_rolling_window_custom_granularity.yaml b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_rolling_window_custom_granularity.yaml index 7849273666b86..a87788b57eac5 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_rolling_window_custom_granularity.yaml +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/schemas/yaml_files/common/integration_rolling_window_custom_granularity.yaml @@ -21,6 +21,7 @@ cubes: origin: "2024-01-01" - name: bi_weekly interval: "2 weeks" + origin: "2024-01-01" - name: fiscal_year interval: "1 year" offset: "3 months" diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/custom_granularities.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/custom_granularities.rs index dc2075f82c8ce..1b811ed3b0839 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/custom_granularities.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/custom_granularities.rs @@ -102,10 +102,6 @@ async fn test_to_date_custom_fiscal_year_with_month_granularity() { // --- Custom granularity as query granularity --- -// FIXME: Custom granularity as query granularity requires generate_custom_time_series() -// which is not implemented in mock (todo! in mock_base_tools.rs:81). The planner needs -// the mock to generate the time series for non-standard intervals (half_year, bi_weekly). -#[ignore] #[tokio::test(flavor = "multi_thread")] async fn test_rolling_sum_with_half_year_query_granularity() { let ctx = create_context(); @@ -128,8 +124,6 @@ async fn test_rolling_sum_with_half_year_query_granularity() { } } -// FIXME: Same — generate_custom_time_series() not implemented in mock. -#[ignore] #[tokio::test(flavor = "multi_thread")] async fn test_rolling_sum_with_bi_weekly_query_granularity() { let ctx = create_context(); @@ -152,8 +146,6 @@ async fn test_rolling_sum_with_bi_weekly_query_granularity() { } } -// FIXME: Same — generate_custom_time_series() not implemented in mock. -#[ignore] #[tokio::test(flavor = "multi_thread")] async fn test_to_date_with_custom_query_granularity() { let ctx = create_context(); diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__rolling_sum_with_bi_weekly_query_granularity.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__rolling_sum_with_bi_weekly_query_granularity.snap new file mode 100644 index 0000000000000..c9d53a553174c --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__rolling_sum_with_bi_weekly_query_granularity.snap @@ -0,0 +1,13 @@ +--- +source: cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/custom_granularities.rs +expression: result +--- +orders__created_at_bi_weekly | orders__rolling_sum_7d +-----------------------------+----------------------- +2024-01-01 00:00:00 | NULL +2024-01-15 00:00:00 | NULL +2024-01-29 00:00:00 | 20.00 +2024-02-12 00:00:00 | NULL +2024-02-26 00:00:00 | NULL +2024-03-11 00:00:00 | 30.00 +2024-03-25 00:00:00 | NULL diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__rolling_sum_with_half_year_query_granularity.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__rolling_sum_with_half_year_query_granularity.snap new file mode 100644 index 0000000000000..058a99e9fec2b --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__rolling_sum_with_half_year_query_granularity.snap @@ -0,0 +1,10 @@ +--- +source: cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/custom_granularities.rs +expression: result +--- +orders__created_at_half_year | orders__rolling_sum_7d +-----------------------------+----------------------- +2024-01-01 00:00:00 | NULL +2024-07-01 00:00:00 | NULL +2025-01-01 00:00:00 | NULL +2025-07-01 00:00:00 | NULL diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__to_date_with_custom_query_granularity.snap b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__to_date_with_custom_query_granularity.snap new file mode 100644 index 0000000000000..acf6ab3e8c102 --- /dev/null +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/snapshots/cubesqlplanner__tests__integration__rolling_window__custom_granularities__to_date_with_custom_query_granularity.snap @@ -0,0 +1,10 @@ +--- +source: cubesqlplanner/cubesqlplanner/src/tests/integration/rolling_window/custom_granularities.rs +expression: result +--- +orders__created_at_half_year | orders__rolling_sum_to_date_month +-----------------------------+---------------------------------- +2024-01-01 00:00:00 | 150.00 +2024-07-01 00:00:00 | 400.00 +2025-01-01 00:00:00 | 230.00 +2025-07-01 00:00:00 | 420.00 From 9435e8f4bb9d0eea9a4f626e3ae2eba8849d3737 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Mon, 1 Jun 2026 12:03:55 +0200 Subject: [PATCH 4/7] refactor(tesseract): drop dead time-series bridge methods from BaseTools The planner now generates time series via QueryTimeSeries, so the BaseTools bridge no longer calls generate_time_series / generate_custom_time_series. Remove them from the native_bridge trait and the mock. The JavaScript implementations stay in place for the legacy planner. --- .../src/cube_bridge/base_tools.rs | 18 ++---------- .../cube_bridge/mock_base_tools.rs | 29 ------------------- 2 files changed, 3 insertions(+), 44 deletions(-) diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_tools.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_tools.rs index a784f4ad24a71..208899f5c49a1 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_tools.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/cube_bridge/base_tools.rs @@ -14,26 +14,14 @@ use std::any::Any; use std::rc::Rc; /// Dialect-independent callbacks to the JavaScript side, used -/// during compilation and planning: SQL templates, time-series -/// generation, allocated params, pre-aggregation lookup, join-tree -/// resolution. Dialect-specific helpers live behind `DriverTools`, -/// reachable via `driver_tools()`. +/// during compilation and planning: SQL templates, allocated params, +/// pre-aggregation lookup, join-tree resolution. Dialect-specific +/// helpers live behind `DriverTools`, reachable via `driver_tools()`. #[nativebridge::native_bridge] pub trait BaseTools { fn driver_tools(&self, external: bool) -> Result, CubeError>; fn sql_templates(&self) -> Result, CubeError>; fn sql_utils_for_rust(&self) -> Result, CubeError>; - fn generate_time_series( - &self, - granularity: String, - date_range: Vec, - ) -> Result>, CubeError>; - fn generate_custom_time_series( - &self, - granularity: String, - date_range: Vec, - origin: String, - ) -> Result>, CubeError>; fn get_allocated_params(&self) -> Result, CubeError>; fn all_cube_members(&self, path: String) -> Result, CubeError>; fn interval_and_minimal_time_unit(&self, interval: String) -> Result, CubeError>; diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs index 2d297fec651ac..4140d81bf1a06 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/test_fixtures/cube_bridge/mock_base_tools.rs @@ -64,35 +64,6 @@ impl BaseTools for MockBaseTools { Ok(self.sql_utils.clone()) } - fn generate_time_series( - &self, - granularity: String, - date_range: Vec, - ) -> Result>, CubeError> { - if date_range.len() != 2 { - return Err(CubeError::internal( - "date_range must have exactly 2 elements".to_string(), - )); - } - let range = [date_range[0].clone(), date_range[1].clone()]; - crate::planner::QueryTimeSeries::generate_predefined(&granularity, &range, 3) - } - - fn generate_custom_time_series( - &self, - granularity_interval: String, - date_range: Vec, - origin: String, - ) -> Result>, CubeError> { - if date_range.len() != 2 { - return Err(CubeError::internal( - "date_range must have exactly 2 elements".to_string(), - )); - } - let range = [date_range[0].clone(), date_range[1].clone()]; - crate::planner::QueryTimeSeries::generate_custom(&granularity_interval, &range, &origin, 3) - } - fn get_allocated_params(&self) -> Result, CubeError> { Ok(vec![]) } From 8624d60c4853d544bfde6bfc20f5c001817de921 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Mon, 1 Jun 2026 12:30:58 +0200 Subject: [PATCH 5/7] fix(tesseract): address time-series review feedback - Drop the now-removed generate_time_series / generate_custom_time_series from the cubejs-backend-native bridge test exports (StubBaseTools impl and invoke_base_tools) and the matching JS bridge fixtures / coverage list, so the native crate compiles after the BaseTools trait change. - Cap generate_predefined at the shared MAX_BUCKETS (hoisted to module level) so a wide range at second/minute granularity errors instead of looping unbounded. - add_interval_to_dt: return the combined datetime directly and note the fixed unit-application order. --- .../src/bridge_test_exports.rs | 23 ----------- .../test/bridge/bridge-fixtures.ts | 2 - .../bridge/object-bridges-coverage.test.ts | 2 - .../src/planner/time_dimension/time_series.rs | 41 ++++++++++++++----- 4 files changed, 31 insertions(+), 37 deletions(-) diff --git a/packages/cubejs-backend-native/src/bridge_test_exports.rs b/packages/cubejs-backend-native/src/bridge_test_exports.rs index f569174040f89..6e60eda1e8a92 100644 --- a/packages/cubejs-backend-native/src/bridge_test_exports.rs +++ b/packages/cubejs-backend-native/src/bridge_test_exports.rs @@ -189,21 +189,6 @@ impl BaseTools for StubBaseTools { fn sql_utils_for_rust(&self) -> Result, CubeError> { Err(stub_err("sql_utils_for_rust")) } - fn generate_time_series( - &self, - _granularity: String, - _date_range: Vec, - ) -> Result>, CubeError> { - Err(stub_err("generate_time_series")) - } - fn generate_custom_time_series( - &self, - _granularity: String, - _date_range: Vec, - _origin: String, - ) -> Result>, CubeError> { - Err(stub_err("generate_custom_time_series")) - } fn get_allocated_params(&self) -> Result, CubeError> { Err(stub_err("get_allocated_params")) } @@ -821,14 +806,6 @@ fn invoke_base_tools(b: &NativeBaseTools) -> InvokeResult { r.record("driver_tools", b.driver_tools(false)); r.record("sql_templates", b.sql_templates()); r.record("sql_utils_for_rust", b.sql_utils_for_rust()); - r.record( - "generate_time_series", - b.generate_time_series("day".to_string(), vec![]), - ); - r.record( - "generate_custom_time_series", - b.generate_custom_time_series("day".to_string(), vec![], "2024-01-01".to_string()), - ); r.record("get_allocated_params", b.get_allocated_params()); r.record("all_cube_members", b.all_cube_members("Orders".to_string())); r.record( diff --git a/packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts b/packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts index 67fb875c3af11..b4d3e0d30deb5 100644 --- a/packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts +++ b/packages/cubejs-backend-native/test/bridge/bridge-fixtures.ts @@ -242,8 +242,6 @@ export const baseToolsFixture = (): unknown => ({ driverTools: () => driverToolsFixture(), sqlTemplates: () => ({}), sqlUtilsForRust: () => sqlUtilsFixture(), - generateTimeSeries: () => [], - generateCustomTimeSeries: () => [], getAllocatedParams: () => [], allCubeMembers: () => [], intervalAndMinimalTimeUnit: () => ['1', 'day'], diff --git a/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts b/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts index 629ae33d5945a..8824b465fcbf7 100644 --- a/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts +++ b/packages/cubejs-backend-native/test/bridge/object-bridges-coverage.test.ts @@ -68,8 +68,6 @@ const BRIDGES: BridgeSpec[] = [ expected: [ 'all_cube_members', 'driver_tools', - 'generate_custom_time_series', - 'generate_time_series', 'get_allocated_params', 'get_pre_aggregation_by_name', 'interval_and_minimal_time_unit', diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs index b12a224989947..024ddd3bfb1a6 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs @@ -9,6 +9,11 @@ const PREDEFINED_GRANULARITIES: &[&str] = &[ "second", "minute", "hour", "day", "week", "month", "quarter", "year", ]; +// Cap on emitted buckets. Guards against pathologically wide ranges or tiny +// intervals running effectively unbounded; expressed as an iteration limit +// since calendar units have no fixed length in seconds. +const MAX_BUCKETS: usize = 50_000; + pub fn is_predefined_granularity(name: &str) -> bool { PREDEFINED_GRANULARITIES.contains(&name) } @@ -36,6 +41,12 @@ impl QueryTimeSeries { let mut current = range_start; let mut buckets = Vec::new(); loop { + if buckets.len() >= MAX_BUCKETS { + return Err(CubeError::user(format!( + "Time series exceeded {MAX_BUCKETS} buckets; \ + reduce date range or use a larger granularity" + ))); + } let window = predefined_bucket(granularity, current, timestamp_precision)?; if window.bucket_start > range_end { break; @@ -67,9 +78,6 @@ impl QueryTimeSeries { let nines = "9".repeat(timestamp_precision as usize); let mut aligned = align_to_origin(range_start, &interval, origin)?; let mut buckets = Vec::new(); - // Guard against pathologically tiny intervals, expressed as an - // iteration limit since calendar units have no fixed length in seconds. - const MAX_BUCKETS: usize = 50_000; while aligned < range_end { if buckets.len() >= MAX_BUCKETS { return Err(CubeError::user(format!( @@ -279,8 +287,13 @@ fn add_interval_to_dt( dt: NaiveDateTime, interval: &SqlInterval, ) -> Result { + // Units are applied in a fixed order (months → days → sub-day) regardless + // of how they appear in the interval string. For non-canonical intervals + // (e.g. "5 days 1 month") month-end clamping could differ from applying + // units in their written order; in practice granularity intervals are + // canonical, so this stays a latent edge case. let mut date = dt.date(); - let mut time = dt.time(); + let time = dt.time(); // Calendar parts first (year/quarter/month) — they're not fixed in seconds. let total_months = interval.year * 12 + interval.quarter * 3 + interval.month; date = apply_months(date, total_months)?; @@ -295,13 +308,9 @@ fn add_interval_to_dt( + Duration::minutes(interval.minute as i64) + Duration::seconds(interval.second as i64); let combined = date.and_time(time); - let result = combined + combined .checked_add_signed(sub_day) - .ok_or_else(|| CubeError::user(format!("Date overflow adding sub-day to {dt}")))?; - // Re-extract time in case sub_day arithmetic rolled days. - date = result.date(); - time = result.time(); - Ok(date.and_time(time)) + .ok_or_else(|| CubeError::user(format!("Date overflow adding sub-day to {dt}"))) } fn sub_interval_from_dt( @@ -530,6 +539,18 @@ mod tests { assert!(err.message.contains("Unsupported timestamp precision")); } + #[test] + fn predefined_exceeding_buckets_errors() { + // One day at second granularity is 86_400 buckets, over the 50k cap. + let err = QueryTimeSeries::generate_predefined( + "second", + &dr("2024-01-10T00:00:00", "2024-01-11T00:00:00"), + 3, + ) + .unwrap_err(); + assert!(err.message.contains("exceeded")); + } + // ---- custom ---- #[test] From 945634da52395736ceb41ab804466bbe4e55af89 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Mon, 1 Jun 2026 12:57:54 +0200 Subject: [PATCH 6/7] test(tesseract): cover custom interval edge cases and bucket cap Add unit tests for previously-uncovered QueryTimeSeries paths: - custom MAX_BUCKETS cap - months+days and sub-day intervals in add_interval_to_dt - the non-advancing (net-negative) interval guard - precision-6 padding on the custom path - quarter and year custom intervals --- .../src/planner/time_dimension/time_series.rs | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs index 024ddd3bfb1a6..6ee7c6717039b 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs @@ -636,4 +636,117 @@ mod tests { .unwrap_err(); assert!(!err.message.is_empty()); } + + #[test] + fn custom_exceeding_buckets_errors() { + // One day at a 1-second interval is 86_400 buckets, over the 50k cap. + let err = QueryTimeSeries::generate_custom( + "1 second", + &dr("2024-01-10T00:00:00", "2024-01-11T00:00:00"), + "2024-01-10T00:00:00", + 3, + ) + .unwrap_err(); + assert!(err.message.contains("exceeded")); + } + + #[test] + fn custom_non_advancing_interval_errors() { + // Net-negative interval with origin == range start reaches the + // "did not advance" guard without spinning in alignment. + let err = QueryTimeSeries::generate_custom( + "-1 day", + &dr("2024-01-01", "2024-01-10"), + "2024-01-01", + 3, + ) + .unwrap_err(); + assert!(err.message.contains("did not advance")); + } + + #[test] + fn custom_month_plus_days_interval() { + // "1 month 15 days" from Jan 1: Feb 16, Mar 31, May 15. Mar 31 + 1 month + // clamps to Apr 30 before adding 15 days, exercising months+days. + let result = QueryTimeSeries::generate_custom( + "1 month 15 days", + &dr("2024-01-01", "2024-05-01"), + "2024-01-01", + 3, + ) + .unwrap(); + assert_eq!(result.len(), 3); + assert_eq!(result[0][0], "2024-01-01T00:00:00.000"); + assert_eq!(result[0][1], "2024-02-15T23:59:59.999"); + assert_eq!(result[1][0], "2024-02-16T00:00:00.000"); + assert_eq!(result[1][1], "2024-03-30T23:59:59.999"); + assert_eq!(result[2][0], "2024-03-31T00:00:00.000"); + assert_eq!(result[2][1], "2024-05-14T23:59:59.999"); + } + + #[test] + fn custom_sub_day_interval() { + // "2 days 6 hours" = 54h steps from Jan 1 00:00. + let result = QueryTimeSeries::generate_custom( + "2 days 6 hours", + &dr("2024-01-01T00:00:00", "2024-01-06T00:00:00"), + "2024-01-01T00:00:00", + 3, + ) + .unwrap(); + assert_eq!(result.len(), 3); + assert_eq!( + result[0], + vec!["2024-01-01T00:00:00.000", "2024-01-03T05:59:59.999"] + ); + assert_eq!(result[1][0], "2024-01-03T06:00:00.000"); + assert_eq!(result[2][0], "2024-01-05T12:00:00.000"); + } + + #[test] + fn custom_quarter_interval() { + let result = QueryTimeSeries::generate_custom( + "1 quarter", + &dr("2024-01-01", "2024-12-31"), + "2024-01-01", + 3, + ) + .unwrap(); + assert_eq!(result.len(), 4); + assert_eq!(result[0][0], "2024-01-01T00:00:00.000"); + assert_eq!(result[0][1], "2024-03-31T23:59:59.999"); + assert_eq!(result[3][0], "2024-10-01T00:00:00.000"); + assert_eq!(result[3][1], "2024-12-31T23:59:59.999"); + } + + #[test] + fn custom_year_interval() { + let result = QueryTimeSeries::generate_custom( + "1 year", + &dr("2022-01-01", "2024-06-01"), + "2022-01-01", + 3, + ) + .unwrap(); + assert_eq!(result.len(), 3); + assert_eq!(result[0][1], "2022-12-31T23:59:59.999"); + assert_eq!(result[2][0], "2024-01-01T00:00:00.000"); + assert_eq!(result[2][1], "2024-12-31T23:59:59.999"); + } + + #[test] + fn custom_precision_six() { + let result = QueryTimeSeries::generate_custom( + "1 day", + &dr("2024-01-01", "2024-01-02"), + "2024-01-01", + 6, + ) + .unwrap(); + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + vec!["2024-01-01T00:00:00.000000", "2024-01-01T23:59:59.999999"] + ); + } } From d169333c65b7476594a587570c9ede9f1f0641f0 Mon Sep 17 00:00:00 2001 From: Aleksandr Romanenko Date: Mon, 1 Jun 2026 13:14:49 +0200 Subject: [PATCH 7/7] fix(tesseract): cap origin alignment and clarify boundary semantics - Guard both align_to_origin loops with a MAX_BUCKETS cap so a net-negative or net-zero interval errors instead of spinning the planner thread; add a regression test for start < origin. - Document the intentional inclusive (predefined) vs exclusive (custom) range-end handling. - Tighten the add_interval_to_dt comment: SqlInterval is field-based, so month-end clamping happens before days are added. --- .../src/planner/time_dimension/time_series.rs | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs index 6ee7c6717039b..0e2126b0cafa8 100644 --- a/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs +++ b/rust/cube/cubesqlplanner/cubesqlplanner/src/planner/time_dimension/time_series.rs @@ -48,6 +48,10 @@ impl QueryTimeSeries { ))); } let window = predefined_bucket(granularity, current, timestamp_precision)?; + // Inclusive upper bound: a bucket whose start lands exactly on + // range_end is kept. The custom path is exclusive (`aligned < + // range_end`); the asymmetry is intentional and matches the + // established predefined/custom semantics. if window.bucket_start > range_end { break; } @@ -287,11 +291,10 @@ fn add_interval_to_dt( dt: NaiveDateTime, interval: &SqlInterval, ) -> Result { - // Units are applied in a fixed order (months → days → sub-day) regardless - // of how they appear in the interval string. For non-canonical intervals - // (e.g. "5 days 1 month") month-end clamping could differ from applying - // units in their written order; in practice granularity intervals are - // canonical, so this stays a latent edge case. + // `SqlInterval` is field-based, so the written order of the source string + // is already lost. Units are applied months → days → sub-day; month-end + // clamping (e.g. Mar 31 + 1 month → Apr 30) therefore happens before days + // are added. let mut date = dt.date(); let time = dt.time(); // Calendar parts first (year/quarter/month) — they're not fixed in seconds. @@ -345,15 +348,20 @@ fn align_to_origin( ) -> Result { let mut aligned = start; let mut offset = origin; + // Cap iterations: a net-negative (or net-zero) interval would otherwise + // step away from `start` forever instead of converging on it. + let mut steps = 0; if start < origin { // Pull origin back until it sits at or below start. while offset > start { + converge_guard(&mut steps)?; offset = sub_interval_from_dt(offset, interval)?; } aligned = offset; } else { // Push origin forward; remember the last step that didn't overshoot. while offset < start { + converge_guard(&mut steps)?; aligned = offset; offset = add_interval_to_dt(offset, interval)?; } @@ -364,6 +372,16 @@ fn align_to_origin( Ok(aligned) } +fn converge_guard(steps: &mut usize) -> Result<(), CubeError> { + *steps += 1; + if *steps > MAX_BUCKETS { + return Err(CubeError::user( + "Origin alignment did not converge; check the granularity interval".to_string(), + )); + } + Ok(()) +} + fn format_with_padding(dt: NaiveDateTime, sub_second: &str) -> String { format!( "{}T{:02}:{:02}:{:02}.{sub_second}", @@ -664,6 +682,20 @@ mod tests { assert!(err.message.contains("did not advance")); } + #[test] + fn custom_alignment_non_convergent_errors() { + // start < origin with a net-negative interval would step away from + // start forever in align_to_origin; the cap turns it into an error. + let err = QueryTimeSeries::generate_custom( + "-1 day", + &dr("2024-01-05", "2024-01-10"), + "2024-01-10", + 3, + ) + .unwrap_err(); + assert!(err.message.contains("did not converge")); + } + #[test] fn custom_month_plus_days_interval() { // "1 month 15 days" from Jan 1: Feb 16, Mar 31, May 15. Mar 31 + 1 month