1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Userモデルのバリデーションを解説【Ruby on Rails】

Posted at

どんな値も取れるname属性・email属性は問題!

例えばnameは空であってはならず、emailはメールアドレスのフォーマットに従う必要がある。さらに、メールアドレスをユーザーがログインするときの一意のユーザー名として使おうとしているため、メールアドレスがデータベース内で重複することのないようにする必要もある。

属性値には、何らかの制約を与える必要がある。Active Recordでは検証(Validation) という機能を通して、こういった制約を課すことができるようになっている。

よく使われるのは

  • 存在(preference)の検証
  • 長さ(length)の検証
  • フォーマット(format)の検証
  • 一意性(uniqueness)の検証
  • 確認(confirmation)の検証

検証はテスト駆動開発にぴったりの機能!

setupメソッド内の処理は、各テストが走る直前に実行される。@userはインスタンス変数だが、setupメソッド内で宣言しておけば、全てのテスト内でこのインスタンス変数が使えるようになる。
したがって、valid?メソッドを使ってUserオブジェクトの有効性をテストすることができる。

test/models/user_test.rb
require 'test_helper'
class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com")
  end

  test "should be valid" do
    assert @user.valid?
  end
end

存在を検証する

これは単に、「渡された属性が存在すること」を検証する。
まず、テストを追加する。@user変数のname属性に対して空白の文字列をセットする。そして、assert_notメソッドを使ってUserオブジェクトが有効でなくなったことを確認する

test/models/user_test.rb
require 'test_helper'
class UserTest < ActiveSupport::TestCase
.
.
.
  test "email should be present" do
    @user.name = "  "
    assert_not @user.valid?
  end
end

この状態では、テストはRED(エラー)になる。
name属性の存在を検証するには、validateメソッドにpresence: trueという引数を与えて使う。

app/models/user.rb
class User < ApplicationRecord
  validates :name, presence: true
end

上記を設定した状態で、Userオブジェクトをデータベースに保存しようとすると自動的に失敗する。そのため、この設定をした上でrails test:modelsを実行するとエラーがなくなる。

長さを検証する

ユーザーの名前はWebサイト上に載ったりもするので、名前の長さにも制限を与える必要がある。
ここでは、50を上限とする。また、ほとんどのデータベースでは文字列の上限を255としているため、それに合わせて255文字を上限とする。

以下は、nameとemailの長さを検証するテスト

test/models/user_test.rb
require 'test_helper'
class UserTest < ActiveSupport::TestCase
  .
  .
  .
  test "name should not be too long" do
    @user.name = "a" * 51
    assert_not @user.valid?
  end

  test "email should not be too long" do
    @user.email = "a" * 244 + "@example.com"
    asseert_not @user.valid?
  end
end

この時点で、テストはエラーになっているが、バリデーションの:maximumパラメータの:lengthを設定することで、長さを強制することができる。

app/models/user.rb
class User < ApplicationRecord
  validates :name,  presence: true, length: { maximum: 50 }
  validates :email, presence: true, length: { maximum: 255 }
end

フォーマットの検証

email属性に関しては適切なフォーマットであるかを検証する必要がある。

メールアドレスのバリデーションは扱いが難しく、エラーが難しい部分なので、有効なメールアドレスと無効なメールアドレスをいくつか用意して、バリデーション内のエラーを検知していく。

具体的には、「user@example,com」のような無効メールアドレスが弾かれて、「user@example.com」のような有効メールアドレスが通ることを確認しながら、バリデーションを実施する。
test "email validation should accept valid addresses"は有効メールアドレスが通るかを確認するテストであり、test "email validation should reject invalid addresses"で無効アドレスを弾けるかをテストしている。

test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com")
  end
  .
  .
  .
  test "email validation should accept valid addresses" do
    valid_addresses = %w[user@example.com USER@foo.COM A_US-ER@foo.bar.org
                         first.last@foo.jp alice+bob@baz.cn]
    valid_addresses.each do |valid_address|
      @user.email = valid_address
      assert @user.valid?, "#{valid_address.inspect} should be valid"
    end
  end

  test "email validation should reject invalid addresses" do
    invalid_addresses = %w[user@example,com user_at_foo.org user.name@example.
                           foo@bar_baz.com foo@bar+baz.com]
    invalid_addresses.each do |invalid_address|
      @user.email = invalid_address
      assert_not @user.valid?, "#{invalid_address.inspect} should be invalid"
    end
  end
