コーディング未経験のPO/PdMのためのRails on Dockerハンズオン vol.6 - Model validation -


はじめに

第6回目となる今回は、前回作成したUserモデルにバリデーションを付けていきます。
バリデーションとは、日本語では『検証』と訳されますが、モデルの保存条件のようなもので、例えばnamenilじゃダメ、とかemail@が含まれていないとダメ、とかそういうやつです。

前回のソースコード

前回のソースコードはこちらに格納してます。今回のだけやりたい場合はこちらからダウンロードしてください。

Validationをつけてみる

早速Validationを付けてみます。いよいよModelファイルをいじる時がきました。

app/models/user.rb
  class User < ApplicationRecord
+   validates :name,
+     presence: true,
+     length: { maximum: 50 }
+
+   VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
+   validates :email,
+     presence: true,
+     length: { maximum: 255 },
+     format: { with: VALID_EMAIL_REGEX },
+     uniqueness: { case_sensitive: false }
  end

Validationはvalidates [attribute_name], [validations]の形式で定義することができます。
一つの属性に対して複数のValidationを一気に定義していますね。
どんなValidationが定義されているのか紹介していきます!

presence

presenceは『存在性』のValidationです。presence: trueなので『存在しなければならない』ことを検証します。
ユーザー情報としてnameemailが不足しているのはおかしいですよね。
なのでnameemail両方にPresence validationを与えています。

動作を確認してみましょう。Rails consoleで確認していくのでまずはコンテナ&Rails consoleの起動から。

$ docker-compose up -d
$ docker-compose exec web ash
# rails c
> user = User.new
=> #<User:0x0000558587d7e8c8
 id: nil,
 name: nil,
 email: nil,
 created_at: nil,
 updated_at: nil>
> user.save
   (0.4ms)  BEGIN
  User Exists? (5.5ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" IS NULL LIMIT $1  [["LIMIT", 1]]
   (0.4ms)  ROLLBACK
=> false

おっと、saveメソッドでfalseが返却されていますね。前回も少しお話しましたが、saveメソッドはデータのDB保存に失敗する場合(validationで引っかかる場合)、falseを返却するようになっています。

エラーの内容はuser.errorsに格納されます。中でもuser.errors.full_messagesを見れば、ユーザー向けのエラーメッセージが格納されているのでエラー理由が一目瞭然です。

> user.errors.full_messages
=> ["Nameを入力してください", "Emailを入力してください", "Emailは不正な値です"]

『を入力してください』がPresence validationに違反した場合のエラーメッセージです。

属性の日本語化

...
validationは日本語化されていますが属性は英語になっていますね...
それもそのはず。属性の表現の仕方を定義していないのですから!

ということで初期設定の時にi18n化対応したのと同じように、localesファイルを編集して『Name』を『お名前』、『Email』を『メールアドレス』と日本語化してあげましょう。

config/locales/ja.yml
  ja:
    activerecord:
+     attributes:
+       user:
+         name: "お名前"
+         email: "メールアドレス"
      errors:
  ...

属性の名称はactiverecord.attributes.[model_name].[attribute_name]で定義します。
一度Rails consoleをリロードして、もう一度エラーを起こして確認してみましょう!

> reload!
Reloading...
=> true
> user = User.new
=> #<User:0x0000558587e99de8
 id: nil,
 name: nil,
 email: nil,
 created_at: nil,
 updated_at: nil>
> user.save
   (0.5ms)  BEGIN
  User Exists? (1.7ms)  SELECT 1 AS one FROM "users" WHERE "users"."email" IS NULL LIMIT $1  [["LIMIT", 1]]
   (0.3ms)  ROLLBACK
=> false
> user.errors.full_messages
=> ["お名前を入力してください", "メールアドレスを入力してください", "メールアドレスは不正な値です"]

属性も日本語化されたエラーメッセージに変わりました!

length

lengthは属性値の長さを検証するvalidationです。
maximumで最大文字数(最大桁数)、minimumで最小文字数(最小桁数)、inで最小と最大の範囲、isで特定の文字数(桁数)を検証します。
今回は、nameには最大50文字、emailには最大255文字のvalidationを定義しているので、エラーの確認をするために51文字のnameと256文字のemailを持つUserモデルをsaveしてみましょう。

> user = User.new
=> #<User:0x0000558586601da8
 id: nil,
 name: nil,
 email: nil,
 created_at: nil,
 updated_at: nil>
> user.name = "a" * 51
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
> user.email = "b" * 245 + "@sample.com"
=> "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb@sample.com"
> user.save
   (0.3ms)  BEGIN
  User Exists? (2.7ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb@sample.com"], ["LIMIT", 1]]
   (0.3ms)  ROLLBACK
=> false
> user.errors.full_messages
=> ["お名前は50文字以内で入力してください", "メールアドレスは255文字以内で入力してください"]

文字数について検証してくれていることが確認できました!

format

formatは正規表現とマッチするかを検証するvalidationです。
今回のケースでは、正規表現として/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/iを与えています。これはメールアドレスの正規表現として一般的なもので、簡単に言えば「【何か文字列】@【何か文字列】.【何か文字列】」を表しています。
正規表現の表現方法については今回は端折ります。例えば「Ruby 正規表現の使い方 - Qiita」などを参考に勉強してみてください。常に知っておく必要はあんまりないと思いますが、必要になったときに調べながらでも書けるようになっているのが望ましいでしょう。

では、早速このフォーマットバリデーションが正しく動作するかを確認します。例えば、「@」を抜いた「taro.com」なんていかがでしょうか?絶対メールアドレスじゃないのでちゃんとエラーになってほしいですよね。

> user = User.new(name: "taro", email: "taro.com")
=> #<User:0x00005585870da1f8
 id: nil,
 name: "taro",
 email: "taro.com",
 created_at: nil,
 updated_at: nil>
> user.save
   (0.8ms)  BEGIN
  User Exists? (2.6ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "taro.com"], ["LIMIT", 1]]
   (0.8ms)  ROLLBACK
=> false
> user.errors.full_messages
=> ["メールアドレスは不正な値です"]

メールアドレスのフォーマットエラーになりました。

uniqueness

uniquenessは一意性を検証するvalidationです。
case_sensitivefalseに設定すると大文字小文字の区別をしないで一意性を検証するようになります。emailで「[email protected]」と「[email protected]」は同じメールアドレスですのでこのオプションを付与してます。

では検証してみましょう。

> User.create(name: "taro", email: "[email protected]")
   (0.7ms)  BEGIN
  User Exists? (2.8ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "[email protected]"], ["LIMIT", 1]]
  User Create (4.9ms)  INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "taro"], ["email", "[email protected]"], ["created_at", "2020-03-09 13:52:38.337106"], ["updated_at", "2020-03-09 13:52:38.337106"]]
   (2.1ms)  COMMIT
