fix(postgres): render daterange values as readable ranges

Closes #5616
This commit is contained in:
t8y2 2026-08-07 15:26:22 +08:00
parent 15b524404b
commit 2df9a1dd48
No known key found for this signature in database
2 changed files with 171 additions and 0 deletions

View File

@ -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<Self, Box<dyn std::error::Error + Sync + Send>> {
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<String> {
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<String> {
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<f32>.
///
/// 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<i32>, upper: Option<i32>) -> Vec<u8> {
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));

View File

@ -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)"));
}