Repository: ibis-project/ibis

Issue:
bug(duckdb): int-to-timestamp type cast issue in duckdb backend
Consider the following code snippet:
```
import ibis
from sqlalchemy.dialects import postgresql

table = ibis.table(name='A', schema=[('a', 'int64')])
str(ibis.duckdb.compile(
    table['a'].cast('timestamp')
).compile(
    dialect=postgresql.dialect(paramstyle="named"),
    compile_kwargs={"literal_binds": True}
))
```
It produces the following expression:
`'SELECT timezone(\'UTC\', to_timestamp(t0.a)) AS "cast(a, timestamp)" \nFROM "A" AS t0'`
Let us try to use this query to coerce int64 column to timestamp in duckdb:
```
import duckdb
import pyarrow as pa

conn = duckdb.connect(database=':memory:')
arrow_table = pa.table([
    pa.array([1675000234,None,823456789])
    ],
    names=['a']
)
conn.register('A', arrow_table)
conn.execute(
    'SELECT timezone(\'UTC\', to_timestamp(t0.a)) AS "cast(a, timestamp)" \nFROM "A" AS t0'
).fetchall()
```
We get an error:
```
RuntimeError: Binder Error: No function matches the given name and argument types
'timezone(VARCHAR, TIMESTAMP)'. You might need to add explicit type casts.
	Candidate functions:
	timezone(TIMESTAMP WITH TIME ZONE) -> BIGINT
	timezone(DATE) -> BIGINT
	timezone(TIMESTAMP) -> BIGINT
	timezone(INTERVAL) -> BIGINT
LINE 1: SELECT timezone('UTC', to_timestamp(t0.a)) AS ...
```
It might be worth abolishing `timezone()` call, because `conn.execute('SELECT to_timestamp(t0.a) AS "cast(a, timestamp)" \nFROM "A" AS t0').arrow()` works successfully.


Discussion:

Resolving patch:
diff --git a/ibis/backends/base/sql/alchemy/registry.py b/ibis/backends/base/sql/alchemy/registry.py
--- a/ibis/backends/base/sql/alchemy/registry.py
+++ b/ibis/backends/base/sql/alchemy/registry.py
@@ -175,7 +175,7 @@ def _cast(t, expr):
 
     # specialize going from an integer type to a timestamp
     if isinstance(arg.type(), dt.Integer) and isinstance(sa_type, sa.DateTime):
-        return sa.func.timezone('UTC', sa.func.to_timestamp(sa_arg))
+        return t.integer_to_timestamp(sa_arg)
 
     if arg.type().equals(dt.binary) and typ.equals(dt.string):
         return sa.func.encode(sa_arg, 'escape')
diff --git a/ibis/backends/base/sql/alchemy/translator.py b/ibis/backends/base/sql/alchemy/translator.py
--- a/ibis/backends/base/sql/alchemy/translator.py
+++ b/ibis/backends/base/sql/alchemy/translator.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import sqlalchemy as sa
+
 import ibis
 import ibis.expr.datatypes as dt
 import ibis.expr.operations as ops
@@ -46,6 +48,8 @@ class AlchemyExprTranslator(ExprTranslator):
     _bool_aggs_need_cast_to_int32 = True
     _has_reduction_filter_syntax = False
 
+    integer_to_timestamp = sa.func.to_timestamp
+
     def name(self, translated, name, force=True):
         return translated.label(name)
 
diff --git a/ibis/backends/mysql/compiler.py b/ibis/backends/mysql/compiler.py
--- a/ibis/backends/mysql/compiler.py
+++ b/ibis/backends/mysql/compiler.py
@@ -1,3 +1,4 @@
+import sqlalchemy as sa
 import sqlalchemy.dialects.mysql as mysql
 import toolz
 
@@ -29,6 +30,7 @@ class MySQLExprTranslator(AlchemyExprTranslator):
         }
     )
     _bool_aggs_need_cast_to_int32 = False
+    integer_to_timestamp = sa.func.from_unixtime
 
 
 rewrites = MySQLExprTranslator.rewrites


Test patch:
diff --git a/ibis/backends/postgres/tests/test_functions.py b/ibis/backends/postgres/tests/test_functions.py
--- a/ibis/backends/postgres/tests/test_functions.py
+++ b/ibis/backends/postgres/tests/test_functions.py
@@ -125,7 +125,7 @@ def test_timestamp_cast_noop(alltypes, at, translate):
     assert isinstance(result2, ir.TimestampColumn)
 
     expected1 = at.c.timestamp_col
-    expected2 = sa.func.timezone('UTC', sa.func.to_timestamp(at.c.int_col))
+    expected2 = sa.func.to_timestamp(at.c.int_col)
 
     assert str(translate(result1)) == str(expected1)
     assert str(translate(result2)) == str(expected2)
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
@@ -728,3 +728,15 @@ def test_timestamp_extract_milliseconds_with_big_value(con):
     millis = timestamp.millisecond()
     result = con.execute(millis)
     assert result == 333
+
+
+@pytest.mark.notimpl(["datafusion"])
+@pytest.mark.broken(
+    ["dask", "pandas"],
+    reason="Pandas and Dask interpret integers as nanoseconds since epoch",
+)
+def test_integer_cast_to_timestamp(backend, alltypes, df):
+    expr = alltypes.int_col.cast("timestamp")
+    expected = pd.to_datetime(df.int_col, unit="s").rename(expr.get_name())
+    result = expr.execute()
+    backend.assert_series_equal(result, expected)