Rails 6.0で"Uniqueness validator will no longer enforce case sensitive comparison in Rails 6.1."という警告が出たときの対処法

## はじめに

MySQLを使っている既存のRailsアプリケーションをRails 6.0にアップデートすると、次のような警告が出ることがあります。

```
DEPRECATION WARNING: Uniqueness validator will no longer enforce case sensitive comparison in Rails 6.1. To continue case sensitive comparison on the :name attribute in User model, pass `case_sensitive: true` option explicitly to the uniqueness validator.
```

> （翻訳）
> 非推奨の警告: UniquenessバリデータはRails 6.1で「強制的に大文字小文字を区別する比較」をしなくなります。Userモデルの`:name`属性について引き続き「大文字小文字を区別する比較」を使い続けたい場合は、uniquenessバリデータに対して明示的に`case_sensitive: true`オプションを指定してください。

警告が出るのは次のようにuniquenessバリデータを使っている部分です。

```ruby
class User < ApplicationRecord
  validates :name, uniqueness: true
end
```

とりあえず、こんなふうに`case_sensitive`オプションを付けると警告は出なくなります。

```ruby
class User < ApplicationRecord
  # こうすれば警告は出なくなる、が！！！
  validates :name, uniqueness: { case_sensitive: true }
end
```

しかし、深く考えずにオプションを付けるのはあまりよくありません。
というわけで、この記事ではこの警告に対する対処方法を詳しく説明していきます。

## Rails 5.2以前の仕様（と問題）

前提としてこの問題はMySQLを使っている場合に発生します。PostgreSQLを使っている場合は通常問題になりません。

詳しい話は省略しますが、MySQLにはcollationという概念があります。
デフォルトでは`utf8mb4_unicode_ci`というようなcollationになっており、この場合はデータベースに保存された文字列の大文字小文字を区別しません。

つまり、"jnchito"という名前を検索するのに、`WHERE name = 'jnchito'`というSQLを発行しても、`WHERE name = 'JNCHITO'`というSQLを発行してもどちらもヒットします。

しかし、Rails 5.2以前のuniquenessバリデータはデフォルトで親切にも大文字小文字を区別する比較をしてくれます。

なので、DBに"jnchito"がすでに保存されている場合は、次のように振る舞います。

```ruby
# 小文字のjnchitoはすでに登録済みなのでNG
user.name = 'jnchito'
user.valid? #=> false

# 大文字のjnchitoはすでに未登録なのでOK
user.name = 'JNCHITO'
user.valid? #=> true

# 背後では以下のようなSQLが発行されている（BINARYが付く）
# SELECT 1 AS one FROM `users` WHERE `users`.`name` = BINARY 'JNCHITO' LIMIT 1
```

一見これはありがたい仕様のように見えますが、次のような思わぬデメリットがあります。

- DB上のユニーク制約に一致しないため、バリデーションの結果が100%信用できない
- DB上のINDEXが効率良く使えないため、DBの負荷が大きくなる

実際、先ほど挙げたコードは以下のような矛盾した振る舞いをします。
（DB側にユニーク制約が付けられていた場合）

```ruby
# 大文字の"JNCHITO"なら検証エラーなしだから保存できそうだ
user.name = 'JNCHITO'
user.valid? #=> true

# 保存実行・・・あれっ、DBのユニーク制約違反に引っかかって例外が発生しちゃった！！
user.save
#=> ActiveRecord::RecordNotUnique:
#     Mysql2::Error: Duplicate entry 'JNCHITO' for key 'users.index_users_on_name'
```

RailsでMySQLを使っているとこのような問題がたびたび発生していたようです。
（僕は普段PostgreSQLを使っているので気づいていませんでしたが）

## （Rails 6.0ではなく）Rails 6.1で導入される仕様

この問題を回避するため、Rails 6.1のuniquenessバリデータはデフォルトで大文字小文字を区別しなくなります。
というか、厳密には「Rails側では素直にSQLを発行して、大文字小文字の区別はDB側の設定に任せる」という仕様になります。

これにより、DB側の機能をフル活用できるようになるため、上で挙げていた、

- DB上のユニーク制約に一致しないため、バリデーションの結果が100%信用できない
- DB上のINDEXが効率良く使えないため、DBの負荷が大きくなる

といった問題が発生しなくなります。

たとえば、DBに"jnchito"がすでに保存されている場合、Rails 6.1ではおそらく次のような振る舞いになるはずです。

