ユーザーのマイクロポスト
■第13章
Twitterのミニクローンを完成させる。
13.1 Micropostモデル
Micropostリソースの最も本質的な部分を表現するMicropostモデルを作成する。
13.1.1 基本的なモデル
まずはMicropostモデルを生成。
rails generate model Micropost content:text user:references
ApplicationRecordを継承したモデルが作られる。そして、belongs_toのコードも自動的に追加されている。
理由は、user:referencesという引数も含めていたため。
自動生成されたMicropostモデル。
class Micropost < ApplicationRecord
   belongs_to :user
end
Userモデルとの最大の違いはreferences型を利用している点。
これを利用すると、自動的にインデックスと外部キー参照つきのuser_idカラムが追加され、UserとMicropostを関連付けする下準備をしてくれる。
class CreateMicroposts < ActiveRecord::Migration[5.1]
  def change
    create_table :microposts do |t|
      t.text :content
      t.references :user, foreign_key: true
      t.timestamps
    end
    add_index :microposts, [:user_id, :created_at]
  end
end
add_indexによって、作成時刻の逆順で取り出しやすくなる。
13.1.2 Micropostのバリデーション
バリデーションを追加していく。まずはMicropostモデル単体を動くようにしてみます。
setupでは、fixtureのサンプルユーザーと紐づけた新しいマイクロポストを作成している。
次に、マイクロポストが有効であるかどうかをチェックする。
最後に、user_idの存在性のバリデーションに対するテストも追加する。
有効性に対するテスト。
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
  def setup
    @user = users(:michael)
    # このコードは慣習的に正しくない
    @micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
  end
  test "should be valid" do
    assert @micropost.valid?
  end
  test "user id should be present" do
    @micropost.user_id = nil
    assert_not @micropost.valid?
  end
end
setupの中は実は慣習的には正しくないが、後で修正する。
1つ目のテストでは、正常な状態かどうかをテストしている。2つ目のテストでは、user_idが存在しているかどうかをテストしている。このテストをパスせさるために、存在性のバリデーションを追加してみる。
class Micropost < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
end
次に、content属性に対するバリデーションを追加する。140文字より長くないように制限を加える。
テストを作成。
  test "content should be present" do
    @micropost.content = "   "
    assert_not @micropost.valid?
  end
  test "content should be at most 140 characters" do
    @micropost.content = "a" * 141
    assert_not @micropost.valid?
  end
そしてバリデーションを追加。
class Micropost < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end
テストはなぜか手こずりましたが、無事GREENに。
13.1.3 User/Micropostの関連付け
1つのツイートは1人のユーザーのみ持っていて、1人のユーザーは複数のツイートを持ってる。
user.microposts.create
user.microposts.create!
user.microposts.build
これらのメソッドは使うと、紐付いているユーザーを通してマイクロポストを作成することができる。
さっき書いた
@user = users(:michael)
# このコードは慣習的に正しくない
@micropost = Micropost.new(content: "Lorem ipsum", user_id: @user.id)
をこんな風に書くことができる。
@user = users(:michael)
@micropost = @user.microposts.build(content: "Lorem ipsum")
@user.microposts.buildのようなコードを使うためには、 UserモデルとMicropostモデルをそれぞれ更新して、関連付ける必要がある。
Userモデルはhas_many :micropostsと追加する。
class Micropost < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end
class User < ApplicationRecord
  has_many :microposts
  .
  .
  .
end
関連付けできたら、setupメソッドを修正する。
  def setup
    @user = users(:michael)
    @micropost = @user.microposts.build(content: "Lorem ipsum")
  end
テストも無事GREEN。
13.1.4 マイクロポストを改良する
UserとMicropostの関連付けを改良していく。
最も新しいマイクロポストを最初に表示できるようにする。これを実装するためには、default scopeというテクニックを使う。
マイクロポストの順序付けをテスト。
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
  .
  .
  .
  test "order should be most recent first" do
    assert_equal microposts(:most_recent), Micropost.first
  end
end
fixtureファイルも作成しておく。
orange:
  content: "I just ate an orange!"
  created_at: <%= 10.minutes.ago %>
tau_manifesto:
  content: "Check out the @tauday site by @mhartl: http://tauday.com"
  created_at: <%= 3.years.ago %>
cat_video:
  content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk"
  created_at: <%= 2.hours.ago %>
