Skip to content

Commit fcbfa3e

Browse files
authored
Add Table::get_views_for_target_batch_size for easier Table -> wire batching. (#146)
* Add Table::get_views_for_target_batch_size * Update CHANGELOG.md
1 parent e08962b commit fcbfa3e

2 files changed

Lines changed: 126 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## Unreleased
1111

12-
- Add producer-driven record batch stream export
12+
### Added
13+
- Table::get_views_for_target_batch_size for easier Table -> wire batching.
14+
- Producer-driven record batch stream export for FFI.
1315

1416
## 0.16.0 - 2026-07-16
1517

‎src/structs/table.rs‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ use crate::traits::{
6161
};
6262
#[cfg(all(feature = "views", feature = "select"))]
6363
use crate::Bitmask;
64+
#[cfg(all(feature = "views", feature = "chunked", feature = "size"))]
65+
use crate::{ByteSize, SuperTableV};
6466
#[cfg(feature = "views")]
6567
use crate::{Array, BitmaskV, NumericArrayV, TableV, TextArrayV};
6668

@@ -466,6 +468,38 @@ impl Table {
466468
TableV::from_table(self.clone(), offset, len)
467469
}
468470

471+
/// Splits this table into a [`SuperTableV`] of zero-copy row views
472+
/// sized towards `target_bytes` each.
473+
///
474+
/// The row count per view derives from the table's average bytes per
475+
/// row via [`ByteSize::est_bytes`]. Views carry row bounds rather
476+
/// than buffers, so no column data moves and the parent's buffers
477+
/// stay untouched. The final view holds the remaining rows, and a
478+
/// table that fits within `target_bytes` returns a single view.
479+
///
480+
/// `target_bytes` is a sizing target rather than a strict bound, as
481+
/// a view's actual bytes follow its rows' variable-width content.
482+
#[cfg(all(feature = "views", feature = "chunked", feature = "size"))]
483+
pub fn get_views_for_target_batch_size(&self, target_bytes: usize) -> SuperTableV {
484+
let rows = self.n_rows;
485+
if rows == 0 {
486+
return SuperTableV {
487+
slices: vec![self.slice(0, 0)],
488+
len: 0,
489+
};
490+
}
491+
let per_row = (self.est_bytes() / rows).max(1);
492+
let stride = (target_bytes / per_row).clamp(1, rows);
493+
let mut slices = Vec::with_capacity(rows.div_ceil(stride));
494+
let mut offset = 0;
495+
while offset < rows {
496+
let len = stride.min(rows - offset);
497+
slices.push(self.slice(offset, len));
498+
offset += stride;
499+
}
500+
SuperTableV { slices, len: rows }
501+
}
502+
469503
/// Gather the rows at the given indices into a new materialised Table.
470504
#[cfg(all(feature = "views", feature = "select"))]
471505
pub fn gather_rows(&self, indices: &[usize]) -> Table {
@@ -1309,8 +1343,97 @@ mod tests {
13091343
#[cfg(all(feature = "views", feature = "select"))]
13101344
use crate::traits::selection::ColumnSelection;
13111345
use crate::{Array, BooleanArray, IntegerArray, NumericArray};
1346+
#[cfg(all(feature = "views", feature = "chunked", feature = "size"))]
1347+
use crate::{ByteSize, FloatArray, StringArray};
13121348
use crate::{fa_bool, fa_i32, fa_i64, fa_u32};
13131349

1350+
#[cfg(all(feature = "views", feature = "chunked", feature = "size"))]
1351+
fn view_batch_table(rows: usize) -> Table {
1352+
let ids: Vec<i32> = (0..rows as i32).collect();
1353+
let values: Vec<f64> = (0..rows).map(|i| i as f64 * 0.5).collect();
1354+
let labels: Vec<String> = (0..rows).map(|i| format!("row_{}", i)).collect();
1355+
let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect();
1356+
Table::new(
1357+
"bench".to_string(),
1358+
Some(vec![
1359+
FieldArray::from_arr("ids", IntegerArray::<i32>::from_slice(&ids)),
1360+
FieldArray::from_arr("values", FloatArray::<f64>::from_slice(&values)),
1361+
FieldArray::from_arr("labels", StringArray::<u32>::from_slice(&label_refs)),
1362+
]),
1363+
)
1364+
}
1365+
1366+
#[test]
1367+
#[cfg(all(feature = "views", feature = "chunked", feature = "size"))]
1368+
fn views_tile_the_table_with_a_remainder() {
1369+
let rows = 1300;
1370+
let table = view_batch_table(rows);
1371+
let per_row = (table.est_bytes() / rows).max(1);
1372+
let st = table.get_views_for_target_batch_size(500 * per_row);
1373+
let counts: Vec<usize> = st.slices.iter().map(|s| s.len).collect();
1374+
assert_eq!(counts, vec![500, 500, 300]);
1375+
assert_eq!(st.len, rows);
1376+
let offsets: Vec<usize> = st.slices.iter().map(|s| s.offset).collect();
1377+
assert_eq!(offsets, vec![0, 500, 1000]);
1378+
}
1379+
1380+
#[test]
1381+
#[cfg(all(feature = "views", feature = "chunked", feature = "size"))]
1382+
fn views_read_the_parent_rows_in_place() {
1383+
let rows = 1200;
1384+
let table = view_batch_table(rows);
1385+
let per_row = (table.est_bytes() / rows).max(1);
1386+
let st = table.get_views_for_target_batch_size(500 * per_row);
1387+
let parent_ptr = match &table.cols[0].array {
1388+
Array::NumericArray(NumericArray::Int32(a)) => a.data.as_slice().as_ptr(),
1389+
_ => panic!("expected Int32 column"),
1390+
};
1391+
let mut row = 0usize;
1392+
for view in &st.slices {
1393+
// The view's array is the parent array behind a reference
1394+
// count, so its buffer pointer matches the parent's.
1395+
let (array, offset, len) = view.cols[0].as_tuple_ref();
1396+
assert_eq!(offset, row);
1397+
match array {
1398+
Array::NumericArray(NumericArray::Int32(a)) => {
1399+
assert_eq!(a.data.as_slice().as_ptr(), parent_ptr);
1400+
for i in 0..len {
1401+
assert_eq!(a.data.as_slice()[offset + i], (row + i) as i32);
1402+
}
1403+
}
1404+
_ => panic!("expected Int32 column"),
1405+
}
1406+
for i in 0..view.len {
1407+
assert_eq!(
1408+
view.cols[2].get_str(i).unwrap(),
1409+
format!("row_{}", row + i)
1410+
);
1411+
}
1412+
row += view.len;
1413+
}
1414+
assert_eq!(row, rows);
1415+
}
1416+
1417+
#[test]
1418+
#[cfg(all(feature = "views", feature = "chunked", feature = "size"))]
1419+
fn table_within_target_returns_one_view() {
1420+
let rows = 700;
1421+
let table = view_batch_table(rows);
1422+
let st = table.get_views_for_target_batch_size(usize::MAX);
1423+
assert_eq!(st.slices.len(), 1);
1424+
assert_eq!(st.slices[0].len, rows);
1425+
assert_eq!(st.len, rows);
1426+
}
1427+
1428+
#[test]
1429+
#[cfg(all(feature = "views", feature = "chunked", feature = "size"))]
1430+
fn empty_table_returns_one_empty_view() {
1431+
let table = Table::new("empty".to_string(), None);
1432+
let st = table.get_views_for_target_batch_size(1024);
1433+
assert_eq!(st.slices.len(), 1);
1434+
assert_eq!(st.len, 0);
1435+
}
1436+
13141437
#[test]
13151438
fn test_new_table() {
13161439
let t = Table::new_empty();

0 commit comments

Comments
 (0)