Search code examples
ruby-on-railspostgresqlintervalsdstpostgresql-9.4

TIMESTAMP WITHOUT TIME ZONE, INTERVAL and DST extravaganza


I'm working on a Rails application which stores all dates to PostgreSQL as "TIMESTAMP WITHOUT TIME ZONE". (Rails handles the time zone on the application layer which for this application is "Europe/Berlin".) Unfortunately, Daylight Savings Time (DST) becomes an issue.

The simplified "projects" table has the following columns:

started_at TIMESTAMP WITHOUT TIME ZONE
duration INTEGER

Projects start at started_at and run for duration days.

Now, say there's only one project which starts on 2015-01-01 at 10:00. Since this is "Europe/Berlin" and it's January (no DST), the record looks like this on the database:

SET TimeZone = 'UTC';
SELECT started_at from projects;
# => 2015-01-01 09:00:00

It should end on 2015-06-30 at 10:00 (Europe/Berlin). But it's summer now, so DST applies and 10:00 in "Europe/Berlin" is now 08:00 in UTC.

Due to this, finding all projects for which the duration has elapsed by use of the following query does not work for projects which start/end across DST boundaries:

SELECT * FROM projects WHERE started_at + INTERVAL '1 day' * duration < NOW()

I guess it would be best if the above WHERE did the calculation in timezone "Europe/Berlin" rather than "UTC". I've tried a few things with ::TIMESTAMTZ and AT TIME ZONE none of which has worked.

As a side note: According to the PostgreSQL docs, + INTERVAL should deal with '1 day' intervals differently from '24 hours' intervals when it comes to DST. Adding days ignores DST, so 10:00 always stays 10:00. When adding hours on the other hand, 10:00 may become 09:00 or 11:00 if you cross the DST boundary one way or another.

Thanks a lot for any hints!


Solution

  • I've created a function which calculates ended_at by adding duration days to started_at honoring DST changes of a given time zone. Both started_at and ended_at, however, are in UTC and therefore play nice with Rails.

    It turns started_at (timestamp without time zone, implicit UTC by Rails) to a timestamp with time zone UTC, then to the given time zone, adds the duration and returns the timestamp without time zone (implicit UTC).

    # ended_at(started_at, duration, time_zone)
    CREATE FUNCTION ended_at(timestamp, integer, text = 'Europe/Zurich') RETURNS timestamp AS $$
      SELECT (($1::timestamp AT TIME ZONE 'UTC' AT TIME ZONE $3 + INTERVAL '1 day' * $2) AT TIME ZONE $3)::timestamp
    $$  LANGUAGE SQL IMMUTABLE SET search_path = public, pg_temp;
    

    With this function, I can omit having to add ended_at as an explicit column which would have to be kept in sync. And it's easy to use:

    SELECT ended_at(started_at, duration) FROM projects