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}