Repository: ibis-project/ibis

Issue:
rlike works but ibis.show_sql produces error in duckdb, but when pandas is used neither works. 
```
import ibis
from ibis.interactive import *
t = ex.penguins.fetch()
res = t.filter(_.island.rlike('Biscoe|Dream'))
```
`ibis.show_sql(res)` creates the below error: 

```
---------------------------------------------------------------------------
ParseError                                Traceback (most recent call last)
Cell In[4], line 1
----> 1 ibis.show_sql(res)

File /miniconda3/envs/ml310/lib/python3.10/site-packages/ibis/expr/sql.py:324, in show_sql(expr, dialect, file)
    288 @public
    289 def show_sql(
    290     expr: ir.Expr,
    291     dialect: str | None = None,
    292     file: IO[str] | None = None,
    293 ) -> None:
    294     """Pretty-print the compiled SQL string of an expression.
    295 
    296     If a dialect cannot be inferred and one was not passed, duckdb
   (...)
    322     FROM t AS t0
    323     """
--> 324     print(to_sql(expr, dialect=dialect), file=file)

File /miniconda3/envs/ml310/lib/python3.10/site-packages/ibis/expr/sql.py:388, in to_sql(expr, dialect, **kwargs)
    385         read = write = getattr(backend, "_sqlglot_dialect", dialect)
    387 sql = backend._to_sql(expr, **kwargs)
--> 388 (pretty,) = sg.transpile(sql, read=read, write=write, pretty=True)
    389 return SQLString(pretty)

File /miniconda3/envs/ml310/lib/python3.10/site-packages/sqlglot/__init__.py:184, in transpile(sql, read, write, identity, error_level, **opts)
    165 """
    166 Parses the given SQL string in accordance with the source dialect and returns a list of SQL strings transformed
    167 to conform to the target dialect. Each string in the returned list represents a single transformed SQL statement.
   (...)
    179     The list of transpiled SQL statements.
    180 """
    181 write = write or read if identity else write
    182 return [
    183     Dialect.get_or_raise(write)().generate(expression, **opts)
--> 184     for expression in parse(sql, read, error_level=error_level)
    185 ]

File /miniconda3/envs/ml310/lib/python3.10/site-packages/sqlglot/__init__.py:72, in parse(sql, read, **opts)
     60 """
     61 Parses the given SQL string into a collection of syntax trees, one per parsed SQL statement.
     62 
   (...)
     69     The resulting syntax tree collection.
     70 """
     71 dialect = Dialect.get_or_raise(read)()
---> 72 return dialect.parse(sql, **opts)

File /miniconda3/envs/ml310/lib/python3.10/site-packages/sqlglot/dialects/dialect.py:163, in Dialect.parse(self, sql, **opts)
    162 def parse(self, sql: str, **opts) -> t.List[t.Optional[exp.Expression]]:
--> 163     return self.parser(**opts).parse(self.tokenize(sql), sql)

File /miniconda3/envs/ml310/lib/python3.10/site-packages/sqlglot/parser.py:785, in Parser.parse(self, raw_tokens, sql)
    771 def parse(
    772     self, raw_tokens: t.List[Token], sql: t.Optional[str] = None
    773 ) -> t.List[t.Optional[exp.Expression]]:
    774     """
    775     Parses a list of tokens and returns a list of syntax trees, one tree
    776     per parsed SQL statement.
   (...)
    783         The list of syntax trees.
    784     """
--> 785     return self._parse(
    786         parse_method=self.__class__._parse_statement, raw_tokens=raw_tokens, sql=sql
    787     )

File /miniconda3/envs/ml310/lib/python3.10/site-packages/sqlglot/parser.py:851, in Parser._parse(self, parse_method, raw_tokens, sql)
    848     expressions.append(parse_method(self))
    850     if self._index < len(self._tokens):
--> 851         self.raise_error("Invalid expression / Unexpected token")
    853     self.check_errors()
    855 return expressions

File /miniconda3/envs/ml310/lib/python3.10/site-packages/sqlglot/parser.py:894, in Parser.raise_error(self, message, token)
    882 error = ParseError.new(
    883     f"{message}. Line {token.line}, Col: {token.col}.\n"
    884     f"  {start_context}\033[4m{highlight}\033[0m{end_context}",
   (...)
    890     end_context=end_context,
    891 )
    893 if self.error_level == ErrorLevel.IMMEDIATE:
--> 894     raise error
    896 self.errors.append(error)

ParseError: Invalid expression / Unexpected token. Line 3, Col: 17.
   t0.flipper_length_mm, t0.body_mass_g, t0.sex, t0.year 
FROM ibis_read_csv_0 AS t0 
WHERE t0.island ~ 'Biscoe|Dream'
```


