Skip to content

Commit 8ccb81d

Browse files
committed
feat(ux): add duckdb as the default backend
1 parent fa59d10 commit 8ccb81d

File tree

6 files changed

+129
-12
lines changed

6 files changed

+129
-12
lines changed

ibis/backends/dask/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def execute_with_scope(
169169
# computing anything *and* before associating leaf nodes with data. This
170170
# allows clients to provide their own data for each leaf.
171171
if clients is None:
172-
clients = expr._find_backends()
172+
clients, _ = expr._find_backends()
173173

174174
if aggcontext is None:
175175
aggcontext = agg_ctx.Summarize()

ibis/backends/pandas/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ def execute_with_scope(
205205
# computing anything *and* before associating leaf nodes with data. This
206206
# allows clients to provide their own data for each leaf.
207207
if clients is None:
208-
clients = expr._find_backends()
208+
clients, _ = expr._find_backends()
209209

210210
if aggcontext is None:
211211
aggcontext = agg_ctx.Summarize()

ibis/backends/tests/test_client.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import platform
2+
import re
23

34
import pandas as pd
45
import pandas.testing as tm
@@ -7,6 +8,7 @@
78
from pytest import mark, param
89

910
import ibis
11+
import ibis.common.exceptions as com
1012
import ibis.expr.datatypes as dt
1113
from ibis.util import guid
1214

@@ -661,3 +663,51 @@ def test_create_from_in_memory_table(con, t):
661663
finally:
662664
con.drop_table(tmp_name)
663665
assert tmp_name not in con.list_tables()
666+
667+
668+
def test_default_backend_no_duckdb(backend):
669+
# backend is used to ensure that this test runs in CI in the setting
670+
# where only the dependencies for a a given backend are installed
671+
672+
# if duckdb is available then this test won't fail and so we skip it
673+
try:
674+
import duckdb # noqa: F401
675+
676+
pytest.skip(
677+
"duckdb is installed; it will be used as the default backend"
678+
)
679+
except ImportError:
680+
pass
681+
682+
df = pd.DataFrame({'a': [1, 2, 3]})
683+
t = ibis.memtable(df)
684+
expr = t.a.sum()
685+
686+
# run this twice to ensure that we hit the optimizations in
687+
# `_default_backend`
688+
for _ in range(2):
689+
with pytest.raises(
690+
com.IbisError,
691+
match="Expression depends on no backends",
692+
):
693+
expr.execute()
694+
695+
696+
@pytest.mark.duckdb
697+
def test_default_backend():
698+
pytest.importorskip("duckdb")
699+
700+
df = pd.DataFrame({'a': [1, 2, 3]})
701+
t = ibis.memtable(df)
702+
expr = t.a.sum()
703+
# run this twice to ensure that we hit the optimizations in
704+
# `_default_backend`
705+
for _ in range(2):
706+
assert expr.execute() == df.a.sum()
707+
708+
sql = ibis.to_sql(expr)
709+
rx = """\
710+
SELECT
711+
SUM\\((\\w+)\\.a\\) AS sum
712+
FROM \\w+ AS \\1"""
713+
assert re.match(rx, sql) is not None

ibis/config.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,31 @@ def query_text_length_ge_zero(cls, query_text_length: int) -> int:
8686
return query_text_length
8787

8888

89+
_HAS_DUCKDB = True
90+
_DUCKDB_CON = None
91+
92+
93+
def _default_backend() -> Any:
94+
global _HAS_DUCKDB, _DUCKDB_CON
95+
96+
if not _HAS_DUCKDB:
97+
return None
98+
99+
if _DUCKDB_CON is not None:
100+
return _DUCKDB_CON
101+
102+
try:
103+
import duckdb as _ # noqa: F401
104+
except ImportError:
105+
_HAS_DUCKDB = False
106+
return None
107+
108+
import ibis
109+
110+
_DUCKDB_CON = ibis.duckdb.connect(":memory:")
111+
return _DUCKDB_CON
112+
113+
89114
class Options(BaseSettings):
90115
"""Ibis configuration options."""
91116

@@ -106,10 +131,15 @@ class Options(BaseSettings):
106131
default=False,
107132
description="Render expressions as GraphViz PNGs when running in a Jupyter notebook.", # noqa: E501
108133
)
134+
109135
default_backend: Any = Field(
110136
default=None,
111-
description="The default backend to use for execution.",
137+
description=(
138+
"The default backend to use for execution. "
139+
"Defaults to DuckDB if not set."
140+
),
112141
)
142+
113143
context_adjustment: ContextAdjustment = Field(
114144
default=ContextAdjustment(),
115145
description=ContextAdjustment.__doc__,

ibis/expr/types/core.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,13 @@
77

88
from public import public
99

10-
from ibis import config
1110
from ibis.common.exceptions import (
1211
ExpressionError,
1312
IbisError,
1413
IbisTypeError,
1514
TranslationError,
1615
)
16+
from ibis.config import _default_backend, options
1717
from ibis.expr.typing import TimeContext
1818
from ibis.util import UnnamedMarker, deprecated
1919

@@ -33,7 +33,7 @@ def __init__(self, arg: ops.Node) -> None:
3333
self._arg = arg
3434

3535
def __repr__(self) -> str:
36-
if not config.options.interactive:
36+
if not options.interactive:
3737
return self._repr()
3838

3939
try:
@@ -103,7 +103,7 @@ def _key(self) -> tuple[Hashable, ...]:
103103
return type(self), self._safe_name, self.op()
104104

105105
def _repr_png_(self) -> bytes | None:
106-
if config.options.interactive or not config.options.graphviz_repr:
106+
if options.interactive or not options.graphviz_repr:
107107
return None
108108
try:
109109
import ibis.expr.visualize as viz
@@ -189,14 +189,15 @@ def pipe(self, f, *args: Any, **kwargs: Any) -> Expr:
189189
def op(self) -> ops.Node:
190190
return self._arg
191191

192-
def _find_backends(self) -> list[BaseBackend]:
192+
def _find_backends(self) -> tuple[list[BaseBackend], bool]:
193193
"""Return the possible backends for an expression.
194194
195195
Returns
196196
-------
197197
list[BaseBackend]
198198
A list of the backends found.
199199
"""
200+
import ibis.expr.operations as ops
200201
from ibis.backends.base import BaseBackend
201202

202203
seen_backends: dict[
@@ -205,11 +206,13 @@ def _find_backends(self) -> list[BaseBackend]:
205206

206207
stack = [self.op()]
207208
seen = set()
209+
has_unbound = False
208210

209211
while stack:
210212
node = stack.pop()
211213

212214
if node not in seen:
215+
has_unbound |= isinstance(node, ops.UnboundTable)
213216
seen.add(node)
214217

215218
for arg in node.flat_args():
@@ -219,13 +222,36 @@ def _find_backends(self) -> list[BaseBackend]:
219222
elif isinstance(arg, Expr):
220223
stack.append(arg.op())
221224

222-
return list(seen_backends.values())
225+
return list(seen_backends.values()), has_unbound
223226

224-
def _find_backend(self) -> BaseBackend:
225-
backends = self._find_backends()
227+
def _find_backend(self, *, use_default: bool = False) -> BaseBackend:
228+
"""Find the backend attached to an expression.
229+
230+
Parameters
231+
----------
232+
use_default
233+
If [`True`][True] and the default backend isn't set, initialize the
234+
default backend and use that. This should only be set to `True` for
235+
`.execute()`. For other contexts such as compilation, this option
236+
doesn't make sense so the default value is [`False`][False].
237+
238+
Returns
239+
-------
240+
BaseBackend
241+
A backend that is attached to the expression
242+
"""
243+
backends, has_unbound = self._find_backends()
226244

227245
if not backends:
228-
default = config.options.default_backend
246+
if has_unbound:
247+
raise IbisError(
248+
"Expression contains unbound tables and therefore cannot "
249+
"be executed. Use ibis.<backend>.execute(expr) or "
250+
"assign a backend instance to "
251+
"`ibis.options.default_backend`."
252+
)
253+
if (default := options.default_backend) is None and use_default:
254+
default = _default_backend()
229255
if default is None:
230256
raise IbisError(
231257
'Expression depends on no backends, and found no default'
@@ -262,7 +288,7 @@ def execute(
262288
params
263289
Mapping of scalar parameter expressions to value
264290
"""
265-
return self._find_backend().execute(
291+
return self._find_backend(use_default=True).execute(
266292
self, limit=limit, timecontext=timecontext, params=params, **kwargs
267293
)
268294

ibis/tests/expr/test_table.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,3 +1551,14 @@ def test_memtable_filter():
15511551
t = ibis.memtable([(1, 2), (3, 4), (5, 6)], columns=["x", "y"])
15521552
expr = t.filter(t.x > 1)
15531553
assert expr.columns == ["x", "y"]
1554+
1555+
1556+
def test_default_backend_with_unbound_table():
1557+
t = ibis.table(dict(a="int"), name="t")
1558+
expr = t.a.sum()
1559+
1560+
with pytest.raises(
1561+
com.IbisError,
1562+
match="Expression contains unbound tables",
1563+
):
1564+
assert expr.execute()

0 commit comments

Comments
 (0)