Skip to content

Commit 413b8c2

Browse files
timsaucerclaude
andcommitted
feat: let extension bundles declare tables and table functions
`SessionExtensionComponents` gains `udtfs` and `table_providers`, both as `(name, value)` pairs. Neither carries a name of its own the way a scalar function's capsule does, and both getters take the session — which is what makes them different from everything the stack has added so far. Because they take the session, the host resolves them against the handle carrying the completed codec chains rather than against the context the components hook received. A bundle wrapping one itself would bind it to a chain missing every library in the call, including its own, and the failure would not surface until a decode somewhere else. So a bundle hands over the unwrapped value and lets the host wrap it. `RecordingTableFunction` in the example crate records the codec ids it was handed, which turns that claim into an assertion rather than a paragraph. Tables do not shadow. DataFusion refuses a duplicate table registration rather than replacing it, so a declared name already on the session is an error too, not just one two bundles both claim. Both are caught while resolving, alongside resolving the destination schema, so a bad name costs nothing. `_resolve_extension_tables` and `_install_extension_tables` keep the same split as the rules, with one honest exception: the insert goes through a `SchemaProvider`, and a foreign one can still refuse what it reported as free. Tables are therefore committed first, so nothing else has been written when that happens. The guide says so rather than claiming a guarantee that does not hold. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3a24768 commit 413b8c2

13 files changed

Lines changed: 535 additions & 31 deletions

File tree

crates/core/src/context.rs

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ use arrow::pyarrow::FromPyArrow;
2727
use datafusion::arrow::datatypes::{DataType, Schema, SchemaRef};
2828
use datafusion::arrow::pyarrow::PyArrowType;
2929
use datafusion::arrow::record_batch::RecordBatch;
30-
use datafusion::catalog::{CatalogProvider, CatalogProviderList, TableProviderFactory};
31-
use datafusion::common::{DFSchema, ScalarValue, TableReference, exec_err};
30+
use datafusion::catalog::{
31+
CatalogProvider, CatalogProviderList, SchemaProvider, TableProviderFactory,
32+
};
33+
use datafusion::common::{DFSchema, ScalarValue, TableReference, exec_datafusion_err, exec_err};
3234
use datafusion::datasource::file_format::file_compression_type::FileCompressionType;
3335
use datafusion::datasource::file_format::parquet::ParquetFormat;
3436
use datafusion::datasource::listing::{
@@ -1764,6 +1766,68 @@ impl PySessionContext {
17641766
Ok(())
17651767
}
17661768

1769+
/// Resolve the tables a `with_extensions` call declared.
1770+
///
1771+
/// The fallible half. Each provider is imported against `slf` — the handle
1772+
/// carrying the completed codec chains, not the context the components hook
1773+
/// was given — and each name is resolved to the schema that will hold it.
1774+
/// A name already taken is refused here, because DataFusion refuses a
1775+
/// duplicate registration rather than replacing it, and a refusal is much
1776+
/// more useful before anything has been written.
1777+
///
1778+
/// **Writes nothing.**
1779+
pub fn _resolve_extension_tables<'py>(
1780+
slf: &Bound<'py, Self>,
1781+
tables: Vec<(String, Bound<'py, PyAny>)>,
1782+
) -> PyDataFusionResult<PyResolvedTables> {
1783+
let session = slf.clone().into_bound_py_any(slf.py())?;
1784+
let state = slf.borrow().ctx.state();
1785+
1786+
let mut resolved = Vec::with_capacity(tables.len());
1787+
for (name, obj) in tables {
1788+
let provider = PyTable::new(obj, Some(session.clone()))?.table;
1789+
let reference = TableReference::from(name.as_str());
1790+
let table_name = reference.table().to_owned();
1791+
let schema = state.schema_for_ref(reference)?;
1792+
// Checked against the schema rather than against this call's own
1793+
// list, so a name the session already holds is caught too. Both
1794+
// are the same error to a caller.
1795+
if schema.table_exist(&table_name) {
1796+
return Err(exec_datafusion_err!(
1797+
"An extension declared a table named {name}, which is already registered"
1798+
)
1799+
.into());
1800+
}
1801+
resolved.push(ResolvedTable {
1802+
schema,
1803+
name: table_name,
1804+
provider,
1805+
});
1806+
}
1807+
Ok(PyResolvedTables { tables: resolved })
1808+
}
1809+
1810+
/// Commit the tables for a `with_extensions` call.
1811+
///
1812+
/// Runs first among the commit steps. Every name was resolved and found
1813+
/// free by [`Self::_resolve_extension_tables`], so the only way an insert
1814+
/// still fails is a foreign `SchemaProvider` refusing a registration it
1815+
/// reported as available — the one place in `with_extensions` that can
1816+
/// leave a call part-applied. Going first is what keeps the blast radius
1817+
/// to the tables themselves: no planner is bound and no function is
1818+
/// registered behind it.
1819+
pub fn _install_extension_tables(
1820+
&self,
1821+
resolved: PyRef<'_, PyResolvedTables>,
1822+
) -> PyDataFusionResult<()> {
1823+
for table in &resolved.tables {
1824+
table
1825+
.schema
1826+
.register_table(table.name.clone(), Arc::clone(&table.provider))?;
1827+
}
1828+
Ok(())
1829+
}
1830+
17671831
/// Import the physical optimizer rules a `with_extensions` call declared.
17681832
///
17691833
/// The fallible half of installing them, run while the call can still fail
@@ -1818,6 +1882,24 @@ impl PySessionContext {
18181882
}
18191883
}
18201884

