Problem using custom DateTime type with Postgres

Issue #3560 closed
Peter Taylour created an issue

I haven't used SqlAlchemy much beyond the Flask-SqlAlchemy API, so apologies if this is trivial--and not a bug.

The issue occurs when filtering by a datetime field, which uses a Custom Type UTCDateTime. I can reproduce the problem on Postgres but not with SQLite.

postgres version: PostgreSQL 9.4.5 on x86_64-apple-darwin14.5.0, compiled by Apple LLVM version 7.0.0 (clang-700.0.72), 64-bit

tested sqlalchemy versions: 1.0.9, SQLAlchemy==1.1.0b1.dev0 with psycopg2==2.6.1

from datetime import datetime, timedelta
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import types
from pytz import utc

# setup typical flask-sqlalchemy enabled app
app = Flask(__name__)
app.config.update(
  SQLALCHEMY_DATABASE_URI = 'postgresql://taylop53:@localhost:5432/test',
  SQLALCHEMY_ECHO = False,
  SECRET_KEY = 'a-secret-key',
  DEBUG = True
)
db = SQLAlchemy(app)

# custom datetime type
class UTCDateTime(types.TypeDecorator):
    """ decorator to enforce tz aware DateTime.
        http://stackoverflow.com/questions/79797/how-do-i-convert-local-time-to-utc-in-python
    """
    impl = types.DateTime

    def process_bind_param(self, value, engine):
        if value is not None:
            return value.astimezone(utc)

    def process_result_value(self, value, engine):
        if value is not None:
            return value.replace(tzinfo=utc)

# model that uses UTCDateTime
class EventUTC(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    timestamp = db.Column(UTCDateTime)

    def events_before(self):

        return EventUTC.query.filter(
            (EventUTC.timestamp <= self.timestamp)
        ).all()


def create_sample_events():
    db.drop_all()
    db.create_all()
    # create some utc events
    now = datetime.utcnow().replace(tzinfo=utc)
    events = [
        EventUTC(timestamp=now + timedelta(minutes=t))
        for t in range(-2, 1)
    ]
    for ev in events: db.session.add(ev)
    db.session.commit()


def check_timestamp_is_before(candidate, timestamp):
    print 'evaluating: ', candidate.timestamp, ' <= ', timestamp
    if not (candidate.timestamp <= timestamp):
        print '^^^ failed'

def test_filter_query(event):
    events_before =  event.events_before()
    print 'test for event with timestamp: ', event.timestamp
    for test_ev in events_before:
        check_timestamp_is_before(test_ev, event.timestamp)
    print '\n'


if __name__ == '__main__':
    create_sample_events()
    all_utc_event = EventUTC.query.all()

    # 'for each test event,'
    # 'fetch all events that occur before (or at the same time)'
    # 'Using tz aware datetimes'
    # 'fails when using postgres, succeeds for sqlite'
    map(test_filter_query, all_utc_event)

I'm using a TypeDecorator to enforce UTC timezoneaware datetimes, instead of using the standard DateTime type.

This works with postgres when simply storing and accessing the datetime field.

However, it behaves unexpected when filtering on the datetime field.

Comments (7)

  1. Mike Bayer repo owner

    two things:

    1. instead of linking to a whole github repo, please provide a succinct and self contained script as described here: http://stackoverflow.com/help/mcve

    2. if the sqlalchemy_utils type is working, examine its source to see why your type is not acting as expected.

  2. Peter Taylour reporter

    Hi Mike, thanks for the advice - new to this so really appreciate you help.

    Re: your message 1. I've updating the Issue with a self contained, simplified version of the script. 2. I shall take a look. At first glance it looks to quite similar, but I'll give it a proper comparison tomorrow

  3. Mike Bayer repo owner
    • removed milestone

    any chance you could describe the "unexpected behavior" here? stack trace, or something else? I will see if I can run this to see what we're referring to.

  4. Mike Bayer repo owner

    this is confusing but there's two things going on.

    1. the DateTime type here is timezone-naive, because you aren't specifying "timezone=True". So when you pass a datetime to psycopg2 that has a timezone, it compares to what's in the DB based on the local time of the server. on my side, I get no rows back, on your side you get the wrong ones because I guess you're on the other side of UTC :)

    2. if you do put DateTime(timezone=True), now we are closer, but psycopg2 does not persist the timezone as UTC; it persists with a fixed offset, over here its -420, I'm not sure what he origin of that is but it isn't UTC:

    -- INPUT:
    INSERT INTO event (timestamp) VALUES (%(timestamp)s) RETURNING event.id
    {'timestamp': datetime.datetime(2016, 2, 8, 16, 7, 41, 369721, tzinfo=<UTC>)}
    INSERT INTO event (timestamp) VALUES (%(timestamp)s) RETURNING event.id
    {'timestamp': datetime.datetime(2016, 2, 8, 16, 8, 41, 369721, tzinfo=<UTC>)}
    INSERT INTO event (timestamp) VALUES (%(timestamp)s) RETURNING event.id
    {'timestamp': datetime.datetime(2016, 2, 8, 16, 9, 41, 369721, tzinfo=<UTC>)}
    
    -- OUTPUT:
    SELECT event.id AS event_id, event.timestamp AS event_timestamp 
    FROM event
    ('event_id', 'event_timestamp')
    (1, datetime.datetime(2016, 2, 8, 9, 7, 41, 369721, tzinfo=psycopg2.tz.FixedOffsetTimezone(offset=-420, name=None)))
    (2, datetime.datetime(2016, 2, 8, 9, 8, 41, 369721, tzinfo=psycopg2.tz.FixedOffsetTimezone(offset=-420, name=None)))
    (3, datetime.datetime(2016, 2, 8, 9, 9, 41, 369721, tzinfo=psycopg2.tz.FixedOffsetTimezone(offset=-420, name=None)))
    

    In that case, the tests fails because you are getting back datetimes with an actual tzinfo of -420 but the result_processor is forcing it to "UTC" without any modification, hence fails there too.

    Solution: use timezone-naive datatypes on the database and do not send tzinfo into any queries:

    class UTCDateTime(types.TypeDecorator):
        impl = types.DateTime
    
        def process_bind_param(self, value, engine):
            if value is not None:
                return value.replace(tzinfo=None)
    
        def process_result_value(self, value, engine):
            if value is not None:
                return value.replace(tzinfo=utc)
    
  5. Log in to comment