v-crn Code Log

主に備忘録

Rails Tutorial 第4版 第6章 演習 解答例

f:id:v-crn:20190711155257p:plain

この文書はRails Tutorial 第4版 第6章 ユーザーのモデルを作成するの演習に対する個人の解答例です。解答には誤りや不適切な表現が含まれていることがありますが、もし誤謬を見つけたらコメント頂けると嬉しいです。
それでは、やっていきましょう!

6.1 Userモデル

6.1.1 データベースの移行

1. Railsはdb/ディレクトリの中にあるschema.rbというファイルを使っています。これはデータベースの構造 (スキーマ (schema) と呼びます) を追跡するために使われます。さて、あなたの環境にあるdb/schema.rbの内容を調べ、その内容とマイグレーションファイル (リスト 6.2) の内容を比べてみてください。

db/schema.rb

# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# Note that this schema.rb definition is the authoritative source for your
# database schema. If you need to create the application database on another
# system, you should be using db:schema:load, not running all the migrations
# from scratch. The latter is a flawed and unsustainable approach (the more migrations
# you'll amass, the slower it'll run and the greater likelihood for issues).
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2019_07_13_160043) do

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

end

db/migrate/20190713160043_create_users.rb

class CreateUsers < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      t.string :name
      t.string :email

      t.timestamps
    end
  end
end

schemaファイルとマイグレーションファイルとの間では、create_table "users"以下のブロックの内容がほぼ一致しています。一部異なるのはマイグレーションファイルにおけるt.timestampsがschemaファイルではt.datetime "created_at", null: falset.datetime "updated_at", null: falseに変化しているように見えることです。

2. ほぼすべてのマイグレーションは、元に戻すことが可能です (少なくとも本チュートリアルにおいてはすべてのマイグレーションを元に戻すことができます)。元に戻すことを「ロールバック (rollback)と呼び、Railsではdb:rollbackというコマンドで実現できます。$ rails db:rollback 上のコマンドを実行後、db/schema.rbの内容を調べてみて、ロールバックが成功したかどうか確認してみてください (コラム 3.1ではマイグレーションに関する他のテクニックもまとめているので、参考にしてみてください)。上のコマンドでは、データベースからusersテーブルを削除するためにdrop_tableコマンドを内部で呼び出しています。これがうまくいくのは、drop_tableとcreate_tableがそれぞれ対応していることをchangeメソッドが知っているからです。この対応関係を知っているため、ロールバック用の逆方向のマイグレーションを簡単に実現することができるのです。なお、あるカラムを削除するような不可逆なマイグレーションの場合は、changeメソッドの代わりに、upとdownのメソッドを別々に定義する必要があります。詳細については、Railsガイドの「Active Record マイグレーション」を参照してください。

ロールバック後のschemaファイルを以下に示します。

db/schema.rb

# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# Note that this schema.rb definition is the authoritative source for your
# database schema. If you need to create the application database on another
# system, you should be using db:schema:load, not running all the migrations
# from scratch. The latter is a flawed and unsustainable approach (the more migrations
# you'll amass, the slower it'll run and the greater likelihood for issues).
#
# It's strongly recommended that you check this file into your version control system.

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

end

usersテーブルのカラムに関する記述が消えているので、マイグレートする前の状態に戻ったようです。

3. もう一度rails db:migrateコマンドを実行し、db/schema.rbの内容が元に戻ったことを確認してください。

コードは省略しますが、db/schema.rbの内容が元に戻ることを確認しました。

6.1.2 modelファイル

1. Railsコンソールを開き、User.newでUserクラスのオブジェクトが生成されること、そしてそのオブジェクトがApplicationRecordを継承していることを確認してみてください (ヒント: 4.4.4で紹介したテクニックを使ってみてください)。

>> user = User.new
=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil>
>> user.class.superclass
=> ApplicationRecord(abstract)

2. 同様にして、ApplicationRecordがActiveRecord::Baseを継承していることについて確認してみてください。

>> ApplicationRecord.superclass
=> ActiveRecord::Base

6.1.3 ユーザーオブジェクトを作成する

1. user.nameとuser.emailが、どちらもStringクラスのインスタンスであることを確認してみてください。

>> user.name.class=> String
>> user.email.class
=> String

user.nameとuser.emailがStringクラスのインスタンスであることを確認できました。

2. created_atとupdated_atは、どのクラスのインスタンスでしょうか?

>> user.created_at.class
=> ActiveSupport::TimeWithZone
>> user.updated_at.class
=> ActiveSupport::TimeWithZone

両者はActiveSupport::TimeWithZoneクラスのインスタンスです。

6.1.4 ユーザーオブジェクトを検索する

