Repository: ibis-project/ibis

Issue:
bug: sqlalchemy backends semi-join generates cartesian product in some cases
This test:

```python
def test_topk_filter_op(backend, alltypes, df, result_fn, expected_fn):
    # TopK expression will order rows by "count" but each backend
    # can have different result for that.
    # Note: Maybe would be good if TopK could order by "count"
    # and the field used by TopK
    t = alltypes.sort_by(alltypes.string_col)
    df = df.sort_values('string_col')
    expr = result_fn(t)
    result = expr.execute()
    expected = expected_fn(df)
    assert result.shape[0] == expected.shape[0]
```

generates the following SQL for the sqlalchemy backends (I've removed the explicit columns for brevity). The second `FROM` (sequentially) is followed by an implicit `CROSS JOIN`.

```sql
SELECT *
FROM
  (SELECT *
   FROM functional_alltypes AS t2
   ORDER BY t2.string_col) AS t0,
  (SELECT *
   FROM
     (SELECT *
      FROM functional_alltypes AS t2
      ORDER BY t2.string_col) AS t0
   WHERE EXISTS
       (SELECT 1
        FROM
          (SELECT *
           FROM
             (SELECT t3.string_col AS string_col,
                     count(t3.string_col) AS count
              FROM functional_alltypes AS t3
              GROUP BY t3.string_col
              ORDER BY t3.string_col) AS t2
           ORDER BY t2.count DESC
           LIMIT 3) AS t1
        WHERE t0.string_col = t1.string_col)) AS anon_1
```

The expected query is the same as the incorrect query with the left side of the implicit cross join removed:

```sql
SELECT *
 FROM
   (SELECT *
    FROM functional_alltypes AS t2
    ORDER BY t2.string_col) AS t0
 WHERE EXISTS
     (SELECT 1
      FROM
        (SELECT *
         FROM
           (SELECT t3.string_col AS string_col,
                   count(t3.string_col) AS count
            FROM functional_alltypes AS t3
            GROUP BY t3.string_col
            ORDER BY t3.string_col) AS t2
         ORDER BY t2.count DESC
         LIMIT 3) AS t1
      WHERE t0.string_col = t1.string_col)
```


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
@@ -58,12 +58,12 @@ def get_result(self):
             elif jtype is ops.OuterJoin:
                 result = result.outerjoin(table, onclause, full=True)
             elif jtype is ops.LeftSemiJoin:
-                result = sa.select([result]).where(
-                    sa.exists(sa.select([1]).where(onclause))
+                result = result.select().where(
+                    sa.exists(sa.select(1).where(onclause))
                 )
             elif jtype is ops.LeftAntiJoin:
-                result = sa.select([result]).where(
-                    ~(sa.exists(sa.select([1]).where(onclause)))
+                result = result.select().where(
+                    ~sa.exists(sa.select(1).where(onclause))
                 )
             else:
                 raise NotImplementedError(jtype)
@@ -194,9 +194,7 @@ def _compile_table_set(self):
         if self.table_set is not None:
             helper = _AlchemyTableSetFormatter(self, self.table_set)
             result = helper.get_result()
-            if isinstance(result, sql.selectable.Select) and hasattr(
-                result, 'subquery'
-            ):
+            if isinstance(result, sql.selectable.Select):
                 return result.subquery()
             return result
         else:
@@ -240,14 +238,38 @@ def _add_select(self, table_set):
         if self.distinct:
             result = result.distinct()
 
-        if not has_select_star:
-            if table_set is not None:
-                return result.select_from(table_set)
-            else:
-                return result
-        else:
+        # if we're SELECT *-ing or there's no table_set (e.g., SELECT 1) then
+        # we can return early
+        if has_select_star or table_set is None:
             return result
 
+        # if we're selecting from something that isn't a subquery e.g., Select,
+        # Alias, Table
+        if not isinstance(table_set, sa.sql.Subquery):
+            return result.select_from(table_set)
+
+        final_froms = result.get_final_froms()
+        num_froms = len(final_froms)
+
+        # if the result subquery has no FROMs then we can select from the
+        # table_set since there's only a single possibility for FROM
+        if not num_froms:
+            return result.select_from(table_set)
+
+        # otherwise we expect a single FROM in `result`
+        assert num_froms == 1, f"num_froms == {num_froms:d}"
+
+        # and then we need to replace every occurrence of `result`'s `FROM`
+        # with `table_set` to handle correlated EXISTs coming from
+        # semi/anti-join
+        #
+        # previously this was `replace_selectable`, but that's deprecated so we
+        # inline its implementation here
+        #
+        # sqlalchemy suggests using the functionality in sa.sql.visitors, but
+        # that would effectively require reimplementing ClauseAdapter
+        return sa.sql.util.ClauseAdapter(table_set).traverse(result)
+
     def _add_groupby(self, fragment):
         # GROUP BY and HAVING
         if not len(self.group_by):
diff --git a/ibis/util.py b/ibis/util.py
--- a/ibis/util.py
+++ b/ibis/util.py
@@ -12,6 +12,7 @@
 import warnings
 from numbers import Real
 from typing import (
+    IO,
     TYPE_CHECKING,
     Any,
     Hashable,
@@ -28,6 +29,8 @@
 from ibis.config import options
 
 if TYPE_CHECKING:
+    import sqlalchemy as sa
+
     from .expr import operations as ops
     from .expr import types as ir
 
@@ -538,3 +541,32 @@ def toposort(graph: Graph) -> Iterator[ops.Node]:
 
     if any(in_degree.values()):
         raise ValueError("cycle in expression graph")
+
+
+def psql(
+    expr: ir.Expr | sa.sql.ClauseElement,
+    reindent: bool = True,
+    file: IO[str] = None,
+    **kwargs: Any,
+) -> None:
+    """Pretty-print the compiled SQL string of an expression.
+
+    Accepts both ibis and SQLAlchemy expressions.
+
+    Parameters
+    ----------
+    expr
+        Expression whose SQL will be printed
+    reindent
+        Tell `sqlparse` to reindent the SQL string
+    file
+        File to write output to
+    kwargs
+        `sqlparse.format` options
+    """
+    import sqlparse as sp
+
+    print(
+        sp.format(str(expr.compile()), reindent=reindent, **kwargs),
+        file=file,
+    )


Test patch:
diff --git a/ibis/backends/tests/test_aggregation.py b/ibis/backends/tests/test_aggregation.py
--- a/ibis/backends/tests/test_aggregation.py
+++ b/ibis/backends/tests/test_aggregation.py
@@ -365,11 +365,8 @@ def test_topk_op(backend, alltypes, df, result_fn, expected_fn):
         )
     ],
 )
-@pytest.mark.notyet(['sqlite'], reason='Issue #2128')
-@pytest.mark.notyet(["mysql"], reason="Issue #2131")
-@pytest.mark.notyet(["postgres"], reason="Issue #2132")
-@mark.notimpl(["datafusion", "duckdb", "pandas", "dask", "pyspark"])
-def test_topk_filter_op(backend, alltypes, df, result_fn, expected_fn):
+@mark.notimpl(["datafusion", "pandas", "dask", "pyspark"])
+def test_topk_filter_op(alltypes, df, result_fn, expected_fn):
     # TopK expression will order rows by "count" but each backend
     # can have different result for that.
     # Note: Maybe would be good if TopK could order by "count"