From 2df9a1dd48746deba00c44fa2d4de83d20b2bc39 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 7 Aug 2026 15:26:22 +0800 Subject: [PATCH] fix(postgres): render daterange values as readable ranges Closes #5616 --- crates/dbx-core/src/db/postgres.rs | 146 ++++++++++++++++++ .../dbx-core/tests/live_postgres_daterange.rs | 25 +++ 2 files changed, 171 insertions(+) create mode 100644 crates/dbx-core/tests/live_postgres_daterange.rs diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index 3f3b9573b..6ed98972e 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -214,6 +214,80 @@ fn format_pg_interval(interval: PgInterval) -> String { parts.join(" ") } +struct PgDateRange(String); + +impl<'a> FromSql<'a> for PgDateRange { + fn from_sql(_: &Type, raw: &'a [u8]) -> Result> { + decode_pg_daterange_bytes(raw).map(Self).ok_or_else(|| "invalid PostgreSQL daterange binary value".into()) + } + + fn accepts(ty: &Type) -> bool { + *ty == Type::DATE_RANGE + } +} + +const PG_RANGE_EMPTY: u8 = 0b0000_0001; +const PG_RANGE_LOWER_INCLUSIVE: u8 = 0b0000_0010; +const PG_RANGE_UPPER_INCLUSIVE: u8 = 0b0000_0100; +const PG_RANGE_LOWER_UNBOUNDED: u8 = 0b0000_1000; +const PG_RANGE_UPPER_UNBOUNDED: u8 = 0b0001_0000; +const PG_RANGE_KNOWN_FLAGS: u8 = PG_RANGE_EMPTY + | PG_RANGE_LOWER_INCLUSIVE + | PG_RANGE_UPPER_INCLUSIVE + | PG_RANGE_LOWER_UNBOUNDED + | PG_RANGE_UPPER_UNBOUNDED; + +fn decode_pg_date_bound(raw: &[u8], cursor: &mut usize, unbounded: bool) -> Option { + if unbounded { + return Some(String::new()); + } + + let length = read_i32_be(raw, cursor)?; + if length != 4 { + return None; + } + let days = read_i32_be(raw, cursor)?; + match days { + i32::MIN => Some("-infinity".to_string()), + i32::MAX => Some("infinity".to_string()), + days => NaiveDate::from_ymd_opt(2000, 1, 1)? + .checked_add_signed(chrono::Duration::days(i64::from(days))) + .map(|date| date.to_string()), + } +} + +fn decode_pg_daterange_bytes(raw: &[u8]) -> Option { + let (&flags, _) = raw.split_first()?; + if flags & !PG_RANGE_KNOWN_FLAGS != 0 { + return None; + } + if flags == PG_RANGE_EMPTY { + return (raw.len() == 1).then(|| "empty".to_string()); + } + if flags & PG_RANGE_EMPTY != 0 { + return None; + } + + let lower_unbounded = flags & PG_RANGE_LOWER_UNBOUNDED != 0; + let upper_unbounded = flags & PG_RANGE_UPPER_UNBOUNDED != 0; + if (lower_unbounded && flags & PG_RANGE_LOWER_INCLUSIVE != 0) + || (upper_unbounded && flags & PG_RANGE_UPPER_INCLUSIVE != 0) + { + return None; + } + + let mut cursor = 1; + let lower = decode_pg_date_bound(raw, &mut cursor, lower_unbounded)?; + let upper = decode_pg_date_bound(raw, &mut cursor, upper_unbounded)?; + if cursor != raw.len() { + return None; + } + + let lower_delimiter = if flags & PG_RANGE_LOWER_INCLUSIVE != 0 { '[' } else { '(' }; + let upper_delimiter = if flags & PG_RANGE_UPPER_INCLUSIVE != 0 { ']' } else { ')' }; + Some(format!("{lower_delimiter}{lower},{upper}{upper_delimiter}")) +} + /// Decode pgvector binary format into a Vec. /// /// pgvector binary layout (big-endian): @@ -451,6 +525,7 @@ pub(crate) enum PgColType { Json, Bool, Interval, + DateRange, Temporal { fallback: PgTemporalFallback }, Numeric, Uuid, @@ -500,6 +575,9 @@ pub(crate) fn classify_pg_type(type_name: &str) -> PgColType { if upper == "INTERVAL" { return PgColType::Interval; } + if upper == "DATERANGE" { + return PgColType::DateRange; + } if upper.contains("TIMESTAMP") || upper == "DATE" || upper == "TIME" @@ -581,6 +659,10 @@ pub(crate) fn pg_value_to_json_classified(row: &Row, idx: usize, col_type: PgCol .try_get::<_, PgInterval>(idx) .map(|interval| serde_json::Value::String(format_pg_interval(interval))) .unwrap_or_else(|_| pg_fallback_value_to_json(row, idx)), + PgColType::DateRange => row + .try_get::<_, PgDateRange>(idx) + .map(|range| serde_json::Value::String(range.0)) + .unwrap_or_else(|_| pg_fallback_value_to_json(row, idx)), PgColType::Temporal { fallback } => { if let Some(v) = pg_temporal_to_json_value(row, idx) { return v; @@ -5179,6 +5261,70 @@ mod tests { assert!(!pg_type_requires_text_protocol(&Type::INTERVAL, PgColType::Interval)); } + fn pg_date_range_bytes(flags: u8, lower: Option, upper: Option) -> Vec { + let mut raw = vec![flags]; + for days in [lower, upper].into_iter().flatten() { + raw.extend_from_slice(&4_i32.to_be_bytes()); + raw.extend_from_slice(&days.to_be_bytes()); + } + raw + } + + #[test] + fn postgres_daterange_binary_decodes_reported_value() { + let raw = pg_date_range_bytes(PG_RANGE_LOWER_INCLUSIVE, Some(9_163), Some(9_168)); + let range = PgDateRange::from_sql(&Type::DATE_RANGE, &raw).unwrap(); + + assert_eq!(range.0, "[2025-02-01,2025-02-06)"); + } + + #[test] + fn postgres_daterange_binary_handles_empty_unbounded_and_infinite_bounds() { + assert_eq!(PgDateRange::from_sql(&Type::DATE_RANGE, &[PG_RANGE_EMPTY]).unwrap().0, "empty"); + assert_eq!( + PgDateRange::from_sql( + &Type::DATE_RANGE, + &pg_date_range_bytes(PG_RANGE_UPPER_UNBOUNDED | PG_RANGE_LOWER_INCLUSIVE, Some(9_163), None), + ) + .unwrap() + .0, + "[2025-02-01,)" + ); + assert_eq!( + PgDateRange::from_sql( + &Type::DATE_RANGE, + &pg_date_range_bytes(PG_RANGE_LOWER_UNBOUNDED, None, Some(9_168)), + ) + .unwrap() + .0, + "(,2025-02-06)" + ); + assert_eq!( + PgDateRange::from_sql( + &Type::DATE_RANGE, + &pg_date_range_bytes(PG_RANGE_LOWER_INCLUSIVE, Some(i32::MIN), Some(i32::MAX)), + ) + .unwrap() + .0, + "[-infinity,infinity)" + ); + } + + #[test] + fn postgres_daterange_rejects_malformed_binary_and_keeps_binary_protocol() { + assert!(PgDateRange::from_sql(&Type::DATE_RANGE, &[]).is_err()); + assert!(PgDateRange::from_sql(&Type::DATE_RANGE, &[PG_RANGE_EMPTY, 0]).is_err()); + assert!(PgDateRange::from_sql( + &Type::DATE_RANGE, + &pg_date_range_bytes(PG_RANGE_LOWER_UNBOUNDED | PG_RANGE_LOWER_INCLUSIVE, None, Some(9_168)), + ) + .is_err()); + assert!(PgDateRange::from_sql(&Type::DATE_RANGE, &[PG_RANGE_LOWER_INCLUSIVE, 0, 0, 0, 3, 0, 0, 0]).is_err()); + assert_eq!(classify_pg_type("daterange"), PgColType::DateRange); + assert_eq!(classify_pg_type("_daterange"), PgColType::GenericArray); + assert!(!pg_type_requires_text_protocol(&Type::DATE_RANGE, PgColType::DateRange)); + } + #[test] fn postgres_custom_other_type_requires_text_protocol() { assert!(pg_scalar_type_requires_text_protocol(8_880, PgColType::Other)); diff --git a/crates/dbx-core/tests/live_postgres_daterange.rs b/crates/dbx-core/tests/live_postgres_daterange.rs new file mode 100644 index 000000000..532589710 --- /dev/null +++ b/crates/dbx-core/tests/live_postgres_daterange.rs @@ -0,0 +1,25 @@ +use std::time::Duration; + +use dbx_core::db::postgres; + +#[tokio::test] +#[ignore = "requires DBX_TEST_POSTGRES_URL pointing at a PostgreSQL database"] +async fn postgres_daterange_values_render_as_readable_ranges() { + let url = std::env::var("DBX_TEST_POSTGRES_URL").expect("DBX_TEST_POSTGRES_URL"); + let pool = postgres::connect(&url, Duration::from_secs(5)).await.expect("connect postgres"); + + let result = postgres::execute_query( + &pool, + "SELECT daterange(DATE '2025-02-01', DATE '2025-02-06', '[)') AS bounded, \ + 'empty'::daterange AS empty, \ + daterange(NULL, DATE '2025-02-06', '()') AS lower_unbounded", + ) + .await + .expect("select daterange values"); + + assert_eq!(result.column_types, vec!["daterange", "daterange", "daterange"]); + assert_eq!(result.rows.len(), 1); + assert_eq!(result.rows[0][0].as_str(), Some("[2025-02-01,2025-02-06)")); + assert_eq!(result.rows[0][1].as_str(), Some("empty")); + assert_eq!(result.rows[0][2].as_str(), Some("(,2025-02-06)")); +}