Posted at

Rails Tutorialsで勉強のメモ その6(ユーザーモデルの作成)

More than 5 years have passed since last update.


やること


  • サイトのユーザー用のデータモデルの作成

  • データを保存する手段の確保


ブランチを切る

$ git checkout master

$ git checkout -b modeling-users


Userモデル


  • モデル


    • Railsの場合、データモデルで使用するデフォルトのデータ構造をさす



  • データを永続化する方法


    • データベースを使用してデータを長期間保存

    • データベースとやり取りするRailsライブラリはデフォルトでActive Recordという




  • Active Record


    • データオブジェクトの作成/保存/検索のためのメソッドを保持している

    • 上記メソッドを使用する際にSQLを意識する必要はない



  • マイグレーション


    • データの定義をRubyで記述


      • DDLを使う必要がない






データベースの移行



  • generate model


    • モデルを作成するコマンド

    • モデル名には単数形を用いる


      • コントローラ名は複数形






Userモデルの作成

$ rails generate model User name:string email:string

invoke active_record
create db/migrate/20140814160532_create_user.rb
create app/models/user.rb
invoke rspec
create spec/models/user_spec.rb


  • マイグレーション


    • データベースの構造をインクリメンタルに変更する手段を提供

    • 要求が変更された場合にデータモデルを適合させることができる

    • 上記の例の場合、db/migrate/20140814160532_create_users.rbにあたる


      • タイムスタンプを名前の中につけることで、複数開発者が生成しても競合しないようにしている






db/migrate/20140814160532_create_user.db:Userモデルのマイグレーション

class CreateUsers < ActiveRecord::Migration

def change
create_table :user do |t|
t.string :name
t.string :email