1. nameを使ってユーザーオブジェクトを検索してみてください。また、 find_by_nameメソッドが使えることも確認してみてください (古いRailsアプリケーションでは、古いタイプのfind_byをよく見かけることでしょう)。

>> User.find_by(name: "Michael Hartl")
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."name" = ? LIMIT ?  [["name", "Michael Hartl"], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2019-07-13 17:54:18", updated_at: "2019-07-13 17:54:18">
>> User.find_by_name("Michael Hartl")
  User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."name" = ? LIMIT ?  [["name", "Michael Hartl"], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2019-07-13 17:54:18", updated_at: "2019-07-13 17:54:18">

2. 実用的な目的のため、User.allはまるで配列のように扱うことができますが、実際には配列ではありません。User.allで生成されるオブジェクトを調べ、ArrayクラスではなくUser::ActiveRecord_Relationクラスであることを確認してみてください。

>> User.all.class
=> User::ActiveRecord_Relation

3. User.allに対してlengthメソッドを呼び出すと、その長さを求められることを確認してみてください (4.2.3)。Rubyの性質として、そのクラスを詳しく知らなくてもなんとなくオブジェクトをどう扱えば良いかわかる、という性質があります。これをダックタイピング (duck typing) と呼び、よく次のような格言で言い表されています「もしアヒルのような容姿で、アヒルのように鳴くのであれば、それはもうアヒルだろう」。(訳注: そういえばRubyKaigi 2016の基調講演で、Ruby作者のMatzがダックタイピングについて説明していました。2〜3分の短くて分かりやすい説明なので、ぜひ視聴してみてください!)

>> User.all.length
  User Load (0.1ms)  SELECT "users".* FROM "users"
=> 1

6.1.5 ユーザーオブジェクトを更新する

1. userオブジェクトへの代入を使ってname属性を使って更新し、saveで保存してみてください。

=> "yamada"
>> user.save
   (0.2ms)  SAVEPOINT active_record_1
  User Update (0.5ms)  UPDATE "users" SET "name" = ?, "email" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "yamada"], ["email", "m@example.com"], ["updated_at", "2019-07-13 18:42:56.552791"], ["id", 1]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true

2. 今度はupdate_attributesを使って、email属性を更新および保存してみてください。

>> user.update_attributes(name: "Yanagida", email: "y@gmail.com")
   (0.4ms)  SAVEPOINT active_record_1
  User Update (0.7ms)  UPDATE "users" SET "name" = ?, "email" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "Yanagida"], ["email", "y@gmail.com"], ["updated_at", "2019-07-13 18:44:59.680964"], ["id", 1]]
   (0.2ms)  RELEASE SAVEPOINT active_record_1
=> true

3. 同様にして、マジックカラムであるcreated_atも直接更新できることを確認してみてください。ヒント: 更新するときは「1.year.ago」を使うと便利です。これはRails流の時間指定の1つで、現在の時刻から1年前の時間を算出してくれます。

>> user.update_attributes(created_at: 1.year.ago)   (0.1ms)  SAVEPOINT active_record_1
  User Update (0.3ms)  UPDATE "users" SET "created_at" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["created_at", "2018-07-13 18:46:23.868735"], ["updated_at", "2019-07-13 18:46:23.882587"], ["id", 1]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true

6.2 ユーザーを検証する

6.2.1 有効性を検証する

1. コンソールから、新しく生成したuserオブジェクトが有効 (valid) であることを確認してみましょう。

>> user = User.new(name: "Ruby Rails")
=> #<User id: nil, name: "Ruby Rails", email: nil, created_at: nil, updated_at: nil>
>> user.valid?
=> true

2. 6.1.3で生成したuserオブジェクトも有効であるかどうか、確認してみましょう。

>> user = User.new
=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil>
>> user.valid?
=> true

6.2.2 存在性を検証する

1. 新しいユーザーuを作成し、作成した時点では有効ではない (invalid) ことを確認してください。なぜ有効ではないのでしょうか? エラーメッセージを確認してみましょう。

>> u = User.new
=> #<User id: nil, name: nil, email: nil, created_at: nil, updated_at: nil>
>> u.valid?
=> false
>> u.errors.full_messages
=> ["Name can't be blank", "Email can't be blank"]

2. u.errors.messagesを実行すると、ハッシュ形式でエラーが取得できることを確認してください。emailに関するエラー情報だけを取得したい場合、どうやって取得すれば良いでしょうか?

>> u.errors.messages
=> {:name=>["can't be blank"], :email=>["can't be blank"]}
>> u.errors.messages[:email]
=> ["can't be blank"]

6.2.3 長さを検証する

1. 長すぎるnameとemail属性を持ったuserオブジェクトを生成し、有効でないことを確認してみましょう。

