Skip to main content

arrow_schema/extension/canonical/
timestamp_with_offset.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Timestamp with an offset in minutes
19//!
20//! <https://arrow.apache.org/docs/format/CanonicalExtensions.html#timestamp-with-offset>
21
22use crate::{ArrowError, DataType, extension::ExtensionType};
23
24/// The extension type for `TimestampWithOffset`.
25///
26/// Extension name: `arrow.timestamp_with_offset`.
27///
28/// This type represents a timestamp column that stores potentially different timezone offsets per
29/// value. The timestamp is stored in UTC alongside the original timezone offset in minutes. This
30/// extension type is intended to be compatible with ANSI SQL's `TIMESTAMP WITH TIME ZONE`, which
31/// is supported by multiple database engines.
32///
33/// The storage type of the extension is a `Struct` with 2 fields, in order: - `timestamp`: a
34/// non-nullable `Timestamp(time_unit, "UTC")`, where `time_unit` is any Arrow `TimeUnit` (s, ms,
35/// us or ns). - `offset_minutes`: a non-nullable signed 16-bit integer (`Int16`) representing the
36/// offset in minutes from the UTC timezone. Negative offsets represent time zones west of UTC,
37/// while positive offsets represent east. Offsets normally range from -779 (-12:59) to +780
38/// (+13:00).
39///
40/// This type has no type parameters.
41///
42/// Metadata is either empty or an empty string.
43///
44/// It is also *permissible* for the `offset_minutes` field to be dictionary-encoded with a
45/// preferred (*but not required*) index type of `int8`, or run-end-encoded with a preferred (*but
46/// not required*) runs type of `int8`.
47///
48/// It's worth noting that the data source needs to resolve timezone strings such as `UTC` or
49/// `Americas/Los_Angeles` into an offset in minutes in order to construct a `TimestampWithOffset`.
50/// This makes `TimestampWithOffset` type "lossy" in the sense that any original "unresolved"
51/// timezone string gets lost in this conversion. It's a tradeoff for optimizing the row
52/// representation and simplifying the client code, which does not need to know how to convert from
53/// timezone string to its corresponding offset in minutes.
54///
55/// <https://arrow.apache.org/docs/format/CanonicalExtensions.html#timestamp-with-offset>
56#[derive(Debug, Default, Clone, Copy, PartialEq)]
57pub struct TimestampWithOffset;
58
59const TIMESTAMP_FIELD_NAME: &str = "timestamp";
60const OFFSET_FIELD_NAME: &str = "offset_minutes";
61
62impl ExtensionType for TimestampWithOffset {
63    const NAME: &'static str = "arrow.timestamp_with_offset";
64
65    type Metadata = ();
66
67    fn metadata(&self) -> &Self::Metadata {
68        &()
69    }
70
71    fn serialize_metadata(&self) -> Option<String> {
72        None
73    }
74
75    fn deserialize_metadata(metadata: Option<&str>) -> Result<Self::Metadata, ArrowError> {
76        metadata.map_or_else(
77            || Ok(()),
78            |v| {
79                if !v.is_empty() {
80                    Err(ArrowError::InvalidArgumentError(
81                        "TimestampWithOffset extension type expects no metadata".to_owned(),
82                    ))
83                } else {
84                    Ok(())
85                }
86            },
87        )
88    }
89
90    fn supports_data_type(&self, data_type: &DataType) -> Result<(), ArrowError> {
91        let ok = match data_type {
92            DataType::Struct(fields) => match fields.len() {
93                2 => {
94                    let maybe_timestamp = fields.first().unwrap();
95                    let maybe_offset = fields.get(1).unwrap();
96
97                    let timestamp_type_ok = matches!(maybe_timestamp.data_type(), DataType::Timestamp(_, tz) if {
98                        match tz {
99                            Some(tz) => {
100                                tz.as_ref() == "UTC"
101                            },
102                            None => false
103                        }
104                    });
105
106                    let offset_type_ok = match maybe_offset.data_type() {
107                        DataType::Int16 => true,
108                        DataType::Dictionary(key_type, value_type) => {
109                            key_type.is_dictionary_key_type()
110                                && matches!(value_type.as_ref(), DataType::Int16)
111                        }
112                        DataType::RunEndEncoded(run_ends, values) => {
113                            run_ends.data_type().is_run_ends_type()
114                                && matches!(values.data_type(), DataType::Int16)
115                        }
116                        _ => false,
117                    };
118
119                    maybe_timestamp.name() == TIMESTAMP_FIELD_NAME
120                        && timestamp_type_ok
121                        && !maybe_timestamp.is_nullable()
122                        && maybe_offset.name() == OFFSET_FIELD_NAME
123                        && offset_type_ok
124                        && !maybe_offset.is_nullable()
125                }
126                _ => false,
127            },
128            _ => false,
129        };
130
131        match ok {
132            true => Ok(()),
133            false => Err(ArrowError::InvalidArgumentError(format!(
134                "TimestampWithOffset data type mismatch, expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found {data_type}"
135            ))),
136        }
137    }
138
139    fn try_new(data_type: &DataType, _metadata: Self::Metadata) -> Result<Self, ArrowError> {
140        Self.supports_data_type(data_type).map(|()| Self)
141    }
142
143    fn validate(data_type: &DataType, _metadata: Self::Metadata) -> Result<(), ArrowError> {
144        Self.supports_data_type(data_type)
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use std::sync::Arc;
151
152    #[cfg(feature = "canonical_extension_types")]
153    use crate::extension::CanonicalExtensionType;
154    use crate::{
155        Field, Fields, TimeUnit,
156        extension::{EXTENSION_TYPE_METADATA_KEY, EXTENSION_TYPE_NAME_KEY},
157    };
158
159    use super::*;
160
161    fn make_valid_field_primitive(time_unit: TimeUnit) -> Field {
162        Field::new(
163            "",
164            DataType::Struct(Fields::from_iter([
165                Field::new(
166                    TIMESTAMP_FIELD_NAME,
167                    DataType::Timestamp(time_unit, Some("UTC".into())),
168                    false,
169                ),
170                Field::new(OFFSET_FIELD_NAME, DataType::Int16, false),
171            ])),
172            false,
173        )
174    }
175
176    fn make_valid_field_dict_encoded(time_unit: TimeUnit, key_type: DataType) -> Field {
177        assert!(key_type.is_dictionary_key_type());
178
179        Field::new(
180            "",
181            DataType::Struct(Fields::from_iter([
182                Field::new(
183                    TIMESTAMP_FIELD_NAME,
184                    DataType::Timestamp(time_unit, Some("UTC".into())),
185                    false,
186                ),
187                Field::new(
188                    OFFSET_FIELD_NAME,
189                    DataType::Dictionary(Box::new(key_type), Box::new(DataType::Int16)),
190                    false,
191                ),
192            ])),
193            false,
194        )
195    }
196
197    fn make_valid_field_run_end_encoded(time_unit: TimeUnit, run_ends_type: DataType) -> Field {
198        assert!(run_ends_type.is_run_ends_type());
199        Field::new(
200            "",
201            DataType::Struct(Fields::from_iter([
202                Field::new(
203                    TIMESTAMP_FIELD_NAME,
204                    DataType::Timestamp(time_unit, Some("UTC".into())),
205                    false,
206                ),
207                Field::new(
208                    OFFSET_FIELD_NAME,
209                    DataType::RunEndEncoded(
210                        Arc::new(Field::new(
211                            Field::REE_RUN_ENDS_FIELD_DEFAULT_NAME,
212                            run_ends_type,
213                            false,
214                        )),
215                        Arc::new(Field::new(
216                            Field::REE_VALUES_FIELD_DEFAULT_NAME,
217                            DataType::Int16,
218                            false,
219                        )),
220                    ),
221                    false,
222                ),
223            ])),
224            false,
225        )
226    }
227
228    #[test]
229    fn valid_primitive_offsets() -> Result<(), ArrowError> {
230        let time_units = [
231            TimeUnit::Second,
232            TimeUnit::Millisecond,
233            TimeUnit::Microsecond,
234            TimeUnit::Nanosecond,
235        ];
236
237        for time_unit in time_units {
238            let mut field = make_valid_field_primitive(time_unit);
239            field.try_with_extension_type(TimestampWithOffset)?;
240            field.try_extension_type::<TimestampWithOffset>()?;
241            #[cfg(feature = "canonical_extension_types")]
242            assert_eq!(
243                field.try_canonical_extension_type()?,
244                CanonicalExtensionType::TimestampWithOffset(TimestampWithOffset)
245            );
246        }
247
248        Ok(())
249    }
250
251    #[test]
252    fn valid_dict_encoded_offsets() -> Result<(), ArrowError> {
253        let time_units = [
254            TimeUnit::Second,
255            TimeUnit::Millisecond,
256            TimeUnit::Microsecond,
257            TimeUnit::Nanosecond,
258        ];
259
260        let key_types = [
261            DataType::UInt8,
262            DataType::UInt16,
263            DataType::UInt32,
264            DataType::UInt64,
265            DataType::Int8,
266            DataType::Int16,
267            DataType::Int32,
268            DataType::Int64,
269        ];
270
271        for time_unit in time_units {
272            for key_type in &key_types {
273                let mut field = make_valid_field_dict_encoded(time_unit, key_type.clone());
274                field.try_with_extension_type(TimestampWithOffset)?;
275                field.try_extension_type::<TimestampWithOffset>()?;
276                #[cfg(feature = "canonical_extension_types")]
277                assert_eq!(
278                    field.try_canonical_extension_type()?,
279                    CanonicalExtensionType::TimestampWithOffset(TimestampWithOffset)
280                );
281            }
282        }
283
284        Ok(())
285    }
286
287    #[test]
288    fn valid_run_end_encoded_offsets() -> Result<(), ArrowError> {
289        let time_units = [
290            TimeUnit::Second,
291            TimeUnit::Millisecond,
292            TimeUnit::Microsecond,
293            TimeUnit::Nanosecond,
294        ];
295
296        let run_ends_types = [DataType::Int16, DataType::Int32, DataType::Int64];
297
298        for time_unit in time_units {
299            for run_ends_type in &run_ends_types {
300                let mut field = make_valid_field_run_end_encoded(time_unit, run_ends_type.clone());
301                field.try_with_extension_type(TimestampWithOffset)?;
302                field.try_extension_type::<TimestampWithOffset>()?;
303                #[cfg(feature = "canonical_extension_types")]
304                assert_eq!(
305                    field.try_canonical_extension_type()?,
306                    CanonicalExtensionType::TimestampWithOffset(TimestampWithOffset)
307                );
308            }
309        }
310
311        Ok(())
312    }
313
314    #[test]
315    #[should_panic(expected = "Extension type name missing")]
316    fn missing_name() {
317        let field = make_valid_field_primitive(TimeUnit::Second)
318            .with_metadata([(EXTENSION_TYPE_METADATA_KEY, "")]);
319        field.extension_type::<TimestampWithOffset>();
320    }
321
322    #[test]
323    #[should_panic(
324        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Boolean"
325    )]
326    fn invalid_type_top_level() {
327        Field::new("", DataType::Boolean, false).with_extension_type(TimestampWithOffset);
328    }
329
330    #[test]
331    #[should_panic(
332        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Struct"
333    )]
334    fn invalid_type_struct_field_count() {
335        let data_type =
336            DataType::Struct(Fields::from_iter([Field::new("", DataType::Int16, false)]));
337        Field::new("", data_type, false).with_extension_type(TimestampWithOffset);
338    }
339
340    #[test]
341    #[should_panic(
342        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Struct"
343    )]
344    fn invalid_type_wrong_timestamp_type() {
345        let data_type = DataType::Struct(Fields::from_iter([
346            Field::new(TIMESTAMP_FIELD_NAME, DataType::Int16, false),
347            Field::new(OFFSET_FIELD_NAME, DataType::Int16, false),
348        ]));
349        Field::new("", data_type, false).with_extension_type(TimestampWithOffset);
350    }
351
352    #[test]
353    #[should_panic(
354        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Struct"
355    )]
356    fn invalid_type_wrong_offset_type() {
357        let data_type = DataType::Struct(Fields::from_iter([
358            Field::new(
359                TIMESTAMP_FIELD_NAME,
360                DataType::Timestamp(TimeUnit::Second, Some("UTC".into())),
361                false,
362            ),
363            Field::new(OFFSET_FIELD_NAME, DataType::UInt64, false),
364        ]));
365        Field::new("", data_type, false).with_extension_type(TimestampWithOffset);
366    }
367
368    #[test]
369    #[should_panic(
370        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Struct"
371    )]
372    fn invalid_type_wrong_offset_key_dict_encoded() {
373        let data_type = DataType::Struct(Fields::from_iter([
374            Field::new(
375                TIMESTAMP_FIELD_NAME,
376                DataType::Timestamp(TimeUnit::Second, Some("UTC".into())),
377                false,
378            ),
379            Field::new(
380                OFFSET_FIELD_NAME,
381                DataType::Dictionary(Box::new(DataType::Boolean), Box::new(DataType::Int16)),
382                false,
383            ),
384        ]));
385        Field::new("", data_type, false).with_extension_type(TimestampWithOffset);
386    }
387
388    #[test]
389    #[should_panic(
390        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Struct"
391    )]
392    fn invalid_type_wrong_offset_value_dict_encoded() {
393        let data_type = DataType::Struct(Fields::from_iter([
394            Field::new(
395                TIMESTAMP_FIELD_NAME,
396                DataType::Timestamp(TimeUnit::Second, Some("UTC".into())),
397                false,
398            ),
399            Field::new(
400                OFFSET_FIELD_NAME,
401                DataType::Dictionary(Box::new(DataType::UInt8), Box::new(DataType::Int32)),
402                false,
403            ),
404        ]));
405        Field::new("", data_type, false).with_extension_type(TimestampWithOffset);
406    }
407
408    #[test]
409    #[should_panic(
410        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Struct"
411    )]
412    fn invalid_type_wrong_run_ends_run_end_encoded() {
413        let data_type = DataType::Struct(Fields::from_iter([
414            Field::new(
415                TIMESTAMP_FIELD_NAME,
416                DataType::Timestamp(TimeUnit::Second, Some("UTC".into())),
417                false,
418            ),
419            Field::new(
420                OFFSET_FIELD_NAME,
421                DataType::RunEndEncoded(
422                    Arc::new(Field::new(
423                        Field::REE_RUN_ENDS_FIELD_DEFAULT_NAME,
424                        DataType::Boolean,
425                        false,
426                    )),
427                    Arc::new(Field::new(
428                        Field::REE_VALUES_FIELD_DEFAULT_NAME,
429                        DataType::Int16,
430                        false,
431                    )),
432                ),
433                false,
434            ),
435        ]));
436        Field::new("", data_type, false).with_extension_type(TimestampWithOffset);
437    }
438
439    #[test]
440    #[should_panic(
441        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Struct"
442    )]
443    fn invalid_type_wrong_values_run_end_encoded() {
444        let data_type = DataType::Struct(Fields::from_iter([
445            Field::new(
446                TIMESTAMP_FIELD_NAME,
447                DataType::Timestamp(TimeUnit::Second, Some("UTC".into())),
448                false,
449            ),
450            Field::new(
451                OFFSET_FIELD_NAME,
452                DataType::RunEndEncoded(
453                    Arc::new(Field::new(
454                        Field::REE_RUN_ENDS_FIELD_DEFAULT_NAME,
455                        DataType::UInt16,
456                        false,
457                    )),
458                    Arc::new(Field::new(
459                        Field::REE_VALUES_FIELD_DEFAULT_NAME,
460                        DataType::Int32,
461                        false,
462                    )),
463                ),
464                false,
465            ),
466        ]));
467        Field::new("", data_type, false).with_extension_type(TimestampWithOffset);
468    }
469
470    #[test]
471    #[should_panic(
472        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Struct"
473    )]
474    fn invalid_type_nullable_timestamp() {
475        let data_type = DataType::Struct(Fields::from_iter([
476            Field::new(
477                TIMESTAMP_FIELD_NAME,
478                DataType::Timestamp(TimeUnit::Second, Some("UTC".into())),
479                true,
480            ),
481            Field::new(OFFSET_FIELD_NAME, DataType::Int16, false),
482        ]));
483        Field::new("", data_type, false).with_extension_type(TimestampWithOffset);
484    }
485
486    #[test]
487    #[should_panic(
488        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Struct"
489    )]
490    fn invalid_type_nullable_offset() {
491        let data_type = DataType::Struct(Fields::from_iter([
492            Field::new(
493                TIMESTAMP_FIELD_NAME,
494                DataType::Timestamp(TimeUnit::Second, Some("UTC".into())),
495                false,
496            ),
497            Field::new(OFFSET_FIELD_NAME, DataType::Int16, true),
498        ]));
499        Field::new("", data_type, false).with_extension_type(TimestampWithOffset);
500    }
501
502    #[test]
503    #[should_panic(
504        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Struct"
505    )]
506    fn invalid_type_no_timezone() {
507        let data_type = DataType::Struct(Fields::from_iter([
508            Field::new(
509                TIMESTAMP_FIELD_NAME,
510                DataType::Timestamp(TimeUnit::Second, None),
511                false,
512            ),
513            Field::new(OFFSET_FIELD_NAME, DataType::Int16, false),
514        ]));
515        Field::new("", data_type, false).with_extension_type(TimestampWithOffset);
516    }
517
518    #[test]
519    #[should_panic(
520        expected = "expected Struct(\"timestamp\": Timestamp(_, Some(\"UTC\")), \"offset_minutes\": Int16), found Struct"
521    )]
522    fn invalid_type_wrong_timezone() {
523        let data_type = DataType::Struct(Fields::from_iter([
524            Field::new(
525                TIMESTAMP_FIELD_NAME,
526                DataType::Timestamp(TimeUnit::Second, Some("Americas/Sao_Paulo".into())),
527                false,
528            ),
529            Field::new(OFFSET_FIELD_NAME, DataType::Int16, false),
530        ]));
531        Field::new("", data_type, false).with_extension_type(TimestampWithOffset);
532    }
533
534    #[test]
535    fn no_metadata() {
536        let field = make_valid_field_primitive(TimeUnit::Second)
537            .with_metadata([(EXTENSION_TYPE_NAME_KEY, TimestampWithOffset::NAME)]);
538        field.extension_type::<TimestampWithOffset>();
539    }
540
541    #[test]
542    fn empty_metadata() {
543        let field = make_valid_field_primitive(TimeUnit::Second).with_metadata([
544            (EXTENSION_TYPE_NAME_KEY, TimestampWithOffset::NAME),
545            (EXTENSION_TYPE_METADATA_KEY, ""),
546        ]);
547        field.extension_type::<TimestampWithOffset>();
548    }
549}