Repository: ibis-project/ibis

Issue:
bug(bigquery): ibis.memtable doesn't escape field names
### What happened?

If I provide a DataFrame with spaces in the column names, Ibis BigQuery backend generates invalid SQL.

```python
import ibis
import pandas as pd

df = pd.DataFrame({"Column One": [1, 2, 3]})
table = ibis.memtable(df)
table
bq = ibis.bigquery.connect()
print(bq.compile(table))
```

Output:

```
SELECT t0.*
FROM UNNEST(ARRAY<STRUCT<Column One INT64>>[STRUCT(1 AS Column One), STRUCT(2 AS Column One), STRUCT(3 AS Column One)]) t0
```

Instead, I would expect the following query:

```
SELECT t0.*
FROM UNNEST(ARRAY<STRUCT<`Column One` INT64>>[STRUCT(1 AS `Column One`), STRUCT(2 AS `Column One`), STRUCT(3 AS `Column One`)]) t0
```

### What version of ibis are you using?

6.2.0

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

BigQuery

### Relevant log output

```sh
In [10]: bq.execute(table)
---------------------------------------------------------------------------
BadRequest                                Traceback (most recent call last)
Cell In[10], line 1
----> 1 bq.execute(table)

File /opt/miniconda3/envs/bigframes/lib/python3.10/site-packages/ibis/backends/bigquery/__init__.py:380, in Backend.execute(self, expr, params, limit, **kwargs)
    378 sql = query_ast.compile()
    379 self._log(sql)
--> 380 cursor = self.raw_sql(sql, params=params, **kwargs)
    382 result = self.fetch_from_cursor(cursor, expr.as_table().schema())
    384 return expr.__pandas_result__(result)

File /opt/miniconda3/envs/bigframes/lib/python3.10/site-packages/ibis/backends/bigquery/__init__.py:327, in Backend.raw_sql(self, query, results, params)
    314 def raw_sql(self, query: str, results=False, params=None):
    315     query_parameters = [
    316         bigquery_param(
    317             param.type(),
   (...)
    325         for param, value in (params or {}).items()
    326     ]
--> 327     return self._execute(query, results=results, query_parameters=query_parameters)

File /opt/miniconda3/envs/bigframes/lib/python3.10/site-packages/ibis/backends/bigquery/__init__.py:311, in Backend._execute(self, stmt, results, query_parameters)
    307 job_config.use_legacy_sql = False  # False by default in >=0.28
    308 query = self.client.query(
    309     stmt, job_config=job_config, project=self.billing_project
    310 )
--> 311 query.result()  # blocks until finished
    312 return BigQueryCursor(query)

File /opt/miniconda3/envs/bigframes/lib/python3.10/site-packages/google/cloud/bigquery/job/query.py:1520, in QueryJob.result(self, page_size, max_results, retry, timeout, start_index, job_retry)
   1517     if retry_do_query is not None and job_retry is not None:
   1518         do_get_result = job_retry(do_get_result)
-> 1520     do_get_result()
   1522 except exceptions.GoogleAPICallError as exc:
   1523     exc.message = _EXCEPTION_FOOTER_TEMPLATE.format(
   1524         message=exc.message, location=self.location, job_id=self.job_id
   1525     )

File /opt/miniconda3/envs/bigframes/lib/python3.10/site-packages/google/api_core/retry.py:349, in Retry.__call__.<locals>.retry_wrapped_func(*args, **kwargs)
    345 target = functools.partial(func, *args, **kwargs)
    346 sleep_generator = exponential_sleep_generator(
    347     self._initial, self._maximum, multiplier=self._multiplier
    348 )
--> 349 return retry_target(
    350     target,
    351     self._predicate,
    352     sleep_generator,
    353     self._timeout,
    354     on_error=on_error,
    355 )

File /opt/miniconda3/envs/bigframes/lib/python3.10/site-packages/google/api_core/retry.py:191, in retry_target(target, predicate, sleep_generator, timeout, on_error, **kwargs)
    189 for sleep in sleep_generator:
    190     try:
--> 191         return target()
    193     # pylint: disable=broad-except
    194     # This function explicitly must deal with broad exceptions.
    195     except Exception as exc:

File /opt/miniconda3/envs/bigframes/lib/python3.10/site-packages/google/cloud/bigquery/job/query.py:1510, in QueryJob.result.<locals>.do_get_result()
   1507     self._retry_do_query = retry_do_query
   1508     self._job_retry = job_retry
-> 1510 super(QueryJob, self).result(retry=retry, timeout=timeout)
   1512 # Since the job could already be "done" (e.g. got a finished job
   1513 # via client.get_job), the superclass call to done() might not
   1514 # set the self._query_results cache.
   1515 self._reload_query_results(retry=retry, timeout=timeout)

File /opt/miniconda3/envs/bigframes/lib/python3.10/site-packages/google/cloud/bigquery/job/base.py:911, in _AsyncJob.result(self, retry, timeout)
    908     self._begin(retry=retry, timeout=timeout)
    910 kwargs = {} if retry is DEFAULT_RETRY else {"retry": retry}
--> 911 return super(_AsyncJob, self).result(timeout=timeout, **kwargs)

File /opt/miniconda3/envs/bigframes/lib/python3.10/site-packages/google/api_core/future/polling.py:261, in PollingFuture.result(self, timeout, retry, polling)
    256 self._blocking_poll(timeout=timeout, retry=retry, polling=polling)
    258 if self._exception is not None:
    259     # pylint: disable=raising-bad-type
    260     # Pylint doesn't recognize that this is valid in this case.
--> 261     raise self._exception
    263 return self._result

BadRequest: 400 Syntax error: Expected "," or ">" but got identifier "INT64" at [2:37]

Location: US
Job ID: ad11045a-9e1c-42b9-bf86-5b7ac1545806
```


