has_manyで関連づけられたモデルも一括保存する方法

Nested Attributesによって, has_manyで関連づけられたモデルのデータも丸ごと保存する方法を紹介します.

モデル例

本記事では例として以下のようなモデルを扱うことにします.

class User < ApplicationRecord
  has_many :posts
end

class Post < ApplicationRecord
  belongs_to :user
end

実現したいこと

やりたいことは, UserといくつかのPostをフォームから同時に登録することです. もう少しだけ正確に言うと, Userの登録時に, いくつかのPostもまとめて登録できるようにします. つまりメインのデータはUserということです.

では実装すべきコードを順に見ていきましょう.

モデルの実装コード

以下のようにUserモデルに1行追加します.

class User < ApplicationRecord
  has_many :posts
  accepts_nested_attributes_for :posts
end

これで, Postも一緒に保存するための受け入れ体制ができました.

またPostモデルからは, (もし書いていたら)以下のバリデーション を取り除きます.

class Post < ApplicationRecord
  belongs_to :user
  # validates :user_id, presence: true
end

当然湧いてくるであろう疑問

  • なぜこのバリデーションを除く必要があるのか
  • バリデーションはどのように定義すればいいのか

については最後に説明しますのでご心配なく.

コントローラの実装コード

次はコントローラです.

class UsersController < ApplicationController
  def new
    @uesr = User.new
    2.times { @user.posts.build }
  end 
  
  def create
    @user = User.new(user_params)
    if @user.save
      flash[:success] = 'User created!'
      redirect_to root_url
    else
      render 'new'
    end
  end
  
  private
    
    def user_params
      params.require(:user).permit(:name, :age, posts_attributes: [:title, :body])
    end
end

細かいところは仕様によって変わるでしょうが, 最低限上記のような実装は必要になると思います. 詳しく見ていきましょう.

newアクション

newアクションでのポイントは以下のコードですね.

2.times { @user.posts.build }

単にUser.newで終わらせるのではなく, 空のPostオブジェクトもbuildしておく必要があります. 今回は例としてPostを2つ作成しましたが, ここは仕様に左右されるところでしょう.

Strong Parameters

Postモデルも保存するわけですから, Strong Parametersにおいてこれを許可しておかなくてはなりません. 書き方は以下の通りです.

posts_attributes: [:title, :body]

attributesは複数形です. また今回はUser has many Postsという関係ですから, postsも複数形となります.

createアクション

ここまでできていれば, Postオブジェクト込みのUserオブジェクトを作成することは簡単です.

User.new(user_params)

Userオブジェクト単体で作成するときと同じですね.

コントローラはどんな形でパラメータを受け取るのか

モデルとコントローラが実装できれば, integration testを書くことができます. そのためには, コントローラがどんな形でパラメータを受け取るのかを知る必要がありますね. その答えは以下の通りです.

params: { user: {
  name: 'Ruby on Rails',
  age: 14,
  posts_attributes: [
    { title: 'hey', body: 'Lorem ipsum' },
    { title: 'hi', body: 'Thank you!'}
  ]
} }

ビューの実装コード

フォームのコード例です.

<%= form_for @user do |f| %>
  <%= f.label :name %>
  <%= f.text_field :name %>
  
  <%= f.label :age %>
  <%= f.number_field :age %>
  
  <%= f.fields_for :posts do |post_fields| %>
    <%= post_fields.label :title %>
    <%= post_fields.text_field :title %>
    
    <%= post_fields.label :body %>
    <%= post_fields.text_field :body %>
  <% end %>
  
  <%= f.submit "Submit" %>
<% end %>

簡単ですね! コントローラで2.timesとしているので, 自動的にPost用のフォームが2つ作られます.

Postモデルのバリデーション

はじめにPostモデルから以下のバリデーションを取り除きました.

validates :user_id, presence: true

これについて深掘りしていきます.

なぜこのバリデーションを取り除く必要があるのか

accepts_nested_attributes_forにより子モデルであるPostもまとめて保存できるようにした場合, 以下の流れで処理が進みます.

  • Userのバリデーション
  • Postのバリデーション
  • Userの保存処理
  • Postの保存処理

これは以下の記事を参考にしました. Rails5でnested attributesに詰まった話 - ウェブエンジニア珍道中

この記事にもあるように上のバリデーションを適用してしまうと,

Postのバリデーションの段階で, 未だ作られていないUserオブジェクトのidを要求してしまうため, 必ずバリデーション失敗する.

ということが起きてしまうわけです.

どのようにバリデーションを定義すべきか

失敗してしまうからといって, user_idの存在性を諦める必要はありません. ちゃんと別の方法が用意されています. 各モデルを以下のように編集しましょう.

class User < ApplicationRecord
  has_many :posts, inverse_of :user
  accepts_nested_attributes_for :posts
end

class Post < ApplicationRecord
  belongs_to :user, inverse_of :posts
  validates_presence_of :user
end

これで一括保存とバリデーションを両立することができます.

Railsチュートリアル 第14章

14.1 Relationshipモデル

14.1.1

マイグレーションした際にschema.rbに反映されませんでした. しかしバッファにはなんのエラーも出ませんでした. ちょっと調べてみましょう.

以下のコマンドでデータベースに接続できます.

$ rails db

私のようにDockerで環境構築している場合は次のようになるでしょう. ちなみに私はMySQLRailsとは別のコンテナで起動させています.

$ docker-compose exec web rails db

するとパスワードが求められるので, database.ymlで指定したパスワードを入力します. すると以下のようなプロンプトが表示されます.

MySQL [myapp_development]>

myapp_developmentはデータベース名ですね. マイグレーションしたテーブルが存在するかを確認してみます.

MySQL [myapp_development]> show tables;
+-----------------------------+
| Tables_in_myapp_development |
+-----------------------------+
| ar_internal_metadata        |
| microposts                  |
| relationships               |
| schema_migrations           |
| users                       |
+-----------------------------+
5 rows in set (0.01 sec)

しかしschema.rbに反映されていないのはおかしいので, マイグレーションをやりなおしましょう.

MySQL [myapp_development]> exit
Bye
$ docker-compose exec web rails db:rollback
$ docker-compose exec web rails db:migrate

すると以下のようなエラーが出ました.

