Skip to main content

vortex_arrow/
dtype.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Convert between Vortex [`vortex_array::dtype::DType`] and Apache Arrow [`arrow_schema::DataType`].
5//!
6//! Apache Arrow's type system includes physical information, which could lead to ambiguities as
7//! Vortex treats encodings as separate from logical types.
8//!
9//! [`DType::to_arrow_schema`] and its sibling [`DType::to_arrow_dtype`] use a simple algorithm,
10//! where every logical type is encoded in its simplest corresponding Arrow type. This reflects the
11//! reality that most compute engines don't make use of the entire type range arrow-rs supports.
12//!
13//! For this reason, it's recommended to do as much computation as possible within Vortex, and then
14//! materialize an Arrow ArrayRef at the very end of the processing chain.
15
16use std::sync::Arc;
17
18use arrow_schema::DataType;
19use arrow_schema::Field;
20use arrow_schema::FieldRef;
21use arrow_schema::Fields;
22use arrow_schema::Schema;
23use arrow_schema::SchemaBuilder;
24use arrow_schema::SchemaRef;
25use arrow_schema::TimeUnit as ArrowTimeUnit;
26use vortex_array::dtype::DType;
27use vortex_array::dtype::DecimalDType;
28use vortex_array::dtype::FieldName;
29use vortex_array::dtype::Nullability;
30use vortex_array::dtype::PType;
31use vortex_array::dtype::StructFields;
32use vortex_array::extension::datetime::AnyTemporal;
33use vortex_array::extension::datetime::Date;
34use vortex_array::extension::datetime::TemporalMetadata;
35use vortex_array::extension::datetime::Time;
36use vortex_array::extension::datetime::TimeUnit;
37use vortex_array::extension::datetime::Timestamp;
38use vortex_error::VortexExpect;
39use vortex_error::VortexResult;
40use vortex_error::vortex_bail;
41use vortex_error::vortex_ensure;
42use vortex_error::vortex_ensure_eq;
43use vortex_error::vortex_err;
44use vortex_error::vortex_panic;
45
46/// Trait for converting Arrow types to Vortex types.
47pub trait FromArrowType<T>: Sized {
48    /// Convert the Arrow type to a Vortex type.
49    fn from_arrow(value: T) -> Self;
50}
51
52/// Trait for converting Vortex types to Arrow types.
53pub trait TryFromArrowType<T>: Sized {
54    /// Convert the Arrow type to a Vortex type.
55    fn try_from_arrow(value: T) -> VortexResult<Self>;
56}
57
58impl TryFromArrowType<&DataType> for PType {
59    fn try_from_arrow(value: &DataType) -> VortexResult<Self> {
60        match value {
61            DataType::Int8 => Ok(Self::I8),
62            DataType::Int16 => Ok(Self::I16),
63            DataType::Int32 => Ok(Self::I32),
64            DataType::Int64 => Ok(Self::I64),
65            DataType::UInt8 => Ok(Self::U8),
66            DataType::UInt16 => Ok(Self::U16),
67            DataType::UInt32 => Ok(Self::U32),
68            DataType::UInt64 => Ok(Self::U64),
69            DataType::Float16 => Ok(Self::F16),
70            DataType::Float32 => Ok(Self::F32),
71            DataType::Float64 => Ok(Self::F64),
72            _ => Err(vortex_err!(
73                "Arrow datatype {:?} cannot be converted to ptype",
74                value
75            )),
76        }
77    }
78}
79
80impl TryFromArrowType<&DataType> for DecimalDType {
81    fn try_from_arrow(value: &DataType) -> VortexResult<Self> {
82        match value {
83            DataType::Decimal32(precision, scale)
84            | DataType::Decimal64(precision, scale)
85            | DataType::Decimal128(precision, scale)
86            | DataType::Decimal256(precision, scale) => Self::try_new(*precision, *scale),
87
88            _ => Err(vortex_err!(
89                "Arrow datatype {:?} cannot be converted to DecimalDType",
90                value
91            )),
92        }
93    }
94}
95
96impl FromArrowType<&ArrowTimeUnit> for TimeUnit {
97    fn from_arrow(value: &ArrowTimeUnit) -> Self {
98        Self::from_arrow(*value)
99    }
100}
101
102impl FromArrowType<ArrowTimeUnit> for TimeUnit {
103    fn from_arrow(value: ArrowTimeUnit) -> Self {
104        match value {
105            ArrowTimeUnit::Second => Self::Seconds,
106            ArrowTimeUnit::Millisecond => Self::Milliseconds,
107            ArrowTimeUnit::Microsecond => Self::Microseconds,
108            ArrowTimeUnit::Nanosecond => Self::Nanoseconds,
109        }
110    }
111}
112
113/// Convert a Vortex [`TimeUnit`] to an Arrow [`ArrowTimeUnit`].
114///
115/// # Errors
116///
117/// Returns an error for units with no Arrow equivalent (e.g. [`TimeUnit::Days`]).
118pub fn to_arrow_time_unit(value: TimeUnit) -> VortexResult<ArrowTimeUnit> {
119    Ok(match value {
120        TimeUnit::Seconds => ArrowTimeUnit::Second,
121        TimeUnit::Milliseconds => ArrowTimeUnit::Millisecond,
122        TimeUnit::Microseconds => ArrowTimeUnit::Microsecond,
123        TimeUnit::Nanoseconds => ArrowTimeUnit::Nanosecond,
124        _ => vortex_bail!("Cannot convert {value} to Arrow TimeUnit"),
125    })
126}
127
128impl FromArrowType<SchemaRef> for DType {
129    fn from_arrow(value: SchemaRef) -> Self {
130        Self::from_arrow(value.as_ref())
131    }
132}
133
134impl TryFromArrowType<SchemaRef> for DType {
135    fn try_from_arrow(value: SchemaRef) -> VortexResult<Self> {
136        Self::try_from_arrow(value.as_ref())
137    }
138}
139
140impl FromArrowType<&Schema> for DType {
141    fn from_arrow(value: &Schema) -> Self {
142        Self::try_from_arrow(value).vortex_expect("arrow schema to dtype")
143    }
144}
145
146impl TryFromArrowType<&Schema> for DType {
147    fn try_from_arrow(value: &Schema) -> VortexResult<Self> {
148        Ok(Self::Struct(
149            StructFields::try_from_arrow(value.fields())?,
150            Nullability::NonNullable, // Must match From<RecordBatch> for Array
151        ))
152    }
153}
154
155impl FromArrowType<&Fields> for StructFields {
156    fn from_arrow(value: &Fields) -> Self {
157        Self::try_from_arrow(value).vortex_expect("arrow fields to struct fields")
158    }
159}
160
161impl TryFromArrowType<&Fields> for StructFields {
162    fn try_from_arrow(value: &Fields) -> VortexResult<Self> {
163        value
164            .into_iter()
165            .map(|f| {
166                Ok((
167                    FieldName::from(f.name().as_str()),
168                    DType::try_from_arrow(f.as_ref())?,
169                ))
170            })
171            .collect::<VortexResult<StructFields>>()
172    }
173}
174
175impl FromArrowType<(&DataType, Nullability)> for DType {
176    fn from_arrow(value: (&DataType, Nullability)) -> Self {
177        Self::try_from_arrow(value).vortex_expect("arrow data type to dtype")
178    }
179}
180
181impl TryFromArrowType<(&DataType, Nullability)> for DType {
182    fn try_from_arrow((data_type, nullability): (&DataType, Nullability)) -> VortexResult<Self> {
183        if data_type.is_integer() || data_type.is_floating() {
184            return Ok(DType::Primitive(
185                PType::try_from_arrow(data_type)?,
186                nullability,
187            ));
188        }
189
190        Ok(match data_type {
191            DataType::Null => DType::Null,
192            DataType::Decimal32(precision, scale)
193            | DataType::Decimal64(precision, scale)
194            | DataType::Decimal128(precision, scale)
195            | DataType::Decimal256(precision, scale) => {
196                DType::Decimal(DecimalDType::new(*precision, *scale), nullability)
197            }
198            DataType::Boolean => DType::Bool(nullability),
199            DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => DType::Utf8(nullability),
200            DataType::Binary | DataType::LargeBinary | DataType::BinaryView => {
201                DType::Binary(nullability)
202            }
203            DataType::Date32 => DType::Extension(Date::new(TimeUnit::Days, nullability).erased()),
204            DataType::Date64 => {
205                DType::Extension(Date::new(TimeUnit::Milliseconds, nullability).erased())
206            }
207            DataType::Time32(unit) => {
208                DType::Extension(Time::new(TimeUnit::from_arrow(unit), nullability).erased())
209            }
210            DataType::Time64(unit) => {
211                DType::Extension(Time::new(TimeUnit::from_arrow(unit), nullability).erased())
212            }
213            DataType::Timestamp(unit, tz) => DType::Extension(
214                Timestamp::new_with_tz(TimeUnit::from_arrow(unit), tz.clone(), nullability)
215                    .erased(),
216            ),
217            DataType::List(e)
218            | DataType::LargeList(e)
219            | DataType::ListView(e)
220            | DataType::LargeListView(e) => {
221                DType::List(Arc::new(Self::try_from_arrow(e.as_ref())?), nullability)
222            }
223            DataType::FixedSizeList(e, size) => DType::FixedSizeList(
224                Arc::new(Self::try_from_arrow(e.as_ref())?),
225                *size as u32,
226                nullability,
227            ),
228            DataType::Struct(f) => DType::Struct(StructFields::try_from_arrow(f)?, nullability),
229            DataType::Map(entries, keys_sorted) => {
230                vortex_ensure!(
231                    !entries.is_nullable(),
232                    "Arrow map entries field must be non-nullable"
233                );
234                let DataType::Struct(fields) = entries.data_type() else {
235                    vortex_bail!(
236                        "Arrow map entries field must have Struct type, got {:?}",
237                        entries.data_type()
238                    );
239                };
240                vortex_ensure_eq!(
241                    fields.len(),
242                    2,
243                    InvalidArgument: "Arrow map entries struct must contain exactly two fields"
244                );
245                let key = &fields[0];
246                let value = &fields[1];
247                vortex_ensure!(
248                    !key.is_nullable(),
249                    "Arrow map key field must be non-nullable"
250                );
251                DType::map(
252                    Self::try_from_arrow(key.as_ref())?,
253                    Self::try_from_arrow(value.as_ref())?,
254                    *keys_sorted,
255                    nullability,
256                )?
257            }
258            DataType::Dictionary(_, value_type) => {
259                Self::try_from_arrow((value_type.as_ref(), nullability))?
260            }
261            DataType::RunEndEncoded(_, value_type) => {
262                Self::try_from_arrow((value_type.data_type(), nullability))?
263            }
264            _ => vortex_bail!("Arrow data type not supported: {data_type:?}"),
265        })
266    }
267}
268
269impl FromArrowType<&Field> for DType {
270    fn from_arrow(field: &Field) -> Self {
271        Self::try_from_arrow(field).vortex_expect("arrow field to dtype")
272    }
273}
274
275impl TryFromArrowType<&Field> for DType {
276    fn try_from_arrow(field: &Field) -> VortexResult<Self> {
277        if field
278            .metadata()
279            .get("ARROW:extension:name")
280            .map(|s| s.as_str())
281            == Some("arrow.parquet.variant")
282        {
283            return Ok(DType::Variant(field.is_nullable().into()));
284        }
285        Self::try_from_arrow((field.data_type(), field.is_nullable().into()))
286    }
287}
288
289/// Extension trait converting Vortex [`DType`]s into Arrow schemas and data types.
290///
291/// This mirrors inherent methods that lived on [`DType`] before Arrow interoperability moved
292/// into this crate.
293pub trait ToArrowType {
294    /// Convert a Vortex [`DType`] into an Arrow [`Schema`].
295    ///
296    /// **Prefer `ArrowSession::to_arrow_schema`.** This method is not plugin-aware and
297    /// strips any `ARROW:extension:name` metadata for non-builtin extensions (only
298    /// `arrow.parquet.variant` is special-cased here). Use the session method when you
299    /// need round-trippable extension metadata.
300    fn to_arrow_schema(&self) -> VortexResult<Schema>;
301
302    /// Returns the Arrow [`DataType`] that best corresponds to this Vortex [`DType`].
303    ///
304    /// **Prefer `ArrowSession::to_arrow_datatype` (or `to_arrow_field`).** This method
305    /// has no awareness of registered Arrow extension plugins, so any [`DType::Extension`]
306    /// outside the builtin temporal set will fail or silently lose its
307    /// `ARROW:extension:name` metadata. The session methods recurse through containers
308    /// and dispatch plugins at every extension node.
309    #[deprecated(note = "Use `ArrowSession::to_arrow_datatype` instead")]
310    fn to_arrow_dtype(&self) -> VortexResult<DataType>;
311}
312
313impl ToArrowType for DType {
314    fn to_arrow_schema(&self) -> VortexResult<Schema> {
315        let DType::Struct(struct_dtype, nullable) = self else {
316            vortex_bail!("only DType::Struct can be converted to arrow schema");
317        };
318
319        if *nullable != Nullability::NonNullable {
320            vortex_bail!("top-level struct in Schema must be NonNullable");
321        }
322
323        let mut builder = SchemaBuilder::with_capacity(struct_dtype.names().len());
324        for (field_name, field_dtype) in struct_dtype.names().iter().zip(struct_dtype.fields()) {
325            let field = if field_dtype.is_variant() {
326                let storage = DataType::Struct(variant_storage_fields_minimal());
327                Field::new(field_name.as_ref(), storage, field_dtype.is_nullable()).with_metadata(
328                    [(
329                        "ARROW:extension:name".to_owned(),
330                        "arrow.parquet.variant".to_owned(),
331                    )]
332                    .into(),
333                )
334            } else {
335                Field::new(
336                    field_name.as_ref(),
337                    to_data_type_naive(&field_dtype)?,
338                    field_dtype.is_nullable(),
339                )
340            };
341            builder.push(field);
342        }
343
344        Ok(builder.finish())
345    }
346
347    fn to_arrow_dtype(&self) -> VortexResult<DataType> {
348        to_data_type_naive(self)
349    }
350}
351
352/// Naive conversion from a Vortex `DType` to the nearest Arrow physical data type.
353pub(crate) fn to_data_type_naive(dtype: &DType) -> VortexResult<DataType> {
354    Ok(match dtype {
355        DType::Null => DataType::Null,
356        DType::Bool(_) => DataType::Boolean,
357        DType::Primitive(ptype, _) => match ptype {
358            PType::U8 => DataType::UInt8,
359            PType::U16 => DataType::UInt16,
360            PType::U32 => DataType::UInt32,
361            PType::U64 => DataType::UInt64,
362            PType::I8 => DataType::Int8,
363            PType::I16 => DataType::Int16,
364            PType::I32 => DataType::Int32,
365            PType::I64 => DataType::Int64,
366            PType::F16 => DataType::Float16,
367            PType::F32 => DataType::Float32,
368            PType::F64 => DataType::Float64,
369        },
370        DType::Decimal(dt, _) => {
371            let precision = dt.precision();
372            let scale = dt.scale();
373
374            match precision {
375                // This code is commented out until DataFusion improves its support for smaller decimals.
376                // // DECIMAL32_MAX_PRECISION
377                // 0..=9 => DataType::Decimal32(precision, scale),
378                // // DECIMAL64_MAX_PRECISION
379                // 10..=18 => DataType::Decimal64(precision, scale),
380                // DECIMAL128_MAX_PRECISION
381                0..=38 => DataType::Decimal128(precision, scale),
382                // DECIMAL256_MAX_PRECISION
383                39.. => DataType::Decimal256(precision, scale),
384            }
385        }
386        DType::Utf8(_) => DataType::Utf8View,
387        DType::Binary(_) => DataType::BinaryView,
388        // There are four kinds of lists: List (32-bit offsets), Large List (64-bit), List View
389        // (32-bit), Large List View (64-bit). We cannot both guarantee zero-copy and commit to an
390        // Arrow dtype because we do not how large our offsets are.
391        DType::List(elem_dtype, _) => DataType::List(FieldRef::new(Field::new_list_field(
392            to_data_type_naive(elem_dtype)?,
393            elem_dtype.nullability().into(),
394        ))),
395        DType::FixedSizeList(elem_dtype, size, _) => DataType::FixedSizeList(
396            FieldRef::new(Field::new_list_field(
397                to_data_type_naive(elem_dtype)?,
398                elem_dtype.nullability().into(),
399            )),
400            *size as i32,
401        ),
402        DType::Map(map_dtype, _) => {
403            let key = Field::new("key", to_data_type_naive(&map_dtype.key_dtype())?, false);
404            let value_dtype = map_dtype.value_dtype();
405            let value = Field::new(
406                "value",
407                to_data_type_naive(&value_dtype)?,
408                value_dtype.is_nullable(),
409            );
410            let entries = Field::new_struct("entries", Fields::from(vec![key, value]), false);
411            DataType::Map(FieldRef::new(entries), map_dtype.keys_sorted())
412        }
413        DType::Struct(struct_dtype, _) => {
414            let mut fields = Vec::with_capacity(struct_dtype.names().len());
415            for (field_name, field_dt) in struct_dtype.names().iter().zip(struct_dtype.fields()) {
416                fields.push(FieldRef::from(Field::new(
417                    field_name.as_ref(),
418                    to_data_type_naive(&field_dt)?,
419                    field_dt.is_nullable(),
420                )));
421            }
422
423            DataType::Struct(Fields::from(fields))
424        }
425        DType::Union(..) => todo!("TODO(connor)[Union]: unimplemented"),
426        DType::Variant(_) => vortex_bail!(
427            "DType::Variant requires Arrow Field metadata; use to_arrow_schema or a Field helper"
428        ),
429        DType::Extension(ext_dtype) => {
430            // NOTE: Temporal are the only builtin and default-loaded extension types, and they map
431            // directly onto non-extension Arrow physical encodings. For this reason, we
432            // choose to special-case them as part of this function rather than implementing them
433            // as an import/export VTable.
434            if let Some(temporal) = ext_dtype.metadata_opt::<AnyTemporal>() {
435                return Ok(match temporal {
436                    TemporalMetadata::Timestamp(unit, tz) => {
437                        DataType::Timestamp(to_arrow_time_unit(*unit)?, tz.clone())
438                    }
439                    TemporalMetadata::Date(unit) => match unit {
440                        TimeUnit::Days => DataType::Date32,
441                        TimeUnit::Milliseconds => DataType::Date64,
442                        TimeUnit::Nanoseconds | TimeUnit::Microseconds | TimeUnit::Seconds => {
443                            vortex_panic!(InvalidArgument: "Invalid TimeUnit {} for {}", unit, ext_dtype.id())
444                        }
445                    },
446                    TemporalMetadata::Time(unit) => match unit {
447                        TimeUnit::Seconds => DataType::Time32(ArrowTimeUnit::Second),
448                        TimeUnit::Milliseconds => DataType::Time32(ArrowTimeUnit::Millisecond),
449                        TimeUnit::Microseconds => DataType::Time64(ArrowTimeUnit::Microsecond),
450                        TimeUnit::Nanoseconds => DataType::Time64(ArrowTimeUnit::Nanosecond),
451                        TimeUnit::Days => {
452                            vortex_panic!(InvalidArgument: "Invalid TimeUnit {} for {}", unit, ext_dtype.id())
453                        }
454                    },
455                });
456            };
457
458            vortex_bail!("Unsupported extension type \"{}\"", ext_dtype.id())
459        }
460    })
461}
462
463fn variant_storage_fields_minimal() -> Fields {
464    Fields::from(vec![
465        Field::new("metadata", DataType::Binary, false),
466        Field::new("value", DataType::Binary, true),
467    ])
468}
469
470#[cfg(test)]
471mod test {
472    #![expect(deprecated, reason = "tests for deprecated methods")]
473    use arrow_schema::DataType;
474    use arrow_schema::Field;
475    use arrow_schema::FieldRef;
476    use arrow_schema::Fields;
477    use arrow_schema::Schema;
478    use rstest::fixture;
479    use rstest::rstest;
480    use vortex_array::dtype::DType;
481    use vortex_array::dtype::FieldName;
482    use vortex_array::dtype::FieldNames;
483    use vortex_array::dtype::Nullability;
484    use vortex_array::dtype::PType;
485    use vortex_array::dtype::StructFields;
486
487    use super::*;
488
489    #[test]
490    fn test_dtype_conversion_success() {
491        assert_eq!(DType::Null.to_arrow_dtype().unwrap(), DataType::Null);
492
493        assert_eq!(
494            DType::Bool(Nullability::NonNullable)
495                .to_arrow_dtype()
496                .unwrap(),
497            DataType::Boolean
498        );
499
500        assert_eq!(
501            DType::Primitive(PType::U64, Nullability::NonNullable)
502                .to_arrow_dtype()
503                .unwrap(),
504            DataType::UInt64
505        );
506
507        assert_eq!(
508            DType::Utf8(Nullability::NonNullable)
509                .to_arrow_dtype()
510                .unwrap(),
511            DataType::Utf8View
512        );
513
514        assert_eq!(
515            DType::Binary(Nullability::NonNullable)
516                .to_arrow_dtype()
517                .unwrap(),
518            DataType::BinaryView
519        );
520
521        assert_eq!(
522            DType::struct_(
523                [
524                    ("field_a", DType::Bool(false.into())),
525                    ("field_b", DType::Utf8(true.into()))
526                ],
527                Nullability::NonNullable,
528            )
529            .to_arrow_dtype()
530            .unwrap(),
531            DataType::Struct(Fields::from(vec![
532                FieldRef::from(Field::new("field_a", DataType::Boolean, false)),
533                FieldRef::from(Field::new("field_b", DataType::Utf8View, true)),
534            ]))
535        );
536    }
537
538    #[rstest]
539    #[case(1, DataType::Decimal128(1, 0))]
540    #[case(38, DataType::Decimal128(38, 0))]
541    #[case(39, DataType::Decimal256(39, 0))]
542    #[case(76, DataType::Decimal256(76, 0))]
543    fn test_decimal_dtype_to_arrow(#[case] precision: u8, #[case] expected: DataType) {
544        use vortex_array::dtype::DecimalDType;
545
546        let dtype = DType::Decimal(DecimalDType::new(precision, 0), Nullability::NonNullable);
547        assert_eq!(dtype.to_arrow_dtype().unwrap(), expected);
548    }
549
550    #[test]
551    fn test_variant_dtype_to_arrow_dtype_errors() {
552        let err = DType::Variant(Nullability::NonNullable)
553            .to_arrow_dtype()
554            .unwrap_err()
555            .to_string();
556        assert!(err.contains("Variant"));
557    }
558
559    #[test]
560    fn infer_nullable_list_element() {
561        let list_non_nullable = DType::List(
562            Arc::new(DType::Primitive(PType::I64, Nullability::NonNullable)),
563            Nullability::Nullable,
564        );
565
566        let arrow_list_non_nullable = list_non_nullable.to_arrow_dtype().unwrap();
567
568        let list_nullable = DType::List(
569            Arc::new(DType::Primitive(PType::I64, Nullability::Nullable)),
570            Nullability::Nullable,
571        );
572        let arrow_list_nullable = list_nullable.to_arrow_dtype().unwrap();
573
574        assert_ne!(arrow_list_non_nullable, arrow_list_nullable);
575        assert_eq!(
576            arrow_list_nullable,
577            DataType::List(Arc::new(Field::new_list_field(DataType::Int64, true))),
578        );
579        assert_eq!(
580            arrow_list_non_nullable,
581            DataType::List(Arc::new(Field::new_list_field(DataType::Int64, false))),
582        );
583    }
584
585    #[fixture]
586    fn the_struct() -> StructFields {
587        StructFields::new(
588            FieldNames::from([
589                FieldName::from("field_a"),
590                FieldName::from("field_b"),
591                FieldName::from("field_c"),
592            ]),
593            vec![
594                DType::Bool(Nullability::NonNullable),
595                DType::Utf8(Nullability::NonNullable),
596                DType::Primitive(PType::I32, Nullability::Nullable),
597            ],
598        )
599    }
600
601    #[rstest]
602    fn test_schema_conversion(the_struct: StructFields) {
603        let schema_nonnull = DType::Struct(the_struct, Nullability::NonNullable);
604
605        assert_eq!(
606            schema_nonnull.to_arrow_schema().unwrap(),
607            Schema::new(Fields::from(vec![
608                Field::new("field_a", DataType::Boolean, false),
609                Field::new("field_b", DataType::Utf8View, false),
610                Field::new("field_c", DataType::Int32, true),
611            ]))
612        );
613    }
614
615    #[test]
616    fn test_schema_variant_field_metadata() {
617        let dtype = DType::struct_(
618            [("v", DType::Variant(Nullability::NonNullable))],
619            Nullability::NonNullable,
620        );
621        let schema = dtype.to_arrow_schema().unwrap();
622        let field = schema.field(0);
623        assert_eq!(
624            field
625                .metadata()
626                .get("ARROW:extension:name")
627                .map(|s| s.as_str()),
628            Some("arrow.parquet.variant")
629        );
630        assert!(matches!(field.data_type(), DataType::Struct(_)));
631        assert!(!field.is_nullable());
632    }
633
634    #[rstest]
635    #[should_panic]
636    fn test_schema_conversion_panics(the_struct: StructFields) {
637        let schema_null = DType::Struct(the_struct, Nullability::Nullable);
638        schema_null.to_arrow_schema().unwrap();
639    }
640
641    #[test]
642    fn test_unicode_field_names_roundtrip() {
643        // Regression test for https://github.com/vortex-data/vortex/issues/5979.
644
645        // Unicode characters in field names should survive an Arrow roundtrip without
646        // double-escaping.
647        let unicode_field_name = "\u{5}=A";
648        let original_dtype = DType::struct_(
649            [(
650                unicode_field_name,
651                DType::Primitive(PType::I8, Nullability::Nullable),
652            )],
653            Nullability::NonNullable,
654        );
655
656        let arrow_dtype = original_dtype.to_arrow_dtype().unwrap();
657        let roundtripped_dtype = DType::from_arrow((&arrow_dtype, Nullability::NonNullable));
658
659        assert_eq!(original_dtype, roundtripped_dtype);
660    }
661
662    #[test]
663    fn test_unicode_field_names_nested_roundtrip() {
664        // Regression test for https://github.com/vortex-data/vortex/issues/5979.
665
666        // Nested structs with unicode field names should also survive an Arrow roundtrip.
667        let inner_struct = DType::struct_(
668            [(
669                "\u{6}=inner",
670                DType::Primitive(PType::I32, Nullability::Nullable),
671            )],
672            Nullability::Nullable,
673        );
674        let original_dtype =
675            DType::struct_([("\u{7}=outer", inner_struct)], Nullability::NonNullable);
676
677        let arrow_dtype = original_dtype.to_arrow_dtype().unwrap();
678        let roundtripped_dtype = DType::from_arrow((&arrow_dtype, Nullability::NonNullable));
679
680        assert_eq!(original_dtype, roundtripped_dtype);
681    }
682
683    #[test]
684    fn map_dtype_roundtrip_uses_conventional_export_names_and_positional_import() -> VortexResult<()>
685    {
686        let dtype = DType::map(
687            DType::Primitive(PType::I32, Nullability::NonNullable),
688            DType::Utf8(Nullability::Nullable),
689            true,
690            Nullability::Nullable,
691        )?;
692
693        let arrow = dtype.to_arrow_dtype()?;
694        let DataType::Map(entries, keys_sorted) = &arrow else {
695            panic!("expected Map, got {arrow:?}");
696        };
697        assert!(*keys_sorted);
698        assert_eq!(entries.name(), "entries");
699        assert!(!entries.is_nullable());
700        let DataType::Struct(fields) = entries.data_type() else {
701            panic!("expected map entries to be a struct");
702        };
703        assert_eq!(fields[0].name(), "key");
704        assert!(!fields[0].is_nullable());
705        assert_eq!(fields[1].name(), "value");
706        assert!(fields[1].is_nullable());
707        assert_eq!(
708            DType::try_from_arrow((&arrow, Nullability::Nullable))?,
709            dtype
710        );
711
712        let positional = DataType::Map(
713            Arc::new(Field::new_struct(
714                "anything",
715                Fields::from(vec![
716                    Field::new("first", DataType::Int32, false),
717                    Field::new("second", DataType::Utf8, true),
718                ]),
719                false,
720            )),
721            false,
722        );
723        assert_eq!(
724            DType::try_from_arrow((&positional, Nullability::NonNullable))?,
725            DType::map(
726                DType::Primitive(PType::I32, Nullability::NonNullable),
727                DType::Utf8(Nullability::Nullable),
728                false,
729                Nullability::NonNullable,
730            )?
731        );
732
733        Ok(())
734    }
735
736    #[test]
737    fn map_dtype_import_rejects_invalid_arrow_shape() {
738        let invalid_entries = [
739            Field::new_struct(
740                "entries",
741                Fields::from(vec![
742                    Field::new("key", DataType::Int32, false),
743                    Field::new("value", DataType::Utf8, true),
744                ]),
745                true,
746            ),
747            Field::new("entries", DataType::Int32, false),
748            Field::new_struct(
749                "entries",
750                Fields::from(vec![Field::new("key", DataType::Int32, false)]),
751                false,
752            ),
753            Field::new_struct(
754                "entries",
755                Fields::from(vec![
756                    Field::new("key", DataType::Int32, true),
757                    Field::new("value", DataType::Utf8, true),
758                ]),
759                false,
760            ),
761        ];
762
763        for entries in invalid_entries {
764            let data_type = DataType::Map(Arc::new(entries), false);
765            assert!(DType::try_from_arrow((&data_type, Nullability::NonNullable)).is_err());
766        }
767    }
768
769    // Regression test for https://github.com/vortex-data/vortex/issues/8346: unsupported Arrow
770    // types must return an error instead of panicking with `unimplemented!`.
771    #[rstest]
772    #[case::duration(DataType::Duration(ArrowTimeUnit::Microsecond))]
773    #[case::interval(DataType::Interval(arrow_schema::IntervalUnit::DayTime))]
774    #[case::fixed_size_binary(DataType::FixedSizeBinary(3))]
775    fn test_try_from_arrow_unsupported_type_errors(#[case] data_type: DataType) {
776        let err = DType::try_from_arrow((&data_type, Nullability::NonNullable))
777            .expect_err("unsupported Arrow type should not convert")
778            .to_string();
779        assert!(err.contains("not supported"), "unexpected error: {err}");
780
781        // The same unsupported type nested in a field or schema must also error cleanly.
782        let field = Field::new("c0", data_type, true);
783        assert!(DType::try_from_arrow(&field).is_err());
784        let schema = Schema::new(vec![field]);
785        assert!(DType::try_from_arrow(&schema).is_err());
786    }
787
788    #[test]
789    fn test_try_from_arrow_supported_type_succeeds() -> VortexResult<()> {
790        let dtype = DType::try_from_arrow((&DataType::Int32, Nullability::Nullable))?;
791        assert_eq!(dtype, DType::Primitive(PType::I32, Nullability::Nullable));
792        Ok(())
793    }
794}