経緯
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.momnth
と Date.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)