0
0

More than 3 years have passed since last update.

【Rails】ユーザーのマイクロポスト【Rails Tutorial 13章まとめ】

Posted at

Micropostモデル

ユーザーに紐づいた短いメッセージの投稿機能を実装する。

user:references

user:referencesをつけて、text型のcontentカラムを持つMicropostモデルを作成する。

$ rails generate model Micropost content:text user:references

これによりMicropostモデルは自動でbelong_toによってユーザーに関連づけられ、user_idカラムも追加される。

ユーザーごとの投稿を作成時刻の逆順(新しい順)で取り出しやすくするために、user_idカラムとcreated_atカラムにインデックスを追加する。

db/migrate/[timestamp]_create_microposts.rb
class CreateMicroposts < ActiveRecord::Migration[5.0]
  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

マイグレーションを実行しておく。

Micropostのバリデーション

Micropostモデルの各カラムにバリデーションを設定していく。
先にテストを書く。
一つ目はMicropostモデルが正常かどうか、二つ目はuser_idの存在性のテストである。

test/models/micropost_test.rb
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

バリデーションを設定してテストが通るようにしておく。

app/models/micropost.rb
class Micropost < ActiveRecord::Base
  belongs_to :user
  validates :user_id, presence: true
end

次に、content属性に関するバリデーションを設定する。
content属性は140文字まで、空白は無効とする。
先にテストを書く。

test/models/micropost_test.rb
  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

バリデーションを追加する。

app/models/micropost.rb
class Micropost < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 140 }
end

User/Micropostの関連付け

belong_toとhas_many

一つのマイクロポストは、一人のユーザーに関連づけられる一対一の関係にある(belong_to)。
また、一人のユーザーは複数のマイクロポストを持つ一対多の関係にある(has_many)。

そこで、Userモデルにhas_many :micropostsを追加する。

app/models/user.rb
class User < ApplicationRecord
  has_many :microposts
  .
  .
  .
end

この関連付けによって、Userオブジェクトからマイクロポストを作成したり取得できるようになる。
具体的には、Userオブジェクトにmicropostsというメソッドを使用する。

user.microposts.create
user.microposts.create!
user.microposts.build

これで先ほどのテストを書き直すと以下のようになる。

test/models/micropost_test.rb
  def setup
    @user = users(:michael)
    @micropost = @user.microposts.build(content: "Lorem ipsum")
  end

マイクロポストの改良

デフォルトのスコープ

マイクロポストを作成した日時が新しい順に表示できるようにする。
テスト駆動開発で進めるため、まずfixtureファイルにテスト用のマイクロポストを作る。

test/fixtures/microposts.yml
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 %>

fixtureファイルの中では、埋め込みRubyを使って投稿時間を設定できる。
この中で最も新しい投稿であるmonst_recentが最初に取得されるようなテストを書く。

test/models/micropost_test.rb
require 'test_helper'

class MicropostTest < ActiveSupport::TestCase
  .
  .
  .
  test "order should be most recent first" do
    assert_equal microposts(:most_recent), Micropost.first
  end
end

マイクロポストの取得順はdefault_scopeメソッドを使って変更できる。
デフォルトでは昇順(asc)になっているので、これを降順(desc)にする。

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

これでテストがGREENになる。

ユーザーと同時に投稿を削除する

ユーザーが削除された時、そのマイクロポストも削除されるようにする。
そのために、has_manyメソッドにdependent: :destroyオプションを追加する。

app/models/user.rb
  has_many :microposts, dependent: :destroy

テストを書く。

test/models/user_test.rb
  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

削除するユーザーに関連付くマイクロポストを一つ作成し、ユーザーの削除とともにそれが破棄され、マイクロポストの数が1減ることをassert_differenceで確認する。

マイクロポストの表示

ユーザープロフィールページ

マイクロポストをユーザーのプロフィールページに表示する。
まずMicropostsコントローラを作成する。

$ rails generate controller Microposts

マイクロポストの表示部分はパーシャルを使う。
ユーザー一覧ページでは次のようなコードでパーシャルを呼び出していた。

<ul class="users">
  <%= render @users %>
</ul>

こうすると、_user.html.erbパーシャルが呼び出されるとともに、@users変数がパーシャルで使えるようになる。
同じことをマイクロポストの表示でも行うことにして、まずパーシャルを作成する。

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>

time_ago_in_wordsメソッドを使うと、「3分前に投稿」といった文字列を表示できる。
また、埋め込みRubyを使ってid="micropost-<%= micropost.id %>とすることで、各マイクロポストにそのidごとにcssのidを与えている。

マイクロポストの表示にもページネーション機能を使うが、Usersコントローラ内でマイクロポストを使う場合は、以下のようにwill_paginateに@microposts変数を引数として与えねばならない。

<%= will_paginate @microposts %>

Usersコントローラ内で@users変数を使う場合は、この引数を省略できる。

@micropostsインスタンス変数を、Usersコントローラのshowアクション内に定義する。

app/controllers/users_controller.rb
  def show
    @user = User.find(params[:id])
    @microposts = @user.microposts.paginate(page: params[:page])
  end

