Repository: ibis-project/ibis

Issue:
feat: support table.sql() for Polars
### Is your feature request related to a problem?

`con.sql()` is implemented, but `table.sql()` is not:

```python
[ins] In [1]: import ibis

[ins] In [2]: ibis.options.interactive = True

[ins] In [3]: ibis.set_backend("polars")

[ins] In [4]: t = ibis.examples.penguins.fetch()

[ins] In [5]: t
Out[5]:
┏━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━┓
┃ species ┃ island    ┃ bill_length_mm ┃ bill_depth_mm ┃ flipper_length_mm ┃ body_mass_g ┃ sex    ┃ year  ┃
┡━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━┩
│ string  │ string    │ float64        │ float64       │ int64             │ int64       │ string │ int64 │
├─────────┼───────────┼────────────────┼───────────────┼───────────────────┼─────────────┼────────┼───────┤
│ Adelie  │ Torgersen │           39.1 │          18.7 │               181 │        3750 │ male   │  2007 │
│ Adelie  │ Torgersen │           39.5 │          17.4 │               186 │        3800 │ female │  2007 │
│ Adelie  │ Torgersen │           40.3 │          18.0 │               195 │        3250 │ female │  2007 │
│ Adelie  │ Torgersen │           NULL │          NULL │              NULL │        NULL │ NULL   │  2007 │
│ Adelie  │ Torgersen │           36.7 │          19.3 │               193 │        3450 │ female │  2007 │
│ Adelie  │ Torgersen │           39.3 │          20.6 │               190 │        3650 │ male   │  2007 │
│ Adelie  │ Torgersen │           38.9 │          17.8 │               181 │        3625 │ female │  2007 │
│ Adelie  │ Torgersen │           39.2 │          19.6 │               195 │        4675 │ male   │  2007 │
│ Adelie  │ Torgersen │           34.1 │          18.1 │               193 │        3475 │ NULL   │  2007 │
│ Adelie  │ Torgersen │           42.0 │          20.2 │               190 │        4250 │ NULL   │  2007 │
│ …       │ …         │              … │             … │                 … │           … │ …      │     … │
└─────────┴───────────┴────────────────┴───────────────┴───────────────────┴─────────────┴────────┴───────┘

[ins] In [6]: t.sql("SELECT species, island, count(*) AS count FROM penguins GROUP BY species, island")
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
Cell In[6], line 1
----> 1 t.sql("SELECT species, island, count(*) AS count FROM penguins GROUP BY species, island")

File ~/repos/ibis/ibis/expr/types/relations.py:3329, in Table.sql(self, query, dialect)
   3326     name = util.gen_name("sql_query")
   3327     expr = self
-> 3329 schema = backend._get_sql_string_view_schema(name, expr, query)
   3330 node = ops.SQLStringView(child=self.op(), query=query, schema=schema)
   3331 return node.to_expr()

File ~/repos/ibis/ibis/backends/polars/__init__.py:410, in Backend._get_sql_string_view_schema(self, name, table, query)
    409 def _get_sql_string_view_schema(self, name, table, query) -> sch.Schema:
--> 410     raise NotImplementedError("table.sql() not yet supported in polars")

NotImplementedError: table.sql() not yet supported in polars
```

### Describe the solution you'd like

support `table.sql()` with the Polars backend

this is mainly useful to me for consistency in example code for the documentation

### What version of ibis are you running?

main

### What backend(s) are you using, if any?

Polars

### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
Thanks for the issue.

Can you paste in a copy-pastable example 😁 