Discussion:
Welcome @ededovic 👋🏻! Thanks for opening an issue.

The `ParseError` is coming from [`sqlglot`](https://github.com/tobymao/sqlglot). I'll report that upstream; bugs are often fixed there on the same day they are reported.

As for the pandas backend, do you have some failing example code?

Resolving patch:
diff --git a/ibis/backends/druid/registry.py b/ibis/backends/druid/registry.py
--- a/ibis/backends/druid/registry.py
+++ b/ibis/backends/druid/registry.py
@@ -43,6 +43,7 @@ def _join(t, op):
         ops.Log10: fixed_arity(sa.func.log10, 1),
         ops.Sign: _sign,
         ops.StringJoin: _join,
+        ops.RegexSearch: fixed_arity(sa.func.regexp_like, 2),
     }
 )
 
diff --git a/ibis/backends/duckdb/registry.py b/ibis/backends/duckdb/registry.py
--- a/ibis/backends/duckdb/registry.py
+++ b/ibis/backends/duckdb/registry.py
@@ -313,6 +313,7 @@ def _array_filter(t, op):
         ops.RegexReplace: fixed_arity(
             lambda *args: sa.func.regexp_replace(*args, sa.text("'g'")), 3
         ),
+        ops.RegexSearch: fixed_arity(lambda x, y: x.op("SIMILAR TO")(y), 2),
         ops.StringContains: fixed_arity(sa.func.contains, 2),
         ops.ApproxMedian: reduction(
             # without inline text, duckdb fails with
diff --git a/ibis/backends/pyspark/__init__.py b/ibis/backends/pyspark/__init__.py
--- a/ibis/backends/pyspark/__init__.py
+++ b/ibis/backends/pyspark/__init__.py
@@ -704,3 +704,6 @@ def _register_failure(self):
             f"Cannot infer appropriate read function for input, "
             f"please call one of {msg} directly"
         )
+
+    def _to_sql(self, expr: ir.Expr, **kwargs) -> str:
+        raise NotImplementedError(f"Backend '{self.name}' backend doesn't support SQL")