most_recent:
  content: "Writing a short test"
  created_at: <%= Time.zone.now %>
created_atカラムは基本的に手動で更新できないが、fixtureファイルの中では更新可能。
試しにテストしてみるとREDに。
古いバージョンのRailsでは、欲しい振る舞いにするためには生のSQLを書くしか選択肢がなかったが、Rails 4.0からは次のようにRubyの文法でも書けるようになったらしい。
デフォルトスコープを変更する書き方が以下のコード。これによって新規作成順に並ぶ。
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
これでテストをやると無事通る。
ユーザーが削除されたらマイクロポストも削除されるようにする方法は、実はかなり簡単。一行追加するだけ。
class User < ApplicationRecord
  has_many :microposts, dependent: :destroy
  .
  .
  .
end
dependent: :destroyを使うと。持ち主のいないMicropostがデータベース上に残り続けるという問題を解決することができる。
そのテストを追加する。
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 "associated microposts should be destroyed" do
    @user.save
    @user.microposts.create!(content: "Lorem ipsum")
    assert_difference 'Micropost.count', -1 do
      @user.destroy
    end
  end
end
テストもGREENに。
13.2 マイクロポストを表示する
マイクロポストの表示とテスト部分を作る。
twitterのような独立したマイクロポストのindexページは作らずに、ユーザーのshowページで直接マイクロポストを表示してみる。
13.2.1 マイクロポストの描画
一度データベースをリセットし、サンプルデータを再生成しておく。
$ rails db:migrate:reset
$ rails db:seed
Micropostsコントローラを作っておく。
$ rails generate controller Microposts
パーシャルを作る。
<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>
time_ago_in_wordsというヘルパーメソッドで「○分前に投稿」みたいな文字列を出力できる。
ページネーション用にshowで@micropostsを作成する。
class UsersController < ApplicationController
  .
  .
  .
  def show
    @user = User.find(params[:id])
    @microposts = @user.microposts.paginate(page: params[:page])
  end
  .
  .
  .
end
プロフィール画面にマイクロポストを表示させる。
<% provide(:title, @user.name) %>
<div class="row">
  <aside class="col-md-4">
    <section class="user_info">
      <h1>
        <%= gravatar_for @user %>
        <%= @user.name %>
      </h1>
    </section>
  </aside>
  <div class="col-md-8">
    <% if @user.microposts.any? %>
      <h3>Microposts (<%= @user.microposts.count %>)</h3>
      <ol class="microposts">
        <%= render @microposts %>
      </ol>
      <%= will_paginate @microposts %>
    <% end %>
  </div>
</div>
13.2.2 マイクロポストのサンプル
データベースに6人分のマイクロポストを作る。
.
.
.
users = User.order(:created_at).take(6)
50.times do
  content = Faker::Lorem.sentence(5)
  users.each { |user| user.microposts.create!(content: content) }
end
ここでいつものようにサンプルデータを生成。
$ rails db:migrate:reset
$ rails db:seed
あとはCSSの見た目を整えて終わり。
13.2.3 プロフィール画面のマイクロポストをテストする
プロフィール画面で表示されるマイクロポストに対して、統合テストを書いていく。統合テストを作成する。
$ rails generate integration_test users_profile
fixtureを書いていく。
orange:
  content: "I just ate an orange!"
  created_at: <%= 10.minutes.ago %>
  user: michael
tau_manifesto:
  content: "Check out the @tauday site by @mhartl: http://tauday.com"
  created_at: <%= 3.years.ago %>
  user: michael
cat_video:
  content: "Sad cats are sad: http://youtu.be/PKffm2uI4dk"
  created_at: <%= 2.hours.ago %>
  user: michael
most_recent:
  content: "Writing a short test"
  created_at: <%= Time.zone.now %>
  user: michael
<% 30.times do |n| %>
micropost_<%= n %>:
  content: <%= Faker::Lorem.sentence(5) %>
  created_at: <%= 42.days.ago %>
  user: michael
