blob: 2a3466eec7571631d0d541970b87deae2eafa27a [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
from datetime import datetime, timezone
import pyarrow as pa
import pyarrow.compute as pc
import pytest
from datafusion import Accumulator, SessionContext, column, udaf
from datafusion.expr import Window
class Summarize(Accumulator):
"""Interface of a user-defined accumulation."""
def __init__(self, initial_value: float = 0.0, as_scalar: bool = False):
self._sum = initial_value
self.as_scalar = as_scalar
def state(self) -> list[pa.Scalar]:
if self.as_scalar:
return [pa.scalar(self._sum)]
return [self._sum]
def update(self, values: pa.Array) -> None:
# Not nice since pyarrow scalars can't be summed yet.
# This breaks on `None`
self._sum = self._sum + pc.sum(values).as_py()
def merge(self, states: list[pa.Array]) -> None:
# Not nice since pyarrow scalars can't be summed yet.
# This breaks on `None`
self._sum = self._sum + pc.sum(states[0]).as_py()
def evaluate(self) -> pa.Scalar:
if self.as_scalar:
return pa.scalar(self._sum)
return self._sum
class NotSubclassOfAccumulator:
pass
class MissingMethods(Accumulator):
def __init__(self):
self._sum = pa.scalar(0)
def state(self) -> list[pa.Scalar]:
return [self._sum]
class CollectTimestamps(Accumulator):
def __init__(self, wrap_in_scalar: bool):
self._values: list[datetime] = []
self.wrap_in_scalar = wrap_in_scalar
def state(self) -> list[pa.Scalar]:
if self.wrap_in_scalar:
return [pa.scalar(self._values, type=pa.list_(pa.timestamp("ns")))]
return [pa.array(self._values, type=pa.timestamp("ns"))]
def update(self, values: pa.Array) -> None:
self._values.extend(values.to_pylist())
def merge(self, states: list[pa.Array]) -> None:
for state in states[0].to_pylist():
if state is not None:
self._values.extend(state)
def evaluate(self) -> pa.Scalar:
if self.wrap_in_scalar:
return pa.scalar(self._values, type=pa.list_(pa.timestamp("ns")))
return pa.array(self._values, type=pa.timestamp("ns"))
@pytest.fixture
def df(ctx):
# create a RecordBatch and a new DataFrame from it
batch = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 4, 6])],
names=["a", "b"],
)
return ctx.create_dataframe([[batch]], name="test_table")
def test_errors(df):
with pytest.raises(TypeError):
udaf(
NotSubclassOfAccumulator,
pa.float64(),
pa.float64(),
[pa.float64()],
volatility="immutable",
)
msg = (
"Can't instantiate abstract class MissingMethods (without an implementation "
"for abstract methods 'evaluate', 'merge', 'update'|with abstract methods "
"evaluate, merge, update)"
)
with pytest.raises(Exception, match=msg):
accum = udaf( # noqa: F841
MissingMethods,
pa.int64(),
pa.int64(),
[pa.int64()],
volatility="immutable",
)
def test_udaf_aggregate(df):
summarize = udaf(
Summarize,
pa.float64(),
pa.float64(),
[pa.float64()],
volatility="immutable",
)
df1 = df.aggregate([], [summarize(column("a"))])
# execute and collect the first (and only) batch
result = df1.collect()[0]
assert result.column(0) == pa.array([1.0 + 2.0 + 3.0])
df2 = df.aggregate([], [summarize(column("a"))])
# Run a second time to ensure the state is properly reset
result = df2.collect()[0]
assert result.column(0) == pa.array([1.0 + 2.0 + 3.0])
def test_udaf_decorator_aggregate(df):
@udaf(pa.float64(), pa.float64(), [pa.float64()], "immutable")
def summarize():
return Summarize()
df1 = df.aggregate([], [summarize(column("a"))])
# execute and collect the first (and only) batch
result = df1.collect()[0]
assert result.column(0) == pa.array([1.0 + 2.0 + 3.0])
df2 = df.aggregate([], [summarize(column("a"))])
# Run a second time to ensure the state is properly reset
result = df2.collect()[0]
assert result.column(0) == pa.array([1.0 + 2.0 + 3.0])
def test_udaf_decorator_keyword_arguments(df):
@udaf(
input_types=pa.float64(),
return_type=pa.float64(),
state_type=[pa.float64()],
volatility="immutable",
)
def summarize():
return Summarize()
result = df.aggregate([], [summarize(column("a"))]).collect()[0]
assert result.column(0) == pa.array([1.0 + 2.0 + 3.0])
def test_udaf_function_keyword_arguments(df):
summarize = udaf(
accum=Summarize,
input_types=pa.float64(),
return_type=pa.float64(),
state_type=[pa.float64()],
volatility="immutable",
)
result = df.aggregate([], [summarize(column("a"))]).collect()[0]
assert result.column(0) == pa.array([1.0 + 2.0 + 3.0])
@pytest.mark.parametrize("as_scalar", [True, False])
def test_udaf_aggregate_with_arguments(df, as_scalar):
bias = 10.0
summarize = udaf(
lambda: Summarize(initial_value=bias, as_scalar=as_scalar),
pa.float64(),
pa.float64(),
[pa.float64()],
volatility="immutable",
)
df1 = df.aggregate([], [summarize(column("a"))])
# execute and collect the first (and only) batch
result = df1.collect()[0]
assert result.column(0) == pa.array([bias + 1.0 + 2.0 + 3.0])
df2 = df.aggregate([], [summarize(column("a"))])
# Run a second time to ensure the state is properly reset
result = df2.collect()[0]
assert result.column(0) == pa.array([bias + 1.0 + 2.0 + 3.0])
def test_udaf_decorator_aggregate_with_arguments(df):
bias = 10.0
@udaf(pa.float64(), pa.float64(), [pa.float64()], "immutable")
def summarize():
return Summarize(bias)
df1 = df.aggregate([], [summarize(column("a"))])
# execute and collect the first (and only) batch
result = df1.collect()[0]
assert result.column(0) == pa.array([bias + 1.0 + 2.0 + 3.0])
df2 = df.aggregate([], [summarize(column("a"))])
# Run a second time to ensure the state is properly reset
result = df2.collect()[0]
assert result.column(0) == pa.array([bias + 1.0 + 2.0 + 3.0])
def test_group_by(df):
summarize = udaf(
Summarize,
pa.float64(),
pa.float64(),
[pa.float64()],
volatility="immutable",
)
df = df.aggregate([column("b")], [summarize(column("a"))])
batches = df.collect()
arrays = [batch.column(1) for batch in batches]
joined = pa.concat_arrays(arrays)
assert joined == pa.array([1.0 + 2.0, 3.0])
def test_register_udaf(ctx, df) -> None:
summarize = udaf(
Summarize,
pa.float64(),
pa.float64(),
[pa.float64()],
volatility="immutable",
)
ctx.register_udaf(summarize)
df_result = ctx.sql("select summarize(b) from test_table")
assert df_result.collect()[0][0][0].as_py() == 14.0
@pytest.fixture
def distinct_ctx():
ctx = SessionContext()
ctx.register_udaf(
udaf(
Summarize,
pa.float64(),
pa.float64(),
[pa.float64()],
volatility="immutable",
)
)
ctx.from_pydict({"v": [1.0, 1.0, 1.0, 5.0]}, name="t")
return ctx
@pytest.mark.parametrize(
"run",
[
pytest.param(
lambda ctx, summarize: ctx.table("t").aggregate(
[], [summarize(column("v")).distinct().build().alias("r")]
),
id="distinct aggregate",
),
pytest.param(
lambda ctx, summarize: ctx.table("t").select(
summarize(column("v")).over(Window()).distinct().build().alias("r")
),
id="distinct window",
),
],
)
def test_udaf_distinct_raises(distinct_ctx, run):
# The Python accumulator cannot deduplicate, so it would count every row.
summarize = udaf(
Summarize,
pa.float64(),
pa.float64(),
[pa.float64()],
volatility="immutable",
)
with pytest.raises(Exception, match="DISTINCT is not supported"):
run(distinct_ctx, summarize).collect()
def test_udaf_distinct_rewritten_to_group_by_runs(distinct_ctx):
# The optimizer groups by the distinct values, so the accumulator never
# sees DISTINCT.
result = distinct_ctx.sql("select summarize(distinct v) as r from t")
assert result.collect_column("r").to_pylist() == [6.0]
@pytest.mark.parametrize("wrap_in_scalar", [True, False])
def test_udaf_list_timestamp_return(ctx, wrap_in_scalar) -> None:
timestamps1 = [
datetime(2024, 1, 1, tzinfo=timezone.utc),
datetime(2024, 1, 2, tzinfo=timezone.utc),
]
timestamps2 = [
datetime(2024, 1, 3, tzinfo=timezone.utc),
datetime(2024, 1, 4, tzinfo=timezone.utc),
]
batch1 = pa.RecordBatch.from_arrays(
[pa.array(timestamps1, type=pa.timestamp("ns"))],
names=["ts"],
)
batch2 = pa.RecordBatch.from_arrays(
[pa.array(timestamps2, type=pa.timestamp("ns"))],
names=["ts"],
)
df = ctx.create_dataframe([[batch1], [batch2]], name="timestamp_table")
list_type = pa.list_(
pa.field("item", type=pa.timestamp("ns"), nullable=wrap_in_scalar)
)
collect = udaf(
lambda: CollectTimestamps(wrap_in_scalar),
pa.timestamp("ns"),
list_type,
[list_type],
volatility="immutable",
)
result = df.aggregate([], [collect(column("ts"))]).collect()[0]
# There is no guarantee about the ordering of the batches, so perform a sort
# to get consistent results. Alternatively we could sort on evaluate().
assert (
result.column(0).values.sort()
== pa.array(
[[*timestamps1, *timestamps2]],
type=list_type,
).values
)