Skip to main content

dora_arrow_convert/
lib.rs

1//! Provides functions for converting between Apache Arrow arrays and Rust data types.
2//!
3//! # Arrow version decoupling
4//!
5//! The types in this crate are re-exported from `dora-node-api`, whose public
6//! API is frozen at 1.0. To keep dora free to bump its *internal* Arrow major
7//! in a minor release, no Arrow type appears in the ungated public surface
8//! here: received payloads are [`DoraArray`], a dora-owned newtype with a
9//! private field, and [`IntoArrow::into_arrow`] returns that same type.
10//!
11//! Direct access to the underlying Arrow array is available, but only behind a
12//! feature that names the major explicitly:
13//!
14//! | Feature | Effect |
15//! |---|---|
16//! | `arrow-v59` | dora's *internal* major. Adds the borrowing accessors [`DoraArray::as_array`] / [`DoraArray::into_inner`] and `From`/`Into` for `arrow::array::ArrayRef`. Pulls **no extra dependency** — it re-exports the copy of Arrow 59 dora already links. |
17//! | `arrow-v58` | An *older* major. Adds an aliased `arrow58` dependency plus `TryFrom` impls in both directions, which hop across the Arrow C Data Interface (zero-copy, see the `ffi_bridge` module). The hop is fallible, hence `TryFrom` rather than `From`. |
18//!
19//! `default = []`, so neither is on unless asked for. See
20//! `docs/plan-arrow-version-decoupling.md` and the support-window policy in
21//! `docs/api-rust.md`.
22
23#![warn(missing_docs)]
24
25use arrow::array::{
26    Array, Float16Array, Float32Array, Float64Array, Int8Array, Int16Array, Int32Array, Int64Array,
27    UInt8Array, UInt16Array, UInt32Array, UInt64Array,
28};
29use arrow::datatypes::DataType;
30use eyre::{ContextCompat, Result, eyre};
31use num::NumCast;
32
33#[cfg(feature = "arrow-v58")]
34pub mod ffi_bridge;
35mod from_impls;
36pub mod internal;
37mod into_impls;
38
39/// Data that can be converted into a dora payload.
40///
41/// This is the conversion that dora node APIs use to turn plain Rust values
42/// into the [Apache Arrow](https://arrow.apache.org/) columnar format before
43/// sending them as outputs. Implementations are provided for booleans,
44/// strings, the primitive integer and float types, `Vec`s of those primitive
45/// types, and a few `chrono` date/time types. The unit type `()` converts to
46/// an empty null array, which is useful for outputs that carry only metadata.
47/// [`DoraArray`] itself implements the trait as the identity conversion, so
48/// `send_output` accepts both plain Rust values and already-built payloads.
49///
50/// The trait deliberately has **no associated type**: an
51/// `type A: arrow::array::Array` bound would put Arrow back into dora's frozen
52/// public API and pin 1.x to a single Arrow major. `into_arrow` returns the
53/// dora-owned [`DoraArray`] instead.
54///
55/// For the opposite direction (reading received Arrow data back into Rust
56/// types), see the `TryFrom<&DoraArray>` implementations on [`DoraArray`].
57///
58/// # Example
59///
60/// ```
61/// use dora_arrow_convert::IntoArrow;
62///
63/// let array = vec![1.0_f32, 2.0, 3.0].into_arrow();
64/// assert_eq!(array.len(), 3);
65///
66/// let single = 42_u8.into_arrow();
67/// assert_eq!(single.len(), 1);
68/// ```
69pub trait IntoArrow {
70    /// Convert the data into a dora payload.
71    fn into_arrow(self) -> DoraArray;
72}
73
74/// A dora payload: an Apache Arrow array owned by dora.
75///
76/// `DoraArray` is the counterpart to [`IntoArrow`]: dora node APIs hand
77/// received outputs to nodes as `DoraArray`, which is read back into plain
78/// Rust values through its `TryFrom<&DoraArray>` implementations.
79///
80/// The wrapped Arrow array is **private**. That is the whole point of the
81/// type: it is what lets dora change its internal Arrow major without
82/// breaking the 1.x contract. To reach the Arrow array itself, enable the
83/// feature naming the major you want — see the crate-level docs.
84///
85/// Two conversion shapes are provided, with different length contracts:
86///
87/// - **Scalar** conversions (`bool`, the primitive integer/float types,
88///   `String`, `&str`, and the `chrono` date/time types) require the array to
89///   hold **exactly one element and no nulls**; any other length is an error.
90/// - **Slice / `Vec`** conversions (`&[T]` and `Vec<T>` for the primitive
91///   types) accept **any length** but still reject **any null values**.
92///
93/// # Example
94///
95/// ```
96/// use dora_arrow_convert::{DoraArray, IntoArrow};
97///
98/// // Scalar: a single-element array converts to the value.
99/// let data: DoraArray = 42_u8.into_arrow();
100/// let scalar: u8 = (&data).try_into()?;
101/// assert_eq!(scalar, 42);
102///
103/// // Strings follow the same single-element rule.
104/// let data = "hello".to_string().into_arrow();
105/// let text: String = (&data).try_into()?;
106/// assert_eq!(text, "hello");
107///
108/// // Vec: any length, collected into an owned `Vec`.
109/// let data = vec![1_i32, 2, 3].into_arrow();
110/// let values: Vec<i32> = (&data).try_into()?;
111/// assert_eq!(values, vec![1, 2, 3]);
112///
113/// // A multi-element array cannot be read as a scalar.
114/// let data = vec![1_i32, 2, 3].into_arrow();
115/// let scalar: Result<i32, _> = (&data).try_into();
116/// assert!(scalar.is_err());
117/// # Ok::<(), eyre::Report>(())
118/// ```
119#[derive(Debug, Clone)]
120pub struct DoraArray(arrow::array::ArrayRef);
121
122impl DoraArray {
123    /// The number of elements in the payload.
124    pub fn len(&self) -> usize {
125        self.0.len()
126    }
127
128    /// Whether the payload holds no elements.
129    pub fn is_empty(&self) -> bool {
130        self.0.is_empty()
131    }
132
133    /// The number of null elements in the payload.
134    pub fn null_count(&self) -> usize {
135        self.0.null_count()
136    }
137
138    /// A human-readable name for the payload's Arrow type, e.g. `"UInt8"` or
139    /// `"List(Field { name: \"item\", .. })"`.
140    ///
141    /// Returned as a `String` rather than an `arrow_schema::DataType` so that
142    /// the ungated surface stays free of Arrow types. Use it for logging and
143    /// error messages; to actually inspect the type, take the array through a
144    /// version-gated accessor.
145    pub fn type_name(&self) -> String {
146        format!("{:?}", self.0.data_type())
147    }
148}
149
150/// Borrowing access to the Arrow array, for callers on dora's **internal**
151/// Arrow major.
152///
153/// This is free: no conversion, no allocation, just a reference to the array
154/// dora already holds. When dora later moves internally to Arrow 60, this
155/// borrowing pair re-gates behind `arrow-v60` and `arrow-v59` keeps a
156/// *converting* `TryFrom` pair instead — which is exactly what `arrow-v58`
157/// already looks like today (see the `ffi_bridge` module).
158#[cfg(feature = "arrow-v59")]
159impl DoraArray {
160    /// Borrow the underlying Arrow 59 array.
161    pub fn as_array(&self) -> &arrow::array::ArrayRef {
162        &self.0
163    }
164
165    /// Take the underlying Arrow 59 array.
166    pub fn into_inner(self) -> arrow::array::ArrayRef {
167        self.0
168    }
169
170    /// Build a payload from any Arrow 59 array.
171    pub fn from_array(array: impl arrow::array::Array + 'static) -> Self {
172        Self(arrow::array::make_array(array.to_data()))
173    }
174}
175
176#[cfg(feature = "arrow-v59")]
177impl From<arrow::array::ArrayRef> for DoraArray {
178    fn from(value: arrow::array::ArrayRef) -> Self {
179        Self(value)
180    }
181}
182
183#[cfg(feature = "arrow-v59")]
184impl From<DoraArray> for arrow::array::ArrayRef {
185    fn from(value: DoraArray) -> Self {
186        value.0
187    }
188}
189
190impl IntoArrow for DoraArray {
191    fn into_arrow(self) -> DoraArray {
192        self
193    }
194}
195
196macro_rules! register_array_handlers {
197    ($(($variant:path, $array_type:ty, $type_name:expr)),* $(,)?) => {
198        /// Tries to convert the given payload into a `Vec` of integers or floats.
199        ///
200        /// The array's element type is cast to `T` per element via [`num::NumCast`],
201        /// so the source and target types need not match (e.g. a `UInt64Array`
202        /// into a `Vec<f64>`).
203        ///
204        /// # Errors
205        ///
206        /// Returns an error if the array contains any null values (consistent
207        /// with every other [`TryFrom<&DoraArray>`] impl in this crate), if the
208        /// array's data type is not a supported integer or float type, or if any
209        /// element cannot be represented in `T` (an out-of-range cast).
210        ///
211        /// ```
212        /// use dora_arrow_convert::{IntoArrow, into_vec};
213        ///
214        /// // Values are cast element-wise to the requested target type.
215        /// let data = vec![1u64, 2, 3].into_arrow();
216        /// assert_eq!(into_vec::<u64>(&data).ok(), Some(vec![1, 2, 3]));
217        /// assert_eq!(into_vec::<f64>(&data).ok(), Some(vec![1.0, 2.0, 3.0]));
218        ///
219        /// // Unsupported (non-numeric) array types are rejected.
220        /// let strings = vec!["a".to_string(), "b".to_string()].into_arrow();
221        /// assert!(into_vec::<u64>(&strings).is_err());
222        /// ```
223        pub fn into_vec<T>(data: &DoraArray) -> Result<Vec<T>>
224        where
225            T: Copy + NumCast + 'static,
226        {
227            match data.0.data_type() {
228                $(
229                    $variant => {
230                        let buffer: &$array_type = data
231                            .0
232                            .as_any()
233                            .downcast_ref()
234                            .context(concat!("series is not ", $type_name))?;
235
236                        if buffer.null_count() != 0 {
237                            eyre::bail!("array has nulls");
238                        }
239
240                        let mut result = Vec::with_capacity(buffer.len());
241                        for &v in buffer.values() {
242                            // `with_context` defers the `format!` to the error
243                            // path: `context(format!(...))` would heap-allocate a
244                            // fresh error String on every element even on the
245                            // (overwhelmingly common) success path.
246                            let converted = NumCast::from(v).with_context(|| {
247                                format!("Failed to cast value from {} to target type", $type_name)
248                            })?;
249                            result.push(converted);
250                        }
251                        Ok(result)
252                    }
253                ),*
254                // Error handling for unsupported types
255                unsupported_type => Err(eyre!("Unsupported data type for conversion: {:?}", unsupported_type))
256            }
257        }
258    };
259}
260
261// Register all supported array types in one place
262register_array_handlers! {
263    (DataType::Float32, Float32Array, "float32"),
264    (DataType::Float64, Float64Array, "float64"),
265    (DataType::Int8, Int8Array, "int8"),
266    (DataType::Int16, Int16Array, "int16"),
267    (DataType::Int32, Int32Array, "int32"),
268    (DataType::Int64, Int64Array, "int64"),
269    (DataType::UInt8, UInt8Array, "uint8"),
270    (DataType::UInt16, UInt16Array, "uint16"),
271    (DataType::UInt32, UInt32Array, "uint32"),
272    (DataType::UInt64, UInt64Array, "uint64"),
273    (DataType::Float16, Float16Array, "float16"),
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use arrow::array::ArrayRef;
280    use half::f16;
281    use std::sync::Arc;
282
283    fn wrap(array: ArrayRef) -> DoraArray {
284        internal::from_array_ref(array)
285    }
286
287    /// Round-trips every type registered in `register_array_handlers!` so a
288    /// future macro entry that gets dropped (the original cause of #2080) is
289    /// caught by a failing test rather than a silent runtime error.
290    #[test]
291    fn into_vec_supports_all_registered_types() {
292        macro_rules! assert_round_trip {
293            ($array_type:ty, $rust_type:ty, $values:expr) => {{
294                let values: Vec<$rust_type> = $values;
295                let array: ArrayRef = Arc::new(<$array_type>::from(values.clone()));
296                let data = wrap(array);
297                let result: Vec<$rust_type> = into_vec(&data).unwrap();
298                assert_eq!(result, values);
299            }};
300        }
301
302        assert_round_trip!(Float32Array, f32, vec![1.0, 2.5, -3.0]);
303        assert_round_trip!(Float64Array, f64, vec![1.0, 2.5, -3.0]);
304        assert_round_trip!(Int8Array, i8, vec![-1, 2, 3]);
305        assert_round_trip!(Int16Array, i16, vec![-1, 2, 3]);
306        assert_round_trip!(Int32Array, i32, vec![-1, 2, 3]);
307        assert_round_trip!(Int64Array, i64, vec![-1, 2, 3]);
308        assert_round_trip!(UInt8Array, u8, vec![1, 2, 3]);
309        assert_round_trip!(UInt16Array, u16, vec![1, 2, 3]);
310        assert_round_trip!(UInt32Array, u32, vec![1, 2, 3]);
311        assert_round_trip!(UInt64Array, u64, vec![1, 2, 3]);
312
313        // Float16 needs explicit f16 construction; round-trip back to f16.
314        let values = vec![f16::from_f32(1.0), f16::from_f32(2.5), f16::from_f32(-3.0)];
315        let array: ArrayRef = Arc::new(Float16Array::from(values.clone()));
316        let data = wrap(array);
317        let result: Vec<f16> = into_vec(&data).unwrap();
318        assert_eq!(result, values);
319    }
320
321    /// The case from the issue: a `UInt64` array previously errored with
322    /// "Unsupported data type for conversion: UInt64".
323    #[test]
324    fn into_vec_handles_uint64() {
325        let data = wrap(Arc::new(UInt64Array::from(vec![1u64, 2, 3])));
326        let res: Vec<u64> = into_vec(&data).unwrap();
327        assert_eq!(res, vec![1u64, 2, 3]);
328    }
329
330    #[test]
331    fn into_vec_rejects_arrays_with_nulls() {
332        let array: ArrayRef = Arc::new(UInt64Array::from(vec![Some(1u64), None, Some(3)]));
333        let data = wrap(array);
334        let res: Result<Vec<u64>> = into_vec(&data);
335        assert!(res.is_err());
336    }
337
338    #[test]
339    fn into_vec_rejects_unsupported_type() {
340        let array: ArrayRef = Arc::new(arrow::array::BooleanArray::from(vec![true, false]));
341        let data = wrap(array);
342        let res: Result<Vec<u8>> = into_vec(&data);
343        assert!(res.is_err());
344    }
345
346    /// `DoraArray`'s ungated inspection helpers must not require any Arrow
347    /// feature — they are the whole reason the common receive path compiles
348    /// with `default = []`.
349    #[test]
350    fn ungated_accessors() {
351        let data = vec![1u64, 2, 3].into_arrow();
352        assert_eq!(data.len(), 3);
353        assert!(!data.is_empty());
354        assert_eq!(data.null_count(), 0);
355        assert_eq!(data.type_name(), "UInt64");
356    }
357}