blob: d046f67a6c378dabafc18499fa63e2c02c30f55a [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 gc
import pyarrow as pa
import pytest
from datafusion import Expr, SessionConfig, SessionContext, col, udf
from datafusion_ffi_example import (
IsNullUDF,
MyCatalogProvider,
MyLogicalExtensionCodec,
MyPhysicalExtensionCodec,
MyPhysicalOptimizerRule,
MyTableProvider,
)
from datafusion_ffi_query_planner_example import MyPlannerConfig, MyQueryPlanner
def configured_context(max_rows: int):
config = SessionConfig().with_extension(MyPlannerConfig(max_rows=max_rows))
logical_codec = MyLogicalExtensionCodec()
physical_codec = MyPhysicalExtensionCodec()
ctx = SessionContext(config)
ctx = ctx.with_logical_extension_codec(logical_codec)
ctx = ctx.with_physical_extension_codec(physical_codec)
ctx.register_table("numbers", MyTableProvider(1, 6, 1))
ctx.register_udf(udf(IsNullUDF()))
return ctx, logical_codec, physical_codec
HOST_ONLY_UDF = "my_custom_is_null"
"""Scalar function registered on the host session and nowhere else."""
UNREGISTERED_UDF = "not_registered_anywhere"
def probe_context(
*,
logical_requires: str | None = None,
physical_requires: str | None = None,
max_rows: int = 3,
):
"""Three-library context whose codecs read the task context they are given.
``require_udf_on_decode`` makes each codec resolve a scalar function from
the ``TaskContext`` handed to its FFI decode callback, which is otherwise
unobservable: the example codecs restore objects from a token registry and
never look at the registry they are passed.
"""
config = SessionConfig().with_extension(MyPlannerConfig(max_rows=max_rows))
logical_codec = MyLogicalExtensionCodec(require_udf_on_decode=logical_requires)
physical_codec = MyPhysicalExtensionCodec(require_udf_on_decode=physical_requires)
ctx = SessionContext(config)
ctx = ctx.with_logical_extension_codec(logical_codec)
ctx = ctx.with_physical_extension_codec(physical_codec)
ctx.register_table("numbers", MyTableProvider(1, 6, 1))
ctx.register_udf(udf(IsNullUDF()))
ctx.set_query_planner(MyQueryPlanner())
return ctx, logical_codec, physical_codec
def test_logical_codec_resolves_a_host_registered_udf():
"""``try_decode_table_provider`` sees the host session's registry.
The codec takes its task context provider from the session it is installed
on, so a function the host registered is resolvable inside a decode
callback running in the other library.
"""
ctx, logical_codec, _physical_codec = probe_context(logical_requires=HOST_ONLY_UDF)
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1, 2]
assert logical_codec.table_provider_decode_calls() > 0
assert logical_codec.task_context_udf_resolutions() > 0
def test_physical_codec_resolves_a_host_registered_udf():
"""``try_decode`` sees the host session's registry, as above."""
ctx, _logical_codec, physical_codec = probe_context(physical_requires=HOST_ONLY_UDF)
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1, 2]
assert physical_codec.execution_plan_decode_calls() > 0
assert physical_codec.task_context_udf_resolutions() > 0
def test_codec_still_reports_a_name_registered_nowhere():
"""Negative control: resolution really is a lookup, not an unconditional pass."""
ctx, _logical_codec, _physical_codec = probe_context(
logical_requires=UNREGISTERED_UDF
)
with pytest.raises(
Exception, match=rf"could not resolve scalar function '{UNREGISTERED_UDF}'"
):
ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
def test_codec_sees_a_udf_registered_after_it_was_installed():
"""The provider is a live handle to the session, not a snapshot of it."""
config = SessionConfig().with_extension(MyPlannerConfig(max_rows=3))
logical_codec = MyLogicalExtensionCodec(require_udf_on_decode=HOST_ONLY_UDF)
ctx = SessionContext(config)
ctx = ctx.with_logical_extension_codec(logical_codec)
ctx = ctx.with_physical_extension_codec(MyPhysicalExtensionCodec())
ctx.register_table("numbers", MyTableProvider(1, 6, 1))
# Registered after the codec was installed and bound to this session.
ctx.register_udf(udf(IsNullUDF()))
ctx.set_query_planner(MyQueryPlanner())
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1, 2]
assert logical_codec.task_context_udf_resolutions() > 0
def test_codec_sees_a_udf_registered_after_the_planner():
"""Installing a planner does not detach the codec from the session.
The codec is bound to the session before the planner is installed and the
function is registered afterwards. Installing writes through
``state_ref()`` rather than deriving a new ``SessionContext``, so the
codec's task context provider still points at the one live session and
sees the later registration.
"""
config = SessionConfig().with_extension(MyPlannerConfig(max_rows=3))
logical_codec = MyLogicalExtensionCodec(require_udf_on_decode=HOST_ONLY_UDF)
ctx = SessionContext(config)
ctx = ctx.with_logical_extension_codec(logical_codec)
ctx = ctx.with_physical_extension_codec(MyPhysicalExtensionCodec())
ctx.register_table("numbers", MyTableProvider(1, 6, 1))
ctx.set_query_planner(MyQueryPlanner())
# Registered after the planner, on the same session the codec is bound to.
ctx.register_udf(udf(IsNullUDF()))
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1, 2]
assert logical_codec.table_provider_decode_calls() > 0
assert logical_codec.task_context_udf_resolutions() > 0
@pytest.mark.parametrize("codecs_first", [True, False])
def test_registered_providers_survive_a_planner_install(codecs_first: bool):
"""Neither write path may orphan a previously registered provider.
A foreign catalog provider is handed a codec carrying a *weak*
``FFI_TaskContextProvider`` pointing at the session it was registered on,
and upgrades it on every ``supports_filters_pushdown`` and every ``scan``.
That codec lives inside the registered ``FFI_CatalogProvider``, so nothing
on the Python side can reach it to rebind it. Deriving a replacement
``SessionContext`` would drop the allocation those handles point at, and
the next query would fail with ``TaskContextProvider went out of scope over
FFI boundary``. Installing in place keeps the one allocation alive.
Both parameters exercise that, because both write ``SessionState``:
``set_query_planner`` installs the planner, and installing a codec on a
session that already has one rebuilds that planner against the new codec.
The ``WHERE`` clause is load-bearing -- it forces filter pushdown, which
upgrades the weak handle during logical optimization. It is also why both
codecs have to be installed: without them the query fails at plan
serialization with ``LogicalExtensionCodec is not provided``, which would
mask a dangling handle rather than expose it.
"""
config = SessionConfig().with_extension(MyPlannerConfig(max_rows=10))
ctx = SessionContext(config)
ctx.register_catalog_provider("ffi_catalog", MyCatalogProvider())
def install_codecs(ctx):
ctx = ctx.with_logical_extension_codec(MyLogicalExtensionCodec())
return ctx.with_physical_extension_codec(MyPhysicalExtensionCodec())
if codecs_first:
ctx = install_codecs(ctx)
ctx.set_query_planner(MyQueryPlanner())
else:
ctx.set_query_planner(MyQueryPlanner())
ctx = install_codecs(ctx)
gc.collect()
batches = ctx.sql(
"SELECT units FROM ffi_catalog.my_schema.my_table WHERE units > 5"
).collect()
assert sorted(v for b in batches for v in b.column(0).to_pylist()) == [
7,
10,
20,
30,
]
def codec_context(max_rows: int = 3):
"""Context with both example codecs installed and a table to scan.
Unlike :func:`probe_context` the codecs ask for no function, so the only
thing they record is the session id of the task context they are handed.
No planner yet -- the caller installs one.
"""
config = SessionConfig().with_extension(MyPlannerConfig(max_rows=max_rows))
logical_codec = MyLogicalExtensionCodec()
physical_codec = MyPhysicalExtensionCodec()
ctx = SessionContext(config)
ctx = ctx.with_logical_extension_codec(logical_codec)
ctx = ctx.with_physical_extension_codec(physical_codec)
ctx.register_table("numbers", MyTableProvider(1, 6, 1))
return ctx, logical_codec, physical_codec
def test_installing_a_planner_keeps_the_session_id():
"""A session and its decode callbacks must agree on the session id.
Installing a planner rebuilds ``SessionState`` through
``SessionStateBuilder``, which mints a fresh id unless handed one, while
``SessionContext`` caches its id in a field of its own. Dropping the id
there leaves the session reporting one id from ``session_id()`` and a
different one from every ``TaskContext`` it gives a foreign codec.
Asserting on ``session_id()`` alone cannot catch that: it reads the cached
copy, which stays correct either way. The codec-side id is the only
observable that moves, which is what makes this worth a test rather than a
one-line equality check.
"""
ctx, logical_codec, physical_codec = codec_context()
session_id = ctx.session_id()
ctx.set_query_planner(MyQueryPlanner())
assert ctx.session_id() == session_id
ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert logical_codec.last_task_context_session_id() == session_id
assert physical_codec.last_task_context_session_id() == session_id
def test_adding_a_physical_optimizer_rule_keeps_the_session_id():
"""The same guarantee for the other in-place ``SessionState`` rewrite.
``add_physical_optimizer_rule`` also rebuilds ``SessionState`` and writes
it back, so a regenerated id would desync a context from itself.
"""
ctx, logical_codec, physical_codec = codec_context()
ctx.set_query_planner(MyQueryPlanner())
session_id = ctx.session_id()
ctx.add_physical_optimizer_rule(MyPhysicalOptimizerRule())
assert ctx.session_id() == session_id
ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert logical_codec.last_task_context_session_id() == session_id
assert physical_codec.last_task_context_session_id() == session_id
def test_replacing_a_planner_keeps_the_session_id():
"""Installing repeatedly must not drift the id.
Each install rebuilds ``SessionState``, so an id carried over only on the
first write would still be lost by the second.
"""
ctx, logical_codec, _physical_codec = codec_context()
session_id = ctx.session_id()
ctx.set_query_planner(MyQueryPlanner())
ctx.set_query_planner(MyQueryPlanner())
assert ctx.session_id() == session_id
ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert logical_codec.last_task_context_session_id() == session_id
@pytest.mark.parametrize("raw_capsule", [False, True])
def test_three_library_query_planner(raw_capsule: bool):
"""Host, provider, and planner exchange a real non-empty plan over FFI."""
ctx, logical_codec, physical_codec = configured_context(max_rows=3)
planner = MyQueryPlanner()
exported_planner = (
planner.__datafusion_query_planner__(ctx) if raw_capsule else planner
)
ctx.set_query_planner(exported_planner)
batches = ctx.sql(
'SELECT "A", my_custom_is_null("A") AS is_null FROM numbers ORDER BY "A"'
).collect()
assert batches[0].column(0).to_pylist() == [0, 1, 2]
assert batches[0].column(1).to_pylist() == [False, False, False]
assert planner.last_max_rows() == 3
ctx.sql("SET ffi_query_planner.max_rows = 2").collect()
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1]
assert planner.last_max_rows() == 2
assert planner.plan_calls() >= 2
assert planner.foreign_session_observed()
assert planner.foreign_provider_observed()
assert planner.foreign_plan_observed()
assert logical_codec.table_provider_encode_calls() > 0
assert logical_codec.table_provider_decode_calls() > 0
assert physical_codec.execution_plan_encode_calls() > 0
assert physical_codec.execution_plan_decode_calls() > 0
def test_spawning_plan_across_three_libraries():
"""A plan that spawns Tokio tasks survives the full three-library round trip.
``target_partitions`` above one puts a ``RepartitionExec`` under the
aggregate, and that operator spawns tasks while it runs. This exercises the
codecs on a multi-node plan rather than the bare scan the other tests use.
"""
config = SessionConfig().with_extension(MyPlannerConfig(max_rows=100))
config = config.with_target_partitions(4)
logical_codec = MyLogicalExtensionCodec()
physical_codec = MyPhysicalExtensionCodec()
ctx = SessionContext(config)
ctx = ctx.with_logical_extension_codec(logical_codec)
ctx = ctx.with_physical_extension_codec(physical_codec)
ctx.register_table("numbers", MyTableProvider(1, 6, 3))
planner = MyQueryPlanner()
ctx.set_query_planner(planner)
batches = ctx.sql(
'SELECT "A" % 2 AS parity, count(*) AS n FROM numbers GROUP BY 1 ORDER BY 1'
).collect()
counts = {
row[0]: row[1]
for batch in batches
for row in zip(
batch.column(0).to_pylist(), batch.column(1).to_pylist(), strict=True
)
}
assert sum(counts.values()) == 6 + 7 + 8
assert planner.plan_calls() > 0
assert planner.foreign_provider_observed()
def test_planner_layers_on_the_session_planner():
"""A planner can wrap the one already installed and delegate to it.
The capsule has to be captured before this planner is installed, because
``__datafusion_query_planner__`` exports whichever planner is installed when
it is called. Capturing it afterwards would hand the planner a handle to
itself, and planning would recurse.
"""
ctx, logical_codec, physical_codec = configured_context(max_rows=3)
fallback = ctx.__datafusion_query_planner__()
planner = MyQueryPlanner(fallback=fallback)
# The capsule's FFI codecs hold weak handles to this session. Installing in
# place keeps that session alive, so the capsule stays usable; deriving a
# replacement here would fail with "TaskContextProvider went out of scope
# over FFI boundary".
ctx.set_query_planner(planner)
gc.collect()
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1, 2]
assert planner.plan_calls() > 0
assert planner.used_fallback()
assert logical_codec.table_provider_decode_calls() > 0
assert physical_codec.execution_plan_decode_calls() > 0
def test_a_planner_can_fall_back_to_another_planner_library():
"""A fallback may be another foreign planner, not only a session.
The fallback is imported when this planner is installed rather than when
it is constructed, so its own getter receives the session. Importing it at
construction time would mean calling that getter with no session, which
only a ``SessionContext`` or a raw capsule tolerates -- and layering on
another planner is the case a distributed engine actually needs.
"""
ctx, logical_codec, physical_codec = configured_context(max_rows=3)
inner = MyQueryPlanner()
outer = MyQueryPlanner(fallback=inner)
ctx.set_query_planner(outer)
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1, 2]
assert outer.plan_calls() > 0
assert outer.used_fallback()
# The delegation reached the inner planner rather than stopping at the
# default physical planner.
assert inner.plan_calls() > 0
assert logical_codec.table_provider_decode_calls() > 0
assert physical_codec.execution_plan_decode_calls() > 0
def test_a_session_fallback_delegates_to_its_installed_planner():
"""Passing a SessionContext delegates to whatever planner it holds."""
ctx, _logical_codec, _physical_codec = configured_context(max_rows=3)
first = MyQueryPlanner()
ctx.set_query_planner(first)
second = MyQueryPlanner(fallback=ctx)
ctx.set_query_planner(second)
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1, 2]
assert second.used_fallback()
assert first.plan_calls() > 0
def test_observations_accumulate_across_queries():
"""A later plain query must not retract what an earlier query observed.
The ``*_observed`` accessors answer "was this ever seen". They are written
with ``fetch_or`` rather than ``store`` so a query that touches no foreign
object cannot clear a flag an earlier one set. Written with ``store``,
``SELECT 1`` here clears ``foreign_provider_observed``, and every other
test asserting these flags after more than one query is a coincidence away
from failing.
"""
ctx, _logical_codec, _physical_codec = configured_context(max_rows=3)
planner = MyQueryPlanner()
ctx.set_query_planner(planner)
ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert planner.foreign_session_observed()
assert planner.foreign_provider_observed()
assert planner.foreign_plan_observed()
# Touches no table, so this plan has no foreign provider of its own.
ctx.sql("SELECT 1").collect()
assert planner.foreign_session_observed()
assert planner.foreign_provider_observed()
assert planner.foreign_plan_observed()
# `last_max_rows` is deliberately not cumulative; it reports the last plan.
assert planner.last_max_rows() == 3
assert planner.plan_calls() >= 2
def test_second_planner_replaces_the_first():
"""A session holds exactly one planner, so installing another replaces it."""
ctx, _logical_codec, _physical_codec = configured_context(max_rows=2)
first = MyQueryPlanner()
second = MyQueryPlanner()
ctx.set_query_planner(first)
ctx.set_query_planner(second)
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1]
assert second.plan_calls() > 0
assert first.plan_calls() == 0
def test_the_planner_reaches_every_handle_on_the_session():
"""The planner is session state, so all handles on that session use it.
``set_query_planner`` writes through ``state_ref()``. A context returned by
an earlier ``with_*`` call shares that session, so it plans through the new
planner too -- there is one session and one planner, not a family of
diverging copies.
"""
ctx, _logical_codec, _physical_codec = configured_context(max_rows=2)
# Shares the session with `ctx`; predates the planner.
sibling = ctx.with_python_udf_inlining(enabled=False)
planner = MyQueryPlanner()
ctx.set_query_planner(planner)
batches = sibling.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1]
assert planner.plan_calls() > 0
def test_installed_codecs_outlive_python_exporters():
ctx, logical_codec, physical_codec = configured_context(max_rows=2)
del logical_codec, physical_codec
gc.collect()
ctx.set_query_planner(MyQueryPlanner())
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1]
def test_provider_codecs_can_be_installed_after_planner():
config = SessionConfig().with_extension(MyPlannerConfig(max_rows=2))
planner = MyQueryPlanner()
logical_codec = MyLogicalExtensionCodec()
physical_codec = MyPhysicalExtensionCodec()
ctx = SessionContext(config)
ctx.set_query_planner(planner)
ctx = ctx.with_logical_extension_codec(logical_codec)
ctx = ctx.with_physical_extension_codec(physical_codec)
ctx.register_table("numbers", MyTableProvider(1, 4, 1))
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1]
assert planner.last_max_rows() == 2
assert logical_codec.table_provider_decode_calls() > 0
assert physical_codec.execution_plan_decode_calls() > 0
def test_a_discarded_derived_context_still_rebinds_the_planner():
"""Installing a codec rebinds the planner even if the handle is thrown away.
`with_logical_extension_codec` returns a context sharing this session, and
rebuilding the installed planner against the new codec happens on that
shared session rather than on the returned handle. So the rebind outlives
the handle, and the codec below takes effect on `ctx` despite `ctx`'s own
codec field never changing.
That is spooky enough to be worth pinning as a decision. It is also forced:
`FFI_QueryPlanner` holds its codecs by value, so a planner cannot read the
session's current codecs at plan time and the rebuild has to be eager.
A fresh codec instance is what makes it observable -- it carries its own
counters, and the planner encodes the outbound logical plan with whichever
codec it is holding.
"""
ctx, _logical_codec, _physical_codec = configured_context(max_rows=3)
ctx.set_query_planner(MyQueryPlanner())
later = MyLogicalExtensionCodec()
# Deliberately discarded. The rebind still lands on the shared session.
ctx.with_logical_extension_codec(later)
gc.collect()
batches = ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert batches[0].column(0).to_pylist() == [0, 1, 2]
assert later.table_provider_encode_calls() > 0
def test_the_planner_and_the_handle_can_hold_different_codecs():
"""One session, two codecs in effect, depending on the path taken.
The planner is session state and carries whichever codecs installed it
last -- here, ones that arrived through a handle that was discarded.
Everything else on a context uses that context's own codec field, which
the discarded handle never touched. So `Expr.to_bytes(ctx)` and
`ctx.sql(...)` encode with different codecs on the same `ctx`.
Chaining ``ctx = ctx.with_...(...)`` keeps the two in step; this pins what
happens when they are allowed to diverge.
Inlining has to be off for the assertion to say anything: with it on, a
Python UDF is encoded inline by ``PythonLogicalCodec`` and never reaches
the installed codec's ``try_encode_udf``.
"""
config = SessionConfig().with_extension(MyPlannerConfig(max_rows=3))
handle_codec = MyLogicalExtensionCodec()
ctx = SessionContext(config).with_python_udf_inlining(enabled=False)
ctx = ctx.with_logical_extension_codec(handle_codec)
ctx = ctx.with_physical_extension_codec(MyPhysicalExtensionCodec())
ctx.register_table("numbers", MyTableProvider(1, 6, 1))
ctx.set_query_planner(MyQueryPlanner())
planner_codec = MyLogicalExtensionCodec()
# Discarded, but the planner keeps its codec.
ctx.with_logical_extension_codec(planner_codec)
gc.collect()
identity = udf(
lambda arr: arr,
[pa.int64()],
pa.int64(),
volatility="immutable",
name="identity_i64",
)
ctx.register_udf(identity)
Expr.to_bytes(identity(col("A")), ctx)
# Serializing through `ctx` uses `ctx`'s own codec field.
assert handle_codec.encode_udf_calls() > 0
assert planner_codec.encode_udf_calls() == 0
ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
# Planning through the same `ctx` uses the codec the planner was rebound to.
assert planner_codec.table_provider_encode_calls() > 0
assert handle_codec.table_provider_encode_calls() == 0
def test_an_unchanged_inlining_setting_leaves_the_planner_alone():
"""A no-op toggle must not rebind the session's planner.
``with_python_udf_inlining`` rebuilds the handle's codecs and rebinds the
session's planner to them. Asking for the setting a context already has
changes nothing, so it must not pay that side effect.
Observable only once the planner is holding some *other* handle's codec:
without the guard, a defensive no-op toggle on `ctx` drags the planner back
onto `ctx`'s codec and silently undoes the install below. The rebuilt
codecs otherwise wrap the same inner codec, so nothing else distinguishes
the two paths.
"""
ctx, handle_codec, _physical_codec = codec_context()
ctx.set_query_planner(MyQueryPlanner())
planner_codec = MyLogicalExtensionCodec()
ctx.with_logical_extension_codec(planner_codec) # discarded; planner keeps it
gc.collect()
# The default is on, so this asks for what `ctx` already has.
ctx.with_python_udf_inlining(enabled=True)
gc.collect()
ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert planner_codec.table_provider_encode_calls() > 0
assert handle_codec.table_provider_encode_calls() == 0
def test_reinstalling_a_planner_rebinds_the_session_to_that_handles_codecs():
"""A planner is built against the codecs of the handle it is installed from.
The sequel to the test above, and the trap it sets up. Once a discarded
derived handle has rebound the session's planner to its codec, installing
the same planner again from the *original* handle rebuilds it against that
handle's codec instead -- which never changed. The session's planner tracks
whichever handle wrote it last, not the newest codec installed anywhere.
So "re-install the planner after installing a codec" only repairs anything
when it is done from the handle holding the new codec.
"""
ctx, original_logical, _physical_codec = codec_context()
planner = MyQueryPlanner()
ctx.set_query_planner(planner)
later = MyLogicalExtensionCodec()
# Deliberately discarded, exactly as in the test above.
ctx.with_logical_extension_codec(later)
gc.collect()
ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert later.table_provider_encode_calls() > 0
assert original_logical.table_provider_encode_calls() == 0
# `ctx`'s own codec field never changed, so this rebuilds the planner
# against `original_logical` and drops `later` from the session's planner.
ctx.set_query_planner(planner)
encodes_by_later = later.table_provider_encode_calls()
ctx.sql('SELECT "A" FROM numbers ORDER BY "A"').collect()
assert original_logical.table_provider_encode_calls() > 0
assert later.table_provider_encode_calls() == encodes_by_later
def test_query_planner_requires_provider_codec():
config = SessionConfig().with_extension(MyPlannerConfig(max_rows=2))
ctx = SessionContext(config)
ctx.register_table("numbers", MyTableProvider(1, 3, 1))
ctx.set_query_planner(MyQueryPlanner())
with pytest.raises(Exception, match=r"LogicalExtensionCodec|TableProvider"):
ctx.sql('SELECT "A" FROM numbers').collect()
@pytest.mark.parametrize("max_rows", ["0", "oops"])
def test_query_planner_rejects_invalid_config(max_rows: str):
ctx, _logical_codec, _physical_codec = configured_context(max_rows=2)
ctx.set_query_planner(MyQueryPlanner())
with pytest.raises(Exception, match=r"max_rows|Invalid value"):
ctx.sql(f"SET ffi_query_planner.max_rows = '{max_rows}'").collect()