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

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


The following commit(s) were added to refs/heads/main by this push:
     new 3cd76f34 [python] Add prefix lookup support in python (#540)
3cd76f34 is described below

commit 3cd76f348b891ad25980f717650065fc46a63b1a
Author: Kaiqi Dong <[email protected]>
AuthorDate: Sun May 10 15:11:40 2026 +0200

    [python] Add prefix lookup support in python (#540)
    
    * add prefix lookup support in python
    
    * add more tests
---
 bindings/python/fluss/__init__.pyi              |  55 ++++++++-
 bindings/python/src/lib.rs                      |   2 +
 bindings/python/src/lookup.rs                   |  99 ++++++++++++++++
 bindings/python/src/table.rs                    |  47 ++++++++
 bindings/python/test/test_kv_table.py           | 146 +++++++++++++++++++++---
 website/docs/user-guide/python/api-reference.md |  23 +++-
 6 files changed, 350 insertions(+), 22 deletions(-)

diff --git a/bindings/python/fluss/__init__.pyi 
b/bindings/python/fluss/__init__.pyi
index 5a59ea51..18095c01 100644
--- a/bindings/python/fluss/__init__.pyi
+++ b/bindings/python/fluss/__init__.pyi
@@ -582,15 +582,43 @@ class TableUpsert:
     def __repr__(self) -> str: ...
 
 class TableLookup:
-    """Builder for creating a Lookuper.
+    """Builder for creating a Lookuper or PrefixLookuper.
 
-    Obtain via `FlussTable.new_lookup()`, then call `create_lookuper()`.
+    Obtain via `FlussTable.new_lookup()`, then call `create_lookuper()`
+    for primary key lookup, or `lookup_by(columns).create_lookuper()`
+    for prefix key lookup.
 
     Example:
         lookuper = table.new_lookup().create_lookuper()
+        prefix_lookuper = table.new_lookup().lookup_by(["a", 
"b"]).create_lookuper()
     """
 
     def create_lookuper(self) -> Lookuper: ...
+    def lookup_by(self, column_names: List[str]) -> "TablePrefixLookup":
+        """Switch to prefix-scan mode for the given lookup columns.
+
+        The columns must be the table's partition keys (if any) plus the
+        bucket keys, in that order.
+
+        Args:
+            column_names: List of column names forming the prefix key.
+
+        Returns:
+            TablePrefixLookup builder. Call `create_lookuper()` to get a 
PrefixLookuper.
+        """
+        ...
+    def __repr__(self) -> str: ...
+
+class TablePrefixLookup:
+    """Builder for creating a PrefixLookuper.
+
+    Obtain via `TableLookup.lookup_by(columns)`, then call `create_lookuper()`.
+
+    Example:
+        prefix_lookuper = table.new_lookup().lookup_by(["a", 
"b"]).create_lookuper()
+    """
+
+    def create_lookuper(self) -> "PrefixLookuper": ...
     def __repr__(self) -> str: ...
 
 class AppendWriter:
@@ -729,6 +757,29 @@ class Lookuper:
         ...
     def __repr__(self) -> str: ...
 
+class PrefixLookuper:
+    """Lookuper for performing prefix key lookups on a Fluss table.
+
+    Returns all rows whose primary key starts with the given prefix.
+    Create via `table.new_lookup().lookup_by(columns).create_lookuper()`.
+    """
+
+    async def lookup(self, prefix: dict | list | tuple) -> List[Dict[str, 
object]]:
+        """Lookup all rows matching a prefix key.
+
+        Args:
+            prefix: A dict, list, or tuple containing only the prefix key 
values
+                (the columns specified in lookup_by()).
+                For dict: keys are prefix column names.
+                For list/tuple: values in prefix column order.
+
+        Returns:
+            A list of dicts, each containing the full row data.
+            Empty list if no matches.
+        """
+        ...
+    def __repr__(self) -> str: ...
+
 class LogScanner:
     """Scanner for reading log data from a Fluss table.
 
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 6890e088..2d71491a 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -113,9 +113,11 @@ fn _fluss(m: &Bound<'_, PyModule>) -> PyResult<()> {
     m.add_class::<TableAppend>()?;
     m.add_class::<TableUpsert>()?;
     m.add_class::<TableLookup>()?;
+    m.add_class::<TablePrefixLookup>()?;
     m.add_class::<AppendWriter>()?;
     m.add_class::<UpsertWriter>()?;
     m.add_class::<Lookuper>()?;
+    m.add_class::<PrefixLookuper>()?;
     m.add_class::<Schema>()?;
     m.add_class::<LogScanner>()?;
     m.add_class::<LakeSnapshot>()?;
diff --git a/bindings/python/src/lookup.rs b/bindings/python/src/lookup.rs
index f7bd09a5..196faa1e 100644
--- a/bindings/python/src/lookup.rs
+++ b/bindings/python/src/lookup.rs
@@ -113,3 +113,102 @@ impl Lookuper {
         })
     }
 }