Mysql2::Error: Table 'relationships' already exists

そもそも最初のマイグレーションがうまくいってないから, ロールバックによってテーブルが削除されなかったようですね. では手動でテーブルを削除しましょう.

$ docker-compose exec web rails db
MySQL [myapp_development]> drop table relationships;
Query OK, 0 rows affected (0.02 sec)
MySQL [myapp_development]> exit
Bye

ここでもう一度マイグレーションをしたところ, ちゃんとschema.rbに反映されました.

演習1

答えが問題文中に書かれていますね. 図14.7を見ると以下の配列が得られることがわかります.

[2, 7, 10, 8]

演習2

idが2であるユーザーはidが1のユーザーしかフォローしていません. したがってこのユーザーだけからなる配列が得られます.

したがってuser.followind.map(&:id)[1]に等しいですね.

14.1.2

演習1, 2

省略.

14.1.3

演習1

たしかにバリデーションを解除してもテストは成功しました.

14.1.4

演習1, 2

以下の通りです.

>> first = User.first
>> second = User.second

>> first.following?(second)
    SELECT  1 AS one FROM `users` INNER JOIN `relationships` ON `users`.`id` = `relationships`.`followed_id` WHERE `relationships`.`follower_id` = 1 AND `users`.`id` = 2 LIMIT 1
=> false

>> first.follow(second)
    INSERT INTO `relationships` (`follower_id`, `followed_id`, `created_at`, `updated_at`) VALUES (1, 2, '2018-07-05 15:19:20', '2018-07-05 15:19:20')

>> first.following?(second)
=> true

>> first.unfollow(second)
    DELETE FROM `relationships` WHERE `relationships`.`id` = 1

>> first.following?(second)
=> false

14.1.5

演習1

>> user = User.first
>> second = User.second
>> last = User.last

>> second.follow(user)
>> last.follow(user)

>> user.followers.map(&:id)
    SELECT `users`.* FROM `users` INNER JOIN `relationships` ON `users`.`id` = `relationships`.`follower_id` WHERE `relationships`.`followed_id` = 1
=> [2, 100]

演習2, 3

前者はデータベースの中で数えていますが, 後者はRubyの配列として数えています. したがって前者のほうがパフォーマンスが高いですね.

user.followers.count
    SELECT COUNT(*) FROM `users` INNER JOIN `relationships` ON `users`.`id` = `relationships`.`follower_id` WHERE `relationships`.`followed_id` = 1
=> 2

>> user.followers.to_a.count
=> 2

14.2 [follow]のWebインターフェース

14.2.1

演習1, 2

>> user = User.first
>> user.following.count
=> 49
>> user.followers.count
=> 38

14.2.2

演習1, 2

省略.

演習3

プロフィールテストに書きました.

# test/integration/users_profile_test.rb

test "stats" do
    log_in_as(@user)
    get root_path
    assert_select '#following', text: "#{@user.following.count}"
    assert_select '#followers', text: "#{@user.followers.count}"

    get user_path(@user)
    assert_select '#following', text: "#{@user.following.count}"
    assert_select '#followers', text: "#{@user.followers.count}"
end

以下のように数字のままだとダメでした.

assert_select '#following', text: @user.following.count

14.2.3

演習1, 2

省略.

14.2.4

演習1

省略.

演習2

Follow, Unfollowどちらを押しても, users/showが描画されています.

Rendered users/show.html.erb within layouts/application

14.2.5

演習1

Safariの開発メニューからJavascriptを無効にした場合もちゃんと動作しました.

演習2

省略.

14.2.6

演習1, 2

省略.

14.3 ステータスフィード

14.3.1

演習1

新しい順に取得するので, 以下のようになるでしょう.

user.feed.map(&:id)
=> [10, 9, 7, 5, 4, 2, 1]

14.3.2

演習1

feedメソッドを以下のように変更すればいいですね.

def feed
    Micropost.where("user_id IN (?)", following_ids)
end

この場合はmichael自身の投稿を確認するテストで失敗します.

演習2

これは13章で実装した通りですね.

def feed
    Micropost.where("user_id = ?", id)
end

するとlanaの投稿を確認するテストで失敗します.

演習3

feedメソッドを以下のように変更すればいいですね.

def feed
    Micropost.all
end

するとarcherの投稿を確認するテストで失敗します.

14.3.3

演習1

以下の通りです.

test "feed on Home page" do
    get root_path
    @user.feed.paginate(page: 1) do |micropost|
        assert_match CGI.escapeHTML(micropost.content), response.body
    end
end

演習2

ヒントから, 以下のmicropostがまずいんでしょうが, よくわかりませんでした.

tone:
  content: "I'm sorry. Your words made sense, but your sarcastic tone did not."
  created_at: <%= 10.minutes.ago %>
  user: lana

Railsチュートリアル 第13章

13.1 Micropostモデル

13.1.1

演習1

インスタンスを作成しただけなので, マジックカラムの中身はnilですね.

>> micropost = Micropost.new(content: "Lorem ipsum", user_id: User.first.id)
>> micropost.created_at
=> nil

演習2

関連付けがされているモデル名がアクセサになっているんですね.

