Skip to content

Commit 8a614bb

Browse files
authored
feat(cubesql): Support timestamp parameter binding, fix cube-js#9784 (cube-js#9847)
1 parent 3354291 commit 8a614bb

File tree

9 files changed

+166
-4
lines changed

9 files changed

+166
-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/cubesql/test.sql

Whitespace-only changes.

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
}

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

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: 135 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#L163
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,80 @@ 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]", but PostgreSQL supports
157+
// more formats, so let's align this with parse_date_str function from cubesql crate.
158+
let parsed_datetime = NaiveDateTime::parse_from_str(as_str, "%Y-%m-%d %H:%M:%S")
159+
.or_else(|_| NaiveDateTime::parse_from_str(as_str, "%Y-%m-%d %H:%M:%S%.f"))
160+
.or_else(|_| NaiveDateTime::parse_from_str(as_str, "%Y-%m-%dT%H:%M:%S"))
161+
.or_else(|_| NaiveDateTime::parse_from_str(as_str, "%Y-%m-%dT%H:%M:%S%.f"))
162+
.or_else(|_| NaiveDateTime::parse_from_str(as_str, "%Y-%m-%dT%H:%M:%S%.fZ"))
163+
.or_else(|_| {
164+
NaiveDate::parse_from_str(as_str, "%Y-%m-%d").map(|date| {
165+
date.and_hms_opt(0, 0, 0)
166+
.expect("Unable to set time to 00:00:00")
167+
})
168+
})
169+
.map_err(|err| ProtocolError::ErrorResponse {
170+
source: ErrorResponse::error(
171+
ErrorCode::ProtocolViolation,
172+
format!(
173+
"Unable to parse timestamp from text: '{}', error: {}",
174+
as_str, err
175+
),
176+
),
177+
backtrace: Backtrace::capture(),
178+
})?;
179+
180+
// Convert to Unix nanoseconds
181+
let unix_nano = parsed_datetime
182+
.and_utc()
183+
.timestamp_nanos_opt()
184+
.ok_or_else(|| ProtocolError::ErrorResponse {
185+
source: ErrorResponse::error(
186+
ErrorCode::ProtocolViolation,
187+
format!("Timestamp out of range: '{}'", as_str),
188+
),
189+
backtrace: Backtrace::capture(),
190+
})?;
191+
192+
Ok(TimestampValue::new(unix_nano, None))
193+
}
194+
195+
// https://github.com/postgres/postgres/blob/REL_14_4/src/backend/utils/adt/timestamp.c#L234
196+
fn from_binary(raw: &[u8]) -> Result<Self, ProtocolError> {
197+
if raw.len() != 8 {
198+
return Err(ProtocolError::ErrorResponse {
199+
source: ErrorResponse::error(
200+
ErrorCode::ProtocolViolation,
201+
format!(
202+
"Invalid binary timestamp length: expected 8 bytes, got {}",
203+
raw.len()
204+
),
205+
),
206+
backtrace: Backtrace::capture(),
207+
});
208+
}
209+
210+
let pg_microseconds = BigEndian::read_i64(raw);
211+
212+
// Convert PostgreSQL microseconds to Unix nanoseconds
213+
let unix_nano = pg_base_date_epoch()
214+
.and_utc()
215+
.timestamp_nanos_opt()
216+
.expect("Unable to get timestamp nanos for pg_base_date_epoch")
217+
+ (pg_microseconds * 1_000);
218+
219+
Ok(TimestampValue::new(unix_nano, None))
220+
}
221+
}
222+
142223
#[cfg(test)]
143224
mod tests {
144225
use super::*;
@@ -170,4 +251,57 @@ mod tests {
170251
let ts = TimestampValue::new(1650890322123456789, None);
171252
assert_eq!(ts.get_time_stamp(), 1650890322123456000);
172253
}
254+
255+
#[test]
256+
fn test_invalid_timestamp_text() {
257+
// Test that invalid text formats return errors
258+
assert!(TimestampValue::from_text(b"invalid-date").is_err());
259+
assert!(TimestampValue::from_text(b"2025-13-45 25:70:99").is_err());
260+
assert!(TimestampValue::from_text(b"").is_err());
261+
}
262+
263+
#[test]
264+
fn test_timestamp_from_text_various_formats() {
265+
// Test basic format without fractional seconds
266+
let ts1 = TimestampValue::from_text(b"2025-08-04 20:15:47").unwrap();
267+
assert_eq!(ts1.to_naive_datetime().to_string(), "2025-08-04 20:15:47");
268+
269+
// Test PostgreSQL format with 6-digit fractional seconds
270+
let ts2 = TimestampValue::from_text(b"2025-08-04 20:16:54.853660").unwrap();
271+
assert_eq!(
272+
ts2.to_naive_datetime()
273+
.format("%Y-%m-%d %H:%M:%S%.6f")
274+
.to_string(),
275+
"2025-08-04 20:16:54.853660"
276+
);
277+
278+
// Test format with 3 fractional seconds
279+
let ts3 = TimestampValue::from_text(b"2025-08-04 20:15:47.953").unwrap();
280+
assert_eq!(
281+
ts3.to_naive_datetime()
282+
.format("%Y-%m-%d %H:%M:%S%.3f")
283+
.to_string(),
284+
"2025-08-04 20:15:47.953"
285+
);
286+
287+
// Test ISO format with T separator
288+
let ts4 = TimestampValue::from_text(b"2025-08-04T20:15:47").unwrap();
289+
assert_eq!(ts4.to_naive_datetime().to_string(), "2025-08-04 20:15:47");
290+
291+
// Test ISO format with T separator and fractional seconds
292+
let ts5 = TimestampValue::from_text(b"2025-08-04T20:15:47.953116").unwrap();
293+
assert_eq!(
294+
ts5.to_naive_datetime()
295+
.format("%Y-%m-%d %H:%M:%S%.6f")
296+
.to_string(),
297+
"2025-08-04 20:15:47.953116"
298+
);
299+
}
300+
301+
#[test]
302+
fn test_invalid_timestamp_binary() {
303+
// Test that invalid binary data returns errors
304+
assert!(TimestampValue::from_binary(&[1, 2, 3]).is_err()); // Wrong length
305+
assert!(TimestampValue::from_binary(&[]).is_err()); // Empty
306+
}
173307
}

0 commit comments

Comments
 (0)