Repository: ibis-project/ibis

Issue:
bug: alias() fails on an expression with parameters
### What happened?

```python
import ibis
import pandas as pd

df = pd.DataFrame({"x": ["a", "b", "c"], "y": [1, 2, 3]})
con = ibis.duckdb.connect()
con.create_table("t", ibis.memtable(df))
t = con.table("t")
t.mutate(x=t.x).alias("foo") # works
t.mutate(y=t.y).alias("foo") # works
t.mutate(x=t.x + "").alias("foo") # fails
t.mutate(y=t.y + 1).alias("foo") # fails
```

Results in:

```python traceback
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlalchemy/sql/elements.py:2045, in TextClause.bindparams(self, *binds, **names_to_values)
   2044 try:
-> 2045     existing = new_params[key]
   2046 except KeyError as err:

KeyError: 'param_1'

The above exception was the direct cause of the following exception:

ArgumentError                             Traceback (most recent call last)
Cell In[81], line 11
      9 t.mutate(y=t.y).alias("foo") # works
     10 # t.mutate(x=t.x + "").alias("foo") # fails
---> 11 t.mutate(y=t.y + 1).alias("foo") # fails

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/expr/types/relations.py:1296, in Table.alias(self, alias)
   1291 expr = ops.View(child=self, name=alias).to_expr()
   1293 # NB: calling compile is necessary so that any temporary views are
   1294 # created so that we can infer the schema without executing the entire
   1295 # query
-> 1296 expr.compile()
   1297 return expr

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/expr/types/core.py:299, in Expr.compile(self, limit, timecontext, params)
    275 def compile(
    276     self,
    277     limit: int | None = None,
    278     timecontext: TimeContext | None = None,
    279     params: Mapping[ir.Value, Any] | None = None,
    280 ):
    281     """Compile to an execution target.
    282 
    283     Parameters
   (...)
    297         Mapping of scalar parameter expressions to value
    298     """
--> 299     return self._find_backend().compile(
    300         self, limit=limit, timecontext=timecontext, params=params
    301     )

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/backends/base/sql/__init__.py:346, in BaseSQLBackend.compile(self, expr, limit, params, timecontext)
    319 def compile(
    320     self,
    321     expr: ir.Expr,
   (...)
    324     timecontext: TimeContext | None = None,
    325 ) -> Any:
    326     """Compile an Ibis expression.
    327 
    328     Parameters
   (...)
    344         backend.
    345     """
--> 346     return self.compiler.to_ast_ensure_limit(expr, limit, params=params).compile()

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/backends/base/sql/compiler/base.py:40, in QueryAST.compile(self)
     38 def compile(self):
     39     compiled_setup_queries = [q.compile() for q in self.setup_queries]
---> 40     compiled_queries = [q.compile() for q in self.queries]
     41     compiled_teardown_queries = [q.compile() for q in self.teardown_queries]
     42     return self.context.collapse(
     43         list(
     44             chain(
   (...)
     49         )
     50     )

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/backends/base/sql/compiler/base.py:40, in <listcomp>(.0)
     38 def compile(self):
     39     compiled_setup_queries = [q.compile() for q in self.setup_queries]
---> 40     compiled_queries = [q.compile() for q in self.queries]
     41     compiled_teardown_queries = [q.compile() for q in self.teardown_queries]
     42     return self.context.collapse(
     43         list(
     44             chain(
   (...)
     49         )
     50     )

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/backends/base/sql/alchemy/query_builder.py:151, in AlchemySelect.compile(self)
    147 self.context.set_query(self)
    149 self._compile_subqueries()
--> 151 frag = self._compile_table_set()
    152 steps = [
    153     self._add_select,
    154     self._add_group_by,
   (...)
    157     self._add_limit,
    158 ]
    160 for step in steps:

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/backends/base/sql/alchemy/query_builder.py:178, in AlchemySelect._compile_table_set(self)
    176 if self.table_set is not None:
    177     helper = self.table_set_formatter_class(self, self.table_set)
--> 178     result = helper.get_result()
    179     if isinstance(result, sql.selectable.Select):
    180         return result.subquery()

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/backends/base/sql/alchemy/query_builder.py:39, in _AlchemyTableSetFormatter.get_result(self)
     37     self._walk_join_tree(op)
     38 else:
---> 39     self.join_tables.append(self._format_table(op))
     41 result = self.join_tables[0]
     42 for jtype, table, preds in zip(
     43     self.join_types, self.join_tables[1:], self.join_predicates
     44 ):

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/backends/base/sql/alchemy/query_builder.py:106, in _AlchemyTableSetFormatter._format_table(self, op)
    101     result = sa.table(
    102         ref_op.name,
    103         *_schema_to_sqlalchemy_columns(ref_op.schema),
    104     )
    105     backend = child_expr._find_backend()
--> 106     backend._create_temp_view(view=result, definition=definition)
    107 elif isinstance(ref_op, ops.InMemoryTable):
    108     columns = _schema_to_sqlalchemy_columns(ref_op.schema)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/ibis/backends/base/sql/alchemy/__init__.py:547, in BaseAlchemyBackend._create_temp_view(self, view, definition)
    545 compiled = definition.compile()
    546 defn = self._get_temp_view_definition(name, definition=compiled)
--> 547 query = sa.text(defn).bindparams(**compiled.params)
    548 self.con.execute(query)
    549 self._temp_views.add(raw_name)

File <string>:2, in bindparams(self, *binds, **names_to_values)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlalchemy/sql/base.py:110, in _generative.<locals>._generative(fn, self, *args, **kw)
    107 """Mark a method as generative."""
    109 self = self._generate()
--> 110 x = fn(self, *args, **kw)
    111 assert x is None, "generative methods must have no return value"
    112 return self

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlalchemy/sql/elements.py:2047, in TextClause.bindparams(self, *binds, **names_to_values)
   2045     existing = new_params[key]
   2046 except KeyError as err:
-> 2047     util.raise_(
   2048         exc.ArgumentError(
   2049             "This text() construct doesn't define a "
   2050             "bound parameter named %r" % key
   2051         ),
   2052         replace_context=err,
   2053     )
   2054 else:
   2055     new_params[key] = existing._with_value(value, required=False)

File ~/Library/Application Support/hatch/env/virtual/noatak-UM6-FHel/noatak/lib/python3.10/site-packages/sqlalchemy/util/compat.py:211, in raise_(***failed resolving arguments***)
    208     exception.__cause__ = replace_context
    210 try:
--> 211     raise exception
    212 finally:
    213     # credit to
    214     # https://cosmicpercolator.com/2016/01/13/exception-leaks-in-python-2-and-3/
    215     # as the __traceback__ object creates a cycle
    216     del exception, replace_context, from_, with_traceback

ArgumentError: This text() construct doesn't define a bound parameter named 'param_1'
```

