From b18db98eaee86bf15dd2f2f6506a1e28a6cdace7 Mon Sep 17 00:00:00 2001 From: Alvaro Viebrantz Date: Fri, 10 Jul 2026 20:57:17 +0000 Subject: [PATCH] impl(bigquery): parse date and time related types --- Cargo.lock | 4 + src/bigquery/Cargo.toml | 2 + src/bigquery/src/query/from_sql.rs | 162 ++++++++++++++++++++++++++++- tests/bigquery/Cargo.toml | 2 + tests/bigquery/src/lib.rs | 77 ++++++++++++++ tests/bigquery/tests/driver.rs | 8 ++ 6 files changed, 254 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index a3cb27173b..f837970b18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1975,11 +1975,13 @@ dependencies = [ "google-cloud-bigquery-v2", "google-cloud-gax", "google-cloud-gax-internal", + "google-cloud-type", "google-cloud-wkt", "mockall", "serde_json", "test-case", "thiserror", + "time", "tokio", ] @@ -6112,6 +6114,8 @@ dependencies = [ "google-cloud-bigquery-v2", "google-cloud-gax", "google-cloud-test-utils", + "google-cloud-type", + "google-cloud-wkt", "rand 0.10.1", "tokio", ] diff --git a/src/bigquery/Cargo.toml b/src/bigquery/Cargo.toml index 36a240e725..019f2aead0 100644 --- a/src/bigquery/Cargo.toml +++ b/src/bigquery/Cargo.toml @@ -31,7 +31,9 @@ serde_json.workspace = true google-cloud-auth = { workspace = true } google-cloud-gax.workspace = true google-cloud-bigquery-v2 = { workspace = true } +google-cloud-type = { workspace = true } thiserror.workspace = true +time = { workspace = true, features = ["formatting", "macros", "parsing"] } wkt.workspace = true gaxi = { workspace = true, features = ["_internal-common", "_internal-grpc-client", "_internal-http-client"] } tokio = { workspace = true, features = ["time"] } diff --git a/src/bigquery/src/query/from_sql.rs b/src/bigquery/src/query/from_sql.rs index d8e4b5821b..39d4b2b367 100644 --- a/src/bigquery/src/query/from_sql.rs +++ b/src/bigquery/src/query/from_sql.rs @@ -14,6 +14,18 @@ use crate::error::ConvertError; +pub(crate) const BIGQUERY_DATE_FORMAT: &[time::format_description::FormatItem<'static>] = + time::macros::format_description!("[year]-[month]-[day]"); +pub(crate) const BIGQUERY_TIME_FORMAT: &[time::format_description::FormatItem<'static>] = + time::macros::format_description!("[hour]:[minute]:[second]"); +pub(crate) const BIGQUERY_TIME_SUBSEC_FORMAT: &[time::format_description::FormatItem<'static>] = + time::macros::format_description!("[hour]:[minute]:[second].[subsecond]"); +pub(crate) const BIGQUERY_DATETIME_FORMAT: &[time::format_description::FormatItem<'static>] = + time::macros::format_description!("[year]-[month]-[day]T[hour]:[minute]:[second]"); +pub(crate) const BIGQUERY_DATETIME_SUBSEC_FORMAT: &[time::format_description::FormatItem< + 'static, +>] = time::macros::format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond]"); + /// Converts BigQuery internal [wkt::Value] to Rust types. pub trait FromSql: Sized { /// Converts a BigQuery `wkt::Value` into the implementing type. @@ -126,8 +138,115 @@ impl FromSql for wkt::Struct { } } +impl FromSql for wkt::Timestamp { + fn from_sql(value: wkt::Value) -> Result { + match value { + wkt::Value::String(s) => { + let micros = s + .parse::() + .map_err(|e| ConvertError::Convert(Box::new(e)))?; + timestamp_from_micros(micros) + } + wkt::Value::Number(n) => { + let micros = n.as_i64().ok_or_else(|| { + ConvertError::Convert("timestamp number is not valid i64".into()) + })?; + timestamp_from_micros(micros) + } + wkt::Value::Null => Err(ConvertError::NotNull), + other => Err(ConvertError::TypeMismatch { + expected: "string or number", + got: other, + }), + } + } +} + +fn timestamp_from_micros(micros: i64) -> Result { + wkt::Timestamp::new( + micros.div_euclid(1_000_000), + (micros.rem_euclid(1_000_000) * 1_000) as i32, + ) + .map_err(|e| ConvertError::Convert(Box::new(e))) +} + +impl FromSql for google_cloud_type::model::Date { + fn from_sql(value: wkt::Value) -> Result { + match value { + wkt::Value::String(s) => { + let date = time::Date::parse(s.as_str(), BIGQUERY_DATE_FORMAT) + .map_err(|e| ConvertError::Convert(Box::new(e)))?; + Ok(google_cloud_type::model::Date::new() + .set_year(date.year()) + .set_month(u8::from(date.month()) as i32) + .set_day(date.day() as i32)) + } + wkt::Value::Null => Err(ConvertError::NotNull), + other => Err(ConvertError::TypeMismatch { + expected: "string", + got: other, + }), + } + } +} + +impl FromSql for google_cloud_type::model::TimeOfDay { + fn from_sql(value: wkt::Value) -> Result { + match value { + wkt::Value::String(s) => { + let format = if s.contains('.') { + BIGQUERY_TIME_SUBSEC_FORMAT + } else { + BIGQUERY_TIME_FORMAT + }; + let t = time::Time::parse(s.as_str(), format) + .map_err(|e| ConvertError::Convert(Box::new(e)))?; + Ok(google_cloud_type::model::TimeOfDay::new() + .set_hours(t.hour() as i32) + .set_minutes(t.minute() as i32) + .set_seconds(t.second() as i32) + .set_nanos(t.nanosecond() as i32)) + } + wkt::Value::Null => Err(ConvertError::NotNull), + other => Err(ConvertError::TypeMismatch { + expected: "string", + got: other, + }), + } + } +} + +impl FromSql for google_cloud_type::model::DateTime { + fn from_sql(value: wkt::Value) -> Result { + match value { + wkt::Value::String(s) => { + let format = if s.contains('.') { + BIGQUERY_DATETIME_SUBSEC_FORMAT + } else { + BIGQUERY_DATETIME_FORMAT + }; + let dt = time::PrimitiveDateTime::parse(s.as_str(), format) + .map_err(|e| ConvertError::Convert(Box::new(e)))?; + Ok(google_cloud_type::model::DateTime::new() + .set_year(dt.year()) + .set_month(u8::from(dt.month()) as i32) + .set_day(dt.day() as i32) + .set_hours(dt.hour() as i32) + .set_minutes(dt.minute() as i32) + .set_seconds(dt.second() as i32) + .set_nanos(dt.nanosecond() as i32)) + } + wkt::Value::Null => Err(ConvertError::NotNull), + other => Err(ConvertError::TypeMismatch { + expected: "string", + got: other, + }), + } + } +} + // TODO(#5592): implement for more Rust -// types: f32, i32, Decimal, wkt::Timestamp, etc. +// types: f32, i32, Decimal, etc. #[cfg(test)] mod tests { @@ -216,4 +335,45 @@ mod tests { fn test_from_sql_struct(value: wkt::Value) -> Result { FromSql::from_sql(value).map_err(TestConvertError::from) } + + #[test_case(wkt::Value::String("1779982200000000".to_string()) => Ok(wkt::Timestamp::new(1779982200, 0).unwrap()) ; "timestamp micro integer string")] + #[test_case(wkt::Value::Number(1779982200000000i64.into()) => Ok(wkt::Timestamp::new(1779982200, 0).unwrap()) ; "timestamp micro integer number")] + #[test_case(wkt::Value::String("2026-05-28T15:30:00Z".to_string()) => Err(TestConvertError::Convert("invalid digit found in string".to_string())) ; "timestamp rfc3339 string fails")] + #[test_case(wkt::Value::Number(serde_json::Number::from_f64(1779982200.5).unwrap()) => Err(TestConvertError::Convert("timestamp number is not valid i64".to_string())) ; "timestamp f64 number fails")] + #[test_case(wkt::Value::Null => Err(TestConvertError::NotNull) ; "timestamp null")] + #[test_case(wkt::Value::Bool(true) => Err(TestConvertError::TypeMismatch("string or number")) ; "timestamp type mismatch")] + fn test_from_sql_timestamp(value: wkt::Value) -> Result { + FromSql::from_sql(value).map_err(TestConvertError::from) + } + + #[test_case(wkt::Value::String("2026-05-28".to_string()) => Ok(google_cloud_type::model::Date::new().set_year(2026).set_month(5).set_day(28)) ; "date valid")] + #[test_case(wkt::Value::Null => Err(TestConvertError::NotNull) ; "date null")] + #[test_case(wkt::Value::Number(123.into()) => Err(TestConvertError::TypeMismatch("string")) ; "date type mismatch")] + #[test_case(wkt::Value::String("invalid-date".to_string()) => Err(TestConvertError::Convert("the 'year' component could not be parsed".to_string())) ; "date invalid format")] + #[test_case(wkt::Value::String("2026-abc-28".to_string()) => Err(TestConvertError::Convert("the 'month' component could not be parsed".to_string())) ; "date invalid digits")] + fn test_from_sql_date( + value: wkt::Value, + ) -> Result { + FromSql::from_sql(value).map_err(TestConvertError::from) + } + + #[test_case(wkt::Value::String("15:30:00".to_string()) => Ok(google_cloud_type::model::TimeOfDay::new().set_hours(15).set_minutes(30).set_seconds(0).set_nanos(0)) ; "time of day valid")] + #[test_case(wkt::Value::String("15:30:00.123456".to_string()) => Ok(google_cloud_type::model::TimeOfDay::new().set_hours(15).set_minutes(30).set_seconds(0).set_nanos(123_456_000)) ; "time of day fractional")] + #[test_case(wkt::Value::Null => Err(TestConvertError::NotNull) ; "time of day null")] + #[test_case(wkt::Value::Number(123.into()) => Err(TestConvertError::TypeMismatch("string")) ; "time of day type mismatch")] + fn test_from_sql_time_of_day( + value: wkt::Value, + ) -> Result { + FromSql::from_sql(value).map_err(TestConvertError::from) + } + + #[test_case(wkt::Value::String("2026-05-28T15:30:00".to_string()) => Ok(google_cloud_type::model::DateTime::new().set_year(2026).set_month(5).set_day(28).set_hours(15).set_minutes(30).set_seconds(0).set_nanos(0)) ; "datetime without subseconds")] + #[test_case(wkt::Value::String("2026-05-28T15:30:00.123456".to_string()) => Ok(google_cloud_type::model::DateTime::new().set_year(2026).set_month(5).set_day(28).set_hours(15).set_minutes(30).set_seconds(0).set_nanos(123_456_000)) ; "datetime with subseconds")] + #[test_case(wkt::Value::Null => Err(TestConvertError::NotNull) ; "datetime null")] + #[test_case(wkt::Value::Number(123.into()) => Err(TestConvertError::TypeMismatch("string")) ; "datetime type mismatch")] + fn test_from_sql_datetime( + value: wkt::Value, + ) -> Result { + FromSql::from_sql(value).map_err(TestConvertError::from) + } } diff --git a/tests/bigquery/Cargo.toml b/tests/bigquery/Cargo.toml index 01467218f8..030a311f99 100644 --- a/tests/bigquery/Cargo.toml +++ b/tests/bigquery/Cargo.toml @@ -33,5 +33,7 @@ google-cloud-bigquery = { path = "../../src/bigquery" } google-cloud-bigquery-v2 = { workspace = true, features = ["default"] } google-cloud-gax = { workspace = true, features = ["unstable-stream"] } google-cloud-test-utils = { workspace = true } +google-cloud-type = { workspace = true } rand.workspace = true tokio.workspace = true +wkt.workspace = true diff --git a/tests/bigquery/src/lib.rs b/tests/bigquery/src/lib.rs index b9be4aba41..5ad0a123d5 100644 --- a/tests/bigquery/src/lib.rs +++ b/tests/bigquery/src/lib.rs @@ -249,6 +249,83 @@ pub async fn query_client() -> Result<()> { Ok(()) } +pub async fn query_client_datatypes() -> Result<()> { + let project_id = project_id()?; + let bq = BigQuery::builder().build().await?; + + let query = bq + .query( + "SELECT \ + 'John Doe' AS name, \ + 30 AS age, \ + 1.85 AS height, \ + true AS active, \ + TIMESTAMP '2026-05-28 15:30:00 UTC' AS created_at, \ + DATE '2026-05-28' AS birth_date, \ + TIME '15:30:00' AS daily_alarm, \ + DATETIME '2026-05-28 15:30:00' AS event_time, \ + CAST(NULL AS STRING) AS nullable_name, \ + CAST(NULL AS INT64) AS nullable_age", + ) + .with_project_id(project_id) + .set_labels(vec![(INSTANCE_LABEL, "true")]) + .run() + .await?; + + let complete_query = query.until_done().await?; + assert_eq!(complete_query.metadata().total_rows, Some(1)); + + let mut iter = complete_query.read(); + let row = iter.next().await.expect("row must exist")?; + + assert_eq!(row.get::("name"), "John Doe"); + assert_eq!(row.get::("age"), 30); + assert_eq!(row.get::("height"), 1.85); + assert!(row.get::("active")); + + let created_at = row.get::("created_at"); + assert_eq!(created_at, wkt::Timestamp::new(1779982200, 0).unwrap()); + + let birth_date = row.get::("birth_date"); + assert_eq!( + birth_date, + google_cloud_type::model::Date::new() + .set_year(2026) + .set_month(5) + .set_day(28) + ); + + let daily_alarm = row.get::("daily_alarm"); + assert_eq!( + daily_alarm, + google_cloud_type::model::TimeOfDay::new() + .set_hours(15) + .set_minutes(30) + .set_seconds(0) + .set_nanos(0) + ); + + let event_time = row.get::("event_time"); + assert_eq!( + event_time, + google_cloud_type::model::DateTime::new() + .set_year(2026) + .set_month(5) + .set_day(28) + .set_hours(15) + .set_minutes(30) + .set_seconds(0) + .set_nanos(0) + ); + + assert_eq!(row.get::, _>("nullable_name"), None); + assert_eq!(row.get::, _>("nullable_age"), None); + + assert!(iter.next().await.is_none()); + + Ok(()) +} + pub async fn query_client_multi_page() -> Result<()> { let project_id = project_id()?; let bq = BigQuery::builder().build().await?; diff --git a/tests/bigquery/tests/driver.rs b/tests/bigquery/tests/driver.rs index 3456d60dba..c078e9e2bd 100644 --- a/tests/bigquery/tests/driver.rs +++ b/tests/bigquery/tests/driver.rs @@ -41,6 +41,14 @@ mod bigquery { .inspect_err(anydump) } + #[tokio::test] + async fn run_query_client_datatypes() -> anyhow::Result<()> { + let _guard = enable_tracing(); + integration_tests_bigquery::query_client_datatypes() + .await + .inspect_err(anydump) + } + #[tokio::test] async fn run_query_client_multi_page() -> anyhow::Result<()> { let _guard = enable_tracing();