>> user = User.new(name: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", email: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@mail.net")
=> #<User id: nil, name: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...", email: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...", created_at: nil, updated_at: nil>
>> user.valid?
=> false

2. 長さに関するバリデーションが失敗した時、どんなエラーメッセージが生成されるでしょうか? 確認してみてください。

>> user.errors.full_messages
=> ["Name is too long (maximum is 50 characters)", "Email is too long (maximum is 255 characters)"]

6.2.4 フォーマットを検証する

1. リスト 6.18にある有効なメールアドレスのリストと、リスト 6.19にある無効なメールアドレスのリストをRubularのYour test string:に転記してみてください。その後、リスト 6.21の正規表現をYour regular expression:に転記して、有効なメールアドレスのみがすべてマッチし、無効なメールアドレスはすべてマッチしないことを確認してみましょう。

Your regular expression:

[\w+\-.]+@[a-z\d\-.]+\.[a-z]+

Your test string:

user@example.com
USER@foo.COM
A_US-ER@foo.bar.org
first.last@foo.jp
alice+bob@baz.cn

user@example,com
user_at_foo.org
user.name@example.
foo@bar_baz.com
foo@bar+baz.com

Match resultに上から5番目までのメールアドレスの文字背景に色がついていればOKです。

https://rubular.com/r/sAo4p5M1E6bg3R

2. 先ほど触れたように、リスト 6.21のメールアドレスチェックする正規表現は、foo@bar..comのようにドットが連続した無効なメールアドレスを許容してしまいます。まずは、このメールアドレスをリスト 6.19の無効なメールアドレスリストに追加し、これによってテストが失敗することを確認してください。次に、リスト 6.23で示した、少し複雑な正規表現を使ってこのテストがパスすることを確認してください。

テストにfoo@bar..comを追加します。

test/models/user_test.rb

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 foo@bar..com]
    invalid_addresses.each do |invalid_address|
      @user.email = invalid_address
      assert_not @user.valid?, "#{invalid_address.inspect} should be invalid"
    end
    end

テストを実行します。