1885+
/// Tables resolved for a `with_extensions` call.
1886+
///
1887+
/// Opaque to Python, and deliberately not added to the module, like
1888+
/// [`PyPhysicalOptimizerRules`]. Each entry is a provider that has already been
1889+
/// imported and a schema that has already been looked up, so committing is an
1890+
/// insert into a resolved destination rather than a fresh name resolution.
1891+
#[pyclass(name = "ResolvedTables", module = "datafusion._internal")]
1892+
pub struct PyResolvedTables {
1893+
tables: Vec<ResolvedTable>,
1894+
}
1895+
1896+
/// One entry of [`PyResolvedTables`]: where it goes, and what goes there.
1897+
struct ResolvedTable {
1898+
schema: Arc<dyn SchemaProvider>,
1899+
name: String,
1900+
provider: Arc<dyn TableProvider>,
1901+
}
1902+
18211903
/// Physical optimizer rules imported for a `with_extensions` call.
18221904
///
18231905
/// Opaque to Python, and deliberately not added to the module: it exists only

docs/source/extension-guide/bundles.md

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,23 +55,23 @@ class MyEngineExtension:
5555
return self._make_planner(ctx, fallback=fallback)
5656
```
5757

58-
Implement only the hooks you need. Codecs and functions both go in
58+
Implement only the hooks you need. Codecs, functions, and tables all go in
5959
`__datafusion_session_components__`, with the fields you do not use left empty,
6060
so a codec-only library and a function-only library each define that one alone;
6161
a library shipping nothing but an optimizing planner defines only
6262
`__datafusion_session_planner__`. The caller then writes:
6363

6464
```python
6565
ctx = SessionContext(config).with_extensions(lib_a.Extension(), lib_b.Extension())
66-
ctx.register_table("t", lib_a.TableProvider())
6766
```
6867

69-
Return your functions rather than calling `register_udf` on the `ctx` you were
70-
handed. Both put the function on the session, but a registration you make
71-
inside the hook is written the moment it runs — before the other bundles have
72-
been called, and not undone if one of them raises. What you declare is instead
73-
resolved and checked while a failure still costs nothing, then written once
74-
every bundle has succeeded. See {ref}`extension_bundles_transaction`.
68+
Declare what you contribute rather than calling `register_udf` or
69+
`register_table` on the `ctx` you were handed. Both put it on the session, but a
70+
registration you make inside the hook is written the moment it runs — before the
71+
other bundles have been called, too early to see their codecs, and not undone if
72+
one of them raises. What you declare is instead resolved and checked while a
73+
failure still costs nothing, then written once every bundle has succeeded. See
74+
{ref}`extension_bundles_transaction`.
7575

7676
`MyPlannerExtension` in [`datafusion-ffi-query-planner-example`] is a complete
7777
Rust implementation of the protocol, including taking the task-context provider
@@ -290,6 +290,24 @@ for direct ones. The wrapper travels with the codec; the bundle does not.
290290
The query planner is exempt — it carries no wire id, so it may be an object or
291291
a capsule.
292292

293+
(extension_bundles_binding)=
294+
295+
## What a component is resolved against
296+
297+
Components split in two by what their capsule getter asks for, and it decides
298+
where the host can resolve them:
299+
300+
- **Getters taking no argument** — the three function kinds and physical
301+
optimizer rules. Nothing is session-scoped, so a bundle may hand over either
302+
a wrapped object or the raw exportable.
303+
- **Getters taking the session or a codec** — table functions and table
304+
providers. These are resolved by the host against the *finished* handle,
305+
which is why you hand over the unwrapped value and a name rather than a
306+
{py:class}`~datafusion.user_defined.TableFunction` you built yourself.
307+
Wrapping one inside your components hook binds it to the context that hook
308+
received, which has none of the call's codecs — so it would capture a chain
309+
missing every library in the call, including your own.
310+
293311
(extension_bundles_collisions)=
294312

295313
## Two bundles claiming one name
@@ -328,6 +346,10 @@ Physical optimizer rules are exempt from all of this: they accumulate rather
328346
than replace, so two bundles contributing one each is the normal case and there
329347
is nothing to refuse. See {doc}`other-components`.
330348

349+
Tables go the other way. DataFusion refuses a duplicate table registration
350+
rather than replacing it, so a declared table name that is *already* on the
351+
session is an error too — a table cannot shadow one the way a function can.
352+
331353
Your caller cannot rename your function, so stay out of the way: prefix the
332354
names with something tied to your library.
333355

@@ -358,6 +380,14 @@ where the registry is complete; a planner is called per query, long after the
358380
install has finished. If you need a function at hook time, you already have the
359381
object, because you are the one declaring it.
360382

383+
Tables are the single exception to committing last, and they are committed
384+
first because of it. A declared table has its provider imported, its
385+
destination schema resolved, and a name already taken refused, all while a
386+
failure still costs nothing — but the insert itself goes through a
387+
`SchemaProvider`, and a foreign one can still refuse what it reported as free.
388+
Running that first means no planner is bound and no function is registered
389+
behind it when it does.
390+
361391
Like every other derivation, the returned context is a handle on the *same*
362392
session as the receiver — see {ref}`extension_sessions`. Only the Python-side
363393
codec chains belong to the returned handle; the planner is installed on the

docs/source/extension-guide/functions.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ the same registration methods.
3131
| `__datafusion_scalar_udf__` | scalar function | {py:func}`datafusion.udf` | {py:meth}`~datafusion.SessionContext.register_udf` | `udfs` |
3232
| `__datafusion_aggregate_udf__` | aggregate function | {py:func}`datafusion.udaf` | {py:meth}`~datafusion.SessionContext.register_udaf` | `udafs` |
3333
| `__datafusion_window_udf__` | window function | {py:func}`datafusion.udwf` | {py:meth}`~datafusion.SessionContext.register_udwf` | `udwfs` |
34-
| `__datafusion_table_function__` | function returning a table | {py:func}`datafusion.udtf` | {py:meth}`~datafusion.SessionContext.register_udtf` | |
34+
| `__datafusion_table_function__` | function returning a table | {py:func}`datafusion.udtf` | {py:meth}`~datafusion.SessionContext.register_udtf` | `udtfs`, as `(name, func)` |
3535

3636
All four are implemented in [`datafusion-ffi-example`], one per file. The last
3737
column is the {py:class}`~datafusion.SessionExtensionComponents` field a
@@ -128,6 +128,18 @@ Only literal expressions are supported as arguments. The Python side is
128128
described under
129129
{doc}`Table Functions <../user-guide/common-operations/udf-and-udfa>`.
130130

131+
Because that getter takes the session, a table function is declared on a bundle
132+
as a `(name, func)` pair and the host wraps it — not as a
133+
{py:class}`~datafusion.user_defined.TableFunction` you built, which would
134+
capture the codec chain from before the call:
135+
136+
```python
137+
return SessionExtensionComponents(udtfs=(("expand", my_library.MyTableFunction()),))
138+
```
139+
140+
The name is given here rather than read off the capsule, which is the other way
141+
this differs from the three above. See {ref}`extension_bundles_binding`.
142+
131143
## Serializing functions
132144

133145
A function that appears in a plan leaving the process has to be reconstructible

docs/source/extension-guide/table-providers.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ A schema provider is the one that does not register on the session: you reach a
3939
{py:meth}`SessionContext.catalog <datafusion.SessionContext.catalog>`, and
4040
register the schema on that.
4141

42+
If your library ships a table alongside anything else, declare it on your
43+
bundle as `table_providers` and let one call install everything:
44+
45+
```python
46+
return SessionExtensionComponents(table_providers=(("events", MyProvider()),))
47+
```
48+
49+
Hand over the provider itself, not a {py:class}`~datafusion.catalog.Table` you
50+
wrapped: `__datafusion_table_provider__` takes the session, and the one your
51+
components hook receives has none of the call's codecs yet. The host resolves
52+
it against the finished handle. See {ref}`extension_bundles_binding`.
53+
4254
Start with a table provider. Reach for the schema and catalog levels when your
4355
data source has its own namespace that should be browsable rather than
4456
registered table by table, and for the provider list only when your library is

docs/source/user-guide/extensions.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ ctx.register_table("events", my_engine.TableProvider("s3://bucket/events"))
6363
ctx.sql("SELECT count(*) FROM events").show()
6464
```
6565