+
+/// Lookuper for performing prefix key lookups on a Fluss table.
+///
+/// Returns all rows whose primary key starts with the given prefix.
+/// Create once via `table.new_lookup().lookup_by(columns).create_lookuper()`
+/// and reuse for multiple lookups.
+#[pyclass]
+pub struct PrefixLookuper {
+    inner: Arc<Mutex<fcore::client::PrefixKeyLookuper>>,
+    table_info: Arc<fcore::metadata::TableInfo>,
+    lookup_column_indices: Vec<usize>,
+}
+
+#[pymethods]
+impl PrefixLookuper {
+    /// Lookup all rows matching a prefix key.
+    ///
+    /// Args:
+    ///     prefix: A dict, list, or tuple containing only the prefix key 
values
+    ///         (the columns specified in lookup_by()).
+    ///         For dict: keys are prefix column names.
+    ///         For list/tuple: values in prefix column order.
+    ///
+    /// Returns:
+    ///     A list of dicts, each containing the full row data. Empty list if 
no matches.
+    pub fn lookup<'py>(
+        &self,
+        py: Python<'py>,
+        prefix: &Bound<'_, PyAny>,
+    ) -> PyResult<Bound<'py, PyAny>> {
+        let generic_row =
+            python_to_dense_generic_row(prefix, &self.table_info, 
&self.lookup_column_indices)?;
+        let inner = self.inner.clone();
+        let table_info = self.table_info.clone();
+
+        future_into_py(py, async move {
+            let result = {
+                let mut lookuper = inner.lock().await;
+                lookuper
+                    .lookup(&generic_row)
+                    .await
+                    .map_err(|e| FlussError::from_core_error(&e))?
+            };
+
+            let rows = result
+                .get_rows()
+                .map_err(|e| FlussError::from_core_error(&e))?;
+
+            Python::attach(|py| {
+                let py_rows: Vec<Py<PyAny>> = rows
+                    .iter()
+                    .map(|row| internal_row_to_dict(py, row, &table_info))
+                    .collect::<PyResult<_>>()?;
+                Ok(py_rows)
+            })
+        })
+    }
+
+    fn __repr__(&self) -> String {
+        "PrefixLookuper()".to_string()
+    }
+}
+
+impl PrefixLookuper {
+    pub fn new(
+        connection: &Arc<fcore::client::FlussConnection>,
+        metadata: Arc<fcore::client::Metadata>,
+        table_info: fcore::metadata::TableInfo,
+        lookup_column_names: Vec<String>,
+    ) -> PyResult<Self> {
+        let row_type = table_info.row_type();
+        let lookup_column_indices: Vec<usize> = lookup_column_names
+            .iter()
+            .map(|name| {
+                row_type.get_field_index(name).ok_or_else(|| {
+                    FlussError::new_err(format!("Unknown column name '{name}' 
for prefix lookup"))
+                })
+            })
+            .collect::<PyResult<_>>()?;
+
+        let lookuper = TOKIO_RUNTIME.block_on(async {
+            let fluss_table =
+                fcore::client::FlussTable::new(connection, metadata, 
table_info.clone());
+            let table_lookup = fluss_table
+                .new_lookup()
+                .map_err(|e| FlussError::from_core_error(&e))?;
+            table_lookup
+                .lookup_by(lookup_column_names)
+                .create_lookuper()
+                .map_err(|e| FlussError::from_core_error(&e))
+        })?;
+
+        Ok(Self {
+            inner: Arc::new(Mutex::new(lookuper)),
+            table_info: Arc::new(table_info),
+            lookup_column_indices,
+        })
+    }
+}
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index 98aee5e3..4ec7b8d7 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -888,11 +888,58 @@ impl TableLookup {
         )
     }
 
+    /// Switch to prefix-scan mode for the given lookup columns.
+    ///
+    /// The columns must be the table's partition keys (if any) plus the
+    /// bucket keys, in that order.
+    ///
+    /// Args:
+    ///     column_names: List of column names forming the prefix key.
+    ///
+    /// Returns:
+    ///     TablePrefixLookup builder. Call `create_lookuper()` to get a 
PrefixLookuper.
+    pub fn lookup_by(&self, column_names: Vec<String>) -> TablePrefixLookup {
+        TablePrefixLookup {
+            connection: self.connection.clone(),
+            metadata: self.metadata.clone(),
+            table_info: self.table_info.clone(),
+            lookup_column_names: column_names,
+        }
+    }
+
     fn __repr__(&self) -> String {
         "TableLookup()".to_string()
     }
 }
 
