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

https://qiita.com/official-events/5d4f04cf2ba0cdbc8821

# 背景

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

```
Mysql2::Error: Invalid date in field 'publish_date': 2020-10-00
```

「`publish_date` という（MySQLの日付型の）カラムに `2020-10-00` という日にちを保存できなかった。」ということらしいです。
確かにカレンダー的には `00日`なんて日にちは存在しないのでエラーになって当然だろうとは思いますが、詳しく仕組みが知りたかったので調査し始めました。

結果的には、これ自体はMySQLの [`NO_ZERO_IN_DATE`](https://dev.mysql.com/doc/refman/8.0/ja/sql-mode.html#:~:text=%E3%81%95%E3%82%8C%E3%81%BE%E3%81%99%E3%80%82-,NO_ZERO_IN_DATE,-NO_ZERO_IN_DATE%20%E3%83%A2%E3%83%BC%E3%83%89%E3%81%AF) という `sql_mode` の設定によって保存出来たり出来なかったりするとのことで、そういうものなのかと納得しました。

ただ、MySQLに保存する瞬間までRailsの中で `2020-10-00` という存在しない日付を持ち続けてるのもどうなのと思い、ちゃんとバリデーションするための調査を始めました。

# 先に結論

## 暫定

理想的な結果ではないですが、暫定ではこのような形に落ち着きました。

```ruby
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`も通過しないため、エラーが２行出る
  - ※「入力自体がnil」「空白文字列」も同様
- その他のキャストできない値が入力された場合もinvalidとなる
  - （※to_sが奇跡的に正規表現に一致しない限り）


他に簡便な手段がないので文字列表現を経由して`format_with`を使用していますが、実質`is_a?(:Date)`や`respond_to?(:to_date)`をチェックしているのと変わりません。
gemを探せば型だけをチェックするValidatorもありますが、それだけでも解決しない諸問題があったため、今回の大調査となりました。

## 理想

ちなみに、
Validatorを自作しない範囲で理想を追求すると下のような感じも考えられますが、Date型が登場するたびにこの記述量では割に合わないので、こちらはひとまず見送りました。

```ruby
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
```

# 検証環境

```console
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型について調査

本記事では、以下３種類のDate型が登場します。

- Rubyの日付クラス
  - `::Date`
  - [ActiveSupportのコア拡張機能](https://api.rubyonrails.org/classes/Date.html)
- ActiveModelのデータ型
  - `ActiveModel::Type::Date`
- MySQLのデータ型

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

## タイプキャストの挙動１（ActiveModel::Type::Date#cast_value）

https://github.com/rails/rails/blob/v6.0.3.7/activemodel/lib/active_model/type/date.rb#L18-L27

```ruby
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の取り得る値は、以下３種類（Date,nil,その他）ということが分かります。

| 入力値（before_type_cast） | 出力値（value） |
| -- | -- |
| String（存在する日付） | Date型 |
| String（存在しない日付） | nil |
| String（""） | nil |
| Date,Timeなど、:to_dateを持つ値 | Date型 |
| nil | nil |
| その他（数値など） | 入力値そのまま |

一番下にやっかいなのが出てきていますね。
そうです、ActiveModelのDate型だからタイプキャストを通過すればDate型かnilになっているだろうと思っていましたが、そうではないんですね。タイプキャストできない値を入力するとattributeの中では入力値がそのまま保持されてしまうんですね。

数値を入れると数値のまま保持され続けます。
これが後から効いてくることになります。

## タイプキャストの挙動２ （ActiveModel::Type::Date#fallback_string_to_date）

`fallback_string_to_date` の中身も見てみましょう。

https://github.com/rails/rails/blob/v6.0.3.7/activemodel/lib/active_model/type/date.rb#L29-L44

```ruby
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`

https://docs.ruby-lang.org/en/3.2/Date.html#method-c-_parse

```ruby
pry(main)> Date._parse('2001-02-03')
 {:year=>2001, :mon=>2, :mday=>3}
```

`Date._parse` ではこのように、文字列から年月日の連想配列を生成するようです。

一方、同じ文字列で `Date.parse` を行うと、

```ruby
pry(main)> Date.parse('2001-02-03')
Sat, 03 Feb 2001
pry(main)> Date.parse('2001-02-03').class
Date < Object
```

このようにDateのインスタンスが返ります。

また、存在しない日付であるかどうかのチェックも行われないため、以下のようにしても例外にはなりません。

```ruby
> Date._parse('2023-02-31')
{:year => 2023, :mon => 2, :mday => 31}
```

`Date.parse`では存在しない日付は即例外となるため、この点でも挙動が異なっています。

```ruby
> Date.parse('2023-02-31')
Date::Error: invalid date
```

## `Date.parse`

`Date.parse` は意外にやっかいです。
どの言語にもよくある日付パーサーなのですが、Rubyの`Date.parse`は知られざる多機能性があるようで、これを知らずに気軽に使ってしまうと意図しない変換が起こることになり、とてもやっかいなものになります。

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

https://qiita.com/Nabetani/items/2613c4dea86a4db614d8

この記事にあるように、`Date.parse`は省略記法が充実しています。
そのため、ユーザーの入力ミスが意図しない日付に変換されてしまう危険性があります。

```ruby
> params[:birthday] = '2000023' # <- ユーザーは'2000年2月3日'のつもりで入力したが、入力ミスしている
> birthday = Date.parse(params[:birthday])
Sun, 23 Jan 2000 # <-「2000年の23日目」と解釈され '2000年1月23日' になってしまう
```

この多機能性が活躍するシーンは多々あるとは思うので、そういう場面ではもちろん便利に利用できると思います。
ただ、ユーザー入力を直接入れるような使い方はしない方が良さそうですね。

## タイプキャストの挙動３

これで `fallback_string_to_date` の奇妙な実装の謎も解けてきました。
つまりRails（ActiveModel）では、`Date.parse` のこの（ありがた迷惑な）多機能性を全て捨てて、直感的に日付と分かる文字列（`2000-02-03`とか`2000/2/3`とか）しかDate型にキャストしないようにしてくれているようですね。
ActiveModel側ではユーザーの入力を直接扱うこともありますので、このようになっていると安心ですね。

## MySQLのDate型

更に、MySQLのDate型も絡んできて問題が複雑化します。

先程、タイプキャストで「その他（数値など）」が入力されると入力値がそのままattributeに保持されるとありましたが、この状態でModelをsaveするとどうなるでしょうか？

```ruby
> b = Book.new(publish_date: 20210203) # <- 数値型で入力
> b.save
```

Date型のカラムに数値を入れたら型違いでSQLエラーになりそうな気がしますが、上の値の場合はそうなりません。
この場合の「20210203」という「数値」はMySQL側で暗黙的に型変換され、正常にDate型のカラムに保存されます。これはたまたま「日付として解釈可能な数値の並び」だったから ということになりそうです。

存在しない日付の場合はこのようにSQLエラーになります。

```ruby
> 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`

「存在しない日付」の中でも、以下２種類はMySQLでは特別なようです。

- `0000-00-00` （年月日全て０）
- `yyyy-00-00` `yyyy-mm-00` `yyyy-00-dd` （月日両方かどちらか一方が０）

これらの値は、MySQLの`sql_mode`の設定によって保存できるかどうかを制御できるようです。

ただし、保存できたとしてもRails（ActiveModel）では再度読み込むことが出来ません。
保存できる設定にして検証してみると以下のようになります。

```ruby
> 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にはサポートされている日付の範囲というものがあるそうです。

https://dev.mysql.com/doc/refman/8.0/ja/datetime.html#:~:text=%E6%84%8F%E5%91%B3%E3%81%97%E3%81%BE%E3%81%99%E3%80%82-,DATE,-%E5%9E%8B%E3%81%AF%E3%80%81%E6%97%A5%E4%BB%98

> DATE 型は、日付部分を含むが時間部分は含まない値に使用されます。 MySQL は、DATE 値を'YYYY-MM-DD'形式で取得して表示します。
> サポートしている範囲は '1000-01-01' から '9999-12-31' です。

この「サポートしている範囲」も微妙な表現で、この範囲外の日付がDate型カラムに保存できないわけではないようです。実際に保存はきちんとできます。
「日付の足し算引き算、比較などの動作を保証してない」程度の意味らしいです。（未検証）

ちなみに、Rubyの`Date`はどうかというと、MySQL程の制限はなさそうです。（上限は未確認）
また、Timestampとも関係なく日付を扱えるので、「1900年1月1日以降」といった制限もないようです。

```ruby
> 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-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,<br>invalid format |
| String （""） | nil | blank | blank,<br>invalid format |
| Date、Timeなどのto_dateを持つ値 | Date | -- | - |
| nil | nil | blank | blank,<br>invalid format |
| その他（数値など） | 入力値そのまま | invalid date | invalid format |

１つのカラムに１度に２行のエラーが出ます。（とても嫌です）
もちろん、こんな状態で妥協したくはないのですが、理想的にやろうとすると冒頭の理想形のような記述量になってしまい、人為的ミスが起きる予感しかしないので、妥協しました。

「何らかの入力はしたのに、入力してください とだけエラーが出る」という不親切だけは回避されていますが、「何も入力していないのに、〇〇は不正な値です とも言われる」のも新たな不親切が生まれているようにも思えます。

ただ、今までpresenceだけでは出せていなかった「その他（数値など）」が入力された場合にもきちんとエラーが出るようになりました。
今回はそれだけでもよしとします。今までは素通りだったようです。怖いですね。

また、MySQLのサポート範囲の年以外をエラーにするようなRegexにできているので、これも調査しておいてよかったなと思いました。

# おわり

今回は調査メインだったため実装は妥協に妥協していますが、やはりValidatorを作るのがベターかなと思っています。
いくつかgemは試しましたが、オーバースペックだったり更新されてなかったりi18n未対応だったりで採用し難かったのと、やはりどれもblankと絡めると理想的にならなかったという印象でした。

date_validator がこの辺でそれっぽいことをしてそうなので、この辺を参考にすればささっと出来そうな気もするので、何か作ったらまた記事にしてみたいと思います。

https://github.com/codegram/date_validator/blob/master/lib/active_model/validations/date_validator.rb#L61-L64