### Code of Conduct

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


Discussion:

Resolving patch:
diff --git a/ibis/backends/bigquery/compiler.py b/ibis/backends/bigquery/compiler.py
--- a/ibis/backends/bigquery/compiler.py
+++ b/ibis/backends/bigquery/compiler.py
@@ -5,6 +5,7 @@
 import re
 from functools import partial
 
+import sqlglot as sg
 import toolz
 
 import ibis.common.graph as lin
@@ -66,7 +67,6 @@ def find_bigquery_udf(op):
 
 
 _NAME_REGEX = re.compile(r'[^!"$()*,./;?@[\\\]^`{}~\n]+')
-_EXACT_NAME_REGEX = re.compile(f"^{_NAME_REGEX.pattern}$")
 
 
 class BigQueryExprTranslator(sql_compiler.ExprTranslator):
@@ -125,9 +125,7 @@ def _rewrite_notany(op):
 
 class BigQueryTableSetFormatter(sql_compiler.TableSetFormatter):
     def _quote_identifier(self, name):
-        if _EXACT_NAME_REGEX.match(name) is not None:
-            return name
-        return f"`{name}`"
+        return sg.to_identifier(name).sql("bigquery")
 
     def _format_in_memory_table(self, op):
         import ibis
diff --git a/ibis/backends/bigquery/datatypes.py b/ibis/backends/bigquery/datatypes.py
--- a/ibis/backends/bigquery/datatypes.py
+++ b/ibis/backends/bigquery/datatypes.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import google.cloud.bigquery as bq
+import sqlglot as sg
 
 import ibis.expr.datatypes as dt
 import ibis.expr.schema as sch
@@ -76,7 +77,10 @@ def from_ibis(cls, dtype: dt.DataType) -> str:
         elif dtype.is_array():
             return f"ARRAY<{cls.from_ibis(dtype.value_type)}>"
         elif dtype.is_struct():
-            fields = (f"{k} {cls.from_ibis(v)}" for k, v in dtype.fields.items())
+            fields = (
+                f"{sg.to_identifier(k).sql('bigquery')} {cls.from_ibis(v)}"
+                for k, v in dtype.fields.items()
+            )
             return "STRUCT<{}>".format(", ".join(fields))
         elif dtype.is_json():
             return "JSON"


Test patch:
diff --git a/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_compile_in_memory_table/out.sql b/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_compile_in_memory_table/out.sql
new file mode 100644
--- /dev/null
+++ b/ibis/backends/bigquery/tests/unit/snapshots/test_compiler/test_compile_in_memory_table/out.sql
@@ -0,0 +1,3 @@
+SELECT
+  t0.*
+FROM UNNEST(ARRAY<STRUCT<`Column One` INT64>>[STRUCT(1 AS `Column One`), STRUCT(2 AS `Column One`), STRUCT(3 AS `Column One`)]) AS t0
\ 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
@@ -628,3 +628,9 @@ def test_unnest(snapshot):
         ).select(level_two=lambda t: t.level_one.unnest())
     )
     snapshot.assert_match(result, "out_two_unnests.sql")
+
+
+def test_compile_in_memory_table(snapshot):
+    t = ibis.memtable({"Column One": [1, 2, 3]})
+    result = ibis.bigquery.compile(t)
+    snapshot.assert_match(result, "out.sql")