It seems to be independent of the type of the column that's modified.

### What version of ibis are you using?

4.0.0

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

DuckDB

### Relevant log output

_No response_

### Code of Conduct

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


Discussion:
Actually, looking into the debugger more at https://github.com/ibis-project/ibis/blob/9aa963af76da4e7d50243ee21aebeeae51be60fe/ibis/backends/base/sql/alchemy/__init__.py#L535-L550
I found that on the error that `defn` was `"CREATE OR REPLACE TEMPORARY VIEW "foo" AS SELECT concat(t0.x, CAST(? AS TEXT)) AS x, t0.y \nFROM t AS t0"` and `compiled.params` was `{'param_1': ''}`, so the error is coming from `sa.text(defn).bindparams(**compiled.params)` when we fail at binding that parameter. So Ii doesn't have to do with mutating, just whenever there is a parameter getting injected into the interior sql.
Thanks again for the report, keep 'em coming!
I believe this may be a bug in `duckdb_engine`, or perhaps our use of it:

```
In [37]: import sqlalchemy as sa

In [38]: eng = sa.create_engine('duckdb:///')

In [39]: t = sa.Table('t', sa.MetaData(bind=eng), sa.Column('c', sa.INT()))

In [40]: print(sa.select(t.c.c + 1).compile())
SELECT t.c + ? AS anon_1
FROM t

In [41]: sa.select(t.c.c + 1).compile().params
Out[41]: {'c_1': 1}
```

