Skip to main content

google_cloud_bigquery/
datatypes.rs

1// Copyright 2026 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Custom data types for BigQuery.
16//!
17//! This module provides Rust representations of BigQuery data types such as
18//! [`Interval`] and [`Range`].
19
20use crate::error::ConvertError;
21use crate::query::FromSql;
22use crate::query::from_sql::parse_time;
23
24/// Represents a BigQuery time [INTERVAL] value.
25///
26/// [INTERVAL]: https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#interval_type
27///
28/// # Example
29///
30/// ```
31/// # async fn sample() -> anyhow::Result<()> {
32/// use google_cloud_bigquery::client::BigQuery;
33/// use google_cloud_bigquery::datatypes::Interval;
34///
35/// let client = BigQuery::builder()
36///     .with_project_id("my-project-id")
37///     .build()
38///     .await?;
39/// let mut rows = client
40///     .query("SELECT INTERVAL '1-2 15 5:30:00' YEAR TO SECOND AS duration")
41///     .until_done()
42///     .await?
43///     .read();
44///
45/// if let Some(row) = rows.next().await.transpose()? {
46///     let interval: Interval = row.get("duration");
47///     println!("{} years, {} months, {} days", interval.years, interval.months, interval.days);
48/// }
49/// # Ok(())
50/// # }
51/// ```
52#[derive(Clone, Debug, Default, PartialEq)]
53pub struct Interval {
54    /// Years component.
55    pub years: i32,
56    /// Months component.
57    pub months: i32,
58    /// Days component.
59    pub days: i32,
60    /// Hours component.
61    pub hours: i32,
62    /// Minutes component.
63    pub minutes: i32,
64    /// Seconds component.
65    pub seconds: i32,
66    /// Nanoseconds component.
67    pub nanos: i32,
68}
69
70impl FromSql for Interval {
71    fn from_sql(value: wkt::Value) -> Result<Self, ConvertError> {
72        match value {
73            wkt::Value::String(s) => {
74                let mut parts = s.split_whitespace();
75                let ym_str = parts.next();
76                let days_str = parts.next();
77                let time_str = parts.next();
78                let extra = parts.next();
79
80                let (ym_str, days_str, time_str) = match (ym_str, days_str, time_str, extra) {
81                    (Some(ym), Some(d), Some(t), None) => (ym, d, t),
82                    _ => {
83                        return Err(ConvertError::Convert(
84                            format!("invalid interval format: expected 3 parts, got `{s}`").into(),
85                        ));
86                    }
87                };
88
89                // Parse Y-M
90                let ym_neg = ym_str.starts_with('-');
91                let ym_content = if ym_neg { &ym_str[1..] } else { ym_str };
92                let mut ym_parts = ym_content.split('-');
93                let y_str = ym_parts.next();
94                let m_str = ym_parts.next();
95                let ym_extra = ym_parts.next();
96
97                let (y_str, m_str) = match (y_str, m_str, ym_extra) {
98                    (Some(y), Some(m), None) => (y, m),
99                    _ => {
100                        return Err(ConvertError::Convert(
101                            "invalid interval year-month format".into(),
102                        ));
103                    }
104                };
105                let ym_sign = if ym_neg { -1 } else { 1 };
106                let years = y_str
107                    .parse::<i32>()
108                    .map_err(|e| ConvertError::Convert(Box::new(e)))?
109                    * ym_sign;
110                let months = m_str
111                    .parse::<i32>()
112                    .map_err(|e| ConvertError::Convert(Box::new(e)))?
113                    * ym_sign;
114
115                // Parse Days
116                let days = days_str
117                    .parse::<i32>()
118                    .map_err(|e| ConvertError::Convert(Box::new(e)))?;
119
120                // Parse H:M:S.F
121                let time_neg = time_str.starts_with('-');
122                let time_content = if time_neg { &time_str[1..] } else { time_str };
123                let t = parse_time(time_content)?;
124                let time_sign = if time_neg { -1 } else { 1 };
125                let hours = t.hour() as i32 * time_sign;
126                let minutes = t.minute() as i32 * time_sign;
127                let seconds = t.second() as i32 * time_sign;
128                let nanos = t.nanosecond() as i32 * time_sign;
129
130                Ok(Interval {
131                    years,
132                    months,
133                    days,
134                    hours,
135                    minutes,
136                    seconds,
137                    nanos,
138                })
139            }
140            wkt::Value::Null => Err(ConvertError::NotNull),
141            other => Err(ConvertError::TypeMismatch {
142                expected: "string",
143                got: other,
144            }),
145        }
146    }
147}
148
149/// Represents a BigQuery [RANGE] value.
150///
151/// [RANGE]: https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/data-types#range_type
152///
153/// # Example
154///
155/// ```
156/// # async fn sample() -> anyhow::Result<()> {
157/// use google_cloud_bigquery::client::BigQuery;
158/// use google_cloud_bigquery::datatypes::Range;
159/// use google_cloud_type::model::Date;
160///
161/// let client = BigQuery::builder()
162///     .with_project_id("my-project-id")
163///     .build()
164///     .await?;
165/// let mut rows = client
166///     .query("SELECT RANGE(DATE '2024-01-01', DATE '2024-12-31') AS date_range")
167///     .until_done()
168///     .await?
169///     .read();
170///
171/// if let Some(row) = rows.next().await.transpose()? {
172///     let date_range: Range<Date> = row.get("date_range");
173///     println!("Start: {:?}, End: {:?}", date_range.start, date_range.end);
174/// }
175/// # Ok(())
176/// # }
177/// ```
178#[derive(Clone, Debug, PartialEq)]
179pub struct Range<T> {
180    /// The inclusive start of the range (or None if unbounded).
181    pub start: Option<T>,
182    /// The exclusive end of the range (or None if unbounded).
183    pub end: Option<T>,
184}
185
186impl<T: FromSql> FromSql for Range<T> {
187    fn from_sql(value: wkt::Value) -> Result<Self, ConvertError> {
188        match value {
189            wkt::Value::String(s) => {
190                let trimmed = s.trim();
191                // Strip leading [ and trailing )
192                let content = trimmed
193                    .strip_prefix('[')
194                    .and_then(|c| c.strip_suffix(')'))
195                    .ok_or_else(|| {
196                        ConvertError::Convert(
197                            "invalid range format: missing enclosing brackets".into(),
198                        )
199                    })?;
200
201                // Split on the comma
202                let parts: Vec<&str> = content.split(',').collect();
203                if parts.len() != 2 {
204                    return Err(ConvertError::Convert(
205                        format!(
206                            "invalid range format: expected 2 parts, got {}",
207                            parts.len()
208                        )
209                        .into(),
210                    ));
211                }
212
213                let start_str = parts[0].trim();
214                let end_str = parts[1].trim();
215
216                let start = if start_str.is_empty() || start_str == "UNBOUNDED" {
217                    None
218                } else {
219                    Some(T::from_sql(wkt::Value::String(start_str.to_string()))?)
220                };
221
222                let end = if end_str.is_empty() || end_str == "UNBOUNDED" {
223                    None
224                } else {
225                    Some(T::from_sql(wkt::Value::String(end_str.to_string()))?)
226                };
227
228                Ok(Range { start, end })
229            }
230            wkt::Value::Null => Err(ConvertError::NotNull),
231            other => Err(ConvertError::TypeMismatch {
232                expected: "string",
233                got: other,
234            }),
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use test_case::test_case;
243
244    #[derive(Debug, PartialEq)]
245    enum TestConvertError {
246        NotNull,
247        TypeMismatch(&'static str),
248        Convert(String),
249    }
250
251    impl From<ConvertError> for TestConvertError {
252        fn from(err: ConvertError) -> Self {
253            match err {
254                ConvertError::NotNull => Self::NotNull,
255                ConvertError::TypeMismatch { expected, .. } => Self::TypeMismatch(expected),
256                ConvertError::Convert(e) => Self::Convert(e.to_string()),
257                ConvertError::MissingField(f) => Self::Convert(format!("missing field: {f}")),
258            }
259        }
260    }
261
262    #[test_case(wkt::Value::String("1-2 3 4:05:06.789123456".to_string()) => Ok(Interval { years: 1, months: 2, days: 3, hours: 4, minutes: 5, seconds: 6, nanos: 789_123_456 }) ; "valid interval with nanos")]
263    #[test_case(wkt::Value::String("0-0 0 0:00:00".to_string()) => Ok(Interval { years: 0, months: 0, days: 0, hours: 0, minutes: 0, seconds: 0, nanos: 0 }) ; "zero interval")]
264    #[test_case(wkt::Value::String("0-0 1 2:30:45.123456".to_string()) => Ok(Interval { years: 0, months: 0, days: 1, hours: 2, minutes: 30, seconds: 45, nanos: 123_456_000 }) ; "valid interval from integration test")]
265    #[test_case(wkt::Value::String("1-2 3 4:5:6".to_string()) => Ok(Interval { years: 1, months: 2, days: 3, hours: 4, minutes: 5, seconds: 6, nanos: 0 }) ; "unpadded time without subseconds")]
266    #[test_case(wkt::Value::String("1-2 3 4:5:6.5".to_string()) => Ok(Interval { years: 1, months: 2, days: 3, hours: 4, minutes: 5, seconds: 6, nanos: 500_000_000 }) ; "unpadded time with short subsecond")]
267    #[test_case(wkt::Value::String("-1-2 3 -4:5:6.123".to_string()) => Ok(Interval { years: -1, months: -2, days: 3, hours: -4, minutes: -5, seconds: -6, nanos: -123_000_000 }) ; "mixed signs interval")]
268    #[test_case(wkt::Value::String("0-0 0 1:1:1.000000001".to_string()) => Ok(Interval { years: 0, months: 0, days: 0, hours: 1, minutes: 1, seconds: 1, nanos: 1 }) ; "single nanosecond")]
269    #[test_case(wkt::Value::String("-1-2 -3 -4:05:06.123".to_string()) => Ok(Interval { years: -1, months: -2, days: -3, hours: -4, minutes: -5, seconds: -6, nanos: -123_000_000 }) ; "all negative interval")]
270    #[test_case(wkt::Value::String("0-0 0 0:00:00.1234567899".to_string()) => Ok(Interval { years: 0, months: 0, days: 0, hours: 0, minutes: 0, seconds: 0, nanos: 123_456_789 }) ; "truncated nanos")]
271    #[test_case(wkt::Value::Null => Err(TestConvertError::NotNull) ; "null interval")]
272    #[test_case(wkt::Value::Number(123.into()) => Err(TestConvertError::TypeMismatch("string")) ; "type mismatch interval")]
273    #[test_case(wkt::Value::String("".to_string()) => Err(TestConvertError::Convert("invalid interval format: expected 3 parts, got ``".to_string())) ; "empty interval string")]
274    #[test_case(wkt::Value::String("1-2 3".to_string()) => Err(TestConvertError::Convert("invalid interval format: expected 3 parts, got `1-2 3`".to_string())) ; "invalid interval parts count")]
275    #[test_case(wkt::Value::String("1 3 4:05:06".to_string()) => Err(TestConvertError::Convert("invalid interval year-month format".to_string())) ; "invalid year-month format")]
276    #[test_case(wkt::Value::String("1-2 3 4:05".to_string()) => Err(TestConvertError::Convert("a character literal was not valid".to_string())) ; "invalid time format")]
277    fn test_from_sql_interval(value: wkt::Value) -> Result<Interval, TestConvertError> {
278        FromSql::from_sql(value).map_err(TestConvertError::from)
279    }
280
281    #[test_case(wkt::Value::String("[2026-05-28, 2026-05-29)".to_string()) => Ok(Range { start: Some(google_cloud_type::model::Date::new().set_year(2026).set_month(5).set_day(28)), end: Some(google_cloud_type::model::Date::new().set_year(2026).set_month(5).set_day(29)) }) ; "date range bounded")]
282    #[test_case(wkt::Value::String("[2026-05-28, UNBOUNDED)".to_string()) => Ok(Range { start: Some(google_cloud_type::model::Date::new().set_year(2026).set_month(5).set_day(28)), end: None }) ; "date range unbounded end")]
283    #[test_case(wkt::Value::String("[UNBOUNDED, 2026-05-29)".to_string()) => Ok(Range { start: None, end: Some(google_cloud_type::model::Date::new().set_year(2026).set_month(5).set_day(29)) }) ; "date range unbounded start")]
284    #[test_case(wkt::Value::String("[UNBOUNDED, UNBOUNDED)".to_string()) => Ok(Range { start: None, end: None }) ; "date range unbounded both")]
285    #[test_case(wkt::Value::Null => Err(TestConvertError::NotNull) ; "null range")]
286    #[test_case(wkt::Value::Number(123.into()) => Err(TestConvertError::TypeMismatch("string")) ; "range type mismatch")]
287    #[test_case(wkt::Value::String("[2026-05-28)".to_string()) => Err(TestConvertError::Convert("invalid range format: expected 2 parts, got 1".to_string())) ; "range invalid format one part")]
288    #[test_case(wkt::Value::String("[2026-05-28, 2026-05-29, 2026-05-30)".to_string()) => Err(TestConvertError::Convert("invalid range format: expected 2 parts, got 3".to_string())) ; "range invalid format three parts")]
289    #[test_case(wkt::Value::String("[".to_string()) => Err(TestConvertError::Convert("invalid range format: missing enclosing brackets".to_string())) ; "range too short")]
290    #[test_case(wkt::Value::String("2026-05-28, 2026-05-29".to_string()) => Err(TestConvertError::Convert("invalid range format: missing enclosing brackets".to_string())) ; "range missing brackets")]
291    #[test_case(wkt::Value::String("(2026-05-28, 2026-05-29)".to_string()) => Err(TestConvertError::Convert("invalid range format: missing enclosing brackets".to_string())) ; "range invalid leading parenthesis")]
292    #[test_case(wkt::Value::String("[2026-05-28, 2026-05-29]".to_string()) => Err(TestConvertError::Convert("invalid range format: missing enclosing brackets".to_string())) ; "range invalid trailing square bracket")]
293    fn test_from_sql_range(
294        value: wkt::Value,
295    ) -> Result<Range<google_cloud_type::model::Date>, TestConvertError> {
296        FromSql::from_sql(value).map_err(TestConvertError::from)
297    }
298}