@@ -1331,3 +1331,378 @@ def test_cow_delete_skips_empty_record_count_files(self, tmp_path: Path) -> None
13311331 # Verify the guard: with record_count=0, the file is skipped
13321332 # The guard fires before the threshold check
13331333 assert original_row_count == 0 # Would hit `continue` in the loop
1334+
1335+
1336+ # =============================================================================
1337+ # Test: Positional Deletes + Row Filter (regression test for ea07a19b)
1338+ # =============================================================================
1339+
1340+
1341+ class TestPositionalDeletesWithRowFilter :
1342+ """Regression tests for positional deletes combined with row_filter.
1343+
1344+ These tests verify the fix in commit ea07a19b: the post-filter must be applied
1345+ when positional deletes are present, because the positional delete path does NOT
1346+ push down the row_filter.
1347+
1348+ The bug in commit 2b82fb96 used task.residual to infer whether pushdown happened,
1349+ which was incorrect. task.residual is set by the planner and doesn't reflect
1350+ whether the orchestrator actually pushed down the filter during read.
1351+ """
1352+
1353+ @pytest .fixture
1354+ def wide_data_file (self , tmp_path : Path ) -> str :
1355+ """Write a data file with 100 rows: id=[1..100]."""
1356+ path = str (tmp_path / "wide_data.parquet" )
1357+ table = pa .table ({"id" : list (range (1 , 101 )), "name" : [f"row_{ i } " for i in range (1 , 101 )]})
1358+ pq .write_table (table , path )
1359+ return path
1360+
1361+ @pytest .fixture
1362+ def pos_delete_for_wide (self , tmp_path : Path , wide_data_file : str ) -> str :
1363+ """Position delete file: removes rows at positions 0,1,2,3 (id=1,2,3,4)."""
1364+ path = str (tmp_path / "pos_delete_wide.parquet" )
1365+ table = pa .table (
1366+ {
1367+ "file_path" : [wide_data_file ] * 4 ,
1368+ "pos" : pa .array ([0 , 1 , 2 , 3 ], type = pa .int64 ()),
1369+ }
1370+ )
1371+ pq .write_table (table , path )
1372+ return path
1373+
1374+ def test_positional_deletes_with_row_filter_applies_both (
1375+ self ,
1376+ wide_data_file : str ,
1377+ pos_delete_for_wide : str ,
1378+ tmp_path : Path ,
1379+ ) -> None :
1380+ """Positional deletes + row_filter: both must be applied.
1381+
1382+ This is the exact scenario that failed in CI (test_read_multiple_batches_in_task_with_position_deletes):
1383+ - Data file has many rows
1384+ - Position deletes remove some rows (by position)
1385+ - row_filter should further filter the surviving rows
1386+
1387+ The bug was: post-filter was skipped because task.residual != AlwaysTrue,
1388+ but positional delete paths don't push down the filter.
1389+ """
1390+ from pyiceberg .expressions import LessThanOrEqual
1391+ from pyiceberg .expressions .visitors import bind
1392+
1393+ schema = Schema (
1394+ NestedField (field_id = 1 , name = "id" , field_type = IntegerType (), required = True ),
1395+ NestedField (field_id = 2 , name = "name" , field_type = StringType (), required = False ),
1396+ )
1397+
1398+ backends = Backends (
1399+ read = PyArrowReadBackend (),
1400+ write = MagicMock (),
1401+ compute = PyArrowComputeBackend (),
1402+ io_properties = {},
1403+ )
1404+
1405+ metadata = MagicMock ()
1406+ metadata .schema .return_value = schema
1407+ metadata .format_version = 2
1408+
1409+ data_file = DataFile .from_args (
1410+ content = DataFileContent .DATA ,
1411+ file_path = wide_data_file ,
1412+ file_format = FileFormat .PARQUET ,
1413+ record_count = 100 ,
1414+ file_size_in_bytes = 5000 ,
1415+ )
1416+ pos_delete_file = DataFile .from_args (
1417+ content = DataFileContent .POSITION_DELETES ,
1418+ file_path = pos_delete_for_wide ,
1419+ file_format = FileFormat .PARQUET ,
1420+ record_count = 4 ,
1421+ file_size_in_bytes = 500 ,
1422+ )
1423+
1424+ # Create a task with a non-trivial residual (simulating what the planner would set)
1425+ # The key bug was: task.residual != AlwaysTrue caused post-filter to be skipped
1426+ row_filter = LessThanOrEqual ("id" , 50 )
1427+ bound_filter = bind (schema , row_filter , case_sensitive = True )
1428+
1429+ task = FileScanTask (
1430+ data_file = data_file ,
1431+ delete_files = {pos_delete_file },
1432+ residual = bound_filter , # Non-trivial residual (this was the bug trigger)
1433+ )
1434+
1435+ results = list (
1436+ orchestrate_scan (
1437+ backends = backends ,
1438+ tasks = iter ([task ]),
1439+ table_metadata = metadata ,
1440+ projected_schema = schema ,
1441+ row_filter = bound_filter ,
1442+ case_sensitive = True ,
1443+ )
1444+ )
1445+
1446+ result_table = pa .Table .from_batches (results )
1447+ surviving_ids = sorted (result_table .column ("id" ).to_pylist ())
1448+
1449+ # Position deletes: remove positions 0,1,2,3 → remove id=1,2,3,4
1450+ # Row filter: keep only id <= 50
1451+ # Expected: [5, 6, 7, ..., 50] = 46 rows
1452+ expected_ids = list (range (5 , 51 ))
1453+
1454+ assert surviving_ids == expected_ids , (
1455+ f"Expected { len (expected_ids )} rows (5-50) after positional deletes (remove 1-4) and "
1456+ f"row_filter (id <= 50). Got { len (surviving_ids )} rows: { surviving_ids [:10 ]} ..."
1457+ )
1458+
1459+ def test_positional_deletes_with_row_filter_always_true_residual (
1460+ self ,
1461+ wide_data_file : str ,
1462+ pos_delete_for_wide : str ,
1463+ tmp_path : Path ,
1464+ ) -> None :
1465+ """Positional deletes + row_filter with AlwaysTrue residual: filter still applied.
1466+
1467+ Even when task.residual is AlwaysTrue, if row_filter is non-trivial,
1468+ the post-filter must still run for positional delete paths.
1469+ """
1470+ from pyiceberg .expressions import LessThanOrEqual
1471+ from pyiceberg .expressions .visitors import bind
1472+
1473+ schema = Schema (
1474+ NestedField (field_id = 1 , name = "id" , field_type = IntegerType (), required = True ),
1475+ NestedField (field_id = 2 , name = "name" , field_type = StringType (), required = False ),
1476+ )
1477+
1478+ backends = Backends (
1479+ read = PyArrowReadBackend (),
1480+ write = MagicMock (),
1481+ compute = PyArrowComputeBackend (),
1482+ io_properties = {},
1483+ )
1484+
1485+ metadata = MagicMock ()
1486+ metadata .schema .return_value = schema
1487+ metadata .format_version = 2
1488+
1489+ data_file = DataFile .from_args (
1490+ content = DataFileContent .DATA ,
1491+ file_path = wide_data_file ,
1492+ file_format = FileFormat .PARQUET ,
1493+ record_count = 100 ,
1494+ file_size_in_bytes = 5000 ,
1495+ )
1496+ pos_delete_file = DataFile .from_args (
1497+ content = DataFileContent .POSITION_DELETES ,
1498+ file_path = pos_delete_for_wide ,
1499+ file_format = FileFormat .PARQUET ,
1500+ record_count = 4 ,
1501+ file_size_in_bytes = 500 ,
1502+ )
1503+
1504+ row_filter = LessThanOrEqual ("id" , 50 )
1505+ bound_filter = bind (schema , row_filter , case_sensitive = True )
1506+
1507+ # Task with AlwaysTrue residual
1508+ task = FileScanTask (
1509+ data_file = data_file ,
1510+ delete_files = {pos_delete_file },
1511+ residual = AlwaysTrue (), # AlwaysTrue residual
1512+ )
1513+
1514+ results = list (
1515+ orchestrate_scan (
1516+ backends = backends ,
1517+ tasks = iter ([task ]),
1518+ table_metadata = metadata ,
1519+ projected_schema = schema ,
1520+ row_filter = bound_filter , # But row_filter is non-trivial
1521+ case_sensitive = True ,
1522+ )
1523+ )
1524+
1525+ result_table = pa .Table .from_batches (results )
1526+ surviving_ids = sorted (result_table .column ("id" ).to_pylist ())
1527+
1528+ # Expected: [5, 6, 7, ..., 50] = 46 rows
1529+ expected_ids = list (range (5 , 51 ))
1530+ assert surviving_ids == expected_ids
1531+
1532+ def test_equality_deletes_with_row_filter (self , tmp_path : Path ) -> None :
1533+ """Equality deletes + row_filter: both must be applied.
1534+
1535+ Similar to positional deletes, equality delete paths (anti_join_from_files)
1536+ don't push down the filter, so post-filter is needed.
1537+ """
1538+ from pyiceberg .expressions import LessThanOrEqual
1539+ from pyiceberg .expressions .visitors import bind
1540+
1541+ schema = Schema (
1542+ NestedField (field_id = 1 , name = "id" , field_type = IntegerType (), required = True ),
1543+ NestedField (field_id = 2 , name = "name" , field_type = StringType (), required = False ),
1544+ )
1545+
1546+ backends = Backends (
1547+ read = PyArrowReadBackend (),
1548+ write = MagicMock (),
1549+ compute = PyArrowComputeBackend (),
1550+ io_properties = {},
1551+ )
1552+
1553+ metadata = MagicMock ()
1554+ metadata .schema .return_value = schema
1555+ metadata .format_version = 2
1556+
1557+ # Data file: id=[1..100]
1558+ data_path = str (tmp_path / "eq_data.parquet" )
1559+ pq .write_table (
1560+ pa .table ({"id" : list (range (1 , 101 )), "name" : [f"row_{ i } " for i in range (1 , 101 )]}),
1561+ data_path ,
1562+ )
1563+
1564+ # Equality delete: remove id=1,2,3,4
1565+ eq_path = str (tmp_path / "eq_delete.parquet" )
1566+ pq .write_table (pa .table ({"id" : [1 , 2 , 3 , 4 ]}), eq_path )
1567+
1568+ data_file = DataFile .from_args (
1569+ content = DataFileContent .DATA ,
1570+ file_path = data_path ,
1571+ file_format = FileFormat .PARQUET ,
1572+ record_count = 100 ,
1573+ file_size_in_bytes = 5000 ,
1574+ )
1575+ eq_delete_file = DataFile .from_args (
1576+ content = DataFileContent .EQUALITY_DELETES ,
1577+ file_path = eq_path ,
1578+ file_format = FileFormat .PARQUET ,
1579+ record_count = 4 ,
1580+ file_size_in_bytes = 500 ,
1581+ equality_ids = [1 ], # field_id=1 is "id"
1582+ )
1583+
1584+ row_filter = LessThanOrEqual ("id" , 50 )
1585+ bound_filter = bind (schema , row_filter , case_sensitive = True )
1586+
1587+ task = FileScanTask (
1588+ data_file = data_file ,
1589+ delete_files = {eq_delete_file },
1590+ residual = bound_filter ,
1591+ )
1592+
1593+ results = list (
1594+ orchestrate_scan (
1595+ backends = backends ,
1596+ tasks = iter ([task ]),
1597+ table_metadata = metadata ,
1598+ projected_schema = schema ,
1599+ row_filter = bound_filter ,
1600+ case_sensitive = True ,
1601+ )
1602+ )
1603+
1604+ result_table = pa .Table .from_batches (results )
1605+ surviving_ids = sorted (result_table .column ("id" ).to_pylist ())
1606+
1607+ # Equality deletes: remove id=1,2,3,4
1608+ # Row filter: keep only id <= 50
1609+ # Expected: [5, 6, 7, ..., 50] = 46 rows
1610+ expected_ids = list (range (5 , 51 ))
1611+ assert surviving_ids == expected_ids
1612+
1613+ def test_combined_deletes_with_row_filter (self , tmp_path : Path ) -> None :
1614+ """Combined positional + equality deletes + row_filter: all must be applied."""
1615+ from pyiceberg .expressions import LessThanOrEqual
1616+ from pyiceberg .expressions .visitors import bind
1617+
1618+ schema = Schema (
1619+ NestedField (field_id = 1 , name = "id" , field_type = IntegerType (), required = True ),
1620+ NestedField (field_id = 2 , name = "name" , field_type = StringType (), required = False ),
1621+ )
1622+
1623+ backends = Backends (
1624+ read = PyArrowReadBackend (),
1625+ write = MagicMock (),
1626+ compute = PyArrowComputeBackend (),
1627+ io_properties = {},
1628+ )
1629+
1630+ metadata = MagicMock ()
1631+ metadata .schema .return_value = schema
1632+ metadata .format_version = 2
1633+
1634+ # Data file: id=[1..100]
1635+ data_path = str (tmp_path / "combined_data.parquet" )
1636+ pq .write_table (
1637+ pa .table ({"id" : list (range (1 , 101 )), "name" : [f"row_{ i } " for i in range (1 , 101 )]}),
1638+ data_path ,
1639+ )
1640+
1641+ # Position delete: remove positions 0,1 (id=1,2)
1642+ pos_path = str (tmp_path / "combined_pos.parquet" )
1643+ pq .write_table (
1644+ pa .table (
1645+ {
1646+ "file_path" : [data_path , data_path ],
1647+ "pos" : pa .array ([0 , 1 ], type = pa .int64 ()),
1648+ }
1649+ ),
1650+ pos_path ,
1651+ )
1652+
1653+ # Equality delete: remove id=3,4
1654+ eq_path = str (tmp_path / "combined_eq.parquet" )
1655+ pq .write_table (pa .table ({"id" : [3 , 4 ]}), eq_path )
1656+
1657+ data_file = DataFile .from_args (
1658+ content = DataFileContent .DATA ,
1659+ file_path = data_path ,
1660+ file_format = FileFormat .PARQUET ,
1661+ record_count = 100 ,
1662+ file_size_in_bytes = 5000 ,
1663+ )
1664+ pos_delete_file = DataFile .from_args (
1665+ content = DataFileContent .POSITION_DELETES ,
1666+ file_path = pos_path ,
1667+ file_format = FileFormat .PARQUET ,
1668+ record_count = 2 ,
1669+ file_size_in_bytes = 200 ,
1670+ )
1671+ eq_delete_file = DataFile .from_args (
1672+ content = DataFileContent .EQUALITY_DELETES ,
1673+ file_path = eq_path ,
1674+ file_format = FileFormat .PARQUET ,
1675+ record_count = 2 ,
1676+ file_size_in_bytes = 200 ,
1677+ equality_ids = [1 ],
1678+ )
1679+
1680+ row_filter = LessThanOrEqual ("id" , 50 )
1681+ bound_filter = bind (schema , row_filter , case_sensitive = True )
1682+
1683+ task = FileScanTask (
1684+ data_file = data_file ,
1685+ delete_files = {pos_delete_file , eq_delete_file },
1686+ residual = bound_filter ,
1687+ )
1688+
1689+ results = list (
1690+ orchestrate_scan (
1691+ backends = backends ,
1692+ tasks = iter ([task ]),
1693+ table_metadata = metadata ,
1694+ projected_schema = schema ,
1695+ row_filter = bound_filter ,
1696+ case_sensitive = True ,
1697+ )
1698+ )
1699+
1700+ result_table = pa .Table .from_batches (results )
1701+ surviving_ids = sorted (result_table .column ("id" ).to_pylist ())
1702+
1703+ # Position deletes: remove positions 0,1 → remove id=1,2
1704+ # Equality deletes: remove id=3,4
1705+ # Row filter: keep only id <= 50
1706+ # Expected: [5, 6, 7, ..., 50] = 46 rows
1707+ expected_ids = list (range (5 , 51 ))
1708+ assert surviving_ids == expected_ids
0 commit comments