This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new b8d59052 feat(python): expose data evolution row ranges (#674)
b8d59052 is described below

commit b8d590521eb4cfde916ce3589cda88e75f31046a
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Aug 5 11:45:11 2026 +0800

    feat(python): expose data evolution row ranges (#674)
---
 .../python/python/pypaimon_rust/datafusion.pyi     |  3 ++
 bindings/python/src/read.rs                        | 32 +++++++++++--
 bindings/python/tests/test_read.py                 | 43 +++++++++++++++++
 crates/paimon/src/table/format_read_builder.rs     |  7 ++-
 crates/paimon/src/table/format_table_scan.rs       | 15 +++++-
 crates/paimon/src/table/read_builder.rs            | 55 +++++++++++++++++++---
 crates/paimon/src/table/table_scan.rs              | 22 ++++++---
 7 files changed, 157 insertions(+), 20 deletions(-)

diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi 
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index a43e57f2..f134c274 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -68,6 +68,9 @@ class ReadBuilder:
         ...
     def with_limit(self, limit: int) -> "ReadBuilder": ...
     def with_filter(self, predicate: dict) -> "ReadBuilder": ...
+    def with_row_ranges(self, ranges: Sequence[tuple[int, int]]) -> 
"ReadBuilder":
+        """Set Data Evolution row ranges. Empty selects no rows; format tables 
are unsupported."""
+        ...
     def new_scan(self) -> TableScan: ...
     def new_read(self) -> "TableRead": ...
 
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 49217a43..d5d63ece 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -21,7 +21,7 @@ use std::sync::Arc;
 use arrow::pyarrow::ToPyArrow;
 use futures::TryStreamExt;
 use paimon::spec::Predicate;
-use paimon::table::{DataSplit, Table};
+use paimon::table::{DataSplit, RowRange, Table};
 use paimon_datafusion::runtime::runtime;
 use pyo3::exceptions::{PyTypeError, PyValueError};
 use pyo3::prelude::*;
@@ -62,8 +62,7 @@ fn find_time_travel_selector(opts: &HashMap<String, String>) 
-> Option<(&str, &s
         .find_map(|&name| opts.get(name).map(|v| (name, v.as_str())))
 }
 
-/// Apply projection/limit/filter from a config snapshot onto a core 
ReadBuilder.
-/// Shared by PyTableScan::plan and PyTableRead::read so scan and read stay 
consistent.
+/// Apply common scan/read config onto a core ReadBuilder.
 fn apply_read_config(
     builder: &mut paimon::table::ReadBuilder<'_>,
     projection: &Option<Vec<String>>,
@@ -110,6 +109,7 @@ pub struct PyReadBuilder {
     projection: Option<Vec<String>>,
     limit: Option<usize>,
     filter: Option<Predicate>,
+    row_ranges: Option<Vec<RowRange>>,
     case_sensitive: bool,
 }
 
@@ -120,6 +120,7 @@ impl PyReadBuilder {
             projection: None,
             limit: None,
             filter: None,
+            row_ranges: None,
             case_sensitive: true,
         }
     }
@@ -162,6 +163,7 @@ impl PyReadBuilder {
             projection: None,
             limit: None,
             filter: None,
+            row_ranges: None,
             case_sensitive: true,
         })
     }
@@ -203,12 +205,32 @@ impl PyReadBuilder {
         Ok(slf)
     }
 
+    /// Set inclusive row ID ranges for Data Evolution scan planning.
+    /// Planned splits carry the ranges used by readers.
+    fn with_row_ranges(
+        mut slf: PyRefMut<'_, Self>,
+        ranges: Vec<(i64, i64)>,
+    ) -> PyResult<PyRefMut<'_, Self>> {
+        let mut row_ranges = Vec::with_capacity(ranges.len());
+        for (from, to) in ranges {
+            if from > to {
+                return Err(PyValueError::new_err(format!(
+                    "row range start {from} exceeds end {to}"
+                )));
+            }
+            row_ranges.push(RowRange::new(from, to));
+        }
+        slf.row_ranges = Some(row_ranges);
+        Ok(slf)
+    }
+
     fn new_scan(&self) -> PyTableScan {
         PyTableScan {
             table: Arc::clone(&self.table),
             projection: self.projection.clone(),
             limit: self.limit,
             filter: self.filter.clone(),
+            row_ranges: self.row_ranges.clone(),
             case_sensitive: self.case_sensitive,
         }
     }
