4

How's this possible?

Time.now.utc.to_date + 1.month + 15.days #=> Mon, 01 Dec 2014
Time.now.utc.to_date + 15.days + 1.month #=> Sun, 30 Nov 2014

Has anyone seen it?

/edit

I guess I asked the question a wrong way. How do you guys explain this then?

Time.now.utc.to_date + (15.days + 1.month) #=> Mon, 08 Dec 2014
Time.now.utc.to_date + (1.month + 15.days) #=> Tue, 09 Dec 2014

(15.days + 1.month) #=> 3888000
(1.month + 15.days) #=> 3888000
Marcin Urbanski
  • 2,493
  • 1
  • 16
  • 17
  • (A) Start from the origin, facing north. Turn 90 degrees, and then go front 100 m. (B) Start from the origin, facing north. Go front 100 m, and then turn 90 degrees. How is it possible that you get different results? Not everything in this world is commutative. – sawa Oct 16 '14 at 16:16

4 Answers4

6

First let see Integer#month, it returns an instance of ActiveSupport::Duration. At the rails console:

~/rails/rfinan (1296000):1 > elapsed = 1.month
=> 2592000
~/rails/rfinan (1296000):1 > elapsed.value
=> 2592000
~/rails/rfinan (1296000):1 > elapsed.parts
=> [[:months,1]]
~/rails/rfinan (1296000):1 > elapsed.is_a? ActiveSupport::Duration
=> true

It's time for the method: ActiveSupport::Duration#+

~/rails/rfinan (1296000):1 > sum1 = 1.month + 15.days
=> 3888000
~/rails/rfinan (1296000):1 > sum2 = 15.days + 1.month
=> 3888000
~/rails/rfinan (1296000):1 > sum1.value
=> 3888000
~/rails/rfinan (1296000):1 > sum1.parts
=> [[:months,1],[:days,15]]
~/rails/rfinan (1296000):1 > sum2.value
=> 3888000
~/rails/rfinan (1296000):1 > sum2.parts
=> [[:days,15],[:months,1]]
~/rails/rfinan (1296000):1 > sum1 == sum2
=> true
~/rails/rfinan (1296000):1 > sum1.value == sum2.value
=> true
~/rails/rfinan (1296000):1 > sum1.parts == sum2.parts
=> false

Now Date#+, the ActiveSupport version.

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

That means: if I send :+ to a Date instance with a ActiveSupport::Duration instance as parameter, it calls ActiveSupport::Duration#since, and the last one calls ActiveSupport::Duration#sum, that injects over the date instance, and calls Date#advance on each of the parts of duration instance:

  def sum(sign, time = ::Time.current) #:nodoc:
    parts.inject(time) do |t,(type,number)|
      if t.acts_like?(:time) || t.acts_like?(:date)
        if type == :seconds
          t.since(sign * number)
        else
          t.advance(type => sign * number)
        end
      else
        raise ::ArgumentError, "expected a time or date, got #{time.inspect}"
      end
    end
  end

Remmember sum1.parts != sum2.parts?, sum send advance to the date instance orderly. Let see what means Date#advance

def advance(options)
  options = options.dup
  d = self
  d = d >> options.delete(:years) * 12 if options[:years]
  d = d >> options.delete(:months)     if options[:months]
  d = d +  options.delete(:weeks) * 7  if options[:weeks]
  d = d +  options.delete(:days)       if options[:days]
  d
end

When advance recive month: 1 it calls Date#>> from stdlib, that work diferently of ActiveSupport::Duration#+. At irb:

~ (main) > Date.new(2014,10,31) >> 1
=> #<Date: 2014-11-30 ((2456992j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 2
=> #<Date: 2014-12-31 ((2457023j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 3
=> #<Date: 2015-01-31 ((2457054j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 4
=> #<Date: 2015-02-28 ((2457082j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 5
=> #<Date: 2015-03-31 ((2457113j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 12
=> #<Date: 2015-10-31 ((2457327j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 1200
=> #<Date: 2114-10-31 ((2493486j,0s,0n),+0s,2299161j)>
~ (main) > Date.new(2014,10,31) >> 12000
=> #<Date: 3014-10-31 ((2822204j,0s,0n),+0s,2299161j)>

It's clear that Date#>> don't add days, add months and keep the day number. if the day isn't valid for the target month, it fixes it. Adding a fix number of months doesn't fixes the number of days added, because depend on the start date.

Now we can say that Date#+ is not the same of ActiveSupport::Duration#+, and we know why.

The anwer is Date#+ called with an ActiveSupport::Duration instance (say duration) doesn't care about duration.value, it uses duration.parts, which are different in each case.

Alejandro Babio
  • 5,189
  • 17
  • 28
5

October has 31 days, November does not. This means that it depends a little on how you calculate the 31st + 1 Month.

For the first example:

  • Now + 1 Month = 16-Nov
  • 16-Nov + 15 days = 1-Dec

For the second example:

  • Now + 15 days = 31-Oct
  • 31-Oct + 1 Month = 30-Nov
Geoffrey Wiseman
  • 5,459
  • 3
  • 34
  • 52
4

October has 31 days. When you add 15 days to Oct 16 you get Oct 31. Adding a month carries you to the same date on the next month - Nov. 31, but there is no Nov. 31 so it takes you to Nov 30.

If instead you add the month first, that carries you to Nov 16. Then adding 15 days carries you to Dec 01.

Julian
  • 2,483
  • 20
  • 20
2

When you do:

(15.days + 1.month) #=> 3888000
(1.month + 15.days) #=> 3888000

You are not operating dates, you are operating seconds (Rails Numeric < Object). To prove, let's convert it back to days:

> 3888000 / 60 / 60 / 24
 => 45

45 = 30 + 15. So we know that, when operating seconds, or days, the compiler interprets 1.month as 30 days by default when operating Numerics. See numerics reference: http://api.rubyonrails.org/classes/Numeric.html#method-i-seconds

As you can see in the link above, when you operate Dates with Numerics, rails calls the advance(options) method which is responsible for executing correct Date operations. See advance definition on github: https://github.com/rails/rails/blob/ffc273577a795bb41068bfc2a1bb575ec51a9712/activesupport/lib/active_support/core_ext/time/calculations.rb#L99

Also, when operating dates using Time.now.utc.to_date + (1.month + 15.days) the + () function will actually call the advance(options) method like this:

(Time.now.utc.to_date.advance(month:1)).advance(days:15) #fistCase

when you use Time.now.utc.to_date + (15.days + 1.month), what will be called is this:

(Time.now.utc.to_date.advance(days:15)).advance(month:1) #secondCase

So, lets test #firstCase:

oct16 = Date.new(2014, 10, 16)
 > oct16 + (1.month + 15.days)
 => Mon, 01 Dec 2014
 > (oct16.advance(months:1)).advance(days:15)
 => Mon, 01 Dec 2014

The #firstCase conclusion is, it calls advance(month:1) resulting Nov-16, then it calls .advance(days:15) on Nov-16 and goes to Dez-01

Let's check the #secondCase:

> oct16 + (15.days + 1.month)
 => Sun, 30 Nov 2014
> (oct16.advance(days:15)).advance(months:1)
 => Sun, 30 Nov 2014 

The #secondCase conclusion is, it calls advance(days:15), which results in Oct-31, than it calls advance(months: 1) on the last result, which would give us Nov-31, but wait! Nov-31 does not exist! So the interpreter is smart enough to understand that, since you were on the last day of the month(Oct-31), when you add 1.month, or advance(months:1), you are asking him to take you to the last day of the next month, in that case Nov-30.

That's the convention.

Joao Cunha
  • 772
  • 4
  • 15