Repository: ibis-project/ibis

Issue:
BUG: PostgreSQL interval type not recognized
I believe this would actually be an issue with `sqlalchemy`, but I noticed that a warning is issued when one tries to reference a Postgres table with a column of interval data type (the warning says that the type isn't recognized).

Here is the DDL for such a table from the Postgres documentation (https://www.postgresql.org/docs/9.1/static/sql-createtable.html):

```
create table films (
    code        char(5) constraint firstkey primary key,
    title       varchar(40) not null,
    did         integer not null,
    date_prod   date,
    kind        varchar(10),
    len         interval hour to minute
);
```

If we then try to refer to this table using `ibis` (after creating it in the public schema of a local database called postgres) we see that the len column has an unrecognized type and we end up with a null data type:

```
import ibis

conn = ibis.postgres.connect(database="postgres")
conn.table("films")
```


Discussion:
I just prototyped support for this and I think we can get this in for the next release.

Resolving patch:
diff --git a/ci/datamgr.py b/ci/datamgr.py
--- a/ci/datamgr.py
+++ b/ci/datamgr.py
@@ -287,7 +287,14 @@ def mapd(schema, tables, data_directory, **params):
             port=params['port'],
             dbname='mapd',
         )
-        stmt = 'CREATE DATABASE {}'.format(params['database'])
+        database = params["database"]
+        stmt = "DROP DATABASE {}".format(database)
+        try:
+            conn.execute(stmt)
+        except Exception:
+            logger.warning('MapD DDL statement %r failed', stmt)
+
+        stmt = 'CREATE DATABASE {}'.format(database)
         try:
             conn.execute(stmt)
         except Exception:
@@ -299,7 +306,7 @@ def mapd(schema, tables, data_directory, **params):
         user=params['user'],
         password=params['password'],
         port=params['port'],
-        dbname=params['database'],
+        dbname=database,
     )
 
     # create tables
@@ -343,7 +350,6 @@ def mapd(schema, tables, data_directory, **params):
             conn.load_table_columnar(table, df)
 
     conn.close()
-    logger.info('Done!')
 
 
 @cli.command()
diff --git a/ibis/sql/alchemy.py b/ibis/sql/alchemy.py
--- a/ibis/sql/alchemy.py
+++ b/ibis/sql/alchemy.py
@@ -122,6 +122,38 @@ def sa_double(_, satype, nullable=True):
     return dt.Double(nullable=nullable)
 
 
+POSTGRES_FIELD_TO_IBIS_UNIT = {
+    "YEAR": "Y",
+    "MONTH": "M",
+    "DAY": "D",
+    "HOUR": "h",
+    "MINUTE": "m",
+    "SECOND": "s",
+    "YEAR TO MONTH": "M",
+    "DAY TO HOUR": "h",
+    "DAY TO MINUTE": "m",
+    "DAY TO SECOND": "s",
+    "HOUR TO MINUTE": "m",
+    "HOUR TO SECOND": "s",
+    "MINUTE TO SECOND": "s",
+}
+
+
+@dt.dtype.register(PostgreSQLDialect, sa.dialects.postgresql.INTERVAL)
+def sa_postgres_interval(_, satype, nullable=True):
+    field = satype.fields.upper()
+    unit = POSTGRES_FIELD_TO_IBIS_UNIT.get(field, None)
+    if unit is None:
+        raise ValueError(
+            "Unknown PostgreSQL interval field {!r}".format(field)
+        )
+    elif unit in {"Y", "M"}:
+        raise ValueError(
+            "Variable length timedeltas are not yet supported with PostgreSQL"
+        )
+    return dt.Interval(unit=unit, nullable=nullable)
+
+
 @dt.dtype.register(MySQLDialect, sa.dialects.mysql.DOUBLE)
 def sa_mysql_double(_, satype, nullable=True):
     # TODO: handle asdecimal=True


Test patch:
diff --git a/ibis/sql/postgres/tests/conftest.py b/ibis/sql/postgres/tests/conftest.py
--- a/ibis/sql/postgres/tests/conftest.py
+++ b/ibis/sql/postgres/tests/conftest.py
@@ -66,6 +66,11 @@ def at(alltypes):
     return alltypes.op().sqla_table
 
 
+@pytest.fixture(scope='module')
+def intervals(con):
+    return con.table("intervals")
+
+
 @pytest.fixture
 def translate():
     from ibis.sql.postgres.compiler import PostgreSQLDialect
diff --git a/ibis/sql/postgres/tests/test_client.py b/ibis/sql/postgres/tests/test_client.py
--- a/ibis/sql/postgres/tests/test_client.py
+++ b/ibis/sql/postgres/tests/test_client.py
@@ -13,13 +13,18 @@
 # limitations under the License.
 
 import os
+
 import pytest
+
+import numpy as np
 import pandas as pd
 
-from ibis.tests.util import assert_equal
+import ibis.expr.datatypes as dt
 import ibis.expr.types as ir
 import ibis
 
+from ibis.tests.util import assert_equal
+
 pytest.importorskip('sqlalchemy')
 pytest.importorskip('psycopg2')
 
@@ -131,3 +136,65 @@ def test_schema_table():
     schema = con.schema('information_schema')
 
     assert isinstance(schema['tables'], ir.TableExpr)
+
+
+def test_interval_films_schema(con):
+    t = con.table("films")
+    assert t.len.type() == dt.Interval(unit="m")
+    assert t.len.execute().dtype == np.dtype("timedelta64[ns]")
+
+
+@pytest.mark.parametrize(
+    ("column", "expected_dtype"),
+    [
+        # ("a", dt.Interval("Y")),
+        # ("b", dt.Interval("M")),
+        ("c", dt.Interval("D")),
+        ("d", dt.Interval("h")),
+        ("e", dt.Interval("m")),
+        ("f", dt.Interval("s")),
+        # ("g", dt.Interval("M")),
+        ("h", dt.Interval("h")),
+        ("i", dt.Interval("m")),
+        ("j", dt.Interval("s")),
+        ("k", dt.Interval("m")),
+        ("l", dt.Interval("s")),
+        ("m", dt.Interval("s")),
+    ],
+)
+def test_all_interval_types_schema(intervals, column, expected_dtype):
+    assert intervals[column].type() == expected_dtype
+
+
+@pytest.mark.parametrize(
+    ("column", "expected_dtype"),
+    [
+        # ("a", dt.Interval("Y")),
+        # ("b", dt.Interval("M")),
+        ("c", dt.Interval("D")),
+        ("d", dt.Interval("h")),
+        ("e", dt.Interval("m")),
+        ("f", dt.Interval("s")),
+        # ("g", dt.Interval("M")),
+        ("h", dt.Interval("h")),
+        ("i", dt.Interval("m")),
+        ("j", dt.Interval("s")),
+        ("k", dt.Interval("m")),
+        ("l", dt.Interval("s")),
+        ("m", dt.Interval("s")),
+    ],
+)
+def test_all_interval_types_execute(intervals, column, expected_dtype):
+    expr = intervals[column]
+    series = expr.execute()
+    assert series.dtype == np.dtype("timedelta64[ns]")
+
+
+@pytest.mark.xfail(
+    raises=ValueError, reason="Year and month interval types not yet supported"
+)
+def test_unsupported_intervals(con):
+    t = con.table("not_supported_intervals")
+    assert t["a"].type() == dt.Interval("Y")
+    assert t["b"].type() == dt.Interval("M")
+    assert t["g"].type() == dt.Interval("M")