cc @Mause if you might have any ideas here.
A temporary workaround would also be greatly appreciated!
I am looking to see whether we can implement this on the ibis side in the meantime.

Resolving patch:
diff --git a/ibis/backends/base/sql/alchemy/__init__.py b/ibis/backends/base/sql/alchemy/__init__.py
--- a/ibis/backends/base/sql/alchemy/__init__.py
+++ b/ibis/backends/base/sql/alchemy/__init__.py
@@ -3,7 +3,7 @@
 import contextlib
 import getpass
 from operator import methodcaller
-from typing import TYPE_CHECKING, Any, Literal
+from typing import TYPE_CHECKING, Any, Literal, Mapping
 
 import sqlalchemy as sa
 
@@ -532,19 +532,25 @@ def _get_temp_view_definition(
     def _register_temp_view_cleanup(self, name: str, raw_name: str) -> None:
         pass
 
-    def _create_temp_view(
+    def _get_compiled_statement(
         self,
-        view: sa.Table,
         definition: sa.sql.Selectable,
-    ) -> None:
+        name: str,
+        compile_kwargs: Mapping[str, Any] | None = None,
+    ):
+        if compile_kwargs is None:
+            compile_kwargs = {}
+        compiled = definition.compile(compile_kwargs=compile_kwargs)
+        defn = self._get_temp_view_definition(name, definition=compiled)
+        return defn, compiled.params
+
+    def _create_temp_view(self, view: sa.Table, definition: sa.sql.Selectable) -> None:
         raw_name = view.name
         if raw_name not in self._temp_views and raw_name in self.list_tables():
             raise ValueError(f"{raw_name} already exists as a table or view")
-
-        name = self.con.dialect.identifier_preparer.quote_identifier(raw_name)
-        compiled = definition.compile()
-        defn = self._get_temp_view_definition(name, definition=compiled)
-        query = sa.text(defn).bindparams(**compiled.params)
-        self.con.execute(query)
+        name = self.con.dialect.identifier_preparer.quote_identifier(view.name)
+        compiled, params = self._get_compiled_statement(definition, name)
+        with self.begin() as con:
+            con.execute(compiled, **params)
         self._temp_views.add(raw_name)
         self._register_temp_view_cleanup(name, raw_name)
diff --git a/ibis/backends/duckdb/__init__.py b/ibis/backends/duckdb/__init__.py
--- a/ibis/backends/duckdb/__init__.py
+++ b/ibis/backends/duckdb/__init__.py
@@ -527,3 +527,9 @@ def _get_temp_view_definition(
         definition: sa.sql.compiler.Compiled,
     ) -> str:
         return f"CREATE OR REPLACE TEMPORARY VIEW {name} AS {definition}"
+
+    def _get_compiled_statement(self, view: sa.Table, definition: sa.sql.Selectable):
+        # TODO: remove this once duckdb supports CTAS prepared statements
+        return super()._get_compiled_statement(
+            view, definition, compile_kwargs={"literal_binds": True}
+        )


Test patch:
diff --git a/ibis/backends/tests/test_dot_sql.py b/ibis/backends/tests/test_dot_sql.py
--- a/ibis/backends/tests/test_dot_sql.py
+++ b/ibis/backends/tests/test_dot_sql.py
@@ -192,3 +192,16 @@ def test_table_dot_sql_does_not_clobber_existing_tables(con):
     finally:
         con.drop_table(name, force=True)
         assert name not in con.list_tables()
+
+
+@table_dot_sql_notimpl
+@dot_sql_notimpl
+@dot_sql_notyet
+@dot_sql_never
+@pytest.mark.notimpl(["trino"])
+def test_dot_sql_alias_with_params(backend, alltypes, df):
+    t = alltypes
+    x = t.select(x=t.string_col + " abc").alias("foo")
+    result = x.execute()
+    expected = df.string_col.add(" abc").rename("x")
+    backend.assert_series_equal(result.x, expected)