@@ -230,6 +252,7 @@ pub struct PyTableScan {
     projection: Option<Vec<String>>,
     limit: Option<usize>,
     filter: Option<Predicate>,
+    row_ranges: Option<Vec<RowRange>>,
     case_sensitive: bool,
 }
 
@@ -247,6 +270,9 @@ impl PyTableScan {
                     &self.filter,
                     self.case_sensitive,
                 )?;
+                if let Some(row_ranges) = &self.row_ranges {
+                    builder.with_row_ranges(row_ranges.clone());
+                }
                 let plan = builder.new_scan().plan().await.map_err(to_py_err)?;
                 Ok::<_, PyErr>(plan.splits().to_vec())
             })
diff --git a/bindings/python/tests/test_read.py 
b/bindings/python/tests/test_read.py
index c0ea7367..0f4600a4 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -66,6 +66,49 @@ def test_with_limit():
         assert plan is not None
 
 
+def test_with_row_ranges():
+    with tempfile.TemporaryDirectory() as warehouse:
+        ctx = SQLContext()
+        ctx.register_catalog("paimon", {"warehouse": warehouse})
+        ctx.sql("CREATE SCHEMA paimon.rdb")
+        ctx.sql("""CREATE TABLE paimon.rdb.de (id INT, name STRING) WITH (
+            'row-tracking.enabled' = 'true',
+            'data-evolution.enabled' = 'true')""")
+        ctx.sql("""INSERT INTO paimon.rdb.de (id, name)
+            VALUES (1, 'a'), (2, 'b'), (3, 'c')""")
+        table = PaimonCatalog({"warehouse": warehouse}).get_table("rdb.de")
+        builder = table.new_read_builder().with_row_ranges([(0, 1)])
+        plan = builder.new_scan().plan()
+        batches = builder.new_read().read(plan.splits())
+        assert pa.Table.from_batches(batches).column("id").to_pylist() == [1, 
2]
+
+        restored_splits = [pickle.loads(pickle.dumps(split)) for split in 
plan.splits()]
+        restored_batches = builder.new_read().read(restored_splits)
+        assert 
pa.Table.from_batches(restored_batches).column("id").to_pylist() == [1, 2]
+
+        empty_builder = table.new_read_builder().with_row_ranges([])
+        empty_plan = empty_builder.new_scan().plan()
+        assert empty_plan.splits() == []
+        assert empty_builder.new_read().read(empty_plan.splits()) == []
+
+        with pytest.raises(ValueError, match="start 2 exceeds end 1"):
+            table.new_read_builder().with_row_ranges([(2, 1)])
+
+
+def test_format_table_rejects_row_ranges():
+    with tempfile.TemporaryDirectory() as warehouse:
+        ctx = SQLContext()
+        ctx.register_catalog("paimon", {"warehouse": warehouse})
+        ctx.sql("CREATE SCHEMA paimon.rdb")
+        ctx.sql("""CREATE TABLE paimon.rdb.ft (id INT) WITH (
+            'type' = 'format-table',
+            'file.format' = 'parquet')""")
+        table = PaimonCatalog({"warehouse": warehouse}).get_table("rdb.ft")
+
+        with pytest.raises(NotImplementedError, match="not supported for 
format tables"):
+            table.new_read_builder().with_row_ranges([]).new_scan().plan()
+
+
 def test_plan_len():
     with tempfile.TemporaryDirectory() as warehouse:
         table = _make_table_with_data(warehouse)
diff --git a/crates/paimon/src/table/format_read_builder.rs 
b/crates/paimon/src/table/format_read_builder.rs
index 5d6c1b84..4360d041 100644
--- a/crates/paimon/src/table/format_read_builder.rs
+++ b/crates/paimon/src/table/format_read_builder.rs
@@ -39,6 +39,7 @@ pub(crate) struct FormatReadBuilder<'a> {
     partition_filter: Option<PartitionFilter>,
     data_predicates: Vec<Predicate>,
     limit: Option<usize>,
+    row_ranges: Option<Vec<RowRange>>,
     case_sensitive: bool,
     parquet_read_budget: Option<Arc<ParquetReadBudget>>,
 }
