Rails4, Rails5の時間の足し算の挙動の違い

経緯

Rails4で、日付の足し算の挙動が自分の期待と異なっていたので調査した。 1/31の翌月の翌月も、1/31の二ヶ月後も、どちらも2018/3/31になるのを期待していたが、実際の結果は異なった。 前者は「1/31の翌月の翌月」 => 「2/28の翌月」 => 「3/28」と処理されているもよう。

Loading development environment (Rails 4.2.10)
irb(main):001:0> 1.month + 1.month == 2.month
=> true
irb(main):002:0> Date.new(2018, 1, 31) + (1.month + 1.month)
=> Wed, 28 Mar 2018
irb(main):003:0> Date.new(2018, 1, 31) + (2.month)
=> Sat, 31 Mar 2018

後に、Rails5でも確認したところ、挙動が変更され、自分の期待と同様に動くことを確認した。

結論

Rails5なら、自分の期待通りに動く。 Rails4のときは、 Date.new + x.month + y.momnthDate.new + (x + y).month は計算結果が異なることがあるので、足し方に注意。

調査環境

Rails4, Rails5での挙動は、下記の環境で確認した。

# Rails4環境
~/t/rails4-sandbox (master|✔) $ docker-compose exec web rails --version
Rails 4.2.10
~/t/rails4-sandbox (master|✔) $ docker-compose exec web ruby --version
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux]
~/t/rails4-sandbox (master|✔) $ docker-compose exec db  psql -U postgres -c "SELECT version()"
                                         version

--------------------------------------------------------------------------------
----------
 PostgreSQL 9.6.5 on x86_64-pc-linux-gnu, compiled by gcc (Debian 4.9.2-10) 4.9.
2, 64-bit
(1 row)
# Rails5環境
~/t/rails-sandbox (master|✔) $ docker-compose exec web rails --version
Rails 5.2.1
~/t/rails-sandbox (master|✔) $ docker-compose exec web ruby --version
ruby 2.5.1p57 (2018-03-29 revision 63029) [x86_64-linux]
~/t/rails-sandbox (master|✔) $ docker-compose exec db  psql -U postgres -c "SELECT version()"
                                         version

------------------------------------------------------------------------------------------
 PostgreSQL 9.6.5 on x86_64-pc-linux-gnu, compiled by gcc (Debian 4.9.2-10) 4.9.2, 64-bit
(1 row)

調査メモ

問題の挙動は下記の通り。 どちらも「二ヶ月後」の3/31になると期待していた。

Loading development environment (Rails 4.2.10)
irb(main):001:0> 1.month + 1.month == 2.month
=> true
irb(main):002:0> Date.new(2018, 1, 31) + (1.month + 1.month)
=> Wed, 28 Mar 2018
irb(main):003:0> Date.new(2018, 1, 31) + (2.month)
=> Sat, 31 Mar 2018

Rails5では自分の期待通りに動くようになっている。

Loading development environment (Rails 5.2.1)
irb(main):001:0> 1.month + 1.month == 2.month
=> true
irb(main):002:0> Date.new(2018, 1, 31) + (1.month + 1.month)
=> Sat, 31 Mar 2018
irb(main):003:0> Date.new(2018, 1, 31) + (2.month)
=> Sat, 31 Mar 2018
irb(main):004:0>

Rails4での、ActiveRecord::Durationどうしの足し算の処理方法は、下記の通り。

# https://github.com/rails/rails/blob/4-2-stable/activesupport/lib/active_support/duration.rb#L16-L24
    # Adds another Duration or a Numeric to this Duration. Numeric values
    # are treated as seconds.
    def +(other)
      if Duration === other
        Duration.new(value + other.value, @parts + other.parts)
      else
        Duration.new(value + other, @parts + [[:seconds, other]])
      end
    end

Rails4では、Durationどうしの足し算は、@parts部分は、じつは足していない。配列で足すべき要素を覚えているだけ。試しに実行してみると以下のようになる。

Loading development environment (Rails 4.2.10)
irb(main):001:0> (1.month + 1.month).parts
=> [[:months, 1], [:months, 1]]
irb(main):002:0> 2.month.parts
=> [[:months, 2]]

Rails5のDurationどうしの足し算処理の内容は下記の通り。@parts部分はHash。自分の期待通りに足している。

# https://github.com/rails/rails/blob/74e5205cc0e04a28db86fd3ec82124a8ebf4f549/activesupport/lib/active_support/duration.rb#L234-L247
    # Adds another Duration or a Numeric to this Duration. Numeric values
    # are treated as seconds.
    def +(other)
      if Duration === other
        parts = @parts.dup
        other.parts.each do |(key, value)|
          parts[key] += value
        end
        Duration.new(value + other.value, parts)
      else
        seconds = @parts[:seconds] + other
        Duration.new(value + other, @parts.merge(seconds: seconds))
      end
    end