Test patch:
diff --git a/ibis/backends/tests/snapshots/test_string/test_rlike/bigquery/out.sql b/ibis/backends/tests/snapshots/test_string/test_rlike/bigquery/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/tests/snapshots/test_string/test_rlike/bigquery/out.sql
@@ -0,0 +1,5 @@
+SELECT
+  t0.*
+FROM `ibis-gbq.ibis_gbq_testing.functional_alltypes` AS t0
+WHERE
+  REGEXP_CONTAINS(t0.`string_col`, '0')
\ No newline at end of file
diff --git a/ibis/backends/tests/snapshots/test_string/test_rlike/clickhouse/out.sql b/ibis/backends/tests/snapshots/test_string/test_rlike/clickhouse/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/tests/snapshots/test_string/test_rlike/clickhouse/out.sql
@@ -0,0 +1,5 @@
+SELECT
+  *
+FROM functional_alltypes AS t0
+WHERE
+  match(t0.string_col, '0')
\ No newline at end of file
diff --git a/ibis/backends/tests/snapshots/test_string/test_rlike/duckdb/out.sql b/ibis/backends/tests/snapshots/test_string/test_rlike/duckdb/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/tests/snapshots/test_string/test_rlike/duckdb/out.sql
@@ -0,0 +1,19 @@
+SELECT
+  t0.index,
+  t0."Unnamed: 0",
+  t0.id,
+  t0.bool_col,
+  t0.tinyint_col,
+  t0.smallint_col,
+  t0.int_col,
+  t0.bigint_col,
+  t0.float_col,
+  t0.double_col,
+  t0.date_string_col,
+  t0.string_col,
+  t0.timestamp_col,
+  t0.year,
+  t0.month
+FROM functional_alltypes AS t0
+WHERE
+  t0.string_col SIMILAR TO '0'
\ No newline at end of file
diff --git a/ibis/backends/tests/snapshots/test_string/test_rlike/impala/out.sql b/ibis/backends/tests/snapshots/test_string/test_rlike/impala/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/tests/snapshots/test_string/test_rlike/impala/out.sql
@@ -0,0 +1,5 @@
+SELECT
+  t0.*
+FROM ibis_testing.`functional_alltypes` AS t0
+WHERE
+  t0.`string_col` RLIKE '0'
\ No newline at end of file
diff --git a/ibis/backends/tests/snapshots/test_string/test_rlike/mysql/out.sql b/ibis/backends/tests/snapshots/test_string/test_rlike/mysql/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/tests/snapshots/test_string/test_rlike/mysql/out.sql
@@ -0,0 +1,19 @@
+SELECT
+  t0.`index`,
+  t0.`Unnamed: 0`,
+  t0.id,
+  t0.bool_col = 1 AS bool_col,
+  t0.tinyint_col,
+  t0.smallint_col,
+  t0.int_col,
+  t0.bigint_col,
+  t0.float_col,
+  t0.double_col,
+  t0.date_string_col,
+  t0.string_col,
+  t0.timestamp_col,
+  t0.year,
+  t0.month
+FROM functional_alltypes AS t0
+WHERE
+  REGEXP_LIKE(t0.string_col, '0')
\ No newline at end of file
diff --git a/ibis/backends/tests/snapshots/test_string/test_rlike/postgres/out.sql b/ibis/backends/tests/snapshots/test_string/test_rlike/postgres/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/tests/snapshots/test_string/test_rlike/postgres/out.sql
@@ -0,0 +1,19 @@
+SELECT
+  t0.index,
+  t0."Unnamed: 0",
+  t0.id,
+  t0.bool_col,
+  t0.tinyint_col,
+  t0.smallint_col,
+  t0.int_col,
+  t0.bigint_col,
+  t0.float_col,
+  t0.double_col,
+  t0.date_string_col,
+  t0.string_col,
+  t0.timestamp_col,
+  t0.year,
+  t0.month
+FROM functional_alltypes AS t0
+WHERE
+  t0.string_col ~ '0'
\ No newline at end of file
diff --git a/ibis/backends/tests/snapshots/test_string/test_rlike/snowflake/out.sql b/ibis/backends/tests/snapshots/test_string/test_rlike/snowflake/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/tests/snapshots/test_string/test_rlike/snowflake/out.sql
@@ -0,0 +1,19 @@
+SELECT
+  t0."index",
+  t0."Unnamed: 0",
+  t0."id",
+  t0."bool_col",
+  t0."tinyint_col",
+  t0."smallint_col",
+  t0."int_col",
+  t0."bigint_col",
+  t0."float_col",
+  t0."double_col",
+  t0."date_string_col",
+  t0."string_col",
+  t0."timestamp_col",
+  t0."year",
+  t0."month"
+FROM ibis_testing.voltrondataphillip."FUNCTIONAL_ALLTYPES" AS t0
+WHERE
+  REGEXP_LIKE(t0."string_col", '0')
\ No newline at end of file
diff --git a/ibis/backends/tests/snapshots/test_string/test_rlike/sqlite/out.sql b/ibis/backends/tests/snapshots/test_string/test_rlike/sqlite/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/tests/snapshots/test_string/test_rlike/sqlite/out.sql
@@ -0,0 +1,19 @@
+SELECT
+  t0."index",
+  t0."Unnamed: 0",
+  t0.id,
+  t0.bool_col,
+  t0.tinyint_col,
+  t0.smallint_col,
+  t0.int_col,
+  t0.bigint_col,
+  t0.float_col,
+  t0.double_col,
+  t0.date_string_col,
+  t0.string_col,
+  STRFTIME('%Y-%m-%d %H:%M:%f', t0.timestamp_col) AS timestamp_col,
+  t0.year,
+  t0.month
+FROM main.functional_alltypes AS t0
+WHERE
+  _IBIS_SQLITE_REGEX_SEARCH(t0.string_col, '0')
\ No newline at end of file
diff --git a/ibis/backends/tests/snapshots/test_string/test_rlike/trino/out.sql b/ibis/backends/tests/snapshots/test_string/test_rlike/trino/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/tests/snapshots/test_string/test_rlike/trino/out.sql
@@ -0,0 +1,19 @@
+SELECT
+  t0.index,
+  t0."unnamed: 0",
+  t0.id,
+  t0.bool_col,
+  t0.tinyint_col,
+  t0.smallint_col,
+  t0.int_col,
+  t0.bigint_col,
+  t0.float_col,
+  t0.double_col,
+  t0.date_string_col,
+  t0.string_col,
+  t0.timestamp_col,
+  t0.year,
+  t0.month
+FROM functional_alltypes AS t0
+WHERE
+  REGEXP_POSITION(t0.string_col, '0') <> -1
\ No newline at end of file
diff --git a/ibis/backends/tests/test_generic.py b/ibis/backends/tests/test_generic.py
--- a/ibis/backends/tests/test_generic.py
+++ b/ibis/backends/tests/test_generic.py
@@ -948,7 +948,9 @@ def test_memtable_construct(backend, con, monkeypatch):
     raises=NotImplementedError,
     reason="not a SQL backend",
 )
