LoginSignup
2
1
この記事誰得? 私しか得しないニッチな技術で記事投稿!

Railsの日付型をバリデーションするためにいろいろ調べた話

Last updated at Posted at 2023-06-28

背景

あるとき、こんなエラーに遭遇しました。

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はpresenceformat_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型が登場します。

まずはこれらについて挙動や仕様を確認していこうと思います。

タイプキャストの挙動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は知られざる多機能性があるようで、これを知らずに気軽に使ってしまうと意図しない変換が起こることになり、とてもやっかいなものになります。

知られざる機能はこちらの記事にまとまっていましたので、よければご覧ください。
(感謝感謝 :pray:

この記事にあるように、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 :x: 何も出ない

このように、同じ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 がこの辺でそれっぽいことをしてそうなので、この辺を参考にすればささっと出来そうな気もするので、何か作ったらまた記事にしてみたいと思います。

2
1
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
2
1