Repository: ibis-project/ibis

Issue:
bug(bigquery): memtable and string literals not escaping `\n` or `\` which results in invalid syntax
### What happened?

Code:

```python
import ibis

ibis_client = ibis.bigquery.connect()
table = ibis.memtable(
    {
        "col1": ["a\tb\nc", "d e f", "g'e\"h"],
    }
)
print(ibis_client.compile(table))
```

Output:

```
SELECT t0.*
FROM UNNEST(ARRAY<STRUCT<col1 STRING>>[STRUCT('a        b
c' AS col1), STRUCT('d e f' AS col1), STRUCT('g\'e"h' AS col1)]) t0
```

Note, the following SQL works as expected:

```
SELECT t0.*
FROM UNNEST(ARRAY<STRUCT<col1 STRING>>[STRUCT('a        b\nc' AS col1), STRUCT('d e f' AS col1), STRUCT('g\'e"h' AS col1)]) t0
```

Therefore, we should really be escaping `\n` in addition to `'`. Though, perhaps there are other characters that could break BigQuery syntax? See: https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#string_and_bytes_literals

Alternatively, using triple-quoted strings allows for newline characters in the string literal itself.

### What version of ibis are you using?

6.1.0

also tested on latest commit: 15f8d9575

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

BigQuery

### Relevant log output

```sh
BigQuery API: Syntax error: Unclosed string literal at [2:47]
```


### Code of Conduct

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


Discussion:
Definitely need to escape `\` as well:

```python
import ibis

ibis_client = ibis.bigquery.connect()
table = ibis.memtable({"col1": ["i\\j\\k"]})
print(ibis_client.compile(table))
```

Output

```
SELECT t0.*
FROM UNNEST(ARRAY<STRUCT<col1 STRING>>[STRUCT('i\j\k' AS col1)]) t0

Error:
Syntax error: Illegal escape sequence: \j at [2:49]
```
Same applies to string literals:

```python
import ibis

