Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Railsの技: バリデーションにDBレベルのCHECK制約を使う(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

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の技: to_sqlでActive Recordが生成するクエリを調べる(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。