-@pytest.mark.notimpl(["pyspark"], raises=com.OperationNotDefinedError)
+@pytest.mark.notimpl(
+    ["pyspark"], reason="pyspark doesn't generate SQL", raises=NotImplementedError
+)
 @pytest.mark.notimpl(["druid"], reason="no sqlglot dialect", raises=ValueError)
 def test_many_subqueries(con, snapshot):
     def query(t, group_cols):
diff --git a/ibis/backends/tests/test_string.py b/ibis/backends/tests/test_string.py
--- a/ibis/backends/tests/test_string.py
+++ b/ibis/backends/tests/test_string.py
@@ -171,8 +171,7 @@ def test_string_col_is_unicode(alltypes, df):
             id='re_search',
             marks=[
                 pytest.mark.notimpl(
-                    ["datafusion", "mssql", "druid"],
-                    raises=com.OperationNotDefinedError,
+                    ["datafusion", "mssql"], raises=com.OperationNotDefinedError
                 ),
                 pytest.mark.notimpl(["impala"], raises=AssertionError),
             ],
@@ -183,10 +182,15 @@ def test_string_col_is_unicode(alltypes, df):
             id='re_search_posix',
             marks=[
                 pytest.mark.notimpl(
-                    ["datafusion", "mssql", "druid"],
+                    ["datafusion", "mssql"],
                     raises=com.OperationNotDefinedError,
                 ),
                 pytest.mark.broken(["pyspark"], raises=AssertionError),
+                pytest.mark.never(
+                    ["druid"],
+                    reason="No posix support; regex is interpreted literally",
+                    raises=AssertionError,
+                ),
             ],
         ),
         param(
@@ -861,3 +865,23 @@ def test_multiple_subs(con):
     expr = ibis.literal("foo").substitute(m)
     result = con.execute(expr)
     assert result == "FOO"
+
+
+@pytest.mark.never(
+    ["dask", "datafusion", "pandas", "polars", "pyspark"],
+    raises=NotImplementedError,
+    reason="not a SQL backend",
+)
+@pytest.mark.notyet(
+    ["mssql"],
+    raises=OperationNotDefinedError,
+    reason="Doesn't support regular expressions",
+)
+@pytest.mark.notyet(
+    ["druid"],
+    raises=ValueError,
+    reason="sqlglot doesn't support a druid dialect",
+)
+def test_rlike(snapshot, alltypes):
+    expr = alltypes[alltypes.string_col.rlike('0')]
+    snapshot.assert_match(str(ibis.to_sql(expr)), "out.sql")