Skip to main content

volas_core/
strbuf.rs

1//! [`StrBuffer`]: the Arrow-native columnar string layout — one contiguous UTF-8
2//! [`Buffer<u8>`] plus an `n+1` [`Buffer<i64>`] of offsets (Arrow `LargeUtf8`).
3//!
4//! This replaces an `Arc<Vec<String>>` (one heap allocation per cell, pointer-
5//! chasing scans): a `StrBuffer` is a single allocation, cache-friendly to build
6//! and scan, and byte-compatible with Arrow for zero-copy interop. Cell access is
7//! a slice of the data buffer (`get` / `iter`). Missing cells are tracked by the
8//! column's separate `Validity`; an NA cell carries an empty (or placeholder) span
9//! here, exactly like the old `String::new()` placeholder.
10
11use std::fmt;
12
13use crate::buffer::Buffer;
14
15/// A contiguous UTF-8 string column buffer (Arrow `LargeUtf8`: i64 offsets).
16#[derive(Clone)]
17pub struct StrBuffer {
18    /// `len + 1` monotonic byte offsets into `data`, always **zero-based**:
19    /// `offsets[0] == 0` and `offsets[len] == data.len()`. A volas-built buffer is
20    /// normalized by construction; an Arrow import of a *sliced* array (whose
21    /// offsets are absolute, with a non-zero first offset) is re-based to 0 on the
22    /// way in (`from_buffers` callers trim `data` to the live span and subtract the
23    /// base), so this invariant holds for every `StrBuffer` — the unchecked
24    /// accessors (`get_unchecked` / `iter`) and `extend` rely on it.
25    offsets: Buffer<i64>,
26    /// Concatenated UTF-8 bytes of every cell.
27    data: Buffer<u8>,
28}
29
30impl StrBuffer {
31    /// Build from owned `String`s.
32    #[inline]
33    pub fn from_vec(v: Vec<String>) -> Self {
34        v.into_iter().collect()
35    }
36
37    /// Zero-copy from foreign Arrow `LargeUtf8` buffers (offsets + data + their guard).
38    #[inline]
39    pub fn from_buffers(offsets: Buffer<i64>, data: Buffer<u8>) -> Self {
40        StrBuffer { offsets, data }
41    }
42
43    /// The raw offset / data buffers (for a zero-copy Arrow export).
44    #[inline]
45    pub fn buffers(&self) -> (&Buffer<i64>, &Buffer<u8>) {
46        (&self.offsets, &self.data)
47    }
48
49    #[inline]
50    pub fn len(&self) -> usize {
51        self.offsets.len().saturating_sub(1)
52    }
53
54    #[inline]
55    pub fn is_empty(&self) -> bool {
56        self.len() == 0
57    }
58
59    /// Cell `i` as `&str` (a borrow into the data buffer). Bounds-checked.
60    #[inline]
61    pub fn get(&self, i: usize) -> &str {
62        let off = self.offsets.as_slice();
63        let (s, e) = (off[i] as usize, off[i + 1] as usize);
64        // SAFETY: the data is built from valid UTF-8 (`from_iter`) or imported from
65        // an Arrow Utf8 array (UTF-8 by spec), and offsets are on char boundaries.
66        unsafe { std::str::from_utf8_unchecked(&self.data.as_slice()[s..e]) }
67    }
68
69    /// Cell `i` as `&str`, **without** bounds checks — the zero-overhead accessor for
70    /// the internal hot kernels (compare / sort / scan), mirroring arrow-rs
71    /// `GenericStringArray::value_unchecked`.
72    ///
73    /// # Safety
74    /// `i < self.len()`. The offsets are a structural invariant — monotonic with
75    /// `offsets[len] == data.len()` — so `i < len` makes both offset reads and the
76    /// `[s..e]` data span in bounds; only the redundant checks are elided.
77    #[inline]
78    pub unsafe fn get_unchecked(&self, i: usize) -> &str {
79        let off = self.offsets.as_slice();
80        let (s, e) = (*off.get_unchecked(i) as usize, *off.get_unchecked(i + 1) as usize);
81        std::str::from_utf8_unchecked(self.data.as_slice().get_unchecked(s..e))
82    }
83
84    /// Iterate the cells as `&str`. The two backing slices are resolved **once** (a
85    /// single `Buffer` match each, hoisted out of the walk), then indexed without
86    /// bounds checks — so a sequential scan is one tight loop, matching a `&[String]`
87    /// walk with no per-element overhead.
88    #[inline]
89    pub fn iter(&self) -> impl Iterator<Item = &str> + '_ {
90        let off = self.offsets.as_slice();
91        let data = self.data.as_slice();
92        (0..self.len()).map(move |i| {
93            // SAFETY: `i < len` ⟹ `i + 1 <= len < off.len()`, and offsets are monotonic
94            // with `off[len] == data.len()`, so `off[i]..off[i + 1]` is in bounds of `data`.
95            unsafe {
96                let s = *off.get_unchecked(i) as usize;
97                let e = *off.get_unchecked(i + 1) as usize;
98                std::str::from_utf8_unchecked(data.get_unchecked(s..e))
99            }
100        })
101    }
102
103    /// Materialize owned `String`s (only where a `Vec<String>` is genuinely needed).
104    pub fn to_vec(&self) -> Vec<String> {
105        self.iter().map(String::from).collect()
106    }
107
108    /// A contiguous sub-range `[start, end)` as a fresh `StrBuffer` (re-packs the
109    /// bytes; `Buffer` borrowing of a sub-span is a future refinement).
110    pub fn slice(&self, start: usize, end: usize) -> StrBuffer {
111        (start..end).map(|i| self.get(i)).collect()
112    }
113
114    /// Append the cells of `items` **in place** — the offset and data buffers grow via
115    /// copy-on-write rather than being rebuilt, so a live row-by-row append stays
116    /// amortised `O(1)` per cell instead of `O(n)` (which made repeated append `O(n²)`).
117    /// A borrowed (or shared) buffer materialises once on the first append.
118    pub fn extend<S: AsRef<str>>(&mut self, items: impl IntoIterator<Item = S>) {
119        let data = self.data.make_mut();
120        let offsets = self.offsets.make_mut();
121        for s in items {
122            data.extend_from_slice(s.as_ref().as_bytes());
123            offsets.push(data.len() as i64);
124        }
125    }
126}
127
128/// Incremental builder (append / scatter paths).
129#[derive(Default)]
130pub struct StrBufferBuilder {
131    offsets: Vec<i64>,
132    data: Vec<u8>,
133}
134
135impl StrBufferBuilder {
136    pub fn with_capacity(n: usize) -> Self {
137        let mut offsets = Vec::with_capacity(n + 1);
138        offsets.push(0);
139        StrBufferBuilder { offsets, data: Vec::new() }
140    }
141    #[inline]
142    pub fn push(&mut self, s: &str) {
143        self.data.extend_from_slice(s.as_bytes());
144        self.offsets.push(self.data.len() as i64);
145    }
146    pub fn finish(mut self) -> StrBuffer {
147        if self.offsets.is_empty() {
148            self.offsets.push(0);
149        }
150        StrBuffer {
151            offsets: Buffer::from_vec(self.offsets),
152            data: Buffer::from_vec(self.data),
153        }
154    }
155}
156
157impl PartialEq for StrBuffer {
158    fn eq(&self, other: &Self) -> bool {
159        self.len() == other.len() && self.iter().zip(other.iter()).all(|(a, b)| a == b)
160    }
161}
162
163impl fmt::Debug for StrBuffer {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        f.debug_list().entries(self.iter()).finish()
166    }
167}
168
169impl<S: AsRef<str>> FromIterator<S> for StrBuffer {
170    /// The single construction path: concatenate the cells' UTF-8 into one `data`
171    /// buffer, recording the running byte boundary in `offsets` (`n + 1` entries).
172    fn from_iter<I: IntoIterator<Item = S>>(it: I) -> Self {
173        let mut offsets = vec![0i64];
174        let mut data = Vec::new();
175        for s in it {
176            data.extend_from_slice(s.as_ref().as_bytes());
177            offsets.push(data.len() as i64);
178        }
179        StrBuffer {
180            offsets: Buffer::from_vec(offsets),
181            data: Buffer::from_vec(data),
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn build_access_and_emptiness() {
192        let sb = StrBuffer::from_vec(vec!["a".into(), "".into(), "cd".into()]);
193        assert_eq!(sb.len(), 3);
194        assert!(!sb.is_empty());
195        assert_eq!(sb.get(2), "cd");
196        // SAFETY: 0 < len.
197        assert_eq!(unsafe { sb.get_unchecked(1) }, "");
198        assert_eq!(sb.iter().collect::<Vec<_>>(), ["a", "", "cd"]);
199        assert_eq!(sb.to_vec(), vec!["a".to_string(), "".into(), "cd".into()]);
200        assert!(StrBuffer::from_vec(vec![]).is_empty());
201    }
202
203    #[test]
204    fn buffers_round_trip_zero_copy() {
205        // The Arrow bridge path: take the raw offset/data buffers out and rebuild.
206        let sb = StrBuffer::from_vec(vec!["xy".into(), "z".into()]);
207        let (offsets, data) = sb.buffers();
208        let rebuilt = StrBuffer::from_buffers(offsets.clone(), data.clone());
209        assert_eq!(rebuilt, sb);
210        assert_eq!(rebuilt.slice(1, 2), StrBuffer::from_vec(vec!["z".into()]));
211    }
212
213    #[test]
214    fn builder_default_finishes_empty() {
215        // A `Default` builder that never pushes must still yield valid `[0]` offsets.
216        let empty = StrBufferBuilder::default().finish();
217        assert!(empty.is_empty());
218        let mut b = StrBufferBuilder::with_capacity(2);
219        b.push("p");
220        b.push("qr");
221        assert_eq!(b.finish(), StrBuffer::from_vec(vec!["p".into(), "qr".into()]));
222    }
223
224    #[test]
225    fn debug_lists_cells() {
226        let sb = StrBuffer::from_vec(vec!["a".into(), "b".into()]);
227        assert_eq!(format!("{sb:?}"), r#"["a", "b"]"#);
228    }
229}