実行結果。

Loading development environment (Rails 5.2.1)
irb(main):001:0> (1.month + 1.month).parts
=> {:months=>2}
irb(main):002:0> 2.month.parts
=> {:months=>2}

Date + Durationの処理は、Duration#sumが呼ばれる。

parts.injectで、足す単位(seconds, minutesなど)に応じてDate#advance || Date#sinceしている。

minutes, hoursの場合,Rails4ではadvanceだったのが、Rails5では秒換算でsinceされる。なぜだろう?

advenceされるのは、後のadvanceのコードを見ればわかるとおり、years, months, weeks, daysの場合だ。

# Rails4
# https://github.com/rails/rails/blob/3804d017333da16d76d9fc6633faf5635c7b03d7/activesupport/lib/active_support/duration.rb#L133-L145
      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
# Rails5
# https://github.com/rails/rails/blob/74e5205cc0e04a28db86fd3ec82124a8ebf4f549/activesupport/lib/active_support/duration.rb#L402-L418
      def sum(sign, time = ::Time.current)
        parts.inject(time) do |t, (type, number)|
          if t.acts_like?(:time) || t.acts_like?(:date)
            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
          else
            raise ::ArgumentError, "expected a time or date, got #{time.inspect}" 
          end
        end
      end

Date#sinceは以下の処理。Rails4もRails5も同じ。

# https://github.com/rails/rails/blob/3804d017333da16d76d9fc6633faf5635c7b03d7/activesupport/lib/active_support/core_ext/date/calculations.rb#L57-L62
# https://github.com/rails/rails/blob/74e5205cc0e04a28db86fd3ec82124a8ebf4f549/activesupport/lib/active_support/core_ext/date/calculations.rb#L59-L64
  # Converts Date to a Time (or DateTime if necessary) with the time portion set to the beginning of the day (0:00)
  # and then adds the specified number of seconds
  def since(seconds)
    in_time_zone.since(seconds)
  end
  alias :in :since

。。 Date#advanceは以下の処理。Rails4もRails5も同じ。

# https://github.com/rails/rails/blob/3804d017333da16d76d9fc6633faf5635c7b03d7/activesupport/lib/active_support/core_ext/date/calculations.rb#L108-L118
# https://github.com/rails/rails/blob/74e5205cc0e04a28db86fd3ec82124a8ebf4f549/activesupport/lib/active_support/core_ext/date/calculations.rb#L110-L120
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

秒数換算(1ヶ月30日)の@valuesと、seconds, months, weeks, daysなどの人間に優しい値?の@partsを持っている。

Loading development environment (Rails 4.2.10)
irb(main):001:0> 1.month.instance_variables
=> [:@value, :@parts]
irb(main):002:0> 1.month.instance_variable_get(:@value)
=> 2592000
irb(main):003:0> 1.month.instance_variable_get(:@parts)
=> [[:months, 1]]
irb(main):004:0> (1.month + 2.days + 3.month).instance_variable_get(:@value)
=> 10540800
irb(main):005:0> (1.month + 2.days + 3.month).instance_variable_get(:@parts)
=> [[:months, 1], [:days, 2], [:months, 3]]
irb(main):006:0> d = 1.month + 1.month.value
=> 1 month and 2592000 seconds
irb(main):007:0> 1.month.instance_variables
=> [:@value, :@parts]
irb(main):008:0> d.instance_variable_get(:@value)
=> 5184000
irb(main):009:0> d.instance_variable_get(:@parts)
=> [[:months, 1], [:seconds, 2592000]]
irb(main):010:0> d.value
=> 5184000
Loading development environment (Rails 5.2.1)
rb(main):001:0> 1.month.instance_variables
=> [:@parts, :@value]
irb(main):002:0> 1.month.instance_variable_get(:@value)
=> 2629746
irb(main):003:0> 1.month.instance_variable_get(:@parts)
=> {:months=>1}
irb(main):004:0> (1.month + 2.days + 3.month).instance_variable_get(:@value)
=> 10691784
irb(main):005:0> (1.month + 2.days + 3.month).instance_variable_get(:@parts)
=> {:months=>4, :days=>2}
irb(main):006:0> d = 1.month + 1.month.value
=> 1 month and 2629746 seconds
irb(main):007:0> 1.month.instance_variables
=> [:@parts, :@value]
irb(main):008:0> d.instance_variable_get(:@value)
=> 5259492
irb(main):009:0> d.instance_variable_get(:@parts)
=> {:months=>1, :seconds=>2629746}
irb(main):010:0> d.value
=> 5259492