@@ -52,6 +53,7 @@ impl<'a> FormatReadBuilder<'a> {
             partition_filter: None,
             data_predicates: Vec::new(),
             limit: None,
+            row_ranges: None,
             case_sensitive: true,
             parquet_read_budget: None,
         }
@@ -98,7 +100,8 @@ impl<'a> FormatReadBuilder<'a> {
         false
     }
 
-    pub(crate) fn with_row_ranges(&mut self, _ranges: Vec<RowRange>) -> &mut 
Self {
+    pub(crate) fn with_row_ranges(&mut self, ranges: Vec<RowRange>) -> &mut 
Self {
+        self.row_ranges = Some(ranges);
         self
     }
 
@@ -119,7 +122,7 @@ impl<'a> FormatReadBuilder<'a> {
             Vec::new(),
             None,
             self.limit,
-            None,
+            self.row_ranges.clone(),
         )
     }
 
diff --git a/crates/paimon/src/table/format_table_scan.rs 
b/crates/paimon/src/table/format_table_scan.rs
index d3dbff36..4c83180c 100644
--- a/crates/paimon/src/table/format_table_scan.rs
+++ b/crates/paimon/src/table/format_table_scan.rs
@@ -24,7 +24,7 @@ use crate::spec::{
     Datum, PartitionComputer, Predicate, PredicateOperator,
 };
 use crate::table::partition_filter::PartitionFilter;
-use crate::table::source::DataSplitBuilder;
+use crate::table::source::{DataSplitBuilder, RowRange};
 use chrono::NaiveDate;
 
 #[derive(Debug, Clone)]
@@ -32,6 +32,7 @@ pub(crate) struct FormatTableScan<'a> {
     table: &'a Table,
     partition_filter: Option<PartitionFilter>,
     limit: Option<usize>,
