How does method chaining work in Ruby? - ruby-on-rails

How does method chaining work in ruby? As a C# developer, when I see 1.hour.from_now.localtime, I'm not sure what's going on. How does this code work?
<%= 1.hour.from_now.localtime %>

1 is an object that responds to hour
pry(main)> 1.class
=> Fixnum
.hour is a method on Fixnum that signifies it as an hour (by changing it to 3600)
pry(main)> 1.hour.class
=> Fixnum
pry(main)> 1.hour.to_i
=> 3600
.from_now changes the type of 3600 to a DateTime, 3600 seconds in to the future.
pry(main)> 1.hour.from_now
=> Mon, 22 Sep 2014 19:57:05 UTC +00:00
pry(main)> 1.hour.from_now.class
=> ActiveSupport::TimeWithZone
.localtime changes the TimeZone to the system local time:
pry(main)> 1.hour.from_now.localtime
=> 2014-09-22 12:57:41 -0700

Everything in Ruby is an object, so in this case:
The Fixnum 1 object receives the message hour
Which returns a Fixnum of seconds
3600 seconds receives the message from_now
Which returns an ActiveSupport::TimeWithZone object
Essentially it offsets the current time (the from_now) by an offset (our initial Fixnum of seconds)
This Time object then receives the message localtime
This converts the TimeWithZone object from UTC to your local timezone
http://api.rubyonrails.org/classes/ActiveSupport/TimeWithZone.html#method-i-localtime
If you want to explore each step, you can use the rails console and evaluate each step to see the return value (and call class on each step to see how it changes the type)

Related

Why is %Z giving me the numeric offset instead of the abbreviated time zone name in Ruby?

I'm trying to parse a timestamp from a string, and then subsequently display it with its abbreviated time zone, but what's coming back is the numeric offset from UTC despite what I believe is the correct usage. Here's the console session:
[5] pry(main)> time_string = "2022-08-02T12:00:00 CDT"
=> "2022-08-02T12:00:00 CDT"
[6] pry(main)> DateTime.parse(time_string).zone
=> "-05:00"
[7] pry(main)> DateTime.parse(time_string).strftime("%Z")
=> "-05:00"
What I'm expecting here is that this will return CDT again.
This is Ruby 2.7 on macOS, with Rails:
❯ ruby --version
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-darwin21]
❯ rails --version
Rails 6.1.6
From the strftime docs for rails, says it's working as intended.
Time zone:
%z - Time zone as hour and minute offset from UTC (e.g. +0900)
%:z - hour and minute offset from UTC with a colon (e.g. +09:00)
%::z - hour, minute and second offset from UTC (e.g. +09:00:00)
%:::z - hour, minute and second offset from UTC
(e.g. +09, +09:30, +09:30:30)
%Z - Equivalent to %:z (e.g. +09:00)
If you have the actual timezone name you can use that to get the abbreviation as outline here in the blog post
def human_timezone(time_string, timezone)
time = time_string.in_time_zone(timezone)
if time.zone.match?(/^\w/)
time.zone
else
time.formatted_offset
end
end
>> human_timezone('2019-03-28 16:00', 'Pacific Time (US & Canada)')
=> "PDT"
>> human_timezone('2019-03-28 16:00', 'Berlin')
=> "CET"
>> human_timezone('2019-05-01 16:00', 'Almaty')
=> "+06:00"
Ruby doesn't store timezone name, it stores only the time offset (-5:00).
So, backward conversion is not possible because there are multiple timezones for one offset. For e.g. another timezone with -5:00 offset.
> time_string = "2022-08-02T12:00:00 PET"
=> "2022-08-02T12:00:00 PET"
> DateTime.parse time_string
=> Tue, 02 Aug 2022 12:00:00 -0500

How do I get yesterday's time in milliseconds?

I'm running into a wall. I want to get yesterday's time in milliseconds. I thought the below would do the job
irb(main):001:0> yday = 1.day.ago
=> Tue, 03 Apr 2018 20:35:24 UTC +00:00
irb(main):002:0> yday.strftime('%Q').to_f
=> 0.0
but as you can see I'm getting a "0.0" for yesterday's time. How can I correct what I have to accurately get the time in milliseconds?
You have two options:
(DateTime.now-1.day).strftime('%Q')
Or:
yday = 1.day.ago
yday.to_datetime.strftime('%Q')
Why you need to_datetime? Well, 1.day.ago is ActiveSupport::TimeWithZone which does not have %Q format (documentation) when strftime for DateTime does (documentation)

