blob: 42a827fc6328b83aea13b7e16f7f4cb5f5f1c183 [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.
"""What a function library gets for shipping a bundle instead of a recipe."""
from __future__ import annotations
import pyarrow as pa
import pytest
from datafusion import SessionContext, SessionExtensionComponents, udf
from datafusion_ffi_example import IsNullUDF, MyFunctionExtension
def _session():
"""A session with the library installed in one call.
The comparison this file exists to make: without a bundle this is three
``register_*`` calls the caller has to know about, one per function.
"""
ctx = SessionContext().with_extensions(MyFunctionExtension())
batch = pa.RecordBatch.from_arrays([pa.array([1, 2, 3, None])], names=["a"])
ctx.register_record_batches("test_table", [[batch]])
return ctx
def test_one_call_installs_every_function():
"""All three kinds arrive across the FFI boundary from one hook."""
ctx = _session()
scalar = ctx.sql("select my_custom_is_null(a) from test_table").collect()
assert [r.column(0) for r in scalar] == [
pa.array([False, False, False, True], type=pa.bool_())
]
aggregate = ctx.sql("select my_custom_sum(a) from test_table").collect()
assert aggregate[0].column(0)[0].as_py() == 6
window = ctx.sql(
"select my_custom_rank() over (order by a) from test_table"
).collect()
assert window[0].num_rows == 4
def test_the_names_come_from_the_capsules():
"""Not from anything the bundle or the host said.
The wrappers are built by the host during resolution, so a name it invented
would be the one a query had to use. These are the names the Rust
``ScalarUDFImpl`` and friends report.
"""
ctx = _session()
assert ctx.udf("my_custom_is_null").name == "my_custom_is_null"
assert ctx.udaf("my_custom_sum").name == "my_custom_sum"
assert ctx.udwf("my_custom_rank").name == "my_custom_rank"
def test_the_bundle_is_reusable_across_sessions():
"""One bundle object, two sessions: components are built per install."""
extension = MyFunctionExtension()
first = SessionContext().with_extensions(extension)
second = SessionContext().with_extensions(extension)
assert first.udf("my_custom_is_null").name == "my_custom_is_null"
assert second.udf("my_custom_is_null").name == "my_custom_is_null"
assert first.session_id() != second.session_id()
def test_installing_the_library_twice_is_refused():
"""The collision rule holds for functions arriving over FFI.
Two instances of one library is the shape this actually takes in the wild —
an application assembling its extension list from a plugin registry that
lists the same package twice.
"""
ctx = SessionContext()
with pytest.raises(ValueError, match=r"scalar function named 'my_custom_is_null'"):
ctx.with_extensions(MyFunctionExtension(), MyFunctionExtension())
with pytest.raises(KeyError):
ctx.udf("my_custom_is_null")
def test_a_bare_capsule_is_not_a_declaration():
"""A declared function is an object, even though ``udf`` takes a capsule.
The capsule itself is fine -- ``udf`` wraps it -- so the refusal is the
declaration rule, not a bad capsule.
"""
ctx = SessionContext()
capsule = IsNullUDF().__datafusion_scalar_udf__()
class CapsuleExtension:
def __datafusion_session_components__(
self, ctx: SessionContext
) -> SessionExtensionComponents:
return SessionExtensionComponents(udfs=(capsule,))
with pytest.raises(TypeError, match=r"__datafusion_scalar_udf__"):
ctx.with_extensions(CapsuleExtension())
with pytest.raises(KeyError):
ctx.udf("my_custom_is_null")
assert udf(capsule).name == "my_custom_is_null"
def test_a_failure_after_the_hook_registers_nothing():
"""The transaction covers functions imported across the FFI boundary too."""
ctx = SessionContext()
class BoomPlanner:
def __datafusion_session_planner__(self, ctx, fallback) -> None:
msg = "boom"
raise RuntimeError(msg)
with pytest.raises(RuntimeError, match="boom"):
ctx.with_extensions(MyFunctionExtension(), BoomPlanner())
with pytest.raises(KeyError):
ctx.udf("my_custom_is_null")
def test_the_hook_returns_the_components_type():
"""The bundle builds a real dataclass, not a duck-typed stand-in.
``with_extensions`` rejects anything else, so a Rust bundle that imported
the wrong name would fail at install rather than silently contribute
nothing.
"""
components = MyFunctionExtension().__datafusion_session_components__(
SessionContext()
)
assert isinstance(components, SessionExtensionComponents)
assert len(components.udfs) == 1
assert components.logical_extension_codecs == ()