blob: ac4c69d14c6696c15bfe47fa1f4f22dfe99ecec1 [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
import pyarrow as pa
import pytest
from datafusion import Expr, LogicalPlan, SessionContext, col, udf
from datafusion_ffi_example import (
MyLogicalExtensionCodec,
MyTableProvider,
NameOnlyFunction,
NameOnlyUdfCodec,
)
def _double_udf():
return udf(
lambda arr: pa.array([(v.as_py() or 0) * 2 for v in arr]),
[pa.int64()],
pa.int64(),
volatility="immutable",
name="double",
)
def _encode_provider_plan(token: str) -> tuple[bytes, MyLogicalExtensionCodec]:
"""Serialize a plan over this library's table provider using a codec
that stamps `token` on the encoded provider.
Returns the blob and the codec, so callers can assert on its call
counters. The token is chosen per test so a second codec installed
later is provably unable to claim these bytes.
The codec is installed under ``token`` as its id as well, so a
caller can reinstall the same instance elsewhere and have the tag on
these bytes resolve. Identity would otherwise be derived from the
class, which every instance shares.
"""
codec = MyLogicalExtensionCodec(provider_prefix=token)
ctx = SessionContext().with_logical_extension_codec(codec, codec_id=token)
ctx.register_table("numbers", MyTableProvider(1, 4, 1))
blob = ctx.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(ctx)
assert token.encode() in blob
return blob, codec
def _setup_session_with_codec() -> tuple[SessionContext, MyLogicalExtensionCodec]:
"""Build a session with the user-supplied logical extension codec
installed. Tests use a FROM-less query so plan serialization does
not pull in `try_encode_table_provider`, which the default codec
leaves unimplemented."""
base = SessionContext()
codec = MyLogicalExtensionCodec()
ctx = base.with_logical_extension_codec(codec)
return ctx, codec
def test_ffi_logical_codec_install_and_export():
"""Installing a user FFI codec adds it to the session's logical
codec chain; the capsule getter on the session re-exports it."""
ctx, _codec = _setup_session_with_codec()
capsule = ctx.__datafusion_logical_extension_codec__()
assert capsule is not None
def test_ffi_logical_codec_consulted_on_udf_encode():
"""Serializing through ctx.logical_codec() routes try_encode_udf to
the user-installed FFI codec.
Verifies the dispatch chain
`PyLogicalPlan.to_bytes -> session.logical_codec ->
PythonLogicalCodec -> FFI_LogicalExtensionCodec -> user impl`
is wired correctly. The user codec's atomic counter increments
after a serialization pass, proving every hop forwards.
Does not test any Python-UDF-specific dispatch — PythonLogicalCodec
currently delegates all UDF encoding to its inner codec
unconditionally. Python-vs-other branching lands when in-band
scalar UDF encoding is added.
"""
ctx, codec = _setup_session_with_codec()
df = ctx.sql("SELECT abs(-1) AS x")
plan = df.logical_plan()
before = codec.encode_udf_calls()
_ = plan.to_bytes(ctx)
after = codec.encode_udf_calls()
assert after > before, (
f"Expected user FFI codec encode_udf to fire, before={before} after={after}"
)
def test_ffi_logical_codec_roundtrip():
"""A plan referencing an FFI-imported UDF round-trips through the
user-supplied logical codec (encode via codec, decode resolves from
registry — `try_decode_udf` is only consulted when the UDF is not
in the registry, which is the codec-inlined case)."""
ctx, _codec = _setup_session_with_codec()
df = ctx.sql("SELECT abs(-1) AS x")
blob = df.logical_plan().to_bytes(ctx)
restored = LogicalPlan.from_bytes(ctx, blob)
df_round_trip = ctx.create_dataframe_from_logical_plan(restored)
assert df.collect() == df_round_trip.collect()
def test_ffi_logical_codec_composes_with_later_install():
"""Codecs compose: installing a second codec appends it to the
session's codec chain instead of replacing the first. The second
codec here (a default-backed codec exported from a fresh session)
cannot encode this library's table provider, so the first codec
still claims it. Under replace semantics this test fails with
`LogicalExtensionCodec is not provided`.
Both directions are counted. Asserting only the round-trip result
would pass if decoding resolved the table some other way; the
decode count pins that the bytes went back to the codec that wrote
them."""
ctx, codec = _setup_session_with_codec()
ctx = ctx.with_logical_extension_codec(
SessionContext().__datafusion_logical_extension_codec__()
)
ctx.register_table("numbers", MyTableProvider(1, 4, 1))
df = ctx.sql('SELECT "A" FROM numbers')
plan = df.logical_plan()
encode_before = codec.table_provider_encode_calls()
decode_before = codec.table_provider_decode_calls()
blob = plan.to_bytes(ctx)
assert codec.table_provider_encode_calls() > encode_before
restored = LogicalPlan.from_bytes(ctx, blob)
assert codec.table_provider_decode_calls() > decode_before
df_round_trip = ctx.create_dataframe_from_logical_plan(restored)
assert df.collect() == df_round_trip.collect()
def test_first_installed_codec_encodes():
"""Encoding walks the chain in install order, so the earliest
installed codec that can claim an object gets it.
Both orders run in one test on purpose. Asserting a single order
would also pass under replace semantics, where the second install
simply discards the first codec; swapping the order and getting the
other token proves the losing codec was still installed and merely
lost the race.
The two instances need explicit ids: identity is otherwise derived
from the class, and these are two instances of one class owning
disjoint slices of the wire format.
"""
for winner, loser in (("TOKENAAA", "TOKENBBB"), ("TOKENBBB", "TOKENAAA")):
winner_codec = MyLogicalExtensionCodec(provider_prefix=winner)
loser_codec = MyLogicalExtensionCodec(provider_prefix=loser)
ctx = SessionContext().with_logical_extension_codec(
winner_codec, codec_id=winner
)
ctx = ctx.with_logical_extension_codec(loser_codec, codec_id=loser)
ctx.register_table("numbers", MyTableProvider(1, 4, 1))
blob = ctx.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(ctx)
assert winner.encode() in blob
assert loser.encode() not in blob
assert winner_codec.table_provider_encode_calls() == 1
assert loser_codec.table_provider_encode_calls() == 0
def test_installing_a_codec_cannot_hijack_an_earlier_codecs_objects():
"""Appending is additive: a later install can claim objects nothing
else claimed, but never takes over an object an earlier codec was
already encoding.
This is why install order is append rather than prepend. Under
prepend, adding an unrelated library would silently change how an
existing library's objects encode -- and, once payloads are tagged,
would renumber ids that older payloads already reference.
"""
first = MyLogicalExtensionCodec(provider_prefix="TOKENAAA")
ctx = SessionContext().with_logical_extension_codec(first, codec_id="TOKENAAA")
ctx.register_table("numbers", MyTableProvider(1, 4, 1))
before = ctx.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(ctx)
later = MyLogicalExtensionCodec(provider_prefix="TOKENBBB")
ctx = ctx.with_logical_extension_codec(later, codec_id="TOKENBBB")
after = ctx.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(ctx)
# Provider tokens are minted per encode, so the payloads differ in the
# token id. What must not change is which codec claimed the provider.
assert b"TOKENAAA" in after
assert b"TOKENBBB" not in after
assert later.table_provider_encode_calls() == 0
assert len(before) == len(after)
def test_decode_dispatches_to_the_codec_that_encoded():
"""A payload names the codec that wrote it, so decoding consults
exactly that codec and never offers the bytes to any other.
The blob here is written by ``first`` in its own session. Installing
``second`` alongside it must not put ``second`` anywhere near those
bytes -- under trial-and-error dispatch it would be asked first, and
a codec that decodes structurally similar protobuf would answer.
"""
blob, first = _encode_provider_plan("TOKENAAA")
second = MyLogicalExtensionCodec(provider_prefix="TOKENBBB")
ctx = SessionContext().with_logical_extension_codec(first, codec_id="TOKENAAA")
ctx = ctx.with_logical_extension_codec(second, codec_id="TOKENBBB")
restored = LogicalPlan.from_bytes(ctx, blob)
assert ctx.create_dataframe_from_logical_plan(restored).collect()
assert first.table_provider_decode_calls() == 1
assert second.table_provider_decode_calls() == 0
def test_decode_survives_a_different_install_order():
"""Dispatch keys off codec identity, not chain position, so the
decoding session may install the same codecs in any order.
This is the case positional dispatch cannot handle: the encoding
session has the owning codec at index 0 and the decoding session has
it at index 1. Keying on position would hand the payload to whatever
sits at index 0 in the decoder -- silently, and with a plausible
result.
"""
owner = MyLogicalExtensionCodec(provider_prefix="TOKENAAA")
other = MyLogicalExtensionCodec(provider_prefix="TOKENBBB")
encoder = SessionContext().with_logical_extension_codec(owner, codec_id="TOKENAAA")
encoder = encoder.with_logical_extension_codec(other, codec_id="TOKENBBB")
encoder.register_table("numbers", MyTableProvider(1, 4, 1))
blob = encoder.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(encoder)
# Same codecs, opposite order.
decoder = SessionContext().with_logical_extension_codec(other, codec_id="TOKENBBB")
decoder = decoder.with_logical_extension_codec(owner, codec_id="TOKENAAA")
restored = LogicalPlan.from_bytes(decoder, blob)
assert decoder.create_dataframe_from_logical_plan(restored).collect()
assert other.table_provider_decode_calls() == 0
def test_decode_names_the_codec_that_is_not_installed():
"""When the owning codec is absent the error names it and lists what
is installed, instead of reporting DataFusion's generic "not
provided" from whichever codec was tried last."""
blob, _owner = _encode_provider_plan("TOKENBBB")
ctx = SessionContext().with_logical_extension_codec(
MyLogicalExtensionCodec(provider_prefix="TOKENCCC"), codec_id="lib_c.Codec"
)
with pytest.raises(Exception, match="TOKENBBB") as excinfo:
LogicalPlan.from_bytes(ctx, blob)
message = str(excinfo.value)
# Names the codec the payload belongs to, and what is actually here.
assert "not installed on this session" in message
assert "lib_c.Codec" in message
def test_a_codec_id_defaults_to_its_class_module_and_qualname():
"""A codec that declares no identity is named by its class.
That string goes on the wire in front of every payload the codec
writes, so it is a compatibility surface: renaming the class or moving
the module changes it, and plans stored by an earlier release stop
decoding. A library that needs to rename declares
``__datafusion_codec_id__`` instead, which the next test covers.
Pinned twice on purpose -- once against the literal, so a rename has to
come here and be acknowledged, and once against the rule, so the
literal cannot drift into something the code no longer derives.
"""
ctx = SessionContext().with_logical_extension_codec(MyLogicalExtensionCodec())
assert ctx.logical_extension_codec_ids() == [
"datafusion_ffi_example.MyLogicalExtensionCodec"
]
assert ctx.logical_extension_codec_ids() == [
f"{MyLogicalExtensionCodec.__module__}.{MyLogicalExtensionCodec.__qualname__}"
]
class _CodecUnderItsOldName:
"""A library codec that pins its identity, so the class can be renamed.
Delegates the capsule getter to a real FFI codec. ``session`` is
forwarded, because that argument is how the underlying library reaches
the session the codec is being installed on.
"""
__datafusion_codec_id__ = "pinned.example.Codec"
def __init__(self, inner: MyLogicalExtensionCodec) -> None:
self._inner = inner
def __datafusion_logical_extension_codec__(self, session: object = None) -> object:
return self._inner.__datafusion_logical_extension_codec__(session)
class _CodecUnderItsNewName(_CodecUnderItsOldName):
"""The same codec after a rename. Same pinned id, different class."""
def test_a_pinned_codec_id_survives_a_class_rename():
"""``__datafusion_codec_id__`` decouples identity from the class name,
which is the reason to declare one.
A plan encoded by the codec under its old name decodes on a session
that only knows the new name. Under the class-derived default the two
would be different ids and the payload would be undecodable.
"""
assert _CodecUnderItsOldName.__qualname__ != _CodecUnderItsNewName.__qualname__
old = MyLogicalExtensionCodec(provider_prefix="TOKENAAA")
encoder = SessionContext().with_logical_extension_codec(_CodecUnderItsOldName(old))
assert encoder.logical_extension_codec_ids() == ["pinned.example.Codec"]
encoder.register_table("numbers", MyTableProvider(1, 4, 1))
blob = encoder.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(encoder)
# The pinned id, not the class name, is what the payload carries.
assert b"pinned.example.Codec" in blob
assert b"_CodecUnderItsOldName" not in blob
new = MyLogicalExtensionCodec(provider_prefix="TOKENAAA")
decoder = SessionContext().with_logical_extension_codec(_CodecUnderItsNewName(new))
assert decoder.logical_extension_codec_ids() == ["pinned.example.Codec"]
restored = LogicalPlan.from_bytes(decoder, blob)
assert decoder.create_dataframe_from_logical_plan(restored).collect()
assert new.table_provider_decode_calls() == 1
def test_installing_two_codecs_under_one_id_is_rejected():
"""Identity is derived from the class, so installing two instances of
one class collides. Rejecting at install time is the point: two
codecs sharing an id are indistinguishable when a payload is decoded,
and only the caller knows whether they write the same wire format."""
ctx = SessionContext().with_logical_extension_codec(MyLogicalExtensionCodec())
with pytest.raises(ValueError, match="already installed"):
ctx.with_logical_extension_codec(MyLogicalExtensionCodec())
def _encode_through_a_bare_capsule(token: str) -> tuple[bytes, list[str]]:
"""Serialize a provider plan through a codec installed as a bare
capsule, which is the case with no derivable identity.
Returns the blob and the encoding session's codec ids, so a caller
can compare them against another session's.
"""
exporter = SessionContext().with_logical_extension_codec(
MyLogicalExtensionCodec(provider_prefix=token)
)
encoder = SessionContext().with_logical_extension_codec(
exporter.__datafusion_logical_extension_codec__()
)
encoder.register_table("numbers", MyTableProvider(1, 4, 1))
blob = encoder.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(encoder)
return blob, encoder.logical_extension_codec_ids()
def test_bare_capsule_codec_is_session_local():
"""A bare PyCapsule exposes nothing stable to derive an identity
from -- every capsule reports the same type -- so it is tagged with a
session-local id. A plan it encodes fails on an unrelated session
with an error naming the fix, rather than being decoded by whichever
codec happens to sit at the same position."""
blob, _ids = _encode_through_a_bare_capsule("TOKENAAA")
with pytest.raises(Exception, match="bare PyCapsule") as excinfo:
LogicalPlan.from_bytes(SessionContext(), blob)
assert "codec_id" in str(excinfo.value)
def test_a_bare_capsule_codec_id_is_not_re_mintable_by_another_session():
"""The id given to a capsule-installed codec must be one no other
session can arrive at.
Both sessions here install exactly one bare capsule, so any identity
drawn from a namespace both sessions number the same way -- a
counter, a position in the chain -- collides, and the payload is
handed to the other library's codec. That failure is quiet: a codec
offered bytes it does not recognise falls through to its own inner
default codec, so the error names neither codec and no counter moves.
Asserting on the message is what separates the two schemes.
The empty-chain case in the test above passes under either scheme,
because a lookup in an empty chain misses whatever the id is.
"""
blob, encoder_ids = _encode_through_a_bare_capsule("TOKENAAA")
# An unrelated session, also holding exactly one bare capsule.
other = SessionContext().with_logical_extension_codec(
MyLogicalExtensionCodec(provider_prefix="TOKENBBB")
)
decoder = SessionContext().with_logical_extension_codec(
other.__datafusion_logical_extension_codec__()
)
decoder_ids = decoder.logical_extension_codec_ids()
assert len(encoder_ids) == len(decoder_ids) == 1
assert encoder_ids != decoder_ids
with pytest.raises(Exception, match="bare PyCapsule") as excinfo:
LogicalPlan.from_bytes(decoder, blob)
assert "codec_id" in str(excinfo.value)
def test_installing_a_session_as_a_codec_uses_a_per_session_id():
"""A context installed as a codec is identified by its session, not by
its class.
Every ``SessionContext`` shares one class, so a class-derived id would
name all of them: two contexts could not coexist on one target, and a
payload written through one would resolve to the other on decode. The
sources are held in locals because the imported codecs resolve their
task context against them.
"""
src_a = SessionContext().with_logical_extension_codec(
MyLogicalExtensionCodec(provider_prefix="TOKENAAA")
)
src_b = SessionContext().with_logical_extension_codec(
MyLogicalExtensionCodec(provider_prefix="TOKENBBB")
)
assert src_a.__datafusion_codec_id__ != src_b.__datafusion_codec_id__
# Both sources compose onto one session, which a shared id would refuse.
both = SessionContext().with_logical_extension_codec(src_a)
both = both.with_logical_extension_codec(src_b)
assert both.logical_extension_codec_ids() == [
src_a.__datafusion_codec_id__,
src_b.__datafusion_codec_id__,
]
# A payload written through one source does not resolve to the other.
encoder = SessionContext().with_logical_extension_codec(src_a)
encoder.register_table("numbers", MyTableProvider(1, 4, 1))
blob = encoder.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(encoder)
decoder = SessionContext().with_logical_extension_codec(src_b)
with pytest.raises(Exception, match="not installed on this session") as excinfo:
LogicalPlan.from_bytes(decoder, blob)
assert src_a.__datafusion_codec_id__ in str(excinfo.value)
def test_a_derived_handle_reports_its_session_id():
"""Handles derived from one session share its id, so only one of them
can be installed on a given target. Their payloads would be
indistinguishable on decode, so refusing is the right answer."""
base = SessionContext()
derived = base.with_python_udf_inlining(enabled=False)
assert derived.__datafusion_codec_id__ == base.__datafusion_codec_id__
target = SessionContext().with_logical_extension_codec(base)
with pytest.raises(ValueError, match="already installed"):
target.with_logical_extension_codec(derived)
def test_name_only_codec_round_trips_without_a_payload():
"""A codec may own functions that need no payload: the name is the
whole encoding. ``try_encode_udf`` writes nothing, and the decoder
rebuilds the function from the name with no registry entry.
DataFusion supports this directly -- an empty ``fun_definition``
sends the decoder to the registry first and the codec second. This
test pins that arm from the Python side, because it is the one path
where a payload is still offered to every installed codec: there are
no bytes, so there is no identity to dispatch on.
It is also the guard against a plausible "improvement". Wrapping
every chained encode in the identity envelope would make this
payload non-empty, which sets ``fun_definition`` and permanently
skips the registry lookup -- breaking both this codec and ordinary
by-name round trips, with nothing else in the suite noticing.
"""
codec = NameOnlyUdfCodec()
name = NameOnlyUdfCodec.function_name()
# FROM-less, so serialization never reaches try_encode_table_provider --
# this codec owns functions, not providers.
encoder = SessionContext().with_logical_extension_codec(codec)
encoder.register_udf(udf(NameOnlyFunction()))
blob = encoder.sql(f"SELECT {name}(1) AS x").logical_plan().to_bytes(encoder)
# The name is the entire encoding, so the codec contributed no bytes
# and the payload carries no identity envelope for it.
assert codec.encode_udf_calls() > 0
assert b"DFPYCHN" not in blob
# A fresh session that never registered the function: only the codec
# can supply it, and only from the name.
decoder = SessionContext().with_logical_extension_codec(codec)
restored = LogicalPlan.from_bytes(decoder, blob)
assert codec.decode_udf_calls() > 0
assert decoder.create_dataframe_from_logical_plan(restored).collect()
def test_default_only_session_writes_no_envelope():
"""A session with no extension codecs installed produces the same
bytes as a build without codec chaining: the terminal codec writes
unframed, so the envelope only appears once a codec is installed.
Keeps the wire break scoped to sessions that actually compose."""
ctx = SessionContext()
blob = ctx.sql("SELECT abs(-1) AS x").logical_plan().to_bytes(ctx)
assert b"DFPYCHN" not in blob
def test_udf_inlining_setting_survives_codec_install():
"""Installing an extension codec must not silently re-enable inline
Python UDF encoding on a session that opted out. Regression guard in
both directions: the encoder still emits the by-name form, and the
decoder still refuses an inline payload.
The codec installed here delegates UDF encoding to DataFusion's
default codec. A codec exported from another `SessionContext` would
not work as a probe: that export is itself a Python-aware codec with
inlining enabled, so the strict outer codec would delegate to it and
the inline payload would reappear.
"""
strict = SessionContext().with_python_udf_inlining(enabled=False)
extended = strict.with_logical_extension_codec(
MyLogicalExtensionCodec(provider_prefix="TOKENFFF")
)
e = _double_udf()(col("a"))
assert b"DFPYUDF" not in e.to_bytes(extended)
inline_blob = e.to_bytes(SessionContext())
assert b"DFPYUDF" in inline_blob
with pytest.raises(Exception, match="inlining is disabled"):
Expr.from_bytes(inline_blob, ctx=extended)