以上をまとめると、ユーザープロフィールページのビューは次のようになる。

app/views/users/show.html.erb
<% 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>

マイクロポストのサンプル

6人のユーザーに、それぞれ50個のマイクロポストを作成する。

db/seeds.rb
.
.
.
users = User.order(:created_at).take(6)
50.times do
  content = Faker::Lorem.sentence(5)
  users.each { |user| user.microposts.create!(content: content) }
end

order(:created_at).take(6)とすることで、最初の6人を取得できる。
content属性には、fakerジェムのLorem.sentenceメソッド使うことでランダムな文章を入れている。

データベースをリセットしてseedファイルを実行しておく。

$ rails db:migrate:reset
$ rails db:seed

プロフィールページのマイクロポストのテスト

プロフィールページ用の統合テストを作成する。

$ rails g integration_test users_profile

ユーザーに紐付いたテスト用のマイクロポストをfixtureファイルに作成する。
そのためにはuser: michaelを付ける。
また、fakerと埋め込みRubyを使ってテスト用マイクロポストを大量生成する。

test/fixtures/microposts.yml
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 %>

テストを書く。

test/integration/users_profile_test.rb
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

to_sメソッドは、文字列以外を文字列に変換する。
また、response.bodyはそのページのhtmlの全文を返す。
assert_matchにより、第一引数の文字列がページのどこかに含まれていることを確認する。

Micropostsリソース

ルーティング

Micropostsリソースの表示などはUsersコントローラの中で行うので、Micropostsコントローラに必要なのはcreateとdestroyアクションだけである。
よって、ルーティングは以下のようになる。

config/routes.rb
Rails.application.routes.draw do
  .
  .
  .
  resources :microposts, only: [:create, :destroy]
end

名前付きルートなどの関係は以下のよう。
スクリーンショット 2019-12-03 17.32.47.jpg

ややこしいことに、createアクションはmicroposts_pathだが、destroyアクションはmicropost_pathである。

Micropostsコントローラのアクセス制御

マイクロポストは関連付けられたユーザーを通して投稿されるので、ログイン済みでなければならない。
非ログイン時にcreateアクションやdestroyアクションにアクセスした場合、ログイン画面にリダレクトされるようテストを書く。
また、assert_no_differenceでマイクロポストが増減していないことも確認しておく。

test/controllers/microposts_controller_test.rb
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

ここで、非ログインならばリダイレクトするbeforeフィルターであるlogged_in_userが必要になるが、これはUsersコントローラ内にしかない。
そこで、これを各コントローラが継承するApplicationコントローラに移しておく。

app/controllers/application_controller.rb
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コントローラのほうからは消しておく。

これをMicropostsコントローラのcreateとdestroyアクションに設定する。

app/controllers/microposts_controller.rb
class MicropostsController < ApplicationController
  before_action :logged_in_user, only: [:create, :destroy]

  def create
  end

  def destroy
  end
end

マイクロポストの作成

createアクション

マイクロポストをアプリケーション上で作成するために、createアクションを書いていく。

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
      render 'static_pages/home'
    end
  end

  private

    def micropost_params
      params.require(:micropost).permit(:content)
    end

Strong Parametersを使って属性に値を渡すことも含め、Usersコントローラのcreateアクションとほとんど同じである。

homeビュー

マイクロポストの投稿ページはStaticPagesのhomeビューとする。
ログインしている場合は投稿フォームを表示し、非ログイン時はサインアップページへのリンクを表示する。

app/views/static_pages/home.html.erb
<% 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ページに表示するユーザー情報のパーシャルを作成する。

app/views/shared/_user_info.html.erb
<%= 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>

マイクロポスト投稿フォームのパーシャルを作成する。

app/views/shared/_micropost_form.html.erb
<%= 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 %>

ここで、エラーメッセージ部分のパーシャル呼び出しは次のようになっている。

<%= render 'shared/error_messages', object: f.object %>

ユーザー新規登録・ログイン用に作っていたエラーメッセージのパーシャルは@user変数を直接参照していたので、@micropostを使えるように修正する必要がある。
このパーシャル呼び出しにはobject: f.objectというハッシュが渡されている。
これによってエラーメッセージのパーシャル内でobjectという変数が使えるようになり、これで@user変数を置き換えることで、@micropost変数に対応することができる。

app/views/shared/_error_messages.html.erb
<% 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 %>

エラーメッセージのパーシャルを呼び出していた他のビュー(ユーザー登録、ユーザー編集、パスワード再設定)でもこのobject: f.objectを設定しておく。

次に、StaticPagesコントローラのhomeアクションで、現在のユーザーに関連付いた新しいMicropostオブジェクトを作成する。

app/controllers/static_pages_controller.rb
  def home
    @micropost = current_user.microposts.build if logged_in?
  end

newではなくbuildを使う点に注意する。
マイクロポスト投稿はログイン時のみの機能なので、if logged_in?を付けてログイン時のみ変数が定義されるようになっている。

フィード

投稿がすぐに見れるように、homeビューにマイクロポストのフィードを実装する。
このフィードでは将来的にフォローしたユーザーの投稿も見れるようにする。

