Repository: ibis-project/ibis

Issue:
bug: bigquery TIMESTAMP type can support timezone
### What happened?

BigQuery has [TIMESTAMP](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type) and [DATETIME](https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#datetime_type) types. Here `DATETIME` is the none timezone version, while the `TIMESTAMP` is `UTC` timezone. However, the latest version of ibis takes `TIMESTAMP` as none timezone and misses `DATETIME` type.

### What version of ibis are you using?

0.14.0

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

BigQuery

### Relevant log output

_No response_

### Code of Conduct

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


Discussion:

Resolving patch:
diff --git a/ibis/backends/bigquery/client.py b/ibis/backends/bigquery/client.py
--- a/ibis/backends/bigquery/client.py
+++ b/ibis/backends/bigquery/client.py
@@ -23,10 +23,9 @@
     "BOOL": dt.boolean,
     "STRING": dt.string,
     "DATE": dt.date,
-    # FIXME: enforce no tz info
-    "DATETIME": dt.timestamp,
+    "DATETIME": dt.Timestamp(timezone=None),
     "TIME": dt.time,
-    "TIMESTAMP": dt.timestamp,
+    "TIMESTAMP": dt.Timestamp(timezone="UTC"),
     "BYTES": dt.binary,
     "NUMERIC": dt.Decimal(38, 9),
     "GEOGRAPHY": dt.GeoSpatial(geotype="geography", srid=4326),
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
@@ -2,6 +2,7 @@
 
 from multipledispatch import Dispatcher
 
+import ibis.common.exceptions as com
 import ibis.expr.datatypes as dt
 
 ibis_type_to_bigquery_type = Dispatcher("ibis_type_to_bigquery_type")
@@ -54,9 +55,14 @@ def trans_date(t):
 
 @ibis_type_to_bigquery_type.register(dt.Timestamp)
 def trans_timestamp(t):
-    if t.timezone is not None:
-        raise TypeError("BigQuery does not support timestamps with timezones")
-    return "TIMESTAMP"
+    if t.timezone is None:
+        return "DATETIME"
+    elif t.timezone == 'UTC':
+        return "TIMESTAMP"
+    else:
+        raise com.UnsupportedOperationError(
+            "BigQuery does not support timestamps with timezones other than 'UTC'"
+        )
 
 
 @ibis_type_to_bigquery_type.register(dt.DataType)
@@ -86,7 +92,7 @@ def trans_json(t):
 def trans_geography(t):
     if (t.geotype, t.srid) == ("geography", 4326):
         return "GEOGRAPHY"
-    raise TypeError(
+    raise com.UnsupportedOperationError(
         "BigQuery geography uses points on WGS84 reference ellipsoid."
         f"Current geotype: {t.geotype}, Current srid: {t.srid}"
     )
diff --git a/ibis/backends/bigquery/registry.py b/ibis/backends/bigquery/registry.py
--- a/ibis/backends/bigquery/registry.py
+++ b/ibis/backends/bigquery/registry.py
@@ -376,9 +376,10 @@ def _geo_simplify(translator, op):
 
 
 STRFTIME_FORMAT_FUNCTIONS = {
-    dt.Date: "DATE",
-    dt.Time: "TIME",
-    dt.Timestamp: "TIMESTAMP",
+    dt.date: "DATE",
+    dt.time: "TIME",
+    dt.Timestamp(timezone=None): "DATETIME",
+    dt.Timestamp(timezone="UTC"): "TIMESTAMP",
 }
 
 
@@ -404,19 +405,26 @@ def compiles_strftime(translator, op):
     arg = op.arg
     format_str = op.format_str
     arg_type = arg.output_dtype
-    strftime_format_func_name = STRFTIME_FORMAT_FUNCTIONS[type(arg_type)]
+    strftime_format_func_name = STRFTIME_FORMAT_FUNCTIONS[arg_type]
     fmt_string = translator.translate(format_str)
     arg_formatted = translator.translate(arg)
-    if isinstance(arg_type, dt.Timestamp):
+    if isinstance(arg_type, dt.Timestamp) and arg_type.timezone is None:
+        return "FORMAT_{}({}, {})".format(
+            strftime_format_func_name,
+            fmt_string,
+            arg_formatted,
+        )
+    elif isinstance(arg_type, dt.Timestamp):
         return "FORMAT_{}({}, {}, {!r})".format(
             strftime_format_func_name,
             fmt_string,
             arg_formatted,
-            arg_type.timezone if arg_type.timezone is not None else "UTC",
+            arg_type.timezone,
+        )
+    else:
+        return "FORMAT_{}({}, {})".format(
+            strftime_format_func_name, fmt_string, arg_formatted
         )
-    return "FORMAT_{}({}, {})".format(
-        strftime_format_func_name, fmt_string, arg_formatted
-    )
 
 
 def compiles_string_to_timestamp(translator, op):


Test patch:
diff --git a/ibis/backends/bigquery/tests/system/test_client.py b/ibis/backends/bigquery/tests/system/test_client.py
--- a/ibis/backends/bigquery/tests/system/test_client.py
+++ b/ibis/backends/bigquery/tests/system/test_client.py
@@ -363,3 +363,22 @@ def test_geography_table(con, temp_table):
     assert temporary_table.schema() == ibis.schema(
         [("col1", dt.GeoSpatial(geotype="geography", srid=4326))]
     )
+
+
+def test_timestamp_table(con, temp_table):
+    schema = ibis.schema(
+        {'datetime_col': dt.Timestamp(), 'timestamp_col': dt.Timestamp(timezone="UTC")}
+    )
+    temporary_table = con.create_table(temp_table, schema=schema)
+    con.raw_sql(
+        f"INSERT {con.current_database}.{temp_table} (datetime_col, timestamp_col) VALUES (CURRENT_DATETIME(), CURRENT_TIMESTAMP())"
+    )
+    df = temporary_table.execute()
+    assert df.shape == (1, 2)
+
+    assert temporary_table.schema() == ibis.schema(
+        [
+            ("datetime_col", dt.Timestamp()),
+            ("timestamp_col", dt.Timestamp(timezone="UTC")),
+        ]
+    )
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
@@ -31,7 +31,7 @@ def alltypes():
                 double_col="float64",
                 date_string_col="string",
                 string_col="string",
-                timestamp_col="timestamp",
+                timestamp_col=dt.Timestamp(timezone="UTC"),
                 year="int32",
                 month="int32",
             )
diff --git a/ibis/backends/bigquery/tests/unit/test_datatypes.py b/ibis/backends/bigquery/tests/unit/test_datatypes.py
--- a/ibis/backends/bigquery/tests/unit/test_datatypes.py
+++ b/ibis/backends/bigquery/tests/unit/test_datatypes.py
@@ -2,6 +2,7 @@
 from multipledispatch.conflict import ambiguities
 from pytest import param
 
+import ibis.common.exceptions as com
 import ibis.expr.datatypes as dt
 from ibis.backends.bigquery.datatypes import (
     ibis_type_to_bigquery_type,
@@ -37,14 +38,19 @@ def test_no_ambiguities():
             id="struct",
         ),
         param(dt.date, "DATE", id="date"),
-        param(dt.timestamp, "TIMESTAMP", id="timestamp"),
+        param(dt.timestamp, "DATETIME", id="datetime"),
+        param(
+            dt.Timestamp(timezone="UTC"),
+            "TIMESTAMP",
+            id="timestamp_with_utc_tz",
+        ),
         param(
             dt.Timestamp(timezone="US/Eastern"),
             "TIMESTAMP",
             marks=pytest.mark.xfail(
-                raises=TypeError, reason="Not supported in BigQuery"
+                raises=com.UnsupportedOperationError, reason="Not supported in BigQuery"
             ),
-            id="timestamp_with_tz",
+            id="timestamp_with_other_tz",
         ),
         param(
             "array<struct<a: string>>", "ARRAY<STRUCT<a STRING>>", id="array<struct>"
@@ -58,7 +64,8 @@ def test_no_ambiguities():
             dt.GeoSpatial(geotype="geography"),
             "GEOGRAPHY",
             marks=pytest.mark.xfail(
-                raises=TypeError, reason="Should use the WGS84 reference ellipsoid."
+                raises=com.UnsupportedOperationError,
+                reason="Should use the WGS84 reference ellipsoid.",
             ),
             id="geography",
         ),
diff --git a/ibis/backends/tests/test_param.py b/ibis/backends/tests/test_param.py
--- a/ibis/backends/tests/test_param.py
+++ b/ibis/backends/tests/test_param.py
@@ -12,6 +12,11 @@
 import ibis.expr.datatypes as dt
 from ibis import _
 
+try:
+    from google.api_core.exceptions import BadRequest as GoogleBadRequest
+except ImportError:
+    GoogleBadRequest = None
+
 
 @pytest.mark.parametrize(
     ('column', 'raw_value'),
@@ -120,21 +125,42 @@ def test_scalar_param_map(con):
             "timestamp",
             "timestamp_col",
             id="string_timestamp",
-            marks=pytest.mark.notimpl(["druid"]),
+            marks=[
+                pytest.mark.notimpl(["druid"]),
+                pytest.mark.broken(
+                    ["bigquery"],
+                    raises=GoogleBadRequest,
+                    reason="No matching for operator = for argument types: DATETIME, TIMESTAMP",
+                ),
+            ],
         ),
         param(
             datetime.date(2009, 1, 20),
             "timestamp",
             "timestamp_col",
             id="date_timestamp",
-            marks=pytest.mark.notimpl(["druid"]),
+            marks=[
+                pytest.mark.notimpl(["druid"]),
+                pytest.mark.broken(
+                    ["bigquery"],
+                    raises=GoogleBadRequest,
+                    reason="No matching for operator = for argument types: DATETIME, TIMESTAMP",
+                ),
+            ],
         ),
         param(
             datetime.datetime(2009, 1, 20, 1, 2, 3),
             "timestamp",
             "timestamp_col",
             id="datetime_timestamp",
-            marks=pytest.mark.notimpl(["druid"]),
+            marks=[
+                pytest.mark.notimpl(["druid"]),
+                pytest.mark.broken(
+                    ["bigquery"],
+                    raises=GoogleBadRequest,
+                    reason="No matching for operator = for argument types: DATETIME, TIMESTAMP",
+                ),
+            ],
         ),
     ],
 )
