chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,526 @@
|
||||
"""Tests for arithmetic expression operations.
|
||||
|
||||
This module tests:
|
||||
- Basic arithmetic: ADD, SUB, MUL, DIV, FLOORDIV
|
||||
- Reverse arithmetic: radd, rsub, rmul, rtruediv, rfloordiv
|
||||
- Rounding helpers: ceil, floor, round, trunc
|
||||
- Logarithmic helpers: ln, log10, log2, exp
|
||||
- Trigonometric helpers: sin, cos, tan, asin, acos, atan
|
||||
- Arithmetic helpers: negate, sign, power, abs
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
from pkg_resources import parse_version
|
||||
|
||||
from ray.data._internal.planner.plan_expression.expression_evaluator import eval_expr
|
||||
from ray.data.expressions import BinaryExpr, Operation, UDFExpr, col, lit
|
||||
from ray.data.tests.conftest import get_pyarrow_version
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
get_pyarrow_version() < parse_version("20.0.0"),
|
||||
reason="Expression unit tests require PyArrow >= 20.0.0",
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Basic Arithmetic Operations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestBasicArithmetic:
|
||||
"""Tests for basic arithmetic operations (+, -, *, /, //)."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for arithmetic tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"a": [10, 20, 30, 40],
|
||||
"b": [2, 4, 5, 8],
|
||||
"c": [1.5, 2.5, 3.5, 4.5],
|
||||
}
|
||||
)
|
||||
|
||||
# ── Addition ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_name,expected_values",
|
||||
[
|
||||
(col("a") + 5, "add_literal", [15, 25, 35, 45]),
|
||||
(col("a") + col("b"), "add_cols", [12, 24, 35, 48]),
|
||||
(col("a") + lit(10), "add_lit", [20, 30, 40, 50]),
|
||||
],
|
||||
ids=["col_plus_int", "col_plus_col", "col_plus_lit"],
|
||||
)
|
||||
def test_addition(self, sample_data, expr, expected_name, expected_values):
|
||||
"""Test addition operations."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.ADD
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values, name=None),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
def test_reverse_addition(self, sample_data):
|
||||
"""Test reverse addition (literal + expr)."""
|
||||
expr = 5 + col("a")
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.ADD
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([15, 25, 35, 45])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_string_concat_invalid_input_type(self):
|
||||
"""Reject non-string-like inputs in string concatenation."""
|
||||
table = pa.table({"name": ["a", "b"], "age": [1, 2]})
|
||||
expr = col("name") + col("age")
|
||||
with pytest.raises(TypeError, match="string-like pyarrow.*int64"):
|
||||
eval_expr(expr, table)
|
||||
|
||||
# ── Subtraction ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_values",
|
||||
[
|
||||
(col("a") - 5, [5, 15, 25, 35]),
|
||||
(col("a") - col("b"), [8, 16, 25, 32]),
|
||||
],
|
||||
ids=["col_minus_int", "col_minus_col"],
|
||||
)
|
||||
def test_subtraction(self, sample_data, expr, expected_values):
|
||||
"""Test subtraction operations."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.SUB
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values, name=None),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
def test_reverse_subtraction(self, sample_data):
|
||||
"""Test reverse subtraction (literal - expr)."""
|
||||
expr = 100 - col("a")
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.SUB
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([90, 80, 70, 60])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
# ── Multiplication ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_values",
|
||||
[
|
||||
(col("a") * 2, [20, 40, 60, 80]),
|
||||
(col("a") * col("b"), [20, 80, 150, 320]),
|
||||
],
|
||||
ids=["col_times_int", "col_times_col"],
|
||||
)
|
||||
def test_multiplication(self, sample_data, expr, expected_values):
|
||||
"""Test multiplication operations."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.MUL
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values, name=None),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
def test_reverse_multiplication(self, sample_data):
|
||||
"""Test reverse multiplication (literal * expr)."""
|
||||
expr = 3 * col("b")
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.MUL
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([6, 12, 15, 24])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
# ── Division ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_values",
|
||||
[
|
||||
(col("a") / 2, [5.0, 10.0, 15.0, 20.0]),
|
||||
(col("a") / col("b"), [5.0, 5.0, 6.0, 5.0]),
|
||||
],
|
||||
ids=["col_div_int", "col_div_col"],
|
||||
)
|
||||
def test_division(self, sample_data, expr, expected_values):
|
||||
"""Test division operations."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.DIV
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values, name=None),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
def test_reverse_division(self, sample_data):
|
||||
"""Test reverse division (literal / expr)."""
|
||||
expr = 100 / col("a")
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.DIV
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([10.0, 5.0, 100 / 30, 2.5])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
# ── Floor Division ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_values",
|
||||
[
|
||||
(col("a") // 3, [3, 6, 10, 13]),
|
||||
(col("a") // col("b"), [5, 5, 6, 5]),
|
||||
],
|
||||
ids=["col_floordiv_int", "col_floordiv_col"],
|
||||
)
|
||||
def test_floor_division(self, sample_data, expr, expected_values):
|
||||
"""Test floor division operations."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.FLOORDIV
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values, name=None),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
def test_reverse_floor_division(self, sample_data):
|
||||
"""Test reverse floor division (literal // expr)."""
|
||||
expr = 100 // col("a")
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.FLOORDIV
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([10, 5, 3, 2])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
# ── Modulo ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_values",
|
||||
[
|
||||
(col("a") % 3, [1, 2, 0, 1]),
|
||||
(col("a") % col("c"), [1.0, 0.0, 2.0, 4.0]),
|
||||
(10 % col("b"), [0, 2, 0, 2]),
|
||||
],
|
||||
ids=["col_mod_int", "col_mod_fp", "col_rmod_int"],
|
||||
)
|
||||
def test_modulo(self, sample_data, expr, expected_values):
|
||||
"""Test modulo operations."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.MOD
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values, name=None),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Complex Arithmetic Expressions
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestComplexArithmetic:
|
||||
"""Tests for complex arithmetic expressions with multiple operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for complex arithmetic tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"x": [1.0, 2.0, 3.0, 4.0],
|
||||
"y": [4.0, 3.0, 2.0, 1.0],
|
||||
"z": [2.0, 2.0, 2.0, 2.0],
|
||||
}
|
||||
)
|
||||
|
||||
def test_chained_operations(self, sample_data):
|
||||
"""Test chained arithmetic operations."""
|
||||
expr = (col("x") + col("y")) * col("z")
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([10.0, 10.0, 10.0, 10.0])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_nested_operations(self, sample_data):
|
||||
"""Test nested arithmetic operations."""
|
||||
expr = ((col("x") * 2) + (col("y") / 2)) - 1
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([3.0, 4.5, 6.0, 7.5])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_order_of_operations(self, sample_data):
|
||||
"""Test that order of operations is respected."""
|
||||
# Should compute x + (y * z) due to operator precedence
|
||||
expr = col("x") + col("y") * col("z")
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([9.0, 8.0, 7.0, 6.0]) # 1+8, 2+6, 3+4, 4+2
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Rounding Operations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestRoundingOperations:
|
||||
"""Tests for rounding helper methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data with decimal values for rounding tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"value": [1.2, 2.5, 3.7, -1.3, -2.5, -3.8],
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,expected_values",
|
||||
[
|
||||
("ceil", [2, 3, 4, -1, -2, -3]),
|
||||
("floor", [1, 2, 3, -2, -3, -4]),
|
||||
("trunc", [1, 2, 3, -1, -2, -3]),
|
||||
],
|
||||
ids=["ceil", "floor", "trunc"],
|
||||
)
|
||||
def test_rounding_methods(self, sample_data, method, expected_values):
|
||||
"""Test rounding methods (ceil, floor, trunc)."""
|
||||
expr = getattr(col("value"), method)()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, sample_data)
|
||||
# Convert to list for comparison since PyArrow might return different types
|
||||
result_list = result.tolist()
|
||||
assert result_list == expected_values
|
||||
|
||||
def test_round_method(self, sample_data):
|
||||
"""Test round method (may differ due to banker's rounding)."""
|
||||
expr = col("value").round()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, sample_data)
|
||||
# PyArrow uses banker's rounding (round half to even)
|
||||
# Just verify it runs and returns numeric values
|
||||
assert len(result) == len(sample_data)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Logarithmic Operations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestLogarithmicOperations:
|
||||
"""Tests for logarithmic helper methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data with positive values for logarithmic tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"value": [1.0, math.e, 10.0, 100.0],
|
||||
}
|
||||
)
|
||||
|
||||
def test_ln(self, sample_data):
|
||||
"""Test natural logarithm."""
|
||||
expr = col("value").ln()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = [0.0, 1.0, math.log(10), math.log(100)]
|
||||
for r, e in zip(result.tolist(), expected):
|
||||
assert abs(r - e) < 1e-10
|
||||
|
||||
def test_log10(self, sample_data):
|
||||
"""Test base-10 logarithm."""
|
||||
expr = col("value").log10()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = [0.0, math.log10(math.e), 1.0, 2.0]
|
||||
for r, e in zip(result.tolist(), expected):
|
||||
assert abs(r - e) < 1e-10
|
||||
|
||||
def test_log2(self):
|
||||
"""Test base-2 logarithm."""
|
||||
data = pd.DataFrame({"value": [1.0, 2.0, 4.0, 8.0]})
|
||||
expr = col("value").log2()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, data)
|
||||
expected = [0.0, 1.0, 2.0, 3.0]
|
||||
for r, e in zip(result.tolist(), expected):
|
||||
assert abs(r - e) < 1e-10
|
||||
|
||||
def test_exp(self):
|
||||
"""Test exponential function."""
|
||||
data = pd.DataFrame({"value": [0.0, 1.0, 2.0]})
|
||||
expr = col("value").exp()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, data)
|
||||
expected = [1.0, math.e, math.e**2]
|
||||
for r, e in zip(result.tolist(), expected):
|
||||
assert abs(r - e) < 1e-10
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Trigonometric Operations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrigonometricOperations:
|
||||
"""Tests for trigonometric helper methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data with angles in radians for trig tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"angle": [0.0, math.pi / 6, math.pi / 4, math.pi / 3, math.pi / 2],
|
||||
}
|
||||
)
|
||||
|
||||
def test_sin(self, sample_data):
|
||||
"""Test sine function."""
|
||||
expr = col("angle").sin()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = [0.0, 0.5, math.sqrt(2) / 2, math.sqrt(3) / 2, 1.0]
|
||||
for r, e in zip(result.tolist(), expected):
|
||||
assert abs(r - e) < 1e-10
|
||||
|
||||
def test_cos(self, sample_data):
|
||||
"""Test cosine function."""
|
||||
expr = col("angle").cos()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = [1.0, math.sqrt(3) / 2, math.sqrt(2) / 2, 0.5, 0.0]
|
||||
for r, e in zip(result.tolist(), expected):
|
||||
assert abs(r - e) < 1e-10
|
||||
|
||||
def test_tan(self):
|
||||
"""Test tangent function."""
|
||||
data = pd.DataFrame({"angle": [0.0, math.pi / 4]})
|
||||
expr = col("angle").tan()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, data)
|
||||
expected = [0.0, 1.0]
|
||||
for r, e in zip(result.tolist(), expected):
|
||||
assert abs(r - e) < 1e-10
|
||||
|
||||
def test_asin(self):
|
||||
"""Test arcsine function."""
|
||||
data = pd.DataFrame({"value": [0.0, 0.5, 1.0]})
|
||||
expr = col("value").asin()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, data)
|
||||
expected = [0.0, math.pi / 6, math.pi / 2]
|
||||
for r, e in zip(result.tolist(), expected):
|
||||
assert abs(r - e) < 1e-10
|
||||
|
||||
def test_acos(self):
|
||||
"""Test arccosine function."""
|
||||
data = pd.DataFrame({"value": [1.0, 0.5, 0.0]})
|
||||
expr = col("value").acos()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, data)
|
||||
expected = [0.0, math.pi / 3, math.pi / 2]
|
||||
for r, e in zip(result.tolist(), expected):
|
||||
assert abs(r - e) < 1e-10
|
||||
|
||||
def test_atan(self):
|
||||
"""Test arctangent function."""
|
||||
data = pd.DataFrame({"value": [0.0, 1.0]})
|
||||
expr = col("value").atan()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, data)
|
||||
expected = [0.0, math.pi / 4]
|
||||
for r, e in zip(result.tolist(), expected):
|
||||
assert abs(r - e) < 1e-10
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Arithmetic Helper Operations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestArithmeticHelpers:
|
||||
"""Tests for arithmetic helper methods (negate, sign, power, abs)."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for arithmetic helper tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"value": [5, -3, 0, 10, -7],
|
||||
}
|
||||
)
|
||||
|
||||
def test_negate(self, sample_data):
|
||||
"""Test negate method."""
|
||||
expr = col("value").negate()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = [-5, 3, 0, -10, 7]
|
||||
assert result.tolist() == expected
|
||||
|
||||
def test_sign(self, sample_data):
|
||||
"""Test sign method."""
|
||||
expr = col("value").sign()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = [1, -1, 0, 1, -1]
|
||||
assert result.tolist() == expected
|
||||
|
||||
def test_abs(self, sample_data):
|
||||
"""Test abs method."""
|
||||
expr = col("value").abs()
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = [5, 3, 0, 10, 7]
|
||||
assert result.tolist() == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_values,exponent,expected",
|
||||
[
|
||||
([2, 3, 4], 2, [4, 9, 16]),
|
||||
([2, 3, 4], 3, [8, 27, 64]),
|
||||
([4, 9, 16], 0.5, [2.0, 3.0, 4.0]),
|
||||
],
|
||||
ids=["square", "cube", "sqrt"],
|
||||
)
|
||||
def test_power(self, base_values, exponent, expected):
|
||||
"""Test power method with various exponents."""
|
||||
data = pd.DataFrame({"value": base_values})
|
||||
expr = col("value").power(exponent)
|
||||
assert isinstance(expr, UDFExpr)
|
||||
result = eval_expr(expr, data)
|
||||
for r, e in zip(result.tolist(), expected):
|
||||
assert abs(r - e) < 1e-10
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Tests for boolean/logical expression operations.
|
||||
|
||||
This module tests:
|
||||
- Logical operators: AND (&), OR (|), NOT (~)
|
||||
- Boolean expression combinations
|
||||
- Complex nested boolean expressions
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from ray.data._internal.planner.plan_expression.expression_evaluator import eval_expr
|
||||
from ray.data.expressions import BinaryExpr, Operation, UnaryExpr, col, lit
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Logical AND Operations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestLogicalAnd:
|
||||
"""Tests for logical AND (&) operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for logical AND tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"is_active": [True, True, False, False],
|
||||
"is_verified": [True, False, True, False],
|
||||
"age": [25, 17, 30, 15],
|
||||
}
|
||||
)
|
||||
|
||||
def test_and_two_booleans(self, sample_data):
|
||||
"""Test AND of two boolean columns."""
|
||||
expr = col("is_active") & col("is_verified")
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.AND
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, False, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_and_two_comparisons(self, sample_data):
|
||||
"""Test AND of two comparison expressions."""
|
||||
expr = (col("is_active") == lit(True)) & (col("age") >= 18)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, False, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_and_chained(self, sample_data):
|
||||
"""Test chained AND operations."""
|
||||
expr = (col("is_active")) & (col("is_verified")) & (col("age") >= 18)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, False, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Logical OR Operations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestLogicalOr:
|
||||
"""Tests for logical OR (|) operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for logical OR tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"is_admin": [True, False, False, False],
|
||||
"is_moderator": [False, True, False, False],
|
||||
"age": [25, 17, 30, 15],
|
||||
}
|
||||
)
|
||||
|
||||
def test_or_two_booleans(self, sample_data):
|
||||
"""Test OR of two boolean columns."""
|
||||
expr = col("is_admin") | col("is_moderator")
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.OR
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, True, False, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_or_two_comparisons(self, sample_data):
|
||||
"""Test OR of two comparison expressions."""
|
||||
expr = (col("is_admin") == lit(True)) | (col("age") >= 18)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_or_chained(self, sample_data):
|
||||
"""Test chained OR operations."""
|
||||
expr = (col("is_admin")) | (col("is_moderator")) | (col("age") >= 21)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, True, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Logical NOT Operations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestLogicalNot:
|
||||
"""Tests for logical NOT (~) operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for logical NOT tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"is_active": [True, False, True, False],
|
||||
"is_banned": [False, False, True, True],
|
||||
"age": [25, 17, 30, 15],
|
||||
}
|
||||
)
|
||||
|
||||
def test_not_boolean_column(self, sample_data):
|
||||
"""Test NOT of a boolean column."""
|
||||
expr = ~col("is_active")
|
||||
assert isinstance(expr, UnaryExpr)
|
||||
assert expr.op == Operation.NOT
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, True, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_not_comparison(self, sample_data):
|
||||
"""Test NOT of a comparison expression."""
|
||||
expr = ~(col("age") >= 18)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, True, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_double_negation(self, sample_data):
|
||||
"""Test double negation (~~)."""
|
||||
expr = ~~col("is_active")
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Complex Boolean Combinations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestComplexBooleanExpressions:
|
||||
"""Tests for complex boolean expression combinations."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for complex boolean tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"age": [17, 21, 25, 30, 65],
|
||||
"is_student": [True, True, False, False, False],
|
||||
"is_member": [False, True, True, False, True],
|
||||
"country": ["USA", "UK", "USA", "Canada", "USA"],
|
||||
}
|
||||
)
|
||||
|
||||
def test_and_or_combination(self, sample_data):
|
||||
"""Test combination of AND and OR."""
|
||||
# (age >= 21) AND (is_student OR is_member)
|
||||
expr = (col("age") >= 21) & (col("is_student") | col("is_member"))
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, True, True, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_not_with_and_or(self, sample_data):
|
||||
"""Test NOT combined with AND and OR."""
|
||||
# NOT(age < 18) AND (is_member OR is_student)
|
||||
expr = ~(col("age") < 18) & (col("is_member") | col("is_student"))
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, True, True, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_demorgans_law_and(self, sample_data):
|
||||
"""Test De Morgan's law: ~(A & B) == (~A) | (~B)."""
|
||||
# ~(is_student & is_member)
|
||||
expr1 = ~(col("is_student") & col("is_member"))
|
||||
# (~is_student) | (~is_member)
|
||||
expr2 = (~col("is_student")) | (~col("is_member"))
|
||||
|
||||
result1 = eval_expr(expr1, sample_data)
|
||||
result2 = eval_expr(expr2, sample_data)
|
||||
|
||||
pd.testing.assert_series_equal(
|
||||
result1.reset_index(drop=True),
|
||||
result2.reset_index(drop=True),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
def test_demorgans_law_or(self, sample_data):
|
||||
"""Test De Morgan's law: ~(A | B) == (~A) & (~B)."""
|
||||
# ~(is_student | is_member)
|
||||
expr1 = ~(col("is_student") | col("is_member"))
|
||||
# (~is_student) & (~is_member)
|
||||
expr2 = (~col("is_student")) & (~col("is_member"))
|
||||
|
||||
result1 = eval_expr(expr1, sample_data)
|
||||
result2 = eval_expr(expr2, sample_data)
|
||||
|
||||
pd.testing.assert_series_equal(
|
||||
result1.reset_index(drop=True),
|
||||
result2.reset_index(drop=True),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
def test_deeply_nested_boolean(self, sample_data):
|
||||
"""Test deeply nested boolean expression."""
|
||||
# ((age >= 21) & (country == "USA")) | ((is_student) & (is_member))
|
||||
expr = ((col("age") >= 21) & (col("country") == "USA")) | (
|
||||
(col("is_student")) & (col("is_member"))
|
||||
)
|
||||
result = eval_expr(expr, sample_data)
|
||||
# Row 0: (17>=21 & USA) | (True & False) = False | False = False
|
||||
# Row 1: (21>=21 & UK) | (True & True) = False | True = True
|
||||
# Row 2: (25>=21 & USA) | (False & True) = True | False = True
|
||||
# Row 3: (30>=21 & Canada) | (False & False) = False | False = False
|
||||
# Row 4: (65>=21 & USA) | (False & True) = True | False = True
|
||||
expected = pd.Series([False, True, True, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Boolean Expression Structural Equality
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestBooleanStructuralEquality:
|
||||
"""Tests for structural equality of boolean expressions."""
|
||||
|
||||
def test_and_structural_equality(self):
|
||||
"""Test structural equality for AND expressions."""
|
||||
expr1 = col("a") & col("b")
|
||||
expr2 = col("a") & col("b")
|
||||
expr3 = col("b") & col("a") # Order matters
|
||||
|
||||
assert expr1.structurally_equals(expr2)
|
||||
assert not expr1.structurally_equals(expr3)
|
||||
|
||||
def test_or_structural_equality(self):
|
||||
"""Test structural equality for OR expressions."""
|
||||
expr1 = col("a") | col("b")
|
||||
expr2 = col("a") | col("b")
|
||||
expr3 = col("a") | col("c")
|
||||
|
||||
assert expr1.structurally_equals(expr2)
|
||||
assert not expr1.structurally_equals(expr3)
|
||||
|
||||
def test_not_structural_equality(self):
|
||||
"""Test structural equality for NOT expressions."""
|
||||
expr1 = ~col("a")
|
||||
expr2 = ~col("a")
|
||||
expr3 = ~col("b")
|
||||
|
||||
assert expr1.structurally_equals(expr2)
|
||||
assert not expr1.structurally_equals(expr3)
|
||||
|
||||
def test_complex_boolean_structural_equality(self):
|
||||
"""Test structural equality for complex boolean expressions."""
|
||||
expr1 = (col("a") > 10) & ((col("b") < 5) | ~col("c"))
|
||||
expr2 = (col("a") > 10) & ((col("b") < 5) | ~col("c"))
|
||||
expr3 = (col("a") > 10) & ((col("b") < 6) | ~col("c"))
|
||||
|
||||
assert expr1.structurally_equals(expr2)
|
||||
assert not expr1.structurally_equals(expr3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Tests for comparison expression operations.
|
||||
|
||||
This module tests:
|
||||
- Comparison operators: GT (>), LT (<), GE (>=), LE (<=), EQ (==), NE (!=)
|
||||
- Comparison with columns and literals
|
||||
- Reverse comparisons (literal compared to column)
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from ray.data._internal.planner.plan_expression.expression_evaluator import eval_expr
|
||||
from ray.data.expressions import BinaryExpr, Operation, col, lit
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Basic Comparison Operations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestComparisonOperators:
|
||||
"""Tests for comparison operators (>, <, >=, <=, ==, !=)."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for comparison tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"age": [18, 21, 25, 30, 16],
|
||||
"score": [50, 75, 100, 60, 85],
|
||||
"status": ["active", "inactive", "active", "pending", "active"],
|
||||
}
|
||||
)
|
||||
|
||||
# ── Greater Than ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_values",
|
||||
[
|
||||
(col("age") > 21, [False, False, True, True, False]),
|
||||
(col("age") > col("score") / 10, [True, True, True, True, True]),
|
||||
],
|
||||
ids=["col_gt_literal", "col_gt_col_expr"],
|
||||
)
|
||||
def test_greater_than(self, sample_data, expr, expected_values):
|
||||
"""Test greater than (>) comparisons."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.GT
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
def test_greater_than_reverse(self, sample_data):
|
||||
"""Test reverse greater than (literal > col)."""
|
||||
expr = 22 > col("age")
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.LT # Reverse: 22 > age becomes age < 22
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, True, False, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
# ── Less Than ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_values",
|
||||
[
|
||||
(col("age") < 21, [True, False, False, False, True]),
|
||||
(col("score") < 70, [True, False, False, True, False]),
|
||||
],
|
||||
ids=["col_lt_literal", "score_lt_70"],
|
||||
)
|
||||
def test_less_than(self, sample_data, expr, expected_values):
|
||||
"""Test less than (<) comparisons."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.LT
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
def test_less_than_reverse(self, sample_data):
|
||||
"""Test reverse less than (literal < col)."""
|
||||
expr = 20 < col("age")
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.GT # Reverse: 20 < age becomes age > 20
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, True, True, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
# ── Greater Than or Equal ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_values",
|
||||
[
|
||||
(col("age") >= 21, [False, True, True, True, False]),
|
||||
(col("score") >= 75, [False, True, True, False, True]),
|
||||
],
|
||||
ids=["col_ge_21", "score_ge_75"],
|
||||
)
|
||||
def test_greater_equal(self, sample_data, expr, expected_values):
|
||||
"""Test greater than or equal (>=) comparisons."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.GE
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
def test_greater_equal_reverse(self, sample_data):
|
||||
"""Test reverse greater equal (literal >= col)."""
|
||||
expr = 21 >= col("age")
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.LE # Reverse
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, True, False, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
# ── Less Than or Equal ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_values",
|
||||
[
|
||||
(col("age") <= 21, [True, True, False, False, True]),
|
||||
(col("score") <= 60, [True, False, False, True, False]),
|
||||
],
|
||||
ids=["col_le_21", "score_le_60"],
|
||||
)
|
||||
def test_less_equal(self, sample_data, expr, expected_values):
|
||||
"""Test less than or equal (<=) comparisons."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.LE
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
def test_less_equal_reverse(self, sample_data):
|
||||
"""Test reverse less equal (literal <= col)."""
|
||||
expr = 25 <= col("age")
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.GE # Reverse
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, False, True, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
# ── Equality ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_values",
|
||||
[
|
||||
(col("age") == 21, [False, True, False, False, False]),
|
||||
(col("status") == "active", [True, False, True, False, True]),
|
||||
(col("score") == lit(100), [False, False, True, False, False]),
|
||||
],
|
||||
ids=["age_eq_21", "status_eq_active", "score_eq_100"],
|
||||
)
|
||||
def test_equality(self, sample_data, expr, expected_values):
|
||||
"""Test equality (==) comparisons."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.EQ
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
# ── Not Equal ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_values",
|
||||
[
|
||||
(col("age") != 21, [True, False, True, True, True]),
|
||||
(col("status") != "active", [False, True, False, True, False]),
|
||||
],
|
||||
ids=["age_ne_21", "status_ne_active"],
|
||||
)
|
||||
def test_not_equal(self, sample_data, expr, expected_values):
|
||||
"""Test not equal (!=) comparisons."""
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.NE
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Column vs Column Comparisons
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestColumnToColumnComparison:
|
||||
"""Tests for comparing columns against other columns."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data with comparable columns."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"value_a": [10, 20, 30, 40],
|
||||
"value_b": [15, 20, 25, 45],
|
||||
"threshold": [12, 18, 35, 35],
|
||||
}
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr_fn,expected_values",
|
||||
[
|
||||
(lambda: col("value_a") > col("value_b"), [False, False, True, False]),
|
||||
(lambda: col("value_a") < col("threshold"), [True, False, True, False]),
|
||||
(lambda: col("value_a") == col("value_b"), [False, True, False, False]),
|
||||
(lambda: col("value_a") >= col("threshold"), [False, True, False, True]),
|
||||
],
|
||||
ids=["a_gt_b", "a_lt_threshold", "a_eq_b", "a_ge_threshold"],
|
||||
)
|
||||
def test_column_to_column_comparisons(self, sample_data, expr_fn, expected_values):
|
||||
"""Test various column-to-column comparisons."""
|
||||
expr = expr_fn()
|
||||
result = eval_expr(expr, sample_data)
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True),
|
||||
pd.Series(expected_values),
|
||||
check_names=False,
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Comparison with Expressions
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestComparisonWithExpressions:
|
||||
"""Tests for comparing expressions against other expressions."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for expression comparison tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"price": [100, 200, 150],
|
||||
"discount": [10, 50, 30],
|
||||
"min_price": [80, 160, 130],
|
||||
}
|
||||
)
|
||||
|
||||
def test_compare_computed_values(self, sample_data):
|
||||
"""Test comparing computed expression results."""
|
||||
# (price - discount) > min_price
|
||||
expr = (col("price") - col("discount")) > col("min_price")
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, False]) # 90>80, 150>160, 120>130
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_compare_scaled_values(self, sample_data):
|
||||
"""Test comparing scaled column values."""
|
||||
# price * 0.9 >= min_price (check if 10% discount still meets minimum)
|
||||
expr = col("price") * 0.9 >= col("min_price")
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, True, True]) # 90>=80, 180>=160, 135>=130
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# String Comparisons
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestStringComparison:
|
||||
"""Tests for string equality and inequality."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data with string columns."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"name": ["Alice", "Bob", "Charlie", "Alice"],
|
||||
"city": ["NYC", "LA", "NYC", "SF"],
|
||||
}
|
||||
)
|
||||
|
||||
def test_string_equality(self, sample_data):
|
||||
"""Test string equality comparison."""
|
||||
expr = col("name") == "Alice"
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_string_inequality(self, sample_data):
|
||||
"""Test string inequality comparison."""
|
||||
expr = col("city") != "NYC"
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, True, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Boolean Comparisons
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestBooleanComparison:
|
||||
"""Tests for boolean value comparisons."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data with boolean columns."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"is_active": [True, False, True, False],
|
||||
"is_verified": [True, True, False, False],
|
||||
}
|
||||
)
|
||||
|
||||
def test_boolean_equality_true(self, sample_data):
|
||||
"""Test boolean equality with True."""
|
||||
expr = col("is_active") == lit(True)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_boolean_equality_false(self, sample_data):
|
||||
"""Test boolean equality with False."""
|
||||
expr = col("is_active") == lit(False)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, True, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_boolean_column_to_column(self, sample_data):
|
||||
"""Test comparing two boolean columns."""
|
||||
expr = col("is_active") == col("is_verified")
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,628 @@
|
||||
"""Tests for expression conversion to PyArrow and Iceberg.
|
||||
|
||||
This module tests:
|
||||
- Conversion to PyArrow compute expressions (to_pyarrow)
|
||||
- Conversion to PyIceberg expressions (IcebergExpressionVisitor)
|
||||
- Unsupported expression handling
|
||||
"""
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.compute as pc
|
||||
import pyarrow.dataset as ds
|
||||
import pytest
|
||||
from packaging.version import parse as version_parse
|
||||
from pyiceberg.expressions import (
|
||||
And,
|
||||
EqualTo,
|
||||
GreaterThan,
|
||||
GreaterThanOrEqual,
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Not,
|
||||
NotEqualTo,
|
||||
NotIn,
|
||||
NotNull,
|
||||
Or,
|
||||
Reference,
|
||||
literal,
|
||||
)
|
||||
|
||||
from ray.data._internal.datasource.iceberg_datasource import _IcebergExpressionVisitor
|
||||
from ray.data.datatype import DataType
|
||||
from ray.data.expressions import (
|
||||
BinaryExpr,
|
||||
Operation,
|
||||
UDFExpr,
|
||||
col,
|
||||
download,
|
||||
lit,
|
||||
star,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# PyArrow Conversion Tests
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestToPyArrow:
|
||||
"""Test conversion of Ray Data expressions to PyArrow compute expressions."""
|
||||
|
||||
@pytest.fixture
|
||||
def test_table(self):
|
||||
"""Sample PyArrow table for testing expressions."""
|
||||
return pa.table(
|
||||
{
|
||||
"age": [15, 25, 45, 70],
|
||||
"x": [1, 2, 3, 4],
|
||||
"price": [10.0, 20.0, 30.0, 40.0],
|
||||
"quantity": [2, 3, 1, 5],
|
||||
"tax": [1.0, 2.0, 3.0, 4.0],
|
||||
"status": ["active", "pending", "inactive", "active"],
|
||||
"value": [1, None, 3, None],
|
||||
"active": [True, False, True, False],
|
||||
}
|
||||
)
|
||||
|
||||
# ── Basic Expressions ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_expr,equivalent_pyarrow_expr",
|
||||
[
|
||||
(col("age"), lambda: pc.field("age")),
|
||||
(lit(42), lambda: pc.scalar(42)),
|
||||
(lit("hello"), lambda: pc.scalar("hello")),
|
||||
],
|
||||
ids=["col", "int_lit", "str_lit"],
|
||||
)
|
||||
def test_basic_expressions(self, test_table, ray_expr, equivalent_pyarrow_expr):
|
||||
"""Test conversion of basic expressions."""
|
||||
converted = ray_expr.to_pyarrow()
|
||||
expected = equivalent_pyarrow_expr()
|
||||
assert converted.equals(expected)
|
||||
|
||||
# ── Arithmetic Expressions ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_expr,equivalent_pyarrow_expr",
|
||||
[
|
||||
(
|
||||
col("x") + 5,
|
||||
lambda: pc.add(pc.field("x"), pc.scalar(5)),
|
||||
),
|
||||
(
|
||||
col("x") - 3,
|
||||
lambda: pc.subtract(pc.field("x"), pc.scalar(3)),
|
||||
),
|
||||
(
|
||||
col("x") * 2,
|
||||
lambda: pc.multiply(pc.field("x"), pc.scalar(2)),
|
||||
),
|
||||
(
|
||||
col("x") / 2,
|
||||
lambda: pc.divide(pc.field("x"), pc.scalar(2)),
|
||||
),
|
||||
],
|
||||
ids=["add", "sub", "mul", "div"],
|
||||
)
|
||||
def test_arithmetic_expressions(
|
||||
self, test_table, ray_expr, equivalent_pyarrow_expr
|
||||
):
|
||||
"""Test conversion of arithmetic expressions."""
|
||||
converted = ray_expr.to_pyarrow()
|
||||
expected = equivalent_pyarrow_expr()
|
||||
assert converted.equals(expected)
|
||||
|
||||
# ── Comparison Expressions ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_expr,equivalent_pyarrow_expr,description",
|
||||
[
|
||||
(
|
||||
col("age") > 18,
|
||||
lambda: pc.greater(pc.field("age"), pc.scalar(18)),
|
||||
"greater than",
|
||||
),
|
||||
(
|
||||
col("age") < 65,
|
||||
lambda: pc.less(pc.field("age"), pc.scalar(65)),
|
||||
"less than",
|
||||
),
|
||||
(
|
||||
col("age") >= 21,
|
||||
lambda: pc.greater_equal(pc.field("age"), pc.scalar(21)),
|
||||
"greater equal",
|
||||
),
|
||||
(
|
||||
col("age") <= 30,
|
||||
lambda: pc.less_equal(pc.field("age"), pc.scalar(30)),
|
||||
"less equal",
|
||||
),
|
||||
(
|
||||
col("status") == "active",
|
||||
lambda: pc.equal(pc.field("status"), pc.scalar("active")),
|
||||
"equality",
|
||||
),
|
||||
(
|
||||
col("status") != "deleted",
|
||||
lambda: pc.not_equal(pc.field("status"), pc.scalar("deleted")),
|
||||
"not equal",
|
||||
),
|
||||
],
|
||||
ids=["gt", "lt", "ge", "le", "eq", "ne"],
|
||||
)
|
||||
def test_comparison_expressions(
|
||||
self, test_table, ray_expr, equivalent_pyarrow_expr, description
|
||||
):
|
||||
"""Test conversion of comparison expressions."""
|
||||
converted = ray_expr.to_pyarrow()
|
||||
expected = equivalent_pyarrow_expr()
|
||||
|
||||
# Verify they produce the same results on sample data
|
||||
dataset = ds.dataset(test_table)
|
||||
try:
|
||||
result_converted = dataset.scanner(filter=converted).to_table()
|
||||
result_expected = dataset.scanner(filter=expected).to_table()
|
||||
assert result_converted.equals(result_expected)
|
||||
except (TypeError, pa.lib.ArrowInvalid, pa.lib.ArrowNotImplementedError):
|
||||
pass
|
||||
|
||||
# ── Boolean Expressions ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_expr,equivalent_pyarrow_expr,description",
|
||||
[
|
||||
(
|
||||
(col("age") > 18) & (col("age") < 65),
|
||||
lambda: pc.and_kleene(
|
||||
pc.greater(pc.field("age"), pc.scalar(18)),
|
||||
pc.less(pc.field("age"), pc.scalar(65)),
|
||||
),
|
||||
"logical AND",
|
||||
),
|
||||
(
|
||||
(col("status") == "active") | (col("status") == "pending"),
|
||||
lambda: pc.or_kleene(
|
||||
pc.equal(pc.field("status"), pc.scalar("active")),
|
||||
pc.equal(pc.field("status"), pc.scalar("pending")),
|
||||
),
|
||||
"logical OR",
|
||||
),
|
||||
(
|
||||
~col("active"),
|
||||
lambda: pc.invert(pc.field("active")),
|
||||
"logical NOT",
|
||||
),
|
||||
],
|
||||
ids=["and", "or", "not"],
|
||||
)
|
||||
def test_boolean_expressions(
|
||||
self, test_table, ray_expr, equivalent_pyarrow_expr, description
|
||||
):
|
||||
"""Test conversion of boolean expressions."""
|
||||
converted = ray_expr.to_pyarrow()
|
||||
expected = equivalent_pyarrow_expr()
|
||||
|
||||
dataset = ds.dataset(test_table)
|
||||
try:
|
||||
result_converted = dataset.scanner(filter=converted).to_table()
|
||||
result_expected = dataset.scanner(filter=expected).to_table()
|
||||
assert result_converted.equals(result_expected)
|
||||
except (TypeError, pa.lib.ArrowInvalid, pa.lib.ArrowNotImplementedError):
|
||||
pass
|
||||
|
||||
# ── Predicate Expressions ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_expr,equivalent_pyarrow_expr,description",
|
||||
[
|
||||
(
|
||||
col("value").is_null(),
|
||||
lambda: pc.is_null(pc.field("value")),
|
||||
"is_null check",
|
||||
),
|
||||
(
|
||||
col("value").is_not_null(),
|
||||
lambda: pc.is_valid(pc.field("value")),
|
||||
"is_not_null check",
|
||||
),
|
||||
(
|
||||
col("status").is_in(["active", "pending"]),
|
||||
lambda: pc.is_in(pc.field("status"), pa.array(["active", "pending"])),
|
||||
"is_in with list",
|
||||
),
|
||||
],
|
||||
ids=["is_null", "is_not_null", "is_in"],
|
||||
)
|
||||
def test_predicate_expressions(
|
||||
self, test_table, ray_expr, equivalent_pyarrow_expr, description
|
||||
):
|
||||
"""Test conversion of predicate expressions."""
|
||||
converted = ray_expr.to_pyarrow()
|
||||
expected = equivalent_pyarrow_expr()
|
||||
|
||||
dataset = ds.dataset(test_table)
|
||||
try:
|
||||
result_converted = dataset.scanner(filter=converted).to_table()
|
||||
result_expected = dataset.scanner(filter=expected).to_table()
|
||||
assert result_converted.equals(result_expected)
|
||||
except (TypeError, pa.lib.ArrowInvalid, pa.lib.ArrowNotImplementedError):
|
||||
pass
|
||||
|
||||
# ── Nested Expressions ──
|
||||
|
||||
def test_nested_arithmetic(self, test_table):
|
||||
"""Test nested arithmetic expressions."""
|
||||
ray_expr = (col("price") * col("quantity")) + col("tax")
|
||||
converted = ray_expr.to_pyarrow()
|
||||
assert isinstance(converted, pc.Expression)
|
||||
|
||||
# ── Alias Expressions ──
|
||||
|
||||
def test_alias_expressions(self, test_table):
|
||||
"""Test that alias expressions unwrap to inner expression."""
|
||||
ray_expr = (col("x") + 5).alias("result")
|
||||
converted = ray_expr.to_pyarrow()
|
||||
assert isinstance(converted, pc.Expression)
|
||||
|
||||
# ── PyArrow Compute UDF Expressions ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_expr,equivalent_pyarrow_expr",
|
||||
[
|
||||
pytest.param(
|
||||
col("name").str.match_regex("foo.*bar"),
|
||||
lambda: pc.match_substring_regex(pc.field("name"), "foo.*bar"),
|
||||
id="match_regex",
|
||||
),
|
||||
pytest.param(
|
||||
col("name").str.starts_with("foo"),
|
||||
lambda: pc.starts_with(pc.field("name"), "foo"),
|
||||
id="starts_with",
|
||||
),
|
||||
pytest.param(
|
||||
col("name").str.ends_with("bar"),
|
||||
lambda: pc.ends_with(pc.field("name"), "bar"),
|
||||
id="ends_with",
|
||||
),
|
||||
pytest.param(
|
||||
col("name").str.contains("baz"),
|
||||
lambda: pc.match_substring(pc.field("name"), "baz"),
|
||||
id="contains",
|
||||
),
|
||||
pytest.param(
|
||||
col("name").str.upper(),
|
||||
lambda: pc.utf8_upper(pc.field("name")),
|
||||
id="upper",
|
||||
),
|
||||
pytest.param(
|
||||
col("x").ceil(),
|
||||
lambda: pc.ceil(pc.field("x")),
|
||||
id="ceil",
|
||||
),
|
||||
pytest.param(
|
||||
col("x").abs(),
|
||||
lambda: pc.abs_checked(pc.field("x")),
|
||||
id="abs",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_pyarrow_compute_udf_expressions(
|
||||
self, test_table, ray_expr, equivalent_pyarrow_expr
|
||||
):
|
||||
"""Test that PyArrow-compute-backed UDFs convert to PyArrow expressions."""
|
||||
converted = ray_expr.to_pyarrow()
|
||||
expected = equivalent_pyarrow_expr()
|
||||
assert converted.equals(expected)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
version_parse(pa.__version__) < version_parse("19.0.0"),
|
||||
reason="Requires PyArrow >= 19 for string compute UDF pushdown",
|
||||
)
|
||||
def test_negated_pyarrow_compute_udf(self, test_table):
|
||||
"""Test that negated PyArrow compute UDF expressions convert correctly."""
|
||||
ray_expr = ~col("status").str.match_regex("act.*")
|
||||
converted = ray_expr.to_pyarrow()
|
||||
assert isinstance(converted, pc.Expression)
|
||||
|
||||
dataset = ds.dataset(test_table)
|
||||
result = dataset.to_table(filter=converted)
|
||||
assert all(
|
||||
not bool(pc.match_substring_regex(s, "act.*"))
|
||||
for s in result.column("status").to_pylist()
|
||||
)
|
||||
|
||||
def test_pyarrow_compute_udf_as_dataset_filter(self, test_table):
|
||||
"""Test that converted UDF expressions work as dataset scan filters."""
|
||||
ray_expr = col("status").str.match_regex("^active$")
|
||||
pa_expr = ray_expr.to_pyarrow()
|
||||
|
||||
dataset = ds.dataset(test_table)
|
||||
result = dataset.to_table(filter=pa_expr)
|
||||
assert all(s == "active" for s in result.column("status").to_pylist())
|
||||
|
||||
# ── Unsupported Expressions ──
|
||||
|
||||
def test_user_udf_expression_raises(self):
|
||||
"""Test that user-defined UDF expressions raise TypeError."""
|
||||
|
||||
def dummy_fn(x):
|
||||
return x
|
||||
|
||||
udf_expr = UDFExpr(
|
||||
fn=dummy_fn,
|
||||
args=[col("x")],
|
||||
kwargs={},
|
||||
data_type=DataType(int),
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="UDF expressions cannot be converted"):
|
||||
udf_expr.to_pyarrow()
|
||||
|
||||
def test_download_expression_raises(self):
|
||||
"""Test that download expressions raise TypeError."""
|
||||
with pytest.raises(TypeError, match="Download expressions cannot be converted"):
|
||||
download("uri").to_pyarrow()
|
||||
|
||||
def test_star_expression_raises(self):
|
||||
"""Test that star expressions raise TypeError."""
|
||||
with pytest.raises(TypeError, match="Star expressions cannot be converted"):
|
||||
star().to_pyarrow()
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Iceberg Conversion Tests
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestIcebergExpressionVisitor:
|
||||
"""Test conversion of Ray Data expressions to PyIceberg expressions."""
|
||||
|
||||
# ── Basic Expressions ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_expr,equivalent_iceberg_expr,description",
|
||||
[
|
||||
(col("age"), lambda: Reference("age"), "column reference"),
|
||||
(lit(42), lambda: literal(42), "integer literal"),
|
||||
(lit("active"), lambda: literal("active"), "string literal"),
|
||||
],
|
||||
ids=["col", "int_lit", "str_lit"],
|
||||
)
|
||||
def test_basic_expressions(self, ray_expr, equivalent_iceberg_expr, description):
|
||||
"""Test conversion of basic expressions."""
|
||||
visitor = _IcebergExpressionVisitor()
|
||||
converted = visitor.visit(ray_expr)
|
||||
expected = equivalent_iceberg_expr()
|
||||
assert converted == expected
|
||||
|
||||
# ── Comparison Expressions ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_expr,equivalent_iceberg_expr,description",
|
||||
[
|
||||
(
|
||||
col("age") > 18,
|
||||
lambda: GreaterThan(Reference("age"), literal(18)),
|
||||
"greater than",
|
||||
),
|
||||
(
|
||||
col("age") >= 21,
|
||||
lambda: GreaterThanOrEqual(Reference("age"), literal(21)),
|
||||
"greater than or equal",
|
||||
),
|
||||
(
|
||||
col("age") < 65,
|
||||
lambda: LessThan(Reference("age"), literal(65)),
|
||||
"less than",
|
||||
),
|
||||
(
|
||||
col("age") <= 100,
|
||||
lambda: LessThanOrEqual(Reference("age"), literal(100)),
|
||||
"less than or equal",
|
||||
),
|
||||
(
|
||||
col("status") == "active",
|
||||
lambda: EqualTo(Reference("status"), literal("active")),
|
||||
"equality",
|
||||
),
|
||||
(
|
||||
col("status") != "inactive",
|
||||
lambda: NotEqualTo(Reference("status"), literal("inactive")),
|
||||
"not equal",
|
||||
),
|
||||
],
|
||||
ids=["gt", "ge", "lt", "le", "eq", "ne"],
|
||||
)
|
||||
def test_comparison_expressions(
|
||||
self, ray_expr, equivalent_iceberg_expr, description
|
||||
):
|
||||
"""Test conversion of comparison expressions."""
|
||||
visitor = _IcebergExpressionVisitor()
|
||||
converted = visitor.visit(ray_expr)
|
||||
expected = equivalent_iceberg_expr()
|
||||
assert converted == expected
|
||||
|
||||
# ── Boolean Expressions ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_expr,equivalent_iceberg_expr,description",
|
||||
[
|
||||
(
|
||||
(col("age") > 18) & (col("age") < 65),
|
||||
lambda: And(
|
||||
GreaterThan(Reference("age"), literal(18)),
|
||||
LessThan(Reference("age"), literal(65)),
|
||||
),
|
||||
"logical AND",
|
||||
),
|
||||
(
|
||||
(col("is_member") == lit(True)) | (col("is_premium") == lit(True)),
|
||||
lambda: Or(
|
||||
EqualTo(Reference("is_member"), literal(True)),
|
||||
EqualTo(Reference("is_premium"), literal(True)),
|
||||
),
|
||||
"logical OR",
|
||||
),
|
||||
(
|
||||
~(col("deleted") == lit(True)),
|
||||
lambda: Not(EqualTo(Reference("deleted"), literal(True))),
|
||||
"logical NOT",
|
||||
),
|
||||
],
|
||||
ids=["and", "or", "not"],
|
||||
)
|
||||
def test_boolean_expressions(self, ray_expr, equivalent_iceberg_expr, description):
|
||||
"""Test conversion of boolean expressions."""
|
||||
visitor = _IcebergExpressionVisitor()
|
||||
converted = visitor.visit(ray_expr)
|
||||
expected = equivalent_iceberg_expr()
|
||||
assert converted == expected
|
||||
|
||||
# ── Predicate Expressions ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_expr,equivalent_iceberg_expr,description",
|
||||
[
|
||||
(
|
||||
col("value").is_null(),
|
||||
lambda: IsNull(Reference("value")),
|
||||
"is_null check",
|
||||
),
|
||||
(
|
||||
col("name").is_not_null(),
|
||||
lambda: NotNull(Reference("name")),
|
||||
"is_not_null check",
|
||||
),
|
||||
(
|
||||
col("status").is_in(["active", "pending"]),
|
||||
lambda: In(Reference("status"), ["active", "pending"]),
|
||||
"is_in with list",
|
||||
),
|
||||
(
|
||||
col("status").not_in(["inactive", "deleted"]),
|
||||
lambda: NotIn(Reference("status"), ["inactive", "deleted"]),
|
||||
"not_in with list",
|
||||
),
|
||||
],
|
||||
ids=["is_null", "is_not_null", "is_in", "not_in"],
|
||||
)
|
||||
def test_predicate_expressions(
|
||||
self, ray_expr, equivalent_iceberg_expr, description
|
||||
):
|
||||
"""Test conversion of predicate expressions."""
|
||||
visitor = _IcebergExpressionVisitor()
|
||||
converted = visitor.visit(ray_expr)
|
||||
expected = equivalent_iceberg_expr()
|
||||
assert converted == expected
|
||||
|
||||
# ── Complex Nested Expressions ──
|
||||
|
||||
def test_complex_nested_boolean(self):
|
||||
"""Test complex nested boolean expression."""
|
||||
ray_expr = (
|
||||
(col("age") >= 21)
|
||||
& (col("country") == "USA")
|
||||
& col("verified").is_not_null()
|
||||
)
|
||||
expected = And(
|
||||
And(
|
||||
GreaterThanOrEqual(Reference("age"), literal(21)),
|
||||
EqualTo(Reference("country"), literal("USA")),
|
||||
),
|
||||
NotNull(Reference("verified")),
|
||||
)
|
||||
|
||||
visitor = _IcebergExpressionVisitor()
|
||||
converted = visitor.visit(ray_expr)
|
||||
assert converted == expected
|
||||
|
||||
def test_aliased_expression(self):
|
||||
"""Test that alias expressions unwrap to inner expression."""
|
||||
ray_expr = (col("age") > 18).alias("is_adult")
|
||||
expected = GreaterThan(Reference("age"), literal(18))
|
||||
|
||||
visitor = _IcebergExpressionVisitor()
|
||||
converted = visitor.visit(ray_expr)
|
||||
assert converted == expected
|
||||
|
||||
# ── Unsupported Arithmetic ──
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ray_expr",
|
||||
[
|
||||
col("price") + 10,
|
||||
col("quantity") * 2,
|
||||
col("total") - col("discount"),
|
||||
col("revenue") / col("count"),
|
||||
col("items") // 5,
|
||||
],
|
||||
ids=["add", "mul", "sub", "div", "floordiv"],
|
||||
)
|
||||
def test_arithmetic_raises(self, ray_expr):
|
||||
"""Test that arithmetic operations raise appropriate errors."""
|
||||
visitor = _IcebergExpressionVisitor()
|
||||
with pytest.raises(
|
||||
ValueError, match="Unsupported binary operation for Iceberg"
|
||||
):
|
||||
visitor.visit(ray_expr)
|
||||
|
||||
# ── Unsupported Expressions ──
|
||||
|
||||
def test_udf_expression_raises(self):
|
||||
"""Test that UDF expressions raise TypeError."""
|
||||
|
||||
def dummy_fn(x):
|
||||
return x
|
||||
|
||||
udf_expr = UDFExpr(
|
||||
fn=dummy_fn,
|
||||
args=[col("x")],
|
||||
kwargs={},
|
||||
data_type=DataType(int),
|
||||
)
|
||||
|
||||
visitor = _IcebergExpressionVisitor()
|
||||
with pytest.raises(
|
||||
TypeError, match="UDF expressions cannot be converted to Iceberg"
|
||||
):
|
||||
visitor.visit(udf_expr)
|
||||
|
||||
def test_download_expression_raises(self):
|
||||
"""Test that download expressions raise TypeError."""
|
||||
visitor = _IcebergExpressionVisitor()
|
||||
with pytest.raises(
|
||||
TypeError, match="Download expressions cannot be converted to Iceberg"
|
||||
):
|
||||
visitor.visit(download("uri"))
|
||||
|
||||
def test_star_expression_raises(self):
|
||||
"""Test that star expressions raise TypeError."""
|
||||
visitor = _IcebergExpressionVisitor()
|
||||
with pytest.raises(
|
||||
TypeError, match="Star expressions cannot be converted to Iceberg"
|
||||
):
|
||||
visitor.visit(star())
|
||||
|
||||
def test_is_in_requires_literal_list(self):
|
||||
"""Test that IN/NOT_IN operations require literal lists."""
|
||||
visitor = _IcebergExpressionVisitor()
|
||||
|
||||
# This should work - literal list
|
||||
expr = col("status").is_in(["active", "pending"])
|
||||
result = visitor.visit(expr)
|
||||
assert isinstance(result, In)
|
||||
|
||||
# This should fail - column reference on right side
|
||||
with pytest.raises(
|
||||
ValueError, match="IN operation requires right operand to be a literal list"
|
||||
):
|
||||
invalid_expr = BinaryExpr(Operation.IN, col("a"), col("b"))
|
||||
visitor.visit(invalid_expr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,537 @@
|
||||
"""Tests for core expression types and basic functionality.
|
||||
|
||||
This module tests:
|
||||
- ColumnExpr, LiteralExpr, BinaryExpr, UnaryExpr, AliasExpr, StarExpr
|
||||
- Structural equality for all expression types
|
||||
- Expression tree repr (string representation)
|
||||
- UDFExpr structural equality
|
||||
"""
|
||||
|
||||
import pyarrow as pa
|
||||
import pyarrow.compute as pc
|
||||
import pytest
|
||||
|
||||
from ray.data._internal.planner.plan_expression.expression_visitors import (
|
||||
_InlineExprReprVisitor,
|
||||
)
|
||||
from ray.data.datatype import DataType
|
||||
from ray.data.expressions import (
|
||||
BinaryExpr,
|
||||
ColumnExpr,
|
||||
Expr,
|
||||
LiteralExpr,
|
||||
Operation,
|
||||
StarExpr,
|
||||
UDFExpr,
|
||||
UnaryExpr,
|
||||
col,
|
||||
download,
|
||||
lit,
|
||||
star,
|
||||
udf,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Column Expression Tests
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestColumnExpr:
|
||||
"""Tests for ColumnExpr functionality."""
|
||||
|
||||
def test_column_creation(self):
|
||||
"""Test that col() creates a ColumnExpr with correct name."""
|
||||
expr = col("age")
|
||||
assert isinstance(expr, ColumnExpr)
|
||||
assert expr.name == "age"
|
||||
|
||||
def test_column_name_property(self):
|
||||
"""Test that name property returns the column name."""
|
||||
expr = col("my_column")
|
||||
assert expr.name == "my_column"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name1,name2,expected",
|
||||
[
|
||||
("a", "a", True),
|
||||
("a", "b", False),
|
||||
("column_name", "column_name", True),
|
||||
("COL", "col", False), # Case sensitive
|
||||
],
|
||||
ids=["same_name", "different_name", "long_name", "case_sensitive"],
|
||||
)
|
||||
def test_column_structural_equality(self, name1, name2, expected):
|
||||
"""Test structural equality for column expressions."""
|
||||
assert col(name1).structurally_equals(col(name2)) is expected
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Literal Expression Tests
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestLiteralExpr:
|
||||
"""Tests for LiteralExpr functionality."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[42, 3.14, "hello", True, False, None, [1, 2, 3]],
|
||||
ids=["int", "float", "string", "bool_true", "bool_false", "none", "list"],
|
||||
)
|
||||
def test_literal_creation(self, value):
|
||||
"""Test that lit() creates a LiteralExpr with correct value."""
|
||||
expr = lit(value)
|
||||
assert isinstance(expr, LiteralExpr)
|
||||
assert expr.value == value
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"val1,val2,expected",
|
||||
[
|
||||
(1, 1, True),
|
||||
(1, 2, False),
|
||||
("x", "y", False),
|
||||
("x", "x", True),
|
||||
(1, 1.0, False), # Different types
|
||||
(True, True, True),
|
||||
(True, False, False),
|
||||
([1, 2], [1, 2], True),
|
||||
([1, 2], [1, 3], False),
|
||||
],
|
||||
ids=[
|
||||
"same_int",
|
||||
"different_int",
|
||||
"different_str",
|
||||
"same_str",
|
||||
"int_vs_float",
|
||||
"same_bool",
|
||||
"different_bool",
|
||||
"same_list",
|
||||
"different_list",
|
||||
],
|
||||
)
|
||||
def test_literal_structural_equality(self, val1, val2, expected):
|
||||
"""Test structural equality for literal expressions."""
|
||||
assert lit(val1).structurally_equals(lit(val2)) is expected
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Binary Expression Tests
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestBinaryExpr:
|
||||
"""Tests for BinaryExpr structure (not operation semantics)."""
|
||||
|
||||
def test_binary_expression_structure(self):
|
||||
"""Test that binary expressions have correct structure."""
|
||||
expr = col("a") + lit(1)
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.ADD
|
||||
assert isinstance(expr.left, ColumnExpr)
|
||||
assert isinstance(expr.right, LiteralExpr)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr1,expr2,expected",
|
||||
[
|
||||
(col("a") + 1, col("a") + 1, True),
|
||||
(col("a") + 1, col("a") + 2, False), # Different literal
|
||||
(col("a") + 1, col("b") + 1, False), # Different column
|
||||
(col("a") + 1, col("a") - 1, False), # Different operator
|
||||
# Nested binary expressions
|
||||
((col("a") * 2) + (col("b") / 3), (col("a") * 2) + (col("b") / 3), True),
|
||||
((col("a") * 2) + (col("b") / 3), (col("a") * 2) - (col("b") / 3), False),
|
||||
((col("a") * 2) + (col("b") / 3), (col("c") * 2) + (col("b") / 3), False),
|
||||
((col("a") * 2) + (col("b") / 3), (col("a") * 2) + (col("b") / 4), False),
|
||||
# Commutative operations are not structurally equal
|
||||
(col("a") + col("b"), col("b") + col("a"), False),
|
||||
(lit(1) * col("c"), col("c") * lit(1), False),
|
||||
],
|
||||
ids=[
|
||||
"same_simple",
|
||||
"different_literal",
|
||||
"different_column",
|
||||
"different_operator",
|
||||
"same_nested",
|
||||
"nested_diff_op",
|
||||
"nested_diff_col",
|
||||
"nested_diff_lit",
|
||||
"commutative_add",
|
||||
"commutative_mul",
|
||||
],
|
||||
)
|
||||
def test_binary_structural_equality(self, expr1, expr2, expected):
|
||||
"""Test structural equality for binary expressions."""
|
||||
assert expr1.structurally_equals(expr2) is expected
|
||||
# Test symmetry
|
||||
assert expr2.structurally_equals(expr1) is expected
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Unary Expression Tests
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestUnaryExpr:
|
||||
"""Tests for UnaryExpr structure."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,expected_op",
|
||||
[
|
||||
(col("age").is_null(), Operation.IS_NULL),
|
||||
(col("name").is_not_null(), Operation.IS_NOT_NULL),
|
||||
(~col("active"), Operation.NOT),
|
||||
],
|
||||
ids=["is_null", "is_not_null", "not"],
|
||||
)
|
||||
def test_unary_expression_structure(self, expr, expected_op):
|
||||
"""Test that unary expressions have correct structure."""
|
||||
assert isinstance(expr, UnaryExpr)
|
||||
assert expr.op == expected_op
|
||||
assert isinstance(expr.operand, Expr)
|
||||
|
||||
def test_unary_structural_equality(self):
|
||||
"""Test structural equality for unary expressions."""
|
||||
# Same expressions should be equal
|
||||
assert col("age").is_null().structurally_equals(col("age").is_null())
|
||||
assert (
|
||||
col("active").is_not_null().structurally_equals(col("active").is_not_null())
|
||||
)
|
||||
assert (~col("flag")).structurally_equals(~col("flag"))
|
||||
|
||||
# Different operations should not be equal
|
||||
assert not col("age").is_null().structurally_equals(col("age").is_not_null())
|
||||
|
||||
# Different operands should not be equal
|
||||
assert not col("age").is_null().structurally_equals(col("name").is_null())
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Alias Expression Tests
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestAliasExpr:
|
||||
"""Tests for AliasExpr functionality."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr,alias_name,expected_alias",
|
||||
[
|
||||
(col("price"), "product_price", "product_price"),
|
||||
(lit(42), "answer", "answer"),
|
||||
(col("a") + col("b"), "sum", "sum"),
|
||||
((col("price") * col("qty")) + lit(5), "total_with_fee", "total_with_fee"),
|
||||
(col("age") >= lit(18), "is_adult", "is_adult"),
|
||||
],
|
||||
ids=["col_alias", "lit_alias", "binary_alias", "complex_alias", "comparison"],
|
||||
)
|
||||
def test_alias_functionality(self, expr, alias_name, expected_alias):
|
||||
"""Test alias creation and properties."""
|
||||
aliased_expr = expr.alias(alias_name)
|
||||
assert aliased_expr.name == expected_alias
|
||||
assert aliased_expr.expr.structurally_equals(expr)
|
||||
# Data type should be preserved
|
||||
assert aliased_expr.data_type == expr.data_type
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr1,expr2,expected",
|
||||
[
|
||||
(col("a").alias("b"), col("a").alias("b"), True),
|
||||
(col("a").alias("b"), col("a").alias("c"), False), # Different alias
|
||||
(col("a").alias("b"), col("b").alias("b"), False), # Different column
|
||||
((col("a") + 1).alias("result"), (col("a") + 1).alias("result"), True),
|
||||
(
|
||||
(col("a") + 1).alias("result"),
|
||||
(col("a") + 2).alias("result"),
|
||||
False,
|
||||
), # Different expr
|
||||
(col("a").alias("b"), col("a"), False), # Alias vs non-alias
|
||||
],
|
||||
ids=[
|
||||
"same_alias",
|
||||
"different_alias_name",
|
||||
"different_column",
|
||||
"same_complex",
|
||||
"different_expr",
|
||||
"alias_vs_non_alias",
|
||||
],
|
||||
)
|
||||
def test_alias_structural_equality(self, expr1, expr2, expected):
|
||||
"""Test structural equality for alias expressions."""
|
||||
assert expr1.structurally_equals(expr2) is expected
|
||||
|
||||
def test_alias_structural_equality_respects_rename_flag(self):
|
||||
expr = col("a")
|
||||
aliased = expr.alias("b")
|
||||
renamed = expr._rename("b")
|
||||
|
||||
assert aliased.structurally_equals(aliased)
|
||||
assert renamed.structurally_equals(renamed)
|
||||
assert not aliased.structurally_equals(renamed)
|
||||
assert not aliased.structurally_equals(expr.alias("c"))
|
||||
|
||||
def test_alias_evaluation_equivalence(self):
|
||||
"""Test that alias evaluation produces same result as original."""
|
||||
import pandas as pd
|
||||
|
||||
from ray.data._internal.planner.plan_expression.expression_evaluator import (
|
||||
eval_expr,
|
||||
)
|
||||
|
||||
test_data = pd.DataFrame({"price": [10, 20], "qty": [2, 3]})
|
||||
expr = col("price") * col("qty")
|
||||
aliased = expr.alias("total")
|
||||
|
||||
original_result = eval_expr(expr, test_data)
|
||||
aliased_result = eval_expr(aliased, test_data)
|
||||
|
||||
assert original_result.equals(aliased_result)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Star Expression Tests
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestStarExpr:
|
||||
"""Tests for StarExpr functionality."""
|
||||
|
||||
def test_star_creation(self):
|
||||
"""Test that star() creates a StarExpr."""
|
||||
expr = star()
|
||||
assert isinstance(expr, StarExpr)
|
||||
|
||||
def test_star_structural_equality(self):
|
||||
"""Test structural equality for star expressions."""
|
||||
assert star().structurally_equals(star())
|
||||
assert not star().structurally_equals(col("a"))
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# UDF Expression Tests
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestUDFExpr:
|
||||
"""Tests for UDFExpr structural equality."""
|
||||
|
||||
def test_regular_function_udf_structural_equality(self):
|
||||
"""Test that regular function UDFs compare fn correctly."""
|
||||
|
||||
@udf(return_dtype=DataType.int32())
|
||||
def add_one(x: pa.Array) -> pa.Array:
|
||||
return pc.add(x, 1)
|
||||
|
||||
@udf(return_dtype=DataType.int32())
|
||||
def add_two(x: pa.Array) -> pa.Array:
|
||||
return pc.add(x, 2)
|
||||
|
||||
expr1 = add_one(col("value"))
|
||||
expr2 = add_one(col("value"))
|
||||
expr3 = add_two(col("value"))
|
||||
|
||||
# Same function should be equal
|
||||
assert expr1.structurally_equals(expr2)
|
||||
|
||||
# Different functions should not be equal
|
||||
assert not expr1.structurally_equals(expr3)
|
||||
|
||||
def test_callable_class_udf_structural_equality(self):
|
||||
"""Test that callable class UDFs with same spec are structurally equal."""
|
||||
|
||||
@udf(return_dtype=DataType.int32())
|
||||
class AddOffset:
|
||||
def __init__(self, offset):
|
||||
self.offset = offset
|
||||
|
||||
def __call__(self, x: pa.Array) -> pa.Array:
|
||||
return pc.add(x, self.offset)
|
||||
|
||||
# Create the same callable class instance
|
||||
add_five = AddOffset(5)
|
||||
|
||||
# Each call creates a new _placeholder function internally,
|
||||
# but the callable_class_spec should be the same
|
||||
expr1 = add_five(col("value"))
|
||||
expr2 = add_five(col("value"))
|
||||
|
||||
# These should be structurally equal
|
||||
assert expr1.structurally_equals(expr2)
|
||||
assert expr2.structurally_equals(expr1)
|
||||
|
||||
# Different constructor args should not be equal
|
||||
add_ten = AddOffset(10)
|
||||
expr3 = add_ten(col("value"))
|
||||
assert not expr1.structurally_equals(expr3)
|
||||
|
||||
# Different column args should not be equal
|
||||
expr4 = add_five(col("other"))
|
||||
assert not expr1.structurally_equals(expr4)
|
||||
|
||||
def test_callable_class_vs_regular_function_udf(self):
|
||||
"""Test that callable class UDFs are not equal to regular function UDFs."""
|
||||
|
||||
@udf(return_dtype=DataType.int32())
|
||||
class AddOne:
|
||||
def __call__(self, x: pa.Array) -> pa.Array:
|
||||
return pc.add(x, 1)
|
||||
|
||||
@udf(return_dtype=DataType.int32())
|
||||
def add_one(x: pa.Array) -> pa.Array:
|
||||
return pc.add(x, 1)
|
||||
|
||||
class_expr = AddOne()(col("value"))
|
||||
func_expr = add_one(col("value"))
|
||||
|
||||
# Different types of UDFs should not be equal
|
||||
assert not class_expr.structurally_equals(func_expr)
|
||||
assert not func_expr.structurally_equals(class_expr)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Cross-type Equality Tests
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestCrossTypeEquality:
|
||||
"""Test that different expression types are not structurally equal."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expr1,expr2",
|
||||
[
|
||||
(col("a"), lit("a")),
|
||||
(col("a"), col("a") + 0),
|
||||
(lit(1), lit(1) + 0),
|
||||
(col("a"), col("a").alias("a")),
|
||||
(col("a"), star()),
|
||||
],
|
||||
ids=[
|
||||
"col_vs_lit",
|
||||
"col_vs_binary",
|
||||
"lit_vs_binary",
|
||||
"col_vs_alias",
|
||||
"col_vs_star",
|
||||
],
|
||||
)
|
||||
def test_different_types_not_equal(self, expr1, expr2):
|
||||
"""Test that different expression types are not structurally equal."""
|
||||
assert not expr1.structurally_equals(expr2)
|
||||
assert not expr2.structurally_equals(expr1)
|
||||
|
||||
def test_operator_eq_is_not_structural_eq(self):
|
||||
"""Confirms that == builds an expression, while structurally_equals compares."""
|
||||
# `==` returns a BinaryExpr, not a boolean
|
||||
op_eq_expr = col("a") == col("a")
|
||||
assert isinstance(op_eq_expr, Expr)
|
||||
assert not isinstance(op_eq_expr, bool)
|
||||
|
||||
# `structurally_equals` returns a boolean
|
||||
struct_eq_result = col("a").structurally_equals(col("a"))
|
||||
assert isinstance(struct_eq_result, bool)
|
||||
assert struct_eq_result is True
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Expression Repr Tests
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
def _build_complex_expr():
|
||||
"""Build a convoluted expression that exercises all visitor code paths."""
|
||||
|
||||
def custom_udf(x, y):
|
||||
return x + y
|
||||
|
||||
# Create UDF expression
|
||||
udf_expr = UDFExpr(
|
||||
fn=custom_udf,
|
||||
args=[col("value"), lit(10)],
|
||||
kwargs={"z": col("multiplier")},
|
||||
data_type=DataType(int),
|
||||
)
|
||||
|
||||
# Build the mega-complex expression
|
||||
inner_expr = (
|
||||
((col("age") + lit(10)) * col("rate") / lit(2.5) >= lit(100))
|
||||
& (
|
||||
col("name").is_not_null()
|
||||
| (col("status").is_in(["active", "pending"]) & col("verified"))
|
||||
)
|
||||
& ((col("count") - lit(5)) // lit(2) <= col("limit"))
|
||||
& ~(col("deleted").is_null() | (col("score") != lit(0)))
|
||||
& (download("uri") < star())
|
||||
& (udf_expr.alias("udf_result") > lit(50))
|
||||
).alias("complex_filter")
|
||||
|
||||
return ~inner_expr
|
||||
|
||||
|
||||
class TestExpressionRepr:
|
||||
"""Test expression string representations."""
|
||||
|
||||
def test_tree_repr(self):
|
||||
"""Test tree representation of expressions."""
|
||||
expr = _build_complex_expr()
|
||||
expected = """NOT
|
||||
└── operand: ALIAS('complex_filter')
|
||||
└── AND
|
||||
├── left: AND
|
||||
│ ├── left: AND
|
||||
│ │ ├── left: AND
|
||||
│ │ │ ├── left: AND
|
||||
│ │ │ │ ├── left: GE
|
||||
│ │ │ │ │ ├── left: DIV
|
||||
│ │ │ │ │ │ ├── left: MUL
|
||||
│ │ │ │ │ │ │ ├── left: ADD
|
||||
│ │ │ │ │ │ │ │ ├── left: COL('age')
|
||||
│ │ │ │ │ │ │ │ └── right: LIT(10)
|
||||
│ │ │ │ │ │ │ └── right: COL('rate')
|
||||
│ │ │ │ │ │ └── right: LIT(2.5)
|
||||
│ │ │ │ │ └── right: LIT(100)
|
||||
│ │ │ │ └── right: OR
|
||||
│ │ │ │ ├── left: IS_NOT_NULL
|
||||
│ │ │ │ │ └── operand: COL('name')
|
||||
│ │ │ │ └── right: AND
|
||||
│ │ │ │ ├── left: IN
|
||||
│ │ │ │ │ ├── left: COL('status')
|
||||
│ │ │ │ │ └── right: LIT(['active', 'pending'])
|
||||
│ │ │ │ └── right: COL('verified')
|
||||
│ │ │ └── right: LE
|
||||
│ │ │ ├── left: FLOORDIV
|
||||
│ │ │ │ ├── left: SUB
|
||||
│ │ │ │ │ ├── left: COL('count')
|
||||
│ │ │ │ │ └── right: LIT(5)
|
||||
│ │ │ │ └── right: LIT(2)
|
||||
│ │ │ └── right: COL('limit')
|
||||
│ │ └── right: NOT
|
||||
│ │ └── operand: OR
|
||||
│ │ ├── left: IS_NULL
|
||||
│ │ │ └── operand: COL('deleted')
|
||||
│ │ └── right: NE
|
||||
│ │ ├── left: COL('score')
|
||||
│ │ └── right: LIT(0)
|
||||
│ └── right: LT
|
||||
│ ├── left: DOWNLOAD('uri')
|
||||
│ └── right: COL(*)
|
||||
└── right: GT
|
||||
├── left: ALIAS('udf_result')
|
||||
│ └── UDF(custom_udf)
|
||||
│ ├── arg[0]: COL('value')
|
||||
│ ├── arg[1]: LIT(10)
|
||||
│ └── kwarg['z']: COL('multiplier')
|
||||
└── right: LIT(50)"""
|
||||
assert repr(expr) == expected
|
||||
|
||||
def test_inline_repr_prefix(self):
|
||||
"""Test that inline representation starts correctly."""
|
||||
expr = _build_complex_expr()
|
||||
visitor = _InlineExprReprVisitor()
|
||||
inline_repr = visitor.visit(expr)
|
||||
expected_prefix = "~((((((((col('age') + 10) * col('rate')) / 2.5) >= 100) & (col('name').is_not_null() | ((col('status')"
|
||||
assert inline_repr.startswith(expected_prefix)
|
||||
assert inline_repr.endswith(".alias('complex_filter')")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Unit tests for list namespace expressions.
|
||||
|
||||
These tests verify expression construction logic without requiring Ray.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from ray.data.expressions import col
|
||||
|
||||
|
||||
class TestListNamespaceErrors:
|
||||
"""Tests for proper error handling in list namespace."""
|
||||
|
||||
def test_list_invalid_index_type(self):
|
||||
"""Test list bracket notation rejects invalid types."""
|
||||
with pytest.raises(TypeError, match="List indices must be integers or slices"):
|
||||
col("items").list["invalid"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Tests for predicate expression operations.
|
||||
|
||||
This module tests:
|
||||
- Null predicates: is_null(), is_not_null()
|
||||
- Membership predicates: is_in(), not_in()
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from ray.data._internal.planner.plan_expression.expression_evaluator import eval_expr
|
||||
from ray.data.expressions import BinaryExpr, Operation, UnaryExpr, col, lit
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Null Predicate Operations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsNull:
|
||||
"""Tests for is_null() predicate."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data with null values for null predicate tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"value": [1.0, None, 3.0, None, 5.0],
|
||||
"name": ["Alice", None, "Charlie", "Diana", None],
|
||||
}
|
||||
)
|
||||
|
||||
def test_is_null_numeric(self, sample_data):
|
||||
"""Test is_null on numeric column."""
|
||||
expr = col("value").is_null()
|
||||
assert isinstance(expr, UnaryExpr)
|
||||
assert expr.op == Operation.IS_NULL
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, True, False, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_is_null_string(self, sample_data):
|
||||
"""Test is_null on string column."""
|
||||
expr = col("name").is_null()
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, True, False, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_is_null_structural_equality(self):
|
||||
"""Test structural equality for is_null expressions."""
|
||||
expr1 = col("value").is_null()
|
||||
expr2 = col("value").is_null()
|
||||
expr3 = col("other").is_null()
|
||||
|
||||
assert expr1.structurally_equals(expr2)
|
||||
assert not expr1.structurally_equals(expr3)
|
||||
|
||||
|
||||
class TestIsNotNull:
|
||||
"""Tests for is_not_null() predicate."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data with null values."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"value": [1.0, None, 3.0, None, 5.0],
|
||||
"name": ["Alice", None, "Charlie", "Diana", None],
|
||||
}
|
||||
)
|
||||
|
||||
def test_is_not_null_numeric(self, sample_data):
|
||||
"""Test is_not_null on numeric column."""
|
||||
expr = col("value").is_not_null()
|
||||
assert isinstance(expr, UnaryExpr)
|
||||
assert expr.op == Operation.IS_NOT_NULL
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_is_not_null_string(self, sample_data):
|
||||
"""Test is_not_null on string column."""
|
||||
expr = col("name").is_not_null()
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_is_not_null_structural_equality(self):
|
||||
"""Test structural equality for is_not_null expressions."""
|
||||
expr1 = col("value").is_not_null()
|
||||
expr2 = col("value").is_not_null()
|
||||
expr3 = col("other").is_not_null()
|
||||
|
||||
assert expr1.structurally_equals(expr2)
|
||||
assert not expr1.structurally_equals(expr3)
|
||||
|
||||
|
||||
class TestNullPredicateCombinations:
|
||||
"""Tests for null predicates combined with other operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data with null values and other columns."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"value": [10.0, None, 30.0, None, 50.0],
|
||||
"threshold": [5.0, 20.0, 25.0, 10.0, 40.0],
|
||||
}
|
||||
)
|
||||
|
||||
def test_null_aware_comparison(self, sample_data):
|
||||
"""Test null-aware comparison (is_not_null AND comparison)."""
|
||||
# Filter: value is not null AND value > threshold
|
||||
expr = col("value").is_not_null() & (col("value") > col("threshold"))
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_is_null_or_condition(self, sample_data):
|
||||
"""Test is_null combined with OR."""
|
||||
# value is null OR value > 40
|
||||
expr = col("value").is_null() | (col("value") > 40)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, True, False, True, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# Membership Predicate Operations
|
||||
# ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsIn:
|
||||
"""Tests for is_in() predicate."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for membership tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"status": ["active", "inactive", "pending", "active", "deleted"],
|
||||
"category": ["A", "B", "C", "A", "D"],
|
||||
"value": [1, 2, 3, 4, 5],
|
||||
}
|
||||
)
|
||||
|
||||
def test_is_in_string_list(self, sample_data):
|
||||
"""Test is_in with string list."""
|
||||
expr = col("status").is_in(["active", "pending"])
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.IN
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_is_in_single_value_list(self, sample_data):
|
||||
"""Test is_in with single-value list."""
|
||||
expr = col("status").is_in(["active"])
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, False, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_is_in_numeric_list(self, sample_data):
|
||||
"""Test is_in with numeric list."""
|
||||
expr = col("value").is_in([1, 3, 5])
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_is_in_empty_list(self, sample_data):
|
||||
"""Test is_in with empty list (should return all False)."""
|
||||
expr = col("status").is_in([])
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([False, False, False, False, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_is_in_with_literal_expr(self, sample_data):
|
||||
"""Test is_in with LiteralExpr containing list."""
|
||||
values_expr = lit(["A", "C"])
|
||||
expr = col("category").is_in(values_expr)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_is_in_structural_equality(self):
|
||||
"""Test structural equality for is_in expressions."""
|
||||
expr1 = col("status").is_in(["active", "pending"])
|
||||
expr2 = col("status").is_in(["active", "pending"])
|
||||
expr3 = col("status").is_in(["active"])
|
||||
|
||||
assert expr1.structurally_equals(expr2)
|
||||
assert not expr1.structurally_equals(expr3)
|
||||
|
||||
|
||||
class TestNotIn:
|
||||
"""Tests for not_in() predicate."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for membership tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"status": ["active", "inactive", "pending", "active", "deleted"],
|
||||
"value": [1, 2, 3, 4, 5],
|
||||
}
|
||||
)
|
||||
|
||||
def test_not_in_string_list(self, sample_data):
|
||||
"""Test not_in with string list."""
|
||||
expr = col("status").not_in(["inactive", "deleted"])
|
||||
assert isinstance(expr, BinaryExpr)
|
||||
assert expr.op == Operation.NOT_IN
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_not_in_numeric_list(self, sample_data):
|
||||
"""Test not_in with numeric list."""
|
||||
expr = col("value").not_in([2, 4])
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, False, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_not_in_empty_list(self, sample_data):
|
||||
"""Test not_in with empty list (should return all True)."""
|
||||
expr = col("status").not_in([])
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, True, True, True, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_not_in_structural_equality(self):
|
||||
"""Test structural equality for not_in expressions."""
|
||||
expr1 = col("status").not_in(["deleted"])
|
||||
expr2 = col("status").not_in(["deleted"])
|
||||
expr3 = col("status").not_in(["deleted", "inactive"])
|
||||
|
||||
assert expr1.structurally_equals(expr2)
|
||||
assert not expr1.structurally_equals(expr3)
|
||||
|
||||
|
||||
class TestMembershipWithNulls:
|
||||
"""Tests for membership predicates with null values."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data with null values for membership tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"status": ["active", None, "pending", None, "deleted"],
|
||||
"value": [1, None, 3, None, 5],
|
||||
}
|
||||
)
|
||||
|
||||
def test_is_in_with_nulls_in_data(self, sample_data):
|
||||
"""Test is_in when data contains nulls."""
|
||||
expr = col("status").is_in(["active", "pending"])
|
||||
result = eval_expr(expr, sample_data)
|
||||
# Nulls should return False (null is not in any list)
|
||||
expected = pd.Series([True, False, True, False, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_not_in_with_nulls_in_data(self, sample_data):
|
||||
"""Test not_in when data contains nulls."""
|
||||
expr = col("status").not_in(["active"])
|
||||
result = eval_expr(expr, sample_data)
|
||||
# Nulls should return True (null is not in the exclusion list)
|
||||
expected = pd.Series([False, True, True, True, True])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
|
||||
class TestMembershipCombinations:
|
||||
"""Tests for membership predicates combined with other operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_data(self):
|
||||
"""Sample data for combination tests."""
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"status": ["active", "inactive", "pending", "active", "deleted"],
|
||||
"priority": ["high", "low", "high", "medium", "low"],
|
||||
"value": [100, 50, 75, 200, 25],
|
||||
}
|
||||
)
|
||||
|
||||
def test_is_in_and_comparison(self, sample_data):
|
||||
"""Test is_in combined with comparison."""
|
||||
# status in ["active", "pending"] AND value > 50
|
||||
expr = col("status").is_in(["active", "pending"]) & (col("value") > 50)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_multiple_is_in(self, sample_data):
|
||||
"""Test multiple is_in predicates."""
|
||||
# status in ["active"] AND priority in ["high", "medium"]
|
||||
expr = col("status").is_in(["active"]) & col("priority").is_in(
|
||||
["high", "medium"]
|
||||
)
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, False, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
def test_is_in_or_not_in(self, sample_data):
|
||||
"""Test is_in combined with not_in."""
|
||||
# status in ["active"] OR priority not_in ["low"]
|
||||
expr = col("status").is_in(["active"]) | col("priority").not_in(["low"])
|
||||
result = eval_expr(expr, sample_data)
|
||||
expected = pd.Series([True, False, True, True, False])
|
||||
pd.testing.assert_series_equal(
|
||||
result.reset_index(drop=True), expected, check_names=False
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Tests for schema-aware expression resolution.
|
||||
|
||||
Covers ``Expr.get_type``, ``Expr.nullable``, ``Expr.to_field``, and the
|
||||
``exprlist_to_fields`` helper. These are the building blocks Phase 1
|
||||
operators (``Project``, ``Aggregate``, ``Join``, etc.) use to compute
|
||||
``infer_schema()`` without executing the plan.
|
||||
"""
|
||||
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
|
||||
from ray.data.datatype import DataType
|
||||
from ray.data.expressions import (
|
||||
DownloadExpr,
|
||||
MonotonicallyIncreasingIdExpr,
|
||||
RandomExpr,
|
||||
UDFExpr,
|
||||
UUIDExpr,
|
||||
col,
|
||||
expand_star_exprs,
|
||||
exprlist_to_fields,
|
||||
lit,
|
||||
star,
|
||||
udf,
|
||||
)
|
||||
from ray.data.tests.util import assert_exprs_equal
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schema():
|
||||
return pa.schema(
|
||||
[
|
||||
pa.field("a", pa.int32(), nullable=True),
|
||||
pa.field("b", pa.float32(), nullable=False),
|
||||
pa.field("name", pa.string(), nullable=True),
|
||||
pa.field("flag", pa.bool_(), nullable=True),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestColumnExpr:
|
||||
def test_to_field_returns_input_field_verbatim(self):
|
||||
expected = pa.field("x", pa.int64(), nullable=False, metadata={b"k": b"v"})
|
||||
in_schema = pa.schema([expected])
|
||||
assert col("x").to_field(in_schema) == expected
|
||||
|
||||
def test_to_field_int_column(self, schema):
|
||||
assert col("a").to_field(schema) == pa.field("a", pa.int32(), nullable=True)
|
||||
|
||||
def test_to_field_non_nullable_column(self, schema):
|
||||
assert col("b").to_field(schema) == pa.field("b", pa.float32(), nullable=False)
|
||||
|
||||
def test_to_field_missing(self, schema):
|
||||
assert col("missing").to_field(schema) is None
|
||||
|
||||
|
||||
class TestLiteralExpr:
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
(5, pa.field("v", pa.int64(), nullable=False)),
|
||||
(5.0, pa.field("v", pa.float64(), nullable=False)),
|
||||
("hello", pa.field("v", pa.string(), nullable=False)),
|
||||
(True, pa.field("v", pa.bool_(), nullable=False)),
|
||||
(None, pa.field("v", pa.null(), nullable=True)),
|
||||
],
|
||||
)
|
||||
def test_to_field(self, schema, value, expected):
|
||||
# ``LiteralExpr`` has no name, so ``to_field`` returns ``None``;
|
||||
# we wrap in an alias to get a named field.
|
||||
assert lit(value).alias("v").to_field(schema) == expected
|
||||
|
||||
|
||||
class TestBinaryExpr:
|
||||
@pytest.mark.parametrize(
|
||||
"expr_factory,expected",
|
||||
[
|
||||
# int32 + int64 -> int64 (PyArrow promotion); a is nullable.
|
||||
(lambda: col("a") + lit(5), pa.field("out", pa.int64(), nullable=True)),
|
||||
# int32 + float32 -> float; a is nullable, so output is nullable.
|
||||
(lambda: col("a") + col("b"), pa.field("out", pa.float32(), nullable=True)),
|
||||
# float32 * float64 -> double; both operands are non-nullable
|
||||
# (b is non-null, literal is not None) -> output is non-nullable.
|
||||
(
|
||||
lambda: col("b") * lit(2.0),
|
||||
pa.field("out", pa.float64(), nullable=False),
|
||||
),
|
||||
# comparisons -> bool
|
||||
(lambda: col("a") > lit(0), pa.field("out", pa.bool_(), nullable=True)),
|
||||
(lambda: col("a") == lit(1), pa.field("out", pa.bool_(), nullable=True)),
|
||||
# logical -> bool
|
||||
(
|
||||
lambda: col("flag") & col("flag"),
|
||||
pa.field("out", pa.bool_(), nullable=True),
|
||||
),
|
||||
(
|
||||
lambda: col("flag") | col("flag"),
|
||||
pa.field("out", pa.bool_(), nullable=True),
|
||||
),
|
||||
# in/not_in -> bool
|
||||
(
|
||||
lambda: col("a").is_in([1, 2, 3]),
|
||||
pa.field("out", pa.bool_(), nullable=True),
|
||||
),
|
||||
(
|
||||
lambda: col("a").not_in([1, 2, 3]),
|
||||
pa.field("out", pa.bool_(), nullable=True),
|
||||
),
|
||||
# string concat
|
||||
(
|
||||
lambda: col("name") + lit("!"),
|
||||
pa.field("out", pa.string(), nullable=True),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_to_field(self, schema, expr_factory, expected):
|
||||
assert expr_factory().alias("out").to_field(schema) == expected
|
||||
|
||||
def test_unresolvable_returns_none(self, schema):
|
||||
assert (col("missing") + lit(1)).alias("x").to_field(schema) is None
|
||||
|
||||
|
||||
class TestUnaryExpr:
|
||||
def test_is_null(self, schema):
|
||||
expected = pa.field("isnull", pa.bool_(), nullable=False)
|
||||
assert col("a").is_null().alias("isnull").to_field(schema) == expected
|
||||
|
||||
def test_is_not_null(self, schema):
|
||||
expected = pa.field("isnotnull", pa.bool_(), nullable=False)
|
||||
assert col("a").is_not_null().alias("isnotnull").to_field(schema) == expected
|
||||
|
||||
def test_not_bool(self, schema):
|
||||
expected = pa.field("neg", pa.bool_(), nullable=True)
|
||||
assert (~col("flag")).alias("neg").to_field(schema) == expected
|
||||
|
||||
|
||||
class TestAliasExpr:
|
||||
def test_to_field_renames(self, schema):
|
||||
# Wraps a column field; alias swaps the name, preserves type/nullability.
|
||||
assert col("a").alias("renamed").to_field(schema) == pa.field(
|
||||
"renamed", pa.int32(), nullable=True
|
||||
)
|
||||
|
||||
def test_to_field_around_binary(self, schema):
|
||||
assert (col("a") + col("b")).alias("sum").to_field(schema) == pa.field(
|
||||
"sum", pa.float32(), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class TestSelfContainedExprs:
|
||||
def test_udf_uses_return_dtype(self, schema):
|
||||
@udf(return_dtype=DataType.float64()) # pyrefly: ignore[missing-attribute]
|
||||
def double(x):
|
||||
return x
|
||||
|
||||
assert double(col("a")).alias("d").to_field( # pyrefly: ignore[not-callable]
|
||||
schema
|
||||
) == pa.field("d", pa.float64(), nullable=True)
|
||||
|
||||
def test_download_is_binary(self, schema):
|
||||
assert DownloadExpr("uri").alias("bytes").to_field(schema) == pa.field(
|
||||
"bytes", pa.binary(), nullable=True
|
||||
)
|
||||
|
||||
def test_monotonically_increasing_id(self, schema):
|
||||
assert MonotonicallyIncreasingIdExpr().alias("id").to_field(schema) == pa.field(
|
||||
"id", pa.int64(), nullable=False
|
||||
)
|
||||
|
||||
def test_random(self, schema):
|
||||
assert RandomExpr().alias("r").to_field(schema) == pa.field(
|
||||
"r", pa.float64(), nullable=False
|
||||
)
|
||||
|
||||
def test_uuid(self, schema):
|
||||
assert UUIDExpr().alias("u").to_field(schema) == pa.field(
|
||||
"u", pa.string(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class TestStarExpr:
|
||||
def test_to_field_returns_none(self, schema):
|
||||
# ``StarExpr`` represents many columns; ``exprlist_to_fields``
|
||||
# expands it inline rather than calling ``to_field`` on it.
|
||||
assert star().to_field(schema) is None
|
||||
|
||||
|
||||
class TestExprlistToFields:
|
||||
def test_simple_columns(self, schema):
|
||||
expected = pa.schema(
|
||||
[
|
||||
pa.field("a", pa.int32(), nullable=True),
|
||||
pa.field("b", pa.float32(), nullable=False),
|
||||
]
|
||||
)
|
||||
result = pa.schema(exprlist_to_fields([col("a"), col("b")], schema))
|
||||
assert result == expected
|
||||
|
||||
def test_star_expansion(self, schema):
|
||||
# Star expands to all input fields verbatim.
|
||||
result = pa.schema(exprlist_to_fields([star()], schema))
|
||||
assert result == schema
|
||||
|
||||
def test_star_with_rename(self, schema):
|
||||
result = pa.schema(
|
||||
exprlist_to_fields([star(), col("a")._rename("renamed_a")], schema)
|
||||
)
|
||||
# Renaming "a" -> "renamed_a" substitutes the renamed field at
|
||||
# "a"'s position (matching runtime ``eval_projection``).
|
||||
expected = pa.schema(
|
||||
[
|
||||
pa.field("renamed_a", pa.int32(), nullable=True),
|
||||
pa.field("b", pa.float32(), nullable=False),
|
||||
pa.field("name", pa.string(), nullable=True),
|
||||
pa.field("flag", pa.bool_(), nullable=True),
|
||||
]
|
||||
)
|
||||
assert result == expected
|
||||
|
||||
def test_star_with_rename_missing_source_returns_none(self, schema):
|
||||
# Renaming an absent column must fail resolution (matching the
|
||||
# runtime, which raises "column not found"), not silently append.
|
||||
assert exprlist_to_fields([star(), col("missing")._rename("x")], schema) is None
|
||||
|
||||
def test_star_with_with_column(self, schema):
|
||||
# with_column-style: [star(), expr.alias(name)] preserves all input
|
||||
# columns and appends the new computed column.
|
||||
result = pa.schema(
|
||||
exprlist_to_fields([star(), (col("a") + col("b")).alias("sum")], schema)
|
||||
)
|
||||
expected = pa.schema(
|
||||
list(schema) + [pa.field("sum", pa.float32(), nullable=True)]
|
||||
)
|
||||
assert result == expected
|
||||
|
||||
def test_returns_none_on_unresolvable(self, schema):
|
||||
assert exprlist_to_fields([col("missing")], schema) is None
|
||||
|
||||
def test_with_column_overrides_existing_column(self, schema):
|
||||
# ``with_column("a", new_expr)`` builds ``[star(), new_expr.alias("a")]``.
|
||||
# The override should replace the existing "a" in place (last-wins,
|
||||
# matching runtime ``eval_projection``'s upsert behavior), not
|
||||
# produce a duplicate.
|
||||
result = pa.schema(
|
||||
exprlist_to_fields([star(), (col("a") + lit(10)).alias("a")], schema)
|
||||
)
|
||||
expected = pa.schema(
|
||||
[
|
||||
pa.field("a", pa.int64(), nullable=True), # new type from a + 10
|
||||
pa.field("b", pa.float32(), nullable=False),
|
||||
pa.field("name", pa.string(), nullable=True),
|
||||
pa.field("flag", pa.bool_(), nullable=True),
|
||||
]
|
||||
)
|
||||
assert result == expected
|
||||
|
||||
def test_returns_none_on_udf_without_return_dtype(self, schema):
|
||||
# Construct a synthetic UDFExpr with object data_type to simulate
|
||||
# the "untyped UDF" case.
|
||||
e = UDFExpr(
|
||||
fn=lambda x: x,
|
||||
args=[col("a")],
|
||||
kwargs={},
|
||||
data_type=DataType(object),
|
||||
)
|
||||
assert exprlist_to_fields([e.alias("out")], schema) is None
|
||||
|
||||
|
||||
class TestExpandStarExprs:
|
||||
"""Tests for ``expand_star_exprs`` (eager expansion in ``Project``)."""
|
||||
|
||||
def test_passthrough_without_star(self, schema):
|
||||
# No StarExpr -> input list returned unchanged.
|
||||
exprs = [col("a"), (col("a") + col("b")).alias("sum")]
|
||||
assert expand_star_exprs(exprs, schema) is exprs
|
||||
|
||||
def test_passthrough_when_schema_is_none(self):
|
||||
exprs = [star(), col("a")]
|
||||
assert expand_star_exprs(exprs, None) is exprs
|
||||
|
||||
def test_simple_star(self, schema):
|
||||
# ``[star()]`` -> one ``col()`` per input column.
|
||||
result = expand_star_exprs([star()], schema)
|
||||
assert_exprs_equal(result, [col("a"), col("b"), col("name"), col("flag")])
|
||||
|
||||
def test_star_with_with_column(self, schema):
|
||||
# ``with_column``-style: ``[star(), expr.alias("new")]`` expands
|
||||
# to ``[col(a), col(b), col(name), col(flag), expr.alias("new")]``.
|
||||
new_expr = (col("a") + col("b")).alias("new")
|
||||
result = expand_star_exprs([star(), new_expr], schema)
|
||||
assert_exprs_equal(
|
||||
result, [col("a"), col("b"), col("name"), col("flag"), new_expr]
|
||||
)
|
||||
|
||||
def test_star_with_rename(self, schema):
|
||||
# ``rename_columns({"a": "renamed_a"})``: the rename substitutes for
|
||||
# its source column *in place* (matching runtime ``eval_projection``
|
||||
# / ``exprlist_to_fields``), so ``a`` becomes ``renamed_a`` at
|
||||
# position 0 rather than moving to the end.
|
||||
rename = col("a")._rename("renamed_a")
|
||||
result = expand_star_exprs([star(), rename], schema)
|
||||
assert_exprs_equal(result, [rename, col("b"), col("name"), col("flag")])
|
||||
|
||||
def test_star_with_rename_source_missing(self, schema):
|
||||
# A rename whose source column isn't in the input schema stays in
|
||||
# its trailing position so it still errors ("column not found") at
|
||||
# runtime instead of being silently dropped.
|
||||
rename = col("missing")._rename("renamed")
|
||||
result = expand_star_exprs([star(), rename], schema)
|
||||
assert_exprs_equal(
|
||||
result, [col("a"), col("b"), col("name"), col("flag"), rename]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main([__file__, "-xvs"]))
|
||||
Reference in New Issue
Block a user