@@ -423,18 +423,27 @@ def test_scan_calls_both_pos_and_eq_for_combined_deletes(
423423 result = pa .Table .from_batches (batches )
424424 assert sorted (result .column ("id" ).to_pylist ()) == [2 , 3 , 5 ]
425425
426- def test_scan_calls_filter_for_residual (self , tmp_path : Path , schema : Schema , observable_backends : Backends ) -> None :
427- """orchestrate_scan calls ComputeBackend.filter when row_filter has non-trivial predicate."""
426+ def test_scan_applies_filter_via_pushdown_when_residual_is_always_true (
427+ self , tmp_path : Path , schema : Schema , observable_backends : Backends
428+ ) -> None :
429+ """orchestrate_scan pushes down filter when task.residual is AlwaysTrue.
430+
431+ This tests the fix for REST catalogs returning residual_filter=None:
432+ - task.residual becomes AlwaysTrue
433+ - row_filter should be bound and pushed down to the scanner
434+ - Post-filter (ComputeBackend.filter) should NOT be called
435+ - Result should still be correct (filter applied via pushdown)
436+ """
428437 from pyiceberg .execution ._orchestrate import orchestrate_scan
429438 from pyiceberg .expressions .visitors import bind
430439
431440 data_path = str (tmp_path / "data.parquet" )
432441 pq .write_table (pa .table ({"id" : [1 , 2 , 3 , 4 , 5 ], "name" : ["a" , "b" , "c" , "d" , "e" ]}), data_path )
433442
434- # Create a BOUND predicate (expression_to_pyarrow requires bound predicates)
443+ # Create a BOUND predicate
435444 bound_filter = bind (schema , EqualTo ("id" , 3 ), case_sensitive = True )
436445
437- # Task with AlwaysTrue residual (pushdown handles the filter )
446+ # Task with AlwaysTrue residual (simulating REST catalog )
438447 task = FileScanTask (
439448 data_file = DataFile .from_args (
440449 content = DataFileContent .DATA ,
@@ -461,14 +470,14 @@ def test_scan_calls_filter_for_residual(self, tmp_path: Path, schema: Schema, ob
461470 )
462471 )
463472
464- # BEHAVIORAL PROOF : filter was called with the row_filter
473+ # With the fix : filter is pushed down to scanner, post-filter is NOT called
465474 compute_backend = _get_observable_compute (observable_backends )
466475 filter_calls = [c for c in compute_backend .calls if c ["method" ] == "filter" ]
467- assert len (filter_calls ) == 1
476+ assert len (filter_calls ) == 0 , "Post-filter should not be called when filter is pushed down"
468477
469- # Verify correct result
478+ # Verify correct result - filter was applied via pushdown
470479 result = pa .Table .from_batches (batches )
471- assert result .column ("id" ).to_pylist () == [3 ]
480+ assert result .column ("id" ).to_pylist () == [3 ], "Filter should select only id=3"
472481
473482
474483class TestToArrowDispatchesThroughBackends :
@@ -1325,3 +1334,134 @@ def test_empty_manifests_yields_no_tasks(self, tmp_path: Path) -> None:
13251334 )
13261335
13271336 assert tasks == []
1337+
1338+
1339+ class TestResolvePushdownFilter :
1340+ """Test _resolve_pushdown_filter helper for correct filter selection.
1341+
1342+ This function determines which filter to push down to the Parquet scanner:
1343+ 1. If row_filter is AlwaysTrue → use AlwaysTrue (no filter)
1344+ 2. If task.residual is non-trivial → use it (handles schema evolution)
1345+ 3. Otherwise → bind and return row_filter (REST server returned residual_filter=None)
1346+ """
1347+
1348+ def test_always_true_row_filter_returns_always_true (self ) -> None :
1349+ """When row_filter is AlwaysTrue, pushdown should be AlwaysTrue."""
1350+ from pyiceberg .execution ._orchestrate import _resolve_pushdown_filter
1351+ from pyiceberg .expressions import AlwaysTrue , GreaterThan
1352+
1353+ schema = Schema (
1354+ NestedField (1 , "id" , IntegerType (), required = True ),
1355+ )
1356+ result = _resolve_pushdown_filter (AlwaysTrue (), GreaterThan ("id" , 5 ), schema , case_sensitive = True )
1357+ assert isinstance (result , AlwaysTrue )
1358+
1359+ def test_non_trivial_residual_is_used (self ) -> None :
1360+ """When task.residual is non-trivial, it should be used (schema evolution case)."""
1361+ from pyiceberg .execution ._orchestrate import _resolve_pushdown_filter
1362+ from pyiceberg .expressions import GreaterThan
1363+
1364+ schema = Schema (
1365+ NestedField (1 , "id" , IntegerType (), required = True ),
1366+ )
1367+ row_filter = GreaterThan ("new_col_name" , 5 )
1368+ task_residual = GreaterThan ("old_col_name" , 5 ) # Schema evolution renamed column
1369+
1370+ result = _resolve_pushdown_filter (row_filter , task_residual , schema , case_sensitive = True )
1371+ assert result is task_residual
1372+
1373+ def test_always_true_residual_falls_back_to_bound_row_filter (self ) -> None :
1374+ """When task.residual is AlwaysTrue, bind and return row_filter.
1375+
1376+ This is the critical case for REST catalog: when the server returns
1377+ residual_filter=None, it becomes AlwaysTrue, and we should bind the
1378+ original row_filter and use it for pushdown instead of losing the filter entirely.
1379+ """
1380+ from pyiceberg .execution ._orchestrate import _resolve_pushdown_filter
1381+ from pyiceberg .expressions import AlwaysTrue , BoundGreaterThan , GreaterThan
1382+
1383+ schema = Schema (
1384+ NestedField (1 , "id" , IntegerType (), required = True ),
1385+ )
1386+ row_filter = GreaterThan ("id" , 2 )
1387+ task_residual = AlwaysTrue () # REST server returned residual_filter=None
1388+
1389+ result = _resolve_pushdown_filter (row_filter , task_residual , schema , case_sensitive = True )
1390+ # Should be a bound expression now
1391+ assert isinstance (result , BoundGreaterThan )
1392+ assert not isinstance (result , AlwaysTrue )
1393+
1394+
1395+ class TestPlainReadWithAlwaysTrueResidual :
1396+ """Regression test: plain read path must apply filter when task.residual is AlwaysTrue.
1397+
1398+ Bug scenario (fixed in this PR):
1399+ - User creates unpartitioned table via REST catalog
1400+ - User deletes rows (CoW)
1401+ - User scans with row_filter
1402+ - REST server returns residual_filter=None → task.residual=AlwaysTrue
1403+ - OLD BUG: pushdown_filter = task.residual = AlwaysTrue (filter lost!)
1404+ - FIX: fall back to row_filter when task.residual is AlwaysTrue
1405+ """
1406+
1407+ def test_filter_applied_when_residual_is_always_true (self , tmp_path : Path ) -> None :
1408+ """Plain read with AlwaysTrue residual but non-trivial row_filter must filter rows."""
1409+ # Write a data file with rows [1, 2, 3, 4, 5]
1410+ data_schema = pa .schema ([pa .field ("id" , pa .int32 ()), pa .field ("category" , pa .string ())])
1411+ data_table = pa .table ({"id" : [1 , 2 , 3 , 4 , 5 ], "category" : ["a" , "a" , "a" , "a" , "a" ]}, schema = data_schema )
1412+ data_path = str (tmp_path / "data.parquet" )
1413+ pq .write_table (data_table , data_path )
1414+
1415+ # Create a FileScanTask with AlwaysTrue residual (simulating REST server)
1416+ from pyiceberg .expressions import GreaterThan
1417+ from pyiceberg .manifest import DataFile , DataFileContent , FileFormat
1418+
1419+ data_file = DataFile .from_args (
1420+ content = DataFileContent .DATA ,
1421+ file_path = data_path ,
1422+ file_format = FileFormat .PARQUET ,
1423+ partition = {},
1424+ record_count = 5 ,
1425+ file_size_in_bytes = 1000 ,
1426+ )
1427+
1428+ task = FileScanTask (
1429+ data_file = data_file ,
1430+ delete_files = None ,
1431+ residual = AlwaysTrue (), # REST server returned residual_filter=None
1432+ )
1433+
1434+ # Create mock scan objects
1435+ schema = Schema (
1436+ NestedField (1 , "id" , IntegerType (), required = True ),
1437+ NestedField (2 , "category" , StringType (), required = False ),
1438+ )
1439+ mock_metadata = MagicMock ()
1440+ mock_metadata .schema .return_value = schema
1441+ mock_metadata .format_version = 2
1442+ mock_metadata .name_mapping .return_value = None
1443+ mock_metadata .schemas = [schema ]
1444+
1445+ # Run orchestrate_scan with a row_filter that should filter out id <= 2
1446+ from pyiceberg .execution ._orchestrate import orchestrate_scan
1447+ from pyiceberg .execution .protocol import Backends
1448+
1449+ backends = Backends .resolve ({})
1450+ row_filter = GreaterThan ("id" , 2 )
1451+
1452+ result_batches = list (
1453+ orchestrate_scan (
1454+ backends = backends ,
1455+ tasks = iter ([task ]),
1456+ table_metadata = mock_metadata ,
1457+ projected_schema = schema ,
1458+ row_filter = row_filter ,
1459+ case_sensitive = True ,
1460+ )
1461+ )
1462+
1463+ result = pa .Table .from_batches (result_batches )
1464+ # Should only have rows where id > 2: [3, 4, 5]
1465+ assert sorted (result .column ("id" ).to_pylist ()) == [3 , 4 , 5 ], (
1466+ f"Filter should exclude id <= 2, got { result .column ('id' ).to_pylist ()} "
1467+ )
0 commit comments