Files
2026-07-13 13:17:40 +08:00

1176 lines
46 KiB
Python

import os
import re
from typing import Any, List
import lance
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import pytest
from packaging.version import Version, parse as version_parse
from pytest_lazy_fixtures import lf as lazy_fixture
import ray
from ray.data import Dataset
from ray.data._internal.logical.operators import (
Filter,
Limit,
Project,
Repartition,
Sort,
)
from ray.data._internal.logical.optimizers import LogicalOptimizer
from ray.data._internal.util import rows_same
from ray.data.datasource.partitioning import Partitioning
from ray.data.datasource.path_util import _unwrap_protocol
from ray.data.datatype import DataType
from ray.data.expressions import col, lit, udf
from ray.data.tests.conftest import * # noqa
from ray.data.tests.test_execution_optimizer_limit_pushdown import (
_check_valid_plan_and_result,
)
from ray.data.tests.test_util import (
get_operator_types,
get_operators_of_type,
plan_has_operator,
plan_operator_comes_before,
)
from ray.tests.conftest import * # noqa
# Pattern to match read operators in logical plans.
# Matches V1 ``Read[Read<Format>]`` or the V2 ``ListFiles → ReadFiles``
# chain where the consumer is named ``ReadFiles<Format>`` (e.g.
# ``ReadFilesParquetV2``).
READ_OPERATOR_PATTERN = (
r"^(Read\[Read\w+\]" r"|ListFiles\[ListFiles\] -> ReadFiles\[ReadFiles\w*\])"
)
def _check_plan_with_flexible_read(
ds: Dataset, expected_plan_suffix: str, expected_result: List[Any]
):
"""Check the logical plan with flexible read operator matching.
This function allows flexibility in the read operator part of the plan
by using a configurable pattern (READ_OPERATOR_PATTERN).
Args:
ds: The dataset to check.
expected_plan_suffix: The expected plan after the read operator(s).
If empty string, only the read operator is expected.
expected_result: The expected result data.
"""
# Optimize the logical plan before checking
logical_plan = ds._logical_plan
optimized_plan = LogicalOptimizer().optimize(logical_plan)
actual_plan = optimized_plan.dag.dag_str
match = re.match(READ_OPERATOR_PATTERN, actual_plan)
assert match, f"Expected plan to start with read operator, got: {actual_plan}"
# Check if there's a suffix expected
if expected_plan_suffix:
# The suffix should appear after the read operator
expected_full_pattern = (
f"{READ_OPERATOR_PATTERN} -> {re.escape(expected_plan_suffix)}"
)
assert re.match(expected_full_pattern, actual_plan), (
f"Expected plan to match pattern with suffix '{expected_plan_suffix}', "
f"got: {actual_plan}"
)
# If no suffix, the plan should be just the read operator
else:
assert actual_plan == match.group(
1
), f"Expected plan to be just the read operator, got: {actual_plan}"
# Check the result
assert ds.take_all() == expected_result
@pytest.fixture
def parquet_ds(ray_start_regular_shared):
"""Fixture to load the Parquet dataset for testing."""
ds = ray.data.read_parquet("example://iris.parquet")
assert ds.count() == 150
return ds
@pytest.fixture
def csv_ds(ray_start_regular_shared):
"""Fixture to load the CSV dataset for testing."""
ds = ray.data.read_csv("example://iris.csv")
assert ds.count() == 150
return ds
def test_filter_with_udfs(parquet_ds):
"""Test filtering with UDFs where predicate pushdown does not occur."""
filtered_udf_ds = parquet_ds.filter(lambda r: r["sepal.length"] > 5.0)
filtered_udf_data = filtered_udf_ds.take_all()
assert filtered_udf_ds.count() == 118
assert all(record["sepal.length"] > 5.0 for record in filtered_udf_data)
_check_plan_with_flexible_read(
filtered_udf_ds,
"Filter[Filter(<lambda>)]", # UDF filter doesn't push down
filtered_udf_data,
)
def test_filter_with_expressions(parquet_ds):
"""Test filtering with expressions where predicate pushdown occurs."""
filtered_udf_data = parquet_ds.filter(lambda r: r["sepal.length"] > 5.0).take_all()
filtered_expr_ds = parquet_ds.filter(expr="sepal.length > 5.0")
_check_plan_with_flexible_read(
filtered_expr_ds,
"", # Pushed down to read, no additional operators
filtered_udf_data,
)
def test_filter_with_udf_expression_not_pushed_down(parquet_ds):
"""A UDF based filter expression must not be pushed into the datasource."""
@udf(return_dtype=DataType.bool())
def gt5(value: pa.Array) -> pa.Array:
return pa.compute.greater(value, 5.0)
expected = parquet_ds.filter(lambda r: r["sepal.length"] > 5.0).take_all()
filtered_ds = parquet_ds.filter(expr=gt5(col("sepal.length")))
# The whole UDF predicate stays as a Filter above the Read
_check_plan_with_flexible_read(
filtered_ds,
"Filter[Filter(gt5(col('sepal.length')))]",
expected,
)
@pytest.mark.parametrize("udf_first", [True, False])
def test_filter_mixed_udf_and_expression(parquet_ds, udf_first):
"""Convertible conjuncts push down while the UDF stays as a Filter."""
@udf(return_dtype=DataType.bool())
def gt5(value: pa.Array) -> pa.Array:
return pa.compute.greater(value, 5.0)
udf_filter = gt5(col("sepal.length"))
expr_filter = col("sepal.width") > lit(3.0)
if udf_first:
filtered_ds = parquet_ds.filter(expr=udf_filter).filter(expr=expr_filter)
else:
filtered_ds = parquet_ds.filter(expr=expr_filter).filter(expr=udf_filter)
expected = parquet_ds.filter(
lambda r: r["sepal.length"] > 5.0 and r["sepal.width"] > 3.0
).take_all()
# The convertible ``sepal.width > 3.0`` conjunct is pushed into the Read
# and only the UDF conjunct survives as a residual Filter
_check_plan_with_flexible_read(
filtered_ds,
"Filter[Filter(gt5(col('sepal.length')))]",
expected,
)
def test_or_of_pyarrow_and_udf_not_pushed_down(parquet_ds):
"""``pyarrow_expr | udf_expr`` must stay as a Filter above the Read."""
@udf(return_dtype=DataType.bool())
def gt5(value: pa.Array) -> pa.Array:
return pa.compute.greater(value, 5.0)
or_predicate = (col("sepal.width") > 3.0) | gt5(col("sepal.length"))
filtered_ds = parquet_ds.filter(expr=or_predicate)
expected = parquet_ds.filter(
lambda r: r["sepal.width"] > 3.0 or r["sepal.length"] > 5.0
).take_all()
# The entire OR predicate stays as a single Filter above the Read
_check_plan_with_flexible_read(
filtered_ds,
"Filter[Filter((col('sepal.width') > 3.0) | gt5(col('sepal.length')))]",
expected,
)
def test_and_wrapping_or_with_udf_splits(parquet_ds):
"""An AND of a convertible conjunct and a UDF containing OR splits correctly."""
@udf(return_dtype=DataType.bool())
def gt5(value: pa.Array) -> pa.Array:
return pa.compute.greater(value, 5.0)
or_conjunct = (col("sepal.width") > 3.0) | gt5(col("sepal.length"))
predicate = or_conjunct & (col("sepal.length") > 4.0)
filtered_ds = parquet_ds.filter(expr=predicate)
expected = parquet_ds.filter(
lambda r: (r["sepal.width"] > 3.0 or r["sepal.length"] > 5.0)
and r["sepal.length"] > 4.0
).take_all()
# ``sepal.length > 4.0`` is pushed into the Read and only the OR conjunct
# survives as a residual Filter
_check_plan_with_flexible_read(
filtered_ds,
"Filter[Filter((col('sepal.width') > 3.0) | gt5(col('sepal.length')))]",
expected,
)
def test_not_udf_not_pushed_down(parquet_ds):
"""A negation of a UDF must stay as a Filter."""
@udf(return_dtype=DataType.bool())
def gt5(value: pa.Array) -> pa.Array:
return pa.compute.greater(value, 5.0)
filtered_ds = parquet_ds.filter(expr=~gt5(col("sepal.length")))
expected = parquet_ds.filter(lambda r: r["sepal.length"] <= 5.0).take_all()
# The negated UDF stays as a Filter above the Read
_check_plan_with_flexible_read(
filtered_ds,
"Filter[Filter(~gt5(col('sepal.length')))]",
expected,
)
def test_filter_pushdown_source_and_op(ray_start_regular_shared):
"""Test filtering when expressions are provided both in source and operator."""
filter_expr = "sepal.width > 3.0"
ds = (
ray.data.read_parquet("example://iris.parquet")
.filter(expr=col("sepal.length") > lit(5.0))
.filter(expr=filter_expr)
)
result = ds.take_all()
assert all(r["sepal.length"] > 5.0 and r["sepal.width"] > 3.0 for r in result)
_check_plan_with_flexible_read(
ds,
"", # Both filters pushed down to read
result,
)
def test_chained_filter_with_expressions(parquet_ds):
"""Test chained filtering with expressions where combined pushdown occurs."""
filtered_expr_chained_ds = (
parquet_ds.filter(expr=col("sepal.length") > 1.0)
.filter(expr=col("sepal.length") > 2.0)
.filter(expr=col("sepal.length") > 3.0)
.filter(expr=col("sepal.length") > 3.0)
.filter(expr=col("sepal.length") > 5.0)
)
filtered_udf_data = parquet_ds.filter(lambda r: r["sepal.length"] > 5.0).take_all()
_check_plan_with_flexible_read(
filtered_expr_chained_ds,
"", # All filters combined and pushed down to read
filtered_udf_data,
)
@pytest.mark.parametrize(
"fs,data_path",
[
(None, lazy_fixture("local_path")),
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
# NOTE: an ``s3_fs`` parametrization was previously listed here, but
# it didn't actually exercise S3 — the test uses ``_unwrap_protocol``
# to derive a local FS path, which on the moto-mocked ``s3_path``
# fixture returns a *relative* path (the fixture strips the leading
# ``/`` so the first segment becomes a moto bucket name). The
# resulting ``lance.write_dataset(<relative-path>)`` wrote into the
# current working directory, polluting the repo on every run.
# If S3 lance pushdown ever needs end-to-end coverage, add it
# separately and pass an actual ``s3://`` URI plus storage options
# through to ``lance.write_dataset``/``ray.data.read_lance``.
],
)
# Same pylance version gate as tests/datasource/test_lance.py
@pytest.mark.skipif(
Version(lance.__version__) <= Version("0.3.19"),
reason=f"pylance {lance.__version__} <= 0.3.19; API incompatible",
)
def test_pushdown_filter_lance(ray_start_regular_shared, fs, data_path):
"""Test that Lance predicate pushdown absorbs expression filters into Read."""
df1 = pa.table({"a": [2, 1, 3, 4, 6, 5], "two": ["b", "a", "c", "e", "g", "f"]})
setup_data_path = _unwrap_protocol(data_path)
path = os.path.join(setup_data_path, "test.lance")
lance.write_dataset(df1, path)
# Both filters specified on read_lance() and .filter() should be applied
lance_ds = ray.data.read_lance(path, filter="a <= 5")
filtered_expr_ds = lance_ds.filter(expr=col("a") >= 1.0)
filtered_expr_data = lance_ds.filter(
lambda r: r["a"] <= 5.0 and r["a"] >= 1.0
).take_all()
_check_plan_with_flexible_read(
filtered_expr_ds,
"", # Pushed down to read, no additional Filter operator
filtered_expr_data,
)
@pytest.mark.parametrize(
"filter_fn,expected_suffix",
[
(
lambda ds: ds.filter(lambda r: r["sepal.length"] > 5.0),
"Filter[Filter(<lambda>)]", # UDF filter doesn't push down
),
(
lambda ds: ds.filter(expr=col("sepal.length") > 5.0),
"Filter[Filter(col('sepal.length') > 5.0)]", # CSV doesn't support predicate pushdown
),
],
)
def test_filter_pushdown_csv(csv_ds, filter_fn, expected_suffix):
"""Test filtering on CSV files (CSV doesn't support predicate pushdown)."""
filtered_ds = filter_fn(csv_ds)
filtered_data = filtered_ds.take_all()
assert filtered_ds.count() == 118
assert all(record["sepal.length"] > 5.0 for record in filtered_data)
_check_plan_with_flexible_read(
filtered_ds,
expected_suffix,
filtered_data,
)
def test_filter_mixed(csv_ds):
"""Test that mixed function and expressions work (CSV doesn't support predicate pushdown)."""
csv_ds = csv_ds.filter(lambda r: r["sepal.length"] < 5.0)
csv_ds = csv_ds.filter(expr="sepal.length > 3.0")
csv_ds = csv_ds.filter(expr="sepal.length > 4.0")
csv_ds = csv_ds.map(lambda x: x)
csv_ds = csv_ds.filter(expr="sepal.length > 2.0")
csv_ds = csv_ds.filter(expr="sepal.length > 1.0")
filtered_expr_data = csv_ds.take_all()
assert csv_ds.count() == 22
assert all(record["sepal.length"] < 5.0 for record in filtered_expr_data)
assert all(record["sepal.length"] > 4.0 for record in filtered_expr_data)
# After optimization: expression filters before map get fused, expression filters after map get fused
# CSV doesn't support predicate pushdown, so filters stay after Read
_check_plan_with_flexible_read(
csv_ds,
"Filter[Filter(<lambda>)] -> Filter[Filter((col('sepal.length') > 4.0) & (col('sepal.length') > 3.0))] -> "
"MapRows[Map(<lambda>)] -> Filter[Filter((col('sepal.length') > 1.0) & (col('sepal.length') > 2.0))]",
filtered_expr_data,
)
def test_filter_mixed_expression_first_parquet(ray_start_regular_shared):
"""Test that mixed functional and expressions work with Parquet (supports predicate pushdown)."""
ds = ray.data.read_parquet("example://iris.parquet")
ds = ds.filter(expr="sepal.length > 3.0")
ds = ds.filter(expr="sepal.length > 4.0")
ds = ds.filter(lambda r: r["sepal.length"] < 5.0)
filtered_expr_data = ds.take_all()
assert ds.count() == 22
assert all(record["sepal.length"] < 5.0 for record in filtered_expr_data)
assert all(record["sepal.length"] > 4.0 for record in filtered_expr_data)
_check_plan_with_flexible_read(
ds,
"Filter[Filter(<lambda>)]", # Expressions pushed down, UDF remains
filtered_expr_data,
)
def test_filter_mixed_expression_first_csv(ray_start_regular_shared):
"""Test that mixed functional and expressions work with CSV (doesn't support predicate pushdown)."""
ds = ray.data.read_csv("example://iris.csv")
ds = ds.filter(expr="sepal.length > 3.0")
ds = ds.filter(expr="sepal.length > 4.0")
ds = ds.filter(lambda r: r["sepal.length"] < 5.0)
filtered_expr_data = ds.take_all()
assert ds.count() == 22
assert all(record["sepal.length"] < 5.0 for record in filtered_expr_data)
assert all(record["sepal.length"] > 4.0 for record in filtered_expr_data)
# CSV doesn't support predicate pushdown, so expression filters get fused but not pushed down
_check_plan_with_flexible_read(
ds,
"Filter[Filter((col('sepal.length') > 4.0) & (col('sepal.length') > 3.0))] -> Filter[Filter(<lambda>)]",
filtered_expr_data,
)
def test_filter_mixed_expression_not_readfiles(ray_start_regular_shared):
"""Test that mixed functional and expressions work."""
ds = ray.data.range(100).filter(expr="id > 1.0")
ds = ds.filter(expr="id > 2.0")
ds = ds.filter(lambda r: r["id"] < 5.0)
filtered_expr_data = ds.take_all()
assert ds.count() == 2
assert all(record["id"] < 5.0 for record in filtered_expr_data)
assert all(record["id"] > 2.0 for record in filtered_expr_data)
_check_valid_plan_and_result(
ds,
"Read[ReadRange] -> Filter[Filter((col('id') > 2.0) & (col('id') > 1.0))] -> "
"Filter[Filter(<lambda>)]",
filtered_expr_data,
)
@pytest.mark.parametrize(
"operations,output_rename_map,expected_filter_expr,test_id",
[
(
# rename("sepal.length" -> a).filter(a)
lambda ds: ds.rename_columns({"sepal.length": "a"}).filter(
expr=col("a") > 2.0
),
{"a": "sepal.length"},
col("sepal.length") > 2.0,
"rename_filter",
),
(
# rename("sepal.length" -> a).filter(a).rename(a -> b)
lambda ds: (
ds.rename_columns({"sepal.length": "a"})
.filter(expr=col("a") > 2.0)
.rename_columns({"a": "b"})
),
{"b": "sepal.length"},
col("sepal.length") > 2.0,
"rename_filter_rename",
),
(
# rename("sepal.length" -> a).filter(a).rename(a -> b).filter(b)
lambda ds: (
ds.rename_columns({"sepal.length": "a"})
.filter(expr=col("a") > 2.0)
.rename_columns({"a": "b"})
.filter(expr=col("b") < 5.0)
),
{"b": "sepal.length"},
(col("sepal.length") > 2.0) & (col("sepal.length") < 5.0),
"rename_filter_rename_filter",
),
(
# rename("sepal.length" -> a).filter(a).rename(a -> b).filter(b).rename("sepal.width" -> a)
# Here column a is referred multiple times in rename
lambda ds: (
ds.rename_columns({"sepal.length": "a"})
.filter(expr=col("a") > 2.0)
.rename_columns({"a": "b"})
.filter(expr=col("b") < 5.0)
.rename_columns({"sepal.width": "a"})
),
{"b": "sepal.length", "a": "sepal.width"},
(col("sepal.length") > 2.0) & (col("sepal.length") < 5.0),
"rename_filter_rename_filter_rename",
),
],
ids=lambda x: x if isinstance(x, str) else "",
)
def test_pushdown_with_rename_and_filter(
ray_start_regular_shared,
operations,
output_rename_map,
expected_filter_expr,
test_id,
):
"""Test predicate pushdown with various combinations of rename and filter operations."""
path = "example://iris.parquet"
ds = operations(ray.data.read_parquet(path))
result = ds.take_all()
# Filters are pushed into the scan; renames stay as a ``Project`` of
# ``AliasExpr``s above the pruned scan.
_check_plan_with_flexible_read(ds, "Project[Project]", result)
ds1 = ray.data.read_parquet(path).filter(expr=expected_filter_expr)
# Convert to pandas to ensure both datasets are fully executed
df = ds.to_pandas().rename(columns=output_rename_map)
df1 = ds1.to_pandas()
assert len(df) == len(df1), f"Expected {len(df)} rows, got {len(df1)} rows"
pd.testing.assert_frame_equal(df, df1)
def _get_optimized_plan(ds: Dataset) -> str:
"""Get the optimized logical plan as a string."""
logical_plan = ds._logical_plan
optimized_plan = LogicalOptimizer().optimize(logical_plan)
return optimized_plan.dag.dag_str
def _check_plan_matches_pattern(ds: Dataset, expected_pattern: str):
"""Check that the optimized plan matches the expected regex pattern."""
actual_plan = _get_optimized_plan(ds)
assert re.match(expected_pattern, actual_plan), (
f"Plan mismatch:\n"
f"Expected pattern: {expected_pattern}\n"
f"Actual plan: {actual_plan}"
)
class TestPredicatePushdownIntoRead:
"""Tests for pushing predicates into Read operators.
When a data source supports predicate pushdown (like Parquet),
the filter should be absorbed into the Read operator itself.
"""
@pytest.fixture
def parquet_ds(self, ray_start_regular_shared):
return ray.data.read_parquet("example://iris.parquet")
def test_complex_pipeline_all_filters_push_to_read(self, parquet_ds):
"""Complex pipeline: filters should push through all operators into Read.
Pipeline: Read -> Filter -> Rename -> Filter -> Sort -> Repartition
-> Filter -> Limit -> Filter
All filters should fuse, push through all operators, rebind through rename,
and be absorbed into the Read operator.
"""
ds = (
parquet_ds.filter(expr=col("sepal.length") > 4.0)
.rename_columns({"sepal.length": "len", "sepal.width": "width"})
.filter(expr=col("len") < 7.0)
.sort("len")
.repartition(3)
.filter(expr=col("width") > 2.5)
.limit(100)
.filter(expr=col("len") > 4.5)
)
# Verify correctness: should apply all filters correctly
expected = (
parquet_ds.filter(
expr=(col("sepal.length") > 4.0)
& (col("sepal.length") < 7.0)
& (col("sepal.width") > 2.5)
& (col("sepal.length") > 4.5)
)
.rename_columns({"sepal.length": "len", "sepal.width": "width"})
.sort("len")
.repartition(3)
.limit(100)
)
assert rows_same(ds.to_pandas(), expected.to_pandas())
# Verify plan: all filters pushed into Read, passthrough ops remain
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert not plan_has_operator(
optimized_plan, Filter
), "No Filter operators should remain after pushdown into Read"
class TestPassthroughBehavior:
"""Tests for PASSTHROUGH behavior operators.
Operators: Sort, Repartition, RandomShuffle, Limit
Predicates pass through unchanged - operators don't affect filtering.
"""
@pytest.fixture
def base_ds(self, ray_start_regular_shared):
return ray.data.range(100)
@pytest.mark.parametrize(
"transform,expected_op_type",
[
(lambda ds: ds.sort("id"), "Sort"),
(lambda ds: ds.repartition(10), "Repartition"),
(
lambda ds: ds.repartition(target_num_rows_per_block=10),
"StreamingRepartition",
),
(lambda ds: ds.random_shuffle(), "RandomShuffle"),
(lambda ds: ds.limit(50), "Limit"),
],
ids=["sort", "repartition", "streaming_repartition", "random_shuffle", "limit"],
)
def test_filter_pushes_through_operator(self, base_ds, transform, expected_op_type):
"""Filter should push through passthrough operators."""
ds = transform(base_ds).filter(expr=col("id") < 10)
# Verify correctness against expected result
expected = base_ds.filter(expr=col("id") < 10)
assert rows_same(ds.to_pandas(), expected.to_pandas())
# Filter pushed down, operator remains
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert plan_has_operator(
optimized_plan, Filter
), "Filter should exist after pushdown"
# Verify the passthrough operator is still present
op_types = get_operator_types(optimized_plan)
assert expected_op_type in op_types, f"{expected_op_type} should remain in plan"
def test_filter_pushes_through_multiple_ops(self, base_ds):
"""Filter should push through multiple passthrough operators."""
ds = base_ds.sort("id").repartition(5).limit(50).filter(expr=col("id") < 10)
# Verify correctness against expected result
expected = base_ds.filter(expr=col("id") < 10)
assert rows_same(ds.to_pandas(), expected.to_pandas())
# Verify plan: filter pushed down, all operators remain
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert plan_has_operator(optimized_plan, Filter), "Filter should exist"
assert plan_has_operator(optimized_plan, Sort), "Sort should remain"
assert plan_has_operator(
optimized_plan, Repartition
), "Repartition should remain"
assert plan_has_operator(optimized_plan, Limit), "Limit should remain"
def test_multiple_filters_fuse_and_push_through(self, base_ds):
"""Multiple filters should fuse and push through passthrough operators."""
ds = base_ds.filter(expr=col("id") > 5).sort("id").filter(expr=col("id") < 20)
# Verify correctness against expected result
expected = base_ds.filter(expr=(col("id") > 5) & (col("id") < 20))
assert rows_same(ds.to_pandas(), expected.to_pandas())
# Verify plan: filters fused and pushed, Sort remains
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
filters = get_operators_of_type(optimized_plan, Filter)
assert len(filters) == 1, "Multiple filters should be fused into one"
assert plan_has_operator(optimized_plan, Sort), "Sort should remain"
assert plan_operator_comes_before(
optimized_plan, Filter, Sort
), "Fused filter should come before Sort"
class TestPassthroughWithSubstitutionBehavior:
"""Tests for PASSTHROUGH_WITH_SUBSTITUTION behavior operators.
Operator: Project (used by rename_columns, select, with_column)
Predicates push through but column names must be rebound.
"""
@pytest.fixture
def parquet_ds(self, ray_start_regular_shared):
return ray.data.read_parquet("example://iris.parquet")
def test_simple_rename_with_filter(self, parquet_ds):
"""Filter after rename should rebind columns and push down."""
ds = parquet_ds.rename_columns({"sepal.length": "len"}).filter(
expr=col("len") > 5.0
)
# Verify correctness against expected result
expected = parquet_ds.filter(expr=col("sepal.length") > 5.0).rename_columns(
{"sepal.length": "len"}
)
assert rows_same(ds.to_pandas(), expected.to_pandas())
# Filter rebound and pushed to Read (no Filter operators should remain)
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert not plan_has_operator(
optimized_plan, Filter
), "Filter should be pushed into Read, no Filter operators should remain"
def test_chained_renames_with_filter(self, parquet_ds):
"""Multiple renames should track through filter pushdown."""
ds = (
parquet_ds.rename_columns({"sepal.length": "a"})
.rename_columns({"a": "b"})
.filter(expr=col("b") > 5.0)
)
# Verify correctness against expected result
expected = (
parquet_ds.filter(expr=col("sepal.length") > 5.0)
.rename_columns({"sepal.length": "a"})
.rename_columns({"a": "b"})
)
assert rows_same(ds.to_pandas(), expected.to_pandas())
# Filter should be pushed into Read after column rebinding
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert not plan_has_operator(
optimized_plan, Filter
), "Filter should be pushed into Read after rebinding through renames"
def test_multiple_filters_with_renames(self, parquet_ds):
"""Multiple filters with renames should all rebind and push."""
ds = (
parquet_ds.rename_columns({"sepal.length": "a"})
.filter(expr=col("a") > 2.0)
.rename_columns({"a": "b"})
.filter(expr=col("b") < 5.0)
)
# Verify correctness against expected result
expected = (
parquet_ds.filter(
expr=(col("sepal.length") > 2.0) & (col("sepal.length") < 5.0)
)
.rename_columns({"sepal.length": "a"})
.rename_columns({"a": "b"})
)
assert rows_same(ds.to_pandas(), expected.to_pandas())
# Multiple filters should be fused, rebound, and pushed into Read
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert not plan_has_operator(
optimized_plan, Filter
), "All filters should be fused, rebound, and pushed into Read"
def test_rename_with_partition_residual_filter(
self, ray_start_regular_shared, tmp_path
):
"""Residual Filter ends up below a rename Project, in original names.
When a predicate mixes partition and data columns under OR, the
unsplittable part is wrapped in a Filter above ReadFiles by
``ReadFiles.apply_predicate``. The read stage never renames
columns (renaming is always carried by a ``Project`` above the
read), so the residual Filter — which sits between the rename
``Project`` and ``ReadFiles`` after predicate pushdown — must
reference the original on-disk column names that the scanner
produces, not the renamed ones the user wrote.
"""
table = pa.table(
{
"partition_col": [1, 1, 2, 2, 3, 3],
"data1": [10, 20, 30, 40, 50, 60],
"data2": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
}
)
pq.write_to_dataset(
table, root_path=str(tmp_path), partition_cols=["partition_col"]
)
ds = (
ray.data.read_parquet(
str(tmp_path),
partitioning=Partitioning("hive", field_types={"partition_col": int}),
)
.rename_columns({"data1": "D1", "data2": "D2"})
.filter(
expr=((col("D1") > 25) | (col("partition_col") == 1))
& (col("D2") < 5.5)
)
)
# Equivalent plan without the rename, to validate row-level correctness.
expected = ray.data.read_parquet(
str(tmp_path),
partitioning=Partitioning("hive", field_types={"partition_col": int}),
).filter(
expr=((col("data1") > 25) | (col("partition_col") == 1))
& (col("data2") < 5.5)
)
# The dataset must execute without binding errors and produce the
# expected rows (with renamed column names).
result = ds.to_pandas().rename(columns={"D1": "data1", "D2": "data2"})
assert rows_same(result, expected.to_pandas())
# A residual Filter should remain below the rename ``Project``,
# and its predicate must reference the original on-disk column
# names (``data1``), not the renamed ones the user wrote.
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
residual_filters = get_operators_of_type(optimized_plan, Filter)
assert (
len(residual_filters) == 1
), f"Expected one residual Filter, got plan: {optimized_plan.dag.dag_str}"
residual_expr_str = str(residual_filters[0].predicate_expr)
assert "data1" in residual_expr_str and "D1" not in residual_expr_str, (
f"Residual Filter predicate should reference original column 'data1', "
f"got: {residual_expr_str}"
)
class TestProjectionWithFilterEdgeCases:
"""Tests for edge cases with select_columns and with_column followed by filters.
These tests verify that filters correctly handle:
- Columns that are kept by select (should push through)
- Columns that are removed by select (should NOT push through)
- Computed columns from with_column (should NOT push through)
"""
@pytest.fixture
def base_ds(self, ray_start_regular_shared):
return ray.data.from_items(
[
{"a": 1, "b": 2, "c": 3},
{"a": 2, "b": 5, "c": 8},
{"a": 3, "b": 6, "c": 9},
]
)
def test_select_then_filter_on_selected_column(self, base_ds):
"""Filter on selected column should push through select."""
ds = base_ds.select_columns(["a", "b"]).filter(expr=col("a") > 1)
# Verify correctness
result_df = ds.to_pandas()
expected_df = pd.DataFrame(
[
{"a": 2, "b": 5},
{"a": 3, "b": 6},
]
)
# Sort columns before comparison
result_df = result_df[sorted(result_df.columns)]
expected_df = expected_df[sorted(expected_df.columns)]
assert rows_same(result_df, expected_df)
# Verify plan: filter pushed through select
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert plan_operator_comes_before(
optimized_plan, Filter, Project
), "Filter should be pushed before Project"
def test_select_then_filter_on_removed_column(self, base_ds):
"""Filter on removed column should fail, not push through."""
ds = base_ds.select_columns(["a"])
with pytest.raises((KeyError, ray.exceptions.RayTaskError)):
ds.filter(expr=col("b") == 2).take_all()
def test_with_column_then_filter_on_computed_column(self, base_ds):
"""Filter on computed column should not push through."""
from ray.data.expressions import lit
ds = base_ds.with_column("d", lit(4)).filter(expr=col("d") == 4)
# Verify correctness - all rows should pass (d is always 4)
result_df = ds.to_pandas()
expected_df = pd.DataFrame(
[
{"a": 1, "b": 2, "c": 3, "d": 4},
{"a": 2, "b": 5, "c": 8, "d": 4},
{"a": 3, "b": 6, "c": 9, "d": 4},
]
)
# Sort columns before comparison
result_df = result_df[sorted(result_df.columns)]
expected_df = expected_df[sorted(expected_df.columns)]
assert rows_same(result_df, expected_df)
# Verify plan: filter should NOT push through (stays after with_column)
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert plan_has_operator(
optimized_plan, Filter
), "Filter should remain (not pushed through)"
def test_rename_then_filter_on_old_column_name(self, base_ds):
"""Filter using old column name after rename should fail."""
ds = base_ds.rename_columns({"b": "B"})
with pytest.raises((KeyError, ray.exceptions.RayTaskError)):
ds.filter(expr=col("b") == 2).take_all()
@pytest.mark.parametrize(
"ds_factory,rename_map,filter_col,filter_value,expected_rows",
[
# In-memory dataset: rename a->b, b->b_old
(
lambda: ray.data.from_items(
[
{"a": 1, "b": 2, "c": 3},
{"a": 2, "b": 5, "c": 8},
{"a": 3, "b": 6, "c": 9},
]
),
{"a": "b", "b": "b_old"},
"b",
1,
[{"b": 2, "b_old": 5, "c": 8}, {"b": 3, "b_old": 6, "c": 9}],
),
# Parquet dataset: rename sepal.length->sepal.width, sepal.width->old_width
(
lambda: ray.data.read_parquet("example://iris.parquet"),
{"sepal.length": "sepal.width", "sepal.width": "old_width"},
"sepal.width",
5.0,
None, # Will verify via alternative computation
),
],
ids=["in_memory", "parquet"],
)
def test_rename_chain_with_name_reuse(
self,
ray_start_regular_shared,
ds_factory,
rename_map,
filter_col,
filter_value,
expected_rows,
):
"""Test rename chains where an output name matches another rename's input name.
This tests the fix for a bug where rename(a->b, b->c) followed by filter(b>5)
would incorrectly block pushdown, even though 'b' is a valid output column
(created by a->b).
Example: rename({'a': 'b', 'b': 'temp'}) creates 'b' from 'a' and 'temp' from 'b'.
A filter on 'b' should be able to push through.
"""
ds = ds_factory()
# Apply rename and filter
ds_renamed_filtered = ds.rename_columns(rename_map).filter(
expr=col(filter_col) > filter_value
)
# Verify correctness
if expected_rows is not None:
# For in-memory, compare against expected rows
result_df = ds_renamed_filtered.to_pandas()
expected_df = pd.DataFrame(expected_rows)
result_df = result_df[sorted(result_df.columns)]
expected_df = expected_df[sorted(expected_df.columns)]
assert rows_same(result_df, expected_df)
else:
# For parquet, compare against alternative computation
# Filter on original column, then rename
original_col = next(k for k, v in rename_map.items() if v == filter_col)
expected = ds.filter(expr=col(original_col) > filter_value).rename_columns(
rename_map
)
assert rows_same(ds_renamed_filtered.to_pandas(), expected.to_pandas())
# Verify plan optimization
optimized_plan = LogicalOptimizer().optimize(ds_renamed_filtered._logical_plan)
# Determine if the data source supports predicate pushdown by checking
# if the filter was completely eliminated (pushed into the read operator)
has_filter = plan_has_operator(optimized_plan, Filter)
has_project = plan_has_operator(optimized_plan, Project)
# Three valid post-optimization shapes:
# 1. ``has_filter=False, has_project=False`` — both pushed into a
# legacy Read (rare; happens when neither rename nor filter
# survives optimization).
# 2. ``has_filter=False, has_project=True`` - file-based reads
# can push the filter into the scan and leave the rename
# ``Project`` above it.
# 3. ``has_filter=True, has_project=True`` — source doesn't
# support predicate pushdown (e.g. in-memory); filter at
# least pushed below the rename ``Project``.
if not has_filter and not has_project:
# Filter was pushed into Read - this is the optimal case
pass
elif not has_filter and has_project:
pass
elif has_filter and has_project:
# For in-memory datasets, filter should at least push through projection
assert plan_operator_comes_before(
optimized_plan, Filter, Project
), "Filter should be pushed before Project after rebinding through rename chain"
else:
# Unexpected state - either filter or project but not both
raise AssertionError(
f"Unexpected optimization state: has_filter={has_filter}, has_project={has_project}"
)
class TestPyArrowComputeUDFPushdown:
"""Tests for predicate pushdown of PyArrow-compute-backed UDF expressions.
String namespace methods (str.match_regex, str.starts_with, etc.) and
numeric helpers (ceil, abs, etc.) produce PyArrowComputeUDFExpr nodes
that should be pushed into Read operators just like plain comparison
expressions.
"""
@pytest.fixture
def parquet_ds(self, ray_start_regular_shared):
return ray.data.read_parquet("example://iris.parquet")
@pytest.mark.parametrize(
"build_filter,equivalent_fn",
[
pytest.param(
lambda: ~col("variety").str.match_regex("Set.*"),
lambda r: not bool(re.search("Set.*", r["variety"])),
id="negated_match_regex",
),
pytest.param(
lambda: col("variety").str.starts_with("Vir"),
lambda r: r["variety"].startswith("Vir"),
id="starts_with",
),
pytest.param(
lambda: col("variety").str.contains("set"),
lambda r: "set" in r["variety"],
id="contains",
),
],
)
@pytest.mark.skipif(
version_parse(pa.__version__) < version_parse("15.0.0"),
reason="Requires PyArrow >= 15 for string compute UDF pushdown",
)
def test_string_udf_pushdown_into_parquet(
self, parquet_ds, build_filter, equivalent_fn
):
"""String UDF predicates should be pushed into the Parquet reader."""
ds = parquet_ds.filter(expr=build_filter())
expected = parquet_ds.filter(fn=equivalent_fn)
assert rows_same(ds.to_pandas(), expected.to_pandas())
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert not plan_has_operator(
optimized_plan, Filter
), "PyArrow-compute UDF filter should be pushed into Read"
@pytest.mark.skipif(
version_parse(pa.__version__) < version_parse("15.0.0"),
reason="Requires PyArrow >= 15 for string compute UDF pushdown",
)
def test_udf_combined_with_comparison_pushdown(self, parquet_ds):
"""UDF predicate combined with comparison should both push down."""
ds = parquet_ds.filter(
expr=col("variety").str.starts_with("Vir") & (col("sepal.length") > 6.0)
)
expected = parquet_ds.filter(
fn=lambda r: r["variety"].startswith("Vir") and r["sepal.length"] > 6.0
)
assert rows_same(ds.to_pandas(), expected.to_pandas())
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert not plan_has_operator(
optimized_plan, Filter
), "Combined UDF + comparison filter should be pushed into Read"
@pytest.mark.skipif(
version_parse(pa.__version__) < version_parse("15.0.0"),
reason="Requires PyArrow >= 15 for string compute UDF pushdown",
)
def test_chained_udf_filters_fuse_and_push(self, parquet_ds):
"""Multiple UDF filters should fuse and push into Read."""
ds = parquet_ds.filter(expr=col("variety").str.contains("set")).filter(
expr=col("sepal.length") > 5.0
)
expected = parquet_ds.filter(
fn=lambda r: "set" in r["variety"] and r["sepal.length"] > 5.0
)
assert rows_same(ds.to_pandas(), expected.to_pandas())
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert not plan_has_operator(
optimized_plan, Filter
), "Chained UDF filters should fuse and push into Read"
@pytest.mark.skipif(
version_parse(pa.__version__) < version_parse("15.0.0"),
reason="Requires PyArrow >= 15 for complex type UDF pushdown",
)
@pytest.mark.parametrize(
"table,build_filter,equivalent_fn",
[
pytest.param(
pa.table(
{
"tags": [[1, 2, 3], [4, 5], [6], [7, 8, 9, 10]],
"value": [10, 20, 30, 40],
}
),
lambda: col("tags").list.len() > 2,
lambda r: len(r["tags"]) > 2,
id="list_len",
),
pytest.param(
pa.table(
{
"info": pa.array(
[
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25},
{"name": "Carol", "age": 35},
{"name": "Dave", "age": 20},
],
type=pa.struct(
[
pa.field("name", pa.string()),
pa.field("age", pa.int64()),
]
),
),
"id": [1, 2, 3, 4],
}
),
lambda: col("info").struct.field("age") > 25,
lambda r: r["info"]["age"] > 25,
id="struct_field",
),
],
)
def test_complex_type_udf_pushdown(
self, ray_start_regular_shared, tmp_path, table, build_filter, equivalent_fn
):
"""List/struct UDF predicates should be pushed into the Parquet reader."""
path = str(tmp_path / "data.parquet")
pq.write_table(table, path)
ds = ray.data.read_parquet(path).filter(expr=build_filter())
expected = ray.data.read_parquet(path).filter(fn=equivalent_fn)
assert rows_same(ds.to_pandas(), expected.to_pandas())
optimized_plan = LogicalOptimizer().optimize(ds._logical_plan)
assert not plan_has_operator(
optimized_plan, Filter
), "Complex-type UDF filter should be pushed into Read"
class TestPushIntoBranchesBehavior:
"""Tests for PUSH_INTO_BRANCHES behavior operators.
Operator: Union
Predicates are duplicated and pushed into each branch.
"""
def test_simple_union_with_filter(self, ray_start_regular_shared):
"""Filter after union should push into both branches."""
ds1 = ray.data.range(100, parallelism=2)
ds2 = ray.data.range(100, parallelism=2)
ds = ds1.union(ds2).filter(expr=col("id") >= 50)
# Verify correctness: should have duplicates from both branches
base = ray.data.range(100)
expected = base.filter(expr=col("id") >= 50).union(
base.filter(expr=col("id") >= 50)
)
assert rows_same(ds.to_pandas(), expected.to_pandas())
def test_multiple_unions_with_filter(self, ray_start_regular_shared):
"""Filter should push into all branches of multiple unions."""
ds1 = ray.data.read_parquet("example://iris.parquet")
ds2 = ray.data.read_parquet("example://iris.parquet")
ds3 = ray.data.read_parquet("example://iris.parquet")
ds = ds1.union(ds2).union(ds3).filter(expr=col("sepal.length") > 5.0)
# Verify correctness: should have 3x the filtered results
single_filtered = ray.data.read_parquet("example://iris.parquet").filter(
expr=col("sepal.length") > 5.0
)
expected = single_filtered.union(single_filtered).union(single_filtered)
assert rows_same(ds.to_pandas(), expected.to_pandas())
def test_branch_filters_plus_union_filter(self, ray_start_regular_shared):
"""Individual branch filters plus union filter should all push."""
parquet_ds = ray.data.read_parquet("example://iris.parquet")
ds1 = parquet_ds.filter(expr=col("sepal.width") > 2.0)
ds2 = parquet_ds.filter(expr=col("sepal.width") > 2.0)
ds = ds1.union(ds2).filter(expr=col("sepal.length") < 5.0)
# Verify correctness: both filters applied
expected_single = parquet_ds.filter(
expr=(col("sepal.width") > 2.0) & (col("sepal.length") < 5.0)
)
expected = expected_single.union(expected_single)
assert rows_same(ds.to_pandas(), expected.to_pandas())
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))