>> micropost.user
  User Load (0.5ms)  SELECT  `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", (省略)>

>> micropost.user.name
=> "Example User"

演習3

データベースに保存すれば, そこでタイムスタンプが押されます.

>> micropost.save
>> micropost.created_at
=> Wed, 04 Jul 2018 04:11:06 UTC +00:00

そういえばこの場合はリロードしなくていいんですね.

13.1.2

演習1

>> micropost = Micropost.new
>> micropost.valid?
=> false
>> micropost.errors.full_messages
=> ["User must exist", "User can't be blank", "Content can't be blank"]

演習2

>> micropost.content = "a"*141
>> micropost.valid?
=> false
>> micropost.errors.full_messages
=> ["User must exist", "User can't be blank", "Content is too long (maximum is 140 characters)"]

13.1.3

演習1

マイクロポストが保存されました.

>> user = User.first

>> micropost = user.microposts.create(content: "Lorem ipsum")
   (0.3ms)  BEGIN
  Micropost Create (0.5ms)  INSERT INTO `microposts` (`content`, `user_id`, `created_at`, `updated_at`) VALUES ('Lorem ipsum', 1, '2018-07-04 04:44:53', '2018-07-04 04:44:53')
   (0.8ms)  COMMIT
=> #<Micropost id: 2, content: "Lorem ipsum", user_id: 1, created_at: "2018-07-04 04:44:53", updated_at: "2018-07-04 04:44:53">

演習2

検索する場合はidを渡さなくてはなりません.

>> user.microposts.find(micropost.id)
=> #<Micropost id: 2, content: "Lorem ipsum", user_id: 1, created_at: "2018-07-04 04:44:53", updated_at: "2018-07-04 04:44:53">

>> user.microposts.find(micropost)
ArgumentError (You are passing an instance of ActiveRecord::Base to `find`. Please pass the id of the object by calling `.id`.)

演習3

13.1.2の演習ですでにUser.firstを使ってしまったので, 最後のマイクロポストを取得して比較しました.

>> user == micropost.user
=> true
>> user.microposts.last == micropos
=> true

13.1.4

演習1, 2

以下のようになりました.

>> Micropost.first.created_at
  Micropost Load (2.6ms)  SELECT  `microposts`.* FROM `microposts` ORDER BY `microposts`.`created_at` DESC LIMIT 1
=> Wed, 04 Jul 2018 04:44:53 UTC +00:00

>> Micropost.last.created_at
  Micropost Load (0.8ms)  SELECT  `microposts`.* FROM `microposts` ORDER BY `microposts`.`created_at` ASC LIMIT 1
=> Wed, 04 Jul 2018 04:11:06 UTC +00:00

演習3

ユーザーに紐づけられたマイクロポストの配列を取得する際も, 降順で検索されていることがわかります.

>> user = User.first
>> user.microposts
  Micropost Load (0.5ms)  SELECT  `microposts`.* FROM `microposts` WHERE `microposts`.`user_id` = 1 ORDER BY `microposts`.`created_at` DESC LIMIT 11

ユーザーを削除すると, マイクロポストも削除されます.

>> user.destroy

>> Micropost.find(1)ActiveRecord::RecordNotFound (Couldn't find Micropost with 'id'=1)

13.2 マイクロポストを表示する

13.2.1

1つのマイクロポストを表示するパーシャルには, micropostというローカル変数が使われています.

# app/views/microposts/_micropost.html.erb

<li id="micropost-<%= micropost.id %>">
  <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
  <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
  <span class="content"><%= micropost.content %></span>
  <span class="timestamp">
    Posted <%= time_ago_in_words(micropost.created_at) %> ago.
  </span>
</li>

ではこのローカル変数はどこで定義されているのでしょうか. RailsガイドAPIを見ると, 以下はすべて等価であることがわかります. ただしインスタンス変数@customerは, Customerモデルのインスタンスであることとします.

<%= render @customer %>
<%= render partial: "customer", object: @customer %>
<%= render partial: "customer", locals: { customer: @customer } %>

これらはapp/views/customers/_customer.html.erbレンダリングします. またこのパーシャル内ではパーシャル名の先頭からアンダースコアを除いたローカル変数が利用できます. つまりcustomerというローカル変数が定義されるということです. この場合は

customer = @customer

のような定義がなされます.

Micropostの場合がまさにこの例の通りでしたね. 命名規則によって, シンプルに書くことができるようになっています.

演習1

いろいろ試してみました.

>> helper.time_ago_in_words(3.seconds.ago)
=> "less than a minute"
>> helper.time_ago_in_words(3.minutes.ago)
=> "3 minutes"
>> helper.time_ago_in_words(3.hours.ago)
=> "about 3 hours"
>> helper.time_ago_in_words(3.days.ago) 
=> "3 days"
>> helper.time_ago_in_words(3.weeks.ago)  
=> "21 days"
>> helper.time_ago_in_words(3.months.ago)
=> "3 months"
>> helper.time_ago_in_words(3.years.ago)
=> "about 3 years"

演習2

単数が複数かによって多少挙動が異なりますね.

>> helper.time_ago_in_words(1.second.ago)
=> "less than a minute"
>> helper.time_ago_in_words(1.minute.ago)  
=> "1 minute"
>> helper.time_ago_in_words(1.hour.ago)
=> "about 1 hour"
>> helper.time_ago_in_words(1.day.ago) 
=> "1 day"
>> helper.time_ago_in_words(1.week.ago)  
=> "7 days"
>> helper.time_ago_in_words(1.month.ago)
=> "about 1 month"
>> helper.time_ago_in_words(1.year.ago)
=> "about 1 year"

演習3

以下の通りです.

>> microposts = User.first.microposts.paginate(page: nil)
>> microposts.class
=> Micropost::ActiveRecord_AssociationRelation

直接取得するとこうなります.

>> m = Micropost.all
>> m.class
=> Micropost::ActiveRecord_Relation

13.2.2

演習1, 2

>> (1..10).to_a.take(6)
=> [1, 2, 3, 4, 5, 6]
>> (1..10).take(6)
=> [1, 2, 3, 4, 5, 6]

演習3

>> Faker::University.name
=> "The Harris College"
>> Faker::University.name
=> "West Bergstrom Institute"

>> Faker::PhoneNumber.phone_number
=> "1-252-219-7186"
>> Faker::PhoneNumber.cell_phone
=> "1-248-219-0040"

>> Faker::Hipster.words(4)
=> ["DIY", "knausgaard", "kitsch", "pitchfork"]
>> Faker::Hipster.sentence 
=> "Poutine butcher salvia green juice vhs biodiesel."

>> Faker::ChuckNorris.fact
=> "The only pattern Chuck Norris knows is God Object."
>> Faker::ChuckNorris.fact
=> "Chuck Norris doesn't need a java compiler, he goes straight to .war"

他にも多種多様なFakerが用意されています. ドキュメントを読むだけでも楽しいですね.

13.2.3

演習1, 2

簡単なので省略します.

13.3 マイクロポストを操作する

13.3.1

演習1

ブログ等を見てみると, 単にDRYに反するから, という解答でいいんですね. メソッドのオーバーライドという仕組みがあるので, コード自体に不備があるというわけではないですね. DRYに反していることが不備なのかもしれませんが...

13.3.2

エラーメッセージパーシャルにオブジェクトを渡すために,

object: f.object

という書き方が見られます. でもおかしく感じる点がありますね. objectキーワードは13.2.1でも登場しましたが, その時とは異なる点があります. それはパーシャル内におけるローカル変数の名前です.

たとえば

render 'micropost', object: @new_micropost

のような書き方をすれば, パーシャル内に定義されるローカル変数はmicropostです.

しかしエラーメッセージパーシャルの中では, objectという名前の変数を使っていますね.

<% if object.errors.any? %>
    <div id="error_explanation">
        <div class="alert alert-danger">
            The form contains <%= pluralize(object.errors.count, "error") %>.
        </div>
        <ul>
            <% object.errors.full_messages.each do |msg| %>
                <li><%= msg %></li> 
            <% end %>
        </ul>
    </div>
<% end %>

パーシャル名と同じ名前のローカル変数は定義されていないのでしょうか. では実験してみましょう. printデバッグのような方法を取りましょう. エラーメッセージパーシャルの中に, 以下の一行を加えます.

<%= p error_messages %>

パーシャル名と同名の変数の中身を見てやろうということです. これで不正な値をフォームから送ろうと思ってページ遷移したところ, エラーが出ました.

undefined local variable or method `error_messages'

