Reading Rails - TimeWithZone
Sooner or later, you’re going to have to deal with timezones. Luckily, Rails does a lot of work for you behind the scenes:
# Lets find when a Person was created:
Person.first.created_at #=> Wed, 27 Nov 2013 05:15:53 UTC +00:00
# What time would that be for a user on the West Coast?
Time.zone = 'US/Pacific'
Person.first.created_at #=> Tue, 26 Nov 2013 21:15:53 PST -08:00
Depending on the value of Time.zone
, Rails will parse and display times differently. It does this by using TimeWithZone when you query your database, so you’re probably already using it.
We’re going to look into where TimeWithZone comes from, and how it works.
Where Does TimeWithZone Come From?
I made the assertion that Rails returns TimeWithZone instances when you use ActiveRecord, and we saw evidence of that in the snippet above. Let’s figure out how these are being created. Open up ActiveRecord, and peak at time_zone_conversion.rb
. Previously, we learned how attributes work, here you’ll see that the typical attribute methods Rails uses are being overridden for ActiveRecord to support timezones.
TimeZoneConversion provides an implementation of define_method_attribute=
, which is responsible for defining setter methods in Rails. First, this checks whether the column requires a timezone conversion. If not, the method calls super
, and Ruby will go up the inheritance chain:
def define_method_attribute=(attr_name)
if create_time_zone_conversion_attribute?(attr_name, columns_hash[attr_name])
#...
else
super
end
end
create_time_zone_conversion_attribute?
checks the database column type:
def create_time_zone_conversion_attribute?(name, column)
#...
:datetime == column.type || :timestamp == column.type
end
That’s simple enough, the column is passed in, and if the type is a datetime
or a timestamp
then it will get the special timezone handling. So let’s look at how define_method_attribute=
actually defines those setters:
def define_method_attribute=(attr_name)
if create_time_zone_conversion_attribute?(attr_name, columns_hash[attr_name])
method_body, line = <<-EOV, __LINE__ + 1
def #{attr_name}=(time)
time_with_zone = time.respond_to?(:in_time_zone) ? time.in_time_zone : nil
previous_time = attribute_changed?("#{attr_name}") ? changed_attributes["#{attr_name}"] : read_attribute(:#{attr_name})
write_attribute(:#{attr_name}, time)
#{attr_name}_will_change! if previous_time != time_with_zone
@attributes_cache["#{attr_name}"] = time_with_zone
end
EOV
generated_attribute_methods.module_eval(method_body, __FILE__, line)
#...
Not surprisingly, there’s a bit of metaprogramming going on here. Rails is defining a method on the fly by interpolating the attribute’s name into a string template, and then evaluating that with module_eval
. You might be wondering about the <<-EOV
, __LINE__
, and __FILE__
. Rails is using a multiline string format here. Here’s a little example:
puts <<-DONE
Hi there!
This String has multiple lines.
DONE
# Prints:
# Hi there!
# This String has multiple lines.
Each line between <<-DONE
and the DONE
is a line in the String. You don’t have to use DONE
or EOV
, any pair will do. One odd thing about the Rails code though is that there is also that __LINE__ + 1
, you might think that would be part of the String, but it’s not. Multiline strings start on the next line, what we’re seeing in the Rails source is multiple assignment and multi line strings. Here’s another example to clarify:
# Multiple assignment:
x, y = 1,2
"#{x} and #{y}" #=> "1 and 2"
# Multiple assignment and multiline strings:
message, name = <<-MSG, "Hamlet"
There are more things in heaven and earth, Horatio,
Than are dreamt of in your philosophy.
MSG
"#{name} said:\n#{message}"
#=> "Hamlet said:\n There are more things in heaven and earth, Horatio, \n Than are dreamt of in your philosophy.\n"
This is a common idiom in Rails’ metaprogramming, because __FILE__
and __LINE__
evaluate to the current file and line. module_eval(method_body, __FILE__, line)
tells Ruby where it should say methods were declared. This information largely only shows up in stack traces, but when it does, you’ll thank the developer who included it.
Let’s actually look at the method that would be generated for Person#created_at=
:
# Generated code for Person#created_at=
def created_at=(time)
time_with_zone = time.respond_to?(:in_time_zone) ? time.in_time_zone : nil
previous_time = attribute_changed?("created_at") ? changed_attributes["created_at"] : read_attribute(:created_at)
write_attribute(:created_at, time)
created_at_will_change! if previous_time != time_with_zone
@attributes_cache["created_at"] = time_with_zone
end
First Rails checks to see if the time
argument supports in_time_zone
. Poking around in ActiveSupport, you’ll see that there are two implementations of in_time_zone
, one for Strings, and another for Date, Time, and DateTime. Let’s take a look at the String implementation:
class String
def in_time_zone(zone = ::Time.zone)
if zone
::Time.find_zone!(zone).parse(self)
else
to_time
end
end
end
This method creates a TimeWithZone if a zone
has been defined. As we saw at the very beginning, Time.zone
defines the current default timezone. If one is set, then the current timezone will be looked up, and then used to parse the String. If a timezone is not set, String falls back to just returning a normal Time. This could be a bit confusing, but it turns out that the only way zone
can be nil is if you explicitly call in_time_zone(nil)
since setting Time.zone
to nil
just sets it to UTC:
# Time.zone defaults to UTC:
Time.zone.to_s #=> "(GMT+00:00) UTC"
Time.zone = nil
Time.zone.to_s #=> "(GMT+00:00) UTC"
Continuing our quest to find where TimeWithZone instances come from, lets see what find_zone!
and parse
do.
You can find find_zone!
in ActiveSupport’s core_ext/time/zone.rb
file. To be honest, it gets a bit hairy here. Rails uses the tzinfo
gem, which is the canonical timezone database for most systems, and wraps those timezone representations in Rails’ own timezone class. Instead of delving all the way into tzinfo
, let’s take it on faith that an ActiveSupport::TimeZone is returned. Ignoring the details of the method, there is an interesting Rails idiom for us to investigate here, find_zone!
vs find_zone
:
def find_zone!(time_zone)
#...
rescue TZInfo::InvalidTimezoneIdentifier
raise ArgumentError, "Invalid Timezone: #{time_zone}"
end
def find_zone(time_zone)
find_zone!(time_zone) rescue nil
end
You’ve probably come across save
vs. save!
, and maybe seen create
vs. create!
in Rails, fine_zone!
implements the same pattern. In each of these method pairs, the version with the exclamation mark will raise an exception on failure, while the version without won’t. Rails achieves this by using rescue nil
in the method without an exclamation mark. Using rescue
without an explicit Exception class will rescue any subclass of StandardError. The nil
is then returned instead. This pattern is handy, but also can lead to some surprising errors. For instance, using find_zone
might fail because you’re calling it with bad data, or the API of tzinfo
changed, but you’ll never know. When in doubt, I’d suggest avoiding rescue nil
, it’s tempting, but can lead to hilariously hard to track down errors.
So we’ve finally made it to ActiveSupport::TimeZone, let’s see what parse
does.
def parse(str, now=now)
parts = Date._parse(str, false)
return if parts.empty?
time = Time.new(
parts.fetch(:year, now.year),
parts.fetch(:mon, now.month),
parts.fetch(:mday, now.day),
parts.fetch(:hour, 0),
parts.fetch(:min, 0),
parts.fetch(:sec, 0) + parts.fetch(:sec_fraction, 0),
parts.fetch(:offset, 0)
)
if parts[:offset]
TimeWithZone.new(time.utc, self)
else
TimeWithZone.new(nil, self, time)
end
end
This method uses Date._parse
to break a string up into a Hash of time components. _parse
might look odd, and it is. Typically one doesn’t prepend underscores in Ruby, assume it means “You probably shouldn’t be calling me”.
A time
is created from the Hash, substituting in values for anything Date._parse
can’t intuit out of the given string. The last part is the most important for TimeWithZone. If a timezone offset is present, then a TimeWithZone will be created using the UTC representation of that time. If there is no timezone present, TimeZone assumes the time is in the local timezone.
After a little metaprogramming side trip, we now know where TimeWithZone comes from in Rails.
How TimeWithZone Works
Let’s look at the constructor for TimeWithZone:
def initialize(utc_time, time_zone, local_time = nil, period = nil)
@utc, @time_zone, @time = utc_time, time_zone, local_time
@period = @utc ? period : get_period_and_ensure_valid_local_time(period)
end
We’ve already seen it used above in parse
where initialize was called in two different ways depending on if we were creating a time in a specific timezone, or assuming the local timezone:
def parse
# ...
if parts[:offset]
TimeWithZone.new(time.utc, self)
else
TimeWithZone.new(nil, self, time)
end
As you can see, the second parameter, time_zone
is always passed in, while we may or may not pass in a utc_time
or a local_time
. This isn’t the most elegant interface, but the documentation does warn you not to use it:
# You shouldn't ever need to create a TimeWithZone instance directly via new.
# Instead use methods local, parse, at and now on TimeZone instances,
# and in_time_zone on Time and DateTime instances.
First we see some multiple assignment, as @utc, @time_zone, @time
are all set at once. Next period
is set. For those of you who have been lucky enough not to think too much about timezones, a timezone period represents the time period where an offset from UTC is applied. For instance I’m in Seattle, which is in the “Pacific” time zone, the current period is “Pacific Date Time”, come November 2nd, we’ll be in the “Pacific Standard Time”. get_period_and_ensure_valid_local_time
uses the current timezone and the time to figure out which period it’s in.
What’s the purpose of storing both @utc
and @time
? The UTC version of the time is used for operations such as checking equality, sorting, and so forth:
def <=>(other)
utc <=> other
end
def between?(min, max)
utc.between?(min, max)
end
def eql?(other)
utc.eql?(other)
end
Rails operates on the UTC times for these methods because you want to ignore the timezone for comparisons, ie: 8:00am Pacific Time is the same time as 11:00am Eastern time, and 4:00pm UTC. Meanwhile the local time is used for representation. For instance look at how TimeWithZone overrides strftime
:
def strftime(format)
format = format.gsub('%Z', zone)
.gsub('%z', formatted_offset(false))
.gsub('%:z', formatted_offset(true))
.gsub('%::z', formatted_offset(true) + ":00")
time.strftime(format)
end
Rails replaces any timezone tokens in the format string with representations of the current timezone, then it hands off formatting to the local time
object. Rails uses time
instead of utc
because we want to format the local time. Peruse the source a bit, you’ll see this pattern of using utc
for computation and and time
for representation throughout TimeWithZone.
Did you peruse? If you did, you might have noticed that many of the Time related methods you expect to see are defined, but not all of them. For instance, tomorrow
and hour
aren’t defined, and yet they work:
now = Time.zone.now # => Wed, 27 Aug 2014 19:37:26 PDT -07:00
now.tomorrow # => Thu, 28 Aug 2014 19:37:26 PDT -07:00
now.hour # => 19
The answer to this lies in TimeWithZone’s implementation of method_missing
:
def method_missing(sym, *args, &block)
wrap_with_time_zone time.__send__(sym, *args, &block)
rescue NoMethodError => e
raise e, e.message.sub(time.inspect, self.inspect), e.backtrace
end
This is a typical method_missing
implementation with a twist. Anything that TimeWithZone doesn’t implement will get forwarded on to time
using time.__send__(sym, *args, &block)
. The result though is passed to wrap_with_time_zone
.
def wrap_with_time_zone(time)
if time.acts_like?(:time)
periods = time_zone.periods_for_local(time)
self.class.new(nil, time_zone, time, periods.include?(period) ? period : nil)
else
time
end
end
This method ensures that when you’re working with a TimeWithZone, you keep working with one whenever you’d expect a Time. The code switches based on whether the object, confusingly named time
, is a time or not. If it’s not a time, it is returned, which explains how hour
returns a Fixnum. If it is a time, a new TimeWithZone is instantiated using the current time_zone
and period
if it’s appropriate for the new time
.
Recap
Ruby’s core libraries don’t handle timezones, but ActiveSupport’s TimeWithZone does. It achieves this mostly transparently by encapsulating a UTC time, a local time, and a timezone, and then delegating to them appropriately.
We also saw a few other curious things:
- The current timezone can be set with
Time.zone=
- Multi line strings (
<<-MSG
) can be used in method calls and multiple assignment. - Use
__FILE__
and__LINE__
when callingeval
so backtraces are annotated. - Rails methods like
save!
tend toraise
when they fail, whilesave
returns false. - Get a TimeWithZone by calling
local
,parse
,at
andnow
onTime.zone
- Wrapping delegated method calls can prevent leaky abstractions.
More articles in this series
- Reading Rails - HTTP DELETEs With a Link
- Reading Rails - Time Travel
- Reading Rails - TimeWithZone
- Reading Rails - How Does MessageEncryptor Work?
- Reading Rails - How Does MessageVerifier Work?
- Reading Rails - How Do Batched Queries Work?
- Reading Rails - The Adapter Pattern
- Reading Rails - Errors and Validators
- Reading Rails - How Validations Are Configured
- Reading Rails - Concern
- Reading Rails - More Migrations
- Reading Rails - Migrations
- Reading Rails - Attribute Methods
- Reading Rails - Change Tracking
- Reading Rails - Handling Exceptions