diff --git a/rust/cubesql/cubesql/src/sql/statement.rs b/rust/cubesql/cubesql/src/sql/statement.rs index 3a5552914a4a5..5823f2f4bbb3f 100644 --- a/rust/cubesql/cubesql/src/sql/statement.rs +++ b/rust/cubesql/cubesql/src/sql/statement.rs @@ -627,6 +627,9 @@ impl<'ast> Visitor<'ast, ConnectionError> for PostgresStatementParamsBinder { BindValue::Timestamp(v) => { *value = ast::Value::SingleQuotedString(v.to_string()); } + BindValue::Date(v) => { + *value = ast::Value::SingleQuotedString(v.to_string()); + } BindValue::Null => { *value = ast::Value::Null; } @@ -1073,6 +1076,7 @@ impl<'ast> Visitor<'ast, ConnectionError> for SensitiveDataSanitizer { mod tests { use super::*; use crate::CubeError; + use pg_srv::{DateValue, TimestampValue}; use sqlparser::{dialect::PostgreSqlDialect, parser::Parser}; fn run_cast_replacer(input: &str, output: &str) -> Result<(), CubeError> { @@ -1254,6 +1258,26 @@ mod tests { vec![BindValue::String("test1".to_string())], )?; + // test TimestampValue binding in the WHERE clause + run_pg_binder( + "SELECT * FROM events WHERE created_at BETWEEN $1 AND $2", + "SELECT * FROM events WHERE created_at BETWEEN '2022-04-25T12:38:42.000' AND '2025-08-08T09:30:45.123'", + vec![ + BindValue::Timestamp(TimestampValue::new(1650890322000000000, None)), + BindValue::Timestamp(TimestampValue::new(1754645445123456000, None)), + ], + )?; + + // test DateValue binding in the WHERE clause + run_pg_binder( + "SELECT * FROM orders WHERE order_date >= $1 AND order_date <= $2", + "SELECT * FROM orders WHERE order_date >= '1999-12-31' AND order_date <= '2000-01-01'", + vec![ + BindValue::Date(DateValue::from_ymd_opt(1999, 12, 31).unwrap()), + BindValue::Date(DateValue::from_ymd_opt(2000, 1, 1).unwrap()), + ], + )?; + Ok(()) } diff --git a/rust/cubesql/pg-srv/src/decoding.rs b/rust/cubesql/pg-srv/src/decoding.rs index 7876bc0a413c6..df73ee371cfc5 100644 --- a/rust/cubesql/pg-srv/src/decoding.rs +++ b/rust/cubesql/pg-srv/src/decoding.rs @@ -129,6 +129,8 @@ mod tests { use crate::protocol::Format; use crate::values::timestamp::TimestampValue; use bytes::BytesMut; + #[cfg(feature = "with-chrono")] + use chrono::NaiveDate; fn assert_test_decode( value: T, @@ -160,6 +162,13 @@ mod tests { assert_test_decode(TimestampValue::new(0, None), Format::Text)?; assert_test_decode(TimestampValue::new(1234567890123456000, None), Format::Text)?; + #[cfg(feature = "with-chrono")] + { + assert_test_decode(NaiveDate::from_ymd_opt(2025, 8, 8).unwrap(), Format::Text)?; + assert_test_decode(NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), Format::Text)?; + assert_test_decode(NaiveDate::from_ymd_opt(1999, 12, 31).unwrap(), Format::Text)?; + } + Ok(()) } @@ -183,6 +192,16 @@ mod tests { Format::Binary, )?; + #[cfg(feature = "with-chrono")] + { + assert_test_decode(NaiveDate::from_ymd_opt(2025, 8, 8).unwrap(), Format::Binary)?; + assert_test_decode(NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), Format::Binary)?; + assert_test_decode( + NaiveDate::from_ymd_opt(1999, 12, 31).unwrap(), + Format::Binary, + )?; + } + Ok(()) } } diff --git a/rust/cubesql/pg-srv/src/encoding.rs b/rust/cubesql/pg-srv/src/encoding.rs index 6de1b088cef91..3605a8a6b6957 100644 --- a/rust/cubesql/pg-srv/src/encoding.rs +++ b/rust/cubesql/pg-srv/src/encoding.rs @@ -2,9 +2,6 @@ use crate::{protocol::Format, ProtocolError}; use bytes::{BufMut, BytesMut}; -#[cfg(feature = "with-chrono")] -use chrono::{NaiveDate, NaiveDateTime}; -use std::io::{Error, ErrorKind}; /// This trait explains how to encode values to the protocol format pub trait ToProtocolValue: std::fmt::Debug { @@ -107,49 +104,12 @@ impl_primitive!(i64); impl_primitive!(f32); impl_primitive!(f64); -// POSTGRES_EPOCH_JDATE -#[cfg(feature = "with-chrono")] -fn pg_base_date_epoch() -> NaiveDateTime { - NaiveDate::from_ymd_opt(2000, 1, 1) - .unwrap() - .and_hms_opt(0, 0, 0) - .unwrap() -} - -#[cfg(feature = "with-chrono")] -impl ToProtocolValue for NaiveDate { - // date_out - https://github.com/postgres/postgres/blob/REL_14_4/src/backend/utils/adt/date.c#L176 - fn to_text(&self, buf: &mut BytesMut) -> Result<(), ProtocolError> { - self.to_string().to_text(buf) - } - - // date_send - https://github.com/postgres/postgres/blob/REL_14_4/src/backend/utils/adt/date.c#L223 - fn to_binary(&self, buf: &mut BytesMut) -> Result<(), ProtocolError> { - let n = self - .signed_duration_since(pg_base_date_epoch().date()) - .num_days(); - if n > (i32::MAX as i64) { - return Err(Error::new( - ErrorKind::Other, - format!( - "value too large to store in the binary format (i32), actual: {}", - n - ), - ) - .into()); - } - - buf.put_i32(4); - buf.put_i32(n as i32); - - Ok(()) - } -} - #[cfg(test)] mod tests { use crate::*; use bytes::BytesMut; + #[cfg(feature = "with-chrono")] + use chrono::NaiveDate; fn assert_text_encode(value: T, expected: &[u8]) { let mut buf = BytesMut::new(); @@ -179,20 +139,48 @@ mod tests { 105, 110, 115, 32, 53, 46, 48, 48, 48, 48, 48, 54, 32, 115, 101, 99, 115, ], ); - assert_text_encode( - TimestampValue::new(0, None), - &[ - 0, 0, 0, 26, 49, 57, 55, 48, 45, 48, 49, 45, 48, 49, 32, 48, 48, 58, 48, 48, 58, - 48, 48, 46, 48, 48, 48, 48, 48, 48, - ], - ); - assert_text_encode( - TimestampValue::new(1650890322000000000, None), - &[ - 0, 0, 0, 26, 50, 48, 50, 50, 45, 48, 52, 45, 50, 53, 32, 49, 50, 58, 51, 56, 58, - 52, 50, 46, 48, 48, 48, 48, 48, 48, - ], - ); + + #[cfg(feature = "with-chrono")] + { + // Test TimestampValue encoding + assert_text_encode( + TimestampValue::new(0, None), + &[ + 0, 0, 0, 26, 49, 57, 55, 48, 45, 48, 49, 45, 48, 49, 32, 48, 48, 58, 48, 48, + 58, 48, 48, 46, 48, 48, 48, 48, 48, 48, + ], + ); + assert_text_encode( + TimestampValue::new(1650890322000000000, None), + &[ + 0, 0, 0, 26, 50, 48, 50, 50, 45, 48, 52, 45, 50, 53, 32, 49, 50, 58, 51, 56, + 58, 52, 50, 46, 48, 48, 48, 48, 48, 48, + ], + ); + + // Test NaiveDate encoding + assert_text_encode( + NaiveDate::from_ymd_opt(2025, 8, 8).unwrap(), + &[ + 0, 0, 0, 10, // length: 10 bytes + 50, 48, 50, 53, 45, 48, 56, 45, 48, 56, // "2025-08-08" + ], + ); + assert_text_encode( + NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), + &[ + 0, 0, 0, 10, // length: 10 bytes + 50, 48, 48, 48, 45, 48, 49, 45, 48, 49, // "2000-01-01" + ], + ); + assert_text_encode( + NaiveDate::from_ymd_opt(1999, 12, 31).unwrap(), + &[ + 0, 0, 0, 10, // length: 10 bytes + 49, 57, 57, 57, 45, 49, 50, 45, 51, 49, // "1999-12-31" + ], + ); + } Ok(()) } @@ -218,14 +206,45 @@ mod tests { 0, 0, 0, 16, 0, 0, 0, 2, 146, 85, 83, 70, 0, 0, 0, 2, 0, 0, 0, 1, ], ); - assert_bind_encode( - TimestampValue::new(0, None), - &[0, 0, 0, 8, 255, 252, 162, 254, 196, 200, 32, 0], - ); - assert_bind_encode( - TimestampValue::new(1650890322000000000, None), - &[0, 0, 0, 8, 0, 2, 128, 120, 159, 252, 216, 128], - ); + + #[cfg(feature = "with-chrono")] + { + // Test TimestampValue binary encoding + assert_bind_encode( + TimestampValue::new(0, None), + &[0, 0, 0, 8, 255, 252, 162, 254, 196, 200, 32, 0], + ); + assert_bind_encode( + TimestampValue::new(1650890322000000000, None), + &[0, 0, 0, 8, 0, 2, 128, 120, 159, 252, 216, 128], + ); + + // Test NaiveDate binary encoding + // PostgreSQL epoch is 2000-01-01, so this date should be 0 days + assert_bind_encode( + NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), + &[ + 0, 0, 0, 4, // length: 4 bytes + 0, 0, 0, 0, // 0 days from epoch + ], + ); + // Date after epoch: 2025-08-08 is 9351 days after 2000-01-01 + assert_bind_encode( + NaiveDate::from_ymd_opt(2025, 8, 8).unwrap(), + &[ + 0, 0, 0, 4, // length: 4 bytes + 0, 0, 36, 135, // 9351 days from epoch (0x2487 in hex) + ], + ); + // Date before epoch: 1999-12-31 is -1 day from 2000-01-01 + assert_bind_encode( + NaiveDate::from_ymd_opt(1999, 12, 31).unwrap(), + &[ + 0, 0, 0, 4, // length: 4 bytes + 255, 255, 255, 255, // -1 in two's complement + ], + ); + } Ok(()) } diff --git a/rust/cubesql/pg-srv/src/extended.rs b/rust/cubesql/pg-srv/src/extended.rs index 163af45857973..8e72a46075219 100644 --- a/rust/cubesql/pg-srv/src/extended.rs +++ b/rust/cubesql/pg-srv/src/extended.rs @@ -1,7 +1,7 @@ //! Implementation for Extended Query #[cfg(feature = "with-chrono")] -use crate::TimestampValue; +use crate::{DateValue, TimestampValue}; #[derive(Debug, PartialEq)] pub enum BindValue { @@ -11,5 +11,7 @@ pub enum BindValue { Bool(bool), #[cfg(feature = "with-chrono")] Timestamp(TimestampValue), + #[cfg(feature = "with-chrono")] + Date(DateValue), Null, } diff --git a/rust/cubesql/pg-srv/src/protocol.rs b/rust/cubesql/pg-srv/src/protocol.rs index 401ab647cfe1a..f1ba92a24383d 100644 --- a/rust/cubesql/pg-srv/src/protocol.rs +++ b/rust/cubesql/pg-srv/src/protocol.rs @@ -793,6 +793,10 @@ impl Bind { raw_value, param_format, )?), + #[cfg(feature = "with-chrono")] + PgTypeId::DATE => { + BindValue::Date(chrono::NaiveDate::from_protocol(raw_value, param_format)?) + } _ => { return Err(ErrorResponse::error( ErrorCode::FeatureNotSupported, @@ -1348,6 +1352,60 @@ mod tests { Ok(()) } + #[cfg(feature = "with-chrono")] + #[tokio::test] + async fn test_frontend_message_parse_bind_date() -> Result<(), ProtocolError> { + use chrono::NaiveDate; + + // Test text format date "2025-08-08" + let buffer = parse_hex_dump( + r#" + 42 00 00 00 1e 00 73 30 00 00 01 00 00 00 01 00 B.....s0........ + 00 00 0a 32 30 32 35 2d 30 38 2d 30 38 00 00 00 ...2025-08-08... + 00 . + "# + .to_string(), + ); + let mut cursor = Cursor::new(buffer); + let message = read_message(&mut cursor, MessageTagParserDefaultImpl::with_arc()).await?; + match message { + FrontendMessage::Bind(body) => { + assert_eq!( + body.to_bind_values(&ParameterDescription::new(vec![PgTypeId::DATE]))?, + vec![BindValue::Date( + NaiveDate::from_ymd_opt(2025, 8, 8).unwrap() + )] + ); + } + _ => panic!("Wrong message, must be Bind"), + } + + // Test binary format date (9351 days from 2000-01-01 for 2025-08-08) + let buffer = parse_hex_dump( + r#" + 42 00 00 00 1a 00 73 30 00 00 01 00 01 00 01 00 B.....s0........ + 00 00 04 00 00 24 87 00 00 00 00 .....$...... + "# + .to_string(), + ); + let mut cursor = Cursor::new(buffer); + let message = read_message(&mut cursor, MessageTagParserDefaultImpl::with_arc()).await?; + match message { + FrontendMessage::Bind(body) => { + assert_eq!(body.parameter_formats, vec![Format::Binary]); + assert_eq!( + body.to_bind_values(&ParameterDescription::new(vec![PgTypeId::DATE]))?, + vec![BindValue::Date( + NaiveDate::from_ymd_opt(2025, 8, 8).unwrap() + )] + ); + } + _ => panic!("Wrong message, must be Bind"), + } + + Ok(()) + } + #[tokio::test] async fn test_frontend_message_parse_describe() -> Result<(), ProtocolError> { let buffer = parse_hex_dump( diff --git a/rust/cubesql/pg-srv/src/values/date.rs b/rust/cubesql/pg-srv/src/values/date.rs new file mode 100644 index 0000000000000..e236424806a60 --- /dev/null +++ b/rust/cubesql/pg-srv/src/values/date.rs @@ -0,0 +1,137 @@ +use crate::protocol::{ErrorCode, ErrorResponse}; +use crate::timestamp::pg_base_date_epoch; +use crate::{FromProtocolValue, ProtocolError, ToProtocolValue}; +use byteorder::{BigEndian, ByteOrder}; +use bytes::{BufMut, BytesMut}; +use chrono::NaiveDate; +use std::backtrace::Backtrace; +use std::io::{Error, ErrorKind}; + +pub type DateValue = NaiveDate; + +impl ToProtocolValue for DateValue { + // date_out - https://github.com/postgres/postgres/blob/REL_14_4/src/backend/utils/adt/date.c#L176 + fn to_text(&self, buf: &mut BytesMut) -> Result<(), ProtocolError> { + self.to_string().to_text(buf) + } + + // date_send - https://github.com/postgres/postgres/blob/REL_14_4/src/backend/utils/adt/date.c#L223 + fn to_binary(&self, buf: &mut BytesMut) -> Result<(), ProtocolError> { + let n = self + .signed_duration_since(pg_base_date_epoch().date()) + .num_days(); + if n > (i32::MAX as i64) { + return Err(Error::new( + ErrorKind::Other, + format!( + "value too large to store in the binary format (i32), actual: {}", + n + ), + ) + .into()); + } + + buf.put_i32(4); + buf.put_i32(n as i32); + + Ok(()) + } +} + +impl FromProtocolValue for DateValue { + // date_in - https://github.com/postgres/postgres/blob/REL_14_4/src/backend/utils/adt/date.c#L111 + fn from_text(raw: &[u8]) -> Result { + let as_str = std::str::from_utf8(raw).map_err(|err| ProtocolError::ErrorResponse { + source: ErrorResponse::error(ErrorCode::ProtocolViolation, err.to_string()), + backtrace: Backtrace::capture(), + })?; + + // Parse date string in format "YYYY-MM-DD" + NaiveDate::parse_from_str(as_str, "%Y-%m-%d").map_err(|err| ProtocolError::ErrorResponse { + source: ErrorResponse::error( + ErrorCode::ProtocolViolation, + format!("Unable to parse date from text '{}': {}", as_str, err), + ), + backtrace: Backtrace::capture(), + }) + } + + // date_recv - https://github.com/postgres/postgres/blob/REL_14_4/src/backend/utils/adt/date.c#L207 + fn from_binary(raw: &[u8]) -> Result { + if raw.len() != 4 { + return Err(ProtocolError::ErrorResponse { + source: ErrorResponse::error( + ErrorCode::ProtocolViolation, + format!( + "Invalid binary date format, expected 4 bytes, got {}", + raw.len() + ), + ), + backtrace: Backtrace::capture(), + }); + } + + let days_since_epoch = BigEndian::read_i32(raw); + let base_date = pg_base_date_epoch().date(); + + base_date + .checked_add_signed(chrono::Duration::days(days_since_epoch as i64)) + .ok_or_else(|| ProtocolError::ErrorResponse { + source: ErrorResponse::error( + ErrorCode::ProtocolViolation, + format!( + "Date value {} days from epoch is out of range", + days_since_epoch + ), + ), + backtrace: Backtrace::capture(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::Format; + + #[test] + fn test_date_from_text() { + let date_str = b"2025-08-08"; + let result = DateValue::from_protocol(date_str, Format::Text).unwrap(); + let expected = DateValue::from_ymd_opt(2025, 8, 8).unwrap(); + assert_eq!(result, expected); + } + + #[test] + fn test_date_from_binary() { + // Create a date and encode it + let date = DateValue::from_ymd_opt(2025, 8, 8).unwrap(); + let mut buf = BytesMut::new(); + date.to_binary(&mut buf).unwrap(); + + // Skip the length prefix (4 bytes) to get the actual data + let binary_data = &buf[4..]; + + // Decode it back + let result = DateValue::from_protocol(binary_data, Format::Binary).unwrap(); + assert_eq!(result, date); + } + + #[test] + fn test_date_from_text_invalid() { + let invalid_date = b"not-a-date"; + let result = DateValue::from_protocol(invalid_date, Format::Text); + assert!(result.is_err()); + } + + #[test] + fn test_date_before_the_pg_epoch() { + // Test date before epoch + let before_epoch = DateValue::from_ymd_opt(1999, 12, 31).unwrap(); + let mut buf = BytesMut::new(); + before_epoch.to_binary(&mut buf).unwrap(); + let binary_data = &buf[4..]; + let result = DateValue::from_protocol(binary_data, Format::Binary).unwrap(); + assert_eq!(result, before_epoch); + } +} diff --git a/rust/cubesql/pg-srv/src/values/mod.rs b/rust/cubesql/pg-srv/src/values/mod.rs index 003313654c590..83ed3768325a6 100644 --- a/rust/cubesql/pg-srv/src/values/mod.rs +++ b/rust/cubesql/pg-srv/src/values/mod.rs @@ -1,9 +1,13 @@ //! PostgreSQL value types for wire protocol +#[cfg(feature = "with-chrono")] +mod date; pub mod interval; #[cfg(feature = "with-chrono")] pub mod timestamp; pub use interval::*; + +pub use date::*; #[cfg(feature = "with-chrono")] pub use timestamp::*; diff --git a/rust/cubesql/pg-srv/src/values/timestamp.rs b/rust/cubesql/pg-srv/src/values/timestamp.rs index 9a744ab8215a3..cfb01bc7f6940 100644 --- a/rust/cubesql/pg-srv/src/values/timestamp.rs +++ b/rust/cubesql/pg-srv/src/values/timestamp.rs @@ -101,7 +101,7 @@ impl Display for TimestampValue { // POSTGRES_EPOCH_JDATE // https://github.com/postgres/postgres/blob/REL_14_4/src/include/datatype/timestamp.h#L163 -fn pg_base_date_epoch() -> NaiveDateTime { +pub(crate) fn pg_base_date_epoch() -> NaiveDateTime { NaiveDate::from_ymd_opt(2000, 1, 1) .unwrap() .and_hms_opt(0, 0, 0)