<% end %>
プロフィール画面にアクセスした後に、ページタイトルとユーザー名、Gravatar、マイクロポストの投稿数、そしてページ分割されたマイクロポスト、といった順でテストしていく。
full_titleヘルパーが利用できている。
require 'test_helper'
class UsersProfileTest < ActionDispatch::IntegrationTest
  include ApplicationHelper
  def setup
    @user = users(:michael)
  end
  test "profile display" do
    get user_path(@user)
    assert_template 'users/show'
    assert_select 'title', full_title(@user.name)
    assert_select 'h1', text: @user.name
    assert_select 'h1>img.gravatar'
    assert_match @user.microposts.count.to_s, response.body
    assert_select 'div.pagination'
    @user.microposts.paginate(page: 1).each do |micropost|
      assert_match micropost.content, response.body
    end
  end
end
assert_select 'h1>img.gravatar'
このように書くことで、h1タグの内側にある、gravatarクラス付きのimgタグがあるかどうかをチェックできる。
テストも通りました。
13.3 マイクロポストを操作する
データモデリングとマイクロポスト表示テンプレートの両方が完成。次はWeb経由でそれらを作成するためのインターフェイスに取り掛かる。
最後に、ユーザーがマイクロポストをWeb経由で破棄できるようにする。
マイクロポストリソースのルーティング。
Rails.application.routes.draw do
  root   'static_pages#home'
  get    '/help',    to: 'static_pages#help'
  get    '/about',   to: 'static_pages#about'
  get    '/contact', to: 'static_pages#contact'
  get    '/signup',  to: 'users#new'
  get    '/login',   to: 'sessions#new'
  post   '/login',   to: 'sessions#create'
  delete '/logout',  to: 'sessions#destroy'
  resources :users
  resources :account_activations, only: [:edit]
  resources :password_resets,     only: [:new, :create, :edit, :update]
  resources :microposts,          only: [:create, :destroy]
end
13.3.1 マイクロポストのアクセス制御
関連付けられたユーザーを通してマイクロポストにアクセスするので、createアクションやdestroyアクションをするユーザーはログイン済みでなければならない。
まずはテストを書いていく。
require 'test_helper'
class MicropostsControllerTest < ActionDispatch::IntegrationTest
  def setup
    @micropost = microposts(:orange)
  end
  test "should redirect create when not logged in" do
    assert_no_difference 'Micropost.count' do
      post microposts_path, params: { micropost: { content: "Lorem ipsum" } }
    end
    assert_redirected_to login_url
  end
  test "should redirect destroy when not logged in" do
    assert_no_difference 'Micropost.count' do
      delete micropost_path(@micropost)
    end
    assert_redirected_to login_url
  end
end
このテストをパスするために、少しアプリケーション側のコードをリファクタリングする必要がある。
logged_in_userメソッドがMicropostsコントローラでも必要。各コントローラが継承するApplicationコントローラに、このメソッドを移していく。
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
  private
    # ユーザーのログインを確認する
    def logged_in_user
      unless logged_in?
        store_location
        flash[:danger] = "Please log in."
        redirect_to login_url
      end
    end
end
重複しないように、Usersコントローラからもlogged_in_userを削除しておく。これでlogged_in_userメソッドを呼び出せるようになった。
Micropostsコントローラの各アクションに認可を追加。
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]
  def create
  end
  def destroy
  end
end
テストも無事通りました。
13.3.2 マイクロポストを作成する
マイクロポスト作成画面を作っていく。ユーザーのログイン状況に応じて、ホーム画面の表示を変更することを目標にする。
マイクロポストのcreateアクションを作り始める。
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]
  def create
    @micropost = current_user.microposts.build(micropost_params)
    if @micropost.save
      flash[:success] = "Micropost created!"
      redirect_to root_url
    else
      render 'static_pages/home'
    end
  end
  def destroy
  end
  private
    def micropost_params
      params.require(:micropost).permit(:content)
    end
end
サイト訪問者がログインしているかどうかに応じて異なるHTMLを提供するコードを使う。
<% if logged_in? %>
  <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>
<% else %>
  <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/' %>
<% end %>
いくつかパーシャルを作る必要がある。まずはHomeの新しいサイドバーから作成。
<%= link_to gravatar_for(current_user, size: 50), current_user %>
<h1><%= current_user.name %></h1>
<span><%= link_to "view my profile", current_user %></span>
<span><%= pluralize(current_user.microposts.count, "micropost") %></span>
次はマイクロポスト作成フォーム。
<%= form_for(@micropost) do |f| %>
  <%= render 'shared/error_messages', object: f.object %>
  <div class="field">
    <%= f.text_area :content, placeholder: "Compose new micropost..." %>
  </div>
  <%= f.submit "Post", class: "btn btn-primary" %>
