Skip to content

Commit fa22701

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 74edaff commit fa22701

13 files changed

Lines changed: 502 additions & 20 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: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,13 @@ caller then writes:
6262

6363
```python
6464
ctx = SessionContext(config).with_extensions(lib_a.Extension(), lib_b.Extension())
65-
ctx.register_table("t", lib_a.TableProvider())
6665
```
6766

68-
Declare functions rather than registering them yourself inside the hook.
69-
Declared components are resolved before anything is written, and they are
70-
registered after every codec is installed; a registration you make during the
71-
hook happens too early to see the other bundles' codecs and is not undone if a
72-
later extension fails. Table providers are still registered by the caller, on
73-
the returned handle — see {ref}`extension_bundles_transaction`.
67+
Declare what you contribute rather than registering it yourself inside the
68+
hook. Declared components are resolved before anything is written, and they go
69+
on after every codec is installed; a registration you make during the hook
70+
happens too early to see the other bundles' codecs and is not undone if a later
71+
extension fails. See {ref}`extension_bundles_transaction`.
7472

7573
`MyPlannerExtension` in [`datafusion-ffi-query-planner-example`] is a complete
7674
Rust implementation of the protocol, including taking the task-context provider
@@ -321,6 +319,31 @@ shares one session with the receiver, and undoing a registration is not the
321319
same as restoring what it displaced: deregistering a function that shadowed a
322320
built-in removes the built-in too.
323321

322+
Tables are the single exception, and they are committed first because of it. A
323+
declared table has its provider imported and its destination schema resolved in
324+
step 3, and a name already taken is refused there — but the insert itself goes
325+
through a `SchemaProvider`, and a foreign one can still refuse what it reported
326+
as free. Running that first means no planner is bound and no function is
327+
registered behind it when it does.
328+
329+
(extension_bundles_binding)=
330+
331+
### What a component is resolved against
332+
333+
Components split in two by what their capsule getter asks for, and it decides
334+
where the host can resolve them:
335+
336+
- **Getters taking no argument** — the three function kinds and physical
337+
optimizer rules. Nothing is session-scoped, so a bundle may hand over either
338+
a wrapped object or the raw exportable.
339+
- **Getters taking the session or a codec** — table functions and table
340+
providers. These are resolved by the host against the *finished* handle, in
341+
step 3, which is why you hand over the unwrapped value and a name rather than
342+
a {py:class}`~datafusion.user_defined.TableFunction` you built yourself.
343+
Wrapping one inside your components hook binds it to the context that hook
344+
received, which has none of the call's codecs — so it would capture a chain
345+
missing every library in the call, including your own.
346+
324347
(extension_bundles_collisions)=
325348

326349
### Two bundles claiming one name
@@ -340,6 +363,10 @@ Physical optimizer rules are exempt: they accumulate rather than replace, so
340363
two bundles contributing one each is the normal case and there is nothing to
341364
refuse. See {doc}`other-components`.
342365

366+
Tables go the other way. DataFusion refuses a duplicate table registration
367+
rather than replacing it, so a declared table name that is *already* on the
368+
session is an error too — a table cannot shadow one the way a function can.
369+
343370
Like every other derivation, the returned context is a handle on the *same*
344371
session as the receiver — see {ref}`extension_sessions`. Only the Python-side
345372
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.
3737

@@ -107,6 +107,18 @@ Only literal expressions are supported as arguments. The Python side is
107107
described under
108108
{doc}`Table Functions <../user-guide/common-operations/udf-and-udfa>`.
109109

110+
Because that getter takes the session, a table function is declared on a bundle
111+
as a `(name, func)` pair and the host wraps it — not as a
112+
{py:class}`~datafusion.user_defined.TableFunction` you built, which would
113+
capture the codec chain from before the call:
114+
115+
```python
116+
return SessionExtensionComponents(udtfs=(("expand", my_library.MyTableFunction()),))
117+
```
118+
119+
The name is given here rather than read off the capsule, which is the other way
120+
this differs from the three above. See {ref}`extension_bundles_binding`.
121+
110122
## Serializing functions
111123

112124
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: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,10 @@ ctx.sql("SELECT count(*) FROM events").show()
6767
everything else with the context you called it on, so tables you registered
6868
before the call are still there.
6969

70-
**Functions can arrive either way.** A single function is registered directly
71-
with {py:func}`~datafusion.udf` and
72-
{py:meth}`~datafusion.SessionContext.register_udf`. A library shipping a set of
73-
them usually packages them in the same `Extension` object instead, so
74-
`with_extensions` installs them along with everything else it provides. Follow
70+
**Tables and functions can arrive either way.** A single one is registered
71+
directly, as above. A library shipping a set of them usually packages them in
72+
the same `Extension` object instead, so `with_extensions` installs them along
73+
with everything else it provides — and the library picks the names. Follow
7574
whichever the library documents.
7675

7776
## Using more than one library

‎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)