Ruby / Rails - Change the timezone of a Time, without changing the value

I have a record foo in the database which has :start_time and :timezone attributes.
The :start_time is a Time in UTC - 2001-01-01 14:20:00, for example.
The :timezone is a string - America/New_York, for example.
I want to create a new Time object with the value of :start_time but whose timezone is specified by :timezone. I do not want to load the :start_time and then convert to :timezone, because Rails will be clever and update the time from UTC to be consistent with that timezone.
Currently,
t = foo.start_time
=> 2000-01-01 14:20:00 UTC
t.zone
=> "UTC"
t.in_time_zone("America/New_York")
=> Sat, 01 Jan 2000 09:20:00 EST -05:00
Instead, I want to see
=> Sat, 01 Jan 2000 14:20:00 EST -05:00
ie. I want to do:
t
=> 2000-01-01 14:20:00 UTC
t.zone = "America/New_York"
=> "America/New_York"
t
=> 2000-01-01 14:20:00 EST
Sounds like you want something along the lines of
ActiveSupport::TimeZone.new('America/New_York').local_to_utc(t)
This says convert this local time (using the zone) to utc. If you have Time.zone set then you can of course to
Time.zone.local_to_utc(t)
This won't use the timezone attached to t - it assumes that it's local to the time zone you are converting from.
One edge case to guard against here is DST transitions: the local time you specify may not exist or may be ambiguous.
I've just faced the same problem and here is what I'm going to do:
t = t.asctime.in_time_zone("America/New_York")
Here is the documentation on asctime
If you're using Rails, here is another method along the lines of Eric Walsh's answer:
def set_in_timezone(time, zone)
Time.use_zone(zone) { time.to_datetime.change(offset: Time.zone.now.strftime("%z")) }
end
You need to add the time offset to your time after you convert it.
The easiest way to do this is:
t = Foo.start_time.in_time_zone("America/New_York")
t -= t.utc_offset
I am not sure why you would want to do this, though it is probably best to actually work with times the way they are built. I guess some background on why you need to shift time and timezones would be helpful.
Actually, I think you need to subtract the offset after you convert it, as in:
1.9.3p194 :042 > utc_time = Time.now.utc
=> 2013-05-29 16:37:36 UTC
1.9.3p194 :043 > local_time = utc_time.in_time_zone('America/New_York')
=> Wed, 29 May 2013 12:37:36 EDT -04:00
1.9.3p194 :044 > desired_time = local_time-local_time.utc_offset
=> Wed, 29 May 2013 16:37:36 EDT -04:00
Depends on where you are going to use this Time.
When your time is an attribute
If time is used as an attribute, you can use the same date_time_attribute gem:
class Task
include DateTimeAttribute
date_time_attribute :due_at
end
task = Task.new
task.due_at_time_zone = 'Moscow'
task.due_at # => Mon, 03 Feb 2013 22:00:00 MSK +04:00
task.due_at_time_zone = 'London'
task.due_at # => Mon, 03 Feb 2013 22:00:00 GMT +00:00
When you set a separate variable
Use the same date_time_attribute gem:
my_date_time = DateTimeAttribute::Container.new(Time.zone.now)
my_date_time.date_time # => 2001-02-03 22:00:00 KRAT +0700
my_date_time.time_zone = 'Moscow'
my_date_time.date_time # => 2001-02-03 22:00:00 MSK +0400
Here's another version that worked better for me than the current answers:
now = Time.now
# => 2020-04-15 12:07:10 +0200
now.strftime("%F %T.%N").in_time_zone("Europe/London")
# => Wed, 15 Apr 2020 12:07:10 BST +01:00
It carries over nanoseconds using "%N". If you desire another precision, see this strftime reference.
The question's about Rails but it seems, like me, not everyone here is on the ActiveSupport train, so yet another option:
irb(main):001:0> require "time"
=> true
irb(main):003:0> require "tzinfo"
=> true
irb(main):004:0> t = Time.parse("2000-01-01 14:20:00 UTC")
=> 2000-01-01 14:20:00 UTC
irb(main):005:0> tz = TZInfo::Timezone.get("America/New_York")
=> #<TZInfo::DataTimezone: America/New_York>
irb(main):008:0> utc = tz.local_to_utc(t)
=> 2000-01-01 19:20:00 UTC
irb(main):009:0> tz.utc_to_local(utc)
=> 2000-01-01 14:20:00 -0500
irb(main):010:0>
local_to_utc not doing the opposite of utc_to_local might look like a bug but it is at least documented: https://github.com/tzinfo/tzinfo says:
The offset of the time is ignored - it is treated as if it were a local time for the time zone
I managed to do this by calling change with the desired time zone:
>> t = Time.current.in_time_zone('America/New_York')
=> Mon, 08 Aug 2022 12:04:36.934007000 EDT -04:00
>> t.change(zone: 'Etc/UTC')
=> Mon, 08 Aug 2022 12:04:36.934007000 UTC +00:00
https://api.rubyonrails.org/classes/ActiveSupport/TimeWithZone.html#method-i-change
def relative_time_in_time_zone(time, zone)
DateTime.parse(time.strftime("%d %b %Y %H:%M:%S #{time.in_time_zone(zone).formatted_offset}"))
end
Quick little function I came up with to solve the job. If someone has a more efficient way of doing this please post it!
I spent significant time struggling with TimeZones as well, and after tinkering with Ruby 1.9.3 realized that you don't need to convert to a named timezone symbol before converting:
my_time = Time.now
west_coast_time = my_time.in_time_zone(-8) # Pacific Standard Time
east_coast_time = my_time.in_time_zone(-5) # Eastern Standard Time
What this implies is that you can focus on getting the appropriate time setup first in the region you want, the way you would think about it (at least in my head I partition it this way), and then convert at the end to the zone you want to verify your business logic with.
This also works for Ruby 2.3.1.
I have created few helper methods one of which just does the same thing as is asked by the original author of the post at Ruby / Rails - Change the timezone of a Time, without changing the value.
Also I have documented few peculiarities I observed and also these helpers contains methods to completely ignore automatic day-light savings applicable while time-conversions which is not available out-of-the-box in Rails framework:
def utc_offset_of_given_time(time, ignore_dst: false)
# Correcting the utc_offset below
utc_offset = time.utc_offset
if !!ignore_dst && time.dst?
utc_offset_ignoring_dst = utc_offset - 3600 # 3600 seconds = 1 hour
utc_offset = utc_offset_ignoring_dst
end
utc_offset
end
def utc_offset_of_given_time_ignoring_dst(time)
utc_offset_of_given_time(time, ignore_dst: true)
end
def change_offset_in_given_time_to_given_utc_offset(time, utc_offset)
formatted_utc_offset = ActiveSupport::TimeZone.seconds_to_utc_offset(utc_offset, false)
# change method accepts :offset option only on DateTime instances.
# and also offset option works only when given formatted utc_offset
# like -0500. If giving it number of seconds like -18000 it is not
# taken into account. This is not mentioned clearly in the documentation
# , though.
# Hence the conversion to DateTime instance first using to_datetime.
datetime_with_changed_offset = time.to_datetime.change(offset: formatted_utc_offset)
Time.parse(datetime_with_changed_offset.to_s)
end
def ignore_dst_in_given_time(time)
return time unless time.dst?
utc_offset = time.utc_offset
if utc_offset < 0
dst_ignored_time = time - 1.hour
elsif utc_offset > 0
dst_ignored_time = time + 1.hour
end
utc_offset_ignoring_dst = utc_offset_of_given_time_ignoring_dst(time)
dst_ignored_time_with_corrected_offset =
change_offset_in_given_time_to_given_utc_offset(dst_ignored_time, utc_offset_ignoring_dst)
# A special case for time in timezones observing DST and which are
# ahead of UTC. For e.g. Tehran city whose timezone is Iran Standard Time
# and which observes DST and which is UTC +03:30. But when DST is active
# it becomes UTC +04:30. Thus when a IRDT (Iran Daylight Saving Time)
# is given to this method say '05-04-2016 4:00pm' then this will convert
# it to '05-04-2016 5:00pm' and update its offset to +0330 which is incorrect.
# The updated UTC offset is correct but the hour should retain as 4.
if utc_offset > 0
dst_ignored_time_with_corrected_offset -= 1.hour
end
dst_ignored_time_with_corrected_offset
end
Examples which can be tried on rails console or a ruby script after wrapping the above methods in a class or module:
dd1 = '05-04-2016 4:00pm'
dd2 = '07-11-2016 4:00pm'
utc_zone = ActiveSupport::TimeZone['UTC']
est_zone = ActiveSupport::TimeZone['Eastern Time (US & Canada)']
tehran_zone = ActiveSupport::TimeZone['Tehran']
utc_dd1 = utc_zone.parse(dd1)
est_dd1 = est_zone.parse(dd1)
tehran_dd1 = tehran_zone.parse(dd1)
utc_dd1.dst?
est_dd1.dst?
tehran_dd1.dst?
ignore_dst = true
utc_to_est_time = utc_dd1.in_time_zone(est_zone.name)
if utc_to_est_time.dst? && !!ignore_dst
utc_to_est_time = ignore_dst_in_given_time(utc_to_est_time)
end
puts utc_to_est_time
Hope this helps.
This worked well for me
date = '23/11/2020'
time = '08:00'
h, m = time.split(':')
timezone = 'Europe/London'
date.to_datetime.in_time_zone(timezone).change(hour: h, min: m)
This changes the timezone to 'EST' without changing the time:
time = DateTime.current
Time.find_zone("EST").local(
time.year,
time.month,
time.day,
time.hour,
time.min,
time.sec,
)