<% end %>
homeアクションにマイクロポストのインスタンス変数を追加。
def home
    @micropost = current_user.microposts.build if logged_in?
  end
current_userメソッドはユーザーがログインしているときしか使えないため、@micropost変数もログインしているときのみ定義されるようになる。
Userオブジェクト以外でも動作するようにerror_messagesパーシャルを更新する。
<% 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 %>
このままだとテストは失敗する。error_messagesパーシャルが他の場所でも使われているため。
修正したら、無事テストはGREENに。
13.3.3 フィードの原型
フィードの原型を作る。
feedメソッドの作成。
  # 試作feedの定義
  # 完全な実装は次章の「ユーザーをフォローする」を参照
  def feed
    Micropost.where("user_id = ?", id)
  end
?をつけるのはSQLインジェクションを避けるため。
homeアクションにフィードのインスタンス変数を追加する。
  def home
    if logged_in?
      @micropost  = current_user.microposts.build
      @feed_items = current_user.feed.paginate(page: params[:page])
    end
  end
ステータスフィードのパーシャルも作成。
<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>
そしてHomeページにステータスフィードを追加する。
<% if logged_in? %>
  <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 class="col-md-8">
      <h3>Micropost Feed</h3>
      <%= render 'shared/feed' %>
    </div>
  </div>
13.3.4 マイクロポストを削除する
最後の機能として、マイクロポストを削除する機能を追加する。そのためのリンクを追加する。
マイクロポストのパーシャルに削除リンクを追加する。
<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.
    <% if current_user?(micropost.user) %>
      <%= link_to "delete", micropost, method: :delete,
                                       data: { confirm: "You sure?" } %>
    <% end %>
  </span>
</li>
次に、Micropostsコントローラのdestroyアクションを定義し、現在のユーザーが削除対象のマイクロポストを保有しているかどうかを確認する。
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]
  before_action :correct_user,   only: :destroy
  .
  .
  .
  def destroy
    @micropost.destroy
    flash[:success] = "Micropost deleted"
    redirect_to request.referrer || root_url
  end
  private
    def micropost_params
      params.require(:micropost).permit(:content)
    end
    def correct_user
      @micropost = current_user.microposts.find_by(id: params[:id])
      redirect_to root_url if @micropost.nil?
    end
end
request.referrerメソッドは、DELETEリクエストが発行されたページに戻すことができるので非常に便利らしい。
13.3.5 フィード画面のマイクロポストをテストする
fixtureを作成していく。別々のユーザーに紐付けられたマイクロポストを追加していく。
ants:
  content: "Oh, is that what you want? Because that's how you get ants!"
  created_at: <%= 2.years.ago %>
  user: archer
zone:
  content: "Danger zone!"
  created_at: <%= 3.days.ago %>
  user: archer
tone:
  content: "I'm sorry. Your words made sense, but your sarcastic tone did not."
  created_at: <%= 10.minutes.ago %>
  user: lana
van:
  content: "Dude, this van's, like, rolling probable cause."
  created_at: <%= 4.hours.ago %>
  user: lana
自分以外のユーザーのマイクロポストは削除をしようとすると、適切にリダイレクトされることをテストで確認する。
require 'test_helper'
class MicropostsControllerTest < ActionDispatch::IntegrationTest
  def setup
    @micropost = microposts(:orange)
  end
  test "should redirect create when not logged in" do
    assert_no_difference 'Micropost.count' do
      post microposts_path, params: { micropost: { content: "Lorem ipsum" } }
    end
    assert_redirected_to login_url
  end
  test "should redirect destroy when not logged in" do
    assert_no_difference 'Micropost.count' do
      delete micropost_path(@micropost)
    end
    assert_redirected_to login_url
  end
  test "should redirect destroy for wrong micropost" do
    log_in_as(users(:michael))
    micropost = microposts(:ants)
    assert_no_difference 'Micropost.count' do
      delete micropost_path(micropost)
    end
    assert_redirected_to root_url
  end
end
ここで統合テストを作成。
$ rails generate integration_test microposts_interface
テストの中身を記述して、無事GREENに。
感想
この章はとにかく長かったです。データモデルの関連付けのあたりが業務でも大事になってくるのかなーと感じました。
あと、11章で手こずったHerokuへのアドオンがうまくできたので何よりです。。