+/// Builder for creating a PrefixLookuper.
+///
+/// Obtain via `TableLookup.lookup_by(columns)`, then call `create_lookuper()`.
+#[pyclass]
+pub struct TablePrefixLookup {
+    connection: Arc<fcore::client::FlussConnection>,
+    metadata: Arc<fcore::client::Metadata>,
+    table_info: fcore::metadata::TableInfo,
+    lookup_column_names: Vec<String>,
+}
+
+#[pymethods]
+impl TablePrefixLookup {
+    /// Create a PrefixLookuper from this builder.
+    pub fn create_lookuper(&self) -> PyResult<crate::PrefixLookuper> {
+        crate::PrefixLookuper::new(
+            &self.connection,
+            self.metadata.clone(),
+            self.table_info.clone(),
+            self.lookup_column_names.clone(),
+        )
+    }
+
+    fn __repr__(&self) -> String {
+        "TablePrefixLookup()".to_string()
+    }
+}
+
 /// Writer for appending data to a Fluss table
 #[pyclass]
 pub struct AppendWriter {
diff --git a/bindings/python/test/test_kv_table.py 
b/bindings/python/test/test_kv_table.py
index a17b0ad3..f3cddf8c 100644
--- a/bindings/python/test/test_kv_table.py
+++ b/bindings/python/test/test_kv_table.py
@@ -26,6 +26,7 @@ from datetime import time as dt_time
 from decimal import Decimal
 
 import pyarrow as pa
+import pytest
 
 import fluss
 
@@ -108,7 +109,7 @@ async def test_upsert_delete_and_lookup(connection, admin):
 
 
 async def test_composite_primary_keys(connection, admin):
-    """Test upsert and lookup with composite (multi-column) primary keys."""
+    """Test upsert/lookup with composite PKs, including prefix lookup."""
     table_path = fluss.TablePath("fluss", "py_test_composite_pk")
     await admin.drop_table(table_path, ignore_if_not_exists=True)
 
@@ -120,47 +121,79 @@ async def test_composite_primary_keys(connection, admin):
                 pa.field("region", pa.string()),
                 pa.field("score", pa.int64()),
                 pa.field("user_id", pa.int32()),
+                pa.field("event_id", pa.int64()),
             ]
         ),
-        primary_keys=["region", "user_id"],
+        primary_keys=["region", "user_id", "event_id"],
+    )
+    table_descriptor = fluss.TableDescriptor(
+        schema, bucket_count=3, bucket_keys=["region", "user_id"]
     )
-    table_descriptor = fluss.TableDescriptor(schema)
     await admin.create_table(table_path, table_descriptor, 
ignore_if_exists=False)
 
     table = await connection.get_table(table_path)
     upsert_writer = table.new_upsert().create_writer()
 
     test_data = [
-        ("US", 1, 100),
-        ("US", 2, 200),
-        ("EU", 1, 150),
-        ("EU", 2, 250),
+        ("US", 1, 1, 100),
+        ("US", 1, 2, 200),
+        ("US", 2, 1, 300),
+        ("EU", 1, 1, 150),
+        ("EU", 2, 1, 250),
     ]
 
