blob: 580ccea68e920a41fe868b4307ed6342842d6898 [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.
import ctypes
import datetime as dt
import gc
import gzip
import pathlib
import shutil
from dataclasses import fields
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.dataset as ds
import pytest
from datafusion import (
Accumulator,
CsvReadOptions,
DataFrame,
RuntimeEnvBuilder,
SessionConfig,
SessionContext,
SessionExtensionComponents,
SQLOptions,
Table,
column,
literal,
udaf,
udf,
udwf,
)
from datafusion.user_defined import WindowEvaluator
def test_create_context_no_args():
SessionContext()
def test_create_context_session_config_only():
SessionContext(config=SessionConfig())
def test_create_context_runtime_config_only():
SessionContext(runtime=RuntimeEnvBuilder())
@pytest.mark.parametrize("path_to_str", [True, False])
def test_runtime_configs(tmp_path, path_to_str):
path1 = tmp_path / "dir1"
path2 = tmp_path / "dir2"
path1 = str(path1) if path_to_str else path1
path2 = str(path2) if path_to_str else path2
runtime = RuntimeEnvBuilder().with_disk_manager_specified(path1, path2)
config = SessionConfig().with_default_catalog_and_schema("foo", "bar")
ctx = SessionContext(config, runtime)
assert ctx is not None
db = ctx.catalog("foo").schema("bar")
assert db is not None
@pytest.mark.parametrize("path_to_str", [True, False])
def test_temporary_files(tmp_path, path_to_str):
path = str(tmp_path) if path_to_str else tmp_path
runtime = RuntimeEnvBuilder().with_temp_file_path(path)
config = SessionConfig().with_default_catalog_and_schema("foo", "bar")
ctx = SessionContext(config, runtime)
assert ctx is not None
db = ctx.catalog("foo").schema("bar")
assert db is not None
def test_create_context_with_all_valid_args():
runtime = RuntimeEnvBuilder().with_disk_manager_os().with_fair_spill_pool(10000000)
config = (
SessionConfig()
.with_create_default_catalog_and_schema(enabled=True)
.with_default_catalog_and_schema("foo", "bar")
.with_target_partitions(1)
.with_information_schema(enabled=True)
.with_repartition_joins(enabled=False)
.with_repartition_aggregations(enabled=False)
.with_repartition_windows(enabled=False)
.with_parquet_pruning(enabled=False)
)
ctx = SessionContext(config, runtime)
# verify that at least some of the arguments worked
ctx.catalog("foo").schema("bar")
with pytest.raises(KeyError):
ctx.catalog("datafusion")
def test_session_config_set_rejects_an_unknown_namespace():
"""A bad config key raises rather than aborting through a Rust panic.
`datafusion.runtime.*` appears in `information_schema.df_settings` but has
no `ConfigOptions` namespace, so it is the key a naive "read the settings
back and replay them on the worker" loop hits first.
"""
# `ValueError`, not a bare `Exception`: a panic would arrive as
# `PanicException`, which derives from `BaseException` and so would not be
# caught here at all. Both this and the constructor cases below rely on it.
with pytest.raises(ValueError, match="runtime"):
SessionConfig().set("datafusion.runtime.memory_limit", "unlimited")
def test_session_config_set_rejects_an_unparsable_value():
"""A well-known key with a value of the wrong type raises too."""
with pytest.raises(ValueError, match="batch_size"):
SessionConfig().set("datafusion.execution.batch_size", "not_an_int")
def test_session_config_constructor_applies_options():
"""A dict passed to the constructor reaches the session's options."""
config = SessionConfig(
{
"datafusion.execution.batch_size": "1024",
"datafusion.execution.target_partitions": "3",
}
)
ctx = SessionContext(config.with_information_schema(True))
settings = ctx.sql(
"select name, value from information_schema.df_settings"
" where name in ('datafusion.execution.batch_size',"
" 'datafusion.execution.target_partitions')"
).to_pydict()
assert dict(zip(settings["name"], settings["value"], strict=True)) == {
"datafusion.execution.batch_size": "1024",
"datafusion.execution.target_partitions": "3",
}
def test_session_config_constructor_rejects_an_unknown_namespace():
"""A bad key in the constructor's dict raises rather than panicking.
The same defect as `SessionConfig.set` had, reached through the argument
that a replayed `information_schema.df_settings` dictionary arrives in.
"""
with pytest.raises(ValueError, match="runtime"):
SessionConfig({"datafusion.runtime.memory_limit": "unlimited"})
def test_session_config_constructor_rejects_an_unparsable_value():
"""A well-known key with a value of the wrong type raises too."""
with pytest.raises(ValueError, match="batch_size"):
SessionConfig({"datafusion.execution.batch_size": "not_an_int"})
def test_register_record_batches(ctx):
# create a RecordBatch and register it as memtable
batch = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6])],
names=["a", "b"],
)
ctx.register_record_batches("t", [[batch]])
assert ctx.catalog().schema().names() == {"t"}
result = ctx.sql("SELECT a+b, a-b FROM t").collect()
assert result[0].column(0) == pa.array([5, 7, 9])
assert result[0].column(1) == pa.array([-3, -3, -3])
def test_register_record_batches_empty(ctx):
# A partition list with no record batches carries no schema, so this used to
# panic on unchecked `[0][0]` indexing. It should now raise a clear error.
with pytest.raises(ValueError, match="no record batches"):
ctx.register_record_batches("t", [[]])
# An empty outer partition list carries no schema either, and raises the same error.
with pytest.raises(ValueError, match="no record batches"):
ctx.register_record_batches("t", [])
# The schema is still recovered from a later non-empty partition.
batch = pa.RecordBatch.from_arrays([pa.array([1, 2, 3])], names=["a"])
ctx.register_record_batches("t2", [[], [batch]])
assert ctx.sql("SELECT a FROM t2").collect()[0].column(0) == pa.array([1, 2, 3])
def test_create_dataframe_registers_unique_table_name(ctx):
# create a RecordBatch and register it as memtable
batch = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6])],
names=["a", "b"],
)
df = ctx.create_dataframe([[batch]])
tables = list(ctx.catalog().schema().names())
assert df
assert len(tables) == 1
assert len(tables[0]) == 33
assert tables[0].startswith("c")
# ensure that the rest of the table name contains
# only hexadecimal numbers
for c in tables[0][1:]:
assert c in "0123456789abcdef"
def test_create_dataframe_registers_with_defined_table_name(ctx):
# create a RecordBatch and register it as memtable
batch = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6])],
names=["a", "b"],
)
df = ctx.create_dataframe([[batch]], name="tbl")
tables = list(ctx.catalog().schema().names())
assert df
assert len(tables) == 1
assert tables[0] == "tbl"
def test_from_arrow_table(ctx):
# create a PyArrow table
data = {"a": [1, 2, 3], "b": [4, 5, 6]}
table = pa.Table.from_pydict(data)
# convert to DataFrame
df = ctx.from_arrow(table)
tables = list(ctx.catalog().schema().names())
assert df
assert len(tables) == 1
assert isinstance(df, DataFrame)
assert set(df.schema().names) == {"a", "b"}
assert df.collect()[0].num_rows == 3
def record_batch_generator(num_batches: int):
schema = pa.schema([("a", pa.int64()), ("b", pa.int64())])
for _i in range(num_batches):
yield pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6])], schema=schema
)
@pytest.mark.parametrize(
"source",
[
# __arrow_c_array__ sources
pa.array([{"a": 1, "b": 4}, {"a": 2, "b": 5}, {"a": 3, "b": 6}]),
# __arrow_c_stream__ sources
pa.RecordBatch.from_pydict({"a": [1, 2, 3], "b": [4, 5, 6]}),
pa.RecordBatchReader.from_batches(
pa.schema([("a", pa.int64()), ("b", pa.int64())]), record_batch_generator(1)
),
pa.Table.from_pydict({"a": [1, 2, 3], "b": [4, 5, 6]}),
],
)
def test_from_arrow_sources(ctx, source) -> None:
df = ctx.from_arrow(source)
assert df
assert isinstance(df, DataFrame)
assert df.schema().names == ["a", "b"]
assert df.count() == 3
def test_from_arrow_table_with_name(ctx):
# create a PyArrow table
data = {"a": [1, 2, 3], "b": [4, 5, 6]}
table = pa.Table.from_pydict(data)
# convert to DataFrame with optional name
df = ctx.from_arrow(table, name="tbl")
tables = list(ctx.catalog().schema().names())
assert df
assert tables[0] == "tbl"
def test_from_arrow_table_empty(ctx):
data = {"a": [], "b": []}
schema = pa.schema([("a", pa.int32()), ("b", pa.string())])
table = pa.Table.from_pydict(data, schema=schema)
# convert to DataFrame
df = ctx.from_arrow(table)
tables = list(ctx.catalog().schema().names())
assert df
assert len(tables) == 1
assert isinstance(df, DataFrame)
assert set(df.schema().names) == {"a", "b"}
assert len(df.collect()) == 0
def test_from_arrow_table_empty_no_schema(ctx):
data = {"a": [], "b": []}
table = pa.Table.from_pydict(data)
# convert to DataFrame
df = ctx.from_arrow(table)
tables = list(ctx.catalog().schema().names())
assert df
assert len(tables) == 1
assert isinstance(df, DataFrame)
assert set(df.schema().names) == {"a", "b"}
assert len(df.collect()) == 0
def test_from_pylist(ctx):
# create a dataframe from Python list
data = [
{"a": 1, "b": 4},
{"a": 2, "b": 5},
{"a": 3, "b": 6},
]
df = ctx.from_pylist(data)
tables = list(ctx.catalog().schema().names())
assert df
assert len(tables) == 1
assert isinstance(df, DataFrame)
assert set(df.schema().names) == {"a", "b"}
assert df.collect()[0].num_rows == 3
def test_from_pydict(ctx):
# create a dataframe from Python dictionary
data = {"a": [1, 2, 3], "b": [4, 5, 6]}
df = ctx.from_pydict(data)
tables = list(ctx.catalog().schema().names())
assert df
assert len(tables) == 1
assert isinstance(df, DataFrame)
assert set(df.schema().names) == {"a", "b"}
assert df.collect()[0].num_rows == 3
def test_from_pandas(ctx):
# create a dataframe from pandas dataframe
pd = pytest.importorskip("pandas")
data = {"a": [1, 2, 3], "b": [4, 5, 6]}
pandas_df = pd.DataFrame(data)
df = ctx.from_pandas(pandas_df)
tables = list(ctx.catalog().schema().names())
assert df
assert len(tables) == 1
assert isinstance(df, DataFrame)
assert set(df.schema().names) == {"a", "b"}
assert df.collect()[0].num_rows == 3
def test_from_polars(ctx):
# create a dataframe from Polars dataframe
pd = pytest.importorskip("polars")
data = {"a": [1, 2, 3], "b": [4, 5, 6]}
polars_df = pd.DataFrame(data)
df = ctx.from_polars(polars_df)
tables = list(ctx.catalog().schema().names())
assert df
assert len(tables) == 1
assert isinstance(df, DataFrame)
assert set(df.schema().names) == {"a", "b"}
assert df.collect()[0].num_rows == 3
def test_register_table(ctx, database):
default = ctx.catalog()
public = default.schema("public")
assert public.names() == {"csv", "csv1", "csv2"}
table = public.table("csv")
ctx.register_table("csv3", table)
assert public.names() == {"csv", "csv1", "csv2", "csv3"}
def test_read_table_from_catalog(ctx, database):
default = ctx.catalog()
public = default.schema("public")
assert public.names() == {"csv", "csv1", "csv2"}
table = public.table("csv")
table_df = ctx.read_table(table)
table_df.show()
def test_read_table_from_df(ctx):
df = ctx.from_pydict({"a": [1, 2]})
result = ctx.read_table(df).collect()
assert [b.to_pydict() for b in result] == [{"a": [1, 2]}]
def test_read_table_from_dataset(ctx):
batch = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6])],
names=["a", "b"],
)
dataset = ds.dataset([batch])
result = ctx.read_table(dataset).collect()
assert result[0].column(0) == pa.array([1, 2, 3])
assert result[0].column(1) == pa.array([4, 5, 6])
def test_deregister_table(ctx, database):
default = ctx.catalog()
public = default.schema("public")
assert public.names() == {"csv", "csv1", "csv2"}
ctx.deregister_table("csv")
assert public.names() == {"csv1", "csv2"}
def test_deregister_udf():
ctx = SessionContext()
is_null = udf(
lambda x: x.is_null(),
[pa.float64()],
pa.bool_(),
volatility="immutable",
name="my_is_null",
)
ctx.register_udf(is_null)
# Verify it works
df = ctx.from_pydict({"a": [1.0, None]})
ctx.register_table("t", df.into_view())
result = ctx.sql("SELECT my_is_null(a) FROM t").collect()
assert result[0].column(0) == pa.array([False, True])
# Deregister and verify it's gone
ctx.deregister_udf("my_is_null")
with pytest.raises(ValueError):
ctx.sql("SELECT my_is_null(a) FROM t").collect()
def test_deregister_udaf():
import pyarrow.compute as pc
ctx = SessionContext()
from datafusion import Accumulator, udaf
class MySum(Accumulator):
def __init__(self):
self._sum = 0.0
def update(self, values: pa.Array) -> None:
self._sum += pc.sum(values).as_py()
def merge(self, states: list[pa.Array]) -> None:
self._sum += pc.sum(states[0]).as_py()
def state(self) -> list:
return [self._sum]
def evaluate(self) -> pa.Scalar:
return self._sum
my_sum = udaf(
MySum,
[pa.float64()],
pa.float64(),
[pa.float64()],
volatility="immutable",
name="my_sum",
)
ctx.register_udaf(my_sum)
df = ctx.from_pydict({"a": [1.0, 2.0, 3.0]})
ctx.register_table("t", df.into_view())
result = ctx.sql("SELECT my_sum(a) FROM t").collect()
assert result[0].column(0) == pa.array([6.0])
ctx.deregister_udaf("my_sum")
with pytest.raises(ValueError):
ctx.sql("SELECT my_sum(a) FROM t").collect()
def test_deregister_udwf():
ctx = SessionContext()
from datafusion import udwf
from datafusion.user_defined import WindowEvaluator
class MyRowNumber(WindowEvaluator):
def __init__(self):
self._row = 0
def evaluate_all(self, values, num_rows):
return pa.array(list(range(1, num_rows + 1)), type=pa.uint64())
my_row_number = udwf(
MyRowNumber,
[pa.float64()],
pa.uint64(),
volatility="immutable",
name="my_row_number",
)
ctx.register_udwf(my_row_number)
df = ctx.from_pydict({"a": [1.0, 2.0, 3.0]})
ctx.register_table("t", df.into_view())
result = ctx.sql("SELECT my_row_number(a) OVER () FROM t").collect()
assert result[0].column(0) == pa.array([1, 2, 3], type=pa.uint64())
ctx.deregister_udwf("my_row_number")
with pytest.raises(ValueError):
ctx.sql("SELECT my_row_number(a) OVER () FROM t").collect()
def test_deregister_udtf():
import pyarrow.dataset as ds
ctx = SessionContext()
from datafusion import Table, udtf
class MyTable:
def __call__(self):
batch = pa.RecordBatch.from_pydict({"x": [1, 2, 3]})
return Table(ds.dataset([batch]))
my_table = udtf(MyTable(), "my_table")
ctx.register_udtf(my_table)
result = ctx.sql("SELECT * FROM my_table()").collect()
assert result[0].column(0) == pa.array([1, 2, 3])
ctx.deregister_udtf("my_table")
with pytest.raises(ValueError):
ctx.sql("SELECT * FROM my_table()").collect()
def test_register_table_from_dataframe(ctx):
df = ctx.from_pydict({"a": [1, 2]})
ctx.register_table("df_tbl", df)
result = ctx.sql("SELECT * FROM df_tbl").collect()
assert [b.to_pydict() for b in result] == [{"a": [1, 2]}]
@pytest.mark.parametrize("temporary", [True, False])
def test_register_table_from_dataframe_into_view(ctx, temporary):
df = ctx.from_pydict({"a": [1, 2]})
table = df.into_view(temporary=temporary)
assert isinstance(table, Table)
if temporary:
assert table.kind == "temporary"
else:
assert table.kind == "view"
ctx.register_table("view_tbl", table)
result = ctx.sql("SELECT * FROM view_tbl").collect()
assert [b.to_pydict() for b in result] == [{"a": [1, 2]}]
def test_table_from_dataframe(ctx):
df = ctx.from_pydict({"a": [1, 2]})
table = Table(df)
assert isinstance(table, Table)
ctx.register_table("from_dataframe_tbl", table)
result = ctx.sql("SELECT * FROM from_dataframe_tbl").collect()
assert [b.to_pydict() for b in result] == [{"a": [1, 2]}]
def test_table_from_dataframe_internal(ctx):
df = ctx.from_pydict({"a": [1, 2]})
table = Table(df.df)
assert isinstance(table, Table)
ctx.register_table("from_internal_dataframe_tbl", table)
result = ctx.sql("SELECT * FROM from_internal_dataframe_tbl").collect()
assert [b.to_pydict() for b in result] == [{"a": [1, 2]}]
def test_register_dataset(ctx):
# create a RecordBatch and register it as a pyarrow.dataset.Dataset
batch = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6])],
names=["a", "b"],
)
dataset = ds.dataset([batch])
ctx.register_dataset("t", dataset)
assert ctx.catalog().schema().names() == {"t"}
result = ctx.sql("SELECT a+b, a-b FROM t").collect()
assert result[0].column(0) == pa.array([5, 7, 9])
assert result[0].column(1) == pa.array([-3, -3, -3])
def test_dataset_filter(ctx, capfd):
# create a RecordBatch and register it as a pyarrow.dataset.Dataset
batch = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6])],
names=["a", "b"],
)
dataset = ds.dataset([batch])
ctx.register_dataset("t", dataset)
assert ctx.catalog().schema().names() == {"t"}
df = ctx.sql("SELECT a+b, a-b FROM t WHERE a BETWEEN 2 and 3 AND b > 5")
# Make sure the filter was pushed down in Physical Plan
df.explain()
captured = capfd.readouterr()
assert "filter_expr=(((a >= 2) and (a <= 3)) and (b > 5))" in captured.out
result = df.collect()
assert result[0].column(0) == pa.array([9])
assert result[0].column(1) == pa.array([-3])
def test_dataset_count(ctx):
# `datafusion-python` issue: https://github.com/apache/datafusion-python/issues/800
batch = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6])],
names=["a", "b"],
)
dataset = ds.dataset([batch])
ctx.register_dataset("t", dataset)
# Testing the dataframe API
df = ctx.table("t")
assert df.count() == 3
# Testing the SQL API
count = ctx.sql("SELECT COUNT(*) FROM t")
count = count.collect()
assert count[0].column(0) == pa.array([3])
def test_pyarrow_predicate_pushdown_is_null(ctx, capfd):
"""Ensure that pyarrow filter gets pushed down for `IsNull`"""
# create a RecordBatch and register it as a pyarrow.dataset.Dataset
batch = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6]), pa.array([7, None, 9])],
names=["a", "b", "c"],
)
dataset = ds.dataset([batch])
ctx.register_dataset("t", dataset)
# Make sure the filter was pushed down in Physical Plan
df = ctx.sql("SELECT a FROM t WHERE c is NULL")
df.explain()
captured = capfd.readouterr()
assert "filter_expr=is_null(c, {nan_is_null=false})" in captured.out
result = df.collect()
assert result[0].column(0) == pa.array([2])
def test_pyarrow_predicate_pushdown_timestamp(ctx, tmpdir, capfd):
"""Ensure that pyarrow filter gets pushed down for timestamp"""
# Ref: https://github.com/apache/datafusion-python/issues/703
# create pyarrow dataset with no actual files
col_type = pa.timestamp("ns", "+00:00")
nyd_2000 = pa.scalar(dt.datetime(2000, 1, 1, tzinfo=dt.timezone.utc), col_type)
pa_dataset_fs = pa.fs.SubTreeFileSystem(str(tmpdir), pa.fs.LocalFileSystem())
pa_dataset_format = pa.dataset.ParquetFileFormat()
pa_dataset_partition = pa.dataset.field("a") <= nyd_2000
fragments = [
# NOTE: we never actually make this file.
# Working predicate pushdown means it never gets accessed
pa_dataset_format.make_fragment(
"1.parquet",
filesystem=pa_dataset_fs,
partition_expression=pa_dataset_partition,
)
]
pa_dataset = pa.dataset.FileSystemDataset(
fragments,
pa.schema([pa.field("a", col_type)]),
pa_dataset_format,
pa_dataset_fs,
)
ctx.register_dataset("t", pa_dataset)
# the partition for our only fragment is for a < 2000-01-01.
# so querying for a > 2024-01-01 should not touch any files
df = ctx.sql("SELECT * FROM t WHERE a > '2024-01-01T00:00:00+00:00'")
assert df.collect() == []
def test_dataset_filter_nested_data(ctx):
# create Arrow StructArrays to test nested data types
data = pa.StructArray.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6])],
names=["a", "b"],
)
batch = pa.RecordBatch.from_arrays(
[data],
names=["nested_data"],
)
dataset = ds.dataset([batch])
ctx.register_dataset("t", dataset)
assert ctx.catalog().schema().names() == {"t"}
df = ctx.table("t")
# This filter will not be pushed down to DatasetExec since it
# isn't supported
df = df.filter(column("nested_data")["b"] > literal(5)).select(
column("nested_data")["a"] + column("nested_data")["b"],
column("nested_data")["a"] - column("nested_data")["b"],
)
result = df.collect()
assert result[0].column(0) == pa.array([9])
assert result[0].column(1) == pa.array([-3])
def test_table_exist(ctx):
batch = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6])],
names=["a", "b"],
)
dataset = ds.dataset([batch])
ctx.register_dataset("t", dataset)
assert ctx.table_exist("t") is True
def test_table_not_found(ctx):
from uuid import uuid4
with pytest.raises(KeyError):
ctx.table(f"not-found-{uuid4()}")
def test_session_start_time(ctx):
import datetime
import re
st = ctx.session_start_time()
assert isinstance(st, str)
# Truncate nanoseconds to microseconds for Python 3.10 compat
st = re.sub(r"(\.\d{6})\d+", r"\1", st)
dt = datetime.datetime.fromisoformat(st)
assert dt.isoformat()
def test_enable_ident_normalization(ctx):
assert ctx.enable_ident_normalization() is True
ctx.sql("SET datafusion.sql_parser.enable_ident_normalization = false")
assert ctx.enable_ident_normalization() is False
def test_parse_sql_expr(ctx):
from datafusion.common import DFSchema
schema = DFSchema.empty()
expr = ctx.parse_sql_expr("1 + 2", schema)
assert str(expr) == "Expr(Int64(1) + Int64(2))"
def test_execute_logical_plan(ctx):
df = ctx.from_pydict({"a": [1, 2, 3]})
plan = df.logical_plan()
df2 = ctx.execute_logical_plan(plan)
result = df2.collect()
assert result[0].column(0) == pa.array([1, 2, 3])
def test_refresh_catalogs(ctx):
ctx.refresh_catalogs()
def test_remove_optimizer_rule(ctx):
assert ctx.remove_optimizer_rule("push_down_filter") is True
assert ctx.remove_optimizer_rule("nonexistent_rule") is False
def test_set_query_planner_rejects_wrong_capsule(ctx):
with pytest.raises(ValueError, match="datafusion_query_planner"):
ctx.set_query_planner(ctx.__datafusion_task_context_provider__())
def test_with_extension_rejects_wrong_capsule(ctx):
"""The extension options hook names the capsule it was handed.
Like the rest of the capsule family, this reports which capsule turned up
rather than CPython's fixed "called with incorrect name" string.
"""
class WrongCapsule:
def __datafusion_extension_options__(self):
return ctx.__datafusion_task_context_provider__()
with pytest.raises(ValueError, match="datafusion_extension_options"):
SessionConfig().with_extension(WrongCapsule())
def test_pre_55_codec_signature_reports_an_upgrade(ctx):
"""A getter that refuses the session is named, not left as a bare TypeError.
Extension libraries implement these getters, so the pre-55.0.0 signature
is what an out-of-date one still has. The original error stays reachable
as ``__cause__`` rather than being replaced outright.
"""
class PreSessionCodec:
def __datafusion_logical_extension_codec__(self):
msg = "should never be called"
raise AssertionError(msg)
with pytest.raises(ImportError, match="__datafusion_logical_extension_codec__"):
ctx.with_logical_extension_codec(PreSessionCodec())
with pytest.raises(ImportError) as excinfo:
ctx.with_logical_extension_codec(PreSessionCodec())
assert isinstance(excinfo.value.__cause__, TypeError)
assert "positional argument" in str(excinfo.value.__cause__)
def test_type_error_inside_a_getter_is_not_reported_as_an_upgrade(ctx):
"""A correctly-signed getter's own TypeError must survive unchanged.
Only the call machinery's arity error means the library is out of date.
Rewriting every TypeError would send an author debugging their own getter
off to upgrade a library that is already correct.
"""
class RaisesTypeError:
def __datafusion_logical_extension_codec__(self, session):
msg = "bad cast inside the getter"
raise TypeError(msg)
with pytest.raises(TypeError, match="bad cast inside the getter"):
ctx.with_logical_extension_codec(RaisesTypeError())
def test_non_type_errors_from_a_getter_propagate(ctx):
"""Anything that is not a TypeError was never a signature problem."""
class RaisesValueError:
def __datafusion_logical_extension_codec__(self, session):
msg = "something else entirely"
raise ValueError(msg)
with pytest.raises(ValueError, match="something else entirely"):
ctx.with_logical_extension_codec(RaisesValueError())
def test_set_query_planner_capsule(ctx):
capsule = ctx.__datafusion_query_planner__()
get_name = ctypes.pythonapi.PyCapsule_GetName
get_name.argtypes = [ctypes.py_object]
get_name.restype = ctypes.c_char_p
assert get_name(capsule) == b"datafusion_query_planner"
ctx.register_record_batches(
"query_planner_test",
[[pa.RecordBatch.from_pydict({"value": [1, 2, 3]})]],
)
ctx.set_query_planner(capsule)
assert ctx.table_exist("query_planner_test")
batches = ctx.sql("SELECT 1 AS value").collect()
assert batches[0].column(0) == pa.array([1])
def test_installing_a_planner_leaves_the_session_intact(ctx):
"""The planner is written into the existing session, not a copy of it.
Registrations made before the install are still visible afterwards, and
ones made after are visible too -- there is a single session throughout,
so neither the catalogs nor the function registry are snapshotted.
"""
ctx.register_record_batches(
"registered_before",
[[pa.RecordBatch.from_pydict({"value": [1]})]],
)
before = udf(
lambda arr: arr,
[pa.int64()],
pa.int64(),
volatility="immutable",
name="registered_before",
)
ctx.register_udf(before)
ctx.set_query_planner(ctx.__datafusion_query_planner__())
ctx.register_record_batches(
"registered_after",
[[pa.RecordBatch.from_pydict({"value": [2]})]],
)
after = udf(
lambda arr: arr,
[pa.int64()],
pa.int64(),
volatility="immutable",
name="registered_after",
)
ctx.register_udf(after)
assert ctx.table_exist("registered_before")
assert ctx.table_exist("registered_after")
assert ctx.sql("SELECT registered_before(1)").collect()
assert ctx.sql("SELECT registered_after(1)").collect()
def test_contexts_sharing_a_session_share_the_planner(ctx):
"""A context derived before the install still plans through the planner.
``with_python_udf_inlining`` returns a handle on the same session, and the
query planner lives in that session's state.
"""
sibling = ctx.with_python_udf_inlining(enabled=False)
ctx.register_record_batches(
"shared_planner_test",
[[pa.RecordBatch.from_pydict({"value": [1, 2, 3]})]],
)
ctx.set_query_planner(ctx.__datafusion_query_planner__())
assert sibling.table_exist("shared_planner_test")
assert sibling.session_id() == ctx.session_id()
class _NamedCodec:
"""Wraps a codec capsule in an object that can name itself.
``with_extensions`` requires objects rather than bare capsules, because a
codec's wire id is read off the object it is handed over as. This is the
shape a library holding a raw capsule hands over.
"""
def __init__(self, capsule, codec_id):
self._capsule = capsule
self.__datafusion_codec_id__ = codec_id
def __datafusion_logical_extension_codec__(self, session=None):
return self._capsule
def __datafusion_physical_extension_codec__(self, session=None):
return self._capsule
class _CodecOnlyExtension:
"""Contributes decline-all codecs exported from an unrelated session.
Retaining ``ctx`` is what the protocol tells real extensions not to do —
a bundle is reusable, so a cached context belongs to whichever session it
was last installed on. It is kept here only so a test can assert *which*
context the factory was handed.
"""
def __init__(self, prefix="my_library"):
self.exporter = SessionContext()
self.prefix = prefix
self.bound_ctx = None
def __datafusion_session_components__(self, ctx):
self.bound_ctx = ctx
return SessionExtensionComponents(
logical_extension_codecs=(
_NamedCodec(
self.exporter.__datafusion_logical_extension_codec__(),
f"{self.prefix}.logical",
),
),
physical_extension_codecs=(
_NamedCodec(
self.exporter.__datafusion_physical_extension_codec__(),
f"{self.prefix}.physical",
),
),
)
class _PlannerExtension:
"""Contributes a planner, recording the fallback it was handed.
Passing ``fallback`` straight back through is the degenerate wrap: it plans
the same queries to the same plans, which is what lets a pure-Python test
assert the threading without a real layering planner. It is not a no-op —
the capsule gets installed, so the session ends up planning through a
foreign planner — but nothing here depends on that either way.
"""
def __init__(self, calls=None):
self.fallbacks = []
self.planner_ctx = None
# Shared list the hooks append themselves to, so a test can assert the
# order they ran in rather than only that each ran.
self.calls = [] if calls is None else calls
def __datafusion_session_planner__(self, ctx, fallback):
self.calls.append(self)
self.planner_ctx = ctx
self.fallbacks.append(fallback)
return fallback
def test_with_extensions_accepts_no_extensions(ctx):
"""No extensions installs nothing and returns a handle on this session.
A caller assembling the list at runtime — from a plugin registry, say —
should not have to special-case it being empty, and every sibling varargs
method on ``DataFrame`` accepts zero arguments the same way.
"""
ctx.register_record_batches(
"empty_extensions_test",
[[pa.RecordBatch.from_pydict({"value": [1]})]],
)
result = ctx.with_extensions()
assert result.session_id() == ctx.session_id()
assert result.table_exist("empty_extensions_test")
assert result.logical_extension_codec_ids() == ctx.logical_extension_codec_ids()
def test_with_extensions_no_extensions_keeps_an_installed_planner(ctx):
"""The empty case must not disturb a planner the session already has.
A call that installs no codec and no planner skips the planner commit
entirely, so the installed planner keeps the chains it was bound to and the
session still plans through it.
"""
extension = _CodecOnlyExtension()
installed = ctx.with_extensions(extension, _PlannerExtension())
result = installed.with_extensions()
assert result.logical_extension_codec_ids() == (
installed.logical_extension_codec_ids()
)
batches = result.sql("SELECT 1 AS value").collect()
assert batches[0].column(0) == pa.array([1])
def test_with_extensions_rejects_non_extension(ctx):
with pytest.raises(TypeError, match="__datafusion_session_planner__"):
ctx.with_extensions(object())
def test_with_extensions_rejects_bad_components(ctx):
class BadExtension:
def __datafusion_session_components__(self, ctx):
return 42
with pytest.raises(TypeError, match="SessionExtensionComponents"):
ctx.with_extensions(BadExtension())
@pytest.mark.parametrize(
"field", ["logical_extension_codecs", "physical_extension_codecs"]
)
def test_session_extension_components_rejects_a_single_codec(field):
"""A lone codec is not an iterable of codecs.
Dropping the trailing comma is the easy way to write one by accident. The
check lives on the value type so the error lands in the extension
library's own frame, naming the field it got wrong, rather than surfacing
later inside ``with_extensions`` as ``'_NamedCodec' object is not
iterable``.
"""
codec = _NamedCodec(
SessionContext().__datafusion_logical_extension_codec__(),
"my_library.logical",
)
with pytest.raises(TypeError, match=r"must be an iterable of codec objects"):
SessionExtensionComponents(**{field: codec})
def test_session_extension_components_is_keyword_only():
codecs = (
_NamedCodec(
SessionContext().__datafusion_logical_extension_codec__(),
"my_library.logical",
),
)
with pytest.raises(TypeError, match=r"positional argument"):
SessionExtensionComponents(codecs)
components = SessionExtensionComponents(logical_extension_codecs=codecs)
assert components.logical_extension_codecs == codecs
def test_session_extension_components_rejects_a_string():
"""A str is iterable, so it needs refusing on its own.
Left alone it would normalize into a tuple of characters and fail much
later as that many bogus codecs.
"""
with pytest.raises(TypeError, match=r"not a single str"):
SessionExtensionComponents(logical_extension_codecs="my_library.logical")
def test_with_extensions_accepts_a_planner_only_extension(ctx):
"""An extension may implement the planner hook alone.
A library that ships an optimizing planner and no codecs — nothing to
contribute in phase one — should not have to return empty components.
"""
extension = _PlannerExtension()
result = ctx.with_extensions(extension)
assert len(extension.fallbacks) == 1
assert result.session_id() == ctx.session_id()
def test_with_extensions_threads_the_planner_through_in_order(ctx):
"""Each planner hook receives what the previous one returned.
Planners nest rather than chain, so the host hands each bundle the planner
built so far. Argument order is nesting order, last one outermost.
"""
calls = []
first, second = _PlannerExtension(calls), _PlannerExtension(calls)
ctx.with_extensions(first, second)
# Argument order, once each. Nothing else pins the order: both hooks
# return capsules, and a host that ran them backwards would still leave
# each with one fallback recorded.
assert calls == [first, second]
# `first` returned its fallback unchanged, but the host re-exports every
# hook's return value before handing it on, so `second` receives a capsule
# of its own rather than the object `first` was handed.
assert second.fallbacks[0] is not first.fallbacks[0]
# That is as far as pure Python reaches: a capsule is opaque, so this
# cannot tell a re-export of `first`'s planner from a fresh read of the
# session's. `test_with_extensions_nests_planners_in_argument_order` in
# examples/datafusion-ffi-query-planner-example is what pins the nesting,
# by asserting the outer planner delegated to the inner one.
def test_with_extensions_planner_hook_sees_the_new_handle(ctx):
"""Phase two runs against the handle carrying the final codec chains.
A planner captured against the pre-install handle would encode through a
chain missing every codec this call installed.
"""
codecs = _CodecOnlyExtension()
planner = _PlannerExtension()
result = ctx.with_extensions(codecs, planner)
assert planner.planner_ctx.logical_extension_codec_ids() == ["my_library.logical"]
assert result.logical_extension_codec_ids() == ["my_library.logical"]
def test_with_extensions_skips_a_planner_hook_returning_none(ctx):
"""Returning ``None`` contributes no planner and keeps the fallback.
The skip is what lets the call succeed at all: a host that treated the
``None`` as a contribution would hand it to the export step and fail with
``'None' is not an instance of 'PyCapsule'`` before ``downstream`` ran.
"""
class NoPlanner:
def __init__(self):
self.fallbacks = []
def __datafusion_session_planner__(self, ctx, fallback):
self.fallbacks.append(fallback)
# Spelled out rather than left to fall off the end: `None` is the
# protocol's "contribute no planner", which is what this test is
# about, and an implicit one would read as an oversight.
return None # noqa: RET501, PLR1711
skipped = NoPlanner()
downstream = _PlannerExtension()
result = ctx.with_extensions(skipped, downstream)
# Both hooks ran, and `downstream` was handed a planner rather than the
# `None` in front of it. It is a fresh read of the session's planner, not
# the object `skipped` was given, so a host that fell back by reusing the
# previous hook's *input* is ruled out too.
assert len(skipped.fallbacks) == 1
assert len(downstream.fallbacks) == 1
assert downstream.fallbacks[0] is not skipped.fallbacks[0]
batches = result.sql("SELECT 1 AS value").collect()
assert batches[0].column(0) == pa.array([1])
def test_with_extensions_ignores_a_planner_attribute_set_to_none(ctx):
"""Regression test: ``__datafusion_session_planner__ = None`` is not a hook.
A base class spelling out that its subclasses contribute no planner binds
the attribute to ``None`` rather than omitting it. ``isinstance`` against
:py:class:`~datafusion.extensions.SessionPlannerExportable` rejects that,
but a ``hasattr`` does not, so a host that validated with one and dispatched
with the other called the ``None``: ``'NoneType' object is not callable``,
naming neither the extension nor the hook.
``_commit_extensions`` calls every entry it is handed, so the
``isinstance`` filter in the Python caller is the only check. A refactor
that passes the unfiltered arguments through reintroduces the failure, and
nothing else in this file notices.
"""
class NoPlannerExtension(_CodecOnlyExtension):
__datafusion_session_planner__ = None
result = ctx.with_extensions(NoPlannerExtension())
assert result.logical_extension_codec_ids() == ["my_library.logical"]
assert result.physical_extension_codec_ids() == ["my_library.physical"]
def test_with_extensions_rejects_bad_codec_capsule(ctx):
"""A correctly shaped object still has to return the right capsule."""
class BadCodecExtension:
def __datafusion_session_components__(self, ctx):
wrong_capsule = ctx.__datafusion_task_context_provider__()
return SessionExtensionComponents(
logical_extension_codecs=(
_NamedCodec(wrong_capsule, "my_library.logical"),
),
)
with pytest.raises(
ValueError, match="Expected name 'datafusion_logical_extension_codec'"
):
ctx.with_extensions(BadCodecExtension())
def test_with_extensions_rejects_a_bare_capsule_codec(ctx):
"""A codec must be an object that can name itself, not a bare capsule.
An id is read off the object a codec is handed over as, and a capsule has
no type to read one from. ``with_extensions`` takes no ``codec_id=``, so
the capsule is refused here rather than given an id derived from something
that is not the codec.
"""
class BareCapsuleExtension:
def __init__(self):
self.exporter = SessionContext()
def __datafusion_session_components__(self, ctx):
return SessionExtensionComponents(
logical_extension_codecs=(
self.exporter.__datafusion_logical_extension_codec__(),
),
)
with pytest.raises(
TypeError,
match="must be an object exposing `__datafusion_logical_extension_codec__`",
):
ctx.with_extensions(BareCapsuleExtension())
def test_with_extensions_rejects_a_bare_physical_capsule_codec(ctx):
"""The physical getter is named in its own diagnostic."""
class BareCapsuleExtension:
def __init__(self):
self.exporter = SessionContext()
def __datafusion_session_components__(self, ctx):
return SessionExtensionComponents(
physical_extension_codecs=(
self.exporter.__datafusion_physical_extension_codec__(),
),
)
with pytest.raises(
TypeError,
match="must be an object exposing `__datafusion_physical_extension_codec__`",
):
ctx.with_extensions(BareCapsuleExtension())
def test_with_extensions_codec_ids_survive_composition(ctx):
"""A codec keeps its id when its extension is nested inside another one.
Wire ids have to mean the same thing in whichever process decodes, so
packaging one extension inside another — the natural way for an
application to present several libraries as one — must not re-tag the
inner library's payloads. Reading the id off the handed-over object rather
than off the contributing extension is what guarantees that.
"""
class ComposedExtension:
"""Presents another extension's components as its own."""
def __init__(self, inner):
self.inner = inner
def __datafusion_session_components__(self, ctx):
return self.inner.__datafusion_session_components__(ctx)
direct = ctx.with_extensions(_CodecOnlyExtension())
wrapped = SessionContext().with_extensions(ComposedExtension(_CodecOnlyExtension()))
assert direct.logical_extension_codec_ids() == ["my_library.logical"]
assert wrapped.logical_extension_codec_ids() == ["my_library.logical"]
assert direct.physical_extension_codec_ids() == ["my_library.physical"]
assert wrapped.physical_extension_codec_ids() == ["my_library.physical"]
def test_with_extensions_uses_ids_declared_on_the_codec(ctx):
"""``__datafusion_codec_id__`` on the handed-over object names the codec.
This is how an extension contributing more than one codec of a kind tells
them apart.
"""
class TwoNamedCodecs:
def __init__(self):
self.exporter = SessionContext()
def __datafusion_session_components__(self, ctx):
return SessionExtensionComponents(
logical_extension_codecs=(
_NamedCodec(
self.exporter.__datafusion_logical_extension_codec__(),
"my_library.first",
),
_NamedCodec(
self.exporter.__datafusion_logical_extension_codec__(),
"my_library.second",
),
),
)
result = ctx.with_extensions(TwoNamedCodecs())
assert result.logical_extension_codec_ids() == [
"my_library.first",
"my_library.second",
]
def test_with_extensions_rejects_two_codecs_of_one_class(ctx):
"""Two wrappers of one class claim one class-derived id, so they collide.
Numbering them by position would be an id another library can mint the
same value from, and would break stored plans the first time the extension
reordered what it returns, so the ambiguity is refused instead.
"""
class UnnamedCodec:
def __init__(self, capsule):
self._capsule = capsule
def __datafusion_logical_extension_codec__(self, session=None):
return self._capsule
class TwoUnnamedCodecs:
def __init__(self):
self.exporter = SessionContext()
def __datafusion_session_components__(self, ctx):
capsule = self.exporter.__datafusion_logical_extension_codec__
return SessionExtensionComponents(
logical_extension_codecs=(
UnnamedCodec(capsule()),
UnnamedCodec(capsule()),
),
)
with pytest.raises(ValueError, match="__datafusion_codec_id__"):
ctx.with_extensions(TwoUnnamedCodecs())
def test_with_extensions_leaves_an_exporting_object_its_own_id(ctx):
"""A codec handed over as an object keeps the identity it declares."""
exporter = SessionContext()
class ObjectCodecExtension:
def __datafusion_session_components__(self, ctx):
return SessionExtensionComponents(logical_extension_codecs=(exporter,))
result = ctx.with_extensions(ObjectCodecExtension())
assert result.logical_extension_codec_ids() == [exporter.__datafusion_codec_id__]
def test_with_extensions_installs_codecs_and_planner(ctx):
ctx.register_record_batches(
"extensions_test",
[[pa.RecordBatch.from_pydict({"value": [1, 2, 3]})]],
)
extension = _CodecOnlyExtension()
result = ctx.with_extensions(extension, _PlannerExtension())
assert result.table_exist("extensions_test")
# In-memory tables need a real extension codec to round-trip through the
# FFI planner, so query plans that don't serialize a table provider.
batches = result.sql("SELECT 1 AS value").collect()
assert batches[0].column(0) == pa.array([1])
def test_with_extensions_binds_to_the_receiving_session(ctx):
extension = _CodecOnlyExtension()
result = ctx.with_extensions(extension)
# Factories are handed the receiver itself, so a component bound during
# installation targets the session the returned handle also wraps. There
# is no intermediate context that could be collected out from under it.
assert extension.bound_ctx is ctx
assert result.session_id() == ctx.session_id()
# One session: a registration through either handle is visible to both.
ctx.register_record_batches(
"bound_test",
[[pa.RecordBatch.from_pydict({"value": [1]})]],
)
assert result.table_exist("bound_test")
def test_with_extensions_survives_source_collection():
extension = _CodecOnlyExtension()
result = SessionContext().with_extensions(extension, _PlannerExtension())
gc.collect()
batches = result.sql("SELECT 1 AS value").collect()
assert batches[0].column(0) == pa.array([1])
def test_with_extensions_failure_leaves_source_usable(ctx):
class BoomExtension:
def __datafusion_session_components__(self, ctx):
msg = "boom"
raise RuntimeError(msg)
with pytest.raises(RuntimeError, match="boom"):
ctx.with_extensions(_CodecOnlyExtension(), BoomExtension())
batches = ctx.sql("SELECT 1 AS value").collect()
assert batches[0].column(0) == pa.array([1])
def _doubler(name="double"):
"""A scalar function under a name the caller picks."""
return udf(
lambda arr: pa.array([v.as_py() * 2 for v in arr]),
[pa.int64()],
pa.int64(),
volatility="stable",
name=name,
)
class _Total(Accumulator):
"""The smallest accumulator that survives a partial/final split."""
def __init__(self):
self._sum = 0
def state(self) -> list[pa.Scalar]:
return [pa.scalar(self._sum)]
def update(self, values: pa.Array) -> None:
self._sum += pc.sum(values).as_py() or 0
def merge(self, states: list[pa.Array]) -> None:
self._sum += pc.sum(states[0]).as_py() or 0
def evaluate(self) -> pa.Scalar:
return pa.scalar(self._sum)
class _First(WindowEvaluator):
"""Repeats the first value of the partition across every row."""
def evaluate_all(self, values: list[pa.Array], num_rows: int) -> pa.Array:
first = values[0][0].as_py()
return pa.array([first] * num_rows)
def _total(name="total"):
"""An aggregate function under a name the caller picks."""
return udaf(
_Total,
pa.int64(),
pa.int64(),
[pa.int64()],
volatility="stable",
name=name,
)
def _first(name="first_value_of"):
"""A window function under a name the caller picks."""
return udwf(
_First,
pa.int64(),
pa.int64(),
volatility="immutable",
name=name,
)
class _FunctionExtension:
"""Contributes functions and nothing else.
The shape a library shipping only functions has: no codecs, no planner,
so the whole of its installation is what it declares here.
"""
def __init__(self, udfs=(), udafs=(), udwfs=()):
self._udfs = udfs
self._udafs = udafs
self._udwfs = udwfs
def __datafusion_session_components__(self, ctx):
return SessionExtensionComponents(
udfs=self._udfs, udafs=self._udafs, udwfs=self._udwfs
)
def test_with_extensions_registers_a_declared_udf(ctx):
"""A declared scalar function is callable from SQL on the returned handle."""
result = ctx.with_extensions(_FunctionExtension(udfs=(_doubler(),)))
result.from_pydict({"a": [1, 2, 3]}, name="nums")
batches = result.sql("SELECT double(a) AS doubled FROM nums").collect()
assert batches[0].column(0) == pa.array([2, 4, 6])
def test_with_extensions_registers_udafs_and_udwfs(ctx):
"""The other two function kinds install the same way."""
result = ctx.with_extensions(
_FunctionExtension(udafs=(_total(),), udwfs=(_first(),))
)
result.from_pydict({"a": [1, 2, 3]}, name="nums")
assert result.sql("SELECT total(a) FROM nums").collect()[0].column(0) == pa.array(
[6]
)
batches = result.sql("SELECT first_value_of(a) OVER () FROM nums").collect()
assert batches[0].column(0) == pa.array([1, 1, 1])
def test_with_extensions_registers_on_the_shared_session(ctx):
"""Registrations land on the session, which the source context also holds.
Only the codec chains belong to the returned handle. Pinned deliberately:
a future change that made registrations private to the handle would be a
behaviour change, not a fix.
"""
ctx.with_extensions(_FunctionExtension(udfs=(_doubler(),)))
assert ctx.udf("double").name == "double"
@pytest.mark.parametrize(
("field", "make", "label", "lookup"),
[
("udfs", _doubler, "scalar function", "udf"),
("udafs", _total, "aggregate function", "udaf"),
("udwfs", _first, "window function", "udwf"),
],
)
def test_with_extensions_rejects_a_name_two_extensions_claim(
ctx, field, make, label, lookup
):
"""Registrations have no fall-through, so a clash cannot be resolved by order.
Unlike codecs, which dispatch by id, a second function under one name would
silently replace the first. Parametrized over the kinds to pin each one's
field and label wiring, not just the machinery.
A codec-carrying bundle rides along to pin the other half of the
transaction: resolution runs *after* the codec chains are built, so this
failure lands between the two steps, and the chains must not reach the
session either.
"""
name = make().name
with pytest.raises(ValueError, match=rf"{label} named '{name}'"):
ctx.with_extensions(
_CodecOnlyExtension(),
_FunctionExtension(**{field: (make(),)}),
_FunctionExtension(**{field: (make(),)}),
)
with pytest.raises(KeyError):
getattr(ctx, lookup)(name)
assert ctx.logical_extension_codec_ids() == []
assert ctx.physical_extension_codec_ids() == []
def test_with_extensions_rejects_one_extension_passed_twice(ctx):
"""A bundle object listed twice is the caller's duplicate, not a naming bug.
Two argument positions is what says so, and it is the reason collisions are
keyed on position rather than on object identity: identity would read a
repeat as one bundle colliding with itself, pointing the reader at a rename
of someone else's function that they cannot make.
"""
extension = _FunctionExtension(udfs=(_doubler(),))
with pytest.raises(ValueError, match=r"argument 0 .* and argument 1 "):
ctx.with_extensions(extension, extension)
with pytest.raises(KeyError):
ctx.udf("double")
def test_with_extensions_rejects_a_name_one_extension_claims_twice(ctx):
"""A bundle colliding with itself is caught by the same check.
One argument position named twice is what distinguishes it, and it is the
case the message's rename remedy is for — the only reader who can rename a
function is the one who declared both of them.
"""
with pytest.raises(ValueError, match=r"argument 0 .* and argument 0 ") as excinfo:
ctx.with_extensions(_FunctionExtension(udfs=(_doubler(), _doubler())))
assert "scalar function named 'double'" in str(excinfo.value)
with pytest.raises(KeyError):
ctx.udf("double")
def test_with_extensions_allows_shadowing_an_existing_function(ctx):
"""Claiming a name the session already has is legal.
``ctx.udfs()`` holds every built-in, and ``enable_spark_functions``
overrides built-ins by design, so refusing this would refuse a supported
use rather than catch a mistake.
"""
result = ctx.with_extensions(_FunctionExtension(udfs=(_doubler(name="abs"),)))
result.from_pydict({"a": [1, 2, 3]}, name="nums")
batches = result.sql("SELECT abs(a) AS shadowed FROM nums").collect()
assert batches[0].column(0) == pa.array([2, 4, 6])
def test_with_extensions_registers_nothing_when_a_components_hook_raises(ctx):
"""A failure in phase one leaves the first extension's functions uninstalled."""
class BoomExtension:
def __datafusion_session_components__(self, ctx):
msg = "boom"
raise RuntimeError(msg)
with pytest.raises(RuntimeError, match="boom"):
ctx.with_extensions(
_FunctionExtension(udfs=(_doubler(),)),
BoomExtension(),
)
with pytest.raises(KeyError):
ctx.udf("double")
def test_with_extensions_registers_nothing_when_a_planner_hook_raises(ctx):
"""A failure in phase two does too, which is what pins the ordering.
By the time the planner hooks run, the functions have been resolved and
their names checked. Committing them at that point rather than after would
pass every other test here and still leave this one registered.
"""
class BoomPlanner:
def __datafusion_session_planner__(self, ctx, fallback):
msg = "boom"
raise RuntimeError(msg)
with pytest.raises(RuntimeError, match="boom"):
ctx.with_extensions(
_FunctionExtension(udfs=(_doubler(),)),
BoomPlanner(),
)
with pytest.raises(KeyError):
ctx.udf("double")
def test_with_extensions_rejects_an_unusable_declaration(ctx):
"""Something that is neither a wrapper nor an exportable names both sides."""
with pytest.raises(TypeError, match=r"__datafusion_scalar_udf__"):
ctx.with_extensions(_FunctionExtension(udfs=(object(),)))
@pytest.mark.parametrize("field", ["udfs", "udafs", "udwfs"])
def test_session_extension_components_rejects_a_single_function(field):
"""A lone function is not an iterable of them, as for codecs."""
with pytest.raises(TypeError, match=r"must be an iterable of function objects"):
SessionExtensionComponents(**{field: _doubler()})
def test_every_component_field_has_an_installer():
"""A field added to the components dataclass must be wired into the install.
``SessionExtensionComponents`` normalizes every field named in
``_COMPONENT_NOUNS``, so one added without an installer would be accepted
from a bundle and then quietly dropped — the failure this pins is a
contributed component going nowhere, with no error to say so. Nothing
observable from outside can catch that, because the symptom is silence.
If this fails because you added a field: give it a member on
``datafusion.context._Contributions``, collect it in
``_collect_contributions``, and resolve and install it in
``with_extensions``. Then add it below.
Keep the comparison an equality. A subset check is satisfied by any new
field on its own, which is the one case this exists to catch. A field that
is deliberately not a component collection belongs in ``not_components``.
"""
from datafusion.extensions import _COMPONENT_NOUNS
assert set(_COMPONENT_NOUNS) == {
"logical_extension_codecs",
"physical_extension_codecs",
"udfs",
"udafs",
"udwfs",
}
# Fields that are not component collections: normalized by nothing,
# installed by nothing, and exempt on purpose. Empty today.
not_components: set[str] = set()
declared = {spec.name for spec in fields(SessionExtensionComponents)}
assert declared - not_components == set(_COMPONENT_NOUNS)
def test_table_provider(ctx):
batch = pa.RecordBatch.from_pydict({"x": [10, 20, 30]})
ctx.register_record_batches("provider_test", [[batch]])
tbl = ctx.table_provider("provider_test")
assert tbl.schema == pa.schema([("x", pa.int64())])
def test_table_provider_not_found(ctx):
with pytest.raises(KeyError):
ctx.table_provider("nonexistent_table")
def test_read_json(ctx):
path = pathlib.Path(__file__).parent.resolve()
# Default
test_data_path = path / "data_test_context" / "data.json"
df = ctx.read_json(test_data_path)
result = df.collect()
assert result[0].column(0) == pa.array(["a", "b", "c"])
assert result[0].column(1) == pa.array([1, 2, 3])
# Schema
schema = pa.schema(
[
pa.field("A", pa.string(), nullable=True),
]
)
df = ctx.read_json(test_data_path, schema=schema)
result = df.collect()
assert result[0].column(0) == pa.array(["a", "b", "c"])
assert result[0].schema == schema
# File extension
test_data_path = path / "data_test_context" / "data.json"
df = ctx.read_json(test_data_path, file_extension=".json")
result = df.collect()
assert result[0].column(0) == pa.array(["a", "b", "c"])
assert result[0].column(1) == pa.array([1, 2, 3])
def test_read_json_compressed(ctx, tmp_path):
path = pathlib.Path(__file__).parent.resolve()
test_data_path = path / "data_test_context" / "data.json"
# File compression type
gzip_path = tmp_path / "data.json.gz"
with (
pathlib.Path.open(test_data_path, "rb") as csv_file,
gzip.open(gzip_path, "wb") as gzipped_file,
):
gzipped_file.writelines(csv_file)
df = ctx.read_json(gzip_path, file_extension=".gz", file_compression_type="gz")
result = df.collect()
assert result[0].column(0) == pa.array(["a", "b", "c"])
assert result[0].column(1) == pa.array([1, 2, 3])
def test_read_csv(ctx):
csv_df = ctx.read_csv(path="testing/data/csv/aggregate_test_100.csv")
csv_df.select(column("c1")).show()
def test_read_csv_list(ctx, tmp_path):
source_path = pathlib.Path("testing/data/csv/aggregate_test_100.csv")
copied_path = tmp_path / source_path.name
shutil.copy(source_path, copied_path)
csv_df = ctx.read_csv(path=[source_path])
expected = csv_df.count() * 2
double_csv_df = ctx.read_csv(path=[source_path, copied_path])
actual = double_csv_df.count()
double_csv_df.select(column("c1")).show()
assert actual == expected
def test_read_csv_compressed(ctx, tmp_path):
test_data_path = pathlib.Path("testing/data/csv/aggregate_test_100.csv")
expected = ctx.read_csv(test_data_path).collect()
# File compression type
gzip_path = tmp_path / "aggregate_test_100.csv.gz"
with (
pathlib.Path.open(test_data_path, "rb") as csv_file,
gzip.open(gzip_path, "wb") as gzipped_file,
):
gzipped_file.writelines(csv_file)
csv_df = ctx.read_csv(gzip_path, file_extension=".gz", file_compression_type="gz")
assert csv_df.collect() == expected
csv_df = ctx.read_csv(
gzip_path,
options=CsvReadOptions(file_extension=".gz", file_compression_type="gz"),
)
assert csv_df.collect() == expected
def test_read_parquet(ctx):
parquet_df = ctx.read_parquet(path="parquet/data/alltypes_plain.parquet")
parquet_df.show()
assert parquet_df is not None
path = pathlib.Path.cwd() / "parquet/data/alltypes_plain.parquet"
parquet_df = ctx.read_parquet(path=path)
assert parquet_df is not None
def test_read_avro(ctx):
avro_df = ctx.read_avro(path="testing/data/avro/alltypes_plain.avro")
avro_df.show()
assert avro_df is not None
path = pathlib.Path.cwd() / "testing/data/avro/alltypes_plain.avro"
avro_df = ctx.read_avro(path=path)
assert avro_df is not None
def test_read_arrow(ctx, tmp_path):
# Write an Arrow IPC file, then read it back
table = pa.table({"a": [1, 2, 3], "b": ["x", "y", "z"]})
arrow_path = tmp_path / "test.arrow"
with pa.ipc.new_file(str(arrow_path), table.schema) as writer:
writer.write_table(table)
df = ctx.read_arrow(str(arrow_path))
result = df.collect()
assert result[0].column(0) == pa.array([1, 2, 3])
assert result[0].column(1) == pa.array(["x", "y", "z"])
# Also verify pathlib.Path works
df = ctx.read_arrow(arrow_path)
result = df.collect()
assert result[0].column(0) == pa.array([1, 2, 3])
def test_read_empty(ctx):
df = ctx.read_empty()
result = df.collect()
assert len(result) == 1
assert result[0].num_columns == 0
df = ctx.empty_table()
result = df.collect()
assert len(result) == 1
assert result[0].num_columns == 0
def test_register_arrow(ctx, tmp_path):
# Write an Arrow IPC file, then register and query it
table = pa.table({"x": [10, 20, 30]})
arrow_path = tmp_path / "test.arrow"
with pa.ipc.new_file(str(arrow_path), table.schema) as writer:
writer.write_table(table)
ctx.register_arrow("arrow_tbl", str(arrow_path))
result = ctx.sql("SELECT * FROM arrow_tbl").collect()
assert result[0].column(0) == pa.array([10, 20, 30])
# Also verify pathlib.Path works
ctx.register_arrow("arrow_tbl_path", arrow_path)
result = ctx.sql("SELECT * FROM arrow_tbl_path").collect()
assert result[0].column(0) == pa.array([10, 20, 30])
def test_register_batch(ctx):
batch = pa.RecordBatch.from_pydict({"a": [1, 2, 3], "b": [4, 5, 6]})
ctx.register_batch("batch_tbl", batch)
result = ctx.sql("SELECT * FROM batch_tbl").collect()
assert result[0].column(0) == pa.array([1, 2, 3])
assert result[0].column(1) == pa.array([4, 5, 6])
def test_register_batch_empty(ctx):
batch = pa.RecordBatch.from_pydict({"a": pa.array([], type=pa.int64())})
ctx.register_batch("empty_batch_tbl", batch)
result = ctx.sql("SELECT * FROM empty_batch_tbl").collect()
assert result[0].num_rows == 0
def test_read_batch_returns_dataframe(ctx):
batch = pa.RecordBatch.from_pydict({"a": [1, 2, 3], "b": [4, 5, 6]})
df = ctx.read_batch(batch)
assert df.to_pydict() == {"a": [1, 2, 3], "b": [4, 5, 6]}
# read_batch should not register a named table.
assert ctx.catalog().schema().names() == set()
def test_read_batches_concatenates(ctx):
b1 = pa.RecordBatch.from_pydict({"a": [1, 2]})
b2 = pa.RecordBatch.from_pydict({"a": [3, 4]})
df = ctx.read_batches([b1, b2])
assert df.to_pydict() == {"a": [1, 2, 3, 4]}
def test_read_batches_accepts_iterable(ctx):
b1 = pa.RecordBatch.from_pydict({"a": [1, 2]})
b2 = pa.RecordBatch.from_pydict({"a": [3, 4]})
# Generator: ensures non-list iterables are materialized before FFI.
df = ctx.read_batches(b for b in (b1, b2))
assert df.to_pydict() == {"a": [1, 2, 3, 4]}
# Tuple: same.
df = ctx.read_batches((b1, b2))
assert df.to_pydict() == {"a": [1, 2, 3, 4]}
def test_create_sql_options():
SQLOptions()
def test_sql_with_options_no_ddl(ctx):
sql = "CREATE TABLE IF NOT EXISTS valuetable AS VALUES(1,'HELLO'),(12,'DATAFUSION')"
ctx.sql(sql)
options = SQLOptions().with_allow_ddl(allow=False)
with pytest.raises(Exception, match="DDL"):
ctx.sql_with_options(sql, options=options)
def test_sql_with_options_no_dml(ctx):
table_name = "t"
batch = pa.RecordBatch.from_arrays(
[pa.array([1, 2, 3]), pa.array([4, 5, 6])],
names=["a", "b"],
)
dataset = ds.dataset([batch])
ctx.register_dataset(table_name, dataset)
sql = f'INSERT INTO "{table_name}" VALUES (1, 2), (2, 3);'
ctx.sql(sql)
options = SQLOptions().with_allow_dml(allow=False)
with pytest.raises(Exception, match="DML"):
ctx.sql_with_options(sql, options=options)
def test_sql_with_options_no_statements(ctx):
sql = "SET time zone = 1;"
ctx.sql(sql)
options = SQLOptions().with_allow_statements(allow=False)
with pytest.raises(Exception, match="SetVariable"):
ctx.sql_with_options(sql, options=options)
@pytest.fixture
def batch():
return pa.RecordBatch.from_arrays(
[pa.array([4, 5, 6])],
names=["a"],
)
def test_create_dataframe_with_global_ctx(batch):
ctx = SessionContext.global_ctx()
df = ctx.create_dataframe([[batch]])
result = df.collect()[0].column(0)
assert result == pa.array([4, 5, 6])
def test_csv_read_options_builder_pattern():
"""Test CsvReadOptions builder pattern."""
from datafusion import CsvReadOptions
options = (
CsvReadOptions()
.with_has_header(False)
.with_delimiter("|")
.with_quote("'")
.with_schema_infer_max_records(2000)
.with_truncated_rows(True)
.with_newlines_in_values(True)
.with_file_extension(".tsv")
)
assert options.has_header is False
assert options.delimiter == "|"
assert options.quote == "'"
assert options.schema_infer_max_records == 2000
assert options.truncated_rows is True
assert options.newlines_in_values is True
assert options.file_extension == ".tsv"
def read_csv_with_options_inner(
tmp_path: pathlib.Path,
csv_content: str,
options: CsvReadOptions,
expected: pa.RecordBatch,
as_read: bool,
global_ctx: bool,
) -> None:
from datafusion import SessionContext
# Create a test CSV file
group_dir = tmp_path / "group=a"
group_dir.mkdir(exist_ok=True)
csv_path = group_dir / "test.csv"
csv_path.write_text(csv_content, newline="\n")
ctx = SessionContext()
if as_read:
if global_ctx:
from datafusion.io import read_csv
df = read_csv(str(tmp_path), options=options)
else:
df = ctx.read_csv(str(tmp_path), options=options)
else:
ctx.register_csv("test_table", str(tmp_path), options=options)
df = ctx.sql("SELECT * FROM test_table")
df.show()
# Verify the data
result = df.collect()
assert len(result) == 1
assert result[0] == expected
@pytest.mark.parametrize(
("as_read", "global_ctx"),
[
(True, True),
(True, False),
(False, False),
],
)
def test_read_csv_with_options(tmp_path, as_read, global_ctx):
"""Test reading CSV with CsvReadOptions."""
csv_content = "Alice;30;|New York; NY|\nBob;25\n#Charlie;35;Paris\nPhil;75;Detroit' MI\nKarin;50;|Stockholm\nSweden|" # noqa: E501
# Some of the read options are difficult to test in combination
# such as schema and schema_infer_max_records so run multiple tests
# file_sort_order doesn't impact reading, but included here to ensure
# all options parse correctly
options = CsvReadOptions(
has_header=False,
delimiter=";",
quote="|",
terminator="\n",
escape="\\",
comment="#",
newlines_in_values=True,
schema_infer_max_records=1,
null_regex="[pP]+aris",
truncated_rows=True,
file_sort_order=[[column("column_1").sort(), column("column_2")], ["column_3"]],
)
expected = pa.RecordBatch.from_arrays(
[
pa.array(["Alice", "Bob", "Phil", "Karin"]),
pa.array([30, 25, 75, 50]),
pa.array(["New York; NY", None, "Detroit' MI", "Stockholm\nSweden"]),
],
names=["column_1", "column_2", "column_3"],
)
read_csv_with_options_inner(
tmp_path, csv_content, options, expected, as_read, global_ctx
)
schema = pa.schema(
[
pa.field("name", pa.string(), nullable=False),
pa.field("age", pa.float32(), nullable=False),
pa.field("location", pa.string(), nullable=True),
]
)
options.with_schema(schema)
expected = pa.RecordBatch.from_arrays(
[
pa.array(["Alice", "Bob", "Phil", "Karin"]),
pa.array([30.0, 25.0, 75.0, 50.0]),
pa.array(["New York; NY", None, "Detroit' MI", "Stockholm\nSweden"]),
],
schema=schema,
)
read_csv_with_options_inner(
tmp_path, csv_content, options, expected, as_read, global_ctx
)
csv_content = "name,age\nAlice,30\nBob,25\nCharlie,35\nDiego,40\nEmily,15"
expected = pa.RecordBatch.from_arrays(
[
pa.array(["Alice", "Bob", "Charlie", "Diego", "Emily"]),
pa.array([30, 25, 35, 40, 15]),
pa.array(["a", "a", "a", "a", "a"]),
],
schema=pa.schema(
[
pa.field("name", pa.string(), nullable=True),
pa.field("age", pa.int64(), nullable=True),
pa.field("group", pa.string(), nullable=False),
]
),
)
options = CsvReadOptions(
table_partition_cols=[("group", pa.string())],
)
read_csv_with_options_inner(
tmp_path, csv_content, options, expected, as_read, global_ctx
)
def test_pre_52_table_provider_signature_reports_an_upgrade(ctx):
"""The table provider hook reports an upgrade the same way codecs do.
The 52.0.0 signature change added the session argument. This path had its
own copy of the error mapping and so missed later corrections to it.
"""
class PreSessionProvider:
def __datafusion_table_provider__(self):
msg = "should never be called"
raise AssertionError(msg)
with pytest.raises(ImportError, match="__datafusion_table_provider__") as excinfo:
ctx.register_table("old_sig", PreSessionProvider())
assert isinstance(excinfo.value.__cause__, TypeError)
def test_catalog_provider_getter_arity_error_names_the_codec(ctx):
"""A getter refusing the codec capsule is diagnosed, not left as a TypeError.
`__datafusion_catalog_provider__` is handed the host's logical extension
codec, not the session. It used to call `getattr(...).call1(...)` directly
and so produced a bare `TypeError` for an out-of-date library; it now routes
through `call_capsule_getter` like the rest of the family.
The message has to name the codec rather than the SessionContext, or it
would send the author to change the wrong parameter.
"""
class PreCodecCatalogProvider:
def __datafusion_catalog_provider__(self):
msg = "should never be called"
raise AssertionError(msg)
with pytest.raises(ImportError, match="__datafusion_catalog_provider__") as excinfo:
ctx.register_catalog_provider("old_sig", PreCodecCatalogProvider())
assert "logical extension codec" in str(excinfo.value)
assert "SessionContext" not in str(excinfo.value)
assert isinstance(excinfo.value.__cause__, TypeError)
def test_type_error_inside_a_catalog_provider_getter_propagates(ctx):
"""A correctly-signed catalog getter's own TypeError survives unchanged."""
class RaisesTypeError:
def __datafusion_catalog_provider__(self, codec):
msg = "bad cast inside the getter"
raise TypeError(msg)
with pytest.raises(TypeError, match="bad cast inside the getter"):
ctx.register_catalog_provider("raises", RaisesTypeError())
def test_type_error_inside_a_table_provider_getter_propagates(ctx):
"""A correctly-signed provider getter's own TypeError survives unchanged."""
class RaisesTypeError:
def __datafusion_table_provider__(self, session):
msg = "bad cast inside the getter"
raise TypeError(msg)
with pytest.raises(TypeError, match="bad cast inside the getter"):
ctx.register_table("raises", RaisesTypeError())