Skip to content

Commit 2350d62

Browse files
casting fixes
1 parent f7db2e4 commit 2350d62

4 files changed

Lines changed: 131 additions & 17 deletions

File tree

packages/bigframes/bigframes/core/compile/lowering.py

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ def lower(self, expr: expression.OpExpression) -> expression.Expression:
290290

291291
@dataclasses.dataclass
292292
class LowerAsTypeRule(op_lowering.OpLoweringRule):
293-
dialect: Literal["polars", "substrait"]
293+
dialect: Literal["polars", "substrait-datafusion", "substrait-acero"]
294294

295295
@property
296296
def op(self) -> type[ops.ScalarOp]:
@@ -300,8 +300,8 @@ def lower(self, expr: expression.OpExpression) -> expression.Expression:
300300
assert isinstance(expr.op, ops.AsTypeOp)
301301
if self.dialect == "polars":
302302
return _lower_cast_to_polars(expr.op, expr.inputs[0])
303-
elif self.dialect == "substrait":
304-
return _lower_cast_to_substrait(expr.op, expr.inputs[0])
303+
else:
304+
return _lower_cast_to_substrait(expr.op, expr.inputs[0], dialect=self.dialect)
305305

306306

307307
def invert_bytes(byte_string):
@@ -473,7 +473,11 @@ def _lower_cast_to_polars(cast_op: ops.AsTypeOp, arg: expression.Expression):
473473
return cast_op.as_expr(arg)
474474

475475

476-
def _lower_cast_to_substrait(cast_op: ops.AsTypeOp, arg: expression.Expression):
476+
def _lower_cast_to_substrait(
477+
cast_op: ops.AsTypeOp,
478+
arg: expression.Expression,
479+
dialect: Literal["substrait-datafusion", "substrait-acero"] = "substrait-datafusion",
480+
):
477481
if arg.output_type == dtypes.BOOL_DTYPE and cast_op.to_type == dtypes.STRING_DTYPE:
478482
is_true_cond = ops.eq_op.as_expr(arg, expression.const(True))
479483
is_false_cond = ops.eq_op.as_expr(arg, expression.const(False))
@@ -483,6 +487,29 @@ def _lower_cast_to_substrait(cast_op: ops.AsTypeOp, arg: expression.Expression):
483487
is_false_cond,
484488
expression.const("False"),
485489
)
490+
491+
if cast_op.to_type == dtypes.STRING_DTYPE:
492+
if arg.output_type == dtypes.DATETIME_DTYPE:
493+
cast_expr = cast_op.as_expr(arg)
494+
if dialect == "substrait-datafusion":
495+
return string_ops.ReplaceStrOp(pat="T", repl=" ").as_expr(cast_expr)
496+
else:
497+
# Acero: let it cast natively, compiler will intercept and cast to precision_timestamp(0)
498+
return cast_expr
499+
500+
elif arg.output_type == dtypes.TIME_DTYPE:
501+
# Both engines use native cast (Acero is excluded in test)
502+
return cast_op.as_expr(arg)
503+
504+
elif arg.output_type == dtypes.TIMESTAMP_DTYPE:
505+
cast_expr = cast_op.as_expr(arg)
506+
if dialect == "substrait-datafusion":
507+
replaced_t = string_ops.ReplaceStrOp(pat="T", repl=" ").as_expr(cast_expr)
508+
return string_ops.ReplaceStrOp(pat="Z", repl="+00").as_expr(replaced_t)
509+
else:
510+
# Acero: native cast (excluded in test)
511+
return cast_expr
512+
486513
return cast_op.as_expr(arg)
487514

488515

@@ -567,21 +594,20 @@ def lower(self, expr: expression.OpExpression) -> expression.Expression:
567594
LowerFloorOp(),
568595
)
569596

570-
SUBSTRAIT_LOWERING_RULES = (
571-
LowerEqNullsMatchRule(),
572-
*SUBSTRAIT_LOWER_COMPARISONS,
573-
LowerAsTypeRule(dialect="substrait"),
574-
)
575-
576-
577597
def lower_ops_to_polars(root: bigframe_node.BigFrameNode) -> bigframe_node.BigFrameNode:
578598
return op_lowering.lower_ops(root, rules=POLARS_LOWERING_RULES)
579599

