Repository: ibis-project/ibis

Issue:
bug(sqlalchemy): `con.sql` in interactive mode fails
### What happened?

Looking at

```python
import ibis
con = ibis.duckdb.connect()
con.sql("select cast('2012-01-31' as timestamp_ns)")
```

from #7412, I was unable to run that code in interactive mode.

Here's the code I ran:

```
In [1]: from ibis.interactive import *

In [2]: import ibis

In [3]: con = ibis.duckdb.connect()

In [4]: con.sql("select cast('2012-01-31' as timestamp_ns)")
```

If I slap an `execute()` call on the end of that, that runs:

```
In [1]: from ibis.interactive import *

In [2]: import ibis

In [3]: con = ibis.duckdb.connect()

In [4]: con.sql("select cast('2012-01-31' as timestamp_ns)").execute()
Out[4]:
  CAST('2012-01-31' AS TIMESTAMP_NS)
0                         2012-01-31
```

### What version of ibis are you using?

`307f88110c88fd9c690fb2c9bf9c596539c2d222` which is from my `duckdb-timestamp-types` branch (created for the fix in #7413).

### What backend(s) are you using, if any?

DuckDB

### Relevant log output

```sh
AttributeError                            Traceback (most recent call last)
File /nix/store/2szj82zc4vkdgnya9n28hbs952vrg3d4-python3-3.10.12-env/lib/python3.10/site-packages/IPython/core/formatters.py:708, in PlainTextFormatter.__call__(self, obj)
    701 stream = StringIO()
    702 printer = pretty.RepresentationPrinter(stream, self.verbose,
    703     self.max_width, self.newline,
    704     max_seq_length=self.max_seq_length,
    705     singleton_pprinters=self.singleton_printers,
    706     type_pprinters=self.type_printers,
    707     deferred_pprinters=self.deferred_printers)
--> 708 printer.pretty(obj)
    709 printer.flush()
    710 return stream.getvalue()

File /nix/store/2szj82zc4vkdgnya9n28hbs952vrg3d4-python3-3.10.12-env/lib/python3.10/site-packages/IPython/lib/pretty.py:410, in RepresentationPrinter.pretty(self, obj)
    407                         return meth(obj, self, cycle)
    408                 if cls is not object \
    409                         and callable(cls.__dict__.get('__repr__')):
--> 410                     return _repr_pprint(obj, self, cycle)
    412     return _default_pprint(obj, self, cycle)
    413 finally:

File /nix/store/2szj82zc4vkdgnya9n28hbs952vrg3d4-python3-3.10.12-env/lib/python3.10/site-packages/IPython/lib/pretty.py:778, in _repr_pprint(obj, p, cycle)
    776 """A pprint that just redirects to the normal repr function."""
    777 # Find newlines and replace them with p.break_()
--> 778 output = repr(obj)
    779 lines = output.splitlines()
    780 with p.group():

File ~/src/ibis/ibis/expr/types/core.py:82, in Expr.__repr__(self)
     80 with simple_console.capture() as capture:
     81     try:
---> 82         simple_console.print(self)
     83     except TranslationError as e:
     84         lines = [
     85             "Translation to backend failed",
     86             f"Error message: {e.args[0]}",
     87             "Expression repr follows:",
     88             self._repr(),
     89         ]

File /nix/store/2szj82zc4vkdgnya9n28hbs952vrg3d4-python3-3.10.12-env/lib/python3.10/site-packages/rich/console.py:1700, in Console.print(self, sep, end, style, justify, overflow, no_wrap, emoji, markup, highlight, width, height, crop, soft_wrap, new_line_start, *objects)
   1698 if style is None:
   1699     for renderable in renderables:
-> 1700         extend(render(renderable, render_options))
   1701 else:
   1702     for renderable in renderables:

File /nix/store/2szj82zc4vkdgnya9n28hbs952vrg3d4-python3-3.10.12-env/lib/python3.10/site-packages/rich/console.py:1312, in Console.render(self, renderable, options)
   1310 renderable = rich_cast(renderable)
   1311 if hasattr(renderable, "__rich_console__") and not isclass(renderable):
-> 1312     render_iterable = renderable.__rich_console__(self, _options)  # type: ignore[union-attr]
   1313 elif isinstance(renderable, str):
   1314     text_renderable = self.render_str(
   1315         renderable, highlight=_options.highlight, markup=_options.markup
   1316     )

File ~/src/ibis/ibis/expr/types/core.py:54, in Expr.__rich_console__(self, console, options)
     51     from rich.text import Text
     53     return console.render(Text(self._repr()), options=options)
---> 54 return self.__interactive_rich_console__(console, options)

File ~/src/ibis/ibis/expr/types/relations.py:357, in Table.__interactive_rich_console__(self, console, options)
    354 else:
    355     width = options.max_width
--> 357 table = to_rich_table(self, width)
    358 return console.render(table, options=options)

File ~/src/ibis/ibis/expr/types/pretty.py:258, in to_rich_table(table, console_width)
    256 # Compute the data and return a pandas dataframe
    257 nrows = ibis.options.repr.interactive.max_rows
--> 258 result = table.limit(nrows + 1).to_pyarrow()
    260 # Now format the columns in order, stopping if the console width would
    261 # be exceeded.
    262 col_info = []

File ~/src/ibis/ibis/expr/types/core.py:421, in Expr.to_pyarrow(self, params, limit, **kwargs)
    393 @experimental
    394 def to_pyarrow(
    395     self,
   (...)
    399     **kwargs: Any,
    400 ) -> pa.Table:
    401     """Execute expression and return results in as a pyarrow table.
    402
    403     This method is eager and will execute the associated expression
   (...)
    419         A pyarrow table holding the results of the executed expression.
    420     """
--> 421     return self._find_backend(use_default=True).to_pyarrow(
    422         self, params=params, limit=limit, **kwargs
    423     )

File ~/src/ibis/ibis/backends/duckdb/__init__.py:922, in Backend.to_pyarrow(self, expr, params, limit, **_)
    915 query_ast = self.compiler.to_ast_ensure_limit(expr, limit, params=params)
    917 # We use `.sql` instead of `.execute` below for performance - in
    918 # certain cases duckdb query -> arrow table can be significantly faster
    919 # in this configuration. Currently `.sql` doesn't support parametrized
    920 # queries, so we need to compile with literal_binds for now.
    921 sql = str(
--> 922     query_ast.compile().compile(
    923         dialect=self.con.dialect, compile_kwargs={"literal_binds": True}
    924     )
    925 )
    927 with self.begin() as con:
    928     table = con.connection.sql(sql).to_arrow_table()

File ~/src/ibis/ibis/backends/base/sql/compiler/base.py:37, in QueryAST.compile(self)
     35 def compile(self):
     36     compiled_setup_queries = [q.compile() for q in self.setup_queries]
---> 37     compiled_queries = [q.compile() for q in self.queries]
     38     compiled_teardown_queries = [q.compile() for q in self.teardown_queries]
     39     return self.context.collapse(
     40         list(
     41             chain(
   (...)
     46         )
     47     )

File ~/src/ibis/ibis/backends/base/sql/compiler/base.py:37, in <listcomp>(.0)
     35 def compile(self):
     36     compiled_setup_queries = [q.compile() for q in self.setup_queries]
---> 37     compiled_queries = [q.compile() for q in self.queries]
     38     compiled_teardown_queries = [q.compile() for q in self.teardown_queries]
     39     return self.context.collapse(
     40         list(
     41             chain(
   (...)
     46         )
     47     )

File ~/src/ibis/ibis/backends/base/sql/alchemy/query_builder.py:216, in AlchemySelect.compile(self)
    207 steps = [
    208     self._add_select,
    209     self._add_group_by,
   (...)
    212     self._add_limit,
    213 ]
    215 for step in steps:
--> 216     frag = step(frag)
    218 return frag

File ~/src/ibis/ibis/backends/base/sql/alchemy/query_builder.py:368, in AlchemySelect._add_limit(self, fragment)
    361     n = (
    362         sa.select(self._translate(n))
    363         .select_from(frag.subquery())
    364         .scalar_subquery()
    365     )
    367 if n is not None:
--> 368     fragment = fragment.limit(n)
    370 offset = self.limit.offset
    372 if not isinstance(offset, int):

AttributeError: 'TextualSelect' object has no attribute 'limit'
```


### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:

Resolving patch:
diff --git a/ibis/backends/base/sql/alchemy/query_builder.py b/ibis/backends/base/sql/alchemy/query_builder.py
--- a/ibis/backends/base/sql/alchemy/query_builder.py
+++ b/ibis/backends/base/sql/alchemy/query_builder.py
@@ -365,7 +365,10 @@ def _add_limit(self, fragment):
             )
 
         if n is not None:
-            fragment = fragment.limit(n)
+            try:
+                fragment = fragment.limit(n)
+            except AttributeError:
+                fragment = fragment.subquery().select().limit(n)
 
         offset = self.limit.offset
 


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
@@ -306,3 +306,12 @@ def test_order_by_no_projection(backend):
 
     result = con.sql(ibis.to_sql(expr)).execute().name.iloc[:2]
     assert set(result) == {"Ross, Jerry L.", "Chang-Diaz, Franklin R."}
+
+
+@dot_sql_notimpl
+@dot_sql_notyet
+@dot_sql_never
+@pytest.mark.notyet(["polars"], raises=PolarsComputeError)
+def test_dot_sql_limit(con):
+    expr = con.sql("SELECT 'abc' ts").limit(1)
+    assert expr.execute().equals(pd.DataFrame({"ts": ["abc"]}))