Rails の n.months って Date の加減演算の引数になると月ごとの日数考慮するんだなあ

知らなかったというか、それについて真面目に考えたことが無かった。

コード

class HogeTest < Test::Unit::TestCase
  def test_hoge
    Timecop.freeze Date.new(2014, 11, 1) do
      assert_equal Date.new(2014, 11, 1), Date.today
      assert_equal Date.new(2014, 12, 1), Date.today >> 1
      assert_equal Date.today + 1.months, Date.today >> 1
      assert_equal Date.today, Date.today + 1.months - 30.days
      assert_equal 30.days, 1.months
    end

    Timecop.travel Date.new(2014, 12, 1) do
      assert_equal Date.new(2014, 12, 1), Date.today
      assert_equal Date.new(2015, 1, 1),  Date.today >> 1
      assert_equal Date.today + 1.months, Date.today >> 1
      assert_equal Date.today, Date.today + 1.months - 31.days
      assert_equal 30.days, 1.months
    end

    assert_equal Date.new(2015, 1, 1),  Date.new(2015, 1, 1) + 2.months - 59.days
    assert_equal Date.new(2015, 2, 1),  Date.new(2015, 2, 1) + 3.months - 89.days
  end
end
$ RAILS_ENV=test rbbe rails r hoge.rb

ぽてっと 1.months って書いた場合はいつも固定で30日が来るんですが、加算の左辺が11月の場合30日になって、12月の場合31日になる。2015年の1-2月の2ヶ月だと59日で、2-4月の3ヶ月だと89日。

いつも定数的に使ってるけど、同じスコープ内で同じ表記して違う値が戻ってくるんだなあ。31日から1ヶ月後とやって次の月に31日が無い場合30日に戻るので、「次の月の同じ日に行く!(その日が無かったら有効なとこまで戻る!)」指向なのでありました。

ActiveSupport::Duration 同士の加減算では年月日の情報が保持されている

ちなみに 1.month + 1.days こんな感じに左辺としても使うと呼ばれるのは Duration#+ でこれはもちろん30日固定…なんだけど、この加算で生成された Durationインスタンスは内部的に「月が1で日が1です」って情報を抱えていて、これを Date に対して加算しようとするとやっぱり30日になったり31日になったり28日になったりする。

    duration = 1.months + 1.days
    assert_equal Date.new(2014,11,1) + duration, Date.new(2014,11,1) + 31.days
    assert_equal Date.new(2014,12,1) + duration, Date.new(2014,12,1) + 32.days

これは、自分アホなんで、間違いなく定数として使ってバグらすパターンですね。

実装

Date#+ はその実 Rails が拡張した Date#plus_with_duration で、これが最終的にネイティブの Date>> を使っておられる。

TimeDate はいかにも Ruby ネイティブの少女っぽいふりして Rails によってすっかり女に変えられているので、裏表あって怖い。