Skip to content

Commit 4b91b0b

Browse files
gforsythcpcloud
authored andcommitted
feat: add unbind method to expressions
Calling `unbind()` on an expression returns an equivalent expression but with all references to backend-specific tables, e.g. `AlchemyTable`, `PandasTable`... translated into `UnboundTable`. Should help with serialization of expressions and ease execution of expressions across backends. Resolves #4536
1 parent 8eed1c9 commit 4b91b0b

File tree

3 files changed

+55
-0
lines changed

3 files changed

+55
-0
lines changed

ibis/backends/tests/test_api.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,19 @@ def test_limit_chain(alltypes, expr_fn):
8989
expr = expr_fn(alltypes)
9090
result = expr.execute()
9191
assert len(result) == 5
92+
93+
94+
@pytest.mark.parametrize(
95+
"expr_fn",
96+
[
97+
param(lambda t: t, id="alltypes table"),
98+
param(lambda t: t.join(t.view(), t.id == t.view().int_col), id="self join"),
99+
],
100+
)
101+
def test_unbind(alltypes, expr_fn):
102+
expr = expr_fn(alltypes)
103+
assert expr.unbind() != expr
104+
assert expr.unbind().schema() == expr.schema()
105+
106+
assert "Unbound" not in repr(expr)
107+
assert "Unbound" in repr(expr.unbind())

ibis/expr/analysis.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,38 @@ def fn(node):
214214
return substitute(fn, node)
215215

216216

217+
def substitute_unbound(node):
218+
"""Rewrite the input expression by replacing any table expressions with an
219+
equivalent unbound table."""
220+
assert isinstance(node, ops.Node), type(node)
221+
222+
def fn(node):
223+
if isinstance(node, ops.DatabaseTable):
224+
return ops.UnboundTable(name=node.name, schema=node.schema)
225+
elif isinstance(node, ops.TableColumn):
226+
# For table column references, in the event that we're on top of a
227+
# projection, we need to check whether the ref comes from the base
228+
# table schema or is a derived field. If we've projected out of
229+
# something other than a physical table, then lifting should not
230+
# occur
231+
table = node.table
232+
233+
if isinstance(table, ops.Selection):
234+
for val in table.selections:
235+
if isinstance(val, ops.PhysicalTable) and node.name in val.schema:
236+
return ops.TableColumn(val, node.name)
237+
elif isinstance(node, ops.Join):
238+
return node.__class__(
239+
substitute_unbound(node.left),
240+
substitute_unbound(node.right),
241+
map(substitute_unbound, node.predicates),
242+
)
243+
# keep looking for nodes to substitute
244+
return g.proceed
245+
246+
return substitute(fn, node)
247+
248+
217249
def get_mutation_exprs(exprs: list[ir.Expr], table: ir.Table) -> list[ir.Expr | None]:
218250
"""Given the list of exprs and the underlying table of a mutation op,
219251
return the exprs to use to instantiate the mutation."""

ibis/expr/types/core.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,13 @@ def to_pyarrow(
371371
self, params=params, limit=limit, **kwargs
372372
)
373373

374+
def unbind(self) -> ir.Table:
375+
"""Return equivalent expression built on `UnboundTable` instead of
376+
backend-specific table objects."""
377+
from ibis.expr.analysis import substitute_unbound
378+
379+
return substitute_unbound(self.op()).to_expr()
380+
374381

375382
unnamed = UnnamedMarker()
376383

0 commit comments

Comments
 (0)