単に自分が知らなかったというだけなのですが、ちょっとびっくりしたのでメモ。
調べるに至った経緯
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?
にあたりました。
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
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 がどういう拡張をされているか見てみます。
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 にどういう拡張がされているかです。
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
に変換できます
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 でいえば以下のようなコードになっているようです。
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