Repository: ibis-project/ibis

Issue:
feat(ux): expose `_find_backend` as a public method on TableExpr
### Is your feature request related to a problem?

Before `ibis.read` existed, there wasn't a way to get a table expression without either making a connection to a backend, or creating an unbound table.

Now though, it's possible to get a TableExpr from `read` without knowing which backend you are running on.  For now, this is almost always `duckdb`, but that may change.

### Describe the solution you'd like

I think we could provide a public version of `_find_backend` -- more akin to the `name` attribute on connections, so that new users can check what backend their table expression is running against.

### What version of ibis are you running?

master

### What backend(s) are you using, if any?

_No response_

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
I'd expect this to be a relatively rare operation - perhaps a new top-level function would be better than a method on all expressions (to avoid cluttering the expression namespace). What about something like:

```python
def get_backend(expr: ir.Expr | None = None) -> BaseBackend:
    """Get the backend for an expression, or the default backend if no expr passed or no backend bound on the expr"""
    ...
```
that makes sense to me
My usecase for this:

I have an external data file `names.csv` which contains a list of ~150k valid names. I have a function `def is_name(input: StringColumn) -> BooleanColumn` that checks if the input is contained in that csv. To do that efficiently I need to load the csv file into the database, so I can then do a check using a JOIN, since `input.isin(<python list>)` is very slow. To load it in the database, I do

```python
be = input._find_backend()
names = be.read_csv(NAMES_PATH)
...joining...
```

Resolving patch:
diff --git a/ibis/expr/api.py b/ibis/expr/api.py
--- a/ibis/expr/api.py
+++ b/ibis/expr/api.py
@@ -20,7 +20,7 @@
 import ibis.expr.rules as rlz
 import ibis.expr.schema as sch
 import ibis.expr.types as ir
-from ibis.backends.base import connect
+from ibis.backends.base import BaseBackend, connect
 from ibis.expr.decompile import decompile
 from ibis.expr.deferred import Deferred
 from ibis.expr.schema import Schema
@@ -118,6 +118,7 @@
     'geo_y',
     'geo_y_max',
     'geo_y_min',
+    'get_backend',
     'greatest',
     'ifelse',
     'infer_dtype',
@@ -869,6 +870,27 @@ def read_parquet(sources: str | Path, **kwargs: Any) -> ir.Table:
     return con.read_parquet(sources, **kwargs)
 
 
+def get_backend(expr: Expr | None = None) -> BaseBackend:
+    """Get the current Ibis backend to use for a given expression.
+
+    Parameters
+    ----------
+    expr
+        An expression to get the backend from. If not passed, the default
+        backend is returned.
+
+    Returns
+    -------
+    BaseBackend
+        The Ibis backend.
+    """
+    if expr is None:
+        from ibis.config import _default_backend
+
+        return _default_backend()
+    return expr._find_backend(use_default=True)
+
+
 e = ops.E().to_expr()
 
 pi = ops.Pi().to_expr()
diff --git a/ibis/expr/types/core.py b/ibis/expr/types/core.py
--- a/ibis/expr/types/core.py
+++ b/ibis/expr/types/core.py
@@ -227,8 +227,7 @@ def _find_backend(self, *, use_default: bool = False) -> BaseBackend:
                     "assign a backend instance to "
                     "`ibis.options.default_backend`."
                 )
-            if (default := options.default_backend) is None and use_default:
-                default = _default_backend()
+            default = _default_backend() if use_default else None
             if default is None:
                 raise IbisError(
                     'Expression depends on no backends, and found no default'
@@ -236,7 +235,7 @@ def _find_backend(self, *, use_default: bool = False) -> BaseBackend:
             return default
 
         if len(backends) > 1:
-            raise ValueError('Multiple backends found')
+            raise IbisError('Multiple backends found for this expression')
 
         return backends[0]
 
@@ -330,7 +329,7 @@ def to_pyarrow_batches(
         results
             RecordBatchReader
         """
-        return self._find_backend().to_pyarrow_batches(
+        return self._find_backend(use_default=True).to_pyarrow_batches(
             self,
             params=params,
             limit=limit,
@@ -366,7 +365,7 @@ def to_pyarrow(
         Table
             A pyarrow table holding the results of the executed expression.
         """
-        return self._find_backend().to_pyarrow(
+        return self._find_backend(use_default=True).to_pyarrow(
             self, params=params, limit=limit, **kwargs
         )
 


Test patch:
diff --git a/ibis/backends/tests/test_client.py b/ibis/backends/tests/test_client.py
--- a/ibis/backends/tests/test_client.py
+++ b/ibis/backends/tests/test_client.py
@@ -862,3 +862,16 @@ def test_has_operation_no_geo(con):
     """
     for op in [ops.GeoDistance, ops.GeoAsText, ops.GeoUnaryUnion]:
         assert not con.has_operation(op)
+
+
+def test_get_backend(con, alltypes, monkeypatch):
+    assert ibis.get_backend(alltypes) is con
+    assert ibis.get_backend(alltypes.id.min()) is con
+
+    with pytest.raises(com.IbisError, match="contains unbound tables"):
+        ibis.get_backend(ibis.table({"x": "int"}))
+
+    monkeypatch.setattr(ibis.options, "default_backend", con)
+    assert ibis.get_backend() is con
+    expr = ibis.literal(1) + 2
+    assert ibis.get_backend(expr) is con
diff --git a/ibis/tests/expr/test_table.py b/ibis/tests/expr/test_table.py
--- a/ibis/tests/expr/test_table.py
+++ b/ibis/tests/expr/test_table.py
@@ -1473,10 +1473,7 @@ def test_multiple_db_different_backends():
     backend2_table = con2.table('alltypes')
 
     expr = backend1_table.union(backend2_table)
-    with pytest.raises(
-        ValueError,
-        match=re.compile("multiple backends", flags=re.IGNORECASE),
-    ):
+    with pytest.raises(com.IbisError, match="Multiple backends"):
         expr.compile()