From 504044c61acfa82e0d2e87ee1d5216c773f4839e Mon Sep 17 00:00:00 2001 From: angela-helios Date: Thu, 30 Jul 2026 19:29:54 -0400 Subject: [PATCH 1/2] fix(search): normalize sqlite date comparisons across precisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stored value_date keeps the resource's own precision ('1995-10-02', '2016-01-23T13:07:42-04:00') while search bounds were full datetimes, and SQLite compares TEXT — lexicographically '1995-10-02' sorts before '1995-10-02T00:00:00', so a day never fell inside its own range: eq matched nothing, ge excluded the named day, le/lt leaked the next midnight in. Full-precision values built the impossible range '>= X AND < X'. One shared date_condition() now serves the parameter handler, the chain builder (previously a raw text '=' regardless of precision), and the _filter parser: both sides go through datetime(), which normalizes partial dates and folds timezone offsets to UTC, and the upper bound of a partial value is derived in SQL ('+1 day') so a single bind parameter serves both ends — which also fixes the latent multi-value numbering collision where eq consumed two parameter slots while the caller advanced by one. Postgres parses dates into real timestamps on its own path; the ES and Mongo handlers are unverified and covered by the #448 backend legs. Closes #456 --- .../backends/sqlite/search/chain_builder.rs | 31 +- .../backends/sqlite/search/filter_parser.rs | 24 ++ .../sqlite/search/parameter_handlers/date.rs | 310 +++++++----------- .../sqlite/search/parameter_handlers/mod.rs | 2 +- crates/rest/tests/search_integration.rs | 112 +++++++ 5 files changed, 265 insertions(+), 214 deletions(-) diff --git a/crates/persistence/src/backends/sqlite/search/chain_builder.rs b/crates/persistence/src/backends/sqlite/search/chain_builder.rs index 58a8e1a8e..091e33c48 100644 --- a/crates/persistence/src/backends/sqlite/search/chain_builder.rs +++ b/crates/persistence/src/backends/sqlite/search/chain_builder.rs @@ -740,30 +740,13 @@ impl ChainQueryBuilder { /// Builds a date comparison condition. fn build_date_condition(column: &str, value: &SearchValue, param_num: usize) -> (String, SqlParam) { - use crate::types::SearchPrefix; - - let (op, val) = match value.prefix { - SearchPrefix::Eq => ("=", &value.value), - SearchPrefix::Ne => ("!=", &value.value), - SearchPrefix::Gt => (">", &value.value), - SearchPrefix::Lt => ("<", &value.value), - SearchPrefix::Ge => (">=", &value.value), - SearchPrefix::Le => ("<=", &value.value), - SearchPrefix::Sa => (">", &value.value), - SearchPrefix::Eb => ("<", &value.value), - SearchPrefix::Ap => { - // Approximately equal: within a day for dates - return ( - format!("DATE({}) = DATE(?{})", column, param_num), - SqlParam::String(value.value.clone()), - ); - } - }; - - ( - format!("{} {} ?{}", column, op, param_num), - SqlParam::String(val.clone()), - ) + let (sql, bound) = super::parameter_handlers::date::date_condition( + column, + value.prefix, + &value.value, + param_num, + ); + (sql, SqlParam::String(bound)) } /// Builds a number comparison condition. diff --git a/crates/persistence/src/backends/sqlite/search/filter_parser.rs b/crates/persistence/src/backends/sqlite/search/filter_parser.rs index 36048f785..e0e0f8c9b 100644 --- a/crates/persistence/src/backends/sqlite/search/filter_parser.rs +++ b/crates/persistence/src/backends/sqlite/search/filter_parser.rs @@ -514,6 +514,30 @@ impl FilterSqlGenerator { // Infer the likely column based on parameter name patterns let column = self.infer_column(param); + // Dates need precision-aware, normalized comparison (#456); the + // generic text operators below mis-order mixed-precision values. + if column == "value_date" { + use crate::types::SearchPrefix; + let prefix = match op { + FilterOp::Eq => Some(SearchPrefix::Eq), + FilterOp::Ne => Some(SearchPrefix::Ne), + FilterOp::Gt => Some(SearchPrefix::Gt), + FilterOp::Sa => Some(SearchPrefix::Sa), + FilterOp::Lt => Some(SearchPrefix::Lt), + FilterOp::Eb => Some(SearchPrefix::Eb), + FilterOp::Ge => Some(SearchPrefix::Ge), + FilterOp::Le => Some(SearchPrefix::Le), + FilterOp::Ap => Some(SearchPrefix::Ap), + _ => None, + }; + if let Some(prefix) = prefix { + let (sql, bound) = super::parameter_handlers::date::date_condition( + column, prefix, value, param_num, + ); + return (column, sql, bound); + } + } + match op { FilterOp::Eq => ( column, diff --git a/crates/persistence/src/backends/sqlite/search/parameter_handlers/date.rs b/crates/persistence/src/backends/sqlite/search/parameter_handlers/date.rs index ee2c82cf5..7eb1e6e64 100644 --- a/crates/persistence/src/backends/sqlite/search/parameter_handlers/date.rs +++ b/crates/persistence/src/backends/sqlite/search/parameter_handlers/date.rs @@ -4,6 +4,74 @@ use crate::types::{DatePrecision, SearchPrefix, SearchValue}; use super::super::query_builder::{SqlFragment, SqlParam}; +/// Builds a precision-aware date comparison against a `value_date`-style TEXT +/// column, with one bind parameter (#456). +/// +/// Stored values keep whatever precision the resource carried +/// (`"1995-10-02"`, `"2016-01-23T13:07:42-04:00"`), while search bounds are +/// full datetimes — and SQLite compares TEXT lexicographically, where +/// `'1995-10-02' < '1995-10-02T00:00:00'`, so a day never fell inside its own +/// range. Both sides therefore go through `datetime()`, which normalizes +/// partial dates to `YYYY-MM-DD HH:MM:SS` and folds timezone offsets to UTC. +/// The upper bound of a partial-precision value is derived in SQL with a +/// modifier (`'+1 day'`), so a single parameter serves both ends of the range +/// wherever the caller can only bind one. +/// +/// `datetime()` truncates fractional seconds, so millisecond-precision values +/// compare at second precision — a match too many beats never matching. +/// +/// Returns the SQL and the value to bind for its (single) parameter. +pub(crate) fn date_condition( + column: &str, + prefix: SearchPrefix, + value: &str, + param_num: usize, +) -> (String, String) { + let precision = DatePrecision::from_date_string(value); + + // The range start as a full datetime (always parseable by datetime()), + // and the SQL modifier that derives the range end for partial precisions. + let (start, bump) = match precision { + DatePrecision::Year => (format!("{}-01-01T00:00:00", &value[..4]), Some("+1 year")), + DatePrecision::Month => (format!("{}-01T00:00:00", &value[..7]), Some("+1 month")), + DatePrecision::Day => (format!("{value}T00:00:00"), Some("+1 day")), + _ => (value.to_string(), None), + }; + + let col = format!("datetime({column})"); + let p = format!("datetime(?{param_num})"); + let end = |m: &str| format!("datetime(?{param_num}, '{m}')"); + + let sql = match (prefix, bump) { + (SearchPrefix::Eq, Some(m)) => format!("({col} >= {p} AND {col} < {})", end(m)), + (SearchPrefix::Eq, None) => format!("{col} = {p}"), + (SearchPrefix::Ne, Some(m)) => format!("({col} < {p} OR {col} >= {})", end(m)), + (SearchPrefix::Ne, None) => format!("{col} != {p}"), + // gt / sa: strictly after the whole range. + (SearchPrefix::Gt | SearchPrefix::Sa, Some(m)) => format!("{col} >= {}", end(m)), + (SearchPrefix::Gt | SearchPrefix::Sa, None) => format!("{col} > {p}"), + // lt / eb: strictly before the whole range. + (SearchPrefix::Lt | SearchPrefix::Eb, _) => format!("{col} < {p}"), + (SearchPrefix::Ge, _) => format!("{col} >= {p}"), + (SearchPrefix::Le, Some(m)) => format!("{col} < {}", end(m)), + (SearchPrefix::Le, None) => format!("{col} <= {p}"), + (SearchPrefix::Ap, _) => { + let m = match precision { + DatePrecision::Year => "1 year", + DatePrecision::Month => "1 month", + DatePrecision::Day => "1 day", + DatePrecision::Hour => "1 hour", + DatePrecision::Minute => "10 minutes", + DatePrecision::Second | DatePrecision::Millisecond => "10 seconds", + }; + format!( + "{col} BETWEEN datetime(?{param_num}, '-{m}') AND datetime(?{param_num}, '+{m}')" + ) + } + }; + (sql, start) +} + /// Handles date parameter SQL generation. pub struct DateHandler; @@ -16,220 +84,84 @@ impl DateHandler { /// - "2024-01-15" matches the entire day pub fn build_sql(value: &SearchValue, param_offset: usize) -> SqlFragment { let param_num = param_offset + 1; - let date_value = &value.value; - let precision = DatePrecision::from_date_string(date_value); - - // Precision range [start, end). Comparators match against its - // boundaries per the FHIR spec. When the value is full-precision the - // range is degenerate (start == end); fall back to scalar comparison - // so an exact instant still matches le/ge/eq. - let (start, end) = Self::get_precision_range(date_value, precision); - - match value.prefix { - SearchPrefix::Eq => Self::build_equals(date_value, precision, param_num), - SearchPrefix::Ne => Self::build_not_equals(date_value, precision, param_num), - // gt / sa: strictly after the whole range → value_date >= end. - SearchPrefix::Gt | SearchPrefix::Sa if start != end => Self::cmp(">=", &end, param_num), - SearchPrefix::Gt | SearchPrefix::Sa => Self::cmp(">", date_value, param_num), - // lt / eb: strictly before the whole range → value_date < start. - SearchPrefix::Lt | SearchPrefix::Eb if start != end => { - Self::cmp("<", &start, param_num) - } - SearchPrefix::Lt | SearchPrefix::Eb => Self::cmp("<", date_value, param_num), - SearchPrefix::Ge if start != end => Self::cmp(">=", &start, param_num), - SearchPrefix::Ge => Self::cmp(">=", date_value, param_num), - SearchPrefix::Le if start != end => Self::cmp("<", &end, param_num), - SearchPrefix::Le => Self::cmp("<=", date_value, param_num), - SearchPrefix::Ap => Self::build_approximately(date_value, precision, param_num), - } - } - - /// Builds a single-boundary date comparison `value_date {op} ?`. - fn cmp(op: &str, bound: &str, param_num: usize) -> SqlFragment { - SqlFragment::with_params( - format!("value_date {} ?{}", op, param_num), - vec![SqlParam::string(bound)], - ) + let (sql, bound) = date_condition("value_date", value.prefix, &value.value, param_num); + SqlFragment::with_params(sql, vec![SqlParam::string(bound)]) } +} - /// Equality - matches any date within the precision range. - fn build_equals(date: &str, precision: DatePrecision, param_num: usize) -> SqlFragment { - let (start, end) = Self::get_precision_range(date, precision); +#[cfg(test)] +mod tests { + use super::*; - SqlFragment::with_params( - format!( - "value_date >= ?{} AND value_date < ?{}", - param_num, - param_num + 1 - ), - vec![SqlParam::string(start), SqlParam::string(end)], - ) + fn sql_and_param(prefix: SearchPrefix, value: &str) -> (String, String) { + date_condition("value_date", prefix, value, 1) } - /// Not equals - outside the precision range. - fn build_not_equals(date: &str, precision: DatePrecision, param_num: usize) -> SqlFragment { - let (start, end) = Self::get_precision_range(date, precision); - - SqlFragment::with_params( - format!( - "(value_date < ?{} OR value_date >= ?{})", - param_num, - param_num + 1 - ), - vec![SqlParam::string(start), SqlParam::string(end)], - ) + #[test] + fn eq_day_is_a_normalized_half_open_range() { + let (sql, param) = sql_and_param(SearchPrefix::Eq, "1995-10-02"); + assert_eq!( + sql, + "(datetime(value_date) >= datetime(?1) AND datetime(value_date) < datetime(?1, '+1 day'))" + ); + assert_eq!(param, "1995-10-02T00:00:00"); } - /// Approximately equals - +/- based on precision. - fn build_approximately(date: &str, precision: DatePrecision, param_num: usize) -> SqlFragment { - // SQLite datetime functions for range calculation - let modifier = match precision { - DatePrecision::Year => "1 year", - DatePrecision::Month => "1 month", - DatePrecision::Day => "1 day", - DatePrecision::Hour => "1 hour", - DatePrecision::Minute => "10 minutes", - DatePrecision::Second | DatePrecision::Millisecond => "10 seconds", - }; - - SqlFragment::with_params( - format!( - "value_date BETWEEN datetime(?{}, '-{}') AND datetime(?{}, '+{}')", - param_num, modifier, param_num, modifier - ), - vec![SqlParam::string(date)], - ) + #[test] + fn eq_full_precision_is_normalized_equality_not_an_empty_range() { + let (sql, param) = sql_and_param(SearchPrefix::Eq, "2016-01-23T13:07:42-04:00"); + assert_eq!(sql, "datetime(value_date) = datetime(?1)"); + assert_eq!(param, "2016-01-23T13:07:42-04:00"); } - /// Gets the start and end of the range for a date at a given precision. - fn get_precision_range(date: &str, precision: DatePrecision) -> (String, String) { - match precision { - DatePrecision::Year => { - let year = &date[..4]; - ( - format!("{}-01-01T00:00:00", year), - format!("{}-01-01T00:00:00", year.parse::().unwrap_or(0) + 1), - ) - } - DatePrecision::Month => { - let (year, month) = (&date[..4], &date[5..7]); - let year_num: i32 = year.parse().unwrap_or(0); - let month_num: i32 = month.parse().unwrap_or(1); - - let (next_year, next_month) = if month_num >= 12 { - (year_num + 1, 1) - } else { - (year_num, month_num + 1) - }; - - ( - format!("{}-{:02}-01T00:00:00", year, month_num), - format!("{}-{:02}-01T00:00:00", next_year, next_month), - ) - } - DatePrecision::Day => ( - format!("{}T00:00:00", date), - format!("{}T00:00:00", Self::add_day(date)), - ), - _ => { - // For finer precisions, use the exact value - (date.to_string(), date.to_string()) - } - } + #[test] + fn ge_includes_the_named_day_itself() { + let (sql, param) = sql_and_param(SearchPrefix::Ge, "1995-10-02"); + assert_eq!(sql, "datetime(value_date) >= datetime(?1)"); + assert_eq!(param, "1995-10-02T00:00:00"); } - /// Adds one day to a date string. - fn add_day(date: &str) -> String { - // Simple date arithmetic - in production, use proper datetime library - let parts: Vec<&str> = date.split('-').collect(); - if parts.len() >= 3 { - let year: i32 = parts[0].parse().unwrap_or(0); - let month: i32 = parts[1].parse().unwrap_or(1); - let day: i32 = parts[2].parse().unwrap_or(1); - - let days_in_month = match month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 => { - if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) { - 29 - } else { - 28 - } - } - _ => 30, - }; - - if day >= days_in_month { - if month >= 12 { - format!("{}-01-01", year + 1) - } else { - format!("{}-{:02}-01", year, month + 1) - } - } else { - format!("{}-{:02}-{:02}", year, month, day + 1) - } - } else { - date.to_string() - } + #[test] + fn gt_starts_strictly_after_the_day() { + let (sql, _) = sql_and_param(SearchPrefix::Gt, "2024-01-15"); + assert_eq!(sql, "datetime(value_date) >= datetime(?1, '+1 day')"); } -} - -#[cfg(test)] -mod tests { - use super::*; #[test] - fn test_date_eq_day() { - let value = SearchValue::new(SearchPrefix::Eq, "2024-01-15"); - let frag = DateHandler::build_sql(&value, 0); - - assert!(frag.sql.contains(">=")); - assert!(frag.sql.contains("<")); - assert_eq!(frag.params.len(), 2); + fn lt_excludes_the_boundary_day() { + let (sql, param) = sql_and_param(SearchPrefix::Lt, "1996-01-01"); + assert_eq!(sql, "datetime(value_date) < datetime(?1)"); + assert_eq!(param, "1996-01-01T00:00:00"); } #[test] - fn test_date_gt() { - // gt on day-precision "2024-01-15" matches strictly after the day, i.e. - // value_date >= 2024-01-16T00:00:00. - let value = SearchValue::new(SearchPrefix::Gt, "2024-01-15"); - let frag = DateHandler::build_sql(&value, 0); - - assert!(frag.sql.contains(">= ?1")); - assert_eq!(frag.params.len(), 1); - match &frag.params[0] { - SqlParam::String(s) => assert_eq!(s, "2024-01-16T00:00:00"), - _ => panic!("expected string bound"), - } + fn le_reaches_the_end_of_the_named_day() { + let (sql, _) = sql_and_param(SearchPrefix::Le, "2024-01-15"); + assert_eq!(sql, "datetime(value_date) < datetime(?1, '+1 day')"); } #[test] - fn test_date_le() { - // le on day-precision matches up to the end of the day → < next day. - let value = SearchValue::new(SearchPrefix::Le, "2024-01-15"); - let frag = DateHandler::build_sql(&value, 0); - - assert!(frag.sql.contains("< ?1")); - match &frag.params[0] { - SqlParam::String(s) => assert_eq!(s, "2024-01-16T00:00:00"), - _ => panic!("expected string bound"), - } + fn year_and_month_bounds_are_datetime_parseable() { + let (_, year) = sql_and_param(SearchPrefix::Eq, "1995"); + assert_eq!(year, "1995-01-01T00:00:00"); + let (sql, month) = sql_and_param(SearchPrefix::Eq, "1995-10"); + assert_eq!(month, "1995-10-01T00:00:00"); + assert!(sql.contains("'+1 month'")); } #[test] - fn test_date_ap() { - let value = SearchValue::new(SearchPrefix::Ap, "2024-01-15"); - let frag = DateHandler::build_sql(&value, 0); - - assert!(frag.sql.contains("BETWEEN")); - assert!(frag.sql.contains("datetime")); + fn ap_scales_with_precision() { + let (sql, param) = sql_and_param(SearchPrefix::Ap, "2024-01-15"); + assert!(sql.contains("BETWEEN datetime(?1, '-1 day') AND datetime(?1, '+1 day')")); + assert_eq!(param, "2024-01-15T00:00:00"); } #[test] - fn test_add_day() { - assert_eq!(DateHandler::add_day("2024-01-15"), "2024-01-16"); - assert_eq!(DateHandler::add_day("2024-01-31"), "2024-02-01"); - assert_eq!(DateHandler::add_day("2024-12-31"), "2025-01-01"); + fn build_sql_binds_exactly_one_parameter() { + // The multi-value caller advances the offset by one per value, so eq + // must not consume two slots. + let value = SearchValue::new(SearchPrefix::Eq, "2024-01-15"); + let frag = DateHandler::build_sql(&value, 0); + assert_eq!(frag.params.len(), 1); } } diff --git a/crates/persistence/src/backends/sqlite/search/parameter_handlers/mod.rs b/crates/persistence/src/backends/sqlite/search/parameter_handlers/mod.rs index 60f0407a7..fd90bf3e4 100644 --- a/crates/persistence/src/backends/sqlite/search/parameter_handlers/mod.rs +++ b/crates/persistence/src/backends/sqlite/search/parameter_handlers/mod.rs @@ -3,7 +3,7 @@ //! Each handler knows how to generate SQL conditions for its parameter type. mod composite; -mod date; +pub(crate) mod date; mod number; mod quantity; mod reference; diff --git a/crates/rest/tests/search_integration.rs b/crates/rest/tests/search_integration.rs index c0094311a..4d2312d98 100644 --- a/crates/rest/tests/search_integration.rs +++ b/crates/rest/tests/search_integration.rs @@ -2577,3 +2577,115 @@ mod summary_count { assert!(body["total"].is_null(), "explicit _total=none wins: {body}"); } } + +/// #456: date search must honor the value's precision at every boundary. +/// Stored dates keep their source precision while bounds are full datetimes, +/// and SQLite compares text — so a day never fell inside its own range, and a +/// full-precision timestamp built the impossible range `>= X AND < X`. +mod date_precision { + use super::*; + + async fn seed(backend: &SqliteBackend) { + let tenant = test_tenant(); + let patients = vec![ + json!({"resourceType": "Patient", "id": "d-boundary", "birthDate": "1995-10-02"}), + json!({"resourceType": "Patient", "id": "d-new-year", "birthDate": "1996-01-01"}), + json!({"resourceType": "Patient", "id": "d-earlier", "birthDate": "1975-03-21"}), + ]; + for p in patients { + backend + .create(&tenant, "Patient", p, FhirVersion::R4) + .await + .expect("seed patient"); + } + backend + .create( + &tenant, + "Observation", + json!({ + "resourceType": "Observation", + "id": "d-obs", + "status": "final", + "code": {"coding": [{"system": "http://loinc.org", "code": "8302-2"}]}, + "subject": {"reference": "Patient/d-boundary"}, + "effectiveDateTime": "2016-01-23T13:07:42-04:00" + }), + FhirVersion::R4, + ) + .await + .expect("seed observation"); + } + + async fn total(server: &TestServer, query: &str) -> u64 { + let response = server + .get(&format!("{query}&_total=accurate")) + .add_header(X_TENANT_ID, HeaderValue::from_static("test-tenant")) + .await; + response.assert_status_ok(); + response.json::()["total"].as_u64().expect("total") + } + + #[tokio::test] + async fn day_precision_boundaries() { + let (server, backend) = create_test_server().await; + seed(&backend).await; + + // eq: the patient born that exact day is found. + assert_eq!(total(&server, "/Patient?birthdate=1995-10-02").await, 1); + // ge includes the named day itself. + assert_eq!(total(&server, "/Patient?birthdate=ge1995-10-02").await, 2); + // gt starts strictly after the day. + assert_eq!(total(&server, "/Patient?birthdate=gt1995-10-02").await, 1); + // le must NOT leak into the next day's midnight. + assert_eq!(total(&server, "/Patient?birthdate=le1995-12-31").await, 2); + // lt excludes the boundary day. + assert_eq!(total(&server, "/Patient?birthdate=lt1996-01-01").await, 2); + // A same-day sandwich pins exactly the one patient. + assert_eq!( + total( + &server, + "/Patient?birthdate=ge1995-10-02&birthdate=le1995-10-02" + ) + .await, + 1 + ); + } + + #[tokio::test] + async fn coarser_precisions_still_match() { + let (server, backend) = create_test_server().await; + seed(&backend).await; + + assert_eq!(total(&server, "/Patient?birthdate=1995-10").await, 1); + // The year range must not swallow 1996-01-01. + assert_eq!(total(&server, "/Patient?birthdate=1995").await, 1); + } + + #[tokio::test] + async fn full_precision_timestamp_matches_itself() { + let (server, backend) = create_test_server().await; + seed(&backend).await; + + assert_eq!( + total(&server, "/Observation?date=2016-01-23T13:07:42-04:00").await, + 1 + ); + // And the same instant expressed in UTC matches too: datetime() + // folds offsets before comparing. + assert_eq!( + total(&server, "/Observation?date=2016-01-23T17:07:42Z").await, + 1 + ); + } + + #[tokio::test] + async fn chained_date_inherits_the_fix() { + let (server, backend) = create_test_server().await; + seed(&backend).await; + + assert_eq!( + total(&server, "/Observation?patient.birthdate=1995-10-02").await, + 1 + ); + } +} From 1db4806cb01c4d997e323730e50a4a4a63bda722 Mon Sep 17 00:00:00 2001 From: angela-helios Date: Thu, 30 Jul 2026 22:36:49 -0400 Subject: [PATCH 2/2] test(search): cover every date-comparison arm the fix rewrote The codecov patch gap was real: the ne/sa/eb arms, the full-precision single-bound comparisons, the ap windows at finer precisions, the aliased-column pass-through, the chained-terminal delegate, and the _filter date branch had no direct coverage. --- .../backends/sqlite/search/chain_builder.rs | 29 +++++++ .../backends/sqlite/search/filter_parser.rs | 44 +++++++++++ .../sqlite/search/parameter_handlers/date.rs | 75 +++++++++++++++++++ 3 files changed, 148 insertions(+) diff --git a/crates/persistence/src/backends/sqlite/search/chain_builder.rs b/crates/persistence/src/backends/sqlite/search/chain_builder.rs index 091e33c48..0fb33d07a 100644 --- a/crates/persistence/src/backends/sqlite/search/chain_builder.rs +++ b/crates/persistence/src/backends/sqlite/search/chain_builder.rs @@ -959,3 +959,32 @@ mod tests { assert!(!outer.is_terminal()); } } + +#[cfg(test)] +mod date_condition_tests { + use super::*; + use crate::types::SearchPrefix; + + /// #456: chained date terminals use the precision-aware normalized + /// comparison, not the raw text `=` this used to emit. + #[test] + fn chained_dates_are_precision_aware() { + let value = SearchValue::new(SearchPrefix::Eq, "1995-10-02"); + let (sql, param) = build_date_condition("t2.value_date", &value, 7); + assert_eq!( + sql, + "(datetime(t2.value_date) >= datetime(?7) AND datetime(t2.value_date) < datetime(?7, '+1 day'))" + ); + match param { + SqlParam::String(s) => assert_eq!(s, "1995-10-02T00:00:00"), + _ => panic!("expected string param"), + } + } + + #[test] + fn chained_full_precision_is_equality() { + let value = SearchValue::new(SearchPrefix::Eq, "2016-01-23T13:07:42-04:00"); + let (sql, _) = build_date_condition("t2.value_date", &value, 3); + assert_eq!(sql, "datetime(t2.value_date) = datetime(?3)"); + } +} diff --git a/crates/persistence/src/backends/sqlite/search/filter_parser.rs b/crates/persistence/src/backends/sqlite/search/filter_parser.rs index e0e0f8c9b..33e546601 100644 --- a/crates/persistence/src/backends/sqlite/search/filter_parser.rs +++ b/crates/persistence/src/backends/sqlite/search/filter_parser.rs @@ -797,3 +797,47 @@ mod tests { assert_eq!(sql.params.len(), 2); } } + +#[cfg(test)] +mod date_filter_tests { + use super::*; + + /// #456: `_filter` date comparisons use the precision-aware normalized + /// path, not raw text operators. + #[test] + fn filter_dates_use_normalized_precision_ranges() { + let expr = FilterParser::parse("birthdate eq 1995-10-02").unwrap(); + let frag = FilterSqlGenerator::new(1).generate(&expr); + assert!( + frag.sql.contains("datetime(value_date) >= datetime(?2)"), + "{}", + frag.sql + ); + assert!(frag.sql.contains("'+1 day'"), "{}", frag.sql); + } + + #[test] + fn filter_date_bounds_honor_the_named_day() { + let ge = FilterSqlGenerator::new(1) + .generate(&FilterParser::parse("birthdate ge 1995-10-02").unwrap()); + assert!( + ge.sql.contains("datetime(value_date) >= datetime(?2)"), + "{}", + ge.sql + ); + let le = FilterSqlGenerator::new(1) + .generate(&FilterParser::parse("birthdate le 1995-10-02").unwrap()); + assert!(le.sql.contains("'+1 day'"), "{}", le.sql); + let sa = FilterSqlGenerator::new(1) + .generate(&FilterParser::parse("birthdate sa 1995-10-02").unwrap()); + assert!(sa.sql.contains("'+1 day'"), "{}", sa.sql); + } + + /// Non-date columns keep the plain text operators. + #[test] + fn filter_strings_keep_text_operators() { + let frag = + FilterSqlGenerator::new(1).generate(&FilterParser::parse("name eq Smith").unwrap()); + assert!(frag.sql.contains("value_string = ?2"), "{}", frag.sql); + } +} diff --git a/crates/persistence/src/backends/sqlite/search/parameter_handlers/date.rs b/crates/persistence/src/backends/sqlite/search/parameter_handlers/date.rs index 7eb1e6e64..89036f2e6 100644 --- a/crates/persistence/src/backends/sqlite/search/parameter_handlers/date.rs +++ b/crates/persistence/src/backends/sqlite/search/parameter_handlers/date.rs @@ -165,3 +165,78 @@ mod tests { assert_eq!(frag.params.len(), 1); } } + +#[cfg(test)] +mod prefix_coverage_tests { + use super::*; + + fn sql(prefix: SearchPrefix, value: &str) -> String { + date_condition("value_date", prefix, value, 1).0 + } + + #[test] + fn ne_day_is_the_complement_of_the_range() { + assert_eq!( + sql(SearchPrefix::Ne, "1995-10-02"), + "(datetime(value_date) < datetime(?1) OR datetime(value_date) >= datetime(?1, '+1 day'))" + ); + } + + #[test] + fn ne_full_precision_is_normalized_inequality() { + assert_eq!( + sql(SearchPrefix::Ne, "2016-01-23T13:07:42-04:00"), + "datetime(value_date) != datetime(?1)" + ); + } + + #[test] + fn sa_and_eb_mirror_gt_and_lt() { + assert_eq!( + sql(SearchPrefix::Sa, "1995-10-02"), + "datetime(value_date) >= datetime(?1, '+1 day')" + ); + assert_eq!( + sql(SearchPrefix::Eb, "1995-10-02"), + "datetime(value_date) < datetime(?1)" + ); + } + + #[test] + fn full_precision_single_bounds() { + let instant = "2016-01-23T13:07:42Z"; + assert_eq!( + sql(SearchPrefix::Gt, instant), + "datetime(value_date) > datetime(?1)" + ); + assert_eq!( + sql(SearchPrefix::Ge, instant), + "datetime(value_date) >= datetime(?1)" + ); + assert_eq!( + sql(SearchPrefix::Lt, instant), + "datetime(value_date) < datetime(?1)" + ); + assert_eq!( + sql(SearchPrefix::Le, instant), + "datetime(value_date) <= datetime(?1)" + ); + } + + #[test] + fn ap_at_finer_precisions_scales_its_window() { + assert!(sql(SearchPrefix::Ap, "2016-01-23T13:07:42Z").contains("'-10 seconds'")); + assert!(sql(SearchPrefix::Ap, "1995").contains("'-1 year'")); + assert!(sql(SearchPrefix::Ap, "1995-10").contains("'-1 month'")); + } + + #[test] + fn aliased_columns_pass_through() { + let (sql, bound) = date_condition("t3.value_date", SearchPrefix::Eq, "1995-10-02", 4); + assert_eq!( + sql, + "(datetime(t3.value_date) >= datetime(?4) AND datetime(t3.value_date) < datetime(?4, '+1 day'))" + ); + assert_eq!(bound, "1995-10-02T00:00:00"); + } +}