やっぱりダメでしたね.

ということはrenderメソッドの挙動をまだよく理解できていないということです. 詳しくはまた今度調べます.

演習1

以下のようにパーシャルを作成しました.

# app/views/static_pages/home.html.erb 

<% if logged_in? %>
    <%= render 'home_logged_in' %>
<% else %>
    <%= render 'welcome' %>
<% end %>
# app/views/static_pages/_welcome.html.erb

<div class="center jumbotron">
  <h1>Welcome to the Sample App</h1>

  <h2>
    This is the home page for the 
    <a href="https://railstutorial.jp/">Ruby on Rails Tutorial</a> 
    sample application.
  </h2>

  <%= link_to "Sign up now!", signup_path, class: "btn btn-lg btn-primary" %>
</div>

<%= link_to image_tag("rails.png", alt: "Rails logo"), 'http://rubyonrails.org/' %>
# app/views/static_pages/_home_logged_in.html.erb

<div class="row">
    <aside class="col-md-4">
        <section class="user_info">
            <%= render 'shared/user_info' %>
        </section>
        <section class="micropost_form">
            <%= render 'shared/micropost_form' %>
        </section>
    </aside>
</div>

13.3.3

演習1

以下の通りです.

INSERT INTO `microposts` (`content`, `user_id`, `created_at`, `updated_at`) VALUES ('Good morning.', 1, '2018-07-05 01:07:23', '2018-07-05 01:07:23')

演習2

すべて等しいです.

>> Micropost.where("user_id=?", user.id) == user.microposts
=> true
>> Micropost.where("user_id=?", user.id) == user.feed
=> true

13.3.4

演習1, 2

省略.

13.3.5

演習1

無効な送信に対しては, 以下をコメントアウトしました.

# app/models/micropost.rb

class Micropost < ApplicationRecord
    belongs_to :user

    default_scope -> { order(created_at: :desc) }
    validates :user_id, presence: true
#    validates :content, presence: true, length: { maximum: 140 }
end

たしかにテストは失敗します.

有効な送信に対しては, 以下をコメントアウトしました.

# app/controllers/microposts_controller.rb

    def create
        @micropost = current_user.microposts.build(micropost_params)
#        if @micropost.save
#            flash[:success] = "Micropost created!"
#            redirect_to root_url
#        else
#            @feed_items = []
#            render 'static_pages/home'
#        end
    end

たしかにテストは失敗します.

投稿の削除に対しては, 以下をコメントアウトしました.

# app/controllers/microposts_controller.rb

def destroy
    #@micropost.destroy
    flash[:success] = "Micropost deleted"
    redirect_back(fallback_location: root_url)
end

確かにテストは失敗します.

異なるユーザのプロフィールに対しては, コメントアウトというか, 以下のように編集しました.

# app/views/microposts/_micropost.html.erb

<li id="micropost-<%= micropost.id %>">
    <%= link_to gravatar_for(micropost.user, size: 50), micropost.user %>
    <span class="user"><%= link_to micropost.user.name, micropost.user %></span>
    <span class="content"><%= micropost.content %></span>
    <span class="timestanp">
        Posted <%= time_ago_in_words(micropost.created_at) %> ago.
        <% if true #current_user?(micropost.user) %>
            <%= link_to "delete", micropost, method: :delete, data: { confirm: "You Sure?" } %>
        <% end %>
    </span>
</li>

確かにテストは失敗します.

演習2

省略.

13.4 マイクロポストの画像投稿

13.4.1

Gemfileを編集したら, Dockerイメージに反映させます.

$ docker-compose build

これでイメージが更新され, 同時にGemもインストールされます. あとはいつも通りコンテナを起動すればOKです.

$ docker-compose up -d

演習1

省略.

演習2

テストコードは以下のように編集しました.

# test/integration/microposts_interface_test.rb

test "micropost interface" do
    log_in_as(@user)
    get root_path
    assert_select 'div.pagination'
    assert_select 'input[type=file]' # 変更点

    # invalid post
    assert_no_difference 'Micropost.count' do
        post microposts_path, params: { micropost: { content: "" } }
    end
    assert_select 'div#error_explanation'

    # valid post
    content = "This micropost really ties the room together."
    picture = fixture_file_upload('test/fixtures/rails.png', 'image/png') # 変更点
    assert_difference 'Micropost.count', 1 do
        post microposts_path, params: { micropost: { content: content, picture: picture } } # 変更点
    end
    assert @user.microposts.first.picture? # 変更点
    assert_not flash.empty?
    assert_redirected_to root_url
    follow_redirect!
    assert_match content, response.body
    
    # 省略
    
end

13.4.2

チュートリアルの中ではextension_white_listというメソッドが使われていますが, 自分のファイルを見ると, extension_whitelistというメソッドでした. アンダースコアの分だけ異なります.

carrierwaveのドキュメントには後者が載っていました. バージョンが上がったことにより名前が変わったんでしょう.

演習1

そもそもそんな大きな画像ねーよと思いましたが, 10mb 画像と検索したら出てきました.

