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:

blog comments powered by Disqus
Monkey Small Crow Small