ActiveSupport の入った環境では日付・時刻とその文字列の比較が成立する

単に自分が知らなかったというだけなのですが、ちょっとびっくりしたのでメモ。

## 調べるに至った経緯

`Book` という `timestamps` のカラムを持つ AR モデルがあるとします。

```ruby
b = Book.create(title: 'ルビーの冒険')
expect(b.as_json).to match(b.attributes)

# created_at と updated_at の日付は、as_json の結果と、そのままのものとで型が違うはずなのに match の結果が一致した
```

これが `true` になるという結果に少し驚いたのでした。というのも、日付は少なくとも文字列に変換されたものと、`ActiveSupport::TimeWithZone` 型のもので異なっているはずなので、一致することはないだろうと思っていました。

> 私の環境では MySQL の datetime はミリ秒以下を保存しないので、四捨五入されているため、`ActiveSupport::TimeWithZone` に戻った時もミリ秒以下は 0 です。よって、as_json にした時の精度の違いで差分は出ません。

## 調査

### 最初は RSpec がよしなにやっていると思った

RSpec の `match` matcher を見ていくと、`Support::FuzzyMatcher.values_match?` にあたりました。

https://github.com/rspec/rspec-support/blob/master/lib/rspec/support/fuzzy_matcher.rb#L8-L24

```rb:rspec-support/lib/rspec/support/fuzzy_matcher.rb
module RSpec
  module Support
    module FuzzyMatcher
      def self.values_match?(expected, actual)
        if Hash === actual
          return hashes_match?(expected, actual) if Hash === expected
        elsif Array === expected && Enumerable === actual && !(Struct === actual)
          return arrays_match?(expected, actual.to_a)
        end

        return true if expected == actual

        begin
          expected === actual
        rescue ArgumentError
          false
        end
      end

      def self.hashes_match?(expected_hash, actual_hash)
        return false if expected_hash.size != actual_hash.size

        expected_hash.all? do |expected_key, expected_value|
          actual_value = actual_hash.fetch(expected_key) { return false }
          values_match?(expected_value, actual_value)
        end
      end
```

今回のケースは Hash なのですが、最終的には個々の value の比較になるので `self.values_match?` での `expected == actual` か `expected === actual` のどちらかで、文字列の日時と `ActiveSupport::TimeWithZone` の日時が比較されることになります。

どうやら、RSpec がよしなにやっている訳ではなさそうだ。。

### ということは ActiveSupport か

`ActiveSupport::TimeWithZone` が `expected` 側なので、そこで比較に使うメソッドが override されている可能性がありそうです。

#### TimeWithZone

https://github.com/rails/rails/blob/master/activesupport/lib/active_support/time_with_zone.rb#L222-L224

```rb:rails/activesupport/lib/active_support/time_with_zone.rb
module ActiveSupport
  class TimeWithZone
    def <=>(other)
      utc <=> other
    end

    def utc
      @utc ||= period.to_utc(@time)
    end
    alias_method :comparable_time, :utc
    alias_method :getgm, :utc
    alias_method :getutc, :utc
    alias_method :gmtime, :utc
```

`utc` になると `Time` 型に変換されます。

#### core_ext Time

次に Time がどういう拡張をされているか見てみます。

https://github.com/rails/rails/blob/master/activesupport/lib/active_support/core_ext/time/calculations.rb

```rb:rails/activesupport/lib/active_support/core_ext/time/calculations.rb
class Time
  def compare_with_coercion(other)
    # we're avoiding Time#to_datetime and Time#to_time because they're expensive
    if other.class == Time
      compare_without_coercion(other)
    elsif other.is_a?(Time)
      compare_without_coercion(other.to_time)
    else
      to_datetime <=> other
    end
  end
  alias_method :compare_without_coercion, :<=>
  alias_method :<=>, :compare_with_coercion
```

`<=>` に拡張が入っています。
今回は `other` は文字列なので `to_datetime <=> other` が行われることになります。

`to_datetime` を実行すると `DataTime` に変換されていますね。

#### core_ext DateTime

ということは DateTime にどういう拡張がされているかです。

https://github.com/rails/rails/blob/master/activesupport/lib/active_support/core_ext/date_time/calculations.rb#L190-L198

```rb:rails/activesupport/lib/active_support/core_ext/date_time/calculations.rb
class DateTime
  # Layers additional behavior on DateTime#<=> so that Time and
  # ActiveSupport::TimeWithZone instances can be compared with a DateTime.
  def <=>(other)
    if other.respond_to? :to_datetime
      super other.to_datetime rescue nil
    else
      super
    end
  end
```

ここでついに `other` 側にも変換が入ってきました。
`other` は文字列ですが、、、`to_datetime` で `DateTime` に変換できます :exclamation: 

#### core_ext String

https://github.com/rails/rails/blob/master/activesupport/lib/active_support/core_ext/string/conversions.rb#L48-L56

```rb
class String
  # Converts a string to a DateTime value.
  #
  #   "1-1-2012".to_datetime            # => Sun, 01 Jan 2012 00:00:00 +0000
  #   "01/01/2012 23:59:59".to_datetime # => Sun, 01 Jan 2012 23:59:59 +0000
  #   "2012-12-13 12:50".to_datetime    # => Thu, 13 Dec 2012 12:50:00 +0000
  #   "12/13/2012".to_datetime          # => ArgumentError: invalid date
  def to_datetime
    ::DateTime.parse(self, false) unless blank?
  end
```

うおーー、この文字列を時刻系に変換できるの、どっかで見てたはずなのになんですぐに気づかなかったんだろう。。

### 調査の結果

ここまで見てきたように RSpec の比較が `expected == actual` の順で、`expected` 側が `ActiveSupport::TimeWithZone` であった場合には、`actual` 側が文字列であったとしても （というか `to_datetime` に反応できれば） 比較が成立するので、一致するという事象が起きたということでした。

