個人的リマインド用
参考
Ruby on Rails チュートリアル プロダクト開発の0→1を学ぼう
ユーザーのモデルを作成する
Userモデルの作成
データモデルとして扱うデフォルトのデータ構造のことをモデル(Model)という。データを永続化するために、データベースを使ってデータを保存し、データベースとやりとりするためにRailsライブラリのActive Recordを使う。
さらにRailsにはマイグレーション(Migration)という機能があり、データの定義をRubyで記述することができるため、SQLのDDL(Data Definition Language)を学ぶ必要がない。
データベースのマイグレーション
まずモデルを作成する
rails g model User name:string email:string
モデル名の後にオプションをつけることで、データベースで使いたい2つの属性をRailsに伝える。
そしてgenerateを実行するとマイグレーションと呼ばれるファイルも新しく生成される。マイグレーションは、データベースの構造を段階的に変更する手段を提供してくれる。
db/migrate/[timestamp]_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :name
t.string :email
t.timestamps
end
end
end
マイグレーション自体は、データベースに与える変更を定義したchangeメソッドの集まり。changeメソッドはcreate_tableというメソッドを呼び、ユーザーを保存するためのテーブルをデータベースに作成する。自分で設定していたnameやemail以外にも、created_atやupdate_atという2つのマジックカラムを生成してくれる。
ちなみにモデル名は単数系だが、テーブル名は複数形。
上記のマイグレーションが生成された後は、マイグレーションを適用する作業に入る。
rails db:migrate
モデルファイル
rails g modelでつくられるファイルは2つあり、1つがマイグレーションファイル、もう1つがモデルファイルである。
app/models/user.rb
class User < ApplicationRecord
end
ユーザーオブジェクトを作成する
rails console --sandboxで実行
>> User.new
=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil>
引数なしで読んだ場合は、全てがnil。オブジェクトの属性を設定する初期化ハッシュを引数として受け渡すと
>> user = User.new(name: "Michael Hartl", email: "michael@example.com")
>> user
=> #<User id: nil, name: "Michael Hartl", email: "michael@example.com",
created_at: nil, updated_at: nil>
この設計は、同様の方法でオブジェクトを初期化するActive Recordの設計に基づいている。
またActive Recordを理解する上で、**「有効性(Validity)」**という概念も重要。
>>user.valid?
true
現時点でデータベースにデータは格納されていない。つまりUser.newはメモリ上でオブジェクトを作成しただけで、user.valid?もオブジェクトが有効かどうかを確認しただけだから、データベースにデータが存在するかどうかは有効性と関係ない。
user.save
データベースにデータを保存。ここでマジックカラムにデータが入る。
>> foo = User.create(name: "Foo", email: "foo@bar.com")
User.createを行うとモデルの生成と保存を同時に行える。
foo.destroy
destroyはデータベースから削除するが、メモリ上にはまだデータが残る。
ユーザーオブジェクトを検索する
findは見つかったらユーザーを返してくる。見つからなかったら例外を発生する。
User.find(1) ←idを渡す
find_byを使うと属性を指定してユーザーを検索できる。
User.find_by(email: "michael@example.com"
また、
User.find
User.all
こんなのもある。
ユーザーオブジェクトの更新
基本的な方法は2つ。
1つ目は属性を個別に代入する。
user.email = "mhartl@example.net"
user.save
saveを忘れずに。reloadするとデータベースの情報を元にオブジェクトを再読み込みするので、次のように変更が取り消される。
>> user.email
=> "mhartl@example.net"
>> user.email = "foo@bar.com"
=> "foo@bar.com"
>> user.reload.email
=> "mhartl@example.net"
2つ目はupdateする方法
>> user.update(name: "The Dude", email: "dude@abides.org")
=> true
>> user.name
=> "The Dude"
>> user.email
=> "dude@abides.org"
成功した時は更新と保存を同時にする。ただし、バリデーションに1つでも引っかかると失敗する。
特定の属性のみを更新したい時は、update_attributeを使う。これにはバリデーションを回避するといった特徴もある。
user.update_attribute(:name, "El Duderino")
user.save
有効性を検証する
バリデーションとテスト駆動開発の相性は最高。
手順に関しては、まず有効なモデルのオブジェクトを作成し、その属性のうち1つを有効でない属性に変更する。そして、バリデーションが失敗するかどうかをテストする。念の為、最初に作成時の状態に対してもテストを書いておき、最初のモデルが有効かどうかも確認しておく。このようにすればバリデーションのテストが失敗した時、バリデーションの実装かオブジェクトか、責任の所在がはっきりする。
rails g modelの時に、User用テストの原型ができている。
test/models/user_test.rb
require "test_helper"
class UserTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end
有効なオブジェクトに対してテストを書くために、setupという特殊なメソッドを使って有効なUserオブジェクト(@user)を作成する。
setupメソッド内に書かれた処理は、各テストが走る直前に実行される。@userはインスタンス変数だが、setupメソッド内で宣言しておけば、全てのテスト内でこのインスタンス変数が使えるようになる。
# 有効な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
ちなみに下記コードでmodelに対してのみテストを行える。
rails test:model
存在性を検証する
最も基本的なバリデーションは「存在性(Presence)」。これは単に、渡された属性が存在することを検証する。テストの方法は、まず属性に対して空白の文字列をセットし、assert_notメソッドを使って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
test "name should be present" do # ←ここ この時点でテストはred
@user.name = " "
assert_not @user.valid?
end
end
name属性の存在を検査する方法はvalidatesメソッドにpresence: trueを与える。これは要素を1つ持つオプションハッシュである。
app/models/user.rb
class User < ApplicationRecord
validates :name, presence: true # ←この文でテストはgreenに
end
結果として
>> user = User.new(name: "", email: "michael@example.com")
>> user.valid?
=> false
なぜ失敗したかerrorsオブジェクトを使って確認できる。
user.errors.full_message
=> ["Name can't be blank"]
emailの方も同じ処理を
長さを検証する
今回は名前を50文字まで、メールアドレスを255文字まで。
超過する文字を入れて有効性を確認する。
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 "name should not be too long" do # ←こっから2つの文でテストはredに
@user.name = "a" * 51
assert_not @user.valid?
end
test "email should not be too long" do
@user.email = "a" * 244 + "@example.com"
assert_not @user.valid?
end
end
長さに関するバリデーションの追加
app/models/user.rb
class User < ApplicationRecord
validates :name, presence: true, length: { maximum: 50 } # ←greenになる
validates :email, presence: true, length: { maximum: 255 }
end
フォーマットを検証する
最初に有効なメールアドレスと無効なメールアドレスのコレクションに対するテストを行う。
>> %w[foo bar baz]
=> ["foo", "bar", "baz"]
>> addresses = %w[USER@foo.COM THE_US-ER@foo.bar.org first.last@foo.jp]
=> ["USER@foo.COM", "THE_US-ER@foo.bar.org", "first.last@foo.jp"]
>> addresses.each do |address|
?> puts address
>> end
USER@foo.COM
THE_US-ER@foo.bar.org
first.last@foo.jp
eachメソッドを使ってadresses配列の各要素を繰り返し取り出す。
現在は空ではないメールアドレスなら全てパスするようになっている。
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
end
assertの第2引数にエラーメッセージを追加している。これにより、どのメールアドレスでテストが失敗したかがわかるようになる。ちなみに詳細な文字列を調べるために.inspectが使われている。
次に@がなかったり、.が,になっているアドレスなどの「無効性」についてもテストをする。
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 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
この時点でテストはred。
フォーマット検証のバリデーションオプションはformatである。
validates :email, format: { with: /<regular expression>/ }
↑正規表現を使っている
正規表現の説明に関して、かなり難しいので本文の第6章、真ん中あたりを都度確認。
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 }
end
正規表現は定数に保存。
一意性の検証
一意性を強制するために、validatesメソッドの:uniquenessオプションを使う。
今まではUser.newで、ただ単にメモリ上にオブジェクトを作っていただけ。しかし、一意性のテストにはオブジェクトをデータベースに登録する必要がある。まずは重複したメールアドレスからテストしていく。
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 addresses should be unique" do
duplicate_user = @user.dup
@user.save
assert_not duplicate_user.valid?
end
end
dupは同じ属性を持つデータを複製するためのメソッド。@userを保存した後では、複製されたユーザーのメールアドレスが既にデータベース内に存在するため、ユーザーの作成は無効になるはず。
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
補足:通常メールアドレスでは大文字小文字が区別されない。したがってメールアドレスの検証ではこのような場合も考慮する必要がある。この性質のため、大文字と小文字を区別しないでテストすることが重要。
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 addresses should be unique" do
duplicate_user = @user.dup
duplicate_user.email = @user.email.upcase # ←追加
@user.save
assert_not duplicate_user.valid?
end
end
具体的に見ると
>> user = User.create(name: "Example User", email: "user@example.com")
>> user.email.upcase
=> "USER@EXAMPLE.COM"
>> duplicate_user = user.dup
>> duplicate_user.email = user.email.upcase
>> duplicate_user.valid?
=> true
これはfalseにならないといけない。これは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
テストはgreenになった。
しかしまだ問題が1つ残っている。それはActive Recordはデータベースのレベルでは一意性を保持していないこと。
高速で登録ボタンを2回押した時に、同じメールアドレスを持つ2つのユーザーレコードが作成されてしまう。
この問題はデータベース上のemaikカラムにインデックスを追加することで、一意性を示し解決することができる。またインデックスは検索にも役立ち、インデックスがなければ全表スキャンしなければいけないところを、本の索引のようにパッと見つけることができる。
emailインデックスを追加するには、データモデリングが必要。Railsではマイグレーションでインデックスを追加する。
rails g migration add_index_to_users_email
ユーザー用のマイグレーションとことなり、メールアドレスの一意性のマイグレーションは未定義になっているので、定義を記述する必要がある。
db/migrate/[timestamp]_add_index_to_users_email.rb
class AddIndexToUsersEmail < ActiveRecord::Migration[7.0]
def change
add_index :users, :email, unique: true
end
end
add_indexを使う。また、インデックス自体は一意性を強制しないが、オプションでunique: trueを指定することで強制できるようになる。
rails db:migrate
この時点でのテストはred。rails g modelの時にユーザー用のfixtureが自動的に生成されているが、ここのメールアドレスが一意になっているのが問題なので全部消す。
text/fixtures/users.yml
#全部空
もう1つやらなければならないことがあり、それは一部のデータベースアダプタが、大文字小文字を区別するインデックスを常に使っているとは限らない問題への対処。Foo@ExAMPle.Comとfoo@example.comを別々の文字列として解釈するデータベースがあるが、このアプリケーションではこれらを同一の文字列として扱わなければならない。
対抗策としては、データベースに保存される直前に、全ての文字列を小文字に変換するということ。これはActive Recordの**コールバック(callback)**で実装できる。
今回の場合は、オブジェクトが保存されるタイミングで処理を実行したいので、before_saveというコールバックを使う。
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: true # ←注目!!
end
uniquenessがtrueに戻っている。メールアドレスが全部小文字になるなら、大文字小文字を区別するマッチが問題なく動作するから。
テストも修正が入る。
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 addresses should be unique" do
duplicate_user = @user.dup
@user.save
assert_not duplicate_user.valid?
end
end
セキュアなパスワードを追加する
セキュアパスワードという手法では、各ユーザーにパスワードとパスワードの確認を入力させ、それをそのままではなく、ハッシュ化したものをデータベースに保存する。ハッシュ化とは、ハッシュ関数と呼ばれるものを使って、入力されたデータを不可逆データ(復元不可能なデータ)に変換する処理を行う。ハッシュ化されたパスワードは認証する時に使う。
ハッシュ化されたパスワード
セキュアなパスワードの実装は、has_secure_passwordというRailsのメソッドを呼び出すだけで、ほぼ完了。Userモデルで次のように呼び出せる。
class User < ApplicationRecord
.
.
.
has_secure_password
end
このメソッドを追加すると、次のような機能が使えるようになる。
・セキュアにハッシュ化したパスワードを、データベース内のpassword_digest属性に保存できるようになる。
・2つの仮想的な属性(passwordとpassword_confirmation)が使えるようになる。また、存在性と値が一致するかどうかのバリデーションも追加される。
・authenticateメソッドが使えるようになる(引数の文字列がパスワードと一致するとUserオブジェクトを返し、一致しない場合はfalseを返すメソッド)
このhas_secure_password機能を使うためには、モデル内にpassword_digestという属性が含まれていることが条件。そのために適切なマイグレーションを生成する。
ちなみにマイグレーション名は自由に指定できるが、末尾にto_usersをつけることがおすすめ。これをRailsが認識すると、usersテーブルにカラムを追加するマイグレーションが自動的に作成されるから。
rails g migration add_password_digest_to_users password_digest:string
db/migrate/[timestamp]_add_password_digest_to_users.rb
class AddPasswordDigestToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :password_digest, :string
end
end
今回はadd_columnメソッドを使って、usersテーブルにpassword_digestカラムを追加する。
rails db:migrate
また、has_secure_passwordを使ってパスワードをハッシュ化するためには、bcryptライブラリが必要。
Gemfile
.
.
.
gem "rails", "7.0.4"
gem "bcrypt", "3.1.18"
gem "bootstrap-sass", "3.4.1"
.
.
.
bundle _2.3.14_ install
ユーザーがセキュアなパスワードを持っている
ようやくUserモデルないでhas_secure_passwordが使えるようになった。
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: true
has_secure_password # ←ここ!
end
この時点でテストはred。理由は、has_secure_passwordには、仮想的なpassword属性とpassword_confirmation属性に対してバリデーションをする機能も追加されているから。
テストをパスさせるにはパスワードとパスワード確認の値を追加する。
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
これでテストはgreenになる。
パスワードの最小文字数
最大文字数の時とほぼ同じ
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
.
.
.
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.password = @user.password_confirmation = "a" * 5
その後バリデーションを追加。
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: true
has_secure_password
validates :password, presence: true, length: { minimum: 6 } # ←ここ!
end
この時点でテストはgreen
ユーザーの作成と認証
今回はRails consoleで作る
>> User.create(name: "Michael Hartl", email: "michael@example.com",
?> password: "foobar", password_confirmation: "foobar")
ハッシュ化の確認
>> user = User.find_by(email: "michael@example.com")
>> user.password_digest
=> "$2a$12$WgjER5ovLFjC2hmCItmbTe6nAXzT3bO66GiAQ83Ev03eVp32zyNYG"
また、authenticateメソッドも使えるようになっているかの確認。このメソッドは引数に渡された文字列(パスワード)をハッシュ化した値と、データベース内にあるpassword_digestカラムの値を比較する。
間違った値の場合
>> user.authenticate("not_the_right_password")
false
>> user.authenticate("foobaz")
false
正しい場合
>> user.authenticate("foobar")
=> #<User id: 1, name: "Michael Hartl", email: "michael@example.com",
created_at: "2022-03-11 03:15:38", updated_at: "2022-03-11 03:15:38",
password_digest: [FILTERED]>
ユーザーオブジェクトを返す。
またオブジェクトを返すのではなく、返される値が論理値であることが重要なので、
!!user.authenticate("foobar")
=> true
強制的に論理値を返させる。