+    row_ranges: Option<Vec<RowRange>>,
 }
 
 impl<'a> FormatTableScan<'a> {
@@ -39,14 +40,21 @@ impl<'a> FormatTableScan<'a> {
         table: &'a Table,
         partition_filter: Option<PartitionFilter>,
         limit: Option<usize>,
+        row_ranges: Option<Vec<RowRange>>,
     ) -> Self {
         Self {
             table,
             partition_filter,
             limit,
+            row_ranges,
         }
     }
 
+    pub(crate) fn with_row_ranges(mut self, ranges: Vec<RowRange>) -> Self {
+        self.row_ranges = Some(ranges);
+        self
+    }
+
     pub(crate) async fn plan(&self) -> crate::Result<Plan> {
         self.ensure_query_auth_allowed()?;
         self.plan_inner(None).await
@@ -64,6 +72,11 @@ impl<'a> FormatTableScan<'a> {
     }
 
     async fn plan_inner(&self, trace: Option<&mut ScanTrace>) -> 
crate::Result<Plan> {
+        if self.row_ranges.is_some() {
+            return Err(crate::Error::Unsupported {
+                message: "Row ranges are not supported for format 
tables".to_string(),
+            });
+        }
         let core_options = CoreOptions::new(self.table.schema().options());
         let format_extension = 
supported_format_table_extension(&core_options.file_format())?;
         let schema_id = self.table.schema().id();
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index 94c89c2b..cce6838e 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -210,7 +210,8 @@ impl<'a> ReadBuilder<'a> {
         }
     }
 
-    /// Set row ID ranges `[from, to]` (inclusive) for filtering in data 
evolution mode.
+    /// Set Data Evolution row ID ranges `[from, to]` (inclusive).
+    /// An empty vector selects no rows. Format tables are not supported.
     pub fn with_row_ranges(&mut self, ranges: Vec<RowRange>) -> &mut Self {
         match &mut self.0 {
             ReadBuilderKind::Paimon(builder) => {
@@ -399,13 +400,9 @@ impl<'a> PaimonReadBuilder<'a> {
         )
     }
 
-    /// Set row ID ranges `[from, to]` (inclusive) for filtering in data 
evolution mode.
+    /// Set row ID ranges `[from, to]` (inclusive). An empty vector selects no 
rows.
     pub fn with_row_ranges(&mut self, ranges: Vec<RowRange>) -> &mut Self {
-        self.row_ranges = if ranges.is_empty() {
-            None
-        } else {
-            Some(ranges)
-        };
+        self.row_ranges = Some(ranges);
         self
     }
 
@@ -780,6 +777,50 @@ mod tests {
         )
     }
 
+    #[test]
+    fn test_with_empty_row_ranges_is_preserved() {
+        let table = simple_table();
+        let mut builder = table.new_read_builder();
+        builder.with_row_ranges(Vec::new());
+
+        assert_eq!(paimon_builder(&builder).row_ranges, Some(Vec::new()));
+    }
+
+    #[tokio::test]
+    async fn test_format_table_rejects_row_ranges() {
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .option("type", "format-table")
+            .option("file.format", "parquet")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            FileIOBuilder::new("memory").build().unwrap(),
+            Identifier::new("default", "format_t"),
+            "memory:/format_t".to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+
+        let mut builder = table.new_read_builder();
+        builder.with_row_ranges(Vec::new());
+        let error = builder.new_scan().plan().await.unwrap_err();
+        assert!(
+            matches!(error, crate::Error::Unsupported { ref message } if 
message.contains("format tables"))
+        );
+
+        let error = table
+            .new_read_builder()
+            .new_scan()
+            .with_row_ranges(Vec::new())
+            .plan()
+            .await
+            .unwrap_err();
+        assert!(
+            matches!(error, crate::Error::Unsupported { ref message } if 
message.contains("format tables"))
+        );
+    }
+
     fn dv_pk_table(table_path: &str, merge_engine: &str) -> Table {
         let file_io = FileIOBuilder::new("file").build().unwrap();
         let table_schema = TableSchema::new(
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 8ce0c4a6..18bd9f02 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -795,6 +795,7 @@ impl<'a> TableScan<'a> {
                 table,
                 partition_filter,
                 limit,
+                row_ranges,
             )))
         } else {
             Self(TableScanKind::Paimon(PaimonTableScan::new(
@@ -820,7 +821,9 @@ impl<'a> TableScan<'a> {
             TableScanKind::Paimon(scan) => {
                 Self(TableScanKind::Paimon(scan.with_row_ranges(ranges)))
             }
-            TableScanKind::Format(scan) => Self(TableScanKind::Format(scan)),
+            TableScanKind::Format(scan) => {
+                Self(TableScanKind::Format(scan.with_row_ranges(ranges)))
+            }
         }
     }
 
@@ -963,13 +966,9 @@ impl<'a> PaimonTableScan<'a> {
     /// Set row ranges for scan-time filtering.
     ///
     /// This replaces any existing row_ranges. Typically used to inject
-    /// results from global index lookups (e.g. full-text search).
+    /// results from global index lookups. An empty vector selects no rows.
     pub fn with_row_ranges(mut self, ranges: Vec<RowRange>) -> Self {
-        self.row_ranges = if ranges.is_empty() {
-            None
-        } else {
-            Some(ranges)
-        };
+        self.row_ranges = Some(ranges);
         self
     }
 
@@ -2475,6 +2474,15 @@ mod tests {
         )
     }
 
+    #[test]
+    fn test_scan_with_empty_row_ranges_is_preserved() {
+        let table = limit_test_table();
+        let scan = PaimonTableScan::new(&table, None, Vec::new(), None, None, 
None)
+            .with_row_ranges(Vec::new());
+
+        assert_eq!(scan.row_ranges, Some(Vec::new()));
+    }
+
     fn limit_test_split(file_name: &str, row_count: i64) -> DataSplit {
         let mut file = test_data_file_meta(Vec::new(), Vec::new(), Vec::new(), 
row_count);
         file.file_name = file_name.to_string();

Reply via email to