Repository: ibis-project/ibis

Issue:
feat: unbind expression
I've run into the situation a few times where I have an existing Ibis expression that I pass to some function and it _would_ work except that I am not using unbound tables and so that operation fails.

This just happened to me with @kszucs 's decompiler PR, but also happens when using Ibis-substrait.  

I also think that, outside of those possibly uncommon use-cases, often we have a schema already defined in a backend somewhere and we want the equivalent unbound table -- this isn't terribly hard to do, but it's annoying if you don't notice until you are several steps deep.

So, how about an `unbind()` for Expressions that returns the same expression but with `UnboundTable` in place of `DatabaseTable`

Then `con.execute(expr.unbind()) == expr.execute()`

Some other thoughts / ideas / edge-cases:
What to do with `memtable`?  Should it return the unbound expression AND the data underlying the memtable?

Should all of the backends return either data or sufficient metadata to reload the table in question?
(That should probably be behind a feature flag, I think, or the `execute(expr.unbind())` thing falls down.


cc @saulpw @cpcloud 


Discussion:
For those backends that have `parse_type` defined (a bunch of them...?)  this should be walking the expression graph and subbing in unbound tables a la:

```
def unbound_from_bound(table):
    return ibis.table(
        list(zip(table.columns, map(parse_type, table.dtypes))), name=table.alias
    )
```
I like the idea, also it's kinda a prerequisite to properly serialize a bound expression. 

Lowering a `DatabaseTable` should be straightforward since we know its name and its ibis schema already. Regarding memtables I don't think we can do anything else than leaving them as memtables - they are bound to no backends.

Something like the following could work (haven't tried it):

```py
import ibis.expr.analysis as an

def unbind(op):
    if isinstance(op, ops.DatabaseTable):
        return ops.UnboundTable(name=op.name, schema=op.schema)
    else:
        return op

unbound_node = an.substitute(unbind, bound_node)
```

Resolving patch:
diff --git a/ibis/expr/analysis.py b/ibis/expr/analysis.py
--- a/ibis/expr/analysis.py
+++ b/ibis/expr/analysis.py
@@ -214,6 +214,38 @@ def fn(node):
     return substitute(fn, node)
 
 
+def substitute_unbound(node):
+    """Rewrite the input expression by replacing any table expressions with an
+    equivalent unbound table."""
+    assert isinstance(node, ops.Node), type(node)
+
+    def fn(node):
+        if isinstance(node, ops.DatabaseTable):
+            return ops.UnboundTable(name=node.name, schema=node.schema)
+        elif isinstance(node, ops.TableColumn):
+            # For table column references, in the event that we're on top of a
+            # projection, we need to check whether the ref comes from the base
+            # table schema or is a derived field. If we've projected out of
+            # something other than a physical table, then lifting should not
+            # occur
+            table = node.table
+
+            if isinstance(table, ops.Selection):
+                for val in table.selections:
+                    if isinstance(val, ops.PhysicalTable) and node.name in val.schema:
+                        return ops.TableColumn(val, node.name)
+        elif isinstance(node, ops.Join):
+            return node.__class__(
+                substitute_unbound(node.left),
+                substitute_unbound(node.right),
+                map(substitute_unbound, node.predicates),
+            )
+        # keep looking for nodes to substitute
+        return g.proceed
+
+    return substitute(fn, node)
+
+
 def get_mutation_exprs(exprs: list[ir.Expr], table: ir.Table) -> list[ir.Expr | None]:
     """Given the list of exprs and the underlying table of a mutation op,
     return the exprs to use to instantiate the mutation."""
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
@@ -371,6 +371,13 @@ def to_pyarrow(
             self, params=params, limit=limit, **kwargs
         )
 
+    def unbind(self) -> ir.Table:
+        """Return equivalent expression built on `UnboundTable` instead of
+        backend-specific table objects."""
+        from ibis.expr.analysis import substitute_unbound
+
+        return substitute_unbound(self.op()).to_expr()
+
 
 unnamed = UnnamedMarker()
 


Test patch:
diff --git a/ibis/backends/tests/test_api.py b/ibis/backends/tests/test_api.py
--- a/ibis/backends/tests/test_api.py
+++ b/ibis/backends/tests/test_api.py
@@ -89,3 +89,19 @@ def test_limit_chain(alltypes, expr_fn):
     expr = expr_fn(alltypes)
     result = expr.execute()
     assert len(result) == 5
+
+
+@pytest.mark.parametrize(
+    "expr_fn",
+    [
+        param(lambda t: t, id="alltypes table"),
+        param(lambda t: t.join(t.view(), t.id == t.view().int_col), id="self join"),
+    ],
+)
+def test_unbind(alltypes, expr_fn):
+    expr = expr_fn(alltypes)
+    assert expr.unbind() != expr
+    assert expr.unbind().schema() == expr.schema()
+
+    assert "Unbound" not in repr(expr)
+    assert "Unbound" in repr(expr.unbind())