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}