=> #<User:0x000055858729ad58
 id: 1,
 name: "taro",
 email: "[email protected]",
 created_at: Mon, 09 Mar 2020 13:52:38 JST +09:00,
 updated_at: Mon, 09 Mar 2020 13:52:38 JST +09:00>
> user = User.create(name: "taro", email: "[email protected]")
   (0.5ms)  BEGIN
  User Exists? (1.9ms)  SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "[email protected]"], ["LIMIT", 1]]
   (0.4ms)  ROLLBACK
=> #<User:0x00005585873922d8
 id: nil,
 name: "taro",
 email: "[email protected]",
 created_at: nil,
 updated_at: nil>

idがふられていなかったり、created_atupdated_atnilであることからモデルオブジェクトの作成には成功していますがDB保存はできていないことがわかります。

> user.errors.full_messages
=> ["メールアドレスはすでに存在します"]

emailの一意性チェックでエラーになったことがわかります。また、大文字小文字を区別せずに一意性チェックをしてくれていることもわかりました。

DBの制約

ここまでモデル側、つまりアプリ側にvalidationをかけてきました。
このような制約はDB側でもかけることができますし、その方が安全だ!という考え方もあります。

Railsでは、デザインパターンとしてActiveRecordを採用しています。ActiveRecordでは「制約をかけるのはモデルの仕事」とされているため、上のようにモデル側で制約をかけています。
こうすることで、制約の内容が変わったとしてもDB側の設定を変えることなくアプリ側だけの改修で柔軟に対応をすることができます。

一方で一意性に関しては、複数のアプリが並列で処理を行う構成を考えるとDB側で制御されている方がよいとされています。

今回は一意性(uniqueness)に関して、DB側にも制約をかけます。

まず、マイグレーションファイルを生成します。

> quit
# rails g migration add_index_to_user
Running via Spring preloader in process 194
      invoke  active_record
      create    db/migrate/YYYYMMDDhhmmss_add_index_to_user.rb

これでほとんど空のマイグレーションファイルが生成されますので、中身を書いていきます。

db/migrate/YYYYMMDDhhmmss_add_index_to_user.rb
  class AddIndexToUser < ActiveRecord::Migration[6.0]
    def change
+     add_index :users, :email, unique: true
    end
  end

uniquenessの制約はadd_index [table (model)], [column (attribute)], unique: trueで設定します。
ではマイグレーションファイルを適用していきましょー。

