pockestrap

Programmer's memo

Rails/UniqueValidationWithoutIndex copを追加した

rubocop-railsRails/UniqueValidationWithoutIndex copを追加しました。 rubocop-railsの次期リリース(おそらくv2.5.0)から利用できます。

github.com

これはなに

Rails/UniqueValidationWithoutIndex copは、RDBMSのunique indexがついていないカラムに対して、Active Recordのレイヤーでuniquenessバリデーションを書いている場合に警告をします。

たとえば次のバリデーション定義がある時を考えます。

class User < ApplicationRecord
  validates :account, uniqueness: true
end

この時、このCopは次のスキーマ定義によって警告を出したり出さなかったりします。

# account カラムに対してunique indexがついているので警告は出ない
create_table :users do |t|
  t.string "account", null: false

  t.index ["account"], name: 'idx', unique: true
end

# account カラムに対してindexはついているが、unique制約がないので警告が出る
create_table :users do |t|
  t.string "account", null: false

  t.index ["account"], name: 'idx'
end

# account カラムに対してindexがついていないので、警告が出る
create_table :users do |t|
  t.string "account", null: false
end

なぜindexが必要なのか

では、なぜunique indexが必要なのでしょうか。 これには2つの理由があります。

確実にユニークにするため

1つ目の理由は、確実にレコードをユニークにするためです。 Active Recordのバリデーションだけではレコードはユニークになりません。

Active Recordのバリデーションは、次のようなコードで表せます。

if User.exists?(account: user.account)
  raise "already exists"
else
  user.save!
end

User.exists?SELECT文を発行し存在チェックをした後、存在しなければuser.save!でレコードを作成しています。

これは一見うまく動きそうに見えますが、User.exists?でチェックした後、user.save!でレコードを作成するまでの間に他のスレッド/プロセスが同じaccountの値でユーザーを作成すると、レコードが重複してしまいます。 たとえばユーザー作成ボタンを連打するとそのような自体が容易に起こり得ます。

この問題はRDBMSでunique indexをつけることで回避できます。 このことはRails Guideにも書かれています。

このヘルパーは一意性の制約をデータベース自体には作成しないので、本来一意にすべきカラムに、たまたま2つのデータベース接続によって同じ値を持つレコードが2つ作成される可能性が残ります。これを避けるには、データベースの両方のカラムに一意インデックスを作成する必要があります。

https://railsguides.jp/active_record_validations.html#uniqueness

パフォーマンス上の問題

2つ目はパフォーマンス上の問題です。 indexがない状態でActive Recordのuniquenessバリデーションを使用するとパフォーマンスが悪化する恐れがあります。

先程のコードをもう一度見てみましょう。

if User.exists?(account: user.account)
  raise "already exists"
else
  user.save!
end

user.save!をする前に必ずUser.exists?を呼んでいます。 つまり、ユーザーを保存する前に必ずSELECT文が走っています。 そしてそのSELECT文の条件にはaccountカラムが使われています。

もしaccountカラムにindexがない場合、ユーザーを保存する度にindexがないカラムを検索することになります。 このテーブルが大きく育ったり、更新頻度が高かったりすると危険です。

副産物: db/schema.rbをパースする

このCopを実装するにあたっては、RDBMSのテーブルの情報が必要です。 今回はdb/schema.rbをパースして、RuboCopからRDBMSのテーブルの情報を使えるようにしました。

RuboCopでは解析対象のコードは実行しません。 そのためRailsアプリケーション内のコードを実行せずに、db/schema.rbをParser gemでパースしてテーブルの情報を得ています。 幸いdb/schema.rbは単純なDSLであり、機械的に生成されたファイルなので静的に解析することが容易です。 200行程度のコードで、db/schema.rbからテーブル定義を表すRubyのクラスを生成できました。

このコードは次の2つのファイルで実装されています。 単純なことしかしていないので、興味があればぜひ見てみてください。

db/schema.rbの解析は富山Ruby会議01でのkoicさんの発表を思い出しながら実装していました。1

この情報を使えば、より強力な解析をrubocop-railsに実装できるでしょう。 良いアイディアがあれば、rubocop-railsGitHubリポジトリのIssueなどで教えてください。

最後に

rubocop-railsに新しく実装したCopの紹介と、そのために実装したdb/schema.rbのパーサの紹介をしました。 便利だと思うし新しいCopを作る良い基盤にもなると思います。褒めてください。


  1. これ発表のネタバレじゃん