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///   `String` and `&str` accept all three Arrow string encodings (`Utf8`,
91///   `LargeUtf8` and `Utf8View`).
92/// - **Slice / `Vec`** conversions (`&[T]` and `Vec<T>` for the primitive
93///   types) accept **any length** but still reject **any null values**.
94///
95/// # Example
96///
97/// ```
98/// use dora_arrow_convert::{DoraArray, IntoArrow};
99///
100/// // Scalar: a single-element array converts to the value.
101/// let data: DoraArray = 42_u8.into_arrow();
102/// let scalar: u8 = (&data).try_into()?;
103/// assert_eq!(scalar, 42);
104///
105/// // Strings follow the same single-element rule.
106/// let data = "hello".to_string().into_arrow();
107/// let text: String = (&data).try_into()?;
108/// assert_eq!(text, "hello");
109///
110/// // Vec: any length, collected into an owned `Vec`.
111/// let data = vec![1_i32, 2, 3].into_arrow();
112/// let values: Vec<i32> = (&data).try_into()?;
113/// assert_eq!(values, vec![1, 2, 3]);
114///
115/// // A multi-element array cannot be read as a scalar.
116/// let data = vec![1_i32, 2, 3].into_arrow();
117/// let scalar: Result<i32, _> = (&data).try_into();
118/// assert!(scalar.is_err());
119/// # Ok::<(), eyre::Report>(())
120/// ```
121#[derive(Debug, Clone)]
122pub struct DoraArray(arrow::array::ArrayRef);
123
124impl DoraArray {
125    /// The number of elements in the payload.
126    pub fn len(&self) -> usize {
127        self.0.len()
128    }
129
130    /// Whether the payload holds no elements.
131    pub fn is_empty(&self) -> bool {
132        self.0.is_empty()
133    }
134
135    /// The number of null elements in the payload.
136    pub fn null_count(&self) -> usize {
137        self.0.null_count()
138    }
139
140    /// A human-readable name for the payload's Arrow type, e.g. `"UInt8"` or
141    /// `"List(Field { name: \"item\", .. })"`.
142    ///
143    /// Returned as a `String` rather than an `arrow_schema::DataType` so that
144    /// the ungated surface stays free of Arrow types. Use it for logging and
145    /// error messages; to actually inspect the type, take the array through a
146    /// version-gated accessor.
147    pub fn type_name(&self) -> String {
148        format!("{:?}", self.0.data_type())
149    }
150}
151
152/// Borrowing access to the Arrow array, for callers on dora's **internal**
153/// Arrow major.
154///
155/// This is free: no conversion, no allocation, just a reference to the array
156/// dora already holds. When dora later moves internally to Arrow 60, this
157/// borrowing pair re-gates behind `arrow-v60` and `arrow-v59` keeps a
158/// *converting* `TryFrom` pair instead — which is exactly what `arrow-v58`
159/// already looks like today (see the `ffi_bridge` module).
160#[cfg(feature = "arrow-v59")]
161impl DoraArray {
162    /// Borrow the underlying Arrow 59 array.
163    pub fn as_array(&self) -> &arrow::array::ArrayRef {
164        &self.0
165    }
166
167    /// Take the underlying Arrow 59 array.
168    pub fn into_inner(self) -> arrow::array::ArrayRef {
169        self.0
170    }
171
172    /// Build a payload from any Arrow 59 array.
173    pub fn from_array(array: impl arrow::array::Array + 'static) -> Self {
174        Self(arrow::array::make_array(array.to_data()))
175    }
176}
177
178#[cfg(feature = "arrow-v59")]
179impl From<arrow::array::ArrayRef> for DoraArray {
180    fn from(value: arrow::array::ArrayRef) -> Self {
181        Self(value)
182    }
183}
184
185#[cfg(feature = "arrow-v59")]
186impl From<DoraArray> for arrow::array::ArrayRef {
187    fn from(value: DoraArray) -> Self {
188        value.0
189    }
190}
191
192impl IntoArrow for DoraArray {
193    fn into_arrow(self) -> DoraArray {
194        self
195    }
196}
197
198macro_rules! register_array_handlers {
199    ($(($variant:path, $array_type:ty, $type_name:expr)),* $(,)?) => {
200        /// Tries to convert the given payload into a `Vec` of integers or floats.
201        ///
202        /// The array's element type is cast to `T` per element via [`num::NumCast`],
203        /// so the source and target types need not match (e.g. a `UInt64Array`
204        /// into a `Vec<f64>`).
205        ///
206        /// # Precision
207        ///
208        /// The per-element cast goes through [`num::NumCast`]/[`num::ToPrimitive`],
209        /// so it is **lossy exactly where an `as` cast is**: a float source with a
210        /// fractional part is truncated toward zero into an integer target — it is
211        /// **not** rounded, and it does **not** error. Likewise an integer is
212        /// rounded to the nearest representable float without error, which is
213        /// only exact while it fits the float's mantissa: up to 2^53 in magnitude
214        /// for `f64`, 2^24 for `f32` (so e.g. `u64`/`i64` → `f64` can lose low
215        /// bits). Narrowing `f64` → `f32` likewise rounds, and a magnitude
216        /// beyond `f32`'s range becomes ±infinity rather than an error.
217        /// Converting between integer types that both hold the value is exact.
218        ///
219        /// # Errors
220        ///
221        /// Returns an error if the array contains any null values (consistent
222        /// with every other [`TryFrom<&DoraArray>`] impl in this crate), if the
223        /// array's data type is not a supported integer or float type, or if any
224        /// element is out of range for an integer `T` (e.g. a negative float into
225        /// an unsigned target, NaN, or a magnitude the target cannot hold). A
226        /// merely fractional float is *in range* and is truncated, and a float
227        /// target never errors on range, per **Precision** above.
228        ///
229        /// ```
230        /// use dora_arrow_convert::{IntoArrow, into_vec};
231        ///
232        /// // Values are cast element-wise to the requested target type.
233        /// let data = vec![1u64, 2, 3].into_arrow();
234        /// assert_eq!(into_vec::<u64>(&data).ok(), Some(vec![1, 2, 3]));
235        /// assert_eq!(into_vec::<f64>(&data).ok(), Some(vec![1.0, 2.0, 3.0]));
236        ///
237        /// // Float -> integer truncates toward zero (it does not round or error).
238        /// let floats = vec![1.9f64, -2.9].into_arrow();
239        /// assert_eq!(into_vec::<i64>(&floats).ok(), Some(vec![1, -2]));
240        ///
241        /// // Integer -> float rounds once the value exceeds the mantissa:
242        /// // 2^53 + 1 has no exact `f64` representation.
243        /// let big = vec![(1u64 << 53) + 1].into_arrow();
244        /// assert_eq!(into_vec::<f64>(&big).ok(), Some(vec![(1u64 << 53) as f64]));
245        ///
246        /// // f64 -> f32 overflows to infinity instead of erroring.
247        /// let huge = vec![1e300f64].into_arrow();
248        /// assert_eq!(into_vec::<f32>(&huge).ok(), Some(vec![f32::INFINITY]));
249        ///
250        /// // NaN is out of range for an integer target, so it errors.
251        /// let nan = vec![f64::NAN].into_arrow();
252        /// assert!(into_vec::<i64>(&nan).is_err());
253        ///
254        /// // Unsupported (non-numeric) array types are rejected.
255        /// let strings = vec!["a".to_string(), "b".to_string()].into_arrow();
256        /// assert!(into_vec::<u64>(&strings).is_err());
257        /// ```
258        pub fn into_vec<T>(data: &DoraArray) -> Result<Vec<T>>
259        where
260            T: Copy + NumCast + 'static,
261        {
262            match data.0.data_type() {
263                $(
264                    $variant => {
265                        let buffer: &$array_type = data
266                            .0
267                            .as_any()
268                            .downcast_ref()
269                            .context(concat!("series is not ", $type_name))?;
270
271                        if buffer.null_count() != 0 {
272                            eyre::bail!("array has nulls");
273                        }
274
275                        let mut result = Vec::with_capacity(buffer.len());
276                        for &v in buffer.values() {
277                            // `with_context` defers the `format!` to the error
278                            // path: `context(format!(...))` would heap-allocate a
279                            // fresh error String on every element even on the
280                            // (overwhelmingly common) success path.
281                            let converted = NumCast::from(v).with_context(|| {
282                                format!("Failed to cast value from {} to target type", $type_name)
283                            })?;
284                            result.push(converted);
285                        }
286                        Ok(result)
287                    }
288                ),*
289                // Error handling for unsupported types
290                unsupported_type => Err(eyre!("Unsupported data type for conversion: {:?}", unsupported_type))
291            }
292        }
293    };
294}
295
296// Register all supported array types in one place
297register_array_handlers! {
298    (DataType::Float32, Float32Array, "float32"),
299    (DataType::Float64, Float64Array, "float64"),
300    (DataType::Int8, Int8Array, "int8"),
301    (DataType::Int16, Int16Array, "int16"),
302    (DataType::Int32, Int32Array, "int32"),
303    (DataType::Int64, Int64Array, "int64"),
304    (DataType::UInt8, UInt8Array, "uint8"),
305    (DataType::UInt16, UInt16Array, "uint16"),
306    (DataType::UInt32, UInt32Array, "uint32"),
307    (DataType::UInt64, UInt64Array, "uint64"),
308    (DataType::Float16, Float16Array, "float16"),
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use arrow::array::ArrayRef;
315    use half::f16;
316    use std::sync::Arc;
317
318    fn wrap(array: ArrayRef) -> DoraArray {
319        internal::from_array_ref(array)
320    }
321
322    /// Round-trips every type registered in `register_array_handlers!` so a
323    /// future macro entry that gets dropped (the original cause of #2080) is
324    /// caught by a failing test rather than a silent runtime error.
325    #[test]
326    fn into_vec_supports_all_registered_types() {
327        macro_rules! assert_round_trip {
328            ($array_type:ty, $rust_type:ty, $values:expr) => {{
329                let values: Vec<$rust_type> = $values;
330                let array: ArrayRef = Arc::new(<$array_type>::from(values.clone()));
331                let data = wrap(array);
332                let result: Vec<$rust_type> = into_vec(&data).unwrap();
333                assert_eq!(result, values);
334            }};
335        }
336
337        assert_round_trip!(Float32Array, f32, vec![1.0, 2.5, -3.0]);
338        assert_round_trip!(Float64Array, f64, vec![1.0, 2.5, -3.0]);
339        assert_round_trip!(Int8Array, i8, vec![-1, 2, 3]);
340        assert_round_trip!(Int16Array, i16, vec![-1, 2, 3]);
341        assert_round_trip!(Int32Array, i32, vec![-1, 2, 3]);
342        assert_round_trip!(Int64Array, i64, vec![-1, 2, 3]);
343        assert_round_trip!(UInt8Array, u8, vec![1, 2, 3]);
344        assert_round_trip!(UInt16Array, u16, vec![1, 2, 3]);
345        assert_round_trip!(UInt32Array, u32, vec![1, 2, 3]);
346        assert_round_trip!(UInt64Array, u64, vec![1, 2, 3]);
347
348        // Float16 needs explicit f16 construction; round-trip back to f16.
349        let values = vec![f16::from_f32(1.0), f16::from_f32(2.5), f16::from_f32(-3.0)];
350        let array: ArrayRef = Arc::new(Float16Array::from(values.clone()));
351        let data = wrap(array);
352        let result: Vec<f16> = into_vec(&data).unwrap();
353        assert_eq!(result, values);
354    }
355
356    /// The case from the issue: a `UInt64` array previously errored with
357    /// "Unsupported data type for conversion: UInt64".
358    #[test]
359    fn into_vec_handles_uint64() {
360        let data = wrap(Arc::new(UInt64Array::from(vec![1u64, 2, 3])));
361        let res: Vec<u64> = into_vec(&data).unwrap();
362        assert_eq!(res, vec![1u64, 2, 3]);
363    }
364
365    #[test]
366    fn into_vec_rejects_arrays_with_nulls() {
367        let array: ArrayRef = Arc::new(UInt64Array::from(vec![Some(1u64), None, Some(3)]));
368        let data = wrap(array);
369        let res: Result<Vec<u64>> = into_vec(&data);
370        assert!(res.is_err());
371    }
372
373    #[test]
374    fn into_vec_rejects_unsupported_type() {
375        let array: ArrayRef = Arc::new(arrow::array::BooleanArray::from(vec![true, false]));
376        let data = wrap(array);
377        let res: Result<Vec<u8>> = into_vec(&data);
378        assert!(res.is_err());
379    }
380
381    /// `DoraArray`'s ungated inspection helpers must not require any Arrow
382    /// feature — they are the whole reason the common receive path compiles
383    /// with `default = []`.
384    #[test]
385    fn ungated_accessors() {
386        let data = vec![1u64, 2, 3].into_arrow();
387        assert_eq!(data.len(), 3);
388        assert!(!data.is_empty());
389        assert_eq!(data.null_count(), 0);
390        assert_eq!(data.type_name(), "UInt64");
391    }
392}