Resolving patch:
diff --git a/ibis/backends/polars/__init__.py b/ibis/backends/polars/__init__.py
--- a/ibis/backends/polars/__init__.py
+++ b/ibis/backends/polars/__init__.py
@@ -407,7 +407,17 @@ def compile(
         return translate(node, ctx=self._context)
 
     def _get_sql_string_view_schema(self, name, table, query) -> sch.Schema:
-        raise NotImplementedError("table.sql() not yet supported in polars")
+        import sqlglot as sg
+
+        cte = sg.parse_one(str(ibis.to_sql(table, dialect="postgres")), read="postgres")
+        parsed = sg.parse_one(query, read=self.dialect)
+        parsed.args["with"] = cte.args.pop("with", [])
+        parsed = parsed.with_(
+            sg.to_identifier(name, quoted=True), as_=cte, dialect=self.dialect
+        )
+
+        sql = parsed.sql(self.dialect)
+        return self._get_schema_using_query(sql)
 
     def _get_schema_using_query(self, query: str) -> sch.Schema:
         return PolarsSchema.to_ibis(self._context.execute(query).schema)
diff --git a/ibis/backends/polars/compiler.py b/ibis/backends/polars/compiler.py
--- a/ibis/backends/polars/compiler.py
+++ b/ibis/backends/polars/compiler.py
@@ -1225,8 +1225,6 @@ def execute_arg_min(op, **kw):
 
 @translate.register(ops.SQLStringView)
 def execute_sql_string_view(op, *, ctx: pl.SQLContext, **kw):
-    child = translate(op.child, ctx=ctx, **kw)
-    ctx.register(op.name, child)
     return ctx.execute(op.query)
 
 
diff --git a/ibis/backends/postgres/__init__.py b/ibis/backends/postgres/__init__.py
--- a/ibis/backends/postgres/__init__.py
+++ b/ibis/backends/postgres/__init__.py
@@ -11,10 +11,8 @@
 from typing import TYPE_CHECKING, Any, Callable
 from urllib.parse import parse_qs, urlparse
 
-import psycopg2
 import sqlglot as sg
 import sqlglot.expressions as sge
-from psycopg2 import extras
 
 import ibis
 import ibis.common.exceptions as com
@@ -102,6 +100,8 @@ def _from_url(self, url: str, **kwargs):
         return self.connect(**kwargs)
 
     def _register_in_memory_table(self, op: ops.InMemoryTable) -> None:
+        from psycopg2.extras import execute_batch
+
         schema = op.schema
         if null_columns := [col for col, dtype in schema.items() if dtype.is_null()]:
             raise exc.IbisTypeError(
@@ -148,9 +148,10 @@ def _register_in_memory_table(self, op: ops.InMemoryTable) -> None:
             specs = ", ".join(repeat("%s", len(columns)))
             table = sg.table(name, quoted=quoted)
             sql = f"INSERT INTO {table.sql(self.dialect)} ({cols}) VALUES ({specs})"
+
             with self.begin() as cur:
                 cur.execute(create_stmt_sql)
-                extras.execute_batch(cur, sql, data, 128)
+                execute_batch(cur, sql, data, 128)
 
     @contextlib.contextmanager
     def begin(self):
@@ -258,7 +259,11 @@ def do_connect(
             month : int32
 
         """
+        import psycopg2
+        import psycopg2.extras
+
         psycopg2.extras.register_default_json(loads=lambda x: x)
+
         self.con = psycopg2.connect(
             host=host,
             port=port,
@@ -700,6 +705,9 @@ def _safe_raw_sql(self, *args, **kwargs):
             yield result
 
     def raw_sql(self, query: str | sg.Expression, **kwargs: Any) -> Any:
+        import psycopg2
+        import psycopg2.extras
+
         with contextlib.suppress(AttributeError):
             query = query.sql(dialect=self.dialect)
 
@@ -709,11 +717,11 @@ def raw_sql(self, query: str | sg.Expression, **kwargs: Any) -> Any:
         try:
             # try to load hstore, uuid and ipaddress extensions
             with contextlib.suppress(psycopg2.ProgrammingError):
-                extras.register_hstore(cursor)
+                psycopg2.extras.register_hstore(cursor)
             with contextlib.suppress(psycopg2.ProgrammingError):
-                extras.register_uuid(conn_or_curs=cursor)
+                psycopg2.extras.register_uuid(conn_or_curs=cursor)
             with contextlib.suppress(psycopg2.ProgrammingError):
-                extras.register_ipaddress(cursor)
+                psycopg2.extras.register_ipaddress(cursor)
         except Exception:
             cursor.close()
             raise
@@ -740,4 +748,4 @@ def _to_sqlglot(
 
         if conversions:
             table_expr = table_expr.mutate(**conversions)
-        return super()._to_sqlglot(table_expr, limit=limit, params=params)
+        return super()._to_sqlglot(table_expr, limit=limit, params=params, **kwargs)


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
@@ -77,7 +77,11 @@ def test_con_dot_sql(backend, con, schema):
     backend.assert_series_equal(result.astype(expected.dtype), expected)
 
 
-@pytest.mark.notyet(["polars"], raises=PolarsComputeError)
+@pytest.mark.notyet(
+    ["polars"],
+    raises=PolarsComputeError,
+    reason="polars doesn't support quoted identifiers referencing CTEs",
+)
 @pytest.mark.notyet(
     ["bigquery"], raises=GoogleBadRequest, reason="requires a qualified name"
 )
@@ -119,7 +123,11 @@ def test_table_dot_sql(backend):
     assert pytest.approx(result) == expected
 
 
-@pytest.mark.notyet(["polars"], raises=PolarsComputeError)
+@pytest.mark.notyet(
+    ["polars"],
+    raises=PolarsComputeError,
+    reason="polars doesn't support quoted identifiers referencing CTEs",
+)
 @pytest.mark.notyet(
     ["bigquery"], raises=GoogleBadRequest, reason="requires a qualified name"
 )
@@ -315,7 +323,11 @@ def mem_t(con):
 
 
 @dot_sql_never
-@pytest.mark.notyet(["polars"], raises=NotImplementedError)
+@pytest.mark.notyet(
+    ["polars"],
+    raises=PolarsComputeError,
+    reason="polars doesn't support selecting from quoted identifiers referencing CTEs",
+)
 @pytest.mark.notyet(
     ["druid"],
     raises=KeyError,
@@ -337,3 +349,13 @@ def test_cte(alltypes, df):
     )
 
     tm.assert_frame_equal(result, expected)
+
+
+@dot_sql_never
+def test_bare_minimum(alltypes, df):
+    """Test that a backend that supports dot sql can do the most basic thing."""
+
+    expr = alltypes.sql(
+        'SELECT COUNT(*) AS "n" FROM "functional_alltypes"', dialect="duckdb"
+    )
+    assert expr.to_pandas().iat[0, 0] == len(df)
diff --git a/ibis/backends/tests/test_export.py b/ibis/backends/tests/test_export.py
--- a/ibis/backends/tests/test_export.py
+++ b/ibis/backends/tests/test_export.py
@@ -103,7 +103,7 @@ def test_scalar_to_pyarrow_scalar(limit, awards_players):
     assert isinstance(scalar, pa.Scalar)
 
 
-@pytest.mark.notimpl(["druid", "exasol"])
+@pytest.mark.notimpl(["druid"])
 def test_table_to_pyarrow_table_schema(awards_players):
     table = awards_players.to_pyarrow()
     assert isinstance(table, pa.Table)