どんな値も取れるname属性・email属性は問題!
例えばname
は空であってはならず、email
はメールアドレスのフォーマットに従う必要がある。さらに、メールアドレスをユーザーがログインするときの一意のユーザー名として使おうとしているため、メールアドレスがデータベース内で重複することのないようにする必要もある。
属性値には、何らかの制約を与える必要がある。Active Recordでは検証(Validation) という機能を通して、こういった制約を課すことができるようになっている。
よく使われるのは
- 存在(preference)の検証
- 長さ(length)の検証
- フォーマット(format)の検証
- 一意性(uniqueness)の検証
- 確認(confirmation)の検証
検証はテスト駆動開発にぴったりの機能!
setup
メソッド内の処理は、各テストが走る直前に実行される。@user
はインスタンス変数だが、setupメソッド内で宣言しておけば、全てのテスト内でこのインスタンス変数が使えるようになる。
したがって、valid?
メソッドを使ってUserオブジェクトの有効性をテストすることができる。
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オブジェクトが有効でなくなったことを確認する
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
という引数を与えて使う。
class User < ApplicationRecord
validates :name, presence: true
end
上記を設定した状態で、Userオブジェクトをデータベースに保存しようとすると自動的に失敗する。そのため、この設定をした上でrails test:models
を実行するとエラーがなくなる。
長さを検証する
ユーザーの名前はWebサイト上に載ったりもするので、名前の長さにも制限を与える必要がある。
ここでは、50を上限とする。また、ほとんどのデータベースでは文字列の上限を255としているため、それに合わせて255文字を上限とする。
以下は、nameとemailの長さを検証するテスト
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
を設定することで、長さを強制することができる。
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"
で無効アドレスを弾けるかをテストしている。
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
上記正規表現を適用した、バリデーションが以下になる。
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 "email addresses should be unique" do
duplicate_user = @user.dup
@user.save
assert_not duplicate_user.valid?
end
ここでは、dup
メソッドを使って、setup
メソッドで設定した@user
と同じ属性を持つデータを複製し、それが保存できるかをテストしている。
上記テストを通過するには、email
のバリデーションにuniquess: true
というオプションを追加する。
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.com
はUSER@EXAMPLE.COM
やUser@EXample.coM
と書いても扱いは同じ。したがって、このような場合も検証では考慮する必要がある。
まずは、テストに追記する。
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
を設定するだけで良い。
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つ目のリクエストにより検証にパスするユーザーがメモリ上に作られる
- 2つ目のリクエストにより検証にパスするユーザーがメモリ上に作られる
- リクエスト1のユーザーが保存される
- リクエスト2のユーザーが保存される
このように一意性の検証をしているにも関わらず、同じメールアドレスを持つ2つのユーザーレコードが作成されてしまう。
解決策としては、データベース上のemailカラムにindexを追加し、そのインデックスが一意であるようにすれば解決する。
emailインデックスを追加すると、データモデリングの変更が必要になる。
まずは、マイグレーションファイルを作成する。
$ rails generate migration add_index_to_users_email
その後、作成されたマイグレーションファイルを編集する。
モデルにカラムを追加する解説はこちら。
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.CoMとuser@example.comが別々の文字列だと解釈してしまうデータベースがある。
対処法としては「データベースに保存される直前に全ての文字列を小文字に変換する」という対策を採る。これを実装するために、ActiveRecordのコールバック(callback) メソッドを利用する。このメソッドは、ある特定の時点で呼び出されるメソッド。
オブジェクトが保存される時点で処理を実行するには、before_save
というコールバックを使う。これを使って、ユーザーをデータベースに保存する前にemail属性を強制的に小文字に変換する。
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つのペアの仮想的な属性(
password
とpassword_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
が使えるようになる。
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.rb
のsetup
メソッド内の内容を修正する。
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 "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モデルのインスタンスは次のように作成できる。create
はnew
とsave
を同時に行ってくれるメソッド。
$ 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")`とすればいい。