Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How does ActiveSupport do month sums?

I was pleased and surprised to find that ActiveSupport does month sums in the way I wanted it to. Regardless of how many days are in the months in question, adding 1.month to a particular Time will land you on the same day-of-the-month as the Time.

> Time.utc(2012,2,1)
=> Wed Feb 01 00:00:00 UTC 2012
> Time.utc(2012,2,1) + 1.month
=> Thu Mar 01 00:00:00 UTC 2012

the months method in Fixnum provided by activesupport does not give clues:

def months
  ActiveSupport::Duration.new(self * 30.days, [[:months, self]])
end

Following the + method in Time...

def plus_with_duration(other) #:nodoc:
  if ActiveSupport::Duration === other
    other.since(self)
  else
    plus_without_duration(other)
  end
end

...leads us to since in Fixnum...

def since(time = ::Time.current)
  time + self
end

...which leads us nowhere.

How/where is ActiveSupport (or something else) doing clever month math instead of just adding 30 days?

like image 258
John Bachir Avatar asked Feb 22 '23 00:02

John Bachir


1 Answers

That's a really good question. The short answer is that 1.month is an ActiveSupport::Duration object (as you already saw) and its identity is defined in two different ways:

  • as 30.days (in case you need/try to convert it to a number of seconds), and
  • as 1 month (in case you try to add this duration to a date).

You can see that it still knows that it is equivalent to 1 month by inspecting its parts method:

main > 1.month.parts
=> [[:months, 1]]

Once you see proof that it still knows that it's exactly 1 month, it's less mysterious how calculations like Time.utc(2012,2,1) + 1.month can give the correct result even for months that don't have exactly 29 days, and why it gives a different result than Time.utc(2012,2,1) + 30.days gives.

How do ActiveSupport::Duration conceal their true identity?

The real mystery for me was how it hides its real identity so well. We know that it is a ActiveSupport::Duration object, yet it's very difficult to get it to admit that it is!

When you inspect it in a console (I'm using Pry), it looks exactly like (and claims to be) a normal Fixnum object:

main > one_month = 1.month
=> 2592000

main > one_month.class
=> Fixnum

It even claims to be equivalent to 30.days (or 2592000.seconds), which we've shown to be not true (at least not in all cases):

main > one_month = 1.month
=> 2592000

main > thirty_days = 30.days
=> 2592000

main > one_month == thirty_days
=> true

main > one_month == 2592000
=> true

So to find out whether an object is a ActiveSupport::Duration or not, you can't rely on the class method. Instead, you' have to ask it point-blank: "Are you or are you not an instance of ActiveSupport::Duration?" Confronted with such a direct question, the object in question will have no choice but to confess the truth:

main > one_month.is_a? ActiveSupport::Duration
=> true

Mere Fixnum objects, on the other hand, must hang their heads and admit that they are not:

main > 2592000.is_a? ActiveSupport::Duration
=> false

You can also tell it apart from regular Fixnums by checking if it responds to :parts:

main > one_month.parts
=> [[:months, 1]]

main > 2592000.parts
NoMethodError: undefined method `parts' for 2592000:Fixnum
from (pry):60:in `__pry__'

Having an array of parts is great

The cool thing about having an array of parts is that it allows you to have duration defined as a mix of units, like this:

main > (one_month + 5.days).parts
=> [[:months, 1], [:days, 5]]

This allows it to accurate calculate such things as:

main > Time.utc(2012,2,1) + (one_month + 5.days)
=> 2012-03-06 00:00:00 UTC

... which it would not be able to calculate correctly if it simply stored only a number of days or seconds as its value. You can see this for yourself if we first convert 1.month to its "equivalent" number of seconds or days:

main > Time.utc(2012,2,1) + (one_month + 5.days).to_i
=> 2012-03-07 00:00:00 UTC

main > Time.utc(2012,2,1) + (30.days + 5.days)
=> 2012-03-07 00:00:00 UTC

How does ActiveSupport::Duration work? (Gory implementation details)

ActiveSupport::Duration is actually defined (in gems/activesupport-3.2.13/lib/active_support/duration.rb) as a subclass of BasicObject, which according to the docs, "can be used for creating object hierarchies independent of Ruby's object hierarchy, proxy objects like the Delegator class, or other uses where namespace pollution from Ruby's methods and classes must be avoided."

ActiveSupport::Duration uses method_missing to delegate methods to its @value variable.

Bonus question: Does anyone know why an ActiveSupport::Duration object claims to not respond to :parts even though it actually does, and why the parts method isn't listed in the methods list?

main > 1.month.respond_to? :parts
=> false

main > 1.month.methods.include? :parts
=> false

main > 1.month.methods.include? :since
=> true

Answer: Because BasicObject does not define a respond_to? method, sending respond_to? to an ActiveSupport::Duration object will end up calling its method_missing method, which looks like this:

def method_missing(method, *args, &block) #:nodoc:
  value.send(method, *args, &block)
end

1.month.value is simply the Fixnum 2592000, so it effectively ends up calling 2592000.respond_to? :parts, which of course is false.

This would be easy to solve, though, by simply adding a respond_to? method to the ActiveSupport::Duration class:

main > ActiveSupport::Duration.class_eval do
           def respond_to?(name, include_private = false)
               [:value, :parts].include?(name) or
               value.respond_to?(name, include_private) or
               super
             end
         end
=> nil

main > 1.month.respond_to? :parts
=> true

The explanation for why methods incorrectly omits the :parts method is the same: because the methods message simply gets delegated to value, which of course does not have a parts method. We could fix this bug as easily as adding our own methods method:

main > ActiveSupport::Duration.class_eval do
         def methods(*args)
           [:value, :parts] | super
         end
       end
=> nil

main >  1.month.methods.include? :parts
=> true
like image 151
Tyler Rick Avatar answered Feb 23 '23 15:02

Tyler Rick