Rails magic breakdown: 1.day.ago

03 Jun 2022

Rails is full of "magic", but if you dig a little, that magic is just some ruby code. For instance there is 1.day.ago, which returns a DateTime object for 1 day ago.
Did you ever wonder how does this work? Let's get to the bottom of it.

First of all, let's break down this call. We call the day method on 1, which is an Integer and whatever that call returns, we call the ago method on that object. If you open a Rails console, you can find out where the day method is defined:

Loading development environment (Rails 7.0.2.2)
irb(main):001:0> 1.method(:day).source_location
=>
["/Users/gregmolnar/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/activesupport-7.0.2.2/lib/active_support/core_ext/numeric/time.rb",
 37]
Now if we open active_support/core_ext/numeric/time.rb, we can see the implementation:
# frozen_string_literal: true

require "active_support/duration"
require "active_support/core_ext/time/calculations"
require "active_support/core_ext/time/acts_like"
require "active_support/core_ext/date/calculations"
require "active_support/core_ext/date/acts_like"

class Numeric
  ...
  # Returns a Duration instance matching the number of days provided.
  #
  #   2.days # => 2 days
  def days
    ActiveSupport::Duration.days(self)
  end
  alias :day :days
  ...
end
As we see, day is an alias of days, which calls ActiveSupport::Duration.days with the integer we call the method on. Let's look at what this days method does, but first we need to find where it's located:
irb(main):003:0> ActiveSupport::Duration.method(:days).source_location
=> ["/Users/gregmolnar/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/activesupport-7.0.2.2/lib/active_support/duration.rb", 166]
# frozen_string_literal: true

require "active_support/core_ext/array/conversions"
require "active_support/core_ext/module/delegation"
require "active_support/core_ext/object/acts_like"
require "active_support/core_ext/string/filters"

module ActiveSupport
  # Provides accurate date and time measurements using Date#advance and
  # Time#advance, respectively. It mainly supports the methods on Numeric.
  #
  #   1.month.ago       # equivalent to Time.now.advance(months: -1)
  class Duration
    ...
    class << self
      def days(value) # :nodoc:
        new(value * SECONDS_PER_DAY, { days: value }, true)
      end
    end
    ...
  end
end
So day returns an instance of ActiveSupport::Duration and we call the ago method on that instance.
As the comment at the top of this class mentions, this is just a wrapper on top of Time.now.advance(days: -1), but if we got this far, let's see the method:
# Calculates a new Time or Date that is as far in the past
# as this Duration represents.
def ago(time = ::Time.current)
  sum(-1, time)
end
...
def sum(sign, time = ::Time.current)
  unless time.acts_like?(:time) || time.acts_like?(:date)
    raise ::ArgumentError, "expected a time or date, got #{time.inspect}"
  end

  if @parts.empty?
    time.since(sign * value)
  else
    @parts.inject(time) do |t, (type, number)|
      if type == :seconds
        t.since(sign * number)
      elsif type == :minutes
        t.since(sign * number * 60)
      elsif type == :hours
        t.since(sign * number * 3600)
      else
        t.advance(type => sign * number)
      end
    end
  end
end
...
ago calls sum with -1 and by default with the current time and the definition of sum is where the magic is happening. But we don't what the @parts instance variable holds, so let's look at the initialize method to see if it is set there:
def initialize(value, parts, variable = nil) # :nodoc:
  @value, @parts = value, parts
  @parts.reject! { |k, v| v.zero? } unless value == 0
  @parts.freeze
  @variable = variable

  if @variable.nil?
    @variable = @parts.any? { |part, _| VARIABLE_PARTS.include?(part) }
  end
end
Now we can see that @parts in our case is set to { days: 1 }, so we hit the final else condition which calls advance on the time object with days and -1 * 1 as parameters. As the comment at the top of the class said, we indeed ended up just calling Time.now.advance(days: -1) afterall.

I hope you managed to follow along and now Rails shouldn't feel so magical anymore. It is just ruby code at the end of the day, with a lot of handy abstractions.

I run an indie startup providing vulnerability scanning for your Ruby on Rails app. It is free to use at the moment, and I am grateful for any feedback about it.
If you would like to give it a spin, you can do it here: Vulnerability Scanning for your Ruby on Rails app!

Did you enjoy reading this? Follow me on Twitter or sign up to my newsletter for more content like this!

Related posts