Railsの技: バリデーションにDBレベルのCHECK制約を使う(翻訳)
Active Recordモデルのバリデーションの背後にデータベースレベルの制約を配置する手法は、Railsの定番の技のひとつです。
バリデーションはスキップされてしまうこともあるので、データの整合性を維持するための最後の防衛線はデータベースに任せるのがベストです。たとえばモデルにvalidates :name, presence: true
と書くのはもちろん可能ですが、データベースにnull値が何かのはずみで紛れ込んでしまうと、アプリでnil
に対してname.downcase
を呼び出したときに例外が発生してしまいます。
最もよく使われる例は、「presence
バリデーションと非nullカラムを組み合わせる」や「unique
バリデーションとデータベースのuniqueインデックスを組み合わせる」方法です。
しかし「CHECK制約」と呼ばれるデータベースの機能を使うと、さらに一歩進んだバリデーションを行えることをご存知でしょうか。
使い方
CHECK制約は、データをカラムに保存しようとしたときに実行されます。データがこの制約に違反するとエラーが発生し、Railsはトランザクションをロールバックします。
私たちは、Arrowsというサービスで顧客のカスタムプラン作成を支援しています。プランはテンプレートを元に作成されます。最近私たちは、プラン作成日に一定の日数を加えた日を締め切り日として設定する機能を追加しました。
締め切りのオフセットはinteger
型でデータベースに保存しますが、この値はアプリケーションのコンテキストで決して負の値になってはいけません。テンプレートからプランを作成する場合、以下のようにコードを書きたくなります。
deadline = Date.current + @template.deadline_offset
しかしデータベースのinteger
型は負の値になる可能性があるため、オフセットが正の値になる保証がありませんでした。当然ながら、プラン作成日よりも前に締め切り日を設定したくありません。
これは以下のようにモデルレベルでバリデーションできますが、データベースレベルでもチェックを強制したいと思います。
class Template < ApplicationRecord
validates :deadline_offset, numericality: {
only_integer: true,
greater_than_or_equal_to: 0
}
end
Rails 6.1から、以下のような特定のCHECK制約をマイグレーションで直接指定できるようになりました。
class AddDeadlineOffsetCheckToTemplates < ActiveRecord::Migration[7.0]
def change
add_check_constraint :templates, "deadline_offset >= 0",
name: "deadline_offset_non_negative"
end
end
制約のname
とチェックのために実行するSQL条件を指定します。
これで、万一Active Rcordモデルのバリデーションを書き忘れたりコールバックがスキップされたりしても、データベースレベルで強制チェックされます。
# Live dangerously and skip validations!
=> template.deadline_offset = -12
=> template.save(validate: false)
PG::CheckViolation: ERROR: new row for relation "templates" violates check constraint "deadline_offset_non_negative" (ActiveRecord::StatementInvalid)
その他よく使われる例
CHECK制約を使いたい場所として他にも以下の例が考えられます。
- 安全装置として最低価格を保証する
price > 100
をチェックして、最低価格を下回る製品を誤って追加しないようにする
- 固定フォーマットのバリデーション
- 米国郵便番号を保存するときに
char_length(zipcode) = 5
をチェックする
- 米国郵便番号を保存するときに
- 2つのカラムの関係を正しく保つ
start_date < end_date
制約を追加するsale_price <= price
制約を追加する
CHECK制約は常に追加すべきでしょうか?私の場合、データの完全性に関する保護を追加されて不満に思ったことは一度もありません。ミッションクリティカルなデータが要件に含まれている場合は、CHECK制約を追加することを強くおすすめします。
どんな細かな点にも1つ残らず厳密に制約を加えるべきでしょうか?普通はそこまですることもないでしょう。しかし、アプリケーションのすべてのバリデーションに制約を追加するコストと、後でデータを修正する苦しみを秤にかけて事前に検討しておくとよいでしょう。
参考
- Rails API: add_check_constraint
- Thoughtbotブログ: Validation, Database Constraint, or Both?
概要
原著者の許諾を得て翻訳・公開いたします。