Skip to content

Commit 6a9d9c9

Browse files
committed
feat(cubesql): Support timestamp parameter binding
1 parent 3354291 commit 6a9d9c9

File tree

8 files changed

+158
-4
lines changed

8 files changed

+158
-4
lines changed

rust/cubesql/cubesql/e2e/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ impl TestsRunner {
2222

2323
pub fn register_suite(&mut self, result: AsyncTestConstructorResult) {
2424
match result {
25-
AsyncTestConstructorResult::Sucess(suite) => self.suites.push(suite),
25+
AsyncTestConstructorResult::Success(suite) => self.suites.push(suite),
2626
AsyncTestConstructorResult::Skipped(message) => {
2727
println!("Skipped: {}", message)
2828
}

rust/cubesql/cubesql/e2e/tests/basic.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,6 @@ pub trait AsyncTestSuite: Debug {
2323
}
2424

2525
pub enum AsyncTestConstructorResult {
26-
Sucess(Box<dyn AsyncTestSuite>),
26+
Success(Box<dyn AsyncTestSuite>),
2727
Skipped(String),
2828
}

rust/cubesql/cubesql/e2e/tests/postgres.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ impl PostgresIntegrationTestSuite {
9292
)
9393
.await;
9494

95-
AsyncTestConstructorResult::Sucess(Box::new(PostgresIntegrationTestSuite { client, port }))
95+
AsyncTestConstructorResult::Success(Box::new(PostgresIntegrationTestSuite { client, port }))
9696
}
9797

9898
async fn create_client(config: tokio_postgres::Config) -> Client {

rust/cubesql/cubesql/src/sql/statement.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,9 @@ impl<'ast> Visitor<'ast, ConnectionError> for PostgresStatementParamsBinder {
624624
BindValue::Float64(v) => {
625625
*value = ast::Value::Number(v.to_string(), *v < 0_f64);
626626
}
627+
BindValue::Timestamp(v) => {
628+
*value = ast::Value::SingleQuotedString(v.to_string());
629+
}
627630
BindValue::Null => {
628631
*value = ast::Value::Null;
629632
}

rust/cubesql/pg-srv/src/decoding.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ mod tests {
127127
use crate::*;
128128

129129
use crate::protocol::Format;
130+
use crate::values::timestamp::TimestampValue;
130131
use bytes::BytesMut;
131132

132133
fn assert_test_decode<T: ToProtocolValue + FromProtocolValue + std::cmp::PartialEq>(
@@ -155,6 +156,9 @@ mod tests {
155156
assert_test_decode(std::f64::consts::PI, Format::Text)?;
156157
assert_test_decode(-std::f64::consts::E, Format::Text)?;
157158
assert_test_decode(0.0_f64, Format::Text)?;
159+
assert_test_decode(TimestampValue::new(1650890322000000000, None), Format::Text)?;
160+
assert_test_decode(TimestampValue::new(0, None), Format::Text)?;
161+
assert_test_decode(TimestampValue::new(1234567890123456000, None), Format::Text)?;
158162

159163
Ok(())
160164
}
@@ -169,6 +173,15 @@ mod tests {
169173
assert_test_decode(std::f64::consts::PI, Format::Binary)?;
170174
assert_test_decode(-std::f64::consts::E, Format::Binary)?;
171175
assert_test_decode(0.0_f64, Format::Binary)?;
176+
assert_test_decode(
177+
TimestampValue::new(1650890322000000000, None),
178+
Format::Binary,
179+
)?;
180+
assert_test_decode(TimestampValue::new(0, None), Format::Binary)?;
181+
assert_test_decode(
182+
TimestampValue::new(1234567890123456000, None),
183+
Format::Binary,
184+
)?;
172185

173186
Ok(())
174187
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
//! Implementation for Extended Query
22
3+
#[cfg(feature = "with-chrono")]
4+
use crate::TimestampValue;
5+
36
#[derive(Debug, PartialEq)]
47
pub enum BindValue {
58
String(String),
69
Int64(i64),
710
Float64(f64),
811
Bool(bool),
12+
#[cfg(feature = "with-chrono")]
13+
Timestamp(TimestampValue),
914
Null,
1015
}

rust/cubesql/pg-srv/src/protocol.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ use async_trait::async_trait;
1616
use bytes::BufMut;
1717
use tokio::io::AsyncReadExt;
1818

19+
#[cfg(feature = "with-chrono")]
20+
use crate::TimestampValue;
1921
use crate::{buffer, BindValue, FromProtocolValue, PgType, PgTypeId, ProtocolError};
2022

2123
const DEFAULT_CAPACITY: usize = 64;
@@ -786,6 +788,11 @@ impl Bind {
786788
PgTypeId::FLOAT8 => {
787789
BindValue::Float64(f64::from_protocol(raw_value, param_format)?)
788790
}
791+
#[cfg(feature = "with-chrono")]
792+
PgTypeId::TIMESTAMP => BindValue::Timestamp(TimestampValue::from_protocol(
793+
raw_value,
794+
param_format,
795+
)?),
789796
_ => {
790797
return Err(ErrorResponse::error(
791798
ErrorCode::FeatureNotSupported,

rust/cubesql/pg-srv/src/values/timestamp.rs

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
//! Timestamp value representation for PostgreSQL protocol
22
3-
use crate::{ProtocolError, ToProtocolValue};
3+
use crate::{
4+
protocol::{ErrorCode, ErrorResponse},
5+
FromProtocolValue, ProtocolError, ToProtocolValue,
6+
};
7+
use byteorder::{BigEndian, ByteOrder};
48
use bytes::{BufMut, BytesMut};
59
use chrono::{
610
format::{
@@ -11,6 +15,7 @@ use chrono::{
1115
prelude::*,
1216
};
1317
use chrono_tz::Tz;
18+
use std::backtrace::Backtrace;
1419
use std::io::Error;
1520
use std::{
1621
fmt::{self, Debug, Display, Formatter},
@@ -95,6 +100,7 @@ impl Display for TimestampValue {
95100
}
96101

97102
// POSTGRES_EPOCH_JDATE
103+
// https://github.com/postgres/postgres/blob/REL_14_4/src/include/datatype/timestamp.h#L162
98104
fn pg_base_date_epoch() -> NaiveDateTime {
99105
NaiveDate::from_ymd_opt(2000, 1, 1)
100106
.unwrap()
@@ -118,6 +124,7 @@ impl ToProtocolValue for TimestampValue {
118124
}
119125
}
120126

127+
// https://github.com/postgres/postgres/blob/REL_14_4/src/backend/utils/adt/timestamp.c#L267
121128
fn to_binary(&self, buf: &mut BytesMut) -> Result<(), ProtocolError> {
122129
let ndt = match self.tz_ref() {
123130
None => self.to_naive_datetime(),
@@ -139,6 +146,72 @@ impl ToProtocolValue for TimestampValue {
139146
}
140147
}
141148

149+
impl FromProtocolValue for TimestampValue {
150+
fn from_text(raw: &[u8]) -> Result<Self, ProtocolError> {
151+
let as_str = std::str::from_utf8(raw).map_err(|err| ProtocolError::ErrorResponse {
152+
source: ErrorResponse::error(ErrorCode::ProtocolViolation, err.to_string()),
153+
backtrace: Backtrace::capture(),
154+
})?;
155+
156+
// Parse timestamp string in format "YYYY-MM-DD HH:MM:SS[.fff]"
157+
let parsed_datetime = chrono::NaiveDateTime::parse_from_str(as_str, "%Y-%m-%d %H:%M:%S")
158+
.or_else(|_| chrono::NaiveDateTime::parse_from_str(as_str, "%Y-%m-%d %H:%M:%S%.f"))
159+
.or_else(|_| chrono::NaiveDateTime::parse_from_str(as_str, "%Y-%m-%dT%H:%M:%S"))
160+
.or_else(|_| chrono::NaiveDateTime::parse_from_str(as_str, "%Y-%m-%dT%H:%M:%S%.f"))
161+
.map_err(|err| ProtocolError::ErrorResponse {
162+
source: ErrorResponse::error(
163+
ErrorCode::ProtocolViolation,
164+
format!(
165+
"Unable to parse timestamp from text: '{}', error: {}",
166+
as_str, err
167+
),
168+
),
169+
backtrace: Backtrace::capture(),
170+
})?;
171+
172+
// Convert to Unix nanoseconds
173+
let unix_nano = parsed_datetime
174+
.and_utc()
175+
.timestamp_nanos_opt()
176+
.ok_or_else(|| ProtocolError::ErrorResponse {
177+
source: ErrorResponse::error(
178+
ErrorCode::ProtocolViolation,
179+
format!("Timestamp out of range: '{}'", as_str),
180+
),
181+
backtrace: Backtrace::capture(),
182+
})?;
183+
184+
Ok(TimestampValue::new(unix_nano, None))
185+
}
186+
187+
// https://github.com/postgres/postgres/blob/REL_14_4/src/backend/utils/adt/timestamp.c#234
188+
fn from_binary(raw: &[u8]) -> Result<Self, ProtocolError> {
189+
if raw.len() != 8 {
190+
return Err(ProtocolError::ErrorResponse {
191+
source: ErrorResponse::error(
192+
ErrorCode::ProtocolViolation,
193+
format!(
194+
"Invalid binary timestamp length: expected 8 bytes, got {}",
195+
raw.len()
196+
),
197+
),
198+
backtrace: Backtrace::capture(),
199+
});
200+
}
201+
202+
let pg_microseconds = BigEndian::read_i64(raw);
203+
204+
// Convert PostgreSQL microseconds to Unix nanoseconds
205+
let unix_nano = pg_base_date_epoch()
206+
.and_utc()
207+
.timestamp_nanos_opt()
208+
.unwrap()
209+
+ (pg_microseconds * 1_000);
210+
211+
Ok(TimestampValue::new(unix_nano, None))
212+
}
213+
}
214+
142215
#[cfg(test)]
143216
mod tests {
144217
use super::*;
@@ -170,4 +243,57 @@ mod tests {
170243
let ts = TimestampValue::new(1650890322123456789, None);
171244
assert_eq!(ts.get_time_stamp(), 1650890322123456000);
172245
}
246+
247+
#[test]
248+
fn test_invalid_timestamp_text() {
249+
// Test that invalid text formats return errors
250+
assert!(TimestampValue::from_text(b"invalid-date").is_err());
251+
assert!(TimestampValue::from_text(b"2025-13-45 25:70:99").is_err());
252+
assert!(TimestampValue::from_text(b"").is_err());
253+
}
254+
255+
#[test]
256+
fn test_timestamp_from_text_various_formats() {
257+
// Test basic format without fractional seconds
258+
let ts1 = TimestampValue::from_text(b"2025-08-04 20:15:47").unwrap();
259+
assert_eq!(ts1.to_naive_datetime().to_string(), "2025-08-04 20:15:47");
260+
261+
// Test PostgreSQL format with 6-digit fractional seconds
262+
let ts2 = TimestampValue::from_text(b"2025-08-04 20:16:54.853660").unwrap();
263+
assert_eq!(
264+
ts2.to_naive_datetime()
265+
.format("%Y-%m-%d %H:%M:%S%.6f")
266+
.to_string(),
267+
"2025-08-04 20:16:54.853660"
268+
);
269+
270+
// Test format with 3 fractional seconds
271+
let ts3 = TimestampValue::from_text(b"2025-08-04 20:15:47.953").unwrap();
272+
assert_eq!(
273+
ts3.to_naive_datetime()
274+
.format("%Y-%m-%d %H:%M:%S%.3f")
275+
.to_string(),
276+
"2025-08-04 20:15:47.953"
277+
);
278+
279+
// Test ISO format with T separator
280+
let ts4 = TimestampValue::from_text(b"2025-08-04T20:15:47").unwrap();
281+
assert_eq!(ts4.to_naive_datetime().to_string(), "2025-08-04 20:15:47");
282+
283+
// Test ISO format with T separator and fractional seconds
284+
let ts5 = TimestampValue::from_text(b"2025-08-04T20:15:47.953116").unwrap();
285+
assert_eq!(
286+
ts5.to_naive_datetime()
287+
.format("%Y-%m-%d %H:%M:%S%.6f")
288+
.to_string(),
289+
"2025-08-04 20:15:47.953116"
290+
);
291+
}
292+
293+
#[test]
294+
fn test_invalid_timestamp_binary() {
295+
// Test that invalid binary data returns errors
296+
assert!(TimestampValue::from_binary(&[1, 2, 3]).is_err()); // Wrong length
297+
assert!(TimestampValue::from_binary(&[]).is_err()); // Empty
298+
}
173299
}

0 commit comments

Comments
 (0)