-    for region, user_id, score in test_data:
-        upsert_writer.upsert({"region": region, "user_id": user_id, "score": 
score})
+    for region, user_id, event_id, score in test_data:
+        upsert_writer.upsert(
+            {
+                "region": region,
+                "user_id": user_id,
+                "event_id": event_id,
+                "score": score,
+            }
+        )
     await upsert_writer.flush()
 
     lookuper = table.new_lookup().create_lookuper()
 
-    # Lookup (US, 1) -> score 100
-    result = await lookuper.lookup({"region": "US", "user_id": 1})
+    # Lookup (US, 1, 1) -> score 100
+    result = await lookuper.lookup({"region": "US", "user_id": 1, "event_id": 
1})
     assert result is not None
     assert result["score"] == 100
 
-    # Lookup (EU, 2) -> score 250
-    result = await lookuper.lookup({"region": "EU", "user_id": 2})
+    # Lookup (EU, 2, 1) -> score 250
+    result = await lookuper.lookup({"region": "EU", "user_id": 2, "event_id": 
1})
     assert result is not None
     assert result["score"] == 250
 
-    # Update (US, 1) score (await acknowledgment)
-    handle = upsert_writer.upsert({"region": "US", "user_id": 1, "score": 500})
+    # Update (US, 1, 1) score (await acknowledgment)
+    handle = upsert_writer.upsert(
+        {"region": "US", "user_id": 1, "event_id": 1, "score": 500}
+    )
     await handle.wait()
 
-    result = await lookuper.lookup({"region": "US", "user_id": 1})
+    result = await lookuper.lookup({"region": "US", "user_id": 1, "event_id": 
1})
     assert result is not None
     assert result["score"] == 500
 
+    prefix_lookuper = table.new_lookup().lookup_by(["region", 
"user_id"]).create_lookuper()
+
+    # Prefix (US, 1) should match 2 rows (event_id 1 and 2)
+    rows = await prefix_lookuper.lookup({"region": "US", "user_id": 1})
+    assert len(rows) == 2
+    event_ids = sorted(row["event_id"] for row in rows)
+    assert event_ids == [1, 2]
+
+    # Also validate list/tuple prefix input
+    rows = await prefix_lookuper.lookup(["US", 1])
+    assert len(rows) == 2
+    rows = await prefix_lookuper.lookup(("EU", 2))
+    assert len(rows) == 1
+    assert rows[0]["event_id"] == 1
+
+    # Validate empty-result case: valid prefix shape but no matching rows.
+    rows = await prefix_lookuper.lookup({"region": "APAC", "user_id": 999})
+    assert rows == []
+
     await admin.drop_table(table_path, ignore_if_not_exists=False)
 
 
@@ -604,3 +637,84 @@ async def test_all_supported_datatypes(connection, admin):
             assert result[col] is None, f"{col} should be null"
 
     await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+