diff --git a/ibis/backends/tests/test_temporal.py b/ibis/backends/tests/test_temporal.py
--- a/ibis/backends/tests/test_temporal.py
+++ b/ibis/backends/tests/test_temporal.py
@@ -277,6 +277,11 @@ def test_timestamp_extract_milliseconds(backend, alltypes, df):
     raises=AttributeError,
     reason="'StringColumn' object has no attribute 'epoch_seconds'",
 )
+@pytest.mark.broken(
+    ["bigquery"],
+    raises=GoogleBadRequest,
+    reason="UNIX_SECONDS does not support DATETIME arguments",
+)
 def test_timestamp_extract_epoch_seconds(backend, alltypes, df):
     expr = alltypes.timestamp_col.epoch_seconds().name('tmp')
     result = expr.execute()
@@ -1195,11 +1200,6 @@ def test_temporal_binop_pandas_timedelta(
 
 
 @pytest.mark.parametrize("func_name", ["gt", "ge", "lt", "le", "eq", "ne"])
-@pytest.mark.notimpl(
-    ["bigquery"],
-    raises=TypeError,
-    reason="BigQuery does not support timestamps with timezones",
-)
 @pytest.mark.notimpl(
     ["polars"],
     raises=TypeError,
@@ -1292,11 +1292,6 @@ def test_timestamp_comparison_filter(backend, con, alltypes, df, func_name):
         "ne",
     ],
 )
