This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new c1e5ed1ae3 [python] Support string filters on system tables (#10631)
c1e5ed1ae3 is described below
commit c1e5ed1ae3a531dda79eca4e5debd34da0f0c923
Author: QuakeWang <[email protected]>
AuthorDate: Sun Oct 18 16:48:18 2026 +0800
[python] Support string filters on system tables (#10631)
---
.../pypaimon/table/system/system_table_read.py | 92 +++++--
.../pypaimon/tests/system/files_table_test.py | 14 ++
.../pypaimon/tests/system/options_table_test.py | 24 ++
.../tests/system/system_string_filter_test.py | 278 +++++++++++++++++++++
.../pypaimon/tests/system/system_table_test.py | 13 +-
5 files changed, 395 insertions(+), 26 deletions(-)
diff --git a/paimon-python/pypaimon/table/system/system_table_read.py
b/paimon-python/pypaimon/table/system/system_table_read.py
index e7f4c81736..ece7312d6b 100644
--- a/paimon-python/pypaimon/table/system/system_table_read.py
+++ b/paimon-python/pypaimon/table/system/system_table_read.py
@@ -25,26 +25,25 @@ tables produced by :class:`SystemSplit` — no manifest
reads, no data
files.
"""
+import re
from typing import Any, Iterator, List, Optional, TYPE_CHECKING
import pandas
import pyarrow
-from pypaimon.common.predicate import Predicate
+from pypaimon.common.predicate import Like, Predicate
from pypaimon.read.split import Split
from pypaimon.schema.data_types import (
AtomicType, DataField, PyarrowFieldParser)
+from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.system.system_table_scan import SystemSplit
if TYPE_CHECKING: # pragma: no cover - type-only import
from pypaimon.table.system.system_table import SystemTable
-_PREDICATE_NOT_SUPPORTED = (
- "string-match predicate pushdown (starts_with / ends_with / contains / "
- "like) on system tables is not supported yet; filter the resulting "
- "PyArrow / pandas table on the client side"
-)
+_PREDICATE_NOT_SUPPORTED = "This predicate is not supported on system tables"
+_STRING_PREDICATES = ("startsWith", "endsWith", "contains", "like")
# Value comparisons (equal / greater-than / is-in / ...) have no PyArrow
# compute kernel for nested columns such as ``$files.write_cols`` or
@@ -102,7 +101,8 @@ class SystemTableRead:
return con
def _materialise(self, splits: List[Split]) -> pyarrow.Table:
- from pypaimon.read.push_down_utils import
predicate_supports_arrow_filter
+ from pypaimon.read.push_down_utils import (
+ predicate_field_names, predicate_supports_arrow_filter,
rewrite_predicate_indices)
predicate = self.predicate
if predicate is not None and not isinstance(predicate, Predicate):
@@ -111,19 +111,23 @@ class SystemTableRead:
# arrow_filter dereferences ``.method`` and would otherwise leak
# an internal AttributeError.
raise NotImplementedError(_PREDICATE_NOT_SUPPORTED)
- if predicate is not None and not predicate_supports_arrow_filter(
+ has_string_filter = self._validate_filter(predicate) if predicate is
not None else False
+ if predicate is not None and not has_string_filter and not
predicate_supports_arrow_filter(
predicate
):
raise NotImplementedError(_PREDICATE_NOT_SUPPORTED)
- if predicate is not None:
- self._reject_non_scalar_filter_fields(predicate)
if not splits:
return self._empty_table()
- arrow_predicate = (
- self.predicate.to_arrow() if self.predicate is not None else None
- )
+ row_predicate = None
+ arrow_predicate = None
+ if has_string_filter:
+ predicate_fields = predicate_field_names(predicate)
+ filter_fields = [f for f in self.system_table.row_type().fields if
f.name in predicate_fields]
+ row_predicate = rewrite_predicate_indices(predicate, filter_fields)
+ elif predicate is not None:
+ arrow_predicate = predicate.to_arrow()
projected_names = [f.name for f in self.read_type]
slices: List[pyarrow.Table] = []
@@ -136,7 +140,9 @@ class SystemTableRead:
arrow_table = split.arrow_table()
# Filter on the full schema before projection so a predicate may
# reference columns that are not part of the projection.
- if arrow_predicate is not None:
+ if row_predicate is not None:
+ arrow_table = self._filter_rows(arrow_table, row_predicate,
filter_fields)
+ elif arrow_predicate is not None:
arrow_table = arrow_table.filter(arrow_predicate)
if projected_names:
arrow_table = arrow_table.select(projected_names)
@@ -147,8 +153,45 @@ class SystemTableRead:
combined = combined.slice(0, self.limit)
return
combined.cast(PyarrowFieldParser.from_paimon_schema(self.read_type))
- def _reject_non_scalar_filter_fields(self, predicate: Predicate) -> None:
- """Reject value comparisons on nested system-table columns.
+ def _filter_rows(
+ self, arrow_table: pyarrow.Table, predicate: Predicate, fields:
List[DataField]
+ ) -> pyarrow.Table:
+ # String to_arrow() conversions may silently fall back to True or None.
+ # Evaluate the whole tree locally, including AND / OR.
+ columns = [arrow_table.column(f.name).to_pylist() for f in fields]
+ mask = pyarrow.array([
+ self._test_row(predicate, GenericRow(list(values), fields))
+ for values in zip(*columns)
+ ], type=pyarrow.bool_())
+ return arrow_table.filter(mask)
+
+ def _test_row(self, predicate: Predicate, row: GenericRow) -> bool:
+ if predicate.method in ("and", "or"):
+ results = (self._test_row(child, row) for child in
predicate.literals)
+ return all(results) if predicate.method == "and" else any(results)
+ if predicate.method in (
+ "equal", "notEqual", "lessThan", "lessOrEqual", "greaterThan",
"greaterOrEqual"
+ ) and predicate.literals[0] is None:
+ # Arrow comparisons with NULL never select a row.
+ return False
+ if predicate.method in ("between", "notBetween") and any(v is None for
v in predicate.literals):
+ value = row.get_field(predicate.index)
+ if predicate.method == "between" or value is None:
+ return False
+ # NOT BETWEEN is an OR: a non-null bound can still match.
+ lower, upper = predicate.literals
+ return ((lower is not None and value < lower)
+ or (upper is not None and value > upper))
+ if predicate.method == "like":
+ value = row.get_field(predicate.index)
+ # SQL '_' matches a newline too, as in Java's Like predicate.
+ return value is not None and re.fullmatch(
+ Like._sql_like_to_regex(predicate.literals[0]), value,
flags=re.DOTALL
+ ) is not None
+ return predicate.test(row)
+
+ def _validate_filter(self, predicate: Predicate) -> bool:
+ """Validate operands and report whether exact row evaluation is needed.
System tables expose list/map/row columns (e.g. ``$files.write_cols``,
``$table_indexes.dv_ranges``). PyArrow has no equality / comparison /
@@ -160,12 +203,15 @@ class SystemTableRead:
field_types = {
f.name: f.type for f in self.system_table.row_type().fields
}
+ has_string_filter = False
pending: List[Predicate] = [predicate]
while pending:
current = pending.pop()
if current.method in ("and", "or"):
pending.extend(current.literals or [])
continue
+ if current.method not in Predicate.testers:
+ raise ValueError("Unsupported predicate method: " +
current.method)
if current.method in _COMPARISON_NEEDS_SCALAR:
continue
field_type = field_types.get(current.field)
@@ -180,6 +226,20 @@ class SystemTableRead:
+ ") is not supported; filter the resulting PyArrow / "
+ "pandas table on the client side"
)
+ if current.method in _STRING_PREDICATES:
+ if field_type is None:
+ raise ValueError("Unknown system-table column: " +
repr(current.field))
+ if not
pyarrow.types.is_string(PyarrowFieldParser.from_paimon_type(field_type)):
+ raise NotImplementedError(
+ "String predicates require a string system-table
column: " + repr(current.field))
+ if (not current.literals or len(current.literals) != 1
+ or not isinstance(current.literals[0], str)):
+ raise ValueError("String predicates require a single
string literal")
+ if current.method == "like":
+ # Validate even when input is empty or another branch
matches.
+ Like._sql_like_to_regex(current.literals[0])
+ has_string_filter = True
+ return has_string_filter
def _empty_table(self) -> pyarrow.Table:
target_schema = PyarrowFieldParser.from_paimon_schema(self.read_type)
diff --git a/paimon-python/pypaimon/tests/system/files_table_test.py
b/paimon-python/pypaimon/tests/system/files_table_test.py
index df15a3a59c..ad0e99fc89 100644
--- a/paimon-python/pypaimon/tests/system/files_table_test.py
+++ b/paimon-python/pypaimon/tests/system/files_table_test.py
@@ -97,6 +97,20 @@ class FilesTableTest(unittest.TestCase):
table = self.catalog.get_table("db.t$files")
self.assertIsInstance(table, FilesTable)
+ def test_file_path_suffix_filter(self):
+ self._create_partitioned_table()
+ self._write_two_partitions()
+ table = self.catalog.get_table("db.t$files")
+ expected = _read(table).column("file_path").to_pylist()
+ self.assertTrue(expected)
+ rb = table.new_read_builder()
+ pb = rb.new_predicate_builder()
+ for suffix, paths in ((".parquet", expected), (".missing", [])):
+ with self.subTest(suffix=suffix):
+ rb.with_filter(pb.endswith("file_path",
suffix)).with_projection(["file_path"])
+ result = rb.new_read().to_arrow(rb.new_scan().plan().splits())
+ self.assertEqual(result.column("file_path").to_pylist(), paths)
+
def test_schema_column_layout(self):
self._create_partitioned_table()
table = self.catalog.get_table("db.t$files")
diff --git a/paimon-python/pypaimon/tests/system/options_table_test.py
b/paimon-python/pypaimon/tests/system/options_table_test.py
index c78b79e701..64cc9ae3c8 100644
--- a/paimon-python/pypaimon/tests/system/options_table_test.py
+++ b/paimon-python/pypaimon/tests/system/options_table_test.py
@@ -83,6 +83,30 @@ class OptionsTableTest(unittest.TestCase):
self.assertEqual(["key"], arrow_table.schema.names)
self.assertIn("bucket", arrow_table.column("key").to_pylist())
+ def test_prefix_filter_before_projection_and_limit(self):
+ table = self.catalog.get_table("db.t$options")
+ rb = table.new_read_builder()
+ predicate = rb.new_predicate_builder().startswith("key", "user-")
+ rb.with_filter(predicate).with_projection(["value"]).with_limit(1)
+ result = rb.new_read().to_arrow(rb.new_scan().plan().splits())
+ self.assertEqual(result.to_pylist(), [{"value": "alpha"}])
+
+ def test_prefix_filter_preserves_null_comparison_semantics(self):
+ table = self.catalog.get_table("db.t$options")
+ rb = table.new_read_builder()
+ pb = rb.new_predicate_builder()
+ for method in ("equal", "not_equal", "less_than", "less_or_equal",
"greater_than", "greater_or_equal"):
+ with self.subTest(method=method):
+ comparison = getattr(pb, method)("value", None)
+ rb.with_filter(comparison)
+
self.assertEqual(rb.new_read().to_arrow(rb.new_scan().plan().splits()).to_pylist(),
[])
+ rb.with_filter(pb.and_predicates([pb.startswith("key",
"user-"), comparison]))
+
self.assertEqual(rb.new_read().to_arrow(rb.new_scan().plan().splits()).to_pylist(),
[])
+ rb.with_filter(pb.or_predicates([comparison,
pb.startswith("key", "user-")]))
+
self.assertEqual(rb.new_read().to_arrow(rb.new_scan().plan().splits()).to_pylist(),
[
+ {"key": "user-flag", "value": "alpha"},
+ ])
+
if __name__ == "__main__":
unittest.main()
diff --git a/paimon-python/pypaimon/tests/system/system_string_filter_test.py
b/paimon-python/pypaimon/tests/system/system_string_filter_test.py
new file mode 100644
index 0000000000..c42453107a
--- /dev/null
+++ b/paimon-python/pypaimon/tests/system/system_string_filter_test.py
@@ -0,0 +1,278 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Exact string filtering on in-memory system-table metadata."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.common.predicate import Predicate
+from pypaimon.schema.data_types import AtomicType, DataField, RowType
+from pypaimon.table.system.system_table_scan import SystemSplit
+from pypaimon.tests.system.system_table_test import (
+ _ArraySystemTable, _DataSystemTable, _fake_base)
+
+
+class _StringSystemTable(_DataSystemTable):
+
+ def _build_arrow_table(self):
+ return pa.table({
+ "id": pa.array(range(1, 11), type=pa.int32()),
+ "name": pa.array([
+ "skip.orc", "data-1.parquet", "data-2.orc", "other.parquet",
+ None, "", "DATA-3.parquet", "metric_100%",
"\u6587\u4ef6.parquet", "path\\file",
+ ]),
+ })
+
+
[email protected]
+def table():
+ return _StringSystemTable(_fake_base())
+
+
+def _read(builder):
+ return builder.new_read().to_arrow(builder.new_scan().plan().splits())
+
+
[email protected]("method,pattern,expected", [
+ ("startswith", "data-", [2, 3]),
+ ("endswith", ".parquet", [2, 4, 7, 9]),
+ ("contains", "parquet", [2, 4, 7, 9]),
+ ("like", "data-_.parquet", [2]),
+])
[email protected]("combination", ["leaf", "and", "or"])
+def test_string_predicates_and_combinations(table, method, pattern, expected,
combination):
+ builder = table.new_read_builder()
+ pb = builder.new_predicate_builder()
+ predicate = getattr(pb, method)("name", pattern)
+ if combination == "and":
+ predicate = pb.and_predicates([predicate, pb.greater_than("id", 2)])
+ expected = [i for i in expected if i > 2]
+ elif combination == "or":
+ predicate = pb.or_predicates([pb.equal("id", 1), predicate])
+ expected = [1] + expected
+ result = _read(builder.with_filter(predicate).with_projection(["id"]))
+ assert result.column("id").to_pylist() == expected
+
+
[email protected]("method", [
+ "equal", "not_equal", "less_than", "less_or_equal", "greater_than",
"greater_or_equal",
+])
[email protected]("field", ["id", "name"])
[email protected]("combination", ["and", "or"])
+def test_mixed_null_comparisons_preserve_arrow_semantics(table, method, field,
combination):
+ builder = table.new_read_builder()
+ pb = builder.new_predicate_builder()
+ comparison = getattr(pb, method)(field, None)
+ assert _read(builder.with_filter(comparison)).num_rows == 0
+ predicate = getattr(pb, combination + "_predicates")([
+ comparison, pb.contains("name", "parquet"),
+ ])
+ result = _read(builder.with_filter(predicate).with_projection(["id"]))
+ assert result.column("id").to_pylist() == ([] if combination == "and" else
[2, 4, 7, 9])
+
+
[email protected]("method,literals,expected", [
+ ("between", [None, 3], []),
+ ("between", [3, None], []),
+ ("between", [None, None], []),
+ ("not_between", [None, 3], [4, 5, 6, 7, 8, 9, 10]),
+ ("not_between", [3, None], [1, 2]),
+ ("not_between", [None, None], []),
+ ("is_in", [[None, 2]], [2]),
+ ("is_in", [[None]], []),
+ ("is_not_in", [[None, 2]], []),
+])
[email protected]("combination", ["and", "or"])
+def test_mixed_null_range_and_set_comparisons(table, method, literals,
expected, combination):
+ builder = table.new_read_builder()
+ pb = builder.new_predicate_builder()
+ comparison = getattr(pb, method)("id", *literals)
+ assert _read(builder.with_filter(comparison)).column("id").to_pylist() ==
expected
+ predicate = getattr(pb, combination + "_predicates")([
+ comparison, pb.contains("name", "parquet"),
+ ])
+ string_ids = {2, 4, 7, 9}
+ expected = sorted(set(expected) & string_ids if combination == "and" else
set(expected) | string_ids)
+ assert _read(builder.with_filter(predicate)).column("id").to_pylist() ==
expected
+
+
[email protected]("method,pattern,expected", [
+ ("startswith", "", [1, 2, 3, 4, 6, 7, 8, 9, 10]),
+ ("endswith", "", [1, 2, 3, 4, 6, 7, 8, 9, 10]),
+ ("contains", "", [1, 2, 3, 4, 6, 7, 8, 9, 10]),
+ ("like", "%", [1, 2, 3, 4, 6, 7, 8, 9, 10]),
+ ("like", "", [6]),
+ ("contains", "%", [8]),
+ ("contains", "_", [8]),
+ ("contains", "\u6587\u4ef6", [9]),
+ ("like", r"metric\_100\%", [8]),
+ ("like", r"path\\file", [10]),
+ ("like", "%.parquet", [2, 4, 7, 9]),
+ ("like", "missing%", []),
+])
+def test_string_matching_edge_cases(table, method, pattern, expected):
+ builder = table.new_read_builder()
+ predicate = getattr(builder.new_predicate_builder(), method)("name",
pattern)
+ assert _read(builder.with_filter(predicate)).column("id").to_pylist() ==
expected
+
+
[email protected]("pattern,expected", [("a_b", [1, 2]), ("a%b", [1,
2]), ("a.b", [2])])
+def test_like_wildcards_match_newlines_and_escape_regex_characters(table,
pattern, expected):
+ rows = pa.table({"id": pa.array([1, 2], type=pa.int32()), "name": ["a\nb",
"a.b"]})
+ with patch.object(table, "_build_arrow_table", return_value=rows):
+ builder = table.new_read_builder()
+ pb = builder.new_predicate_builder()
+ predicate = pb.and_predicates([pb.like("name", pattern),
pb.greater_than("id", 0)])
+ assert _read(builder.with_filter(predicate)).column("id").to_pylist()
== expected
+
+
+def test_nested_predicate_never_uses_lossy_arrow_conversion(table):
+ builder = table.new_read_builder()
+ pb = builder.new_predicate_builder()
+ predicate = pb.and_predicates([
+ pb.or_predicates([pb.contains("name", "parquet"), pb.is_null("name")]),
+ pb.greater_than("id", 3),
+ ])
+ with patch.object(Predicate, "to_arrow",
side_effect=AssertionError("Unsafe Arrow conversion")):
+ result = _read(builder.with_filter(predicate))
+ assert result.column("id").to_pylist() == [4, 5, 7, 9]
+
+
+def test_combines_multiple_string_predicates(table):
+ builder = table.new_read_builder()
+ pb = builder.new_predicate_builder()
+ predicate = pb.and_predicates([
+ pb.or_predicates([pb.startswith("name", "data-"), pb.endswith("name",
".parquet")]),
+ pb.contains("name", "parquet"),
+ ])
+ assert _read(builder.with_filter(predicate)).column("id").to_pylist() ==
[2, 4, 7, 9]
+
+
+def test_does_not_convert_unrelated_columns_to_python(table):
+ fields = table.row_type().fields + [DataField(2, "event_time",
AtomicType("TIMESTAMP(6)"))]
+ # Year 10000 fits Arrow timestamps but cannot be converted to datetime.
+ rows = table._build_arrow_table().append_column(
+ "event_time", pa.array([253402300800000000] * 10,
type=pa.timestamp("us")))
+ with patch.object(table, "row_type", return_value=RowType(False, fields)):
+ with patch.object(table, "_build_arrow_table", return_value=rows):
+ builder = table.new_read_builder()
+ predicate = builder.new_predicate_builder().startswith("name",
"data-")
+ result =
_read(builder.with_filter(predicate).with_projection(["id"]))
+ assert result.to_pylist() == [{"id": 2}, {"id": 3}]
+
+
+def test_string_filter_precedes_projection_and_global_limit(table):
+ builder = table.new_read_builder()
+ predicate = builder.new_predicate_builder().endswith("name", ".parquet")
+ builder.with_filter(predicate).with_projection(["id"]).with_limit(2)
+ rows = table._build_arrow_table()
+ splits = [SystemSplit(rows.slice(0, 3)), SystemSplit(rows.slice(3))]
+ assert builder.new_read().to_arrow(splits).to_pylist() == [{"id": 2},
{"id": 4}]
+
+
+def test_projected_predicate_indices_are_rebound_without_mutation(table):
+ builder = table.new_read_builder().with_projection(["name", "id"])
+ pb = builder.new_predicate_builder()
+ string_predicate = pb.startswith("name", "data-")
+ predicate = pb.and_predicates([string_predicate, pb.equal("id", 3)])
+ assert _read(builder.with_filter(predicate)).to_pylist() == [{"name":
"data-2.orc", "id": 3}]
+ assert string_predicate.index == 0
+
+
[email protected]("empty_splits", [False, True])
+def test_string_filter_on_empty_input_keeps_projected_schema(table,
empty_splits):
+ builder = table.new_read_builder()
+ predicate = builder.new_predicate_builder().contains("name", "parquet")
+ builder.with_filter(predicate).with_projection(["id"])
+ splits = [] if empty_splits else
[SystemSplit(table._build_arrow_table().slice(0, 0))]
+ result = builder.new_read().to_arrow(splits)
+ assert result.num_rows == 0
+ assert result.schema == pa.schema([pa.field("id", pa.int32(),
nullable=False)])
+
+
[email protected]("method", ["startswith", "endswith", "contains",
"like"])
[email protected]("field", ["id", "tags"])
+def test_string_predicates_require_string_columns(method, field):
+ builder = _ArraySystemTable(_fake_base()).new_read_builder()
+ predicate = getattr(builder.new_predicate_builder(), method)(field, "a")
+ with pytest.raises(NotImplementedError, match="column"):
+ _read(builder.with_filter(predicate))
+
+
[email protected]("method", ["startswith", "endswith", "contains",
"like"])
[email protected]("pattern", [None, 1, b"data"])
+def test_string_patterns_must_be_strings_even_without_splits(table, method,
pattern):
+ builder = table.new_read_builder()
+ predicate = getattr(builder.new_predicate_builder(), method)("name",
pattern)
+ with pytest.raises(ValueError, match="string literal"):
+ builder.with_filter(predicate).new_read().to_arrow([])
+
+
[email protected]("pattern", ["bad\\", r"bad\q"])
+def test_invalid_like_escape_is_rejected_even_in_unused_branch(table, pattern):
+ builder = table.new_read_builder()
+ pb = builder.new_predicate_builder()
+ predicate = pb.or_predicates([pb.is_not_null("id"), pb.like("name",
pattern)])
+ with pytest.raises(ValueError, match="Invalid escape sequence"):
+ _read(builder.with_filter(predicate))
+
+
[email protected]("literals", [None, [], ["a", "b"]])
+def test_string_pattern_requires_exactly_one_literal(table, literals):
+ builder = table.new_read_builder()
+ predicate = builder.new_predicate_builder().contains("name",
"a").new_literals(literals)
+ with pytest.raises(ValueError, match="single string literal"):
+ _read(builder.with_filter(predicate))
+
+
+def test_unknown_predicate_is_rejected_in_unused_branch(table):
+ builder = table.new_read_builder()
+ pb = builder.new_predicate_builder()
+ predicate = pb.or_predicates([
+ pb.contains("name", ""), Predicate("unknown", 0, "id", []),
+ ])
+ with pytest.raises(ValueError, match="Unsupported predicate method"):
+ _read(builder.with_filter(predicate))
+
+
[email protected]("output", ["to_arrow", "to_pandas", "to_iterator",
"to_record_batch_iterator"])
+def test_string_filter_applies_to_all_outputs(table, output):
+ builder = table.new_read_builder()
+ predicate = builder.new_predicate_builder().startswith("name", "data-")
+ builder.with_filter(predicate).with_projection(["id"]).with_limit(1)
+ result = getattr(builder.new_read(),
output)(builder.new_scan().plan().splits())
+ if output == "to_pandas":
+ result = result.to_dict("records")
+ elif output == "to_iterator":
+ result = list(result)
+ elif output == "to_record_batch_iterator":
+ result = pa.Table.from_batches(list(result)).to_pylist()
+ else:
+ result = result.to_pylist()
+ assert result == [{"id": 2}]
+
+
+def test_native_result_handoff_uses_string_filter(table):
+ builder = table.new_read_builder()
+ predicate = builder.new_predicate_builder().contains("name", "parquet")
+ rows = table._build_arrow_table()
+ with
patch("pypaimon.table.system.native_system_table.try_read_native_system_table",
return_value=rows):
+ with patch.object(table, "_build_arrow_table",
side_effect=AssertionError("Metadata rebuilt")):
+ assert
_read(builder.with_filter(predicate)).column("id").to_pylist() == [2, 4, 7, 9]
diff --git a/paimon-python/pypaimon/tests/system/system_table_test.py
b/paimon-python/pypaimon/tests/system/system_table_test.py
index 2bf76319ef..7ae9a6f2a6 100644
--- a/paimon-python/pypaimon/tests/system/system_table_test.py
+++ b/paimon-python/pypaimon/tests/system/system_table_test.py
@@ -194,16 +194,9 @@ class SystemTableFilterTest(unittest.TestCase):
self.assertEqual(table.column_names, ["name"])
self.assertEqual(table.column("name").to_pylist(), ["c"])
- def test_string_match_filter_still_raises(self):
- # starts_with / ends_with / contains / like are not safe as final
- # Arrow row filters, so they still surface a clear NotImplementedError.
- pred = self._predicate_builder().contains("name", "b")
- sys_table = _DataSystemTable(_fake_base())
- rb = sys_table.new_read_builder().with_filter(pred)
- splits = rb.new_scan().plan().splits()
- read = rb.new_read()
- with self.assertRaises(NotImplementedError):
- read.to_arrow(splits)
+ def test_string_match_filter_selects_matching_rows(self):
+ table = self._read(self._predicate_builder().contains("name", "b"))
+ self.assertEqual(table.column("id").to_pylist(), [2])
def test_comparison_on_array_column_raises(self):
# PyArrow's ArrowNotImplementedError (a NotImplementedError subclass)