+async def test_prefix_lookup_validation_errors(connection, admin):
+    """Test that prefix lookup raises errors for invalid column 
configurations."""
+    table_path = fluss.TablePath("fluss", "py_test_prefix_lookup_validation")
+    await admin.drop_table(table_path, ignore_if_not_exists=True)
+
+    schema = fluss.Schema(
+        pa.schema(
+            [
+                pa.field("a", pa.int32()),
+                pa.field("b", pa.string()),
+                pa.field("c", pa.int64()),
+            ]
+        ),
+        primary_keys=["a", "b", "c"],
+    )
+    table_descriptor = fluss.TableDescriptor(
+        schema, bucket_count=3, bucket_keys=["a", "b"]
+    )
+    await admin.create_table(table_path, table_descriptor, 
ignore_if_exists=False)
+
+    table = await connection.get_table(table_path)
+
+    # lookup_by with columns equal to full PK should error
+    with pytest.raises(fluss.FlussError, match="prefix lookup"):
+        table.new_lookup().lookup_by(["a", "b", "c"]).create_lookuper()
+
+    # lookup_by with wrong column names should error
+    with pytest.raises(fluss.FlussError, match="bucket keys"):
+        table.new_lookup().lookup_by(["a", "c"]).create_lookuper()
+
+    # lookup_by with unknown column should error
+    with pytest.raises(fluss.FlussError, match="Unknown column name"):
+        table.new_lookup().lookup_by(["a", "missing_col"]).create_lookuper()
+
+    await admin.drop_table(table_path, ignore_if_not_exists=False)
+
+    # Partitioned table: lookup columns must include partition keys first,
+    # followed by bucket keys.
+    partitioned_table_path = fluss.TablePath("fluss", 
"py_test_prefix_lookup_validation_pt")
+    await admin.drop_table(partitioned_table_path, ignore_if_not_exists=True)
+
+    partitioned_schema = fluss.Schema(
+        pa.schema(
+            [
+                pa.field("region", pa.string()),
+                pa.field("user_id", pa.int32()),
+                pa.field("event_id", pa.int64()),
+            ]
+        ),
+        primary_keys=["region", "user_id", "event_id"],
+    )
+    partitioned_table_descriptor = fluss.TableDescriptor(
+        partitioned_schema,
+        partition_keys=["region"],
+        bucket_count=3,
+        bucket_keys=["user_id"],
+    )
+    await admin.create_table(
+        partitioned_table_path, partitioned_table_descriptor, 
ignore_if_exists=False
+    )
+
+    partitioned_table = await connection.get_table(partitioned_table_path)
+
+    # Missing partition key in lookup columns.
+    with pytest.raises(fluss.FlussError, match="partition fields"):
+        partitioned_table.new_lookup().lookup_by(["user_id"]).create_lookuper()
+
+    # A non-existent partition returns empty list.
+    partitioned_prefix_lookuper = (
+        partitioned_table.new_lookup().lookup_by(["region", 
"user_id"]).create_lookuper()
+    )
+    rows = await partitioned_prefix_lookuper.lookup({"region": 
"UNKNOWN_REGION", "user_id": 1})
+    assert rows == []
+
+    # After partition keys, remaining columns must equal bucket keys.
+    with pytest.raises(fluss.FlussError, match="bucket keys"):
+        partitioned_table.new_lookup().lookup_by(["region", 
"event_id"]).create_lookuper()
+
+    await admin.drop_table(partitioned_table_path, ignore_if_not_exists=False)
diff --git a/website/docs/user-guide/python/api-reference.md 
b/website/docs/user-guide/python/api-reference.md
index aec4412d..dc252b68 100644
--- a/website/docs/user-guide/python/api-reference.md
+++ b/website/docs/user-guide/python/api-reference.md
@@ -117,11 +117,20 @@ Builder for creating an `UpsertWriter`. Obtain via 
`FlussTable.new_upsert()`.
 
 ## `TableLookup`
 
-Builder for creating a `Lookuper`. Obtain via `FlussTable.new_lookup()`.
+Builder for creating a `Lookuper` or `PrefixLookuper`. Obtain via 
`FlussTable.new_lookup()`.
 
-| Method                           |  Description        |
-|----------------------------------|---------------------|
-| `.create_lookuper() -> Lookuper` | Create the lookuper |
+| Method                                              |  Description           
                   |
+|-----------------------------------------------------|-------------------------------------------|
+| `.create_lookuper() -> Lookuper`                    | Create a primary key 
lookuper             |
+| `.lookup_by(column_names) -> TablePrefixLookup`     | Switch to prefix-scan 
mode for the given columns (partition keys + bucket keys) |
+
+## `TablePrefixLookup`
+
+Builder for creating a `PrefixLookuper`. Obtain via 
`TableLookup.lookup_by(columns)`.
+
+| Method                                     |  Description              |
+|--------------------------------------------|---------------------------|
+| `.create_lookuper() -> PrefixLookuper`     | Create the prefix lookuper |
 
 ## `AppendWriter`
 
@@ -153,6 +162,12 @@ Builder for creating a `Lookuper`. Obtain via 
`FlussTable.new_lookup()`.
 |-------------------------------------|-----------------------------|
 | `await .lookup(pk) -> dict \| None` | Lookup a row by primary key |
 
+## `PrefixLookuper`
+
+| Method                                        |  Description                 
               |
+|-----------------------------------------------|---------------------------------------------|
+| `await .lookup(prefix) -> list[dict]`         | Lookup all rows matching a 
prefix key       |
+
 ## `LogScanner`
 
 | Method                                                        |  Description 
                                                                    |

Reply via email to