#ユーザーのマイクロポスト
■第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へのアドオンがうまくできたので何よりです。。