$ rails test:models
Started with run options --seed 32530

 FAIL["test_email_validation_should_reject_invalid_addresses", #<Minitest::Reporters::Suite:0x00007fa06b19bc08 @name="UserTest">, 0.03620800003409386]
 test_email_validation_should_reject_invalid_addresses#UserTest (0.04s)
        "foo@bar..com" should be invalid
        test/models/user_test.rb:46:in `block (2 levels) in <class:UserTest>'
        test/models/user_test.rb:44:in `each'
        test/models/user_test.rb:44:in `block in <class:UserTest>'

  7/7: [==============] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.03694s
7 tests, 16 assertions, 1 failures, 0 errors, 0 skips

結果はredです。リスト6.23よりuser.rbで使用する正規表現VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/iとして再度テストします。

$ rails test:models
Started with run options --seed 30297

  7/7: [==============] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.02860s
7 tests, 16 assertions, 0 failures, 0 errors, 0 skips

greenになることが確認できました。

3. foo@bar..comをRubularのメールアドレスのリストに追加し、リスト 6.23の正規表現をRubularで使ってみてください。有効なメールアドレスのみがすべてマッチし、無効なメールアドレスはすべてマッチしないことを確認してみましょう。

確認できました。

https://rubular.com/r/6UlnUeFkdbBL3X

6.2.5 一意性を検証する

1. リスト 6.33を参考に、メールアドレスを小文字にするテストをリスト 6.32に追加してみましょう。ちなみに追加するテストコードでは、データベースの値に合わせて更新するreloadメソッドと、値が一致しているかどうか確認するassert_equalメソッドを使っています。リスト 6.33のテストがうまく動いているか確認するためにも、before_saveの行をコメントアウトして redになることを、また、コメントアウトを解除すると greenになることを確認してみましょう。

メールアドレスを小文字にするテストをリスト 6.32に追加し、user.rbのbefore_saveの行をコメントアウトしてテストを実行します。

$ rails test:models
Started with run options --seed 55447

 FAIL["test_email_addresses_should_be_saved_as_lower-case", #<Minitest::Reporters::Suite:0x00007f935f3febd0 @name="UserTest">, 0.04487500002142042]
 test_email_addresses_should_be_saved_as_lower-case#UserTest (0.04s)
        Expected: "foo@example.com"
          Actual: "Foo@ExAMPle.CoM"
        test/models/user_test.rb:61:in `block in <class:UserTest>'

  9/9: [==============] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.05882s
9 tests, 17 assertions, 1 failures, 0 errors, 0 skips

redになることが確認できました。コメントアウトを解除して再度テストします。

$ rails test:models
Started with run options --seed 55480

  9/9: [==============] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.06251s
9 tests, 17 assertions, 0 failures, 0 errors, 0 skips

greenになることが確認できました。

2. テストスイートの実行結果を確認しながら、before_saveコールバックをemail.downcase!に書き換えてみましょう。ヒント: メソッドの末尾に!を付け足すことにより、email属性を直接変更できるようになります (リスト 6.34)。

app/models/user.rb

class User < ApplicationRecord
    before_save { 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

テストを実行します。

$ rails test:models
Started with run options --seed 3506

  9/9: [==============] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.07236s
9 tests, 17 assertions, 0 failures, 0 errors, 0 skips

greenになることが確認できました。

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

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

演習なし。

6.3.2 ユーザーがセキュアなパスワードを持っている

1. この時点では、userオブジェクトに有効な名前とメールアドレスを与えても、valid?で失敗してしまうことを確認してみてください。

>> user = User.new(name: "foo", email: "foo@example.com")=> #<User id: nil, name: "foo", email: "foo@example.com", created_at: nil, updated_at: nil, password_digest: nil>
>> user.valid?
  User Exists (0.2ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) LIMIT ?  [["email", "foo@example.com"], ["LIMIT", 1]]
=> false

2. なぜ失敗してしまうのでしょうか? エラーメッセージを確認してみてください。

>> user.errors.full_messages
=> ["Password can't be blank"]

パスワードが設定されていないから。

6.3.3 パスワードの最小文字数

1. 有効な名前とメールアドレスでも、パスワードが短すぎるとuserオブジェクトが有効にならないことを確認してみましょう。

>> user = User.new(name: "foo", email: "foo@example.com", password: "abc", password_confirmation: "abc")
=> #<User id: nil, name: "foo", email: "foo@example.com", created_at: nil, updated_at: nil, password_digest: "$2a$12$xBDNuHJPMcihRHSEP2vsCeMTU8gV8Nu2q5ofQ0xsMVO...">
>> user.valid?
  User Exists (0.2ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) LIMIT ?  [["email", "foo@example.com"], ["LIMIT", 1]]
=> false

2. 上で失敗した時、どんなエラーメッセージになるでしょうか? 確認してみましょう。

>> user.errors.full_messages
=> ["Password is too short (minimum is 6 characters)"]

6.3.4 ユーザーの作成と認証

1. コンソールを一度再起動して (userオブジェクトを消去して)、このセクションで作ったuserオブジェクトを検索してみてください。

>> user = User.find_by(name: "Michael Hartl")
  User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."name" = ? LIMIT ?  [["name", "Michael Hartl"], ["LIMIT", 1]]
=> #<User id: 1, name: "Michael Hartl", email: "mhartl@example.com", created_at: "2019-07-26 10:03:57", updated_at: "2019-07-26 10:03:57", password_digest: "$2a$12$pbYTT6aNzZj7Be26vSbtRuqAA59NgPEY5QAA34BMYpk...">

2. オブジェクトが検索できたら、名前を新しい文字列に置き換え、saveメソッドで更新してみてください。うまくいきませんね...、なぜうまくいかなかったのでしょうか?

>> user.name = "Mike Hardin"
=> "Mike Hardin"
>> user.save
   (0.1ms)  begin transaction
  User Exists (0.2ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) AND "users"."id" != ? LIMIT ?  [["email", "mhartl@example.com"], ["id", 1], ["LIMIT", 1]]
   (0.1ms)  rollback transaction
=> false

保存に失敗してしまいます。 userオブジェクトのエラー内容を確認してみます。

>> user.errors.full_messages
=> ["Password can't be blank", "Password is too short (minimum is 6 characters)"]

userにパスワードが設定されていないため、バリデーションで弾かれています。 ※「User.createメソッドを使ってユーザーを作成したときにパスワードを設定したじゃないか」と思う人もいるかもしれませんが、パスワードはハッシュ化されpassword_digest属性に保存されるため、データベースからユーザーデータを取得しても直接パスワードが得られるわけではありません。今回の場合、userオブジェクトのpassword属性とpassword_confirmation属性は空っぽの状態です。

3. 今度は6.1.5で紹介したテクニックを使って、userの名前を更新してみてください。

update_attributeメソッドを用いて指定の属性を更新します。

>> user.update_attribute(:name, "Mike Hardin")
   (0.1ms)  SAVEPOINT active_record_1
  User Update (0.7ms)  UPDATE "users" SET "name" = ?, "updated_at" = ? WHERE "users"."id" = ?  [["name", "Mike Hardin"], ["updated_at", "2019-07-28 15:53:42.066320"], ["id", 1]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true

無事更新することができました。

6.4 最後に

6.4.1 本章のまとめ

演習なし。