end

この状態で、`rails test`を実行すると、エラーになることを確認したら、`email``validate`を設定する。

メールアドレスのフォーマットを検証するために、次のように`format`というオプションを使う。
```ruby
validates :email, format: {with: /<regular expression>/ }`

regular expressionの箇所には、正規表現を入れる。メールアドレス標準を定める実践的でシンプルな正規表現は、

VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i

上記正規表現を適用した、バリデーションが以下になる。

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

適用後、rails testを実行するとエラーは無くなっている。

一意性を検証する

メールアドレスの一意性を検証するために、validatesメソッドの:uniquenessオプションを使う。

まずは、テストを書く。User.newは単にメモリ上にRubyオブジェクトを作るだけであり、一意性のテストのためには、メモリ上だけではなく、実際にレコードをデータベースに登録する必要がある。

test/models/user_test.rb
  test "email addresses should be unique" do
    duplicate_user = @user.dup
    @user.save
    assert_not duplicate_user.valid?
  end

ここでは、dupメソッドを使って、setupメソッドで設定した@userと同じ属性を持つデータを複製し、それが保存できるかをテストしている。

上記テストを通過するには、emailのバリデーションにuniquess: trueというオプションを追加する。

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: true
end

また、通常メールアドレスは大文字小文字を区別しない。つまり、user@example.comUSER@EXAMPLE.COMUser@EXample.coMと書いても扱いは同じ。したがって、このような場合も検証では考慮する必要がある。

まずは、テストに追記する。

test/models/user_test.rb
  test "email addresses should be unique" do
    duplicate_user = @user.dup
    duplicate_user.email = @user.email.upcase
    @user.save
    assert_not duplicate_user.valid?
  end

upcaseメソッドを使って、大文字に変換したメールアドレスが登録されるのを防ぐことができるかをテストする。

validateでは、uniquenessに対して:case_sensitiveというオプションでfalseを設定するだけで良い。

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

最後に、未だに残っている問題として、Active Recordはデータベースのレベルでは一意性を保証していない**という問題。
もし、ユーザーが送信ボタンを2連続で押してしまうなどした場合、

  1. 1つ目のリクエストにより検証にパスするユーザーがメモリ上に作られる
  2. 2つ目のリクエストにより検証にパスするユーザーがメモリ上に作られる
  3. リクエスト1のユーザーが保存される
  4. リクエスト2のユーザーが保存される
    このように一意性の検証をしているにも関わらず、同じメールアドレスを持つ2つのユーザーレコードが作成されてしまう。

解決策としては、データベース上のemailカラムにindexを追加し、そのインデックスが一意であるようにすれば解決する。

emailインデックスを追加すると、データモデリングの変更が必要になる。
まずは、マイグレーションファイルを作成する。

ターミナル
$ rails generate migration add_index_to_users_email

その後、作成されたマイグレーションファイルを編集する。
モデルにカラムを追加する解説はこちら

dp/migrate/[timestamp]_add_index_to_users_email.rb
class AddIndexToUsersEmail < ActiveRecord::Migration[6.0]
  def change
    add_index :users, :email, :unique: true
  end
end

usersテーブルのemailカラムにインデックスを使うために、add_indexメソッドを使っており、さらに一意性を付与するために、オプションでunique: tureを指定している。

最後に、データベースにマイグレート

ターミナル
$ rails db:migrate

/test/fixtures/users.yml内で一意性の制限が保たれていない場合は、テストを実行してもエラーになる。今回、エラーになって、これまでのテストでエラーになっていなにのは、fixture内のサンプルデータはバリデーションを通っていなかったため。

最後の最後に、メールアドレスの一意性を保つためにやるべきことがある。それはいくつかのデータベースのアダプタが、常に小文字と大文字を区別するインデックスを使ってるとは限らない問題への対処。
例えば、User@ExampLe.CoMuser@example.comが別々の文字列だと解釈してしまうデータベースがある。