jQueryによるアラートは出ましたが, 投稿自体はできてしまいました. Railsサーバーを再起動したところ, サーバー側のエラーが表示されるようになりました.

演習2

そもそもブラウザからファイルを指定する場合, 許可された拡張子を持つファイルしか選択できませんね.

13.4.3

Debian環境を使っているので, パッケージのインストールは以下のコマンドで行いました. パッケージ名は全て小文字でOKです.

apt-get install -y imagemagick

Dockerイメージに反映させたい場合は, Railsコンテナ用のDockerfileを編集します.

FROM ruby:2.5.1

RUN apt-get update -qq && apt-get -y install \
        vim \
        build-essential \
        libpq-dev \
        nodejs \
        mysql-client \
        imagemagick    # ここを追加!

RUN mkdir /myapp

COPY Gemfile /myapp
COPY Gemfile.lock /myapp

WORKDIR /myapp
RUN gem install bundler && bundle install

ENV COLUMNS=200 LINES=50

DockerfileやGemfileを変更した場合は以下のコマンドを実行します.

$ docker-compose build

あとはいつも通りコンテナを起動すればOKですね.

$ docker-compose up -d

演習1

省略.

演習2

エラー出ない...

rails.pngは小さい画像ですから, 問題なくアップロードされてしまいますよね. だからエラーは出なくて正解な気がしてしまうのですが. とはいえテスト環境ではリサイズが行われないようにしておきます.

13.4.4

この節はやっていません.

Railsチュートリアル 第12章

12.1 PasswordResets リソース

12.1.1

演習1

省略.

演習2

メールにはフルのURLを記載しなくてはなりませんからね.

12.1.2

演習1

APIRailsガイド等を見てみましたが, よくわかりませんでした. 今回の場合は,

という理由しか思い浮かびませんでした.

12.1.3

演習1

UserMailer.password_reserに対し, 以下のエラーが表示されました.

wrong number of arguments (given 1, expected 0)

11章をやっていない場合はまた異なるエラーが出るんでしょうね.

演習2

たしかに演習1で入力したメールアドレスに対応するユーザーには, reset_sent_at属性に値が存在しました.

12.2 パスワード再設定のメール送信

12.2.1

演習1, 2, 3

省略.

12.2.2

演習1

問題なくクリアしました.

演習2

省略.

12.3 パスワードを再設定する

12.3.1

hiddenタグを利用することによって, updateアクションからもメールアドレスがparams[:email]という形で参照できるようになりました. editアクションとupdateアクションにおけるメールアドレスの参照の仕方が統一されたため, before actionをつくることができます.

演習1

問題なく表示されました.

演習2

すでに空っぽのupdateアクションを作っていたので,

No template found for PasswordResetsController#update

というエラーが発生しました.

12.3.2

演習1, 2

省略.

12.3.3

演習1

省略.

演習2

該当箇所は以下のように記述しました.

assert_nil user.reload.reset_digest

演習3

省略.

演習4

updateアクションを以下のように変更しました.

# app/controllers/password_resets_controller.rb

def update
    if params[:user][:password].empty?
        @user.errors.add(:password, :blank)
        render 'edit'
    elsif @user.update_attributes(user_params)
        log_in(@user)
        @user.update_attribute(:reset_digest, nil) # 追加
        flash[:success] = 'Password has been reset'
        redirect_to @user
    else
        render 'edit'
    end
end

12.4 本番環境でのメール送信

この節はやっていません.

Railsチュートリアル 第11章

11.1 AccountActivations リソース

11.1.1

演習1

省略.

演習2

メールにはフルのURLが記載されていないとアクセスできませんからね.

11.1.2

演習1

省略.

演習2

インスタンスを生成するのは面倒ですが, before_createが機能しているかを確かめましょう. create_activation_digestを呼び出した際のログは一部省略しています.

>> user = User.create(
>*   name: "Console Test",
>*   email: "console@test.com",
>*   password: "password",
>*   password_confirmation: "password")

