chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -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"]))