chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,940 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import logging
|
||||
import operator
|
||||
from typing import Any, Callable, Dict, List, Optional, TypeVar, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pyarrow.compute as pc
|
||||
import pyarrow.dataset as ds
|
||||
|
||||
from ray.data._internal.execution.interfaces.task_context import TaskContext
|
||||
from ray.data._internal.logical.rules.projection_pushdown import (
|
||||
_extract_input_columns_renaming_mapping,
|
||||
)
|
||||
from ray.data.block import Block, BlockAccessor, BlockColumn, BlockType
|
||||
from ray.data.expressions import (
|
||||
AliasExpr,
|
||||
BinaryExpr,
|
||||
ColumnExpr,
|
||||
DownloadExpr,
|
||||
Expr,
|
||||
LiteralExpr,
|
||||
MonotonicallyIncreasingIdExpr,
|
||||
Operation,
|
||||
RandomExpr,
|
||||
StarExpr,
|
||||
UDFExpr,
|
||||
UnaryExpr,
|
||||
UUIDExpr,
|
||||
_ExprVisitor,
|
||||
col,
|
||||
is_rename_expr,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _pa_is_in(left: Any, right: Any) -> Any:
|
||||
if not isinstance(right, (pa.Array, pa.ChunkedArray)):
|
||||
right = pa.array(right.as_py() if isinstance(right, pa.Scalar) else right)
|
||||
return pc.is_in(left, right)
|
||||
|
||||
|
||||
_PANDAS_EXPR_OPS_MAP: Dict[Operation, Callable[..., Any]] = {
|
||||
Operation.ADD: operator.add,
|
||||
Operation.SUB: operator.sub,
|
||||
Operation.MUL: operator.mul,
|
||||
Operation.DIV: operator.truediv,
|
||||
Operation.MOD: operator.mod,
|
||||
Operation.FLOORDIV: operator.floordiv,
|
||||
Operation.GT: operator.gt,
|
||||
Operation.LT: operator.lt,
|
||||
Operation.GE: operator.ge,
|
||||
Operation.LE: operator.le,
|
||||
Operation.EQ: operator.eq,
|
||||
Operation.NE: operator.ne,
|
||||
Operation.AND: operator.and_,
|
||||
Operation.OR: operator.or_,
|
||||
Operation.NOT: operator.invert,
|
||||
Operation.IS_NULL: pd.isna,
|
||||
Operation.IS_NOT_NULL: pd.notna,
|
||||
Operation.IN: lambda left, right: left.isin(right),
|
||||
Operation.NOT_IN: lambda left, right: ~left.isin(right),
|
||||
}
|
||||
|
||||
|
||||
def _is_pa_string_type(t: pa.DataType) -> bool:
|
||||
return pa.types.is_string(t) or pa.types.is_large_string(t)
|
||||
|
||||
|
||||
def _is_pa_string_like(x: Union[pa.Array, pa.ChunkedArray]) -> bool:
|
||||
t = x.type
|
||||
if pa.types.is_dictionary(t):
|
||||
t = t.value_type
|
||||
return _is_pa_string_type(t)
|
||||
|
||||
|
||||
def _pa_decode_dict_string_array(x: Union[pa.Array, pa.ChunkedArray]) -> Any:
|
||||
"""Convert Arrow dictionary-encoded string arrays to regular string arrays.
|
||||
|
||||
Dictionary encoding stores strings as indices into a dictionary of unique values.
|
||||
This function converts them back to regular string arrays for string operations.
|
||||
|
||||
Example:
|
||||
# Input: pa.array(['a', 'b']).dictionary_encode()
|
||||
# -- dictionary: ["a", "b"]
|
||||
# -- indices: [0, 1]
|
||||
# Output: regular string array ["a", "b"]
|
||||
Args:
|
||||
x: The input array to convert.
|
||||
Returns:
|
||||
The converted string array.
|
||||
"""
|
||||
if pa.types.is_dictionary(x.type) and _is_pa_string_type(x.type.value_type):
|
||||
return pc.cast(x, pa.string())
|
||||
return x
|
||||
|
||||
|
||||
def _to_pa_string_input(x: Any) -> Any:
|
||||
if isinstance(x, str):
|
||||
return pa.scalar(x)
|
||||
if isinstance(x, (pa.Array, pa.ChunkedArray)) and _is_pa_string_like(x):
|
||||
return _pa_decode_dict_string_array(x)
|
||||
actual_type = (
|
||||
str(x.type) if isinstance(x, (pa.Array, pa.ChunkedArray)) else type(x).__name__
|
||||
)
|
||||
raise TypeError(
|
||||
"Expected string or string-like pyarrow Array/ChunkedArray for string "
|
||||
f"concatenation, got {actual_type}."
|
||||
)
|
||||
|
||||
|
||||
def _pa_add_or_concat(left: Any, right: Any) -> Any:
|
||||
if isinstance(left, pa.Scalar):
|
||||
left = left.as_py()
|
||||
if isinstance(right, pa.Scalar):
|
||||
right = right.as_py()
|
||||
# If either side is string-like, perform string concatenation.
|
||||
if (
|
||||
isinstance(left, str)
|
||||
or isinstance(right, str)
|
||||
or (isinstance(left, (pa.Array, pa.ChunkedArray)) and _is_pa_string_like(left))
|
||||
or (
|
||||
isinstance(right, (pa.Array, pa.ChunkedArray)) and _is_pa_string_like(right)
|
||||
)
|
||||
):
|
||||
left_input = _to_pa_string_input(left)
|
||||
right_input = _to_pa_string_input(right)
|
||||
return pc.binary_join_element_wise(left_input, right_input, "")
|
||||
return pc.add(left, right)
|
||||
|
||||
|
||||
_ARROW_EXPR_OPS_MAP: Dict[Operation, Callable[..., Any]] = {
|
||||
Operation.ADD: _pa_add_or_concat,
|
||||
Operation.SUB: pc.subtract,
|
||||
Operation.MUL: pc.multiply,
|
||||
Operation.DIV: pc.divide,
|
||||
Operation.MOD: lambda left, right: (
|
||||
# Modulo op is essentially:
|
||||
# r = N - floor(N/M) * M
|
||||
pc.subtract(left, pc.multiply(pc.floor(pc.divide(left, right)), right))
|
||||
),
|
||||
Operation.FLOORDIV: lambda left, right: pc.floor(pc.divide(left, right)),
|
||||
Operation.GT: pc.greater,
|
||||
Operation.LT: pc.less,
|
||||
Operation.GE: pc.greater_equal,
|
||||
Operation.LE: pc.less_equal,
|
||||
Operation.EQ: pc.equal,
|
||||
Operation.NE: pc.not_equal,
|
||||
Operation.AND: pc.and_kleene,
|
||||
Operation.OR: pc.or_kleene,
|
||||
Operation.NOT: pc.invert,
|
||||
Operation.IS_NULL: pc.is_null,
|
||||
Operation.IS_NOT_NULL: pc.is_valid,
|
||||
Operation.IN: _pa_is_in,
|
||||
Operation.NOT_IN: lambda left, right: pc.invert(_pa_is_in(left, right)),
|
||||
}
|
||||
|
||||
|
||||
# NOTE: (srinathk) There are 3 distinct stages of handling passed in exprs:
|
||||
# 1. Parsing it (as text)
|
||||
# 2. Resolving unbound names (to schema)
|
||||
# 3. Converting resolved expressions to PA ones
|
||||
# Need to break up the abstraction provided by ExpressionEvaluator.
|
||||
|
||||
ScalarType = TypeVar("ScalarType")
|
||||
|
||||
|
||||
class ExpressionEvaluator:
|
||||
@staticmethod
|
||||
def get_filters(expression: str) -> ds.Expression:
|
||||
"""Parse and evaluate the expression to generate a filter condition.
|
||||
|
||||
Args:
|
||||
expression: A string representing the filter expression to parse.
|
||||
|
||||
Returns:
|
||||
A PyArrow compute expression for filtering data.
|
||||
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
return _ConvertToArrowExpressionVisitor().visit(tree.body)
|
||||
except SyntaxError as e:
|
||||
raise ValueError(f"Invalid syntax in the expression: {expression}") from e
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing expression: {e}")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def parse_native_expression(expression: str) -> "Expr":
|
||||
"""Parse and evaluate the expression to generate a Ray Data expression.
|
||||
|
||||
Args:
|
||||
expression: A string representing the filter expression to parse.
|
||||
|
||||
Returns:
|
||||
A Ray Data Expr object for filtering data.
|
||||
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
return _ConvertToNativeExpressionVisitor().visit(tree.body)
|
||||
except SyntaxError as e:
|
||||
raise ValueError(f"Invalid syntax in the expression: {expression}") from e
|
||||
except Exception as e:
|
||||
logger.exception(f"Error processing expression: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class _ConvertToArrowExpressionVisitor(ast.NodeVisitor):
|
||||
# TODO: Deprecate this visitor after we remove string support in filter API.
|
||||
def visit_Compare(self, node: ast.Compare) -> ds.Expression:
|
||||
"""Handle comparison operations (e.g., a == b, a < b, a in b).
|
||||
|
||||
Args:
|
||||
node: The AST node representing a comparison operation.
|
||||
|
||||
Returns:
|
||||
An expression representing the comparison.
|
||||
"""
|
||||
# Handle left operand
|
||||
# TODO Validate columns
|
||||
if isinstance(node.left, ast.Attribute):
|
||||
# Visit and handle attributes
|
||||
left_expr = self.visit(node.left)
|
||||
elif isinstance(node.left, ast.Name):
|
||||
# Treat as a simple field
|
||||
left_expr = self.visit(node.left)
|
||||
elif isinstance(node.left, ast.Constant):
|
||||
# Constant values are used directly
|
||||
left_expr = node.left.value
|
||||
else:
|
||||
raise ValueError(f"Unsupported left operand type: {type(node.left)}")
|
||||
|
||||
comparators = [self.visit(comp) for comp in node.comparators]
|
||||
|
||||
op = node.ops[0]
|
||||
if isinstance(op, ast.In):
|
||||
return pc.is_in(left_expr, comparators[0])
|
||||
elif isinstance(op, ast.NotIn):
|
||||
return ~pc.is_in(left_expr, comparators[0])
|
||||
elif isinstance(op, ast.Eq):
|
||||
return left_expr == comparators[0]
|
||||
elif isinstance(op, ast.NotEq):
|
||||
return left_expr != comparators[0]
|
||||
elif isinstance(op, ast.Lt):
|
||||
return left_expr < comparators[0]
|
||||
elif isinstance(op, ast.LtE):
|
||||
return left_expr <= comparators[0]
|
||||
elif isinstance(op, ast.Gt):
|
||||
return left_expr > comparators[0]
|
||||
elif isinstance(op, ast.GtE):
|
||||
return left_expr >= comparators[0]
|
||||
else:
|
||||
raise ValueError(f"Unsupported operator type: {op}")
|
||||
|
||||
def visit_BoolOp(self, node: ast.BoolOp) -> ds.Expression:
|
||||
"""Handle logical operations (e.g., a and b, a or b).
|
||||
|
||||
Args:
|
||||
node: The AST node representing a boolean operation.
|
||||
|
||||
Returns:
|
||||
An expression representing the logical operation.
|
||||
"""
|
||||
conditions = [self.visit(value) for value in node.values]
|
||||
combined_expr = conditions[0]
|
||||
|
||||
for condition in conditions[1:]:
|
||||
if isinstance(node.op, ast.And):
|
||||
# Combine conditions with logical AND
|
||||
combined_expr &= condition
|
||||
elif isinstance(node.op, ast.Or):
|
||||
# Combine conditions with logical OR
|
||||
combined_expr |= condition
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported logical operator: {type(node.op).__name__}"
|
||||
)
|
||||
|
||||
return combined_expr
|
||||
|
||||
def visit_Name(self, node: ast.Name) -> ds.Expression:
|
||||
"""Handle variable (name) nodes and return them as pa.dataset.Expression.
|
||||
|
||||
Even if the name contains periods, it's treated as a single string.
|
||||
|
||||
Args:
|
||||
node: The AST node representing a variable.
|
||||
|
||||
Returns:
|
||||
The variable wrapped as a pa.dataset.Expression.
|
||||
"""
|
||||
# Directly use the field name as a string (even if it contains periods)
|
||||
field_name = node.id
|
||||
return pc.field(field_name)
|
||||
|
||||
def visit_Attribute(self, node: ast.Attribute) -> object:
|
||||
"""Handle attribute access (e.g., np.nan).
|
||||
|
||||
Args:
|
||||
node: The AST node representing an attribute access.
|
||||
|
||||
Returns:
|
||||
object: The attribute value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the attribute is unsupported.
|
||||
"""
|
||||
# Recursively visit the left side (base object or previous attribute)
|
||||
if isinstance(node.value, ast.Attribute):
|
||||
# If the value is an attribute, recursively resolve it
|
||||
left_expr = self.visit(node.value)
|
||||
return pc.field(f"{left_expr}.{node.attr}")
|
||||
|
||||
elif isinstance(node.value, ast.Name):
|
||||
# If the value is a name (e.g., "foo"), we can directly return the field
|
||||
left_name = node.value.id # The base name, e.g., "foo"
|
||||
return pc.field(f"{left_name}.{node.attr}")
|
||||
|
||||
raise ValueError(f"Unsupported attribute: {node.attr}")
|
||||
|
||||
def visit_List(self, node: ast.List) -> ds.Expression:
|
||||
"""Handle list literals.
|
||||
|
||||
Args:
|
||||
node: The AST node representing a list.
|
||||
|
||||
Returns:
|
||||
The list of elements wrapped as a pa.dataset.Expression.
|
||||
"""
|
||||
elements = [self.visit(elt) for elt in node.elts]
|
||||
return pa.array(elements)
|
||||
|
||||
def visit_UnaryOp(self, node: ast.UnaryOp) -> ds.Expression:
|
||||
"""Handle case where comparator is UnaryOP (e.g., a == -1).
|
||||
|
||||
AST for this expression will be Compare(left=Name(id='a'), ops=[Eq()],
|
||||
comparators=[UnaryOp(op=USub(), operand=Constant(value=1))])
|
||||
|
||||
Args:
|
||||
node: The constant value.
|
||||
|
||||
Returns:
|
||||
A PyArrow scalar expression representing the unary operation result.
|
||||
"""
|
||||
op = node.op
|
||||
if isinstance(op, ast.USub):
|
||||
return pc.scalar(-node.operand.value)
|
||||
else:
|
||||
raise ValueError(f"Unsupported unary operator: {op}")
|
||||
|
||||
# TODO (srinathk) Note that visit_Constant does not return pa.dataset.Expression
|
||||
# because to support function in() which takes in a List, the elements in the List
|
||||
# needs to values instead of pa.dataset.Expression per pyarrow.dataset.Expression
|
||||
# specification. May be down the road, we can update it as Arrow relaxes this
|
||||
# constraint.
|
||||
def visit_Constant(self, node: ast.Constant) -> object:
|
||||
"""Handle constant values (e.g., numbers, strings).
|
||||
|
||||
Args:
|
||||
node: The AST node representing a constant value.
|
||||
|
||||
Returns:
|
||||
object: The constant value itself (e.g., number, string, or boolean).
|
||||
"""
|
||||
return node.value # Return the constant value directly.
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> ds.Expression:
|
||||
"""Handle function calls (e.g., is_nan(a), is_valid(b)).
|
||||
|
||||
Args:
|
||||
node: The AST node representing a function call.
|
||||
|
||||
Returns:
|
||||
The corresponding expression based on the function called.
|
||||
|
||||
Raises:
|
||||
ValueError: If the function is unsupported or has incorrect arguments.
|
||||
"""
|
||||
func_name = node.func.id
|
||||
function_map = {
|
||||
"is_nan": lambda arg: arg.is_nan(),
|
||||
"is_null": lambda arg, nan_is_null=False: arg.is_null(
|
||||
nan_is_null=nan_is_null
|
||||
),
|
||||
"is_valid": lambda arg: arg.is_valid(),
|
||||
"is_in": lambda arg1, arg2: pc.is_in(arg1, arg2),
|
||||
}
|
||||
|
||||
if func_name in function_map:
|
||||
# Visit all arguments of the function call
|
||||
args = [self.visit(arg) for arg in node.args]
|
||||
# Handle the "is_null" function with one or two arguments
|
||||
if func_name == "is_null":
|
||||
if len(args) == 1:
|
||||
return function_map[func_name](args[0])
|
||||
elif len(args) == 2:
|
||||
return function_map[func_name](args[0], args[1])
|
||||
else:
|
||||
raise ValueError("is_null function requires one or two arguments.")
|
||||
# Handle the "is_in" function with exactly two arguments
|
||||
elif func_name == "is_in" and len(args) != 2:
|
||||
raise ValueError("is_in function requires two arguments.")
|
||||
# Ensure the function has one argument (for functions like is_valid)
|
||||
elif func_name != "is_in" and len(args) != 1:
|
||||
raise ValueError(f"{func_name} function requires exactly one argument.")
|
||||
# Call the corresponding function with the arguments
|
||||
return function_map[func_name](*args)
|
||||
else:
|
||||
raise ValueError(f"Unsupported function: {func_name}")
|
||||
|
||||
|
||||
class _ConvertToNativeExpressionVisitor(ast.NodeVisitor):
|
||||
"""AST visitor that converts string expressions to Ray Data expressions."""
|
||||
|
||||
def visit_Compare(self, node: ast.Compare) -> "Expr":
|
||||
"""Handle comparison operations (e.g., a == b, a < b, a in b)."""
|
||||
from ray.data.expressions import BinaryExpr, Operation
|
||||
|
||||
if len(node.ops) != 1 or len(node.comparators) != 1:
|
||||
raise ValueError("Only simple binary comparisons are supported")
|
||||
|
||||
left = self.visit(node.left)
|
||||
right = self.visit(node.comparators[0])
|
||||
op = node.ops[0]
|
||||
|
||||
# Map AST comparison operators to Ray Data operations
|
||||
op_map = {
|
||||
ast.Eq: Operation.EQ,
|
||||
ast.NotEq: Operation.NE,
|
||||
ast.Lt: Operation.LT,
|
||||
ast.LtE: Operation.LE,
|
||||
ast.Gt: Operation.GT,
|
||||
ast.GtE: Operation.GE,
|
||||
ast.In: Operation.IN,
|
||||
ast.NotIn: Operation.NOT_IN,
|
||||
}
|
||||
|
||||
if type(op) not in op_map:
|
||||
raise ValueError(f"Unsupported comparison operator: {type(op).__name__}")
|
||||
|
||||
return BinaryExpr(op_map[type(op)], left, right)
|
||||
|
||||
def visit_BoolOp(self, node: ast.BoolOp) -> "Expr":
|
||||
"""Handle logical operations (e.g., a and b, a or b)."""
|
||||
from ray.data.expressions import BinaryExpr, Operation
|
||||
|
||||
conditions = [self.visit(value) for value in node.values]
|
||||
combined_expr = conditions[0]
|
||||
|
||||
for condition in conditions[1:]:
|
||||
if isinstance(node.op, ast.And):
|
||||
combined_expr = BinaryExpr(Operation.AND, combined_expr, condition)
|
||||
elif isinstance(node.op, ast.Or):
|
||||
combined_expr = BinaryExpr(Operation.OR, combined_expr, condition)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported logical operator: {type(node.op).__name__}"
|
||||
)
|
||||
|
||||
return combined_expr
|
||||
|
||||
def visit_UnaryOp(self, node: ast.UnaryOp) -> "Expr":
|
||||
"""Handle unary operations (e.g., not a, -5)."""
|
||||
from ray.data.expressions import Operation, UnaryExpr, lit
|
||||
|
||||
if isinstance(node.op, ast.Not):
|
||||
operand = self.visit(node.operand)
|
||||
return UnaryExpr(Operation.NOT, operand)
|
||||
elif isinstance(node.op, ast.USub):
|
||||
operand = self.visit(node.operand)
|
||||
return operand * lit(-1)
|
||||
else:
|
||||
raise ValueError(f"Unsupported unary operator: {type(node.op).__name__}")
|
||||
|
||||
def visit_Name(self, node: ast.Name) -> "Expr":
|
||||
"""Handle variable names (column references)."""
|
||||
from ray.data.expressions import col
|
||||
|
||||
return col(node.id)
|
||||
|
||||
def visit_Constant(self, node: ast.Constant) -> "Expr":
|
||||
"""Handle constant values (numbers, strings, booleans)."""
|
||||
from ray.data.expressions import lit
|
||||
|
||||
return lit(node.value)
|
||||
|
||||
def visit_List(self, node: ast.List) -> "Expr":
|
||||
"""Handle list literals."""
|
||||
from ray.data.expressions import LiteralExpr, lit
|
||||
|
||||
# Visit all elements first
|
||||
visited_elements = [self.visit(elt) for elt in node.elts]
|
||||
|
||||
# Try to extract constant values for literal list
|
||||
elements = []
|
||||
for elem in visited_elements:
|
||||
if isinstance(elem, LiteralExpr):
|
||||
elements.append(elem.value)
|
||||
else:
|
||||
# For compatibility with Arrow visitor, we need to support non-literals
|
||||
# but Ray Data expressions may have limitations here
|
||||
raise ValueError(
|
||||
"List contains non-constant expressions. Ray Data expressions "
|
||||
"currently only support lists of constant values."
|
||||
)
|
||||
|
||||
return lit(elements)
|
||||
|
||||
def visit_Attribute(self, node: ast.Attribute) -> "Expr":
|
||||
"""Handle attribute access (e.g., for nested column names)."""
|
||||
from ray.data.expressions import col
|
||||
|
||||
# For nested column names like "user.age", combine them with dots
|
||||
if isinstance(node.value, ast.Name):
|
||||
return col(f"{node.value.id}.{node.attr}")
|
||||
elif isinstance(node.value, ast.Attribute):
|
||||
# Recursively handle nested attributes
|
||||
left_expr = self.visit(node.value)
|
||||
if isinstance(left_expr, ColumnExpr):
|
||||
return col(f"{left_expr._name}.{node.attr}")
|
||||
|
||||
raise ValueError(
|
||||
f"Unsupported attribute access: {node.attr}. Node details: {ast.dump(node)}"
|
||||
)
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> "Expr":
|
||||
"""Handle function calls for operations like is_null, is_not_null, is_nan, random."""
|
||||
from ray.data.expressions import (
|
||||
BinaryExpr,
|
||||
Operation,
|
||||
UnaryExpr,
|
||||
)
|
||||
|
||||
func_name = node.func.id if isinstance(node.func, ast.Name) else str(node.func)
|
||||
|
||||
if func_name == "is_null":
|
||||
if len(node.args) != 1:
|
||||
raise ValueError("is_null() expects exactly one argument")
|
||||
operand = self.visit(node.args[0])
|
||||
return UnaryExpr(Operation.IS_NULL, operand)
|
||||
# Adding this conditional to keep it consistent with the current implementation,
|
||||
# of carrying Pyarrow's semantic of `is_valid`
|
||||
elif func_name == "is_valid" or func_name == "is_not_null":
|
||||
if len(node.args) != 1:
|
||||
raise ValueError(f"{func_name}() expects exactly one argument")
|
||||
operand = self.visit(node.args[0])
|
||||
return UnaryExpr(Operation.IS_NOT_NULL, operand)
|
||||
elif func_name == "is_nan":
|
||||
if len(node.args) != 1:
|
||||
raise ValueError("is_nan() expects exactly one argument")
|
||||
operand = self.visit(node.args[0])
|
||||
# Use x != x pattern for NaN detection (NaN != NaN is True)
|
||||
return BinaryExpr(Operation.NE, operand, operand)
|
||||
elif func_name == "is_in":
|
||||
if len(node.args) != 2:
|
||||
raise ValueError("is_in() expects exactly two arguments")
|
||||
left = self.visit(node.args[0])
|
||||
right = self.visit(node.args[1])
|
||||
return BinaryExpr(Operation.IN, left, right)
|
||||
elif func_name == "random":
|
||||
raise ValueError(
|
||||
"random() is not supported in string expressions. "
|
||||
"String expressions are deprecated. Please use the expression API instead: "
|
||||
"from ray.data.expressions import random; ds.filter(expr=(random(seed=42)>0.5))"
|
||||
)
|
||||
elif func_name == "uuid":
|
||||
raise ValueError(
|
||||
"uuid() is not supported in string expressions. "
|
||||
"String expressions are deprecated. Please use the expression API instead: "
|
||||
"ds.filter(expr=uuid().str.starts_with('a'))"
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported function: {func_name}")
|
||||
|
||||
|
||||
class NativeExpressionEvaluator(_ExprVisitor[Union[BlockColumn, ScalarType]]):
|
||||
"""Visitor-based expression evaluator that uses Block and BlockColumns
|
||||
|
||||
This evaluator implements the visitor pattern to traverse expression trees
|
||||
and evaluate them against Block data structures. It maintains operation
|
||||
mappings in shared state and returns consistent BlockColumn types.
|
||||
"""
|
||||
|
||||
def __init__(self, block: Block):
|
||||
"""Initialize the evaluator with a block and operation mappings.
|
||||
|
||||
Args:
|
||||
block: The Block to evaluate expressions against.
|
||||
"""
|
||||
self.block = block
|
||||
self.block_accessor = BlockAccessor.for_block(block)
|
||||
|
||||
# Use BlockAccessor to determine operation mappings
|
||||
block_type = self.block_accessor.block_type()
|
||||
if block_type == BlockType.PANDAS:
|
||||
self.ops = _PANDAS_EXPR_OPS_MAP
|
||||
elif block_type == BlockType.ARROW:
|
||||
self.ops = _ARROW_EXPR_OPS_MAP
|
||||
else:
|
||||
raise TypeError(f"Unsupported block type: {block_type}")
|
||||
|
||||
def visit_column(self, expr: ColumnExpr) -> Union[BlockColumn, ScalarType]:
|
||||
"""Visit a column expression and return the column data.
|
||||
|
||||
Args:
|
||||
expr: The column expression.
|
||||
|
||||
Returns:
|
||||
The column data as a BlockColumn.
|
||||
"""
|
||||
return self.block[expr.name]
|
||||
|
||||
def visit_literal(self, expr: LiteralExpr) -> Union[BlockColumn, ScalarType]:
|
||||
"""Visit a literal expression and return the literal value.
|
||||
|
||||
Args:
|
||||
expr: The literal expression.
|
||||
|
||||
Returns:
|
||||
The literal value.
|
||||
"""
|
||||
# Given that expressions support pandas blocks, we need to return the value as is.
|
||||
# Pandas has multiple dtype_backends, so there's no guarantee on the return type.
|
||||
return expr.value
|
||||
|
||||
def visit_binary(self, expr: BinaryExpr) -> Union[BlockColumn, ScalarType]:
|
||||
"""Visit a binary expression and return the result of the operation.
|
||||
|
||||
Args:
|
||||
expr: The binary expression.
|
||||
|
||||
Returns:
|
||||
The result of the binary operation as a BlockColumn.
|
||||
"""
|
||||
left_result = self.visit(expr.left)
|
||||
right_result = self.visit(expr.right)
|
||||
|
||||
return self.ops[expr.op](left_result, right_result)
|
||||
|
||||
def visit_unary(self, expr: UnaryExpr) -> Union[BlockColumn, ScalarType]:
|
||||
"""Visit a unary expression and return the result of the operation.
|
||||
|
||||
Args:
|
||||
expr: The unary expression.
|
||||
|
||||
Returns:
|
||||
The result of the unary operation as a BlockColumn.
|
||||
"""
|
||||
operand_result = self.visit(expr.operand)
|
||||
return self.ops[expr.op](operand_result)
|
||||
|
||||
def visit_udf(self, expr: UDFExpr) -> Union[BlockColumn, ScalarType]:
|
||||
"""Visit a UDF expression and return the result of the function call.
|
||||
|
||||
Args:
|
||||
expr: The UDF expression.
|
||||
|
||||
Returns:
|
||||
The result of the UDF call as a BlockColumn.
|
||||
"""
|
||||
args = [self.visit(arg) for arg in expr.args]
|
||||
kwargs = {k: self.visit(v) for k, v in expr.kwargs.items()}
|
||||
|
||||
result = expr.fn(*args, **kwargs)
|
||||
|
||||
if not isinstance(result, (pd.Series, np.ndarray, pa.Array, pa.ChunkedArray)):
|
||||
function_name = expr.fn.__name__
|
||||
raise TypeError(
|
||||
f"UDF '{function_name}' returned invalid type {type(result).__name__}. "
|
||||
f"Expected type (pandas.Series, numpy.ndarray, pyarrow.Array, "
|
||||
f"pyarrow.ChunkedArray)"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def visit_alias(self, expr: AliasExpr) -> Union[BlockColumn, ScalarType]:
|
||||
"""Visit an alias expression and return the renamed result.
|
||||
|
||||
Args:
|
||||
expr: The alias expression.
|
||||
|
||||
Returns:
|
||||
A Block with the data from the inner expression.
|
||||
"""
|
||||
# Evaluate the inner expression
|
||||
return self.visit(expr.expr)
|
||||
|
||||
def visit_star(self, expr: StarExpr) -> Union[BlockColumn, ScalarType]:
|
||||
"""Visit a star expression.
|
||||
|
||||
Args:
|
||||
expr: The star expression.
|
||||
|
||||
Returns:
|
||||
TypeError: StarExpr cannot be evaluated as a regular expression.
|
||||
"""
|
||||
# star() should not be evaluated directly - it's handled at Project level
|
||||
raise TypeError(
|
||||
"StarExpr cannot be evaluated as a regular expression. "
|
||||
"It should only be used in Project operations."
|
||||
)
|
||||
|
||||
def visit_download(self, expr: DownloadExpr) -> Union[BlockColumn, ScalarType]:
|
||||
"""Visit a download expression.
|
||||
|
||||
Args:
|
||||
expr: The download expression.
|
||||
|
||||
Returns:
|
||||
TypeError: DownloadExpr evaluation not yet implemented.
|
||||
"""
|
||||
raise TypeError(
|
||||
"DownloadExpr evaluation is not yet implemented in NativeExpressionEvaluator."
|
||||
)
|
||||
|
||||
def visit_monotonically_increasing_id(
|
||||
self, expr: MonotonicallyIncreasingIdExpr
|
||||
) -> Union[BlockColumn, ScalarType]:
|
||||
"""Visit a monotonically_increasing_id expression.
|
||||
|
||||
Args:
|
||||
expr: The monotonically_increasing_id expression.
|
||||
|
||||
Returns:
|
||||
The result of the monotonically_increasing_id expression as a BlockColumn.
|
||||
"""
|
||||
ctx = TaskContext.get_current()
|
||||
assert (
|
||||
ctx is not None
|
||||
), "TaskContext is required for monotonically_increasing_id()"
|
||||
|
||||
# Key the counter by expression instance ID so that multiple expressions
|
||||
# in the same projection will have isolated row count state.
|
||||
# This is required because a single task may process multiple blocks if
|
||||
# the upstream data source does not compress the data into a single block.
|
||||
counter_key = f"_mono_id_{expr._instance_id}_counter"
|
||||
|
||||
start_idx = ctx.kwargs.get(counter_key, 0)
|
||||
num_rows = self.block_accessor.num_rows()
|
||||
end_idx = start_idx + num_rows
|
||||
ctx.kwargs[counter_key] = end_idx
|
||||
|
||||
# int64 (signed): upper 30 bits = task ID, lower 33 bits = row number.
|
||||
# Note end_idx is an exclusive upper bound, as the max row ID is end_idx - 1.
|
||||
ROW_BITS = 33
|
||||
TASK_BITS = 30
|
||||
if end_idx > (1 << ROW_BITS):
|
||||
raise ValueError(
|
||||
f"Cannot generate monotonically increasing IDs: row count for this task exceeds the maximum allowed value of {(1 << ROW_BITS) - 1}"
|
||||
)
|
||||
if ctx.task_idx >= (1 << TASK_BITS):
|
||||
raise ValueError(
|
||||
f"Cannot generate monotonically increasing IDs: number of tasks exceeds the maximum allowed value of {(1 << TASK_BITS) - 1}"
|
||||
)
|
||||
|
||||
partition_mask = ctx.task_idx << ROW_BITS
|
||||
ids = partition_mask + np.arange(start_idx, end_idx, dtype=np.int64)
|
||||
|
||||
block_type = self.block_accessor.block_type()
|
||||
if block_type == BlockType.PANDAS:
|
||||
return pd.Series(ids)
|
||||
elif block_type == BlockType.ARROW:
|
||||
return pa.array(ids)
|
||||
else:
|
||||
raise TypeError(f"Unsupported block type: {block_type}")
|
||||
|
||||
def visit_random(self, expr: RandomExpr) -> Union[BlockColumn, ScalarType]:
|
||||
"""Visit a random expression and return the result of the operation.
|
||||
|
||||
Args:
|
||||
expr: The random expression.
|
||||
|
||||
Returns:
|
||||
The result of the random operation as a BlockColumn.
|
||||
"""
|
||||
from ray.data._internal.planner.plan_expression.synthetic_impl import (
|
||||
eval_random,
|
||||
)
|
||||
|
||||
return eval_random(
|
||||
self.block_accessor.num_rows(),
|
||||
self.block_accessor.block_type(),
|
||||
seed=expr.seed,
|
||||
reseed_after_execution=expr.reseed_after_execution,
|
||||
instance_id=expr._instance_id,
|
||||
)
|
||||
|
||||
def visit_uuid(self, expr: UUIDExpr) -> Union[BlockColumn, ScalarType]:
|
||||
"""Visit a uuid expression and return the result of the operation.
|
||||
|
||||
Args:
|
||||
expr: The uuid expression.
|
||||
|
||||
Returns:
|
||||
The result of the uuid operation as a BlockColumn.
|
||||
"""
|
||||
from ray.data._internal.planner.plan_expression.synthetic_impl import eval_uuid
|
||||
|
||||
return eval_uuid(
|
||||
self.block_accessor.num_rows(), self.block_accessor.block_type()
|
||||
)
|
||||
|
||||
|
||||
def eval_expr(expr: Expr, block: Block) -> Union[BlockColumn, ScalarType]:
|
||||
"""Evaluate an expression against a block using the visitor pattern.
|
||||
|
||||
Args:
|
||||
expr: The expression to evaluate.
|
||||
block: The Block to evaluate against.
|
||||
|
||||
Returns:
|
||||
The evaluated result as a BlockColumn or a scalar value.
|
||||
"""
|
||||
evaluator = NativeExpressionEvaluator(block)
|
||||
return evaluator.visit(expr)
|
||||
|
||||
|
||||
def _eval_projection_without_cse(projection_exprs: List[Expr], block: Block) -> Block:
|
||||
"""
|
||||
Evaluate a projection (list of expressions) against a block.
|
||||
|
||||
Handles projection semantics including:
|
||||
- Empty projections
|
||||
- Star() expressions for preserving existing columns
|
||||
- Rename detection
|
||||
- Column ordering
|
||||
|
||||
Args:
|
||||
projection_exprs: List of expressions to evaluate (may include StarExpr)
|
||||
block: The block to project
|
||||
|
||||
Returns:
|
||||
A new block with the projected schema
|
||||
"""
|
||||
block_accessor = BlockAccessor.for_block(block)
|
||||
|
||||
# Skip projection only for schema-less empty blocks.
|
||||
if block_accessor.num_rows() == 0 and len(block_accessor.column_names()) == 0:
|
||||
return block
|
||||
|
||||
# Handle simple cases early.
|
||||
if len(projection_exprs) == 0:
|
||||
return block_accessor.select([])
|
||||
|
||||
input_column_names = list(block_accessor.column_names())
|
||||
# Collect input column rename map from the projection list
|
||||
input_column_rename_map = _extract_input_columns_renaming_mapping(projection_exprs)
|
||||
|
||||
# Expand star expr (if any). ``Project.__post_init__`` eagerly expands
|
||||
# ``StarExpr`` to explicit ``col()`` refs whenever the
|
||||
# input schema is known, so this runtime branch is hit only on the
|
||||
# UDF-fallback path (Project on top of an opaque-schema input).
|
||||
if isinstance(projection_exprs[0], StarExpr):
|
||||
# Bucket the trailing exprs: rename ``AliasExpr``s of an input
|
||||
# column get placed into the original column's position (so the
|
||||
# output preserves on-disk column order); anything else (e.g.
|
||||
# ``with_column`` computed expressions) is appended afterwards.
|
||||
rename_exprs_by_source: Dict[str, Expr] = {}
|
||||
extra_exprs: List[Expr] = []
|
||||
for expr in projection_exprs[1:]:
|
||||
# e.g. ``col(source)._rename(new_name)`` — bucket by ``source`` for column order.
|
||||
# ``rename_exprs_by_source``: input column name -> that rename ``AliasExpr``.
|
||||
if is_rename_expr(expr) and expr.expr.name in input_column_rename_map:
|
||||
rename_exprs_by_source[expr.expr.name] = expr
|
||||
else:
|
||||
extra_exprs.append(expr)
|
||||
|
||||
ordered_exprs: List[Expr] = []
|
||||
for c in input_column_names:
|
||||
if c in rename_exprs_by_source:
|
||||
ordered_exprs.append(rename_exprs_by_source.pop(c))
|
||||
elif c not in input_column_rename_map:
|
||||
ordered_exprs.append(col(c))
|
||||
|
||||
# Any rename whose source column isn't in the block falls through to
|
||||
# ``extra_exprs`` so evaluation raises a "column not found" error
|
||||
# instead of silently dropping the expression.
|
||||
extra_exprs = list(rename_exprs_by_source.values()) + extra_exprs
|
||||
|
||||
projection_exprs = ordered_exprs + extra_exprs
|
||||
|
||||
names, output_cols = zip(*[(e.name, eval_expr(e, block)) for e in projection_exprs])
|
||||
|
||||
# This clumsy workaround is necessary to be able to fill in Pyarrow tables
|
||||
# that has to be "seeded" from existing table with N rows, and couldn't be
|
||||
# started from a truly empty table.
|
||||
#
|
||||
# TODO fix
|
||||
new_block = BlockAccessor.for_block(block).fill_column("__stub__", None)
|
||||
new_block = BlockAccessor.for_block(new_block).drop(input_column_names)
|
||||
|
||||
for name, output_col in zip(names, output_cols):
|
||||
new_block = BlockAccessor.for_block(new_block).fill_column(name, output_col)
|
||||
|
||||
return BlockAccessor.for_block(new_block).drop(["__stub__"])
|
||||
|
||||
|
||||
def _drop_cse_temp_columns(block: Block, temp_columns: List[str]) -> Block:
|
||||
block_accessor = BlockAccessor.for_block(block)
|
||||
drop_columns = [
|
||||
name for name in temp_columns if name in block_accessor.column_names()
|
||||
]
|
||||
if not drop_columns:
|
||||
return block
|
||||
return block_accessor.drop(drop_columns)
|
||||
|
||||
|
||||
def eval_projection(
|
||||
projection_exprs: List[Expr],
|
||||
block: Block,
|
||||
*,
|
||||
common_sub_exprs: Optional[List[Expr]] = None,
|
||||
) -> Block:
|
||||
"""
|
||||
Evaluate a projection (list of expressions) against a block.
|
||||
|
||||
If CSE common expressions are provided, they are evaluated first into
|
||||
temporary columns on a working block. Visible projection expressions are
|
||||
then evaluated against that working block.
|
||||
"""
|
||||
if not common_sub_exprs:
|
||||
return _eval_projection_without_cse(projection_exprs, block)
|
||||
|
||||
working_block = block
|
||||
for common_expr in common_sub_exprs:
|
||||
assert common_expr.name is not None
|
||||
working_block = BlockAccessor.for_block(working_block).fill_column(
|
||||
common_expr.name,
|
||||
eval_expr(common_expr, working_block),
|
||||
)
|
||||
|
||||
output_block = _eval_projection_without_cse(projection_exprs, working_block)
|
||||
temp_columns = [expr.name for expr in common_sub_exprs]
|
||||
return _drop_cse_temp_columns(output_block, temp_columns)
|
||||
@@ -0,0 +1,843 @@
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Dict, Hashable, List, TypeVar
|
||||
|
||||
from ray.data.expressions import (
|
||||
AliasExpr,
|
||||
BinaryExpr,
|
||||
ColumnExpr,
|
||||
DownloadExpr,
|
||||
Expr,
|
||||
LiteralExpr,
|
||||
MonotonicallyIncreasingIdExpr,
|
||||
Operation,
|
||||
RandomExpr,
|
||||
StarExpr,
|
||||
UDFExpr,
|
||||
UnaryExpr,
|
||||
UUIDExpr,
|
||||
_CallableClassUDF,
|
||||
_ExprVisitor,
|
||||
)
|
||||
from ray.data.util.expression_utils import (
|
||||
_alias_fingerprint_key,
|
||||
_binary_fingerprint_key,
|
||||
_column_fingerprint_key,
|
||||
_download_fingerprint_key,
|
||||
_literal_fingerprint_key,
|
||||
_monotonically_increasing_id_fingerprint_key,
|
||||
_random_fingerprint_key,
|
||||
_star_fingerprint_key,
|
||||
_udf_fingerprint_key,
|
||||
_unary_fingerprint_key,
|
||||
_uuid_fingerprint_key,
|
||||
)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Mapping of operations to their string symbols for inline representation
|
||||
_INLINE_OP_SYMBOLS = {
|
||||
Operation.ADD: "+",
|
||||
Operation.SUB: "-",
|
||||
Operation.MUL: "*",
|
||||
Operation.DIV: "/",
|
||||
Operation.MOD: "%",
|
||||
Operation.FLOORDIV: "//",
|
||||
Operation.GT: ">",
|
||||
Operation.LT: "<",
|
||||
Operation.GE: ">=",
|
||||
Operation.LE: "<=",
|
||||
Operation.EQ: "==",
|
||||
Operation.NE: "!=",
|
||||
Operation.AND: "&",
|
||||
Operation.OR: "|",
|
||||
Operation.IN: "in",
|
||||
Operation.NOT_IN: "not in",
|
||||
}
|
||||
|
||||
|
||||
class _ExprVisitorBase(_ExprVisitor[None]):
|
||||
"""Base visitor that provides automatic recursive traversal.
|
||||
|
||||
This class extends _ExprVisitor and provides default implementations
|
||||
for composite nodes that automatically traverse child expressions.
|
||||
"""
|
||||
|
||||
def visit_binary(self, expr: "BinaryExpr") -> None:
|
||||
"""Default implementation: recursively visit both operands."""
|
||||
super().visit(expr.left)
|
||||
super().visit(expr.right)
|
||||
|
||||
def visit_unary(self, expr: "UnaryExpr") -> None:
|
||||
"""Default implementation: recursively visit the operand."""
|
||||
super().visit(expr.operand)
|
||||
|
||||
def visit_alias(self, expr: "AliasExpr") -> None:
|
||||
"""Default implementation: recursively visit the inner expression."""
|
||||
super().visit(expr.expr)
|
||||
|
||||
def visit_udf(self, expr: "UDFExpr") -> None:
|
||||
"""Default implementation: recursively visit all arguments."""
|
||||
for arg in expr.args:
|
||||
super().visit(arg)
|
||||
for value in expr.kwargs.values():
|
||||
super().visit(value)
|
||||
|
||||
def visit_literal(self, expr: LiteralExpr) -> None:
|
||||
"""Visit a literal expression (no columns to collect)."""
|
||||
pass
|
||||
|
||||
def visit_star(self, expr: StarExpr) -> None:
|
||||
"""Visit a star expression (no columns to collect)."""
|
||||
pass
|
||||
|
||||
def visit_download(self, expr: "Expr") -> None:
|
||||
"""Visit a download expression (no columns to collect)."""
|
||||
pass
|
||||
|
||||
def visit_monotonically_increasing_id(
|
||||
self, expr: "MonotonicallyIncreasingIdExpr"
|
||||
) -> None:
|
||||
"""Visit a monotonically_increasing_id expression (no columns to collect)."""
|
||||
pass
|
||||
|
||||
def visit_random(self, expr: "RandomExpr") -> None:
|
||||
"""Visit a synthetic expression (no columns to collect)."""
|
||||
pass
|
||||
|
||||
def visit_uuid(self, expr: "UUIDExpr") -> None:
|
||||
"""Visit a uuid expression (no columns to collect)."""
|
||||
pass
|
||||
|
||||
|
||||
class _ColumnReferenceCollector(_ExprVisitorBase):
|
||||
"""Visitor that collects all column references from expression trees.
|
||||
|
||||
Backed by a ``Counter`` so callers can take either:
|
||||
- ``get_column_refs()`` -> ordered, de-duplicated column names, or
|
||||
- ``get_counts()`` -> per-name reference multiplicity, counting repeats
|
||||
*within* a single expression (``x + x`` -> ``{"x": 2}``).
|
||||
|
||||
``Counter`` preserves first-insertion order, so ``get_column_refs()`` returns the
|
||||
same ordered, de-duplicated list as a plain insertion-ordered ``dict`` would.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize with an empty reference counter."""
|
||||
self._col_refs: Counter = Counter()
|
||||
|
||||
def get_column_refs(self) -> List[str]:
|
||||
return list(self._col_refs.keys())
|
||||
|
||||
def get_counts(self) -> Counter:
|
||||
return self._col_refs
|
||||
|
||||
def visit_column(self, expr: ColumnExpr) -> None:
|
||||
"""Visit a column expression and count its name.
|
||||
|
||||
Args:
|
||||
expr: The column expression.
|
||||
|
||||
Returns:
|
||||
None (only counts columns as a side effect).
|
||||
"""
|
||||
self._col_refs[expr.name] += 1
|
||||
|
||||
def visit_alias(self, expr: AliasExpr) -> None:
|
||||
"""Visit an alias expression and collect from its inner expression.
|
||||
|
||||
Args:
|
||||
expr: The alias expression.
|
||||
|
||||
Returns:
|
||||
None (only collects columns as a side effect).
|
||||
"""
|
||||
self.visit(expr.expr)
|
||||
|
||||
|
||||
class _IdempotencyVisitor(_ExprVisitor[bool]):
|
||||
"""Reports whether an expression is safe to duplicate, reorder, or move.
|
||||
|
||||
Returns ``True`` only when every node in the tree is idempotent. The three
|
||||
non-idempotent leaf types (``RandomExpr``, ``UUIDExpr``,
|
||||
``MonotonicallyIncreasingIdExpr``) return ``False`` and propagate upward: a
|
||||
composite is idempotent iff all of its children are.
|
||||
|
||||
Optimizer rules consult this (via :func:`is_idempotent`) before any rewrite that
|
||||
would change an expression's evaluation count, row set, or position.
|
||||
"""
|
||||
|
||||
# --- non-idempotent leaves ---
|
||||
def visit_random(self, expr: RandomExpr) -> bool:
|
||||
# Conservatively non-idempotent even when seeded: CSE matches structurally and
|
||||
# ignores ``_instance_id``, while the runtime RNG counter keys on it, so a
|
||||
# seeded RandomExpr cannot be safely de-duplicated in general.
|
||||
return False
|
||||
|
||||
def visit_uuid(self, expr: UUIDExpr) -> bool:
|
||||
return False
|
||||
|
||||
def visit_monotonically_increasing_id(
|
||||
self, expr: MonotonicallyIncreasingIdExpr
|
||||
) -> bool:
|
||||
return False
|
||||
|
||||
# --- idempotent leaves ---
|
||||
def visit_column(self, expr: ColumnExpr) -> bool:
|
||||
return True
|
||||
|
||||
def visit_literal(self, expr: LiteralExpr) -> bool:
|
||||
return True
|
||||
|
||||
def visit_star(self, expr: StarExpr) -> bool:
|
||||
return True
|
||||
|
||||
def visit_download(self, expr: DownloadExpr) -> bool:
|
||||
# ``DownloadExpr`` is a leaf with no Expr children. It is idempotent (same URI
|
||||
# yields the same bytes); CSE avoids re-fetching it for *cost* reasons, which
|
||||
# is a separate concern from this correctness contract.
|
||||
return True
|
||||
|
||||
# --- composites: idempotent iff all children are ---
|
||||
#
|
||||
# Children are visited via ``child.is_idempotent()`` (not ``self.visit(child)``)
|
||||
# so each node's result is read from / written to its per-instance cache. This
|
||||
# keeps an all-nodes query (e.g. CSE visiting every occurrence) linear overall
|
||||
# instead of re-walking each subtree.
|
||||
def visit_alias(self, expr: AliasExpr) -> bool:
|
||||
return expr.expr.is_idempotent()
|
||||
|
||||
def visit_unary(self, expr: UnaryExpr) -> bool:
|
||||
return expr.operand.is_idempotent()
|
||||
|
||||
def visit_binary(self, expr: BinaryExpr) -> bool:
|
||||
return expr.left.is_idempotent() and expr.right.is_idempotent()
|
||||
|
||||
def visit_udf(self, expr: UDFExpr) -> bool:
|
||||
# FUTURE EXTENSION POINT: today UDFs are assumed idempotent and we only recurse
|
||||
# into their argument expressions. When per-UDF non-determinism is supported,
|
||||
# gate this on the UDF's declared determinism as well.
|
||||
return all(arg.is_idempotent() for arg in expr.args) and all(
|
||||
value.is_idempotent() for value in expr.kwargs.values()
|
||||
)
|
||||
|
||||
|
||||
# Stateless singleton: ``Expr.is_idempotent`` reuses this rather than allocating a
|
||||
# visitor per node during the initial (uncached) computation.
|
||||
_IDEMPOTENCY_VISITOR = _IdempotencyVisitor()
|
||||
|
||||
|
||||
class _CallableClassUDFCollector(_ExprVisitorBase):
|
||||
"""Visitor that collects all callable class UDFs from expression trees.
|
||||
|
||||
This visitor traverses expression trees and collects _CallableClassUDF instances
|
||||
that wrap callable classes (as opposed to regular functions).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize with an empty list of _CallableClassUDF instances."""
|
||||
self._expr_udfs: List[_CallableClassUDF] = []
|
||||
|
||||
def get_callable_class_udfs(self) -> List[_CallableClassUDF]:
|
||||
"""Get the list of collected _CallableClassUDF instances.
|
||||
|
||||
Returns:
|
||||
List of _CallableClassUDF instances that wrap callable classes.
|
||||
"""
|
||||
return self._expr_udfs
|
||||
|
||||
def visit_column(self, expr: ColumnExpr) -> None:
|
||||
"""Visit a column expression (no UDFs to collect)."""
|
||||
pass
|
||||
|
||||
def visit_udf(self, expr: UDFExpr) -> None:
|
||||
"""Visit a UDF expression and collect it if it's a callable class.
|
||||
|
||||
Args:
|
||||
expr: The UDF expression.
|
||||
|
||||
Returns:
|
||||
None (only collects UDFs as a side effect).
|
||||
"""
|
||||
# Check if fn is an _CallableClassUDF (indicates callable class)
|
||||
if isinstance(expr.fn, _CallableClassUDF):
|
||||
self._expr_udfs.append(expr.fn)
|
||||
|
||||
# Continue visiting child expressions
|
||||
super().visit_udf(expr)
|
||||
|
||||
|
||||
class _ColumnSubstitutionVisitor(_ExprVisitor[Expr]):
|
||||
"""Visitor rebinding column references in ``Expression``s.
|
||||
|
||||
This visitor traverses given ``Expression`` trees and substitutes column references
|
||||
according to a provided substitution map.
|
||||
"""
|
||||
|
||||
def __init__(self, column_ref_substitutions: Dict[str, Expr]):
|
||||
"""Initialize with a column substitution map.
|
||||
|
||||
Args:
|
||||
column_ref_substitutions: Mapping from column names to replacement expressions.
|
||||
"""
|
||||
self._col_ref_substitutions = column_ref_substitutions
|
||||
|
||||
def visit_column(self, expr: ColumnExpr) -> Expr:
|
||||
"""Visit a column expression and substitute it.
|
||||
|
||||
Args:
|
||||
expr: The column expression.
|
||||
|
||||
Returns:
|
||||
The substituted expression or the original if no substitution exists.
|
||||
"""
|
||||
substitution = self._col_ref_substitutions.get(expr.name)
|
||||
|
||||
return substitution if substitution is not None else expr
|
||||
|
||||
def visit_literal(self, expr: LiteralExpr) -> Expr:
|
||||
"""Visit a literal expression (no rewriting needed).
|
||||
|
||||
Args:
|
||||
expr: The literal expression.
|
||||
|
||||
Returns:
|
||||
The original literal expression.
|
||||
"""
|
||||
return expr
|
||||
|
||||
def visit_binary(self, expr: BinaryExpr) -> Expr:
|
||||
"""Visit a binary expression and rewrite its operands.
|
||||
|
||||
Args:
|
||||
expr: The binary expression.
|
||||
|
||||
Returns:
|
||||
A new binary expression with rewritten operands.
|
||||
"""
|
||||
return BinaryExpr(
|
||||
expr.op,
|
||||
self.visit(expr.left),
|
||||
self.visit(expr.right),
|
||||
)
|
||||
|
||||
def visit_unary(self, expr: UnaryExpr) -> Expr:
|
||||
"""Visit a unary expression and rewrite its operand.
|
||||
|
||||
Args:
|
||||
expr: The unary expression.
|
||||
|
||||
Returns:
|
||||
A new unary expression with rewritten operand.
|
||||
"""
|
||||
return UnaryExpr(expr.op, self.visit(expr.operand))
|
||||
|
||||
def visit_udf(self, expr: UDFExpr) -> Expr:
|
||||
"""Visit a UDF expression and rewrite its arguments.
|
||||
|
||||
Args:
|
||||
expr: The UDF expression.
|
||||
|
||||
Returns:
|
||||
A new UDF expression with rewritten arguments.
|
||||
"""
|
||||
new_args = [self.visit(arg) for arg in expr.args]
|
||||
new_kwargs = {key: self.visit(value) for key, value in expr.kwargs.items()}
|
||||
return replace(expr, args=new_args, kwargs=new_kwargs)
|
||||
|
||||
def visit_alias(self, expr: AliasExpr) -> Expr:
|
||||
"""Visit an alias expression and rewrite its inner expression.
|
||||
|
||||
Args:
|
||||
expr: The alias expression.
|
||||
|
||||
Returns:
|
||||
A new alias expression with rewritten inner expression and preserved name.
|
||||
"""
|
||||
# We unalias returned expression to avoid nested aliasing
|
||||
visited = self.visit(expr.expr)._unalias()
|
||||
# NOTE: We're carrying over all of the other aspects of the alias
|
||||
# only replacing inner expre
|
||||
return replace(
|
||||
expr,
|
||||
expr=visited,
|
||||
# Alias expression will remain a renaming one (ie replacing source column)
|
||||
# so long as it's referencing another column (and not otherwise)
|
||||
#
|
||||
# TODO replace w/ standalone rename expr
|
||||
_is_rename=expr._is_rename and _is_col_expr(visited),
|
||||
)
|
||||
|
||||
def visit_download(self, expr: "Expr") -> Expr:
|
||||
"""Visit a download expression (no rewriting needed).
|
||||
|
||||
Args:
|
||||
expr: The download expression.
|
||||
|
||||
Returns:
|
||||
The original download expression.
|
||||
"""
|
||||
return expr
|
||||
|
||||
def visit_star(self, expr: StarExpr) -> Expr:
|
||||
"""Visit a star expression (no rewriting needed).
|
||||
|
||||
Args:
|
||||
expr: The star expression.
|
||||
|
||||
Returns:
|
||||
The original star expression.
|
||||
"""
|
||||
return expr
|
||||
|
||||
def visit_monotonically_increasing_id(
|
||||
self, expr: MonotonicallyIncreasingIdExpr
|
||||
) -> Expr:
|
||||
"""Visit a monotonically_increasing_id expression (no rewriting needed).
|
||||
|
||||
Args:
|
||||
expr: The monotonically_increasing_id expression.
|
||||
|
||||
Returns:
|
||||
The original expression.
|
||||
"""
|
||||
return expr
|
||||
|
||||
def visit_random(self, expr: "RandomExpr") -> Expr:
|
||||
"""Visit a random expression (no rewriting needed).
|
||||
|
||||
Args:
|
||||
expr: The random expression.
|
||||
|
||||
Returns:
|
||||
The original random expression.
|
||||
"""
|
||||
return expr
|
||||
|
||||
def visit_uuid(self, expr: "UUIDExpr") -> Expr:
|
||||
"""Visit a uuid expression (no rewriting needed).
|
||||
|
||||
Args:
|
||||
expr: The uuid expression.
|
||||
|
||||
Returns:
|
||||
The original uuid expression.
|
||||
"""
|
||||
return expr
|
||||
|
||||
|
||||
def _is_col_expr(expr: Expr) -> bool:
|
||||
return isinstance(expr, ColumnExpr) or (
|
||||
isinstance(expr, AliasExpr) and isinstance(expr.expr, ColumnExpr)
|
||||
)
|
||||
|
||||
|
||||
class _TreeReprVisitor(_ExprVisitor[str]):
|
||||
"""Visitor that generates a readable tree representation of expressions. Returns in pre-order traversal."""
|
||||
|
||||
def __init__(self, prefix: str = "", is_last: bool = True):
|
||||
"""
|
||||
Initialize the tree representation visitor.
|
||||
|
||||
Args:
|
||||
prefix: The prefix string for indentation (accumulated from parent nodes)
|
||||
is_last: Whether this node is the last child of its parent
|
||||
"""
|
||||
self.prefix = prefix
|
||||
self.is_last = is_last
|
||||
self._max_length = 50 # Maximum length of the node label
|
||||
|
||||
def _make_tree_lines(
|
||||
self,
|
||||
node_label: str,
|
||||
children: List[tuple[str, "Expr"]] = None,
|
||||
expr: "Expr" = None,
|
||||
) -> str:
|
||||
"""
|
||||
Format a node and its children with tree box-drawing characters.
|
||||
|
||||
Args:
|
||||
node_label: The label for this node (e.g., "ADD")
|
||||
children: List of (label, child_expr) tuples to render as children
|
||||
expr: The expression node (used to extract datatype)
|
||||
|
||||
Returns:
|
||||
Multi-line string representation of the tree
|
||||
"""
|
||||
lines = [node_label]
|
||||
|
||||
if children:
|
||||
for i, (label, child_expr) in enumerate(children):
|
||||
is_last_child = i == len(children) - 1
|
||||
|
||||
# Build prefix for the child based on whether current node is last
|
||||
child_prefix = self.prefix + (" " if self.is_last else "│ ")
|
||||
|
||||
# Choose connector: └── for last child, ├── for others
|
||||
connector = "└── " if is_last_child else "├── "
|
||||
|
||||
# Recursively visit the child with updated prefix
|
||||
child_visitor = _TreeReprVisitor(child_prefix, is_last_child)
|
||||
child_lines = child_visitor.visit(child_expr).split("\n")
|
||||
|
||||
# Add the first line with label and connector
|
||||
if label:
|
||||
lines.append(f"{child_prefix}{connector}{label}: {child_lines[0]}")
|
||||
else:
|
||||
lines.append(f"{child_prefix}{connector}{child_lines[0]}")
|
||||
|
||||
# Add remaining lines from child with proper indentation
|
||||
for line in child_lines[1:]:
|
||||
lines.append(line)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def visit_column(self, expr: "ColumnExpr") -> str:
|
||||
return self._make_tree_lines(f"COL({expr.name!r})", expr=expr)
|
||||
|
||||
def visit_literal(self, expr: "LiteralExpr") -> str:
|
||||
# Truncate long values for readability
|
||||
value_repr = repr(expr.value)
|
||||
if len(value_repr) > self._max_length:
|
||||
value_repr = value_repr[: self._max_length - 3] + "..."
|
||||
return self._make_tree_lines(f"LIT({value_repr})", expr=expr)
|
||||
|
||||
def visit_binary(self, expr: "BinaryExpr") -> str:
|
||||
return self._make_tree_lines(
|
||||
f"{expr.op.name}",
|
||||
children=[
|
||||
("left", expr.left),
|
||||
("right", expr.right),
|
||||
],
|
||||
expr=expr,
|
||||
)
|
||||
|
||||
def visit_unary(self, expr: "UnaryExpr") -> str:
|
||||
return self._make_tree_lines(
|
||||
f"{expr.op.name}",
|
||||
children=[("operand", expr.operand)],
|
||||
expr=expr,
|
||||
)
|
||||
|
||||
def visit_alias(self, expr: "AliasExpr") -> str:
|
||||
rename_marker = " [rename]" if expr._is_rename else ""
|
||||
return self._make_tree_lines(
|
||||
f"ALIAS({expr.name!r}){rename_marker}",
|
||||
children=[("", expr.expr)],
|
||||
expr=expr,
|
||||
)
|
||||
|
||||
def visit_udf(self, expr: "UDFExpr") -> str:
|
||||
# Get function name for better readability
|
||||
fn_name = getattr(expr.fn, "__name__", str(expr.fn))
|
||||
|
||||
children = []
|
||||
# Add positional arguments
|
||||
for i, arg in enumerate(expr.args):
|
||||
children.append((f"arg[{i}]", arg))
|
||||
|
||||
# Add keyword arguments
|
||||
for key, value in expr.kwargs.items():
|
||||
children.append((f"kwarg[{key!r}]", value))
|
||||
|
||||
return self._make_tree_lines(
|
||||
f"UDF({fn_name})",
|
||||
children=children if children else None,
|
||||
expr=expr,
|
||||
)
|
||||
|
||||
def visit_download(self, expr: "DownloadExpr") -> str:
|
||||
return self._make_tree_lines(f"DOWNLOAD({expr.uri_column_name!r})", expr=expr)
|
||||
|
||||
def visit_star(self, expr: "StarExpr") -> str:
|
||||
return self._make_tree_lines("COL(*)", expr=expr)
|
||||
|
||||
def visit_monotonically_increasing_id(
|
||||
self, expr: "MonotonicallyIncreasingIdExpr"
|
||||
) -> str:
|
||||
return self._make_tree_lines("MONOTONICALLY_INCREASING_ID()", expr=expr)
|
||||
|
||||
def visit_random(self, expr: "RandomExpr") -> str:
|
||||
if expr.seed is None:
|
||||
label = "RANDOM()"
|
||||
else:
|
||||
label = f"RANDOM(seed={expr.seed}, reseed_after_execution={expr.reseed_after_execution})"
|
||||
return self._make_tree_lines(label, expr=expr)
|
||||
|
||||
def visit_uuid(self, expr: "UUIDExpr") -> str:
|
||||
return self._make_tree_lines("UUID()", expr=expr)
|
||||
|
||||
|
||||
class _InlineExprReprVisitor(_ExprVisitor[str]):
|
||||
"""Visitor that generates concise inline string representations of expressions.
|
||||
|
||||
This visitor creates single-line string representations suitable for displaying
|
||||
in operator names, log messages, etc. It aims to be human-readable while keeping
|
||||
the representation compact.
|
||||
"""
|
||||
|
||||
def __init__(self, max_literal_length: int = 20):
|
||||
"""Initialize the inline representation visitor.
|
||||
|
||||
Args:
|
||||
max_literal_length: Maximum length for literal value representations
|
||||
"""
|
||||
self._max_literal_length = max_literal_length
|
||||
|
||||
def visit_column(self, expr: "ColumnExpr") -> str:
|
||||
"""Visit a column expression and return its inline representation."""
|
||||
return f"col({expr.name!r})"
|
||||
|
||||
def visit_literal(self, expr: "LiteralExpr") -> str:
|
||||
"""Visit a literal expression and return its inline representation."""
|
||||
value_repr = repr(expr.value)
|
||||
if len(value_repr) > self._max_literal_length:
|
||||
value_repr = value_repr[: self._max_literal_length - 3] + "..."
|
||||
return value_repr
|
||||
|
||||
def visit_binary(self, expr: "BinaryExpr") -> str:
|
||||
"""Visit a binary expression and return its inline representation."""
|
||||
left_str = self.visit(expr.left)
|
||||
right_str = self.visit(expr.right)
|
||||
|
||||
# Add parentheses around child binary expressions to avoid ambiguity
|
||||
if isinstance(expr.left, BinaryExpr):
|
||||
left_str = f"({left_str})"
|
||||
if isinstance(expr.right, BinaryExpr):
|
||||
right_str = f"({right_str})"
|
||||
|
||||
op_str = _INLINE_OP_SYMBOLS.get(expr.op, expr.op.name.lower())
|
||||
return f"{left_str} {op_str} {right_str}"
|
||||
|
||||
def visit_unary(self, expr: "UnaryExpr") -> str:
|
||||
"""Visit a unary expression and return its inline representation."""
|
||||
operand_str = self.visit(expr.operand)
|
||||
|
||||
# Add parentheses around binary expression operands to avoid ambiguity
|
||||
if isinstance(expr.operand, BinaryExpr):
|
||||
operand_str = f"({operand_str})"
|
||||
|
||||
# Map operations to symbols/functions
|
||||
if expr.op == Operation.NOT:
|
||||
return f"~{operand_str}"
|
||||
elif expr.op == Operation.IS_NULL:
|
||||
return f"{operand_str}.is_null()"
|
||||
elif expr.op == Operation.IS_NOT_NULL:
|
||||
return f"{operand_str}.is_not_null()"
|
||||
else:
|
||||
return f"{expr.op.name.lower()}({operand_str})"
|
||||
|
||||
def visit_alias(self, expr: "AliasExpr") -> str:
|
||||
"""Visit an alias expression and return its inline representation."""
|
||||
inner_str = self.visit(expr.expr)
|
||||
return f"{inner_str}.alias({expr.name!r})"
|
||||
|
||||
def visit_udf(self, expr: "UDFExpr") -> str:
|
||||
"""Visit a UDF expression and return its inline representation."""
|
||||
# Get function name for better readability
|
||||
# For callable objects (instances with __call__), use the class name
|
||||
fn_name = getattr(expr.fn, "__name__", expr.fn.__class__.__name__)
|
||||
|
||||
# Build argument list
|
||||
args_str = []
|
||||
for arg in expr.args:
|
||||
args_str.append(self.visit(arg))
|
||||
for key, value in expr.kwargs.items():
|
||||
args_str.append(f"{key}={self.visit(value)}")
|
||||
|
||||
args_repr = ", ".join(args_str) if args_str else ""
|
||||
return f"{fn_name}({args_repr})"
|
||||
|
||||
def visit_download(self, expr: "DownloadExpr") -> str:
|
||||
"""Visit a download expression and return its inline representation."""
|
||||
return f"download({expr.uri_column_name!r})"
|
||||
|
||||
def visit_star(self, expr: "StarExpr") -> str:
|
||||
"""Visit a star expression and return its inline representation."""
|
||||
return "col(*)"
|
||||
|
||||
def visit_monotonically_increasing_id(
|
||||
self, expr: "MonotonicallyIncreasingIdExpr"
|
||||
) -> str:
|
||||
"""Visit a monotonically_increasing_id expression and return its inline representation."""
|
||||
return "monotonically_increasing_id()"
|
||||
|
||||
def visit_random(self, expr: "RandomExpr") -> str:
|
||||
"""Visit a random expression and return its inline representation."""
|
||||
return "random()"
|
||||
|
||||
def visit_uuid(self, expr: "UUIDExpr") -> str:
|
||||
"""Visit a uuid expression and return its inline representation."""
|
||||
return "uuid()"
|
||||
|
||||
|
||||
class _StructuralFingerprintVisitor(_ExprVisitor[Hashable]):
|
||||
"""Visitor that computes a hashable structural fingerprint for an expression.
|
||||
|
||||
Two expressions that are structurally equivalent produce equal fingerprints,
|
||||
so the fingerprint can be used as a cheap bucketing key before falling back to
|
||||
full ``structurally_equals`` comparison (e.g. for common sub-expression
|
||||
elimination).
|
||||
"""
|
||||
|
||||
def visit_column(self, expr: ColumnExpr) -> Hashable:
|
||||
return _column_fingerprint_key(expr)
|
||||
|
||||
def visit_literal(self, expr: LiteralExpr) -> Hashable:
|
||||
return _literal_fingerprint_key(expr)
|
||||
|
||||
def visit_binary(self, expr: BinaryExpr) -> Hashable:
|
||||
return _binary_fingerprint_key(
|
||||
expr,
|
||||
self.visit(expr.left),
|
||||
self.visit(expr.right),
|
||||
)
|
||||
|
||||
def visit_unary(self, expr: UnaryExpr) -> Hashable:
|
||||
return _unary_fingerprint_key(expr, self.visit(expr.operand))
|
||||
|
||||
def visit_udf(self, expr: UDFExpr) -> Hashable:
|
||||
return _udf_fingerprint_key(
|
||||
expr,
|
||||
tuple(self.visit(arg) for arg in expr.args),
|
||||
tuple(
|
||||
(k, self.visit(v))
|
||||
for k, v in sorted(expr.kwargs.items(), key=lambda item: item[0])
|
||||
),
|
||||
)
|
||||
|
||||
def visit_alias(self, expr: AliasExpr) -> Hashable:
|
||||
return _alias_fingerprint_key(expr, self.visit(expr.expr))
|
||||
|
||||
def visit_download(self, expr: DownloadExpr) -> Hashable:
|
||||
return _download_fingerprint_key(expr)
|
||||
|
||||
def visit_star(self, expr: StarExpr) -> Hashable:
|
||||
return _star_fingerprint_key()
|
||||
|
||||
def visit_monotonically_increasing_id(
|
||||
self, expr: MonotonicallyIncreasingIdExpr
|
||||
) -> Hashable:
|
||||
return _monotonically_increasing_id_fingerprint_key(expr)
|
||||
|
||||
def visit_random(self, expr: RandomExpr) -> Hashable:
|
||||
return _random_fingerprint_key(expr)
|
||||
|
||||
def visit_uuid(self, expr: UUIDExpr) -> Hashable:
|
||||
return _uuid_fingerprint_key(expr)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ExpressionOccurrence:
|
||||
expr: Expr
|
||||
key: Hashable
|
||||
depth: int
|
||||
|
||||
|
||||
class _StructuralFingerprintOccurrenceCollector(_ExprVisitor[Hashable]):
|
||||
"""Collect expression occurrences while computing structural keys bottom-up."""
|
||||
|
||||
def __init__(self):
|
||||
self._occurrences: List[_ExpressionOccurrence] = []
|
||||
self._depth = 0
|
||||
|
||||
def get_occurrences(self) -> List[_ExpressionOccurrence]:
|
||||
return self._occurrences
|
||||
|
||||
def _visit_child(self, expr: Expr) -> Hashable:
|
||||
self._depth += 1
|
||||
try:
|
||||
return self.visit(expr)
|
||||
finally:
|
||||
self._depth -= 1
|
||||
|
||||
def _record(self, expr: Expr, key: Hashable) -> Hashable:
|
||||
self._occurrences.append(
|
||||
_ExpressionOccurrence(
|
||||
expr=expr,
|
||||
key=key,
|
||||
depth=self._depth,
|
||||
)
|
||||
)
|
||||
return key
|
||||
|
||||
def visit_column(self, expr: ColumnExpr) -> Hashable:
|
||||
return self._record(expr, _column_fingerprint_key(expr))
|
||||
|
||||
def visit_literal(self, expr: LiteralExpr) -> Hashable:
|
||||
return self._record(expr, _literal_fingerprint_key(expr))
|
||||
|
||||
def visit_binary(self, expr: BinaryExpr) -> Hashable:
|
||||
return self._record(
|
||||
expr,
|
||||
_binary_fingerprint_key(
|
||||
expr,
|
||||
self._visit_child(expr.left),
|
||||
self._visit_child(expr.right),
|
||||
),
|
||||
)
|
||||
|
||||
def visit_unary(self, expr: UnaryExpr) -> Hashable:
|
||||
return self._record(
|
||||
expr,
|
||||
_unary_fingerprint_key(expr, self._visit_child(expr.operand)),
|
||||
)
|
||||
|
||||
def visit_udf(self, expr: UDFExpr) -> Hashable:
|
||||
return self._record(
|
||||
expr,
|
||||
_udf_fingerprint_key(
|
||||
expr,
|
||||
tuple(self._visit_child(arg) for arg in expr.args),
|
||||
tuple(
|
||||
(k, self._visit_child(v))
|
||||
for k, v in sorted(expr.kwargs.items(), key=lambda item: item[0])
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def visit_alias(self, expr: AliasExpr) -> Hashable:
|
||||
return self._record(
|
||||
expr,
|
||||
_alias_fingerprint_key(expr, self._visit_child(expr.expr)),
|
||||
)
|
||||
|
||||
def visit_download(self, expr: DownloadExpr) -> Hashable:
|
||||
return self._record(expr, _download_fingerprint_key(expr))
|
||||
|
||||
def visit_star(self, expr: StarExpr) -> Hashable:
|
||||
return self._record(expr, _star_fingerprint_key())
|
||||
|
||||
def visit_monotonically_increasing_id(
|
||||
self, expr: MonotonicallyIncreasingIdExpr
|
||||
) -> Hashable:
|
||||
return self._record(expr, _monotonically_increasing_id_fingerprint_key(expr))
|
||||
|
||||
def visit_random(self, expr: RandomExpr) -> Hashable:
|
||||
return self._record(expr, _random_fingerprint_key(expr))
|
||||
|
||||
def visit_uuid(self, expr: UUIDExpr) -> Hashable:
|
||||
return self._record(expr, _uuid_fingerprint_key(expr))
|
||||
|
||||
|
||||
def get_column_references(expr: Expr) -> List[str]:
|
||||
"""Extract all column references from an expression.
|
||||
|
||||
This is a convenience function that creates a _ColumnReferenceCollector,
|
||||
visits the expression tree, and returns the list of referenced column names.
|
||||
|
||||
Args:
|
||||
expr: The expression to extract column references from.
|
||||
|
||||
Returns:
|
||||
List of column names referenced in the expression, in order of appearance.
|
||||
|
||||
Example:
|
||||
>>> from ray.data.expressions import col
|
||||
>>> expr = (col("a") > 5) & (col("b") == "test")
|
||||
>>> get_column_references(expr)
|
||||
['a', 'b']
|
||||
"""
|
||||
collector = _ColumnReferenceCollector()
|
||||
collector.visit(expr)
|
||||
return collector.get_column_refs()
|
||||
@@ -0,0 +1,124 @@
|
||||
import uuid
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
|
||||
from ray.data._internal.execution.interfaces.task_context import TaskContext
|
||||
from ray.data.block import BlockColumn, BlockType
|
||||
|
||||
|
||||
def eval_random(
|
||||
num_rows: int,
|
||||
block_type: BlockType,
|
||||
*,
|
||||
seed: int | None = None,
|
||||
reseed_after_execution: bool = True,
|
||||
instance_id: str | None = None,
|
||||
) -> BlockColumn:
|
||||
"""Implementation of the random expression.
|
||||
|
||||
Args:
|
||||
num_rows: The number of rows to generate random values for.
|
||||
block_type: The type of block to generate random values for.
|
||||
seed: The seed to use for the random number generator.
|
||||
reseed_after_execution: Whether to reseed the random number generator after each execution.
|
||||
instance_id: Unique identifier for the random expression instance, used to isolate
|
||||
block count state when a single task processes multiple blocks.
|
||||
|
||||
Returns:
|
||||
A BlockColumn containing the random values.
|
||||
|
||||
Raises:
|
||||
TypeError: If the block type is not supported.
|
||||
"""
|
||||
|
||||
if seed is not None:
|
||||
# Numpy allows using a seed sequence (list of integers) to initialize
|
||||
# a random number generator. This allows us to maintain reproduciblity while
|
||||
# ensuring randomness in parallel execution.
|
||||
# See https://numpy.org/doc/2.2/reference/random/parallel.html#sequence-of-integer-seeds
|
||||
# Below we uses four components to create a seed sequence (fastest changing component first):
|
||||
# 1. A per-block counter within the task (to differentiate blocks in the same task)
|
||||
# 2. An index based on the remote task in Ray Data
|
||||
# 3. An incrementing index of Ray Dataset execution (e.g., multiple training epochs)
|
||||
# 4. A base seed fixed by the user
|
||||
|
||||
ctx = TaskContext.get_current()
|
||||
|
||||
if ctx is None:
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"TaskContext is not available for random() expression with seed. "
|
||||
"Falling back to task_idx=0 for all tasks, which reduces the parallelism "
|
||||
"benefits of random number generation. If you see this warning, please "
|
||||
"report it as it may indicate an execution context issue.",
|
||||
stacklevel=2,
|
||||
)
|
||||
task_idx = 0
|
||||
block_idx = 0
|
||||
else:
|
||||
task_idx = ctx.task_idx
|
||||
|
||||
# Key the counter by expression instance ID so that multiple expressions
|
||||
# in the same projection will have isolated block count state.
|
||||
# This is required because a single task may process multiple blocks if
|
||||
# the upstream data source does not compress the data into a single block.
|
||||
if instance_id is not None:
|
||||
counter_key = f"_random_{instance_id}_counter"
|
||||
block_idx = ctx.kwargs.get(counter_key, 0)
|
||||
ctx.kwargs[counter_key] = block_idx + 1
|
||||
else:
|
||||
block_idx = 0
|
||||
|
||||
if reseed_after_execution:
|
||||
from ray.data.context import DataContext
|
||||
|
||||
data_context = (
|
||||
DataContext.get_current()
|
||||
) # get or create DataContext, never None
|
||||
execution_idx = data_context._execution_idx
|
||||
else:
|
||||
execution_idx = 0
|
||||
|
||||
# Numpy recommends fastest changing component to be the first element
|
||||
block_seed = [block_idx, task_idx, execution_idx, seed]
|
||||
else:
|
||||
block_seed = None
|
||||
|
||||
rng = np.random.default_rng(block_seed)
|
||||
random_values = rng.random(num_rows)
|
||||
|
||||
# Convert to appropriate format based on block type
|
||||
if block_type == BlockType.PANDAS:
|
||||
return pd.Series(random_values, dtype=np.float64)
|
||||
elif block_type == BlockType.ARROW:
|
||||
return pa.array(random_values, type=pa.float64())
|
||||
|
||||
raise TypeError(f"Unsupported block type: {block_type}")
|
||||
|
||||
|
||||
def eval_uuid(
|
||||
num_rows: int,
|
||||
block_type: BlockType,
|
||||
) -> BlockColumn:
|
||||
"""Implementation of the uuid expression.
|
||||
|
||||
Args:
|
||||
num_rows: The number of rows to generate uuid values for.
|
||||
block_type: The type of block to generate uuid values for.
|
||||
|
||||
Returns:
|
||||
A BlockColumn containing the uuid values.
|
||||
|
||||
Raises:
|
||||
TypeError: If the block type is not supported.
|
||||
"""
|
||||
arr = [str(uuid.uuid4()) for _ in range(num_rows)]
|
||||
if block_type == BlockType.PANDAS:
|
||||
return pd.Series(arr, dtype=pd.StringDtype())
|
||||
elif block_type == BlockType.ARROW:
|
||||
return pa.array(arr, type=pa.string())
|
||||
|
||||
raise TypeError(f"Unsupported block type: {block_type}")
|
||||
Reference in New Issue
Block a user