diff --git a/docs/data-types.rst b/docs/data-types.rst index da05ae4..e3b45ff 100644 --- a/docs/data-types.rst +++ b/docs/data-types.rst @@ -20,6 +20,26 @@ Data types ``date`` or ``datetime`` objects, timezone information will not be preserved. If you need to store it, you will need to use a separate column. +.. NOTE:: + + Inserting timezone-aware ``datetime`` objects is supported; the value is + converted to a UTC instant on the way in, as outlined above. On the way + out, the dialect returns **naive** ``datetime`` objects in UTC by default. + + To read values back as timezone-aware ``datetime`` objects instead, + configure the CrateDB driver's ``time_zone`` argument, for example:: + + from sqlalchemy import create_engine + + engine = create_engine( + "crate://localhost:4200", + connect_args={"time_zone": "+0530"}, + ) + + The driver then converts ``TIMESTAMP`` columns to timezone-aware + ``datetime`` objects transparently. See `TIMESTAMP conversion with time + zone`_ in the driver documentation for the accepted ``time_zone`` values. + .. _data-types-sqlalchemy: @@ -88,4 +108,5 @@ __ https://cratedb.com/docs/crate/reference/en/latest/general/ddl/data-types.htm __ https://cratedb.com/docs/crate/reference/en/latest/general/ddl/data-types.html#geo-shape +.. _TIMESTAMP conversion with time zone: https://cratedb.com/docs/python/en/latest/query.html#timestamp-conversion-with-time-zone .. _Unix time: https://en.wikipedia.org/wiki/Unix_time diff --git a/src/sqlalchemy_cratedb/dialect.py b/src/sqlalchemy_cratedb/dialect.py index 70a268a..2434593 100644 --- a/src/sqlalchemy_cratedb/dialect.py +++ b/src/sqlalchemy_cratedb/dialect.py @@ -144,6 +144,11 @@ def result_processor(self, dialect, coltype): def process(value): if not value: return None + # When the driver's `time_zone` is configured, the timestamp column + # has already been converted to `datetime` by the driver's data type converter. + # Pass it through unchanged so this processor stays idempotent. + if isinstance(value, datetime): + return value try: return datetime.utcfromtimestamp(value / 1e3) except TypeError: diff --git a/tests/datetime_test.py b/tests/datetime_test.py index ebb55f8..df994e7 100644 --- a/tests/datetime_test.py +++ b/tests/datetime_test.py @@ -232,6 +232,41 @@ def test_datetime_date(session): assert result["datetime_tz"] == dt.datetime(2009, 5, 13, 0, 0, 0) +@pytest.mark.skipif(SA_VERSION < SA_1_4, reason="Test case not supported on SQLAlchemy 1.3") +def test_datetime_tz_aware_read(session, cratedb_service): + """ + With the driver's `time_zone` configured, `TIMESTAMP` columns read back as + timezone-aware `datetime` objects through the dialect. + + Regression test for https://github.com/crate/sqlalchemy-cratedb/issues/92: + when the driver converts the column to a `datetime` itself, the dialect's + result processor must pass it through unchanged + """ + aware = dt.datetime(2020, 6, 23, 12, 0, 0, tzinfo=dt.timezone(dt.timedelta(hours=2))) + + # Write + session.add(FooBar(name="tz", datetime_tz=aware)) + session.commit() + session.execute(sa.text("REFRESH TABLE foobar")) + + # Read via an engine whose driver returns timezone-aware datetimes. + aware_engine = sa.create_engine( + cratedb_service.database.engine.url, connect_args={"time_zone": "+0530"} + ) + try: + with aware_engine.connect() as conn: + result = conn.execute( + sa.select(FooBar.datetime_tz).where(FooBar.name == "tz") + ).scalar_one() + finally: + aware_engine.dispose() + + # Same instant (10:00 UTC), now timezone-aware at the requested offset. + assert result.tzinfo is not None + assert result.utcoffset() == dt.timedelta(hours=5, minutes=30) + assert result == dt.datetime(2020, 6, 23, 10, 0, 0, tzinfo=dt.timezone.utc) + + @pytest.mark.skipif(SA_VERSION < SA_1_4, reason="Test case not supported on SQLAlchemy 1.3") def test_time(session): """