便利な機能ではあるものの、ちょっとアサートでこれが起きるとびっくりしたなというだけでした。

## 余談

### MySQL の時刻の小数部精度と JSON の小数部精度

[Rails-4.2+MySQL-5.6での時刻オブジェクトのミリ秒の扱いについて - Qiita](http://qiita.com/joker1007/items/d4043eced880cbbce21c)

上記の記事がとても詳しいです。

MySQL 5.6 系から小数秒 (5.6.4 からはマイクロ秒まで） を持てるようになりました。
上記の記事の通りで、持てるようになっただけで、小数部の精度を指定しなければ四捨五入されて投入されるだけです。

今回私が利用していた環境はというと、小数部の指定をしていなかったので `ActiveSupport::TimeWithZone` がマイクロ秒まで精度を持っていたとしても、小数部は全て四捨五入されて DB に格納されるため、取り出した時には小数部は 0 になっていました。

一方で JSON の方はというと TimeWithZone でいえば以下のようなコードになっているようです。

https://github.com/rails/rails/blob/master/activesupport/lib/active_support/time_with_zone.rb#L152-L170

```rb:rails/activesupport/lib/active_support/time_with_zone.rb
module ActiveSupport
  class TimeWithZone
    # Coerces time to a string for JSON encoding. The default format is ISO 8601.
    # You can get %Y/%m/%d %H:%M:%S +offset style by setting
    # <tt>ActiveSupport::JSON::Encoding.use_standard_json_time_format</tt>
    # to +false+.
    #
    #   # With ActiveSupport::JSON::Encoding.use_standard_json_time_format = true
    #   Time.utc(2005,2,1,15,15,10).in_time_zone("Hawaii").to_json
    #   # => "2005-02-01T05:15:10.000-10:00"
    #
    #   # With ActiveSupport::JSON::Encoding.use_standard_json_time_format = false
    #   Time.utc(2005,2,1,15,15,10).in_time_zone("Hawaii").to_json
    #   # => "2005/02/01 05:15:10 -1000"
    def as_json(options = nil)
      if ActiveSupport::JSON::Encoding.use_standard_json_time_format
        xmlschema(ActiveSupport::JSON::Encoding.time_precision)
      else
        %(#{time.strftime("%Y/%m/%d %H:%M:%S")} #{formatted_offset(false)})
      end
    end

    # Returns a string of the object's date and time in the ISO 8601 standard
    # format.
    #
    #   Time.zone.now.xmlschema  # => "2014-12-04T11:02:37-05:00"
    def xmlschema(fraction_digits = 0)
      "#{time.strftime(PRECISIONS[fraction_digits.to_i])}#{formatted_offset(true, 'Z'.freeze)}"
    end
    alias_method :iso8601, :xmlschema

    # Returns a formatted string of the offset from UTC, or an alternative
    # string if the time zone is already UTC.
    #
    #   Time.zone = 'Eastern Time (US & Canada)'   # => "Eastern Time (US & Canada)"
    #   Time.zone.now.formatted_offset(true)       # => "-05:00"
    #   Time.zone.now.formatted_offset(false)      # => "-0500"
    #   Time.zone = 'UTC'                          # => "UTC"
    #   Time.zone.now.formatted_offset(true, "0")  # => "0"
    def formatted_offset(colon = true, alternate_utc_string = nil)
      utc? && alternate_utc_string || TimeZone.seconds_to_utc_offset(utc_offset, colon)
    end
```

使われている設定に関してはデフォルトなので以下でした。

```rb:
> ActiveSupport::JSON::Encoding.use_standard_json_time_format
=> true
> ActiveSupport::JSON::Encoding.time_precision
=> 3
```

つまり、 __ISO 8601 形式 (iso8601 は xmlschema に alias されている）で精度は小数部 3 で文字列に変換__ されていることが分かります。

ということは、今回だと `as_json` をした時点で小数部 3 までに落とされていることになります。

上記を踏まえると以下のような挙動になります。

```rb
book = Book.create('ルビーの冒険').reload

# DB に一度格納されて小数部が落とされているため、JSON で 3 桁になっても 0 同士なので一致する
expect({time: book['created_at']}.as_json).to eq({time: book.created_at}) #=> true

now = Time.zone.now #=> Thu, 09 Feb 2017 04:59:44 UTC +00:00
now.usec #=> 210228
now.as_json #=> "2017-02-09T04:59:44.210Z"

# 上記の通りでマイクロ秒の精度に違いが出てしまうため一致しない
expect({time: now}.as_json).to match({time: now}) #=> fail

# 一応細かい値
> now.to_datetime
=> Thu, 09 Feb 2017 04:59:44 +0000
> now.to_datetime.usec
=> 210228
> now.as_json.to_datetime
=> Thu, 09 Feb 2017 04:59:44 +0000
> now.as_json.to_datetime.usec
=> 210000

# 精度を 6 桁にすると一致する
> ActiveSupport::JSON::Encoding.time_precision = 6
> expect({time: now}.as_json).to match({time: now}) #=> true

# 要は以下が成立するということ
> now.utc.to_datetime == now.as_json.to_datetime #=> true
```

### 参考

- [11.1.2 日付と時間型の概要 - MySQL](https://dev.mysql.com/doc/refman/5.6/ja/date-and-time-type-overview.html)
- [RubyでTimeオブジェクトを"正しい"JSONに出力する - ぼろぼろ平原](http://tatzyr.hatenablog.com/entry/2016/02/21/201259)
- [MySQL DATETIME型のミリ秒の扱いについて - DoRuby](https://doruby.jp/users/ap_tg/entries/MySQL_DATETIME_)