1.yearは360日でも365日でもない。365.25日。1.yearと1.monthのvalue換算の値の大きさが、Rails4とRails5で異なっている。

Loading development environment (Rails 4.2.10)
irb(main):001:0> 1.year.value
=> 31557600.0
irb(main):002:0> 1.month.value * 12
=> 31104000
irb(main):003:0> 1.day.value * 365
=> 31536000

Rails4での各単位の秒数については下記の通り。

# Rails4
# https://github.com/rails/rails/blob/3804d017333da16d76d9fc6633faf5635c7b03d7/activesupport/lib/active_support/duration.rb#L59-L81
   # Returns the number of seconds that this Duration represents.
    #
    #   1.minute.to_i   # => 60
    #   1.hour.to_i     # => 3600
    #   1.day.to_i      # => 86400
    #
    # Note that this conversion makes some assumptions about the
    # duration of some periods, e.g. months are always 30 days
    # and years are 365.25 days:
    #
    #   # equivalent to 30.days.to_i
    #   1.month.to_i    # => 2592000
    #
    #   # equivalent to 365.25.days.to_i
    #   1.year.to_i     # => 31557600
    #
    # In such cases, Ruby's core
    # Date[http://ruby-doc.org/stdlib/libdoc/date/rdoc/Date.html] and
    # Time[http://ruby-doc.org/stdlib/libdoc/time/rdoc/Time.html] should be used for precision
    # date and time arithmetic.
    def to_i
      @value.to_i
    end

Rails5での各単位の秒数については下記の通り。 1ヶ月は30日でなく、1年の12分の1に変更されている。

# Rails5.2
# https://github.com/rails/rails/blob/74e5205cc0e04a28db86fd3ec82124a8ebf4f549/activesupport/lib/active_support/duration.rb#L322-L344
    # Returns the number of seconds that this Duration represents.
    #
    #   1.minute.to_i   # => 60
    #   1.hour.to_i     # => 3600
    #   1.day.to_i      # => 86400
    #
    # Note that this conversion makes some assumptions about the
    # duration of some periods, e.g. months are always 1/12 of year
    # and years are 365.2425 days:
    #
    #   # equivalent to (1.year / 12).to_i
    #   1.month.to_i    # => 2629746
    #
    #   # equivalent to 365.2425.days.to_i
    #   1.year.to_i     # => 31556952
    #
    # In such cases, Ruby's core
    # Date[http://ruby-doc.org/stdlib/libdoc/date/rdoc/Date.html] and
    # Time[http://ruby-doc.org/stdlib/libdoc/time/rdoc/Time.html] should be used for precision
    # date and time arithmetic.
    def to_i
      @value.to_i
    end
Loading development environment (Rails 5.2.1)
irb(main):001:0> 1.year.value
=> 31556952
irb(main):002:0> 1.month.value * 12
=> 31556952
irb(main):003:0> 1.day.value * 365
=> 31536000
irb(main):004:0>

余談:SQL

SQLでも、「二ヶ月間 + 一ヶ月間」と「一ヶ月+二ヶ月間」では、結果が違うことがある。

psql (9.6.5)
Type "help" for help.

postgres=# select date('2017-01-31'), (date('2017-01-31') +  make_interval(months := 2) + interval '1 month') AS expire;
    date    |       expire
------------+---------------------
 2017-01-31 | 2017-04-30 00:00:00
(1 row)

postgres=# select date('2017-01-31'), (date('2017-01-31') +  make_interval(months := 1) + interval '2 month') AS expire;
    date    |       expire
------------+---------------------
 2017-01-31 | 2017-04-28 00:00:00
(1 row)

postgres=# select date('2017-01-31'), (date('2017-01-31') +  make_interval(months := 1) + make_interval(months := 2)) AS expire;
    date    |       expire
------------+---------------------
 2017-01-31 | 2017-04-28 00:00:00
(1 row)

postgres=# select date('2017-01-31'), (date('2017-01-31') +  interval '1 month' + interval '2 month') AS expire;
    date    |       expire
------------+---------------------
 2017-01-31 | 2017-04-28 00:00:00
(1 row)

以下の様に書くと、つねに(私の)期待どおり動作する。

psql (9.6.5)
Type "help" for help.

postgres=# select date('2017-01-31'), (date('2017-01-31') +  make_interval(months := 1 + 2));
    date    |      ?column?
------------+---------------------
 2017-01-31 | 2017-04-30 00:00:00
(1 row)