66-
**Functions arrive by whichever route their library chose.** A library
67-
offering one or two functions hands you the functions themselves, and you wrap
68-
and register each:
66+
**Tables and functions arrive by whichever route their library chose.** A
67+
library offering one or two hands you the objects themselves, and you register
68+
each — a table as above, a function after wrapping it:
6969

7070
```python
7171
from datafusion import udf
@@ -75,8 +75,8 @@ ctx.register_udf(udf(my_library.MyScalarUDF()))
7575

7676
A library shipping a set of them packages them in its `Extension` object
7777
instead, so `with_extensions` installs them all along with everything else it
78-
provides, and there is nothing per-function for you to do. Its documentation
79-
says which.
78+
providesand the library picks the names — leaving nothing per-item for you to
79+
do. Its documentation says which.
8080

8181
`with_extensions` returns a context; use the returned one. It shares
8282
everything else with the context you called it on, so tables you registered

examples/datafusion-ffi-example/python/tests/_test_session_extension.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
import pyarrow as pa
2323
import pytest
2424
from datafusion import SessionContext, SessionExtensionComponents
25-
from datafusion_ffi_example import MyFunctionExtension, MyRuleExtension
25+
from datafusion_ffi_example import (
26+
MyDataExtension,
27+
MyFunctionExtension,
28+
MyLogicalExtensionCodec,
29+
MyRuleExtension,
30+
)
2631

2732

2833
def _session():
@@ -184,6 +189,54 @@ def __datafusion_session_planner__(self, ctx, fallback) -> None:
184189
assert rules.second_calls() == 0
185190

186191

192+
def test_declared_tables_and_table_functions_work():
193+
"""Both arrive as ``(name, value)`` pairs and both are queryable."""
194+
ctx = SessionContext().with_extensions(MyDataExtension())
195+
196+
assert ctx.sql("SELECT * FROM declared_table").collect()[0].num_rows == 2
197+
assert ctx.sql("SELECT * FROM declared_function()").collect()[0].num_rows > 0
198+
199+
200+
class _CodecBundle:
201+
"""Contributes a codec, so a later bundle's chain is observably different."""
202+
203+
def __datafusion_session_components__(self, ctx) -> SessionExtensionComponents:
204+
return SessionExtensionComponents(
205+
logical_extension_codecs=(MyLogicalExtensionCodec(),)
206+
)
207+
208+
209+
def test_a_declared_table_function_sees_the_finished_codec_chain():
210+
"""The claim that makes declaring a table function worth doing.
211+
212+
``__datafusion_table_function__`` takes the session and pulls the host's
213+
logical codec off it. A bundle wrapping one itself would hand it the
214+
context the components hook received, which has none of the call's codecs —
215+
so it would capture a chain missing every library in the call, including
216+
the one contributed *after* it here. The host resolves it against the
217+
finished handle instead, and this asserts the difference rather than
218+
describing it.
219+
"""
220+
data = MyDataExtension()
221+
ctx = SessionContext().with_extensions(data, _CodecBundle())
222+
223+
assert ctx.sql("SELECT * FROM declared_function()").collect()[0].num_rows > 0
224+
assert data.codec_ids_seen() == ctx.logical_extension_codec_ids()
225+
assert data.codec_ids_seen() != []
226+
227+
228+
def test_a_table_name_already_registered_is_refused():
229+
"""Tables cannot shadow, so a clash is caught before anything is written."""
230+
ctx = SessionContext()
231+
ctx.from_pydict({"a": [1]}, name="declared_table")
232+
233+
with pytest.raises(Exception, match=r"already registered"):
234+
ctx.with_extensions(MyFunctionExtension(), MyDataExtension())
235+
236+
with pytest.raises(KeyError):
237+
ctx.udf("my_custom_is_null")
238+
239+
187240
def test_the_hook_returns_the_components_type():
188241
"""The bundle builds a real dataclass, not a duck-typed stand-in.
189242

0 commit comments

Comments
 (0)