Skip to main content

vortex_arrow/
session.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Plugin layer for moving Arrow extension types in and out of Vortex.
5//!
6//! Vortex's canonical Arrow conversion (see [`crate::dtype`] and the executor in
7//! [`crate::executor`]) handles every non-extension Arrow type and the builtin temporal
8//! extensions. The plugins registered here cover the remaining case: **Arrow extension types**.
9//!
10//! * An [`ArrowExportVTable`] is dispatched purely by the **target Arrow extension Id** —
11//!   the plugin is selected when the caller asks for an Arrow [`Field`] carrying matching
12//!   `ARROW:extension:name` metadata. The Vortex source dtype/encoding is irrelevant to
13//!   dispatch.
14//! * An [`ArrowImportVTable`] is dispatched by the **source Arrow extension name** carried
15//!   on the incoming [`Field`]. The plugin is responsible for both preserving extension
16//!   identity and re-encoding storage if needed (e.g. Arrow `FixedSizeBinary[16]` for UUID
17//!   becomes Vortex `FixedSizeList<u8; 16>`).
18//!
19//! Multiple plugins may register against the same key. They are tried in registration order;
20//! each may return [`ArrowExport::Unsupported`] / [`ArrowImport::Unsupported`] to defer to
21//! the next.
22
23use std::any::Any;
24use std::borrow::Cow;
25use std::fmt::Debug;
26use std::sync::Arc;
27
28use arrow_array::Array as ArrowArray;
29use arrow_array::ArrayRef as ArrowArrayRef;
30use arrow_array::RecordBatch;
31use arrow_array::RunArray;
32use arrow_array::make_array;
33use arrow_array::types::Int16Type;
34use arrow_array::types::Int32Type;
35use arrow_array::types::Int64Type;
36use arrow_array::types::RunEndIndexType;
37use arrow_schema::DataType;
38use arrow_schema::Field;
39use arrow_schema::FieldRef;
40use arrow_schema::Fields;
41use arrow_schema::Schema;
42use arrow_schema::extension::EXTENSION_TYPE_NAME_KEY;
43use tracing::debug;
44use tracing::trace;
45use vortex_array::ArrayRef;
46use vortex_array::ExecutionCtx;
47use vortex_array::IntoArray;
48use vortex_array::arrays::DictArray;
49use vortex_array::arrays::FixedSizeListArray;
50use vortex_array::arrays::ListArray;
51use vortex_array::arrays::ListViewArray;
52use vortex_array::arrays::StructArray;
53use vortex_array::dtype::DType;
54use vortex_array::dtype::FieldName;
55use vortex_array::dtype::FieldNames;
56use vortex_array::dtype::NativePType;
57use vortex_array::dtype::Nullability;
58use vortex_array::dtype::StructFields;
59use vortex_array::dtype::extension::ExtId;
60use vortex_array::extension::datetime::AnyTemporal;
61use vortex_array::validity::Validity;
62use vortex_error::VortexResult;
63use vortex_error::vortex_bail;
64use vortex_error::vortex_ensure;
65use vortex_error::vortex_ensure_eq;
66use vortex_error::vortex_err;
67use vortex_session::ArcSwapMap;
68use vortex_session::SessionExt;
69use vortex_session::SessionGuard;
70use vortex_session::SessionVar;
71use vortex_session::registry::Id;
72
73use crate::ArrowExportOptions;
74use crate::IntoVortexArray;
75use crate::convert::from_arrow_dyn;
76use crate::convert::map_from_arrow_parts;
77use crate::convert::nulls;
78use crate::convert::remove_nulls;
79use crate::convert::trim_offsets;
80use crate::dtype::from_arrow_data_type;
81use crate::dtype::to_data_type_naive;
82use crate::executor::execute_arrow_naive;
83use crate::run_end_import::run_end_from_arrow;
84
85/// Outcome of a successful call to [`ArrowExportVTable::execute_arrow`].
86///
87/// Plugins that don't handle the supplied array return [`Unsupported`][Self::Unsupported]
88/// with ownership of the input so the session can probe the next plugin or fall back to the
89/// canonical path. Errors are propagated through [`VortexResult`].
90pub enum ArrowExport {
91    /// The plugin does not handle this input; the session may try another plugin.
92    Unsupported(ArrayRef),
93    /// A successful export.
94    Exported(ArrowArrayRef),
95}
96
97/// Outcome of a successful call to [`ArrowImportVTable::from_arrow_array`].
98///
99/// Plugins that don't handle the supplied array return [`Unsupported`][Self::Unsupported]
100/// with ownership of the input so the session can probe the next plugin or fall back to the
101/// canonical path. Errors are propagated through [`VortexResult`].
102pub enum ArrowImport {
103    /// The plugin does not handle this input; the session may try another plugin.
104    Unsupported(ArrowArrayRef),
105    /// A successful import.
106    Imported(ArrayRef),
107}
108
109/// The Arrow type description of an array being imported by [`ArrowSession::from_arrow_array`].
110///
111/// Callers holding an Arrow [`Field`] (or [`FieldRef`]) should pass it: its
112/// `ARROW:extension:name` metadata is what dispatches the array to a registered
113/// [`ArrowImportVTable`].
114///
115/// An Arrow array can carry a validity (null) buffer regardless of whether its schema declares
116/// the field nullable, so when no [`Field`] is in hand the caller passes the desired nullability
117/// instead, as a [`bool`] or a [`Nullability`]. An anonymous field is then synthesized from the
118/// array's own data type, which means no extension plugin is dispatched for the array itself;
119/// fields nested inside container data types still carry their metadata and are routed through
120/// their importers.
121pub trait IntoArrowField<'a> {
122    /// Resolve to the Arrow [`Field`] describing an array of `data_type`.
123    fn into_arrow_field(self, data_type: &DataType) -> Cow<'a, Field>;
124}
125
126impl<'a> IntoArrowField<'a> for &'a Field {
127    fn into_arrow_field(self, _data_type: &DataType) -> Cow<'a, Field> {
128        Cow::Borrowed(self)
129    }
130}
131
132impl<'a> IntoArrowField<'a> for &'a FieldRef {
133    fn into_arrow_field(self, _data_type: &DataType) -> Cow<'a, Field> {
134        Cow::Borrowed(self.as_ref())
135    }
136}
137
138impl<'a> IntoArrowField<'a> for bool {
139    fn into_arrow_field(self, data_type: &DataType) -> Cow<'a, Field> {
140        Cow::Owned(Field::new("", data_type.clone(), self))
141    }
142}
143
144impl<'a> IntoArrowField<'a> for Nullability {
145    fn into_arrow_field(self, data_type: &DataType) -> Cow<'a, Field> {
146        self.is_nullable().into_arrow_field(data_type)
147    }
148}
149
150/// Plugin layer for exporting a Vortex array to an Arrow extension type.
151///
152/// This is purely an implementation trait, its methods should not be called directly. Instead,
153/// use the methods on [`ArrowSession`].
154pub trait ArrowExportVTable: 'static + Send + Sync + Debug {
155    /// The Arrow extension ID this plugin produces.
156    fn arrow_ext_id(&self) -> Id;
157
158    /// The Vortex array or extension ID this plugin maps from. Used only for inference by
159    /// [`ArrowSession::to_arrow_field`] / [`ArrowSession::to_arrow_schema`]; never as a
160    /// dispatch key for [`execute_arrow`][Self::execute_arrow].
161    fn vortex_id(&self) -> Id;
162
163    /// Build the Arrow [`Field`] this plugin produces for the given Vortex extension
164    /// `dtype`. Used during schema inference.
165    fn to_arrow_field(
166        &self,
167        name: &str,
168        dtype: &DType,
169        session: &ArrowSession,
170    ) -> VortexResult<Option<Field>>;
171
172    /// Convert a Vortex array into an Arrow array shaped to `target`.
173    ///
174    /// Returns ownership of `array` via [`ArrowExport::Unsupported`] when the plugin cannot
175    /// handle the input. Plugins that export child or storage arrays must pass `options` on to
176    /// those exports.
177    fn execute_arrow(
178        &self,
179        array: ArrayRef,
180        target: &Field,
181        options: &ArrowExportOptions,
182        ctx: &mut ExecutionCtx,
183    ) -> VortexResult<ArrowExport>;
184}
185
186/// Plugin layer for importing an Arrow extension-typed array into a Vortex array.
187///
188/// Plugins are dispatched by `arrow_ext_id`.
189///
190/// This is purely an implementation trait, its methods should not be called directly. Instead,
191/// use the methods on [`ArrowSession`].
192pub trait ArrowImportVTable: 'static + Send + Sync + Debug {
193    /// The Arrow extension name this plugin handles.
194    fn arrow_ext_id(&self) -> Id;
195
196    /// Build the Vortex [`DType`] that corresponds to `field` (which carries this plugin's
197    /// Arrow extension metadata).
198    ///
199    /// `session` is provided so plugins can resolve nested or storage fields through the
200    /// plugin-aware conversion (e.g. [`ArrowSession::from_arrow_datatype`]) instead of the
201    /// naive Arrow → Vortex mapping.
202    #[allow(clippy::wrong_self_convention)]
203    fn from_arrow_field(
204        &self,
205        field: &Field,
206        session: &ArrowSession,
207    ) -> VortexResult<Option<DType>>;
208
209    /// Convert an Arrow array into a Vortex array of `dtype`.
210    ///
211    /// Returns ownership of `array` via [`ArrowImport::Unsupported`] when the plugin cannot
212    /// handle the input.
213    ///
214    /// `session` is provided so plugins can convert storage or nested arrays through the
215    /// session (e.g. [`ArrowSession::from_arrow_array`], passing a nullability rather than a
216    /// [`Field`] so the plugin is not dispatched again).
217    #[allow(clippy::wrong_self_convention)]
218    fn from_arrow_array(
219        &self,
220        array: ArrowArrayRef,
221        field: &Field,
222        dtype: &DType,
223        session: &ArrowSession,
224    ) -> VortexResult<ArrowImport>;
225}
226
227pub type ArrowExportVTableRef = Arc<dyn ArrowExportVTable>;
228pub type ArrowImportVTableRef = Arc<dyn ArrowImportVTable>;
229
230/// Registry of Arrow exporters, keyed by target Arrow extension [`Id`].
231type ArrowExporterRegistry = ArcSwapMap<Id, Arc<[ArrowExportVTableRef]>>;
232/// Registry of Arrow exporters, keyed by source Vortex extension [`ExtId`].
233type VortexExporterRegistry = ArcSwapMap<ExtId, Arc<[ArrowExportVTableRef]>>;
234/// Registry of Arrow importers, keyed by source Arrow extension [`Id`].
235type ArrowImporterRegistry = ArcSwapMap<Id, Arc<[ArrowImportVTableRef]>>;
236
237/// Session-scoped registry of Arrow extension plugins.
238///
239/// Exporters are stored in two indices: one keyed by Arrow extension Id (used for
240/// `execute_arrow` dispatch) and one keyed by Vortex extension Id (used **only** by
241/// `to_arrow_field` / `to_arrow_schema` inference, when callers need to translate a Vortex
242/// extension `DType` into an Arrow `Field` with no target schema in hand). Importers are
243/// keyed by Arrow extension name. Extension plugins are registered by the crate that owns the
244/// extension type (for example `vortex-uuid`); temporal extensions are handled by the canonical
245/// Arrow ↔ Vortex path and do not need plugins.
246#[derive(Clone, Debug, Default)]
247pub struct ArrowSession {
248    exporters: ArrowExporterRegistry,
249    exporters_by_vortex: VortexExporterRegistry,
250    importers: ArrowImporterRegistry,
251}
252
253impl ArrowSession {
254    /// Register an [`ArrowExportVTable`] under its target Arrow extension Id (for dispatch)
255    /// and its source Vortex extension Id (for schema inference).
256    pub fn register_exporter(&self, exporter: ArrowExportVTableRef) {
257        self.exporters.push(
258            exporter.arrow_ext_id(),
259            ArrowExportVTableRef::clone(&exporter),
260        );
261        self.exporters_by_vortex
262            .push(exporter.vortex_id(), exporter);
263    }
264
265    /// Register an [`ArrowImportVTable`] under its source Arrow extension name.
266    pub fn register_importer(&self, importer: ArrowImportVTableRef) {
267        self.importers.push(importer.arrow_ext_id(), importer);
268    }
269
270    fn exporters(&self, id: &Id) -> Arc<[ArrowExportVTableRef]> {
271        self.exporters.get(id).unwrap_or_else(|| Arc::from([]))
272    }
273
274    fn exporters_by_vortex(&self, id: &Id) -> Arc<[ArrowExportVTableRef]> {
275        self.exporters_by_vortex
276            .get(id)
277            .unwrap_or_else(|| Arc::from([]))
278    }
279
280    fn importers(&self, id: &Id) -> Arc<[ArrowImportVTableRef]> {
281        self.importers.get(id).unwrap_or_else(|| Arc::from([]))
282    }
283
284    /// Build the Arrow [`Field`] for a Vortex [`DType`].
285    ///
286    /// For [`DType::Extension`]s, plugins registered against the extension's `Id`
287    /// are tried in registration order; the first plugin to return `Some(field)` wins.
288    pub fn to_arrow_field(&self, name: &str, dtype: &DType) -> VortexResult<Field> {
289        // Handle the structural encodings, which may have recursive types
290        match dtype {
291            DType::List(elem_dtype, nullability) => {
292                let elem_field = self.to_arrow_field(Field::LIST_FIELD_DEFAULT_NAME, elem_dtype)?;
293                Ok(Field::new_list(name, elem_field, nullability.is_nullable()))
294            }
295            DType::FixedSizeList(elem_dtype, elem_size, nullability) => {
296                let elem_field = self.to_arrow_field(Field::LIST_FIELD_DEFAULT_NAME, elem_dtype)?;
297                Ok(Field::new_fixed_size_list(
298                    name,
299                    elem_field,
300                    (*elem_size).try_into()?,
301                    nullability.is_nullable(),
302                ))
303            }
304            DType::Map(map_dtype, nullability) => {
305                let key = self.to_arrow_field("key", &map_dtype.key_dtype())?;
306                let value = self.to_arrow_field("value", &map_dtype.value_dtype())?;
307                let entries = Field::new_struct("entries", Fields::from(vec![key, value]), false);
308                Ok(Field::new(
309                    name,
310                    DataType::Map(Arc::new(entries), map_dtype.keys_sorted()),
311                    nullability.is_nullable(),
312                ))
313            }
314            DType::Struct(fields, nullability) => {
315                let arrow_fields = Fields::from_iter(
316                    fields
317                        .fields()
318                        .zip(fields.names().iter())
319                        .map(|(field, name)| self.to_arrow_field(name.as_ref(), &field))
320                        .collect::<VortexResult<Vec<_>>>()?,
321                );
322                Ok(Field::new_struct(
323                    name,
324                    arrow_fields,
325                    nullability.is_nullable(),
326                ))
327            }
328            DType::Extension(ext) if !ext.is::<AnyTemporal>() => {
329                for plugin in self.exporters_by_vortex(&ext.id()).iter() {
330                    if let Some(field) =
331                        plugin.to_arrow_field(name, &DType::Extension(ext.clone()), self)?
332                    {
333                        return Ok(field);
334                    }
335                }
336                vortex_bail!("extension type cannot be converted to Arrow without a plugin: {ext}");
337            }
338            DType::Variant(_) => {
339                // TODO(Adam): This currently encodes information about parquet-variant
340                // at this level. Variant's complexity with being an essentially logical type
341                // with multiple physical layout complicates handling this correctly.
342                Ok(Field::new(
343                    name,
344                    DataType::Struct(
345                        vec![
346                            Field::new("metadata", DataType::BinaryView, dtype.is_nullable()),
347                            Field::new("value", DataType::BinaryView, dtype.is_nullable()),
348                        ]
349                        .into(),
350                    ),
351                    dtype.is_nullable(),
352                )
353                .with_metadata(
354                    [(
355                        EXTENSION_TYPE_NAME_KEY.to_string(),
356                        "arrow.parquet.variant".to_string(),
357                    )]
358                    .into(),
359                ))
360            }
361            _ => Ok(Field::new(
362                name,
363                to_data_type_naive(dtype)?,
364                dtype.is_nullable(),
365            )),
366        }
367    }
368
369    /// Build the Arrow [`Schema`] for a Vortex top-level [`DType::Struct`], dispatching
370    /// extension fields through registered export plugins for inference. Nested
371    /// extensions are preserved via [`Self::to_arrow_field`].
372    pub fn to_arrow_schema(&self, dtype: &DType) -> VortexResult<Schema> {
373        let DType::Struct(struct_dtype, _) = dtype else {
374            vortex_bail!("to_arrow_schema requires a top-level struct dtype, got {dtype}");
375        };
376        let mut fields = Vec::with_capacity(struct_dtype.names().len());
377        for (name, field_dtype) in struct_dtype.names().iter().zip(struct_dtype.fields()) {
378            fields.push(self.to_arrow_field(name.as_ref(), &field_dtype)?);
379        }
380        Ok(Schema::new(fields))
381    }
382
383    /// Returns the Arrow [`DataType`] that best corresponds to the given Vortex [`DType`],
384    /// dispatching [`DType::Extension`]s through registered export plugins.
385    ///
386    /// Note that a bare [`DataType`] cannot carry `ARROW:extension:name` metadata; use
387    /// [`Self::to_arrow_field`] when extension identity must survive the roundtrip.
388    pub fn to_arrow_datatype(&self, dtype: &DType) -> VortexResult<DataType> {
389        Ok(self.to_arrow_field("", dtype)?.data_type().clone())
390    }
391
392    /// Build the Vortex [`DType`] for an Arrow [`Field`].
393    ///
394    /// Plugins registered against the field's Arrow extension name are tried in
395    /// registration order; the first plugin to return `Some(dtype)` wins. If none
396    /// match (or all return `None`), the builtin `arrow.parquet.variant` extension maps
397    /// to [`DType::Variant`], and any other field converts through
398    /// [`Self::from_arrow_datatype`] so extension metadata on nested element/struct
399    /// fields is preserved.
400    #[expect(clippy::disallowed_methods, reason = "interning a dynamic id")]
401    pub fn from_arrow_field(&self, field: &Field) -> VortexResult<DType> {
402        if let Some(name) = field.metadata().get(EXTENSION_TYPE_NAME_KEY) {
403            for plugin in self.importers(&Id::new(name)).iter() {
404                if let Some(dtype) = plugin.from_arrow_field(field, self)? {
405                    return Ok(dtype);
406                }
407            }
408            // Parquet Variant is understood even without a registered importer plugin.
409            if name == "arrow.parquet.variant" {
410                return Ok(DType::Variant(field.is_nullable().into()));
411            }
412        }
413        if let DataType::Dictionary(_, value_type) = field.data_type() {
414            return self
415                .from_arrow_field(&field.clone().with_data_type(value_type.as_ref().clone()));
416        }
417        self.from_arrow_datatype(field.data_type(), field.is_nullable().into())
418    }
419
420    /// Build the Vortex [`DType`] for an Arrow [`DataType`].
421    ///
422    /// Recurses into container types ([`DataType::List`] family, [`DataType::FixedSizeList`],
423    /// [`DataType::Struct`], [`DataType::Map`], [`DataType::RunEndEncoded`]) via
424    /// [`Self::from_arrow_field`] so extension metadata on nested fields dispatches through
425    /// registered import plugins. Leaf types use the canonical Arrow → Vortex mapping.
426    ///
427    /// [`DataType::Dictionary`] is a partial exception: Arrow models its values as a bare
428    /// [`DataType`] rather than a [`Field`], so the values themselves cannot carry an extension
429    /// name. Fields nested *inside* that data type still do, and are dispatched normally.
430    pub fn from_arrow_datatype(
431        &self,
432        data_type: &DataType,
433        nullability: Nullability,
434    ) -> VortexResult<DType> {
435        Ok(match data_type {
436            DataType::List(elem)
437            | DataType::LargeList(elem)
438            | DataType::ListView(elem)
439            | DataType::LargeListView(elem) => {
440                DType::List(Arc::new(self.from_arrow_field(elem.as_ref())?), nullability)
441            }
442            DataType::FixedSizeList(elem, size) => DType::FixedSizeList(
443                Arc::new(self.from_arrow_field(elem.as_ref())?),
444                *size as u32,
445                nullability,
446            ),
447            DataType::Map(entries, keys_sorted) => {
448                vortex_ensure!(
449                    !entries.is_nullable(),
450                    "Arrow map entries field must be non-nullable"
451                );
452                let DataType::Struct(fields) = entries.data_type() else {
453                    vortex_bail!(
454                        "Arrow map entries field must have Struct type, got {:?}",
455                        entries.data_type()
456                    );
457                };
458                vortex_ensure_eq!(
459                    fields.len(),
460                    2,
461                    "Arrow map entries struct must contain exactly two fields"
462                );
463                vortex_ensure!(
464                    !fields[0].is_nullable(),
465                    "Arrow map key field must be non-nullable"
466                );
467                DType::map(
468                    self.from_arrow_field(fields[0].as_ref())?,
469                    self.from_arrow_field(fields[1].as_ref())?,
470                    *keys_sorted,
471                    nullability,
472                )?
473            }
474            DataType::Struct(fields) => DType::Struct(self.from_arrow_fields(fields)?, nullability),
475            DataType::Dictionary(_, value_type) => {
476                self.from_arrow_datatype(value_type.as_ref(), nullability)?
477            }
478            DataType::RunEndEncoded(_, value_field) => {
479                self.from_arrow_field(&run_end_values_field(value_field, nullability))?
480            }
481            _ => from_arrow_data_type(data_type, nullability)?,
482        })
483    }
484
485    /// Build Vortex [`StructFields`] for Arrow [`Fields`], dispatching each field through
486    /// [`Self::from_arrow_field`].
487    pub fn from_arrow_fields(&self, fields: &Fields) -> VortexResult<StructFields> {
488        fields
489            .iter()
490            .map(|f| {
491                self.from_arrow_field(f)
492                    .map(|dt| (FieldName::from(f.name().as_str()), dt))
493            })
494            .collect::<VortexResult<StructFields>>()
495    }
496
497    /// Build the Vortex [`DType`] for an Arrow [`Schema`], dispatching extension fields
498    /// through registered import plugins. The result is a top-level non-nullable struct
499    /// matching the schema's fields.
500    pub fn from_arrow_schema(&self, schema: &Schema) -> VortexResult<DType> {
501        Ok(DType::Struct(
502            self.from_arrow_fields(schema.fields())?,
503            Nullability::NonNullable,
504        ))
505    }
506
507    /// Decode an Arrow [`RecordBatch`] into a Vortex struct array, dispatching each
508    /// extension column through its registered import plugin.
509    ///
510    /// `schema` is the authoritative Arrow schema used for dispatch — the columns are
511    /// consumed positionally. Pass an external schema (rather than relying on
512    /// `batch.schema()`) when upstream DataFusion plumbing may have stripped Field-level
513    /// extension metadata from the runtime RecordBatch.
514    pub fn from_arrow_record_batch(
515        &self,
516        batch: RecordBatch,
517        schema: &Schema,
518    ) -> VortexResult<ArrayRef> {
519        vortex_ensure_eq!(batch.num_columns(), schema.fields().len());
520        let length = batch.num_rows();
521        let names = FieldNames::from_iter(
522            schema
523                .fields()
524                .iter()
525                .map(|f| FieldName::from(f.name().as_str())),
526        );
527        let mut columns = Vec::with_capacity(schema.fields().len());
528        for (col, field) in batch.columns().iter().zip(schema.fields().iter()) {
529            columns.push(self.from_arrow_array_inner(ArrowArrayRef::clone(col), field)?);
530        }
531        Ok(StructArray::try_new(names, columns, length, Validity::NonNullable)?.into_array())
532    }
533
534    /// Execute a Vortex array into an Arrow array.
535    ///
536    /// If `target` carries an `ARROW:extension:name`, the plugin registry is probed for one that
537    /// can support executing to the target extension type.
538    ///
539    /// With `target = None` the fallback path picks the array's preferred Arrow physical type
540    /// and executes directly into that, ignoring extension types.
541    ///
542    /// Uses the default [`ArrowExportOptions`]; see [`exporter`](Self::exporter) to
543    /// change them.
544    pub fn execute_arrow(
545        &self,
546        array: ArrayRef,
547        target: Option<&Field>,
548        ctx: &mut ExecutionCtx,
549    ) -> VortexResult<ArrowArrayRef> {
550        self.exporter(&ArrowExportOptions::default())
551            .execute_arrow(array, target, ctx)
552    }
553
554    /// Return an exporter that applies `options` to its exports.
555    ///
556    /// ```
557    /// use arrow_array::Array;
558    /// use vortex_array::IntoArray;
559    /// use vortex_array::VortexSessionExecute;
560    /// use vortex_array::array_session;
561    /// use vortex_array::arrays::VarBinViewArray;
562    /// use vortex_arrow::ArrowExportOptions;
563    /// use vortex_arrow::ArrowSessionExt;
564    /// use vortex_arrow::CompactBuffers;
565    ///
566    /// let session = array_session();
567    /// let array = VarBinViewArray::from_iter_str(["a", "b"]).into_array();
568    /// let options = ArrowExportOptions::default().with(CompactBuffers(false));
569    /// let arrow = session.arrow().exporter(&options).execute_arrow(
570    ///     array,
571    ///     None,
572    ///     &mut session.create_execution_ctx(),
573    /// )?;
574    /// assert_eq!(arrow.len(), 2);
575    /// # Ok::<(), vortex_error::VortexError>(())
576    /// ```
577    pub fn exporter<'a>(&'a self, options: &'a ArrowExportOptions) -> ArrowExporter<'a> {
578        ArrowExporter {
579            session: self,
580            options,
581        }
582    }
583
584    /// Decode an Arrow array into a Vortex array.
585    ///
586    /// `field` describes the Arrow type the array is imported from: pass an Arrow [`Field`] when
587    /// one is available, or just the desired nullability (`true` / `false` /
588    /// [`Nullability`]) to synthesize an anonymous field from the array's own data type. See
589    /// [`IntoArrowField`] for the trade-off between the two.
590    ///
591    /// Routes through the registered import plugin if `field` carries an Arrow extension
592    /// name we recognize, probing each plugin in registration order until one handles the
593    /// input or all return [`ArrowImport::Unsupported`]. Otherwise recurses into container
594    /// arrays ([`arrow_array::StructArray`], [`arrow_array::GenericListArray`],
595    /// [`arrow_array::FixedSizeListArray`], [`arrow_array::GenericListViewArray`]) so
596    /// extension fields nested inside containers reach their importers; leaf types fall
597    /// through to the canonical Arrow → Vortex array conversion.
598    ///
599    /// # Errors
600    ///
601    /// Returns an error if the field (or requested nullability) is non-nullable but the array
602    /// physically contains nulls, or if the Arrow data type is unsupported.
603    pub fn from_arrow_array<'a>(
604        &self,
605        array: ArrowArrayRef,
606        field: impl IntoArrowField<'a>,
607    ) -> VortexResult<ArrayRef> {
608        let field = field.into_arrow_field(array.data_type());
609        self.from_arrow_array_inner(array, field.as_ref())
610    }
611
612    /// [`Self::from_arrow_array`] with the Arrow [`Field`] already resolved: probe the import
613    /// plugins registered for the field's extension name, then fall back to the canonical
614    /// conversion. Also the recursion point for nested fields, which already have a [`Field`].
615    #[allow(clippy::wrong_self_convention)]
616    fn from_arrow_array_inner(
617        &self,
618        array: ArrowArrayRef,
619        field: &Field,
620    ) -> VortexResult<ArrayRef> {
621        if let Some(extension_name) = field.metadata().get(EXTENSION_TYPE_NAME_KEY) {
622            #[expect(clippy::disallowed_methods, reason = "interning a dynamic id")]
623            let importers = self.importers(&Id::new(extension_name));
624            if !importers.is_empty() {
625                let dtype = self.from_arrow_field(field)?;
626                let len = array.len();
627                let mut current = array;
628                for plugin in importers.iter() {
629                    match plugin.from_arrow_array(current, field, &dtype, self)? {
630                        // Parents such as lists reuse the Arrow offsets into this array without
631                        // checking them again, so a plugin must preserve its length, both in what
632                        // it imports and in what it hands back when it declines.
633                        ArrowImport::Imported(arr) => {
634                            vortex_ensure_eq!(
635                                arr.len(),
636                                len,
637                                "Arrow importer {} returned {} elements for an array of {}",
638                                plugin.arrow_ext_id(),
639                                arr.len(),
640                                len
641                            );
642                            return Ok(arr);
643                        }
644                        ArrowImport::Unsupported(arr) => {
645                            vortex_ensure_eq!(
646                                arr.len(),
647                                len,
648                                "Arrow importer {} declined and returned {} elements for an array of {}",
649                                plugin.arrow_ext_id(),
650                                arr.len(),
651                                len
652                            );
653                            current = arr;
654                        }
655                    }
656                }
657                return self.from_arrow_array_canonical(current.as_ref(), field);
658            }
659        }
660        self.from_arrow_array_canonical(array.as_ref(), field)
661    }
662
663    /// Recurse into Arrow container arrays so nested fields with extension metadata reach
664    /// their importers, falling through to the canonical conversion for leaf types.
665    #[allow(clippy::wrong_self_convention)]
666    fn from_arrow_array_canonical(
667        &self,
668        array: &dyn ArrowArray,
669        field: &Field,
670    ) -> VortexResult<ArrayRef> {
671        use arrow_array::cast::AsArray;
672
673        match field.data_type() {
674            DataType::Struct(fields) => {
675                let arrow_struct = array.as_struct();
676                let names = FieldNames::from_iter(
677                    fields.iter().map(|f| FieldName::from(f.name().as_str())),
678                );
679                let columns = arrow_struct
680                    .columns()
681                    .iter()
682                    .zip(fields.iter())
683                    .map(|(col, child_field)| {
684                        // Arrow pushes nulls into non-nullable fields; strip before recursing
685                        // so Vortex's stricter validity invariants are upheld.
686                        let inner = if col.null_count() > 0 && !child_field.is_nullable() {
687                            make_array(remove_nulls(col.to_data())?)
688                        } else {
689                            ArrowArrayRef::clone(col)
690                        };
691                        self.from_arrow_array_inner(inner, child_field.as_ref())
692                    })
693                    .collect::<VortexResult<Vec<_>>>()?;
694                let validity = nulls(arrow_struct.nulls(), field.is_nullable())?;
695                Ok(
696                    StructArray::try_new(names, columns, arrow_struct.len(), validity)?
697                        .into_array(),
698                )
699            }
700            DataType::List(elem_field) => {
701                let list = array.as_list::<i32>();
702                let (offsets, referenced) = trim_offsets(list.offsets());
703                let elements = self.from_arrow_array(
704                    list.values().slice(referenced.start, referenced.len()),
705                    elem_field.as_ref(),
706                )?;
707                let validity = nulls(list.nulls(), field.is_nullable())?;
708                // SAFETY: Arrow offsets are non-negative and sorted, and `trim_offsets` rebases
709                // them onto the referenced values. The validity has one entry per list.
710                Ok(
711                    unsafe { ListArray::new_unchecked(elements, offsets.into_array(), validity) }
712                        .into_array(),
713                )
714            }
715            DataType::LargeList(elem_field) => {
716                let list = array.as_list::<i64>();
717                let (offsets, referenced) = trim_offsets(list.offsets());
718                let elements = self.from_arrow_array(
719                    list.values().slice(referenced.start, referenced.len()),
720                    elem_field.as_ref(),
721                )?;
722                let validity = nulls(list.nulls(), field.is_nullable())?;
723                // SAFETY: Arrow offsets are non-negative and sorted, and `trim_offsets` rebases
724                // them onto the referenced values. The validity has one entry per list.
725                Ok(
726                    unsafe { ListArray::new_unchecked(elements, offsets.into_array(), validity) }
727                        .into_array(),
728                )
729            }
730            DataType::FixedSizeList(elem_field, list_size) => {
731                let fsl = array.as_fixed_size_list();
732                let elements = self.from_arrow_array_inner(
733                    ArrowArrayRef::clone(fsl.values()),
734                    elem_field.as_ref(),
735                )?;
736                let validity = nulls(fsl.nulls(), field.is_nullable())?;
737                Ok(
738                    FixedSizeListArray::try_new(elements, *list_size as u32, validity, fsl.len())?
739                        .into_array(),
740                )
741            }
742            DataType::ListView(elem_field) => {
743                let list = array.as_list_view::<i32>();
744                let elements = self
745                    .from_arrow_array(ArrowArrayRef::clone(list.values()), elem_field.as_ref())?;
746                let offsets = list.offsets().clone().into_array();
747                let sizes = list.sizes().clone().into_array();
748                let validity = nulls(list.nulls(), field.is_nullable())?;
749                // SAFETY: Arrow list views share one integer type for offsets and sizes, and every
750                // view lies within the values. The validity has one entry per list.
751                Ok(
752                    unsafe { ListViewArray::new_unchecked(elements, offsets, sizes, validity) }
753                        .into_array(),
754                )
755            }
756            DataType::LargeListView(elem_field) => {
757                let list = array.as_list_view::<i64>();
758                let elements = self
759                    .from_arrow_array(ArrowArrayRef::clone(list.values()), elem_field.as_ref())?;
760                let offsets = list.offsets().clone().into_array();
761                let sizes = list.sizes().clone().into_array();
762                let validity = nulls(list.nulls(), field.is_nullable())?;
763                // SAFETY: Arrow list views share one integer type for offsets and sizes, and every
764                // view lies within the values. The validity has one entry per list.
765                Ok(
766                    unsafe { ListViewArray::new_unchecked(elements, offsets, sizes, validity) }
767                        .into_array(),
768                )
769            }
770            DataType::Map(entries_field, keys_sorted) => {
771                let map = array.as_map();
772                let entries_array: ArrowArrayRef = Arc::new(map.entries().clone());
773                let entries = self.from_arrow_array_inner(entries_array, entries_field.as_ref())?;
774                map_from_arrow_parts(
775                    entries,
776                    map.offsets(),
777                    map.nulls(),
778                    *keys_sorted,
779                    field.is_nullable(),
780                )
781            }
782            DataType::RunEndEncoded(ends_field, values_field) => {
783                let values_field = run_end_values_field(values_field, field.is_nullable().into());
784                match ends_field.data_type() {
785                    DataType::Int16 => self.run_end_from_arrow::<Int16Type>(array, &values_field),
786                    DataType::Int32 => self.run_end_from_arrow::<Int32Type>(array, &values_field),
787                    DataType::Int64 => self.run_end_from_arrow::<Int64Type>(array, &values_field),
788                    ends_dt => vortex_bail!(
789                        "Arrow run-end array run ends must be Int16, Int32 or Int64, got {ends_dt}"
790                    ),
791                }
792            }
793            DataType::Dictionary(..) => {
794                let dict = array.as_any_dictionary();
795                // Extension metadata on the dictionary field describes its values.
796                let values_field = field
797                    .clone()
798                    .with_data_type(dict.values().data_type().clone());
799                let values =
800                    self.from_arrow_array(ArrowArrayRef::clone(dict.values()), &values_field)?;
801                let codes = dict.keys();
802                let codes = from_arrow_dyn(codes, codes.is_nullable())?;
803                // SAFETY: arrow-rs enforces the dictionary invariants on construction, so the
804                // codes are in-bounds for the values.
805                Ok(unsafe { DictArray::new_unchecked(codes, values) }.into_array())
806            }
807            _ => from_arrow_dyn(array, field.is_nullable()),
808        }
809    }
810
811    /// Decode an Arrow run-end array, recursing into its values so extension metadata on the
812    /// values field reaches its importer.
813    #[allow(clippy::wrong_self_convention)]
814    fn run_end_from_arrow<R: RunEndIndexType>(
815        &self,
816        array: &dyn ArrowArray,
817        values_field: &Field,
818    ) -> VortexResult<ArrayRef>
819    where
820        R::Native: NativePType,
821    {
822        let run_array = array
823            .as_any()
824            .downcast_ref::<RunArray<R>>()
825            .ok_or_else(|| vortex_err!("expected an Arrow RunArray, got {}", array.data_type()))?;
826        let values =
827            self.from_arrow_array_inner(ArrowArrayRef::clone(run_array.values()), values_field)?;
828        run_end_from_arrow(run_array, values)
829    }
830}
831
832/// The values field of an Arrow [`DataType::RunEndEncoded`], re-stamped with the run-end array's
833/// own nullability.
834fn run_end_values_field(values_field: &FieldRef, nullability: Nullability) -> Field {
835    values_field
836        .as_ref()
837        .clone()
838        .with_nullable(nullability.into())
839}
840
841impl SessionVar for ArrowSession {
842    fn as_any(&self) -> &dyn Any {
843        self
844    }
845
846    fn as_any_mut(&mut self) -> &mut dyn Any {
847        self
848    }
849}
850
851/// An [`ArrowSession`] bound to [`ArrowExportOptions`], created by [`ArrowSession::exporter`].
852#[derive(Clone, Copy, Debug)]
853pub struct ArrowExporter<'a> {
854    session: &'a ArrowSession,
855    options: &'a ArrowExportOptions,
856}
857
858impl ArrowExporter<'_> {
859    pub(crate) fn options(&self) -> &ArrowExportOptions {
860        self.options
861    }
862
863    /// Execute a Vortex array into an Arrow array with the bound options.
864    ///
865    /// Behaves like [`ArrowSession::execute_arrow`]. The options are propagated to nested arrays
866    /// and to registered export plugins.
867    #[expect(clippy::disallowed_methods, reason = "interning a dynamic id")]
868    pub fn execute_arrow(
869        &self,
870        array: ArrayRef,
871        target: Option<&Field>,
872        ctx: &mut ExecutionCtx,
873    ) -> VortexResult<ArrowArrayRef> {
874        // NOTE(aduffy): this looks strange, but we do this to keep target_field as &Field so
875        //  we can avoid cloning target when it is provided. It contains a HashMap internally that
876        //  can be expensive to copy.
877        let arrow_field;
878        let target_field = match target {
879            Some(field) => field,
880            None => {
881                arrow_field = self.session.to_arrow_field("", array.dtype())?;
882                &arrow_field
883            }
884        };
885
886        if let Some(arrow_ext_name) = target_field.metadata().get(EXTENSION_TYPE_NAME_KEY) {
887            // There can be multiple plugins that report support for a particular extension type.
888            // We try them in order until one of them reports a successful conversion.
889            let len = array.len();
890            let mut current = array;
891
892            for plugin in self.session.exporters(&Id::new(arrow_ext_name)).iter() {
893                trace!(
894                    plugin = ?plugin,
895                    extension_name = arrow_ext_name,
896                    "probing plugin for converting Arrow array"
897                );
898
899                match plugin.execute_arrow(current, target_field, self.options, ctx)? {
900                    ArrowExport::Exported(arrow) => {
901                        vortex_ensure_eq!(
902                            arrow.len(),
903                            len,
904                            "Arrow array length does not match Vortex array length after conversion to {:?}",
905                            arrow
906                        );
907                        return Ok(arrow);
908                    }
909                    ArrowExport::Unsupported(array) => current = array,
910                }
911            }
912
913            debug!(
914                extension_id = arrow_ext_name,
915                data_type = ?target_field.data_type(),
916                "unsupported Arrow extension type encountered, falling back to naive execution"
917            );
918
919            return execute_arrow_naive(current, Some(target_field.data_type()), self, ctx);
920        }
921
922        execute_arrow_naive(array, target.map(|field| field.data_type()), self, ctx)
923    }
924}
925
926/// Extension trait for accessing the [`ArrowSession`] on a Vortex session.
927pub trait ArrowSessionExt: SessionExt {
928    /// Get the Arrow session.
929    fn arrow(&self) -> SessionGuard<'_, ArrowSession>;
930}
931
932impl<S: SessionExt> ArrowSessionExt for S {
933    fn arrow(&self) -> SessionGuard<'_, ArrowSession> {
934        self.get::<ArrowSession>()
935    }
936}
937
938#[cfg(test)]
939mod tests {
940    use std::ops::Range;
941    use std::sync::Arc;
942
943    use arrow_array::DictionaryArray;
944    use arrow_array::GenericListArray;
945    use arrow_array::GenericListViewArray;
946    use arrow_array::Int32Array;
947    use arrow_array::Int64Array;
948    use arrow_array::MapArray;
949    use arrow_array::StringArray;
950    use arrow_array::StructArray;
951    use arrow_array::types::Int32Type;
952    use arrow_buffer::OffsetBuffer;
953    use arrow_buffer::ScalarBuffer;
954    use arrow_schema::DataType;
955    use arrow_schema::Field;
956    use arrow_schema::FieldRef;
957    use arrow_schema::Fields;
958    use rstest::rstest;
959    use vortex_array::IntoArray;
960    use vortex_array::VortexSessionExecute as _;
961    use vortex_array::array_session;
962    use vortex_array::arrays::Dict;
963    use vortex_array::arrays::ListArray;
964    use vortex_array::arrays::PrimitiveArray;
965    use vortex_array::arrays::VarBinViewArray;
966    use vortex_array::assert_arrays_eq;
967    use vortex_array::dtype::DType;
968    use vortex_array::dtype::Nullability;
969    use vortex_array::dtype::PType;
970    use vortex_array::validity::Validity;
971    use vortex_error::VortexResult;
972    use vortex_session::registry::CachedId;
973
974    use super::*;
975
976    #[rstest]
977    #[case::prefix(0, 2)]
978    #[case::middle(500, 2)]
979    #[case::empty(500, 0)]
980    #[case::empty_at_end(1000, 0)]
981    fn test_sliced_list_imports_only_its_rows(
982        #[values(false, true)] large: bool,
983        #[case] start: usize,
984        #[case] len: usize,
985    ) -> VortexResult<()> {
986        let lists = |rows: Range<i64>| -> ArrowArrayRef {
987            let values = Arc::new(Int64Array::from_iter_values(
988                rows.flat_map(|row| [row, row + 1]),
989            ));
990            let len = values.len() / 2;
991            let field = Arc::new(Field::new("item", DataType::Int64, false));
992            if large {
993                Arc::new(GenericListArray::<i64>::new(
994                    field,
995                    OffsetBuffer::from_repeated_length(2, len),
996                    values,
997                    None,
998                ))
999            } else {
1000                Arc::new(GenericListArray::<i32>::new(
1001                    field,
1002                    OffsetBuffer::from_repeated_length(2, len),
1003                    values,
1004                    None,
1005                ))
1006            }
1007        };
1008        let session = ArrowSession::default();
1009        let sliced = session.from_arrow_array(lists(0..1000).slice(start, len), false)?;
1010        let fresh = session.from_arrow_array(lists(start as i64..(start + len) as i64), false)?;
1011        assert_eq!(sliced.nbytes(), fresh.nbytes());
1012        let mut ctx = array_session().create_execution_ctx();
1013        let expected = ListArray::new(
1014            PrimitiveArray::from_iter(
1015                (start as i64..(start + len) as i64).flat_map(|row| [row, row + 1]),
1016            )
1017            .into_array(),
1018            PrimitiveArray::from_iter((0..=len as u64).map(|row| row * 2)).into_array(),
1019            Validity::NonNullable,
1020            &mut ctx,
1021        );
1022        assert_arrays_eq!(sliced, expected, &mut ctx);
1023        assert_arrays_eq!(fresh, expected, &mut ctx);
1024        Ok(())
1025    }
1026
1027    #[test]
1028    fn from_arrow_fields_matches_schema_conversion() -> VortexResult<()> {
1029        let session = ArrowSession::default();
1030        let fields = Fields::from(vec![
1031            Field::new("a", DataType::Int32, false),
1032            Field::new("b", DataType::Utf8View, true),
1033        ]);
1034        let struct_fields = session.from_arrow_fields(&fields)?;
1035        let schema_dtype = session.from_arrow_schema(&Schema::new(fields))?;
1036        let expected = StructFields::from_iter([
1037            ("a", DType::Primitive(PType::I32, Nullability::NonNullable)),
1038            ("b", DType::Utf8(Nullability::Nullable)),
1039        ]);
1040        assert_eq!(struct_fields, expected);
1041        assert_eq!(
1042            schema_dtype,
1043            DType::Struct(struct_fields, Nullability::NonNullable)
1044        );
1045        Ok(())
1046    }
1047
1048    #[test]
1049    fn from_arrow_field_maps_variant_without_importer() -> VortexResult<()> {
1050        let session = ArrowSession::default();
1051        let storage = DataType::Struct(
1052            vec![
1053                Field::new("metadata", DataType::BinaryView, false),
1054                Field::new("value", DataType::BinaryView, true),
1055            ]
1056            .into(),
1057        );
1058        let field = Field::new("v", storage, true).with_metadata(
1059            [(
1060                "ARROW:extension:name".to_string(),
1061                "arrow.parquet.variant".to_string(),
1062            )]
1063            .into(),
1064        );
1065        assert_eq!(
1066            session.from_arrow_field(&field)?,
1067            DType::Variant(Nullability::Nullable)
1068        );
1069        Ok(())
1070    }
1071
1072    /// A plain Arrow dictionary imports as a Vortex `Dict` array over the dictionary values,
1073    /// matching the dtype the schema conversion reports.
1074    #[test]
1075    fn dictionary_imports_as_dict_encoding() -> VortexResult<()> {
1076        let session = ArrowSession::default();
1077        let dict: ArrowArrayRef = Arc::new(DictionaryArray::<Int32Type>::try_new(
1078            Int32Array::from(vec![0, 1, 0, 1]),
1079            Arc::new(StringArray::from(vec!["a", "b"])),
1080        )?);
1081        let field = Field::new("s", dict.data_type().clone(), false);
1082
1083        let dtype = session.from_arrow_field(&field)?;
1084        assert_eq!(dtype, DType::Utf8(Nullability::NonNullable));
1085
1086        let array = session.from_arrow_array(dict, &field)?;
1087        assert!(array.is::<Dict>(), "expected a Dict encoding, got {array}");
1088        assert_eq!(array.dtype(), &dtype);
1089        assert_eq!(array.len(), 4);
1090        assert_arrays_eq!(
1091            array,
1092            VarBinViewArray::from_iter_str(["a", "b", "a", "b"]),
1093            &mut array_session().create_execution_ctx()
1094        );
1095        Ok(())
1096    }
1097
1098    static TRUNCATE_ID: CachedId = CachedId::new("test.truncate");
1099
1100    /// An importer that drops the last element of every array it is given, then either imports
1101    /// it or declines and hands it back.
1102    #[derive(Debug)]
1103    struct TruncatingImporter {
1104        decline: bool,
1105    }
1106
1107    impl ArrowImportVTable for TruncatingImporter {
1108        fn arrow_ext_id(&self) -> Id {
1109            *TRUNCATE_ID
1110        }
1111
1112        fn from_arrow_field(
1113            &self,
1114            field: &Field,
1115            session: &ArrowSession,
1116        ) -> VortexResult<Option<DType>> {
1117            session
1118                .from_arrow_datatype(field.data_type(), field.is_nullable().into())
1119                .map(Some)
1120        }
1121
1122        fn from_arrow_array(
1123            &self,
1124            array: ArrowArrayRef,
1125            field: &Field,
1126            _dtype: &DType,
1127            session: &ArrowSession,
1128        ) -> VortexResult<ArrowImport> {
1129            let truncated = array.slice(0, array.len() - 1);
1130            if self.decline {
1131                return Ok(ArrowImport::Unsupported(truncated));
1132            }
1133            session
1134                .from_arrow_array(truncated, field.is_nullable())
1135                .map(ArrowImport::Imported)
1136        }
1137    }
1138
1139    fn truncated_field(name: &str, data_type: DataType) -> FieldRef {
1140        Arc::new(
1141            Field::new(name, data_type, false).with_metadata(
1142                [(
1143                    EXTENSION_TYPE_NAME_KEY.to_string(),
1144                    "test.truncate".to_string(),
1145                )]
1146                .into(),
1147            ),
1148        )
1149    }
1150
1151    fn list_with_truncated_child() -> ArrowArrayRef {
1152        Arc::new(GenericListArray::<i32>::new(
1153            truncated_field("item", DataType::Int64),
1154            OffsetBuffer::new(vec![0, 5].into()),
1155            Arc::new(Int64Array::from_iter_values(0..5)),
1156            None,
1157        ))
1158    }
1159
1160    fn list_view_with_truncated_child() -> ArrowArrayRef {
1161        Arc::new(GenericListViewArray::<i32>::new(
1162            truncated_field("item", DataType::Int64),
1163            ScalarBuffer::from(vec![0]),
1164            ScalarBuffer::from(vec![5]),
1165            Arc::new(Int64Array::from_iter_values(0..5)),
1166            None,
1167        ))
1168    }
1169
1170    fn map_with_truncated_child() -> ArrowArrayRef {
1171        let key_field = Arc::new(Field::new("key", DataType::Int64, false));
1172        let value_field = Arc::new(Field::new("value", DataType::Int64, false));
1173        let entries = StructArray::new(
1174            Fields::from(vec![key_field, value_field]),
1175            vec![
1176                Arc::new(Int64Array::from_iter_values(0..5)),
1177                Arc::new(Int64Array::from_iter_values(5..10)),
1178            ],
1179            None,
1180        );
1181        Arc::new(MapArray::new(
1182            truncated_field("entries", entries.data_type().clone()),
1183            OffsetBuffer::new(vec![0, 5].into()),
1184            entries,
1185            None,
1186            false,
1187        ))
1188    }
1189
1190    #[rstest]
1191    #[case::list(list_with_truncated_child())]
1192    #[case::list_view(list_view_with_truncated_child())]
1193    #[case::map(map_with_truncated_child())]
1194    fn test_rejects_importer_that_changes_child_length(
1195        #[case] array: ArrowArrayRef,
1196        #[values(false, true)] decline: bool,
1197    ) {
1198        let session = ArrowSession::default();
1199        session.register_importer(Arc::new(TruncatingImporter { decline }));
1200
1201        let err = session
1202            .from_arrow_array(array, false)
1203            .expect_err("a child importer that drops elements must be rejected");
1204        assert!(
1205            err.to_string()
1206                .contains("returned 4 elements for an array of 5"),
1207            "unexpected error: {err}"
1208        );
1209    }
1210}