LoginSignup
12
4

More than 5 years have passed since last update.

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

Posted at

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

調べるに至った経緯

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

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? にあたりました。

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 == actualexpected === actual のどちらかで、文字列の日時と ActiveSupport::TimeWithZone の日時が比較されることになります。

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

ということは ActiveSupport か

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

TimeWithZone

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 がどういう拡張をされているか見てみます。

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 にどういう拡張がされているかです。

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_datetimeDateTime に変換できます :exclamation:

core_ext String

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

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

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

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

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

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

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

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

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

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

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

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

参考

12
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
4