>> user.create_activation_digest
NoMethodError (private method `create_activation_digest' called for #<User:0x00007fbb70839d60>)

>> user.activation_digest
=> "$2a$10$olzQ9tgFiKu/mrHwP/D5nO8XVyiz1TJ9dSVKO1.lvkc9EAdnSL6cK"

はじめbefore_createの機能を勘違いしていました. Rubyとしてのオブジェクトを作成する前に実行されるものだと思っていました. だから上の演習もUser.newを実行して確認しようとしていました.

しかしここでいうcreateは, データベース操作CRUDのうちのひとつのことだったんですね. だからUser.newではactivation_tokenが作成されませんでした.

11.2 アカウント有効化のメール送信

11.2.1

演習1

以下のようになりました.

>> CGI.escape("Don't panic!")
=> "Don%27t+panic%21"

11.2.2

config/environments/development.rbを編集したあとの再起動は以下のコマンドで行いました.

$ docker-compose restart

ちなみに再起動する前にメールプレビューを見てみましたが, エラーが出ました.

演習1

Date欄には以下の内容が表示されました.

Mon, 02 Jul 2018 04:59:25 +0000

Localeを変更しても特に変化はありませんでしたね.

11.2.3

演習1, 2

省略.

11.2.4

演習1

Railsサーバーが起動しているコンテナの標準入出力をローカルマシンのターミナルにアタッチします.

$ docker attach <container id or name>

この状態で新規ユーザー登録をすると, メール送信のログを見ることができます.

終わったら

Ctrl+P, Ctrl+Q

を押してデタッチをしましょう. 間違ってCtrl+D等でコンテナを抜け, コンテナが落ちてしまった場合はこちらを参考にしてください.

演習2

省略.

11.3 アカウントを有効化する

11.3.1

演習1, 2

こんな感じです.

>> user = User.create(
>*   name: "Activation Test",
>*   email: "activation@test.com",
>*   password: "password",
>*   password_confirmation: "password")

>> token = user.activation_token
=> "Pr4fTMDEgwnsh4rTUSsQiA"
>> user.authenticated?(:activation, token)
=> true

11.3.2

演習1, 2

省略.

11.3.3

演習1, 2

簡単なので省略.

演習3

アクティベートされていないユーザーをfixture内に作成しました.

# test/fixtures/users.yml

non_activated:
  name: Non Activated
  email: non_active@example.com
  password_digest: <%= User.digest('password') %>

統合テスト用の新しいクラスを作成すべきなのか, 不勉強なため判断がつきませんでした. すくなくともusers/indexページには関連したテストなので, users_index_test.rbに書くことにしました.

# test/integration/users_index_test.rb

def setup
    @admin = users(:michael)
    @non_admin = users(:archer)
    @non_activated = users(:non_activated)
end

test "should not show non-activated user" do
    log_in_as(@admin)
    get users_path
    assert_select 'a[href=?]', user_path(@non_activated), count: 0

    get user_path(@non_activated)
    assert_redirected_to root_url
end

11.4 本番環境でのメール送信

この節はやっていません.

Railsチュートリアル 第10章

10.1 ユーザーを表示する

10.1.1

演習1

省略.

演習2

new.html.erbedit.html.etbのフォームはほとんど同じ形をしているから, パーシャルでまとめてしまおうということですね. ほとんど同じとはいっても異なる点が2箇所あります. それがボタンのテキストと, フォームのアクション属性です. これらを考慮しながらパーシャルを以下のように作りました.

# app/views/users/_form.html.erb

<%= form_for(@user, url: yield(:form_action)) do |f| %>
    <%= render 'shared/error_messages' %> # 気になった点その1

    <%= f.label :name %>
    <%= f.text_field :name, class: 'form-control' %>

    <%= f.label :email %>
    <%= f.email_field :email, class: 'form-control' %>

    <%= f.label :password %>
    <%= f.password_field :password, class: 'form-control' %>

    <%= f.label :password_confirmation, "Confirmation" %>
    <%= f.password_field :password_confirmation, class: 'form-control' %>

    <%= f.submit yield(:button_text), class: 'btn btn-primary' %>
<% end %>
# app/views/users/new.html.erb 

<% provide(:title, "Sign up") %>
<% provide(:button_text, 'Create my account') %>
<% provide(:form_action, signup_path) %>
<h1>Sign up</h1>

<div class="row">
    <div class="col-md-6 col-md-offset-3">
        <%= render 'form' %>
    </div>
</div>
# app/views/users/edit.html.erb  

<% provide(:title, "Edit user") %>
<% provide(:button_text, 'Save changes') %>
<% provide(:form_action, user_path) %> # 気になった点その2

<h1>Update your profile</h1>

<div class="row">
    <div class="col-md-6 col-md-offset-3">
        <%= render 'form' %>
        <div class="gravatar_edit">
            <%= gravatar_for @user %>
            <a href="http://gravatar.com/emails" target="_blank" rel="noopener">Change</a>
        </div>
    </div>
</div>

ただしいくつか気になった点があったのでそれらを掘り下げていくことにします.

気になった点その1

気になった箇所はコード内にコメントしておきました. この行の内容は, チュートリアルの見本コードとは異なります. なぜかというと不要ではないかと感じたからです. まずチュートリアルのコードを提示しましょう.

<%= render 'shared/error_messages', object: @user%>

renderメソッドにハッシュが渡されているのがわかりますね. これが必要かどうかを議論するには, renderメソッドの使い方について理解しなければなりません. 詳しくはAPI)を見ていただきたいのですが, ここでは上のコードにフォーカスして説明します.

renderメソッドには, パーシャルに変数を渡す方法が用意されています. つまりテンプレート内で使用可能な変数を, パーシャル内の変数に割り当てることができます. その方法のうちのひとつが, object:という記法です. Rubyではすべてがオブジェクトですから, :objectというキーが使われているんだと思います.

見ての通り@userというUserオブジェクトをパーシャルに渡していることがわかります. ではパーシャル内ではどのような変数名でこのオブジェクトにアクセスできるのでしょうか.

たとえば

object: @user, as: 'user'

のように書けば, パーシャル内では変数userUserオブジェクトが格納されることになります. つまりパーシャル内の変数名をas:によって指定できるわけですね.

しかしチュートリアルではこの指定が無いのでデフォルトの変数名が指定されます. それはパーシャル名です. ここで言えばerror_messagesですね.

renderメソッドの説明が済んだので結論です. チュートリアルのコードでは, パーシャル_error_messages.html.erb内において, オブジェクト@userが変数error_messagesに格納されるようになっています. しかし変数error_messagesは使われていません. そのかわり直接インスタンス変数@userを参照しています. したがってobject: @userは不要であると考えられます.

気になった点その2

これも気になった箇所はコード内にコメントしておきました. この行ははじめ以下のように書きました.

<% provide(:form_action, user_path(@user)) %>

表7.1を見ても, このような記法がなされています. しかし同じ演習問題の解答を載せているブログ等を見てみると,

<% provide(:form_action, user_path) %>

のように書かれています. Userオブジェクトがなくても正しいURLになるのか不安でした.

このコードでよい理由はおそらく, パーシャルを適用させたテンプレートがこのようになるからでしょうね.

form_for(@user, url: user_path)

form_forメソッドの第一引数に@userを渡している時点で, ユーザーID等の情報がフォームに伝わっています. だからuser_path@userを渡さなくとも, 正しいURLを生成できるということなのでしょう.

10.1.2

省略.

10.1.3

演習1

以下の一行を追加しました.

# test/integration/users_edit_test.rb

assert_select "div.alert", "The form contains 4 errors."

10.1.4

省略.

10.2 認可

10.2.1

beforeフィルターを次のように定義しています.

def logged_in_user
    unless logged_in?
       flash[:danger] = "Please log in."
       redirect_to login_url
    end
end

login_pathではなく, login_urlとしているのがポイントですね. これについては第5章に書かれています.

なお、Railsチュートリアルでは一般的な規約に従い、基本的には_path書式を使い、リダイレクトの場合のみ_url書式を使うようにします。これはHTTPの標準としては、リダイレクトのときに完全なURLが要求されるためです。

演習1

beforeフィルターが全コントローラに適用された場合は, SignupページやLoginページにアクセスできなくなりますから, 多くのテストが失敗しますね.

10.2.2

演習1

調べてみると, curlコマンドでPATCHメソッドのHTTPリクエストを送れるようですね. ということは, フォーム画面=editアクションを経由することなく, いきなりupdateアクションにデータを送信できるということです. だからupdateアクションに対してもbeforeフィルターが必要ということですね.

演習2

ブラウザで確認しやすいのはeditアクションのほうですね.

10.2.3

フレンドリーフォワーディングのテストは過剰な気がします.

  • 編集画面にアクセス
  • ログイン画面にリダイレクト
  • ログイン
  • さっきの編集画面にリダイレクト

という流れをチェックするためのテストなので, データが正しく更新されたかどうかをチェックする部分は不要なのではないでしょうか. 更新がうまくいくかのテストはすでに書いているわけですしね. もちろん手厚いテストを書いておけばいいのはわかりますが, なくてはならないのかが気になります.

演習1

はじめはsuccessful edit with friendly forwardingテストの最後に以下を追加することを考えました.

delete logout_path
get login_path
assert_nil session[:forwarding_url]

つまり一旦ログアウトして, またログインするときにはデフォルトページにリダイレクトされるということを確認しています. しかし本文を見返してみると, この演習について以下のように書かれています.

session.delete(:forwarding_url)という行を通して転送用のURLを削除している点にも注意してください。これをやっておかないと、次回ログインしたときに保護されたページに転送されてしまい、ブラウザを閉じるまでこれが繰り返されてしまいます (このコードのテストは10.2.3.1の演習に回します)。

というわけで, ちゃんとsessionの内容がデリートされているかをチェックすればいいんですね. チュートリアルをこなしているブログを参考にすると, 以下のようなテストがよさそうです. sessionの中身をログインの前後で確認しています.

test "successful edit with friendly forwarding" do
    get edit_user_path(@user)
    assert_equal edit_user_url(@user), session[:forwarding_url] #追加
    log_in_as(@user)
    assert_nil session[:forwarding_url] #追加
    assert_redirected_to edit_user_url(@user)
    name = "Foo Bar"
    email = "foo@bar.com"
    patch user_path(@user), params: { user: {
        name: name,
        email: email
    }}
    assert_not flash.empty?
    assert_redirected_to @user
    @user.reload
    assert_equal name, @user.name
    assert_equal email, @user.email
end

演習2

session[:forwarding_url]request.get?はそれぞれ期待通りの値が返ってきました.

(byebug) session[:forwarding_url]
"http://localhost/users/1/edit"

(byebug) request.get?
true

せっかくだから他のことも調べておこうと思ったら...あれ?

(byebug) request.original_url
"http://localhost/login"

session[:forwarding_url]の内容と異なりますね. つまりログインページにリダイレクトした際にRequestオブジェクトが更新されたということです. APIを参考にこのRequestオブジェクトを少し調べてみましょう.

(byebug) request.class
ActionDispatch::Request

(byebug) request.controller_class
SessionsController

(byebug) request.request_parameters
{}

これだけやればこの演習はよいでしょう.

10.3 すべてのユーザーを表示する

10.3.1

演習1

以下のように書いてみました.

def setup
    @user = users(:michael)
end

test "layout links when logged in" do
    log_in_as(@user)        
    get root_path
    assert_select "a[href=?]", root_path, count: 2
    assert_select "a[href=?]", help_path
    assert_select "a[href=?]", about_path
    assert_select "a[href=?]", contact_path
    assert_select "a[href=?]", user_path(@user)
    assert_select "a[href=?]", edit_user_path(@user)
    assert_select "a[href=?]", logout_path
end

10.3.2

rails dbコマンドに時間は全くかかりませんでした.

演習1

Example Userでログインした状態でusers/2/editへアクセスすると, トップページにリダイレクトされました.

10.3.3

演習1

省略.

演習2

どちらもUsers::ActiveRecord_Relationクラスでした.

10.3.4

演習1

Redにならないぞと思ったら, コメントアウト#でやってました. 正しくコメントアウトしたところ, テストは失敗しました.

演習2

<div class="pagination">が2つあることを確認するために, count: 2を書き加えました.

test "index including pagination" do
    log_in_as(@user)
    get users_path
    assert_template 'users/index'
    assert_select 'div.pagination', count: 2  # 追加
    User.paginate(page: 1).each do |user|
        assert_select 'a[href=?]', user_path(user), text: user.name
    end
end

10.3.5

演習1

省略.

10.4 ユーザーを削除する

10.4.1

演習1

以下のように実装しました.

test "should not allow the admin attribute to be edited via the web" do
    log_in_as(@other_user)
    assert_not @other_user.admin?
    patch user_path(@other_user), params: { user: {
        password: @other_user.password,
        password_confirmation: @other_user.password,
        admin: true
    }}
    assert_not @other_user.reload.admin?
end

10.4.2

演習1

以下のコマンドにより, Railsサーバーが稼働しているコンテナの標準入出力を手元のターミナルにアタッチします.

docker attach <container id or name>

この状態でユーザーを削除すると, 以下のようなログが表示されました.

Started DELETE "/users/6" for 172.18.0.1 at 2018-07-01 07:45:23 +0000
Cannot render console from 172.18.0.1! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
Processing by UsersController#destroy as HTML
  Parameters: {"authenticity_token"=>"tBX07b8P1vb9hze5S0SicpXXW67+E235kSq1EkOgpDF4AXJuJmwVI98KZmGquu8Eq8T42/5HkjSBLSor8YarBg==", "id"=>"6"}
  User Load (0.6ms)  SELECT  `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
  ↳ app/helpers/sessions_helper.rb:18
  User Load (0.5ms)  SELECT  `users`.* FROM `users` WHERE `users`.`id` = 6 LIMIT 1
  ↳ app/controllers/users_controller.rb:44
   (0.3ms)  BEGIN
  ↳ app/controllers/users_controller.rb:44
  User Destroy (0.5ms)  DELETE FROM `users` WHERE `users`.`id` = 6
  ↳ app/controllers/users_controller.rb:44
   (1.2ms)  COMMIT
  ↳ app/controllers/users_controller.rb:44
Redirected to http://localhost/users
Completed 302 Found in 15ms (ActiveRecord: 3.0ms)


Started GET "/users" for 172.18.0.1 at 2018-07-01 07:45:23 +0000
Cannot render console from 172.18.0.1! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
Processing by UsersController#index as HTML
  User Load (0.4ms)  SELECT  `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
  ↳ app/helpers/sessions_helper.rb:18
  Rendering users/index.html.erb within layouts/application
   (0.7ms)  SELECT COUNT(*) FROM `users`
  ↳ app/views/users/index.html.erb:4
  User Load (0.4ms)  SELECT  `users`.* FROM `users` LIMIT 30 OFFSET 0
  ↳ app/views/users/index.html.erb:7
  Rendered collection of users/_user.html.erb [30 times] (4.8ms)
  Rendered users/index.html.erb within layouts/application (22.6ms)
  Rendered layouts/_rails_default.html.erb (107.4ms)
  Rendered layouts/_shim.html.erb (0.3ms)
  Rendered layouts/_header.html.erb (1.1ms)
  Rendered layouts/_footer.html.erb (0.4ms)
Completed 200 OK in 194ms (Views: 180.8ms | ActiveRecord: 1.6ms)

10.4.3

演習1

省略.

Railsチュートリアル 第9章

9.1 Remember me 機能

9.1.1

演習1

問題なく動作しました.

演習2

もともとリスト9.4の書き方をしていました. リスト9.5の書き方は省略します.

9.1.2

疑問1 current_userメソッドの定義

第8章では一時セッションにユーザーIDを保存していましたが, 本章ではユーザー情報を永続的に保存するために, クッキーを用いています. そのため, 第8章で定義したcurrent_userメソッドを修正しています. それがこちらです.

def current_user
    if (user_id = session[:user_id])
        @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
        user = User.find_by(id: user_id) 
        if user && user.authenticated?(cookies[:remember_token])
            log_in user
            @current_user = user
        end
    end
end

はじめはsessioncookiesの評価がどうしてこの順番なのかがわかりませんでした.

コードを見た通り, sessionが空かどうかをチェックし, 空であった場合はcookiesが空かどうかのチェックに移っています. この評価の順番が正当化されるということは, 「sessionは空だがcookiesは空ではない」という状況がありえることになります. それはブラウザを閉じたあとのことですね. sessionは短命なのでブラウザを閉じれは消えてしまいます. しかしcookiesは残ったままです. これでこの順番に評価をすることが意味を持つわけですね.

また後に実装するように, ユーザーはRemember me機能を使わないという選択をとれるようになります. したがってユーザーによってはcookiesには値がない場合もあるわけです. それならば必ず利用されるsessionをはじめに評価するほうが合理的ですね.

疑問2 どうしてログアウトできないのか

演習の直前に, テストスイートが失敗することが述べられています. その理由として, ユーザーがログアウトできないからだと書いてあります. 今までに定義したヘルパーメソッド等を思い出しながら, これについて詳しく見ていきましょう.

実際に統合テストを実行すると失敗します. 失敗した箇所を以下に示します.

# test/integration/users_login_test.rb
# login_with_valid_information_followed_by_logout
# ログアウトの処理以降のみを抜粋

delete logout_path
assert_not is_logged_in?
assert_redirected_to root_url
follow_redirect!
assert_select "a[href=?]", login_path   #ここで失敗
assert_select "a[href=?]", logout_path, count: 0
assert_select "a[href=?]", user_path(@user), count: 0

以下テストで実行される流れを書いていきます.

logout_pathdeleteリクエストが飛びます. これによりSessionsControllerdestroyアクションが実行されます.

# SessionsController

def destroy
    log_out
    redirect_to root_url
end

ログアウト処理がなされ, root_urlにリダイレクトしていますね. このログアウト処理はなにをしているかというと, sessionからユーザーIDを消去し, インスタンス変数の@current_userを初期化しています.

# app/helpers/sessions_helper.rb

def log_out
    session.delete(:user_id)
    @current_user = nil
end

テストの続きへ進みます.

テストヘルパーメソッドのis_logged_in?によって, ログインした状態かどうかをチェックしています.

def is_logged_in?
    !session[:user_id].nil?
end

log_outメソッドによりsessionの内容は消去しているので, 当然このアサーションはクリアします. 次です.

destroyアクションの定義内に, リダイレクトするように書かれていますから, redirected_toアサーションもクリアします. 問題は次です.

さて問題はselectアサーションです. これが失敗する理由は本文にもあるようにログアウトできていないからです. つまりログインしてしまっているからです. ちゃんとsessionを消去したのに, 復活してしまっているんですね. その答えがヘッダー内の次の一行にあります.

# app/views/layouts/_header.html.erb

<% if logged_in? %>

ログインしているかどうかによって, ヘッダーの内容を変化させるためのif文です. ではヘルパーメソッドのlogged_in?の定義を見てみましょう.

# app/helpers/sessions_helper.rb

def logged_in?
    !current_user.nil?
end

current_usernilかどうかで, ログインしているかどうかを判断しています. さてcurrent_userメソッドはどんな定義でしたっけ...? 疑問1でも取り上げたように, 以下のような定義でしたね.

def current_user
    if (user_id = session[:user_id])
        @current_user ||= User.find_by(id: user_id)
    elsif (user_id = cookies.signed[:user_id])
        user = User.find_by(id: user_id) 
        if user && user.authenticated?(cookies[:remember_token])
            log_in user
            @current_user = user
        end
    end
end

そろそろ核心に近づいてきました. current_userメソッドでは, sessionが空であった場合はcookiesの中身を見ます. ログアウトしてsession情報を消去しtも, cookiesの中身は残ったままですよね. ということはそのデータをもとにしてユーザーの検索が行われます. そしてauthenticated?が通った場合, log_inメソッドが実行されてしまいます. ここが, session情報の復活点でした. cookie情報が残った状態でヘッダーのレンダリングを行えば, 自然とログインしてしまうようになっていたのです.

演習1, 2

省略.

9.1.3

これでログアウトができますね.

演習1

省略.

9.1.4

細かいnilの扱いは難しいですね...

演習1, 2, 3

省略.

9.2 [Remenber me] チェックボックス

省略.

9.3 [Remember me] のテスト

9.3.1

演習1

sessions_controller.rbcreate内のuserインスタンス変数@userに変えます. するとassignsメソッドでインスタンス変数を参照できるようになるので, より詳細にテストを書くことができますね.

# test/integration/users_login_test.rb

test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_equal cookies['remember_token'] , assigns(:user).remember_token
end

9.3.2

省略.