580600

581601
def lower_ops_to_substrait(
582602
root: bigframe_node.BigFrameNode,
603+
dialect: Literal["substrait-datafusion", "substrait-acero"] = "substrait-datafusion",
583604
) -> bigframe_node.BigFrameNode:
584-
return op_lowering.lower_ops(root, rules=SUBSTRAIT_LOWERING_RULES)
605+
rules = (
606+
LowerEqNullsMatchRule(),
607+
*SUBSTRAIT_LOWER_COMPARISONS,
608+
LowerAsTypeRule(dialect=dialect),
609+
)
610+
return op_lowering.lower_ops(root, rules=rules)
585611

586612

587613
def _numeric_to_timedelta(expr: expression.Expression) -> expression.Expression:

packages/bigframes/bigframes/core/compile/substrait/compiler.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import bigframes.operations.generic_ops as generic_ops
3131
import bigframes.operations.numeric_ops as numeric_ops
3232
import bigframes.operations.struct_ops as struct_ops
33+
import bigframes.operations.string_ops as string_ops
3334
from bigframes.core import agg_expressions, bigframe_node, nodes, rewrite
3435
from bigframes.core.compile import lowering
3536

@@ -43,9 +44,11 @@ def __init__(
4344
self,
4445
duration_type: Literal["interval_day", "int"],
4546
use_precision_types: bool = True,
47+
dialect: Literal["substrait-datafusion", "substrait-acero"] = "substrait-datafusion",
4648
):
4749
self._duration_type = duration_type
4850
self._use_precision_types = use_precision_types
51+
self._dialect = dialect
4952

