| # 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 | |
| import pyarrow as pa | |
| import pyarrow.dataset as ds | |
| import pytest | |
| from datafusion import ( | |
| CsvReadOptions, | |
| DataFrame, | |
| RuntimeEnvBuilder, | |
| SessionConfig, | |
| SessionContext, | |
| SessionExtensionComponents, | |
| SQLOptions, | |
| Table, | |
| column, | |
| literal, | |
| udf, | |
| ) | |
| 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_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_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 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()) |