Repository: ibis-project/ibis

Issue:
feat(duckdb): add support for more granular timestamps 
### What happened?

In 0.9, DuckDB seems to have introduced more granular timestamps, at least I've never seen them before and also the docs did not mention them. In [0.8 it was just TIMESTAMP and TIMESTAMPTZ](https://duckdb.org/docs/archive/0.9.1/sql/data_types/timestamp), in [0.9 there is also TIMESTAMP_NS, TIMESTAMP_MS, and TIMESTAMP_S](https://duckdb.org/docs/archive/0.9.1/sql/data_types/timestamp). These types currently raise a `KeyError` as `DuckDBType.from_string` does not recognize them.

Below a minimal example. I noticed it when getting data from Snowflake, converting it to a Pandas dataframe and then reading it in with a Duckdb connection and `read_in_memory`. However, that's hard to reproduce here.

```python
import ibis
con = ibis.duckdb.connect()
con.sql("select cast('2012-01-31' as timestamp_ns)")
```


### What version of ibis are you using?

7.0.0

### What backend(s) are you using, if any?

DuckDB 0.9.1

### Relevant log output

```sh
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
/workspaces/earl/development.ipynb Cell 3 line 1
----> [1](vscode-notebook-cell://dev-container%2B7b22686f737450617468223a222f686f6d652f6569702f6769745f7265706f732f6561726c222c226c6f63616c446f636b6572223a66616c73652c22636f6e66696746696c65223a7b22246d6964223a312c2270617468223a222f686f6d652f6569702f6769745f7265706f732f6561726c2f2e646576636f6e7461696e65722f646576636f6e7461696e65722e6a736f6e222c22736368656d65223a227673636f64652d66696c65486f7374227d7d@ssh-remote%2Bg7eip0-debist01/workspaces/earl/development.ipynb#Y114sdnNjb2RlLXJlbW90ZQ%3D%3D?line=0) con.sql("select cast('2012-01-31' as timestamp_ns)")

File /usr/local/lib/python3.11/site-packages/ibis/backends/base/sql/__init__.py:123, in BaseSQLBackend.sql(self, query, schema, dialect)
    121 query = self._transpile_sql(query, dialect=dialect)
    122 if schema is None:
--> 123     schema = self._get_schema_using_query(query)
    124 else:
    125     schema = sch.schema(schema)

File /usr/local/lib/python3.11/site-packages/ibis/backends/base/sql/alchemy/__init__.py:844, in BaseAlchemyBackend._get_schema_using_query(self, query)
    842 def _get_schema_using_query(self, query: str) -> sch.Schema:
    843     """Return an ibis Schema from a backend-specific SQL string."""
--> 844     return sch.Schema.from_tuples(self._metadata(query))

File /usr/local/lib/python3.11/site-packages/ibis/expr/schema.py:118, in Schema.from_tuples(cls, values)
     92 @classmethod
     93 def from_tuples(
     94     cls,
     95     values: Iterable[tuple[str, str | dt.DataType]],
     96 ) -> Schema:
     97     """Construct a `Schema` from an iterable of pairs.
     98 
     99     Parameters
   (...)
    116     }
    117     """
--> 118     pairs = list(values)
    119     if len(pairs) == 0:
    120         return cls({})

File /usr/local/lib/python3.11/site-packages/ibis/backends/duckdb/__init__.py:1074, in Backend._metadata(self, query)
   1070 for name, type, null in toolz.pluck(
   1071     ["column_name", "column_type", "null"], rows.mappings()
   1072 ):
   1073     nullable = null.lower() == "yes"
-> 1074     ibis_type = DuckDBType.from_string(type, nullable=nullable)
   1075     yield name, ibis_type

File /usr/local/lib/python3.11/site-packages/ibis/backends/duckdb/datatypes.py:60, in DuckDBType.from_string(cls, type_string, nullable)
     58 @classmethod
     59 def from_string(cls, type_string, nullable=True):
---> 60     return SqlglotDuckdbType.from_string(type_string, nullable=nullable)

File /usr/local/lib/python3.11/site-packages/ibis/backends/base/sqlglot/datatypes.py:178, in SqlglotType.from_string(cls, text, nullable)
    175     return dtype
    177 sgtype = sg.parse_one(text, into=sge.DataType, read=cls.dialect)
--> 178 return cls.to_ibis(sgtype, nullable=nullable)

File /usr/local/lib/python3.11/site-packages/ibis/backends/base/sqlglot/datatypes.py:156, in SqlglotType.to_ibis(cls, typ, nullable)
    154     dtype = method(*typ.expressions)
    155 else:
--> 156     dtype = _from_sqlglot_types[typecode](nullable=cls.default_nullable)
    158 if nullable is not None:
    159     return dtype.copy(nullable=nullable)

KeyError: <Type.TIMESTAMP_NS: 'TIMESTAMP_NS'>
```


### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
Yeah! This is a new issue. I was actually the person who added the support for these in sqlglot, but I forgot to bring that support to ibis 😁

Resolving patch:
diff --git a/ibis/backends/base/sqlglot/datatypes.py b/ibis/backends/base/sqlglot/datatypes.py
--- a/ibis/backends/base/sqlglot/datatypes.py
+++ b/ibis/backends/base/sqlglot/datatypes.py
@@ -352,6 +352,26 @@ class DuckDBType(SqlglotType):
     default_decimal_scale = 3
     default_interval_precision = "us"
 
+    @classmethod
+    def _from_sqlglot_TIMESTAMP(cls) -> dt.Timestamp:
+        return dt.Timestamp(scale=6, nullable=cls.default_nullable)
+
+    @classmethod
+    def _from_sqlglot_TIMESTAMPTZ(cls) -> dt.Timestamp:
+        return dt.Timestamp(scale=6, timezone="UTC", nullable=cls.default_nullable)
+
+    @classmethod
+    def _from_sqlglot_TIMESTAMP_S(cls) -> dt.Timestamp:
+        return dt.Timestamp(scale=0, nullable=cls.default_nullable)
+
+    @classmethod
+    def _from_sqlglot_TIMESTAMP_MS(cls) -> dt.Timestamp:
+        return dt.Timestamp(scale=3, nullable=cls.default_nullable)
+
+    @classmethod
+    def _from_sqlglot_TIMESTAMP_NS(cls) -> dt.Timestamp:
+        return dt.Timestamp(scale=9, nullable=cls.default_nullable)
+
 
 class TrinoType(SqlglotType):
     dialect = "trino"


Test patch:
diff --git a/ibis/backends/duckdb/tests/test_client.py b/ibis/backends/duckdb/tests/test_client.py
--- a/ibis/backends/duckdb/tests/test_client.py
+++ b/ibis/backends/duckdb/tests/test_client.py
@@ -3,9 +3,12 @@
 import duckdb
 import pytest
 import sqlalchemy as sa
+from pytest import param
 
 import ibis
+import ibis.expr.datatypes as dt
 from ibis.conftest import LINUX, SANDBOXED
+from ibis.util import gen_name
 
 
 @pytest.fixture(scope="session")
@@ -113,3 +116,19 @@ def test_attach_detach(tmpdir):
 
     with pytest.raises(sa.exc.ProgrammingError):
         con2.detach(name)
+
+
+@pytest.mark.parametrize(
+    "scale",
+    [
+        None,
+        param(0, id="seconds"),
+        param(3, id="millis"),
+        param(6, id="micros"),
+        param(9, id="nanos"),
+    ],
+)
+def test_create_table_with_timestamp_scales(con, scale):
+    schema = ibis.schema(dict(ts=dt.Timestamp(scale=scale)))
+    t = con.create_table(gen_name("duckdb_timestamp_scale"), schema=schema, temp=True)
+    assert t.schema() == schema
diff --git a/ibis/backends/duckdb/tests/test_datatypes.py b/ibis/backends/duckdb/tests/test_datatypes.py
--- a/ibis/backends/duckdb/tests/test_datatypes.py
+++ b/ibis/backends/duckdb/tests/test_datatypes.py
@@ -29,8 +29,8 @@
             ("SMALLINT", dt.int16),
             ("TIME", dt.time),
             ("TIME WITH TIME ZONE", dt.time),
-            ("TIMESTAMP", dt.timestamp),
-            ("TIMESTAMP WITH TIME ZONE", dt.Timestamp("UTC")),
+            ("TIMESTAMP", dt.Timestamp(scale=6)),
+            ("TIMESTAMP WITH TIME ZONE", dt.Timestamp(scale=6, timezone="UTC")),
             ("TINYINT", dt.int8),
             ("UBIGINT", dt.uint64),
             ("UINTEGER", dt.uint32),
@@ -53,6 +53,9 @@
             ("INTEGER[][]", dt.Array(dt.Array(dt.int32))),
             ("JSON", dt.json),
             ("HUGEINT", dt.Decimal(38, 0)),
+            ("TIMESTAMP_S", dt.Timestamp(scale=0)),
+            ("TIMESTAMP_MS", dt.Timestamp(scale=3)),
+            ("TIMESTAMP_NS", dt.Timestamp(scale=9)),
         ]
     ],
 )