430 lines
14 KiB
Python
430 lines
14 KiB
Python
import numpy as np
|
|
import pandas as pd
|
|
import pyarrow as pa
|
|
import pyarrow.parquet as pq
|
|
import pytest
|
|
from pkg_resources import parse_version
|
|
|
|
from ray.data._internal.logical.operators import CSE_TEMP_COLUMN_PREFIX
|
|
from ray.data._internal.planner.plan_expression.expression_evaluator import (
|
|
ExpressionEvaluator,
|
|
eval_projection,
|
|
)
|
|
from ray.data.expressions import col, star
|
|
from ray.data.tests.conftest import get_pyarrow_version
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def sample_data(tmpdir_factory):
|
|
"""Fixture to create and yield sample Parquet data, and clean up afterwards."""
|
|
# Sample data for testing purposes
|
|
data = {
|
|
"age": [
|
|
25,
|
|
32,
|
|
45,
|
|
29,
|
|
40,
|
|
np.nan,
|
|
], # List of ages, including a None value for testing
|
|
"city": [
|
|
"New York",
|
|
"San Francisco",
|
|
"Los Angeles",
|
|
"Los Angeles",
|
|
"San Francisco",
|
|
"San Jose",
|
|
],
|
|
"is_student": [False, True, False, False, True, None], # Including a None value
|
|
}
|
|
|
|
# Define the schema explicitly
|
|
schema = pa.schema(
|
|
[("age", pa.float64()), ("city", pa.string()), ("is_student", pa.bool_())]
|
|
)
|
|
|
|
# Create a PyArrow table from the sample data
|
|
table = pa.table(data, schema=schema)
|
|
|
|
# Use tmpdir_factory to create a temporary directory
|
|
temp_dir = tmpdir_factory.mktemp("data")
|
|
parquet_file = temp_dir.join("sample_data.parquet")
|
|
|
|
# Write the table to a Parquet file in the temporary directory
|
|
pq.write_table(table, str(parquet_file))
|
|
|
|
# Yield the path to the Parquet file for testing
|
|
yield str(parquet_file), schema
|
|
|
|
|
|
expressions_and_expected_data = [
|
|
# Parameterized test cases with expressions and their expected results
|
|
# Comparison Ops
|
|
(
|
|
"40 > age",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
],
|
|
),
|
|
(
|
|
"40 >= age",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
(
|
|
"30 < age",
|
|
[
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
(
|
|
"age >= 30",
|
|
[
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
(
|
|
"age == 40",
|
|
[{"age": 40, "city": "San Francisco", "is_student": True}],
|
|
),
|
|
(
|
|
"is_student != True",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
],
|
|
),
|
|
(
|
|
"age < 0",
|
|
[],
|
|
),
|
|
(
|
|
"is_student == True",
|
|
[
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
(
|
|
"is_student == False",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
],
|
|
),
|
|
# Op 'in'
|
|
(
|
|
"city in ['Los Angeles', 'New York']",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
],
|
|
),
|
|
(
|
|
"city in ['Los Angeles']",
|
|
[
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
],
|
|
),
|
|
(
|
|
"city in ['New York', 'San Francisco']",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
(
|
|
"age in []",
|
|
[],
|
|
),
|
|
(
|
|
"age in [25, 32, 45, 29, 40]",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
(
|
|
"age in [25, 32, None]",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
# Op 'not in'
|
|
(
|
|
"is_student not in [None]",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
(
|
|
"is_student not in [True, None]",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
],
|
|
),
|
|
# Logical Ops 'and'
|
|
(
|
|
"age > 30 and is_student == True",
|
|
[
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
(
|
|
"city == 'Los Angeles' and age < 40",
|
|
[
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
],
|
|
),
|
|
(
|
|
"age < 40 and city in ['New York', 'Los Angeles']",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
],
|
|
),
|
|
# Logical Ops 'or'
|
|
(
|
|
"age < 30 or is_student == True",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
(
|
|
"city == 'New York' or city == 'San Francisco'",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
(
|
|
"age < 30 or age > 40",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
],
|
|
),
|
|
# Logical Ops combination 'and' and 'or'
|
|
(
|
|
"(age < 30 or age > 40) and is_student != True",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
],
|
|
),
|
|
# Op 'is_null'
|
|
(
|
|
"is_null(is_student)",
|
|
[
|
|
{"age": None, "city": "San Jose", "is_student": None},
|
|
],
|
|
),
|
|
(
|
|
"age < 40 or is_null(is_student)",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
{"age": None, "city": "San Jose", "is_student": None},
|
|
],
|
|
),
|
|
# Op 'is_nan'
|
|
(
|
|
"is_nan(age)",
|
|
[
|
|
{"age": None, "city": "San Jose", "is_student": None},
|
|
],
|
|
),
|
|
(
|
|
"city in ['San Jose', 'Los Angeles'] and is_nan(age)",
|
|
[
|
|
{"age": None, "city": "San Jose", "is_student": None},
|
|
],
|
|
),
|
|
# Op 'is_valid'
|
|
(
|
|
"is_valid(is_student)",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
(
|
|
"is_valid(is_student) and is_valid(age)",
|
|
[
|
|
{"age": 25, "city": "New York", "is_student": False},
|
|
{"age": 32, "city": "San Francisco", "is_student": True},
|
|
{"age": 45, "city": "Los Angeles", "is_student": False},
|
|
{"age": 29, "city": "Los Angeles", "is_student": False},
|
|
{"age": 40, "city": "San Francisco", "is_student": True},
|
|
],
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
get_pyarrow_version() < parse_version("20.0.0"),
|
|
reason="test_filter requires PyArrow >= 20.0.0",
|
|
)
|
|
@pytest.mark.parametrize("expression, expected_data", expressions_and_expected_data)
|
|
def test_filter(sample_data, expression, expected_data):
|
|
"""Test the filter functionality of the ExpressionEvaluator."""
|
|
|
|
# Instantiate the ExpressionEvaluator with valid column names
|
|
sample_data_path, _ = sample_data
|
|
filters = ExpressionEvaluator.get_filters(expression=expression)
|
|
|
|
# Read the table from the Parquet file with the applied filters
|
|
filtered_table = pq.read_table(sample_data_path, filters=filters)
|
|
|
|
# Convert the filtered table back to a list of dictionaries for comparison
|
|
result = filtered_table.to_pandas().to_dict(orient="records")
|
|
|
|
def convert_nan_to_none(data):
|
|
return [
|
|
{k: (None if pd.isna(v) else v) for k, v in record.items()}
|
|
for record in data
|
|
]
|
|
|
|
# Convert NaN to None for comparison
|
|
result_converted = convert_nan_to_none(result)
|
|
|
|
assert result_converted == expected_data
|
|
|
|
|
|
def test_filter_equal_negative_number():
|
|
df = pd.DataFrame.from_dict(
|
|
{"A": [-1, -1, 1, 2, -1, 3, 4, 5], "B": [-1, -1, 1, 2, -1, 3, 4, 5]}
|
|
)
|
|
expression = ExpressionEvaluator.get_filters(expression="A == -1")
|
|
result = pa.table(df).filter(expression)
|
|
result_df = result.to_pandas().to_dict(orient="records")
|
|
expected = df[df["A"] == -1].to_dict(orient="records")
|
|
assert result_df == expected
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
get_pyarrow_version() < parse_version("20.0.0"),
|
|
reason="test_filter requires PyArrow >= 20.0.0",
|
|
)
|
|
def test_filter_bad_expression(sample_data):
|
|
with pytest.raises(ValueError, match="Invalid syntax in the expression"):
|
|
ExpressionEvaluator.get_filters(expression="bad filter")
|
|
|
|
filters = ExpressionEvaluator.get_filters(expression="hi > 3")
|
|
|
|
sample_data_path, _ = sample_data
|
|
with pytest.raises(pa.ArrowInvalid):
|
|
pq.read_table(sample_data_path, filters=filters)
|
|
|
|
|
|
def test_eval_projection_star_rename_missing_source_raises():
|
|
"""A rename targeting a column not present in the block must raise rather
|
|
than be silently dropped during star expansion."""
|
|
block = pa.table({"a": [1, 2, 3], "b": [4, 5, 6]})
|
|
projection = [star(), col("nonexistent")._rename("x")]
|
|
|
|
with pytest.raises(KeyError):
|
|
eval_projection(projection, block)
|
|
|
|
|
|
def test_eval_projection_with_common_sub_exprs_arrow():
|
|
block = pa.table({"a": [1, 2, 3]})
|
|
common = (col("a") + 1).alias(f"{CSE_TEMP_COLUMN_PREFIX}test_0")
|
|
projection = [
|
|
(
|
|
col(f"{CSE_TEMP_COLUMN_PREFIX}test_0")
|
|
+ col(f"{CSE_TEMP_COLUMN_PREFIX}test_0")
|
|
).alias("y")
|
|
]
|
|
|
|
out = eval_projection(
|
|
projection,
|
|
block,
|
|
common_sub_exprs=[common],
|
|
)
|
|
|
|
assert out.column_names == ["y"]
|
|
assert out["y"].to_pylist() == [4, 6, 8]
|
|
|
|
|
|
def test_eval_projection_cse_temp_columns_do_not_leak_with_star():
|
|
block = pa.table({"a": [1, 2, 3]})
|
|
common = (col("a") + 1).alias(f"{CSE_TEMP_COLUMN_PREFIX}test_0")
|
|
|
|
out = eval_projection(
|
|
[star(), col(f"{CSE_TEMP_COLUMN_PREFIX}test_0").alias("y")],
|
|
block,
|
|
common_sub_exprs=[common],
|
|
)
|
|
|
|
assert out.column_names == ["a", "y"]
|
|
assert out["y"].to_pylist() == [2, 3, 4]
|
|
|
|
|
|
def test_eval_projection_preserves_reserved_prefix_without_cse():
|
|
block = pa.table({f"{CSE_TEMP_COLUMN_PREFIX}user": [1, 2]})
|
|
out = eval_projection([star()], block)
|
|
assert out.column_names == [f"{CSE_TEMP_COLUMN_PREFIX}user"]
|
|
|
|
|
|
def test_eval_projection_with_common_sub_exprs_pandas():
|
|
block = pd.DataFrame({"a": [1, 2, 3]})
|
|
common = (col("a") + 1).alias(f"{CSE_TEMP_COLUMN_PREFIX}test_0")
|
|
projection = [
|
|
(
|
|
col(f"{CSE_TEMP_COLUMN_PREFIX}test_0")
|
|
+ col(f"{CSE_TEMP_COLUMN_PREFIX}test_0")
|
|
).alias("y")
|
|
]
|
|
|
|
out = eval_projection(
|
|
projection,
|
|
block,
|
|
common_sub_exprs=[common],
|
|
)
|
|
|
|
assert out.columns.tolist() == ["y"]
|
|
assert out["y"].tolist() == [4, 6, 8]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
sys.exit(pytest.main(["-v", __file__]))
|