背景
あるとき、こんなエラーに遭遇しました。
Mysql2::Error: Invalid date in field 'publish_date': 2020-10-00
「publish_date
という(MySQLの日付型の)カラムに 2020-10-00
という日にちを保存できなかった。」ということらしいです。
確かにカレンダー的には 00日
なんて日にちは存在しないのでエラーになって当然だろうとは思いますが、詳しく仕組みが知りたかったので調査し始めました。
結果的には、これ自体はMySQLの NO_ZERO_IN_DATE
という sql_mode
の設定によって保存出来たり出来なかったりするとのことで、そういうものなのかと納得しました。
ただ、MySQLに保存する瞬間までRailsの中で 2020-10-00
という存在しない日付を持ち続けてるのもどうなのと思い、ちゃんとバリデーションするための調査を始めました。
先に結論
暫定
理想的な結果ではないですが、暫定ではこのような形に落ち着きました。
class ApplicationRecord < ActiveRecord::Base
VALID_DATE_REGEX = /\A[1-9]\d{3}-\d{2}-\d{2}\z/
end
class Book < ApplicationRecord
validates :publish_date, presence: true, format: { with: VALID_DATE_REGEX }
end
- 「存在する日付」の場合は通過する
- 「存在する日付」はタイプキャストによりRubyのDate型として保持されている
- その文字列表現(to_s)は常に
yyyy-mm-dd
となっているため正規表現で比較できる
- 「存在しない日付」の場合はinvalidとなる
- RubyのDate型は「存在しない日付」を持つことが出来ないため、タイプキャストによりnil値となっている
- nilは
presence
もformat_with
も通過しないため、エラーが2行出る - ※「入力自体がnil」「空白文字列」も同様
- その他のキャストできない値が入力された場合もinvalidとなる
- (※to_sが奇跡的に正規表現に一致しない限り)
他に簡便な手段がないので文字列表現を経由してformat_with
を使用していますが、実質is_a?(:Date)
やrespond_to?(:to_date)
をチェックしているのと変わりません。
gemを探せば型だけをチェックするValidatorもありますが、それだけでも解決しない諸問題があったため、今回の大調査となりました。
理想
ちなみに、
Validatorを自作しない範囲で理想を追求すると下のような感じも考えられますが、Date型が登場するたびにこの記述量では割に合わないので、こちらはひとまず見送りました。
class Book < ApplicationRecord
validates :publish_date, presence: true, if: -> (r){ r.publish_date_before_type_cast.blank? && r.publish_date.nil? }
validates :publish_date, format: { with: VALID_DATE_REGEX }, if: -> (r){ r.publish_date_before_type_cast.present? }
end
検証環境
Ruby 2.7.2 / 2.7.8
Rails 6.0.3.7 / 6.1.7
MySQL 5.7
※おそらく Rails 6.1, 7.0, 7.1でも MySQL 8.0でも変わらないはずです
各種Date型について調査
本記事では、以下3種類のDate型が登場します。
- Rubyの日付クラス
::Date
- ActiveSupportのコア拡張機能
- ActiveModelのデータ型
ActiveModel::Type::Date
- MySQLのデータ型
まずはこれらについて挙動や仕様を確認していこうと思います。
タイプキャストの挙動1(ActiveModel::Type::Date#cast_value)
module ActiveModel
module Type
class Date < Value
private
def cast_value(value)
if value.is_a?(::String)
return if value.empty?
fast_string_to_date(value) || fallback_string_to_date(value)
elsif value.respond_to?(:to_date)
value.to_date
else
value
end
end
以上より、タイプキャストを通ったDate型attributeの取り得る値は、以下3種類(Date,nil,その他)ということが分かります。
入力値(before_type_cast) | 出力値(value) |
---|---|
String(存在する日付) | Date型 |
String(存在しない日付) | nil |
String("") | nil |
Date,Timeなど、:to_dateを持つ値 | Date型 |
nil | nil |
その他(数値など) | 入力値そのまま |
一番下にやっかいなのが出てきていますね。
そうです、ActiveModelのDate型だからタイプキャストを通過すればDate型かnilになっているだろうと思っていましたが、そうではないんですね。タイプキャストできない値を入力するとattributeの中では入力値がそのまま保持されてしまうんですね。
数値を入れると数値のまま保持され続けます。
これが後から効いてくることになります。
タイプキャストの挙動2 (ActiveModel::Type::Date#fallback_string_to_date)
fallback_string_to_date
の中身も見てみましょう。
module ActiveModel
module Type
class Date < Value
private
ISO_DATE = /\A(\d{4})-(\d\d)-(\d\d)\z/
def fast_string_to_date(string)
if string =~ ISO_DATE
new_date $1.to_i, $2.to_i, $3.to_i
end
end
def fallback_string_to_date(string)
new_date(*::Date._parse(string, false).values_at(:year, :mon, :mday))
end
def new_date(year, mon, mday)
unless year.nil? || (year == 0 && mon == 0 && mday == 0)
::Date.new(year, mon, mday) rescue nil
end
end
なにやら奇妙なことしていました。
Date._parse
なるものを経由して文字列からDate型に変換しているようです。
Date.parse
ではないようです。_
の有無は何が違うのでしょうか。
Date._parse
pry(main)> Date._parse('2001-02-03')
{:year=>2001, :mon=>2, :mday=>3}
Date._parse
ではこのように、文字列から年月日の連想配列を生成するようです。
一方、同じ文字列で Date.parse
を行うと、
pry(main)> Date.parse('2001-02-03')
Sat, 03 Feb 2001
pry(main)> Date.parse('2001-02-03').class
Date < Object
このようにDateのインスタンスが返ります。
また、存在しない日付であるかどうかのチェックも行われないため、以下のようにしても例外にはなりません。
> Date._parse('2023-02-31')
{:year => 2023, :mon => 2, :mday => 31}
Date.parse
では存在しない日付は即例外となるため、この点でも挙動が異なっています。
> Date.parse('2023-02-31')
Date::Error: invalid date
Date.parse
Date.parse
は意外にやっかいです。
どの言語にもよくある日付パーサーなのですが、RubyのDate.parse
は知られざる多機能性があるようで、これを知らずに気軽に使ってしまうと意図しない変換が起こることになり、とてもやっかいなものになります。
知られざる機能はこちらの記事にまとまっていましたので、よければご覧ください。
(感謝感謝 )
この記事にあるように、Date.parse
は省略記法が充実しています。
そのため、ユーザーの入力ミスが意図しない日付に変換されてしまう危険性があります。
> params[:birthday] = '2000023' # <- ユーザーは'2000年2月3日'のつもりで入力したが、入力ミスしている
> birthday = Date.parse(params[:birthday])
Sun, 23 Jan 2000 # <-「2000年の23日目」と解釈され '2000年1月23日' になってしまう
この多機能性が活躍するシーンは多々あるとは思うので、そういう場面ではもちろん便利に利用できると思います。
ただ、ユーザー入力を直接入れるような使い方はしない方が良さそうですね。
タイプキャストの挙動3
これで fallback_string_to_date
の奇妙な実装の謎も解けてきました。
つまりRails(ActiveModel)では、Date.parse
のこの(ありがた迷惑な)多機能性を全て捨てて、直感的に日付と分かる文字列(2000-02-03
とか2000/2/3
とか)しかDate型にキャストしないようにしてくれているようですね。
ActiveModel側ではユーザーの入力を直接扱うこともありますので、このようになっていると安心ですね。
MySQLのDate型
更に、MySQLのDate型も絡んできて問題が複雑化します。
先程、タイプキャストで「その他(数値など)」が入力されると入力値がそのままattributeに保持されるとありましたが、この状態でModelをsaveするとどうなるでしょうか?
> b = Book.new(publish_date: 20210203) # <- 数値型で入力
> b.save
Date型のカラムに数値を入れたら型違いでSQLエラーになりそうな気がしますが、上の値の場合はそうなりません。
この場合の「20210203」という「数値」はMySQL側で暗黙的に型変換され、正常にDate型のカラムに保存されます。これはたまたま「日付として解釈可能な数値の並び」だったから ということになりそうです。
存在しない日付の場合はこのようにSQLエラーになります。
> b = Book.new(publish_date: 20210230) # <- 数値型で入力、存在しない日付
> b.save
ActiveRecord::StatementInvalid: Mysql2::Error: Incorrect date value: '20210230' for column 'publish_date' at row 1
NO_ZERO_IN_DATE
/ NO_ZERO_DATE
「存在しない日付」の中でも、以下2種類はMySQLでは特別なようです。
-
0000-00-00
(年月日全て0) -
yyyy-00-00
yyyy-mm-00
yyyy-00-dd
(月日両方かどちらか一方が0)
これらの値は、MySQLのsql_mode
の設定によって保存できるかどうかを制御できるようです。
ただし、保存できたとしてもRails(ActiveModel)では再度読み込むことが出来ません。
保存できる設定にして検証してみると以下のようになります。
> b = Book.new(publish_date: 20210200) # <- ZERO_IN_DATEな日付
> b.save # <- 保存自体は成功する
true
> b.reload # <- 読み込み直すとエラーになる
Mysql2::Error: Invalid date in field 'publish_date': 2021-02-00
from /app/vendor/bundle/ruby/2.7.0/gems/activerecord-6.1.7/lib/active_record/connection_adapters/mysql/database_statements.rb:59:in `each'
この「Mysql2::Error: Invalid date
」が冒頭で遭遇したエラーです。
これ自体はsql_mode
で禁止してしまえばいいので、大した問題ではなさそうです。
ただ、MySQLの中にこのような日付表現が存在していることがすごい不思議なので、これはまた別の機会に調査しようと思います。
日付の範囲
更に、MySQLにはサポートされている日付の範囲というものがあるそうです。
DATE 型は、日付部分を含むが時間部分は含まない値に使用されます。 MySQL は、DATE 値を'YYYY-MM-DD'形式で取得して表示します。
サポートしている範囲は '1000-01-01' から '9999-12-31' です。
この「サポートしている範囲」も微妙な表現で、この範囲外の日付がDate型カラムに保存できないわけではないようです。実際に保存はきちんとできます。
「日付の足し算引き算、比較などの動作を保証してない」程度の意味らしいです。(未検証)
ちなみに、RubyのDate
はどうかというと、MySQL程の制限はなさそうです。(上限は未確認)
また、Timestampとも関係なく日付を扱えるので、「1900年1月1日以降」といった制限もないようです。
> Date.new(-1000,2,3)
Fri, 03 Feb -1000
> Date.new(0,2,3)
Tue, 03 Feb 0000
> Date.new(999,2,3)
Fri, 03 Feb 0999
> Date.new(10020,2,3)
Mon, 03 Feb 10020
冒頭の正規表現で1桁目を[1-9]
に制限しているのはこのサポート範囲に収めるためです。
バリデーションを実装
ここまでの調査を踏まえて、Date型のバリデーションをしていきたいと思います。
まず、今回の理想的なエラーを整理すると以下のようになります。
参考までに、presence: true
のみ設定した場合のエラーも記載して比較してみます。
入力値(before_type_cast) | 出力値(value) | 理想的なエラー | presenceのみ |
---|---|---|---|
String(存在する日付) | Date | -- | -- |
String(存在しない日付) | nil | invalid date | blank |
String ("") | nil | blank | blank |
Date、Timeなどのto_dateを持つ値 | Date | -- | -- |
nil | nil | blank | blank |
その他(数値など) | 入力値そのまま | invalid date | 何も出ない |
このように、同じnil値でも「入力自体がblankだった」のか「入力自体は何らかあるがタイプキャストした結果nilになった」のかを区別してエラーが出せると理想的です。入力したユーザーにとっても親切そうです。
ただ、これがなかなか大変だったので、今回は妥協しました。
冒頭の結論のような実装で妥協した結果、このようになりました。
入力値(before_type_cast) | 出力値(value) | 理想的なエラー | 妥協エラー |
---|---|---|---|
String(存在する日付) | Date | -- | -- |
String(存在しない日付) | nil | invalid date | blank, invalid format |
String ("") | nil | blank | blank, invalid format |
Date、Timeなどのto_dateを持つ値 | Date | -- | - |
nil | nil | blank | blank, invalid format |
その他(数値など) | 入力値そのまま | invalid date | invalid format |
1つのカラムに1度に2行のエラーが出ます。(とても嫌です)
もちろん、こんな状態で妥協したくはないのですが、理想的にやろうとすると冒頭の理想形のような記述量になってしまい、人為的ミスが起きる予感しかしないので、妥協しました。
「何らかの入力はしたのに、入力してください とだけエラーが出る」という不親切だけは回避されていますが、「何も入力していないのに、〇〇は不正な値です とも言われる」のも新たな不親切が生まれているようにも思えます。
ただ、今までpresenceだけでは出せていなかった「その他(数値など)」が入力された場合にもきちんとエラーが出るようになりました。
今回はそれだけでもよしとします。今までは素通りだったようです。怖いですね。
また、MySQLのサポート範囲の年以外をエラーにするようなRegexにできているので、これも調査しておいてよかったなと思いました。
おわり
今回は調査メインだったため実装は妥協に妥協していますが、やはりValidatorを作るのがベターかなと思っています。
いくつかgemは試しましたが、オーバースペックだったり更新されてなかったりi18n未対応だったりで採用し難かったのと、やはりどれもblankと絡めると理想的にならなかったという印象でした。
date_validator がこの辺でそれっぽいことをしてそうなので、この辺を参考にすればささっと出来そうな気もするので、何か作ったらまた記事にしてみたいと思います。