Not Null制約とmodelのバリデーション(presence: true)の違い


マイグレーションファイルでNot Null制約をつけるのと、モデルにバリデーションを付与するのって、結局同じことやってるような気がするけど何が違うの?という疑問が湧いたので調べてみました。

結論

分かりやすい記事をQiitaで見つけたのでご紹介させていただきます。

【Rails】「テーブルのカラムに定義するNot Null制約」と「モデルに定義するバリデーション(presence: true)」の挙動の違い。

この記事によると、

ケース 結果
DB自体にNotNull制約をつける nillを拒否(rollback)
モデルへのバリデーション(presence: true) nill && 空文字(“”) を拒否(rollback)

・・・ということのようです。要するに、NotNull制約はnilは拒否するが、空文字は拒否しないということですね。

自分でもやってみた

Not Null制約

参照したサイトと全く同じ結果になりますが、一応自分でもやってみます。

migration.rb
class CreateTasks < ActiveRecord::Migration[5.2]
  def change
    create_table :tasks do |t|
      t.string :task_name, null: false
    end
  end
end

コンソールで実行してみると、予想通りの結果。NotNull制約に引っかかっります。

irb(main):002:0> Task.create(task_name: nil)
(中略)
ActiveRecord::NotNullViolation (PG::NotNullViolation: ERROR:  null value in column "task_name" violates not-null constraint)

しかし。 task_name の要素を nil ではなく空文字にしてみると。

migration.rb
irb(main):003:0> Task.create(task_name: "")
   (0.3ms)  BEGIN
  Task Create (2.4ms)  INSERT INTO "tasks" ("task_name", "created_at", "updated_at") VALUES ($1, $2, $3 RETURNING "id"  [["task_name", ""], ["created_at", "2020-12-17 03:57:38.946999"], ["updated_at", "2020-12-17 03:57:38.946999"]]
   (4.3ms)  COMMIT

普通に保存できてしまいます。マイグレーションファイルのNotNull制約はnilは拒否するが、空文字は拒否しないということが確認できました。

モデルへのバリデーション(presence: true)

今度はmodelファイルにバリデーションを付与してみます。

model.rb
class Task < ApplicationRecord
  validates :task_name, presence: true
end

irb で確認すると。

irb(main):001:0> Task.create(task_name: "")
   (0.1ms)  BEGIN
   (0.2ms)  ROLLBACK
=> #<Task id: nil, task_name: "", created_at: nil, updated_at: nil>
irb(main):002:0>

今度は Rollback されて保存できないようになっていました。

公式リファレンスで調べてみる

それならmodel ファイルにバリデーションを設定するだけでいいじゃん!と思ってしまいそうになりますが、調べてみるとそういうものでもないらしい。例えばRailsチュートリアルのこちらの記事。

以下、Railsチュートリアルからの引用になります。

しかし、依然としてここには1つの問題が残っています。それはActive Recordはデータベースのレベルでは一意性を保証していないという問題です。具体的なシナリオを使ってその問題を説明します。

アリスはサンプルアプリケーションにユーザー登録します。メールアドレスは[email protected]です。
アリスは誤って “Submit” を素早く2回クリックしてしまいます。そのためリクエストが2つ連続で送信されます。
次のようなことが順に発生します。リクエスト1は、検証にパスするユーザーをメモリー上に作成します。リクエスト2でも同じことが起きます。リクエスト1のユーザーが保存され、リクエスト2のユーザーも保存されます。

この結果、一意性の検証が行われているにもかかわらず、同じメールアドレスを持つ2つのユーザーレコードが作成されてしまいます。

上のシナリオが信じがたいもののように思えるかもしれませんが、どうか信じてください。RailsのWebサイトでは、トラフィックが多いときにこのような問題が発生する可能性があるのです (筆者もこれを理解するのに苦労しました)。幸い、解決策の実装は簡単です。実は、この問題はデータベースレベルでも一意性を強制するだけで解決します。具体的にはデータベース上のemailのカラムにインデックス (index)を追加し (コラム6.2)、そのインデックスが一意であるようにすれば解決します。

ここでの解説はメールアドレスの一意性(同じメールアドレスは2つ以上登録できない)について例を挙げて説明していますが「RailsのWebサイトでは、トラフィックが多いときにこのような問題が発生する可能性があるのです」という説明がある通り、modelファイルのバリデーションはトラフィックが多い場合にすり抜けてしまうケースがあるようです。

そうすると、万が一 Null の値が model ファイルのバリデーションをくぐり抜けても、データベース上のカラムにNot Null 制約を設定しておけば弾くことができる、ということになります。

まとめ

・データベースでのNotNull制約はnilは拒否するが、空文字は拒否しない
・modelファイルのバリデーション設定(presence: true)はnilも空文字も拒否する
・modelファイルのバリデーションは、トラフィックが多い時に不正な値がすり抜けてしまうことがあるので、とりあえずデータベースへのNotNull制約とmodelファイルへのバリデーション設定の両方をやっておくのがベスト