Sleep statement in Ruby doesn't accept any rails convertions

I found this very weird. Why am I not able to do this?:
puts Time.now
x=2
puts x
puts x.class
sleep x
puts Time.now
x=x.seconds
puts x
puts x.class
sleep (x)
puts Time.now
The output I get is:
Mon Oct 01 16:14:58 +0530 2012
2
Fixnum
Mon Oct 01 16:15:00 +0530 2012
2
Fixnum
rake aborted!
can't convert ActiveSupport::Duration into time interval
/Users/hariharanganapathiraman/Documents/MigrationScripts/sample/lib/import/boot.rb:23:in `sleep'
Similarly it doesn't work for .minutes or .days.
That's because ActiveSupport::Duration mimics itself as Fixnum class:
ActiveSupport::Duration.new(3600, [[:seconds, 3600]]).class #=> Fixnum
Use sleep(2.seconds.to_i).
The problem is with ActiveSupport::Duration
It's inherited from BasicObject that have only limited number of methods and does not have class method.
If you take a look in ActiveSupport::Duration implementation you'll see that all methods are proxied(via method_missing) to #value, that's Fixnum in your case.
Hence, since ActiveSupport::Duration has no class method, your class call for a x.seconds
goes to Fixnum.

Querying for date range in rails

I have a scope that queries for today's calls. Based off of the scope I use it to count the amount of calls for today.
My dates are stored in UTC but rails converts to my local timezone. What I'm trying to do is find all calls between today at 00:00 and 23:59.
Scope:
scope :today, where("DATE(transfer_date) BETWEEN ? AND ?", Time.zone.now.utc.beginning_of_day, Time.zone.now.utc.end_of_day)
irb output: (The call it catches due to UTC)
irb(main):010:0> Call.last.transfer_date
Call Load (0.9ms) SELECT "calls".* FROM "calls" ORDER BY "calls"."id" DESC LIMIT 1
=> Sun, 07 Oct 2012 19:45:00 CDT -05:00
irb(main):011:0>
irb(main):011:0> Call.last.transfer_date.utc
Call Load (1.3ms) SELECT "calls".* FROM "calls" ORDER BY "calls"."id" DESC LIMIT 1
=> 2012-10-08 00:45:00 UTC
I'm trying to figure out how to query only calls that were between 00:00 and 23:59 for today. So far trying to scope with and without utc, zone, etc doesn't work. It keeps pulling the scope based off of UTC which includes the call from yesterday (yesterday if it's formatted with the local timezone).
How can I query between the two times to get the correct output? I'm kind of lost here.
You can use an exclusive range.
scope :today, where(:transfer_date => Date.today...Date.tomorrow)
I was able to compensate for UTC by rewriting my scope as follows:
scope :today, where("transfer_date BETWEEN ? AND ?", Time.zone.now.beginning_of_day, Time.zone.now.end_of_day)
Maybe this is overkill, but I would suggest using a helper method for getting the time range and then querying the db. Something like
# Gets time range for x number timeunits ago
def time_range(unit, timeunit = nil)
if timeunit == "weeks"
now = Time.zone.now.beginning_of_week
elsif timeunit == "months"
now = Time.zone.now.beginning_of_month
else
now = Time.zone.now.beginning_of_day
end
# Ex: time_range(0, "days") --> Get the time range for today between the beginning of today and the beginning of tommorow - 1 second
now - unit.send(timeunit)..now + 1.send(timeunit) - 1.seconds - unit.send(timeunit)
end
will help you to request time ranges. So when you request something like;
time_range(0, "days")
it will return the time range for 0 days ago (today);
Wed, 07 Sep 2016 00:00:00 UTC +00:00..Wed, 07 Sep 2016 23:59:59 UTC +00:00
And then you can simply query the database object and count everything within the range with;
Calls.where(transfer_date: time_range(unit, timeunit)).count

Resources