5053
def compile(self, plan: bigframe_node.BigFrameNode) -> Optional[bytes]:
5154
"""
@@ -56,7 +59,7 @@ def compile(self, plan: bigframe_node.BigFrameNode) -> Optional[bytes]:
5659

5760
# Need to bind types in before lowering
5861
plan = rewrite.bind_schema_to_tree(plan)
59-
plan = lowering.lower_ops_to_substrait(plan)
62+
plan = lowering.lower_ops_to_substrait(plan, dialect=self._dialect)
6063
pb_rel = self._compile_node(plan)
6164

6265
pb_plan = plan_pb2.Plan()
@@ -73,10 +76,26 @@ def compile(self, plan: bigframe_node.BigFrameNode) -> Optional[bytes]:
7376
)
7477
)
7578

76-
for name, anchor in self._EXTENSIONS.items():
79+
# Determine extensions dynamically based on execution engine
80+
extensions = dict(self._EXTENSIONS)
81+
if self._use_precision_types:
82+
# DataFusion supports standard "replace"
83+
extensions["replace"] = 76
84+
else:
85+
# Acero expects Arrow simple extension function URN for "replace_substring"
86+
extensions["replace_substring"] = 76
87+
88+
# Register Arrow simple extension URN at anchor 1
89+
arrow_uri = pb_plan.extension_urns.add()
90+
arrow_uri.extension_urn_anchor = 1
91+
arrow_uri.urn = "urn:arrow:substrait_simple_extension_function"
92+
93+
for name, anchor in extensions.items():
7794
ext = pb_plan.extensions.add()
7895
ext.extension_function.function_anchor = anchor
7996
ext.extension_function.name = name
97+
if name == "replace_substring" and not self._use_precision_types:
98+
ext.extension_function.extension_urn_reference = 1
8099

81100
return pb_plan.SerializeToString()
82101

@@ -848,8 +867,64 @@ def _compile_astype(
848867
child: nodes.BigFrameNode,
849868
) -> algebra_pb2.Expression:
850869
arg_expr = self._compile_expression(inputs[0], child)
870+
from_dtype = self._get_expression_dtype(inputs[0], child)
871+
872+
if op.to_type == dtypes.STRING_DTYPE:
873+
if from_dtype == dtypes.DATETIME_DTYPE:
874+
# This is only reached for Acero (dialect="substrait-acero"),
875+
# because DataFusion was lowered to ReplaceStrOp in lowering.py!
876+
if not self._use_precision_types:
877+
# Cast to precision_timestamp with precision 0 first, then to string
878+
second_ts_expr = self._compile_cast_with_type_dict(
879+
arg_expr, {"precision_timestamp": {"precision": 0}}
880+
)
881+
return self._compile_cast(second_ts_expr, dtypes.STRING_DTYPE)
882+
851883
return self._compile_cast(arg_expr, op.to_type)
852884

885+
@_compile_op.register(string_ops.ReplaceStrOp)
886+
def _compile_replace_str(
887+
self,
888+
op: string_ops.ReplaceStrOp,
889+
inputs: Sequence[ex.Expression],
890+
child: nodes.BigFrameNode,
891+
) -> algebra_pb2.Expression:
892+
arg_expr = self._compile_expression(inputs[0], child)
893+
return self._compile_replace(arg_expr, op.pat, op.repl)
894+
895+
def _compile_cast_with_type_dict(
896+
self, input_expr: algebra_pb2.Expression, type_dict: Dict[str, Any]
897+
) -> algebra_pb2.Expression:
898+
pb_expr = algebra_pb2.Expression()
899+
cast = pb_expr.cast
900+
cast.input.CopyFrom(input_expr)
901+
json_format.ParseDict(type_dict, cast.type)
902+
cast.failure_behavior = (
903+
algebra_pb2.Expression.Cast.FAILURE_BEHAVIOR_THROW_EXCEPTION
904+
)
905+
return pb_expr
906+
907+
def _compile_replace(
908+
self,
909+
str_expr: algebra_pb2.Expression,
910+
search: str,
911+
replacement: str,
912+
) -> algebra_pb2.Expression:
913+
pb_expr = algebra_pb2.Expression()
914+
pb_expr.scalar_function.function_reference = 76 # "replace" or "replace_substring"
915+
916+
pb_expr.scalar_function.arguments.add().value.CopyFrom(str_expr)
917+
918+
search_expr = algebra_pb2.Expression()
919+
search_expr.literal.string = search
920+
pb_expr.scalar_function.arguments.add().value.CopyFrom(search_expr)
921+
922+
replace_expr = algebra_pb2.Expression()
923+
replace_expr.literal.string = replacement
924+
pb_expr.scalar_function.arguments.add().value.CopyFrom(replace_expr)
925+
926+
return pb_expr
927+
853928
@_compile_op.register(struct_ops.StructOp)
854929
def _compile_struct_op(
855930
self,

packages/bigframes/bigframes/session/substrait_executor.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,18 @@ def default_for_engine(cls, engine_name: str) -> SubstraitExecutor:
115115
return cls(
116116
AceroSubstraitConsumer(),
117117
substrait_compiler.SubstraitCompiler(
118-
duration_type="int", use_precision_types=False
118+
duration_type="int",
119+
use_precision_types=False,
120+
dialect="substrait-acero",
119121
),
120122
)
121123
elif engine_name == "datafusion":
122124
return cls(
123125
DataFusionSubstraitConsumer(),
124-
substrait_compiler.SubstraitCompiler(duration_type="int"),
126+
substrait_compiler.SubstraitCompiler(
127+
duration_type="int",
128+
dialect="substrait-datafusion",
129+
),
125130
)
126131
else:
127132
raise ValueError(f"Unknown engine: {engine_name}")

packages/bigframes/tests/system/small/engines/test_generic_ops.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,18 @@ def test_engines_astype_bool(scalars_array_value: array_value.ArrayValue, engine
178178
)
179179
def test_engines_astype_string(scalars_array_value: array_value.ArrayValue, engine):
180180
# floats work slightly different with trailing zeroes rn
181+
excluded_cols = ["float64_col"]
182+
183+
# Acero's Substrait consumer lacks support for string functions like replace/replace_substring
184+
# and precision_time, so we cannot format time_col and timestamp_col inside the Substrait plan.
185+
from bigframes.session.substrait_executor import SubstraitExecutor
186+
if isinstance(engine, SubstraitExecutor) and not engine._compiler._use_precision_types:
187+
excluded_cols.extend(["time_col", "timestamp_col"])
188+
181189
arr = apply_op(
182190
scalars_array_value,
183191
ops.AsTypeOp(to_type=bigframes.dtypes.STRING_DTYPE),
184-
excluded_cols=["float64_col"],
192+
excluded_cols=excluded_cols,
185193
)
186194

187195
assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine)

0 commit comments

Comments
 (0)