全てのユーザーがフィードを持つので、Userモデルにfeedメソッドを定義する。

app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 試作feedの定義
  def feed
    Micropost.where("user_id = ?", id)
  end

  private
    .
    .
    .
end

whereの検索条件を"user_id = ?", idとしているのは、セキュリティ上の問題を解決するためである。

このfeedメソッドを使って現在のユーザーのマイクロポストを取得し、インスタンス変数@feed_itemsに入れる。

app/controllers/static_pages_controller.rb
 def home
    if logged_in?
      @micropost  = current_user.microposts.build
      @feed_items = current_user.feed.paginate(page: params[:page])
    end
  end

ステータスフィードのパーシャルは次のようになる。

app/views/shared/_feed.html.erb
<% if @feed_items.any? %>
  <ol class="microposts">
    <%= render @feed_items %>
  </ol>
  <%= will_paginate @feed_items %>
<% end %>

ここで、<%= render @feed_items %>というパーシャル呼び出しが使われている。
@feed_itemsに入っている要素はMicropostクラスを持っているために、Railsは対応する名前のパーシャル(_micropost.html.erb)を、同じディレクトリから探してくれる。

これを使って、homeビューにステータスフィードを追加する。

app/views/static_pages/home.html.erb
<% 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>
<% else %>

ここで、マイクロポストの投稿が失敗すると、Homeページは@feed_itemsインスタンス変数を期待しているためエラーになる。

ここの意味がよく分からなかったのだが、次のサイトが参考になった。
「Railsチュートリアル13章 @feed_itemsがnilになる?」
https://teratail.com/questions/194996

つまりこういうことになる。
①ステータスフィードのパーシャルは<% if @feed_items.any? %>というif文を使い、@feed_itemsがnilでない時のみマイクロポストのフィードを表示する。
②再レンダリングされるhomeページで使えるインスタンス変数は、Micropostsコントローラのcreateアクション内で定義されたものだけである
③これはリダイレクトではないのでStaticPagesコントローラのhomeアクションは実行されないためである。すなわち、homeアクション内の@feed_itemsが定義されず、nilとなる。
④createアクションで@feed_itemsを定義してやることで、homeビューで@feed_itemsが使えるようになる。

そこで、マイクロポストの投稿が失敗した際の処理として、@feed_itemsを定義してやる。

app/controllers/sessions_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 = current_user.feed.paginate(page: params[:page])
      render 'static_pages/home'
    end
  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="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>

ここで、ユーザーとマイクロポストの関連付けを利用して、現在のユーザーがそのマイクロポストを投稿したユーザーの場合のみ削除リンクを表示するようにしている。

<% if current_user?(micropost.user) %>

次に、マイクロポストを削除するdestroyアクションを作成する。

app/controllers/microposts_controller.rb
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

correct_userメソッドは、削除するマイクロポストに関連付けられたユーザーが現在のユーザーと一致するかを確認するフィルターである。
同時に、このメソッドは削除するマイクロポストの取得も行なっている。
つまり、現在のユーザーのマイクロポストの中に、削除するマイクロポストがあるかどうかを確認し、あれば取得して、destroyアクションに繋ぐ。
無ければリダイレクトする。

destroyアクション内の、マイクロポスト削除後のリダイレクトは以下のようになっている。

request.referrer || root_url

request.referrerは、一つ前のURLを返す。
マイクロポストをhomeページから削除すればhomeページに、プロフィールページから削除すればプロフィールページに戻る。
もし戻り先が見つからなかったとしても、or演算子でルートURLを指定して、そちらに移動するようにしている。

フィード画面のマイクロポストのテスト

認可のテスト

他人のマイクロポストを削除できないことのテストを書く。
まず、fixtureファイルに別のユーザーと紐づけられたマイクロポストを追加する。

test/fixtures/microposts.yml
.
.
.
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

テストは以下のようになる。

test/controllers/microposts_controller_test.rb
  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

統合テスト

マイクロポスト機能の統合テストを作成する。

$ rails generate integration_test microposts_interface
test/integration/microposts_interface_test.rb
require 'test_helper'

class MicropostsInterfaceTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end

  test "micropost interface" do
    log_in_as(@user)
    get root_path
    assert_select 'div.pagination'
    # 無効な送信
    assert_no_difference 'Micropost.count' do
      post microposts_path, params: { micropost: { content: "" } }
    end
    assert_select 'div#error_explanation'
    # 有効な送信
    content = "This micropost really ties the room together"
    assert_difference 'Micropost.count', 1 do
      post microposts_path, params: { micropost: { content: content } }
    end
    assert_redirected_to root_url
    follow_redirect!
    assert_match content, response.body
    # 投稿を削除する
    assert_select 'a', text: 'delete'
    first_micropost = @user.microposts.paginate(page: 1).first
    assert_difference 'Micropost.count', -1 do
      delete micropost_path(first_micropost)
    end
    # 違うユーザーのプロフィールにアクセス (削除リンクがないことを確認)
    get user_path(users(:archer))
    assert_select 'a', text: 'delete', count: 0
  end
end
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0