Skip to content

Commit 69728a7

Browse files
feat: Local date accessor execution support
1 parent c0b54f0 commit 69728a7

File tree

5 files changed

+148
-2
lines changed

5 files changed

+148
-2
lines changed

bigframes/core/compile/polars/compiler.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@
3333
import bigframes.operations.aggregations as agg_ops
3434
import bigframes.operations.bool_ops as bool_ops
3535
import bigframes.operations.comparison_ops as comp_ops
36+
import bigframes.operations.date_ops as date_ops
3637
import bigframes.operations.datetime_ops as dt_ops
38+
import bigframes.operations.frequency_ops as freq_ops
3739
import bigframes.operations.generic_ops as gen_ops
3840
import bigframes.operations.json_ops as json_ops
3941
import bigframes.operations.numeric_ops as num_ops
@@ -74,6 +76,20 @@ def decorator(func):
7476

7577

7678
if polars_installed:
79+
_FREQ_MAPPING = {
80+
"Y": "1y",
81+
"Q": "1q",
82+
"M": "1mo",
83+
"W": "1w",
84+
"D": "1d",
85+
"h": "1h",
86+
"min": "1m",
87+
"s": "1s",
88+
"ms": "1ms",
89+
"us": "1us",
90+
"ns": "1ns",
91+
}
92+
7793
_DTYPE_MAPPING = {
7894
# Direct mappings
7995
bigframes.dtypes.INT_DTYPE: pl.Int64(),
@@ -329,11 +345,48 @@ def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
329345
else:
330346
return pl.any_horizontal(*(input.str.ends_with(pat) for pat in op.pat))
331347

348+
@compile_op.register(freq_ops.FloorDtOp)
349+
def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
350+
assert isinstance(op, freq_ops.FloorDtOp)
351+
return input.dt.truncate(every=_FREQ_MAPPING[op.freq])
352+
332353
@compile_op.register(dt_ops.StrftimeOp)
333354
def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
334355
assert isinstance(op, dt_ops.StrftimeOp)
335356
return input.dt.strftime(op.date_format)
336357

358+
@compile_op.register(date_ops.YearOp)
359+
def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
360+
return input.dt.year()
361+
362+
@compile_op.register(date_ops.QuarterOp)
363+
def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
364+
return input.dt.quarter()
365+
366+
@compile_op.register(date_ops.MonthOp)
367+
def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
368+
return input.dt.month()
369+
370+
@compile_op.register(date_ops.DayOfWeekOp)
371+
def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
372+
return input.dt.weekday() - 1
373+
374+
@compile_op.register(date_ops.DayOp)
375+
def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
376+
return input.dt.day()
377+
378+
@compile_op.register(date_ops.IsoYearOp)
379+
def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
380+
return input.dt.iso_year()
381+
382+
@compile_op.register(date_ops.IsoWeekOp)
383+
def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
384+
return input.dt.week()
385+
386+
@compile_op.register(date_ops.IsoDayOp)
387+
def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
388+
return input.dt.weekday()
389+
337390
@compile_op.register(dt_ops.ParseDatetimeOp)
338391
def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr:
339392
assert isinstance(op, dt_ops.ParseDatetimeOp)

bigframes/operations/datetimes.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
_ONE_DAY = pandas.Timedelta("1d")
3131
_ONE_SECOND = pandas.Timedelta("1s")
3232
_ONE_MICRO = pandas.Timedelta("1us")
33+
_SUPPORTED_FREQS = ("Y", "Q", "M", "W", "D", "h", "min", "s", "ms", "us")
3334

3435

3536
@log_adapter.class_logger
@@ -155,4 +156,6 @@ def normalize(self) -> series.Series:
155156
return self._apply_unary_op(ops.normalize_op)
156157

157158
def floor(self, freq: str) -> series.Series:
158-
return self._apply_unary_op(ops.FloorDtOp(freq=freq))
159+
if freq not in _SUPPORTED_FREQS:
160+
raise ValueError(f"freq must be one of {_SUPPORTED_FREQS}")
161+
return self._apply_unary_op(ops.FloorDtOp(freq=freq)) # type: ignore

bigframes/operations/frequency_ops.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,22 @@
2727
@dataclasses.dataclass(frozen=True)
2828
class FloorDtOp(base_ops.UnaryOp):
2929
name: typing.ClassVar[str] = "floor_dt"
30-
freq: str
30+
freq: typing.Literal[
31+
"Y",
32+
"Q",
33+
"M",
34+
"W",
35+
"D",
36+
"h",
37+
"min",
38+
"s",
39+
"ms",
40+
"us",
41+
]
3142

3243
def output_type(self, *input_types):
44+
if not dtypes.is_datetime_like(input_types[0]):
45+
raise TypeError("dt floor requires datetime-like arguments")
3346
return input_types[0]
3447

3548

bigframes/session/polars_executor.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
from bigframes.operations import (
2525
bool_ops,
2626
comparison_ops,
27+
date_ops,
28+
frequency_ops,
2729
generic_ops,
2830
numeric_ops,
2931
string_ops,
@@ -60,6 +62,15 @@
6062
comparison_ops.GtOp,
6163
comparison_ops.LeOp,
6264
comparison_ops.GeOp,
65+
date_ops.YearOp,
66+
date_ops.QuarterOp,
67+
date_ops.MonthOp,
68+
date_ops.DayOfWeekOp,
69+
date_ops.DayOp,
70+
date_ops.IsoYearOp,
71+
date_ops.IsoWeekOp,
72+
date_ops.IsoDayOp,
73+
frequency_ops.FloorDtOp,
6374
numeric_ops.AddOp,
6475
numeric_ops.SubOp,
6576
numeric_ops.MulOp,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
17+
from bigframes.core import array_value
18+
import bigframes.operations as ops
19+
from bigframes.session import polars_executor
20+
from bigframes.testing.engine_utils import assert_equivalence_execution
21+
22+
pytest.importorskip("polars")
23+
24+
# Polars used as reference as its fast and local. Generally though, prefer gbq engine where they disagree.
25+
REFERENCE_ENGINE = polars_executor.PolarsExecutor()
26+
27+
28+
@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True)
29+
def test_engines_dt_floor(scalars_array_value: array_value.ArrayValue, engine):
30+
arr, _ = scalars_array_value.compute_values(
31+
[
32+
ops.FloorDtOp("us").as_expr("timestamp_col"),
33+
ops.FloorDtOp("ms").as_expr("timestamp_col"),
34+
ops.FloorDtOp("s").as_expr("timestamp_col"),
35+
ops.FloorDtOp("min").as_expr("timestamp_col"),
36+
ops.FloorDtOp("h").as_expr("timestamp_col"),
37+
ops.FloorDtOp("D").as_expr("timestamp_col"),
38+
ops.FloorDtOp("W").as_expr("timestamp_col"),
39+
ops.FloorDtOp("M").as_expr("timestamp_col"),
40+
ops.FloorDtOp("Q").as_expr("timestamp_col"),
41+
ops.FloorDtOp("Y").as_expr("timestamp_col"),
42+
ops.FloorDtOp("Q").as_expr("datetime_col"),
43+
ops.FloorDtOp("us").as_expr("datetime_col"),
44+
]
45+
)
46+
assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine)
47+
48+
49+
@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True)
50+
def test_engines_date_accessors(scalars_array_value: array_value.ArrayValue, engine):
51+
datelike_cols = ["datetime_col", "timestamp_col", "date_col"]
52+
accessors = [
53+
ops.day_op,
54+
ops.dayofweek_op,
55+
ops.month_op,
56+
ops.quarter_op,
57+
ops.year_op,
58+
ops.iso_day_op,
59+
ops.iso_week_op,
60+
ops.iso_year_op,
61+
]
62+
63+
exprs = [acc.as_expr(col) for acc in accessors for col in datelike_cols]
64+
65+
arr, _ = scalars_array_value.compute_values(exprs)
66+
assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine)

0 commit comments

Comments
 (0)