t.timestamps
end
end
end




  • マイグレーション


    • データベースに与える変更を定義したchangeメソッドの集合



      • create_tableメソッドを呼び、ユーザーを保持するためのテーブルをデータベースに作成する


        • モデルは単数形(user)だが、テーブル名は複数形(users



      • ブロックの中で、nameemailというカラムをデータベースに作成


      • t.timestampcreated_atupdated_atというカラムを作成


        • レコード作成/更新時に自動的に更新されるカラム








  • ユーザーデータモデル



    • idは各行を一意に識別するためのカラムで自動生成される



カラム名

id
integer

name
string

email
string

created_at
datetime

updated_at
datetime


  • マイグレーションの実行


    • bundle exec rake db:migrate

    • マイグレーションの適用(migrating up)

    • 内部的にcreate_tableを呼び出している


    • db/development.sqlite3というDDLのファイルが作成される


      • SQLite Database Browserでデータベースの構造を参照できる





  • マイグレーションの取り消し


    • bundle exec rake db:rollback

    • マイグレーションの取り消し(migrating down)

    • 内部的にdrop_tableを呼び出している




マイグレーションの適用

$ bundle exec rake db:migrate

== 20140814160532 CreateUsers: migrating ======================================
-- create_table(:users)
-> 0.0077s
== 20140814160532 CreateUsers: migrated (0.0079s) =============================


modelファイル


  • Userモデル



    • ActiveRecord::Baseの全ての機能を持つ




app/models/user.rb:Userモデル

class User < ActiveRecord::Base

end


ユーザーオブジェクトの作成



  • rails consoleのサンドボックスモード


    • コンソール上でDBの変更を行ってもロールバックされる




Userオブジェクトの操作:作成・保存と削除

$ rails c --sandbox

Loading development environment in sandbox (Rails 4.0.4)
Any modifications you make will be rolled back on exit

# Userオブジェクトの作成
> User.new

# Userオブジェクトを初期化してuser変数に代入
> user = User.new(name: "Michael Hartl", email: "mhart@example.com")

# user変数をDBに保存
> user.save

# user変数の属性を確認
> user.name
=> "Michael Hartl"
> user.email
=> "mhart@example.com"
> user.updated_at
=> Sat, 23 Aug 2014 13:24:40 UTC +00:00

# Userオブジェクトの作成と同時に保存
> User.create(name: "A Nother", email: "another@example.org")
(0.2ms) SAVEPOINT active_record_1
SQL (0.9ms) INSERT INTO "users" ("created_at", "email", "name", "updated_at") VALUES (?, ?, ?, ?) [["created_at", Sat, 23 Aug 2014 14:09:14 UTC +00:00], ["email", "another@example.org"], ["name", "A Nother"], ["updated_at", Sat, 23 Aug 2014 14:09:14 UTC +00:00]]
(0.4ms) RELEASE SAVEPOINT active_record_1
=> #<User id: 4, name: "A Nother", email: "another@example.org", created_at: "2014-08-23 14:09:14", updated_at: "2014-08-23 14:09:14">

# userの破棄
> user = user.destroy
(0.4ms) SAVEPOINT active_record_1
SQL (0.6ms) DELETE FROM "users" WHERE "users"."id" = ? [["id", 3]]
(0.8ms) RELEASE SAVEPOINT active_record_1
=> #<User id: 3, name: "Michael Hartl", email: "mhart@example.com", created_at: "2014-08-23 13:24:40", updated_at: "2014-08-23 13:24:40">



ユーザーオブジェクトの検索


Userオブジェクトの検索

# idで検索

> User.find(5)
User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1 [["id", 5]]
=> #<User id: 5, name: "A Nother", email: "another@example.org", created_at: "2014-08-23 14:30:53", updated_at: "2014-08-23 14:30:53">

# Emailで検索
> User.find_by_email("another@example.org")
User Load (0.4ms) SELECT "users".* FROM "users" WHERE "users"."email" = 'another@example.org' LIMIT 1
=> #<User id: 4, name: "A Nother", email: "another@example.org", created_at: "2014-08-23 14:09:14", updated_at: "2014-08-23 14:09:14">

# 上記よりもハッシュで指定するfind_byメソッドを使うべき
> User.find_by(email: "another@example.org")
User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."email" = 'another@example.org' LIMIT 1
=> #<User id: 4, name: "A Nother", email: "another@example.org", created_at: "2014-08-23 14:09:14", updated_at: "2014-08-23 14:09:14">

# 最初の1件目を取得
> User.first
User Load (0.4ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1
=> #<User id: 2, name: "テストユーザー", email: "admin@localhost", created_at: "2014-08-02 06:36:09", updated_at: "2014-08-02 06:36:09">

# 全てのデータを返す
> User.all



ユーザーオブジェクトの更新


  • オブジェクトの更新方法


    • 属性を個別に代入



      • saveメソッド



    • 属性の更新と保存を同時に行う



      • update_attributesメソッド






属性の更新

# userの登録

> user = User.create(name: "Michael Hartl", email: "mhart@exampl
e.net"
)
(0.2ms) SAVEPOINT active_record_1
SQL (0.6ms) INSERT INTO "users" ("created_at", "email", "name", "updated_at") VALUES (?, ?, ?, ?) [["created_at", Sat, 23 Aug 2014 14:39:04 UTC +00:00], ["email", "mhart@example.net"], ["name", "Michael Hartl"], ["updated_at", Sat, 23 Aug 2014 14:39:04 UTC +00:00]]
(1.4ms) RELEASE SAVEPOINT active_record_1
=> #<User id: 7, name: "Michael Hartl", email: "mhart@example.net", created_at: "2014-08-23 14:39:04", updated_at: "2014-08-23 14:39:04">

# 属性の変更
> user.email = "mhartl@example.com"
=> "mhartl@example.com"

# 属性の更新
> user.save
(0.2ms) SAVEPOINT active_record_1
SQL (0.8ms) UPDATE "users" SET "email" = ?, "updated_at" = ? WHERE "users"."id" = 7 [["email", "mhartl@example.com"], ["updated_at", Sat, 23 Aug 2014 14:39:30 UTC +00:00]]
(0.2ms) RELEASE SAVEPOINT active_record_1
=> true

# 属性の更新+保存
> user.update_attributes(name: "The Dude", email: "dude@abides.org")
(0.2ms) SAVEPOINT active_record_1
SQL (1.0ms) UPDATE "users" SET "name" = ?, "email" = ?, "updated_at" = ? WHERE "users"."id" = 7 [["name", "The Dude"], ["email", "dude@abides.org"], ["updated_at", Sat, 23 Aug 2014 14:43:01 UTC +00:00]]
(0.1ms) RELEASE SAVEPOINT active_record_1
=> true



ユーザの検証



  • Active Recordの検証(バリデーション)


    • 存在の検証(presence)

    • 長さの検証(length)

    • フォーマットの検証(format)

    • 一意性の検証(uniqueness)




最初のユーザテスト


  • 初期spec



    • pendingメソッドを呼び出して、黄色い文字を出力するだけ




初期spec

require 'spec_helper'

describe User do
pending "add some examples to (or delete) #{__FILE__}"
end



テストの実行

# DBの作成

$ bundle exec rake db:migrate

# 開発データベースのデータモデルdb/development.sqlite3がテストデータベースdb/test.sqlite3に反映されるようにするもの
$ bundle exec rake test:prepare

# テストを実行
$ bundle exec rspec spec/models/user_spec.rb



spec/models/user_sprc.rbにテストケースを追加

require 'spec_helper'

describe User do
before { @user = User.new(name: "Example User", email: "user@example.com") }

subject { @user }

it { should respond_to(:name) }
it { should respond_to(:email) }
end



上記は下記と同義

it "should respond to 'name'" do

expect(@user).to respond_to(:name)
end



  • respond_toメソッド


    • Rubyのrespond_to?メソッドを呼び出す

    • そのシンボルが表すメソッドまたは属性が存在しているかどうかを確認




テストを実施:成功

$ bundle exec rspec spec/



respond_to?の動作確認

$ rails c --sandbox

> user = User.new
> user.respond_to?(:name)
=> true
> user.respond_to?(:foo)
=> false



プレゼンスの検証


  • 属性の存在を検証


    • validatesメソッドにpresence: true`という引数を与える




app/models/user.rb

class User < ActiveRecord::Base

validates :name, presence: true
end


presenceの検証

$ rails c --sandbox

# name属性を未指定で作成
> user = User.new(name: "", email: "mhartl@example.com")

# 保存しようとするとfalseが返却される
> user.save
=> false

# 検証してもfalseが返却される
> user.valid?
=> false

# エラーメッセージの確認
> user.errors.full_messages
=> ["Name can't be blank"]



  • テストケースの追加



    • validがtrueであること

    • name、email属性がブランクの時はvalid?メソッドがfalseであること




spec/models/user_spec.rb

require 'spec_helper'

describe User do
before { @user = User.new(name: "Example User", email: "user@example.com") }

subject { @user }

it { should respond_to(:name) }
it { should respond_to(:email) }

it { should be_valid }

describe "when name is not present" do
before { @user.name = " " }
it { should_not be_valid }
end

describe "when email is not present" do
before { @user.email = " " }
it { should_not be_valid }
end
end



rspecの実行:emailのpresenceがないのでエラー

$ bundle exec rspec

.......F...........

Failures:

1) User when email is not present
Failure/Error: it { should_not be_valid }
expected #<User id: nil, name: "Example User", email: " ", created_at: nil, updated_at: nil> not to be valid



app/models/user.rbにemailのpresence設定を追加

class User < ActiveRecord::Base

validates :name, presence: true
validates :email, presence: true
end


テスト成功

$ bundle exec rspec



長さの検証


spec/models/user_spec.rbに長さのテストケースを追加

  describe "when name is too long" do

before { @user.name = "a" * 51 }
it { should_not be_valid }
end


テストを実施して失敗

$ bundle exec rspec



  • 長さの検証の設定


    • length: { maximum: <最大文字数> }




app/models/user.rbに長さの設定を追加

class User < ActiveRecord::Base

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


フォーマットの検証


spec/models/user_spec.rbにemailのフォーマット検証ロジックを追加

  describe "when email format is invalid" do

it "should be invalid" do
addresses = %w[
user@foo,com
user_at_foo.org
example.user@foo.
foo@bar_baz.com
foo@bar+baz.com
]

addresses.each do |invalid_address|
@user.email = invalid_address
expect(@user).not_to be_valid
end
end
end

describe "when email format is valid" do
it "should be valid" do
addresses = %w[
user@foo.COM
A_US-ER@f.b.org
frst.lst@foo.jp
a+b@baz.cn
]

addresses.each do |valid_address|
@user.email = valid_address
expect(@user).to be_valid
end
end
end



  • フォーマットの検証


    • format: { with: <フォーマット> }


    • \Aは文字列の先頭を表す


    • \zは文字列の末尾を表す




app/models/user.rbにemailのフォーマット検証を追加

class User < ActiveRecord::Base

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


一意性の検証



  • @user.dup


    • 同じユーザを複製

    • 既に登録済みのユーザを登録しようとしてエラーになることを期待




spec/models/user_spec.rbに一意性検証のテストコードを追加

  describe "when email address is already taken" do

before do
user_with_same_email = @user.dup
user_with_same_email.save
end

it { should_not be_valid }
end



  • 一意性の検証



    • uniqueness: trueを設定




app/models/user.rbに一意性の検証を追加

class User < ActiveRecord::Base

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


一意性の検証で、現時点では大文字小文字を区別する

$ rails c --sandbox

> user = User.create(name: "Example User", email: "user@example.com")
> user_with_same_email = user.dup
# メールアドレスを大文字に変換してバリデーションチェック → 一意性がtrueとみなされている
> user_with_same_email.email = user.email.upcase
> user_with_same_email.valid?
=> true



  • 大文字小文字を区別しないためには



    • :case_sensitiveを設定




spec/models/user_spec.rbに一意性検証のテストコードを追加

  describe "when email address is already taken" do

before do
user_with_same_email = @user.dup
user_with_same_email.email = @user.email.upcase
user_with_same_email.save
end

it { should_not be_valid }
end



app/models/user.rbに大文字小文字を区別しない設定を追加

class User < ActiveRecord::Base

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


  • DB側の一意性制約


    • emailカラムにDBのインデックスを作成し、そのインデックスが一意であることを要求


    • rails generate migrationで作成

    • add_index :<テーブル名>, :<カラム名>, unique: true




インデックスの作成

$ rails generate migration add_index_to_users_email

invoke active_record
create db/migrate/20140824083152_add_index_to_users_email.rb


db/migrate/20140824083152_add_index_to_users_email.rb

class AddIndexToUsersEmail < ActiveRecord::Migration

def change
add_index :users, :email, unique: true
end
end


データベースのマイグレート

$ cat db/schema.rb

ActiveRecord::Schema.define(version: 20140814160532) do

create_table "users", force: true do |t|
t.string "name"
t.string "email"
t.datetime "created_at"
t.datetime "updated_at"
end

end

$ bundle exec rake db:migrate

$ cat db/schema.rb
ActiveRecord::Schema.define(version: 20140824083152) do

create_table "users", force: true do |t|
t.string "name"
t.string "email"
t.datetime "created_at"
t.datetime "updated_at"
end

add_index "users", ["email"], name: "index_users_on_email", unique: true

end



  • メールアドレス保存時の考慮事項


    • メールアドレス保存時に全ての文字を小文字にする


      • DBのアダプタが常に大文字小文字を区別するインデックスを使用しているとは限らないため



    • コールバック機能を使用



  • コールバック



    • Active Recordオブジェクトが持続している間のどこかの時点で、Active Recordオブジェクトに呼び出してもらうメソッド

    • 今回はbefore_saveコールバックを使用




app/models/user.rbに、メールアドレス保存時に小文字に変換するロジックを追加

class User < ActiveRecord::Base

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, format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
end



セキュアなパスワードの追加


  • ユーザの認証


    • パスワードの送信

    • 暗号化

    • DB内の暗号化された値との比較


      • 暗号化された値同士を比較する





  • セキュアなパスワードの実装方法



    • has_secure_passwordメソッドを使用

    • ハッシュ関数にはbcryptを使用して、不可逆的に暗号化してパスワードハッシュを作成




暗号化されたパスワード


  • ユーザのデータモデル



    • password_digestカラムを追加



カラム名

id
integer

name
string

email
string

password_digest
string

created_at
datetime

updated_at
datetime


Gemfileにbcrypt用のgemを追加

gem 'bcrypt-ruby', '3.1.2'



gemのインストール

$ bundle install



spec/models/user_spec.rbにpassword_digestカラムがあることのテストケースを追加

   it { should respond_to(:password_digest) }



  • マイグレーション名


    • 末尾に_to_usersとつけると、usersテーブルにカラムを追加するマイグレーションがRailsによって自動作成される

    • 第2引数でカラム名と型を指定




password_digest用のマイグレーションを作成

$ rails generate migration add_password_digest_to_users password_digest:string

invoke active_record
create db/migrate/20140824101047_add_password_digest_to_users.rb

$ cat db/migrate/20140824101047_add_password
_digest_to_users.rb
class AddPasswordDigestToUsers < ActiveRecord::Migration
def change
add_column :users, :password_digest, :string
end
end



開発DBをマイグレーションしてテストDBを準備し、テストを実施

$ bundle exec rake db:migrate

$ bundle exec rake test:prepare
$ bundle exec rspec spec/


パスワードと確認



  • パスワードの確認


    • モデルの中でActive Recordを使用して制限を与えるのが慣習


    • password属性とpassword_confirmation属性をUserモデルに追加し、レコードをDBに保存する前に2つの属性が一致するように要求する


      • これらの属性は一時的にメモリ上に置き、DBには保存されないようにする

      • これらの属性はhas_secure_passwordで自動的に実装される






  • テストコードの追加/修正



    • @userの初期化時にpassword属性とpassword_confirmation属性も初期化する


    • passwordpassword_confirmationカラムの存在チェック



      • passwordpassword_confirmationはDB上に作成されるわけではない(仮想的な属性)




    • passwordの存在確認テスト


    • passwordpassword_confirmationの不一致テスト




spec/models/user_spec.rb

  before do

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

it { should respond_to(:password) }
it { should respond_to(:password_confirmation) }

describe "when password is not present" do
before do
@user = User.new(name: "Example User", email: "user@example.com",
password: " ", password_confirmation: " ")
end
it { should_not be_valid }
end

describe "when password doesn't match confirmation" do
before { @user.password_confirmation = "mismatch" }
it { should_not be_valid }
end



app/models/user.rbにhas_secure_passwordを追加

class User < ActiveRecord::Base

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, format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
end




  • has_secure_passwordを追加するだけでテストコードがパスできる


    • テストがOKになることを確認したら、上記のhas_secure_passwordをコメントアウトしておく




テストコードの実行

$ bundle exec rspec spec/



ユーザー認証



  • ユーザをメールアドレスとパスワードに基づいて取得する手段


    1. ユーザをメールアドレスで検索


      • `user = User.find_by(email: email)



    2. 受け取ったパスワードでユーザを認証


      • current_user = user.authenticate(password)






  • 認証に関するテストケース



    • authenticateが応答することの確認

    • パスワードが一致する場合と一致しない場合についてテストケースを記載

    • パスワードの長さの確認




spec/models/user_spec.rbに上記テストケースを追加

  it { should respond_to(:authenticate) }

describe "with a password that's too short" do
before { @user.password = @user.password_confirmation = "a" * 5 }
it { should be_invalid }
end

describe "return value of authenticate method" do
before { @user.save }
let(:found_user) { User.find_by(email: @user.email) }

describe "with valid password" do
let(:user_for_invalid_password) { found_user.authenticate("invalid") }

it { should_not eq user_for_invalid_password }
specify { expect(user_for_invalid_password).to be_false }
end
end




  • letメソッド


    • テスト内でローカル変数を作成


    • let(:found_user) { User.find_by(email: @user.email) }の場合



      • found_userという変数を作成し、ブロック内の戻り値が代入される






  • specifyメソッド



    • itと同義


    • itを使用すると英語として不自然な場合に使用


      • ×:user: user with invalid password should be false

      • ○:specify: user with invalid password should be false






  • eq演算子


    • オブジェクト同士が同値であるかどうかを確認




ユーザモデルのパスワード認証の実装



  • パスワードの長さ検証


    • `length: { minimum: 6 }



  • passwordpassword_confirmationを追加


  • パスワードが存在することの確認


  • パスワードとパスワードの確認が一致することの確認



  • password_digestを比較してユーザを認証




app/models/user.rbにパスワード関連の設定を追加

class User < ActiveRecord::Base

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,
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
validates :password, length: { minimum: 6 }
end



ユーザの作成


ユーザデータを作成

$ rails c

# ユーザの作成
> User.create(name: "Michael Hartl", email: "mhartl@example.com", password: "foobar", password_confirmation: "foobar")

# パスワードの確認
> user = User.find_by(email: "mhartl@example.com")
> user.password_digest
=> "$2a$10$GEZwKUtE7ZqAkgsBhHmIrueLFPZhECEauTC2FqbIGsWg8rzGr3Mwq"

# authenticateメソッドの確認
# パスワードが誤っている場合はfalse、正しい場合はユーザオブジェクトを返す
> user.authenticate("invalid")
=> false
> user.authenticate("foobar")
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2014-08-24 15:11:15", updated_at: "2014-08-24 15:11:15", password_digest: "$2a$10$GEZwKUtE7ZqAkgsBhHmIrueLFPZhECEauTC2FqbIGsWg...">



変更内容のコミット


変更内容のコミットとmasterへのマージ

$ git branch

filling-in-layout
master
* modeling-users
static-pages

$ git add .
$ git commit -m "Make a basic User model (including secure passwords)"

$ git checkout master
$ git merge modeling-users