```ruby
# jnchitoはすでに登録済みなのでNG（大文字小文字を区別しない）
user.name = 'jnchito'
user.valid? #=> false

# JNCHITOはすでに登録済みなのでNG（大文字小文字を区別しない）
user.name = 'JNCHITO'
user.valid? #=> false

# 背後では以下のようなSQLが発行されるはず（BINARYが付かない）
# SELECT 1 AS one FROM `users` WHERE `users`.`name` = 'JNCHITO' LIMIT 1
```

## Rails 6.0は6.1の仕様変更に向けて、開発者にコードやDB設定の見直しを促す

しかし、Rails 6.1の仕様変更は「思わぬデメリット」を避けられるのと引き換えに、「大文字小文字の区別をしなくなる」という振る舞いの変化を招いてしまいます。

そこで、Rails 6.0ではRails 5.2以前の振る舞いを保ちつつ、「Rails 6.1は振る舞いが変わるよ！今のうちにどうしたいか決めて！」と、開発者に変更を促します。それが冒頭に紹介した警告です。

### 大文字小文字を区別する場合

Rails 5.2時代と同様に大文字小文字を区別したい場合は、明示的に`case_sensitive: true`のオプションを付ければ警告は消えます。

ただし、DB側のcollationに変更がなければ、

- DB上のユニーク制約に一致しないため、バリデーションの結果が100%信用できない
- DB上のINDEXが効率良く使えないため、DBの負荷が大きくなる

という問題を抱えたままになってしまいます。

```ruby
class User < ApplicationRecord
  # 警告は出なくなるが、DB側のcollationを変えなければ「思わぬデメリット」は残ったまま
  validates :name, uniqueness: { case_sensitive: true }
end
```

こうした問題を解消したい場合は、Rails側のコードを修正するのではなく、DB側のcollationを`utf8mb4_bin`のような「大文字小文字を区別するcollation」に変更する必要があります。（collationを変更する手順はここでは割愛します）

DB側のcollationが大文字小文字を区別するようになっていれば、Railsのuniquenessバリデータの振る舞いとミスマッチがなくなるので警告は出なくなります。（`case_sensitive`オプションを指定する必要はありません）

```ruby
class User < ApplicationRecord
  # DB側のcollationを変えればcase_sensitiveオプションは不要。警告も「思わぬデメリット」も発生しない
  validates :name, uniqueness: true
end
```

### 大文字小文字を区別しない場合

大文字小文字を区別しなくていい場合は明示的に`case_sensitive: false`を指定します。
こうすればDB側のcollationもRailsのuniquenessバリデータも大文字小文字を区別しなくなるので、ミスマッチが解消され、警告も表示されなくなります。

ただし、この場合はアプリケーションの挙動が変わってしまうので、ユーザーに混乱を招いたりしないか、よく検討する必要があります。

```ruby
class User < ApplicationRecord
  # 警告は出なくなる。「思わぬデメリット」もなくなる。が、Rails 5.2と挙動が変わる
  validates :name, uniqueness: { case_sensitive: false }
end
```

また、Rails 6.1にアプリケーションをアップグレードしたあとは`case_sensitive: false`のオプションを外しても問題ありません。（デフォルトで大文字小文字を区別しなくなるため）

```ruby
class User < ApplicationRecord
  # Rails 6.1ではcase_sensitiveをなくしてしまってもOK
  validates :name, uniqueness: true
end
```

## 参考：Rails 5.2〜6.1の振る舞いまとめ

この話は「MySQL側のcollation」と「uniquenessバリデータの`case_sensitive`オプション」と「Railsのバージョン」の組み合わせによって話がいろいろと変わってきます。

それぞれの組み合わせで何が起きるか、以下の表にまとめておきます。

<img width="1185" alt="Screen Shot 2020-05-28 at 11.14.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/8ca2198a-ace9-1fa3-4c7a-ce1163b6d32e.png">

最終的には上の表の「DB側とRailsのミスマッチ？」欄が"NO"になる組み合わせが実現できれば理想的な状態、となります。

## 参考文献

- [Rails 6\.0でDeprecatedになるActive Recordの振る舞い3つ \- かみぽわーる](https://blog.kamipo.net/entry/2019/05/15/152652)
- [Deprecate mismatched collation comparison for uniquness validator by kamipo · Pull Request \#35350 · rails/rails](https://github.com/rails/rails/pull/35350)
- [本当にあったRailsの怖い話](https://www.slideshare.net/ssuserf3788f/rails-83053313/12)

## 謝辞

この件についてはRailsコミッタのkamipoさんにTwitter上で質問して丁寧に回答していただきました（[参考](https://twitter.com/jnchito/status/1249637368614637569)）。kamipoさん、どうもありがとうございました！