ibis_client = ibis.bigquery.connect()
table = ibis.memtable({"col1": ["a"]})
col1 = table["col1"] + ibis.literal("\\b\nc")
print(ibis_client.compile(col1))
```

Output:

```
SELECT
  CONCAT(t0.`col1`, '\b
  c') AS `StringConcat`
FROM UNNEST(ARRAY<STRUCT<col1 STRING>>[STRUCT('a' AS col1)]) t0
```

Resolving patch:
diff --git a/ibis/backends/base/sql/registry/literal.py b/ibis/backends/base/sql/registry/literal.py
--- a/ibis/backends/base/sql/registry/literal.py
+++ b/ibis/backends/base/sql/registry/literal.py
@@ -21,7 +21,22 @@ def _boolean_literal_format(translator, op):
 
 
 def _string_literal_format(translator, op):
-    return "'{}'".format(op.value.replace("'", "\\'"))
+    return "'{}'".format(
+        op.value
+        # Escape \ first so we don't double escape other characters.
+        .replace("\\", "\\\\")
+        # Escape ' since we're using those for the string literal.
+        .replace("'", "\\'")
+        # ASCII escape sequences that are recognized in Python:
+        # https://docs.python.org/3/reference/lexical_analysis.html#string-and-bytes-literals
+        .replace("\a", "\\a")  # Bell
+        .replace("\b", "\\b")  # Backspace
+        .replace("\f", "\\f")  # Formfeed
+        .replace("\n", "\\n")  # Newline / Linefeed
+        .replace("\r", "\\r")  # Carriage return
+        .replace("\t", "\\t")  # Tab
+        .replace("\v", "\\v")  # Vertical tab
+    )
 
 
 def _number_literal_format(translator, op):


Test patch:
diff --git a/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/escape_ascii_sequences/out.sql b/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/escape_ascii_sequences/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/escape_ascii_sequences/out.sql
@@ -0,0 +1 @@
+SELECT 'a\ab\bc\fd\ne\rf\tg\vh' AS `'a_x07b_x08c_x0cd_ne_rf_tg_x0bh'`
\ No newline at end of file
diff --git a/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/escape_backslash/out.sql b/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/escape_backslash/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/escape_backslash/out.sql
@@ -0,0 +1 @@
+SELECT 'a\\b\\c' AS `'a_b_c'`
\ No newline at end of file
diff --git a/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/escape_quote/out.sql b/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/escape_quote/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/escape_quote/out.sql
@@ -0,0 +1 @@
+SELECT 'a\'b"c' AS `'a_'b_c'`
\ No newline at end of file
diff --git a/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/not_escape_special_characters/out.sql b/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/not_escape_special_characters/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_literal_string/not_escape_special_characters/out.sql
@@ -0,0 +1 @@
+SELECT '`~!@#$%^&*()_=+-|[]{};:/?<>' AS `'_#_%_&__=+-|_:_<>'`
\ No newline at end of file
diff --git a/ibis/backends/bigquery/tests/unit/test_compiler.py b/ibis/backends/bigquery/tests/unit/test_compiler.py
--- a/ibis/backends/bigquery/tests/unit/test_compiler.py
+++ b/ibis/backends/bigquery/tests/unit/test_compiler.py
@@ -114,6 +114,20 @@ def test_integer_to_timestamp(case, unit, snapshot):
     snapshot.assert_match(to_sql(expr), "out.sql")
 
 
+@pytest.mark.parametrize(
+    ("case",),
+    [
+        param("a\\b\\c", id="escape_backslash"),
+        param("a\ab\bc\fd\ne\rf\tg\vh", id="escape_ascii_sequences"),
+        param("a'b\"c", id="escape_quote"),
+        param("`~!@#$%^&*()_=+-|[]{};:/?<>", id="not_escape_special_characters"),
+    ],
+)
+def test_literal_string(case, snapshot):
+    expr = ibis.literal(case)
+    snapshot.assert_match(to_sql(expr), "out.sql")
+
+
 @pytest.mark.parametrize(
     ("case", "dtype"),
     [
diff --git a/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_extract/out.sql b/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_extract/out.sql
--- a/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_extract/out.sql
+++ b/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_extract/out.sql
@@ -1 +1 @@
-regexp_extract(`string_col`, '[\d]+', 0)
\ No newline at end of file
+regexp_extract(`string_col`, '[\\d]+', 0)
\ No newline at end of file
diff --git a/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_replace/out.sql b/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_replace/out.sql
--- a/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_replace/out.sql
+++ b/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_replace/out.sql
@@ -1 +1 @@
-regexp_replace(`string_col`, '[\d]+', 'aaa')
\ No newline at end of file
+regexp_replace(`string_col`, '[\\d]+', 'aaa')
\ No newline at end of file
diff --git a/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_search/out.sql b/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_search/out.sql
--- a/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_search/out.sql
+++ b/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/re_search/out.sql
@@ -1 +1 @@
-regexp_like(`string_col`, '[\d]+')
\ No newline at end of file
+regexp_like(`string_col`, '[\\d]+')
\ No newline at end of file
diff --git a/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/rlike/out.sql b/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/rlike/out.sql
--- a/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/rlike/out.sql
+++ b/ibis/backends/impala/tests/snapshots/test_string_builtins/test_string_builtins/rlike/out.sql
@@ -1 +1 @@
-regexp_like(`string_col`, '[\d]+')
\ No newline at end of file
+regexp_like(`string_col`, '[\\d]+')
\ No newline at end of file
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
@@ -200,7 +200,6 @@ def test_string_col_is_unicode(alltypes, df):
                     ["mssql", "oracle"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.notimpl(["impala"], raises=AssertionError),
             ],
         ),
         param(
@@ -212,7 +211,6 @@ def test_string_col_is_unicode(alltypes, df):
                     ["mssql", "oracle"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.notimpl(["impala"], raises=AssertionError),
             ],
         ),
         param(
@@ -241,7 +239,6 @@ def test_string_col_is_unicode(alltypes, df):
                     ["mssql", "druid", "oracle"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(["impala"], raises=AssertionError),
             ],
         ),
         param(
@@ -253,7 +250,6 @@ def test_string_col_is_unicode(alltypes, df):
                     ["mssql", "druid", "oracle"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(["impala"], raises=AssertionError),
             ],
         ),
         param(
@@ -277,7 +273,6 @@ def test_string_col_is_unicode(alltypes, df):
                     ["mssql", "druid", "oracle"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(["impala"], raises=AssertionError),
             ],
         ),
         param(
@@ -291,7 +286,6 @@ def test_string_col_is_unicode(alltypes, df):
                     ["mssql", "druid", "oracle"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(["impala"], raises=AssertionError),
             ],
         ),
         param(
@@ -305,7 +299,6 @@ def test_string_col_is_unicode(alltypes, df):
                     ["mssql", "druid", "oracle"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(["impala"], raises=AssertionError),
             ],
         ),
         param(
@@ -319,7 +312,6 @@ def test_string_col_is_unicode(alltypes, df):
                     ["mssql", "druid", "oracle"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(["impala"], raises=AssertionError),
             ],
         ),
         param(
@@ -331,7 +323,6 @@ def test_string_col_is_unicode(alltypes, df):
                     ["mssql", "druid", "oracle"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(["impala"], raises=AssertionError),
             ],
         ),
         param(
@@ -343,7 +334,6 @@ def test_string_col_is_unicode(alltypes, df):
                     ["mssql", "druid", "oracle"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(["impala"], raises=AssertionError),
             ],
         ),
         param(
@@ -370,10 +360,6 @@ def test_string_col_is_unicode(alltypes, df):
                     ["mysql", "mssql", "druid", "oracle"],
                     raises=com.OperationNotDefinedError,
                 ),
-                pytest.mark.broken(
-                    ["impala"],
-                    raises=AssertionError,
-                ),
             ],
         ),
         param(