-@pytest.mark.notimpl(
-    ["bigquery"],
-    raises=TypeError,
-    reason="BigQuery does not support timestamps with timezones",
-)
 @pytest.mark.broken(
     ["druid"],
     raises=AttributeError,
@@ -1842,18 +1837,6 @@ def test_timestamp_literal(con, backend):
         assert con.execute(expr.typeof()) == TIMESTAMP_BACKEND_TYPES[backend_name]
 
 
-TIMESTAMP_TIMEZONE_BACKEND_TYPES = {
-    'bigquery': "DATETIME",
-    'clickhouse': 'Nullable(DateTime64(3))',
-    'impala': 'TIMESTAMP',
-    'snowflake': 'TIMESTAMP_NTZ',
-    'sqlite': "text",
-    'trino': 'timestamp(3) with time zone',
-    "duckdb": "TIMESTAMP WITH TIME ZONE",
-    "postgres": "timestamp with time zone",
-}
-
-
 @pytest.mark.notimpl(
     ["pandas", "datafusion", "mysql", "dask", "pyspark"],
     raises=com.OperationNotDefinedError,
@@ -1903,8 +1886,8 @@ def test_timestamp_literal(con, backend):
 )
 @pytest.mark.notimpl(
     ['bigquery'],
-    "BigQuery does not support timestamps with timezones",
-    raises=TypeError,
+    "BigQuery does not support timestamps with timezones other than 'UTC'",
+    raises=com.UnsupportedOperationError,
 )
 @pytest.mark.notimpl(
     ["druid"],