How do I use the AT TIME ZONE method while using a joins table?
scope :not_reserved_between, ->(start_at, end_at) { ids = Reservation.joins(:ride).where(":e >= (reservations.start_at AT TIME ZONE rides.time_zone_name)::timestamp AND :s <= (reservations.end_at AT TIME ZONE rides.time_zone_name)::timestamp", s: start_at.utc, e: end_at.utc).pluck(:ride_id); where('rides.id not in (?)', ids) }
In this query I'm taking the reservations.start_at and end_at times and I want to convert them to a different timezone (the time_zone_name in the joined rides table)
Every reservation has a ride and will thus have a time_zone_name. I want to apply that ride's time_zone_name to the reservation's start_at and end_at stamps. If I do:
scope :not_reserved_between, ->(start_at, end_at) { ids = Reservation.joins(:ride).where(":e >= (reservations.start_at AT TIME ZONE rides.time_zone_name)::timestamp AND :s <= (reservations.end_at)::timestamp", s: start_at.utc, e: end_at.utc).pluck(:ride_id); where('rides.id not in (?)', ids) }
This will work for some reason. The only difference between this second query and the first original query is that the second query does not have AT TIME ZONE rides.time_zone_name for reservations.ends_at.
Here is the table schema:
# == Schema Information
#
# Table name: reservations
#
# id :integer not null, primary key
# ride_id :integer
# start_at :datetime
# end_at :datetime
# created_at :datetime
# updated_at :datetime
#
# == Schema Information
#
# Table name: rides
#
# id :integer not null, primary key
# user_id :integer
# latitude :float
# longitude :float
# created_at :datetime
# updated_at :datetime
# utc_offset :integer
# time_zone_name :string(255)
#
All datetimes are stored in UTC zero in PG (PostgreSQL 9.3.5). My goal is to pass 'start_at' and 'end_at' timestamps to my scope and have the correct rides returned. Rides can be stored in different time zones (they have a column for utc_offset and time_zone_name, time_zone_name being 'America/Los_Angeles', etc) and the Reservations that belong to them (ride has_many reservations) have their start and end times stored in utc zero as well.
My goal is to pass a datetime (without timezone) as my 'start_at' and 'end_at' params for the scope. Here is an example: I want to find all rides that do not have reservations at 8am-9am relative to the ride's (the reservation's ride) timezone. This means I can't just search the DB for conflicts with two specific start and end timestamp because that will only find collisions with that exact time. Also this method can give me "false collisions": if I search for two specific start and end timestamps (say 7/10/2015 8am PST - 7/10/2015 9am PST) I may end up having collisions with Reservations that occur at the same time however they occur at a different relative time (the reservation may occur at the same time stamp as 7/10/2015 8am PST - 7/10/2015 9am PST however because the ride is located in EST the Reservation should not be counted as a collision as we are searching for rides available between 8-9am in the local time of the ride). To put it bluntly I am storing everything in UTC zero as a standard however I will be treating some timestamps as "datetimes without timezones" because they need to be converted from a 'relative time'.
scope :not_reserved_between, ->(start_at, end_at) { ids = Reservation.joins(:ride).where(":e >= (reservations.start_at AT TIME ZONE rides.time_zone_name)::timestamp AND :s <= (reservations.end_at AT TIME ZONE rides.time_zone_name)::timestamp", s: start_at.utc, e: end_at.utc).pluck(:ride_id); where('rides.id not in (?)', ids) }
^My thought process from the above code is that I am comparing the scope params with reservation start_at and end_at times. I should be able to take all of the reservation start and end times (stored in UTC zero), convert them to their local time in the ride's time zone (by using 'AT TIME ZONE rides.time_zone_name') and then '::timestamp' the value to receive just the datetime without the time zone. This local time can be compared to the 'relative' UTC zero start/end times I am supplying to the scope.
Rails ActiveRecord converts all datetimes and timestamps – which are synonymous in Rails – to UTC to avoid performing any time zone-dependent logic in the database. This applies to storing as well as querying values. As such, you should be able to define the scope without any time zone logic:
self.not_reserved_between(start_at, end_at)
includes(:reservations).where.not("tsrange(reservations.start_at, reservations.end_at, '[]') && tsrange(?, ?, '[]')", start_at, end_at)
end
Note that I've made use of PostgreSQL's tsrange function for ranges of non-zoned times, and that the '[]' in the last argument indicates that they are inclusive ranges, i.e. that the endpoints are a part of the range. For exclusive ranges, use '()'.
Related
I'm on Rails 7.0.4 and Postgres 15. I have a trigger that checks if a timestamp(6) with time zone is within a certain date range:
CREATE TABLE public.contracts (
id bigint NOT NULL,
starts_on date NOT NULL,
ends_on date
);
CREATE TABLE public.time_entries (
id bigint NOT NULL,
contract_id bigint,
"from" timestamp(6) with time zone,
"to" timestamp(6) with time zone
);
And this is the trigger:
CREATE FUNCTION time_entries_contract_trigger() RETURNS trigger AS $time_entries_contract_trigger$
BEGIN
IF EXISTS (SELECT 1 FROM contracts WHERE contracts.id = NEW."contract_id" AND NEW."from"::date < starts_on) THEN
RAISE EXCEPTION 'from % is before contracts.starts_on', NEW."from";
END IF;
IF EXISTS (SELECT 1 FROM contracts WHERE contracts.id = NEW."contract_id" AND NEW."to"::date > ends_on) THEN
RAISE EXCEPTION 'to % is after contracts.ends_on', NEW."to";
END IF;
RETURN NEW;
END;
$time_entries_contract_trigger$ LANGUAGE plpgsql;
CREATE TRIGGER time_entries_contract_trigger BEFORE INSERT OR UPDATE ON time_entries
FOR EACH ROW EXECUTE PROCEDURE time_entries_contract_trigger();
And I set the default timezone like this:
module App
class Application < Rails::Application
# ...
config.time_zone = "Europe/Berlin"
# ...
end
The problem now is, that whenever I try to store a TimeEntry, Rails converts it to UTC and I get an error like this:
> contract = Contract.create(:contract, starts_on: Date.parse("2021-12-19"))
> contract.time_entries << build(:time_entry, from: Time.zone.parse("2021-12-19T00:00"))
ActiveRecord::StatementInvalid:
PG::RaiseException: ERROR: from 2021-12-18 23:00:00+00 is before contracts.starts_on
CONTEXT: PL/pgSQL function time_entries_contract_trigger() line 4 at RAISE
It makes sense insofar, that Rails converts Time.zone.parse("2021-12-19T00:00") to UTC and my trigger just casts it to a date with NEW."from"::date, just returning the date part (which now is 2021-12-18 and not 2021-12-19).
My questions now are:
How could I force Rails to store the columns with the timezone information in it? My Postgres columns are already timezone aware, so I guess converting it to UTC doesn't make sense any more? Or are there any drawbacks that I'm missing?
I have a series of Appointments where the date and time is stored under start_date as DateTime. I'd like to categorize them as starting in the Morning, Daytime, or Evening. I created an array of hashes with labels and ranges I used for a SQL statement where I convert the start_date records into seconds using CAST(EXTRACT (EPOCH FROM start_time) AS INT)
TIME_RANGES = [
{label: "Morning", min: 0, max: 32399},
{label: "Daytime", min: 32400, max: 61199},
{label: "Evening", min: 61200, max: 86399}
]
cases = TIME_RANGES.map do |r|
"when CAST (EXTRACT (EPOCH FROM start_date) AS INT) % 86400 between '#{r[:min]}' and '#{r[:max]}' then '#{r[:label]}'"
end
time_ranges = Appointment.select("count(*) as n, case #{time_cases.join(' ')} end as time_range").group('time_range')
This takes the number of seconds in a day (86400) and labels the appointments based on the modulo of the start_date with 86400. However, a number of appointments take place in different timezones, but they're all stored as UTC. So an appointment at 08:00 AM EST is equivalent to one at 07:00 AM CST, but both are stored internally as 12:00 PM UTC. This would cause the appointments to be incorrectly labelled as "Daytime" when they're intended to be "Morning" (from the perspective of the User booking it).
Ideally, I would like some way to convert the start_date based on the User's timezone to make it look like it occurred in UTC. So I would want a 12:00 PM EST appointment to be labelled as if it were a 12:00 PM UTC appointment instead of 04:00 PM UTC. More specifically, I would like to subtract 14400 seconds from the converted start_date before performing the modulo.
I can join Appoinments to Users, which contains the User's timezone. How can I incorporate this information into my query above, so that a modified start_date is used for each record, depending on the User's timezone in that same record?
I know I could accomplish this with a loop of each timezone and adding/substracting a specific amount of seconds in each loop, then combining the results of all the loops, but I was wondering if there was a way to do it in one query.
Per my comment, I am assuming we have three tables: appointments, users, and preferences. In appointments we have start_date and user_id. In users we have preference_id. In preferences we have some column that names the time zone, so I'll call that tz_name.
Note: Postgres timezone functions are messy. I would highly recommend you read up on them. This excellent article is a good place to start.
It is possible to use pure SQL to generate the time ranges and return a grouped result. A pure SQL solution would be best if you need to label and group many records (thousands or more) at a time.
Assuming you are working with 1000 records or fewer at a time, you'll probably want to use Rails scopes, as this will give you an ActiveRecord result. Then you'll do your grouping using Ruby's Array methods.
That solution would look something like this:
# app/user.rb
class User < ApplicationRecord
belongs_to :preference
has_many :appointments
end
# app/appointment.rb
class Appointment < ApplicationRecord
belongs_to :user
scope :with_seconds, lambda {
joins(user: :preference)
.select('appointments.*, extract(epoch from timezone(tz_name, start_date::timestamptz)::time)::int as seconds')
}
# This method is optional. If it is excluded, calling #seconds
# on an appointment instance will raise an error if the
# .with_seconds scope has not been applied.
def seconds
attributes['seconds']
end
def time_range
return nil unless seconds.present?
if seconds.between?(0, 32_399)
'Morning'
elsif seconds.between?(32_400, 61_199)
'Daytime'
else
'Evening'
end
end
end
The select portion of the scope probably deserves some explanation.
start_date::timestamptz: Take the start_date, which is stored as a Postgres timestamp, and convert it into a Postgres timestamp with time zone in the time zone of the Postgres server (presumably UTC).
timezone(tz_name, start_date::timestamptz): Convert the timestamp with time zone back into a timestamp type in the local time of the tz_name time zone.
timezone(tz_name, start_date::timestamptz)::time: Drop the date and keep the time component.
Then we extract epoch from that time component, which converts it into seconds.
Finally we convert the result to an integer to avoid anything falling through the cracks when we determine the time range.
Now you can do:
Appointment.all.with_seconds.group_by(&:time_range)
or
user = User.first
user.appointments.with_seconds.group_by(&:time_range)
For a pure SQL solution that will return ids grouped under the three time ranges, add this method to your Appointment model:
def self.grouped_by_time_range
current_scope = with_seconds.to_sql
query = <<~SQL
with converted_seconds as (#{current_scope})
select array_agg(id) as ids, case when seconds < 32400 then 'Morning'
when seconds < 61200 then 'Daytime'
else 'Evening' end as time_range
from converted_seconds
group by time_range
SQL
result = ActiveRecord::Base.connection.execute(query.squish)
result.to_a
end
If you don't need strictly SQL based solution you might use Ruby's select method to extract this appointments as in this example:
(I am assuming there is some kind of tz_name field in appointment model which holds timezone name)
morning_appointments = Appointment.all.select do |a|
a.start_date.in_time_zone(a.tz_name).hour > 6 && a.start_date.in_time_zone(a.tz_name).hour < 20
end
Edit:
Thanks #moveson for pointing out my mistake, I changed solution a bit.
date_start = Time.parse('11/08/2015').beginning_of_day
date_end = Time.parse('11/08/2015').end_of_day
created_at_day_tz = "date(created_at AT TIME ZONE \'UTC\'
AT TIME ZONE \'#{Time.zone.tzinfo.identifier}\')"
users = User.where("users.created_at BETWEEN ? AND ?", date_start, date_end)
Grouping by created_at as created_at_day (date only, new name for the groupped attribute)
grouped_with_timezone_day = users.group(created_at_day_tz).
order(created_at_day_tz).
select("#{created_at_day_tz} as created_at_day, count(*) as count")
# grouped_with_timezone_day.map {|u| [u.created_at_day, u.count] }
# => [[Tue, 11 Aug 2015, 186]]
Grouping by created_at as created_at (date only, same name for the groupped attribute)
grouped_with_timezone = users.group(created_at_day_tz).
order(created_at_day_tz).
select("#{created_at_day_tz} as created_at, count(*) as count")
# grouped_with_timezone.map {|u| [u.created_at, u.count] }
# => [[Mon, 10 Aug 2015 21:00:00 BRT -03:00, 186]]
Why the results differ if the records are the same? Why one result comes with timezone, as DateTime, and the other comes as Date only?
Is activerecord 'casting' to DateTime with Timezone because created_at is defined that way (btw, this makes the dates incorrect in this case)?
The timestamp isn't incorrect - that is, it's 2015-08-11 at midnight UTC - it's just displaying in your local time.
Rails has a bit of special behavior for created_at and updated_at:
The timestamps macro adds two columns, created_at and updated_at. These special columns are automatically managed by Active Record if they exist.
It always treats created_at coming back from a query as a timestamp. Your query returns just the date 2015-08-11, which is interpreted as midnight. When printed, the timestamp is displayed in your locale's timezone (which I presume must be -03:00), leading to 3 hours before midnight on the 11th.
When you name the result created_at_day, you avoid Rails converting it to a timestamp and get just the date you expect.
There is a page in my Rails-app where tours should be displayed so that the start_date field is equal to tomorrow's date (in GMT+00)
I use default timezone in application.rb
# Set Time.zone default to the specified zone and make Active Record ...
# Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
# config.time_zone = 'Central Time (US & Canada)'
However there in is a problem. Instead of the page displaying tour dates starting from tomorrow, I see dates from today and even yesterday.
I put to the page the following information:
Time.zone (GMT+00:00) UTC
Date.today_local_zone 2013-11-20
Date.today 2013-11-20
Time.now 2013-11-20 00:48:21 +0000
Code in my controller:
puts "... #{ Tour.upcoming.order('start_date ASC').to_sql }"
#tours = Tour.upcoming.order('start_date ASC')
And the scope in Tour model
class Tour < ActiveRecord::Base
attr_accessible :start_date, :title
scope :upcoming, where('tours.start_date > ?', Date.today_local_zone)
end
Briefly about my today_local_zone method:
class Date
def self.today_local_zone
Time.zone.now.to_date
end
end
Here is a line from my logs (the date the query is different from the date in the logs)
2013-11-20T00:48:21.178380+00:00 app[web.1]: ... SELECT "tours".* FROM "tours" WHERE (tours.start_date > '2013-11-19') ORDER BY start_date ASC
After heroku restart or deploy dates besome correct
In heroku console heroku run rails c all dates are correct too
I decided to start another application from scratch and to deploy in on heroku. And result remained the same.
I ping this page every 5 minutes with pingdom. Date become correct after hour or two or even 23 after midnight. i.e. value of lag is different each day.
UPD:
I tried to log value of Time.zone.now.to_s.to_date. It's value is correct.
Please also look at my gist with:
Gemfile
Screenshot of webpage, including all values and text of generated query
I missed this on first review. The problem is in your scope statement:
scope :upcoming, where('tours.start_date > ?', Date.today_local_zone)
This definition is wrong. It loads Date.today_local_zone when the class is loaded. You need to wrap it in a Proc. So it should be:
scope :upcoming, lambda { where('tours.start_date > ?', Date.today_local_zone) }
which ensures that Date.today_local_zone is executed each time the scope is used.
I know that Ruby on rails stores all times in UTC (for created_at and updated_at fields) and when you fetch an active-record object from database, and ask RoR for it's date, it will convert it your configured (in environment.rb) Timezone and show you.
But my case is different. I am building a custom query. And I am adding a where clause manually to it. Where clause is such that: select * where created_at > [user entered date].
Now the problem that's arising is that the user entered date is in UTC - 7 and created_at is in UTC. So I can't really make it work. I could hardcode it like select * where created_at > [user-entered-date] 07:00:00 - but this created problem because of daylight savings, and also doesn't seem like a good solution.
This is not the only problem, the second problem is that when I print out the record.created_at, I am getting UTC date (perhaps because I build a custom query?), which also I don't want to manually (hardcode) convert to local time.
Here's my code for the query:
cond = EZ::Where::Condition.new
if !start_date.empty?
start_date = params[:filter][:start_date].to_date.to_s(:db)
cond.append "(registrations.created_at) >= '#{start_date} 07:00:00'" #Here!
end
if !end_date.empty?
end_date = params[:filter][:end_date].to_date
end_date = end_date + 1.day;
end_date = end_date.to_s(:db)
cond.append "(registrations.created_at) <= '#{end_date} 07:00:00'" #Here!
end
registrations = Registration.all(
:joins => [:event],
:select => 'registrations.id, registrations.first_name, registrations.last_name, registrations.company_name,
(registrations.created_at) AS reg_date, events.name AS evt_name, sum(fees) AS fees, code, events.id AS event_id',
:group => 'registrations.id',
:order => 'evt_name, events.id',
:conditions=> cond.to_sql
)
unless registrations.empty?
registrations.each_with_index do |registration, i|
sheet[ i, 3 ] = (DateTime.strptime(registration.reg_date, "%Y-%m-%d %H:%M:%S") - 7.hours).to_date #Here!
end
end
Try to use TimeWithZone and TimeZone
tz = ActiveSupport::TimeZone.new("Mountain Time (US & Canada)")
...
start_date = tz.local_to_utc(params[:filter][:start_date].to_time).to_s(:db)
...
sheet[ i, 3 ] = registration.reg_date.in_time_zone("Mountain Time (US & Canada)").to_date