Behaviour of ActiveSupport::Duration when added together

Hi all.

What's up with this?

Time.now

=> Mon Aug 31 12:18:00 +1000 2009

1.month + 1.month

=> 2 months

(1.month + 1.month).from_now

=> Fri Oct 30 12:19:51 +1100 2009

2.months.from_now

=> Sat Oct 31 12:20:33 +1100 2009

Intended behaviour? Quirk due to implementation details in Duration +
Duration? Rails gem version is 2.3.2.

TX

I'm pretty sure that this is intentional. A duration contains a list
of parts which represent the time increments, it also contains an
integer value which is the number of seconds in the duration. But that
value for things like month and year isn't what's used when adding a
duration to a Time, since the number of seconds in a month or a year
depends on the particular month( whichc could be 28, 29, 30, or 31
days) or year (365 or 366 days).

When you add two durations, it combines the list of parts, so:

  2.months has one part [:months, 2]
but
(1.month + 1.month) has two parts [:months, 1], [:months, 1]

Now duration.from_now gets Time.now and then iterates over the parts
effectively (simplified the code to get the gist)

        parts.inject(Time.now) do |t,(type,number)|
              t.advance(type => sign * number)
        end

So,

2.months.from_now when Time.now is during August 31, ends up as

   Time.now.advance(:months => 2) and since October has 31 days you get there.

but

(1.months + 1.months).from_now ends up as:

  Time.now.advance(:months => 1).advance(:months => 1)

The first gets us to September 30 since September only has 30 days,
and that's how time.advance is defined, then the next advance gets us
to October 30.

You need to look at the code to understand this, Duration is a bit
tricky since it acts as a proxy to it's value, it's implemented as a
subclass of BasicObject and forwards anything it doesn't have a method
for to the value, so it reports it's class as Fixnum, or BigInteger,
and it also defines == to return true if the two values are equal
ignoring the parts.

Great explanation, thanks.

Also note that this behavior is consistent with the behavior of Ruby's
Date#>> (which Time#advance relies on):

(d=Date.new(2009,8,31)).to_s

=> "2009-08-31"

(d >> 1).to_s

=> "2009-09-30"

(d >> 2).to_s

=> "2009-10-31"

(d >> 1 >> 1).to_s

=> "2009-10-30"

Great explanation, thanks.

Also note that this behavior is consistent with the behavior of Ruby's
Date#>> (which Time#advance relies on):

(d=Date.new(2009,8,31)).to_s

=> "2009-08-31"

(d >> 1).to_s

=> "2009-09-30"

(d >> 2).to_s

=> "2009-10-31"

(d >> 1 >> 1).to_s

=> "2009-10-30"

I'm going to object here because this particular example is not
analogous to mine.

I see this as okay: (after all, it demands an intermediate state)

d=Date.new(2009,8,31)

=> Mon, 31 Aug 2009

1.month.since(1.month.since(d))

=> Fri, 30 Oct 2009

But this is confusing:

(1.month + 1.month).since(d)

=> Fri, 30 Oct 2009

The reason it is confusing:

1.month + 1.month

=> 2 months

(1.month + 1.month).since(d)

=> Fri, 30 Oct 2009

2.months.since(d)

=> Sat, 31 Oct 2009

(1.month + 1.month) == 2.months

=> true

Clearly it isn't equal because applying the two has different results.
I think that multiple hops of the same unit should coalesce such that
1.month + 1.month actually is 2.months (as it claims to be via nearly
any other API call.)

TX

I can't reproduce your problem. On a new Rails project:

Loading development environment (Rails 2.3.3)

(1.month + 1.month).from_now

=> Sun, 01 Nov 2009 14:47:26 UTC 00:00

2.months.from_now

=> Sun, 01 Nov 2009 14:47:32 UTC 00:00

Are you sure you haven't installed a plugin that 'enhances' Time?

-christos

Are you sure you're running this on August 31st? Looks like you're
running it on November 1st to me.

TX

Are you sure you're running this on August 31st? Looks like you're
running it on November 1st to me.

Was running on September 1st. Wasn't clear from your original message
that the exact date was a requirement for the bug to occur.

Changing the date to August 31 I can reproduce the anomaly.

Furthermore it seems like calling to_i on the returned Duration
calculates correctly (incorrectly?)

>> (1.month + 1.month).to_i.from_now
=> Fri Oct 30 08:59:17 0100 2009
>> (2.months).to_i.from_now
=> Fri Oct 30 08:59:25 0100 2009

What is strange is (after having read Rick DeNatale's explanation)
that the '+' operator doesn't add parts even if they are of the same
type (months)

Any reason for that?

-christos

Because a duration in fact represents a series of steps to increment a
date/time/datetime and it's useful to differentiate between

(x.months + y.months)

and

(x+y).months

Think of the + when adding durations as if it were 'then'

(apologies if this might be becoming off-list; feel free to shut me up at any time)

Thanks for the explanation Rick. I see the 'why' now.

The 'how' still bothers me.

1.month + 1.month is internally distinct from 2.months...

   (1.month + 1.month).parts # => [[:months, 1], [:months, 1]]
   2.months.parts # => [[:months, 2]]

...although everything else points to the contrary:

   (1.month + 1.month) === 2.months # => true
   (1.month + 1.month) == 2.months # => true
   (1.month + 1.month).to_i # => 5184000
   2.months.to_1 # => 5184000
   2.months.inspect # => "2 months"
   (1.month + 1.month).inspect # => "2 months"
   (1.month + 1.month).class # => Fixnum
   2.months.class # => Fixnum

If the above tests behaved as expected and returned a Duration, I wouldn't be so surprised by the behaviour of the + operator, or the == and === for that matter.

As it stands, it is not very intuitive, but Time never is, I guess.

-christos