対処法としては「データベースに保存される直前に全ての文字列を小文字に変換する」という対策を採る。これを実装するために、ActiveRecordのコールバック(callback) メソッドを利用する。このメソッドは、ある特定の時点で呼び出されるメソッド。

オブジェクトが保存される時点で処理を実行するには、before_saveというコールバックを使う。これを使って、ユーザーをデータベースに保存する前に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

セキュアなパスワードを追加する

セキュアなパスワードを実現するには、ユーザーにパスワードとパスワードの確認を入力させ、それをハッシュ化したものをデータベースに保存する。

ハッシュ化とは、簡単に言うとハッシュ関数を使い入力されたデータを元に戻せない不可逆なデータにする処理を指す。

このハッシュ化されたパスワードは、ログイン機構でユーザーを認証する際に利用する。

ハッシュ化されたパスワード

セキュアなパスワードの実装は、has_secure_passwordというRailsのメソッドを呼び出すだけでほとんど終わってします。

class User < ApplicationRecord
  .
  .
  has_secure_password
end

このメソッドにより以下のような機能が使えるようになる。

  • セキュア化したパスワードを、データベース内のpassword_digestという属性に保存できるようになる。
  • 2つのペアの仮想的な属性(passwordpassword_confirmation)が使えるようになる。また、存在性と値が一致するかどうかのバリデーションも追加される。
  • authenticateメソッドを使えるようになる(引数の文字列とパスワードと一致するとUserオブジェクトを、間違っているとfalseを返す)

このhas_secure_password機能を使えるようにするにはモデル内にpassword_digestという属性が含まれている必要がある。

以下のコマンドを実行して、Userモデルにpassword_digest属性を追加する。さらにデータベースに対してマイグレーションを実行する。

$ rails generate migration add_password_digest_to_users password_digest:string
$ rails db:migrate

has_secure_passwordを使ってパスワードをハッシュ化するためには、最先端のハッシュ関数であるbcryptが必要になる。bcryptを使うためには、bcrypt gemをGemfileに追加する。

source 'https://rubygems.org'

gem 'rails',          '6.0.4'
gem 'bcrypt',         '3.1.13'
gem 'bootstrap-sass', '3.4.1'

次に$ bundle installをターミナルで実行する。

Userモデルにpassword_digest属性を追加すれば、has_secure_psswordが使えるようになる。

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 }
  has_secure_password
end

このままでは、テストが失敗してしまうので、test/models/user_test.rbsetupメソッド内の内容を修正する。

test/models/user_test.rb
require 'test_helper'

class UserTest < ActiveSupport::TestCase

  def setup
    @user = User.new(name: "Example User", email: "user@example.com",
                     password: "foobar", password_confirmation: "foobar")
  end
  .
  .
  .
end

パスワードの最小文字数

パスワードを簡単に当てられないようにするために、パスワードの最小文字数を設定しておくことが一般的。
パスワードが空でないことと最小文字数(6文字)を検証するテストを追記する。

test/models/user_test.rb
.
.
.
  test "password should be present (nonblank)" do
    @user.password = @user.password_confirmation = " " * 6
    assert_not @user.valid?
  end

  test "password should have a minimum length" do
    @user.password = @user.password_confirmation = "a" * 5
    assert_not @user.valid?
  end
end

Userモデルを作ってみる

これまでの設定を終えて、Userモデルのインスタンスは次のように作成できる。createnewsaveを同時に行ってくれるメソッド。

ターミナル
$ rails c
>> User.create(name: "Taro Yamada", email: "taro@yamada.com",
?>             password: "password", password_confirmation: "password")

has_secure_passwordをUserモデルに追加したことにより、authenticateメソッドが使えるようになる。このメソッドは、引数の文字列をハッシュ化した値と、データベースにあるpassword_digest'の値(パスワードがハッシュ化された値)を比較し、異なればfalse、同じであればUserオブジェクトを返す。正しいバスワードを入れた際にtrueが返るようにするには、!!user.authenticate("password")`とすればいい。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?