# rails db:migrate
== 20200130053807 AddIndexToUser: migrating ===================================
-- add_index(:users, :email, {:unique=>true})
   -> 0.0942s
== 20200130053807 AddIndexToUser: migrated (0.0947s) ==========================

DBの一意性チェックは利用するDBによりますが大文字小文字を区別してしまう可能性があります。そのため、モデル側でDBに保存する前に強制的にemailを小文字化する処理を入れます。

app/models/user.rb
  class User < ApplicationRecord
+   before_save { self.email = email.downcase }

    validates :name,
      presence: true,
      length: { maximum: 50 }

    VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
    validates :email,
      presence: true,
      length: { maximum: 255 },
      format: { with: VALID_EMAIL_REGEX },
      uniqueness: { case_sensitive: false }
  end

user.saveをするときに、RailsではCallbacksといいますがシーケンシャルな処理が存在します。まずvalidationが実行されsaveが実行されcommitが実行されるといった流れです。
before_saveはその名前から分かるとおり、validationが通った後、saveが始まる前に処理を挟み込むことを意味しています。before_saveの後の{}の中身が処理になりますが、email.downscaleで現在のemailを小文字化して再度self.email、つまり自分の属性値に代入しています。

さて、ここまでやるとDBでも一意制約を入れることができている状態になります。

一度モデル側のuniqueness validationを外しておき、DBだけで一意制約を担保できるか確認してみましょう。

app/models/user.rb
  class User < ApplicationRecord
    before_save { self.email = email.downcase }

    validates :name,
      presence: true,
      length: { maximum: 50 }

    VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
    validates :email,
      presence: true,
      length: { maximum: 255 },
-     format: { with: VALID_EMAIL_REGEX },
-     uniqueness: { case_sensitive: false }
+     format: { with: VALID_EMAIL_REGEX }
end

format: { with: VALID_EMAIL_REGEX }が削除で追加になってるよ?と思うかもしれませんが、一番後ろの,を消さないと定義が終わっていないものと見做されてエラーになるので、こんな感じの表現に...(わかりにくくてすみません!,消して欲しいだけです!)
またコンソールから確認してみます。

# rails c
> user = User.create(name: "taro", email: "[email protected]")
  (0.3ms)  BEGIN
  User Create (8.2ms)  INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["name", "taro"], ["email", "[email protected]"], ["created_at", "2020-03-09 14:00:36.861653"], ["updated_at", "2020-03-09 14:00:36.861653"]]
   (0.2ms)  ROLLBACK
ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_users_on_email"
DETAIL:  Key (email)=([email protected]) already exists.
from /usr/local/bundle/gems/activerecord-6.0.2.1/lib/active_record/connection_adapters/postgresql_adapter.rb:672:in `exec_params'
Caused by PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_users_on_email"
DETAIL:  Key (email)=([email protected]) already exists.

from /usr/local/bundle/gems/activerecord-6.0.2.1/lib/active_record/connection_adapters/postgresql_adapter.rb:672:in `exec_params'

先ほどのuniquenessのときとは違う挙動をとっていることがわかります。
さらに、DETAILというところでメールアドレスの重複をDBが検知していることがわかりますね。

さて、DB側の制約でも一意性を担保できることを確認できたので、モデルのコメントアウトを元に戻しておきます。

app/models/user.rb
  class User < ApplicationRecord
    before_save { self.email = email.downcase }

    validates :name,
      presence: true,
      length: { maximum: 50 }

    VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
    validates :email,
      presence: true,
      length: { maximum: 255 },
-     format: { with: VALID_EMAIL_REGEX }
+     format: { with: VALID_EMAIL_REGEX },
+     uniqueness: { case_sensitive: false }
end

後片付け

じゃあ、またデータ消しておきましょう。

> quit
# exit
$ docker-compose down
$ docker-compose run --rm web rails db:migrate:reset
$ docker-compose down

まとめ

今回は、Modelにvalidationを付与してみました。

ふんふん。ちゃんとvalidationをつけれましたね。
validationは他にもいろいろあります。
Active Record バリデーション - Railsガイド
自分のアプリケーションに合致するバリデーションを見つけましょう!

次回は、Userモデルにセキュアなパスワードを付与して行こうと思います。
パスワードを平文で持つのはやっぱりNG。Railsは簡単にセキュアなパスワードを実装できるんです!

では、次回も乞うご期待!ここまでお読みいただきありがとうございました!

Next: コーディング未経験のPO/PdMのためのRails on Dockerハンズオン vol.7 - Secure password - - Qiita

本日のソースコード

Reference

Other Hands-on Links