#ユーザーのマイクロポスト
ユーザーが短いメッセージを投稿できるようにするためのリソース「マイクロポスト」を追加していく。Micropostデータモデルを作成し、Userモデルとhas_manyおよびbelongs_toメソッドを使って関連付けを行い、さらに、結果を処理し表示するために必要なフォームとその部品を作成していく。
##Micropostモデル
まずはMicropostリソースの最も本質的な部分を表現するMicropostモデルを作成するところから始めよう。
###基本的なモデル
Micropostモデルは、マイクロポストの内容を保存するcontent属性と、特定のユーザーとマイクロポストを関連付けるuser_id属性の2つの属性だけを持つ。実行した結果のMicropostモデルの構造は下図のようになる。
このモデルでは、マイクロポストの投稿にString型ではなくText型を使っている点に注目する。これは、ある程度の量のテキストを格納するときに使われる型であり、のちに投稿フォームにString用のテキストフィールドではなくてText用のテキストエリアを使うため、text型を使う方がより自然な投稿フォームが実現できる。
6章でUserモデルを生成したときと同様に、Railsのgenerate modelコマンドを使ってMicropostモデルを生成する
$ rails generate model Micropost content:text user:references
上のコマンドを実行すると、以下に示すApplicationRecordを継承したMicropostモデルが生成される。今回はコマンドを実行したときにuser:referencesという引数も含めていたので、生成されたモデルの中に、ユーザーと1対1の関係であることを表すbelongs_toのコードも追加されている。
class Micropost < ApplicationRecord
belongs_to :user
end
また、usersテーブルを作るマイグレーションを生成したときと同様に、このgenerateコマンドはmicropostsテーブルを作成するためのマイグレーションファイルを生成する。Userモデルとの最大の違いはreferences型を利用している点である。これを利用すると、自動的にインデックスと外部キー参照付きのuser_idカラムが追加され、UserとMicropostを関連付けする下準備をしてくれる。
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
ここではuser_idとcreated_atカラムにインデックスが付与されていることに注目する。こうすることで、user_idに関連付けられたすべてのマイクロポストを作成時刻の逆順で取り出しやすくなる。
また、user_idとcreated_atの両方を1つの配列に含めている。こうすることでActive Recordは、両方のキーを同時に扱う複合キーインデックス (Multiple Key Index) を作成する。
$ rails db:migrate
いつものように上のコマンドでデータベースを更新する。
###User/Micropostの関連付け
Webアプリケーション用のデータモデルを構築するにあたって、個々のモデル間での関連付けを十分考えておくことが重要である。それぞれのマイクロポストは1人のユーザーと関連付けられ、それぞれのユーザーは (潜在的に) 複数のマイクロポストと関連付けられる。この関連付けを以下に示す。
MicropostとそのUserは belongs_to (1対1) の関係性がある
UserとそのMicropostは has_many (1対多) の関係性がある
この節で定義するbelongs_to/has_many関連付けを使うことで、表 13.1に示すようなメソッドをRailsで使えるようになる。一度正しい関連付けを定義してしまえば、@micropost変数のuser_idには、関連するユーザーのidが自動的に設定される。
メソッド 用途
micropost.user Micropostに紐付いたUserオブジェクトを返す
user.microposts Userのマイクロポストの集合をかえす
user.microposts.create(arg) userに紐付いたマイクロポストを作成する
user.microposts.create!(arg) userに紐付いたマイクロポストを作成する (失敗時に例外を発生)
user.microposts.build(arg) userに紐付いた新しいMicropostオブジェクトを返す
user.microposts.find_by(id: 1) userに紐付いていて、idが1であるマイクロポストを検索する
表 13.1: user/micropost関連メソッドのまとめ
@user.microposts.buildのようなコードを使うためには、 UserモデルとMicropostモデルをそれぞれ更新して、関連付ける必要がある。。Micropostモデルの方では、belongs_to :userというコードが必要になるが、これは、先ほどのマイグレーションによって自動的に生成されている。一方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
正しく関連付けができたら、リスト 13.4のsetupメソッドを修正して、慣習的に正しくマイクロポストを作成してみる。
require 'test_helper'
class MicropostTest < ActiveSupport::TestCase
def setup
@user = users(:michael)
@micropost = @user.microposts.build(content: "Lorem ipsum")
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
###マイクロポストを改良する
この項では、UserとMicropostの関連付けを改良していきく。具体的には、ユーザーのマイクロポストを特定の順序で取得できるようにしたり、マイクロポストをユーザーに依存させて、ユーザーが削除されたらマイクロポストも自動的に削除されるようにしていく。
デフォルトのスコープ
user.micropostsメソッドはデフォルトでは読み出しの順序に対して何も保証されないが、作成時間の逆順、つまり最も新しいマイクロポストを最初に表示するようにしてみよう。これを実装するためには、default scopeというテクニックを使う。
default scopeを使うには、default_scopeの引数にorderを与える。例えば、created_atカラムの順にしたい場合は次のようになる。
order(:created_at)
ただし、デフォルトの順序が昇順 (ascending) となっているので、このままでは数の小さい値から大きい値にソートされてしまう(最も古い投稿が最初に表示されてしまう)。順序を逆にしたい場合は、次のように生のSQLを引数に与える必要がある。
order('created_at DESC')
ここで使ったDESCとは、SQLの降順 (descending) を指す。したがって、これで新しい投稿から古い投稿の順に並ぶ。また、このコードは次と等価である。
order(created_at: :desc)
このコードを使ってMicropostモデルを更新した結果を以下に示す。
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
Dependent: destroy
今度はマイクロポストに第二の要素を追加して、ユーザーが破棄された場合、ユーザーのマイクロポストも同様に破棄されるようにする。この振る舞いは、has_manyメソッドにオプションを渡すことで実装できる。
class User < ApplicationRecord
has_many :microposts, dependent: :destroy
.
.
.
end
dependent: :destroyというオプションを使うと、ユーザーが削除されたときに、そのユーザーに紐付いた (そのユーザーが投稿した) マイクロポストも一緒に削除されるようになる。
##マイクロポストを表示する
ここでは、Twitterのような独立したマイクロポストのindexページは作らずに、下図のモックアップに示したように、ユーザーのshowページで直接マイクロポストを表示させる。
最初に極めてシンプルなERbテンプレートを作成する。次に、サンプルデータ生成タスクにマイクロポストのサンプルを追加して、画面にサンプルデータが表示されるようにしてみる。
###マイクロポストの描画
本項では、ユーザーのプロフィール画面 (show.html.erb) でそのユーザーのマイクロポストを表示させたり、これまでに投稿した総数も表示させたりしていく。一度データベースをリセットし、サンプルデータを再生成しておく。
$ rails db:migrate:reset
$ rails db:seed
まずは、Micropostのコントローラとビューを作成するために、コントローラを生成しよう。
$ rails generate controller Microposts
今回の目的は、ユーザー毎にすべてのマイクロポストを描画できるようにすることである。10章で見た次のコードでは、
<ul class="users">
<%= render @users %>
</ul>
_user.html.erbパーシャルを使って自動的に@users変数内のそれぞれのユーザーを出力していた。これを参考に、_micropost.html.erbパーシャルを使ってマイクロポストのコレクションを表示しようとすると、次のようになる。
<ol class="microposts">
<%= render @microposts %>
</ol>
まずは、順序無しリストのulタグではなく、順序付きリストのolタグを使っている点に注目する。これは、マイクロポストが特定の順序 (新しい→古い) に依存しているためです。次に、対応するパーシャルを以下に示す。
<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分前に投稿」といった文字列を出力する。
次は、一度にすべてのマイクロポストが表示されてしまう潜在的問題に対処します。10章ではページネーションを使ったが、今回も同じ方法でこの問題を解決する。前回同様、will_paginateメソッドを使うと次のようになる。
<%= will_paginate @microposts %>
今回の場合はUsersコントローラのコンテキストからマイクロポストをページネーションしたいため (つまりコンテキストが異なるため)、明示的に@microposts変数をwill_paginateに渡す必要があり,そのようなインスタンス変数をUsersコントローラのshowアクションで定義しなければならない。
class UsersController < ApplicationController
.
.
.
def show
@user = User.find(params[:id])
@microposts = @user.microposts.paginate(page: params[:page])
end
.
.
.
end
マイクロポストの投稿数を表示は、countメソッドを使うことで実装できる。
user.microposts.count
これですべての要素が揃ったので、プロフィール画面にマイクロポストを表示させてみよう。(このとき、if @user.microposts.any? を使って、ユーザーのマイクロポストが1つもない場合には空のリストを表示させていない点にも注目する。)
<% 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>
ここで、改良した新しいプロフィール画面をブラウザで見てみても、マイクロポストが一つもないので、何も表示されていない寂しい画面となる。次にマイクロポストを追加していく。
###マイクロポストのサンプル
サンプルデータ生成タスクにマイクロポストも追加する。すべてのユーザーにマイクロポストを追加しようとすると時間が掛かり過ぎるので、takeメソッドを使って最初の6人だけに追加する。
User.order(:created_at).take(6)
(このとき、orderメソッドを経由することで、作成されたユーザーの最初の6人を明示的に呼び出すようにしている。)
この6人については、1ページの表示限界数 (30) を越えさせるために、それぞれ50個分のマイクロポストを追加するようにしている。また、各投稿内容については、Faker gemにLorem.sentenceという便利なメソッド(ダミーのテキストを返す)があるので、これを使う。変更した結果を以下に示す。
.
.
.
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
再度サンプルデータを生成する。
##マイクロポストを操作する
データモデリングとマイクロポスト表示テンプレートの両方が完成したので、次はWeb経由でそれらを作成するためのインターフェイスに取りかかろう。
Micropostsリソースへのインターフェイスは、主にプロフィールページとHomeページのコントローラを経由して実行されるので、Micropostsコントローラにはnewやeditのようなアクションは不要ということになる。つまり、createとdestroyがあれば十分である。したがってMicropostのリソースは以下のようになる。
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
###マイクロポストのアクセス制御
Micropostsリソースの開発では、Micropostsコントローラ内のアクセス制御から始める。関連付けられたユーザーを通してマイクロポストにアクセスするので、createアクションやdestroyアクションを利用するユーザーは、ログイン済みでなければならない。したがって、beforeフィルターを使ってログインを要求するために、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を削除しておく。
以上で、Micropostsコントローラからもlogged_in_userメソッドを呼び出せるようになった。これにより、createアクションやdestroyアクションに対するアクセス制限が、beforeフィルターで簡単に実装できるようになる。
class MicropostsController < ApplicationController
before_action :logged_in_user, only: [:create, :destroy]
def create
end
def destroy
end
end
###マイクロポストを作成する
第7章では、HTTP POSTリクエストをUsersコントローラのcreateアクションに発行するHTMLフォームを作成することで、ユーザーのサインアップを実装した。マイクロポスト作成の実装もこれと似ている。主な違いは、別の micropost/new ページを使う代わりに、ホーム画面 (つまりルートパス) にフォームを置くという点である。
図 13.10: マイクロポスト作成フォームのあるホーム画面のモックアップ
最後にホーム画面を実装したときは、[Sign up now!] ボタンが中央にあった。マイクロポスト作成フォームは、ログインしている特定のユーザーのコンテキストでのみ機能するので、この節の一つの目標は、ユーザーのログイン状態に応じて、ホーム画面の表示を変更することである。
次に、マイクロポストのcreateアクションを作り始める。新しいマイクロポストをbuildするためにUser/Micropost関連付けを使っている点に注目する。micropost_paramsでStrong Parametersを使っていることにより、マイクロポストのcontent属性だけがWeb経由で変更できる。
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 %>
このフォームが動くようにするためには、2箇所の変更が必要である。1つは、(以前と同様) 関連付けを使って次のように@micropostを定義することである。
@micropost = current_user.microposts.build
作成したコードを以下に示す。
class StaticPagesController < ApplicationController
def home
@micropost = current_user.microposts.build if logged_in?
end
def help
end
def about
end
def contact
end
end
current_userメソッドはユーザーがログインしているときしか使えないので、@micropost変数もログインしているときのみ定義されるようになる。
micropostパーシャルを動かすためのもう1つの変更は、エラーメッセージのパーシャルを再定義することである。
<%= render 'shared/error_messages', object: f.object %>
7章ではエラーメッセージパーシャルが@user変数を直接参照していた。今回は代わりに@micropost変数を使う必要がある。これらのケースをまとめると、フォーム変数fをf.objectとすることによって、関連付けられたオブジェクトにアクセスすることができる。したがって、
form_for(@user) do |f|
上のようにf.objectが@userとなる場合と、
form_for(@micropost) do |f|
上のようにf.objectが@micropostになる場合などがある。
micropostパーシャルの2行目のobject: f.objectはerror_messagesパーシャルの中で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 %>
リスト 13.41: Userオブジェクト以外でも動作するようにerror_messagesパーシャルを更新
error_messagesパーシャルの他の出現場所であるユーザー登録 (リスト 7.20)、パスワード再設定 (リスト 12.14)、そしてユーザー編集 (リスト 10.2) のそれぞれのビューを更新する必要がある。
<% provide(:title, 'Sign up') %>
<h1>Sign up</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(@user) do |f| %>
<%= render 'shared/error_messages', object: f.object %>
<%= 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 "Create my account", class: "btn btn-primary" %>
<% end %>
</div>
</div>
リスト 13.43: ユーザー登録時のエラー表示を更新する
<% provide(:title, "Edit user") %>
<h1>Update your profile</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(@user) do |f| %>
<%= render 'shared/error_messages', object: f.object %>
<%= 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 "Save changes", class: "btn btn-primary" %>
<% end %>
<div class="gravatar_edit">
<%= gravatar_for @user %>
<a href="http://gravatar.com/emails">change</a>
</div>
</div>
</div>
リスト 13.44: ユーザー編集時のエラー表示を更新する
<% provide(:title, 'Reset password') %>
<h1>Password reset</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(@user, url: password_reset_path(params[:id])) do |f| %>
<%= render 'shared/error_messages', object: f.object %>
<%= hidden_field_tag :email, @user.email %>
<%= 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 "Update password", class: "btn btn-primary" %>
<% end %>
</div>
</div>
これでこの章で作成したすべてのHTMLが適切に表示されるようになったはずである。最終的なフォームを以下に示す。
###フィードの原型
マイクロポスト投稿フォームが動くようになったが、今の段階では投稿した内容をすぐに見ることができない。というのも、Homeページにまだマイクロポストを表示する部分が実装されていないからである。
マイクロポストを表示するfeedメソッドをUserモデルで実装していく。フィードの原型では、まずログインしているユーザーの全てのマイクロポストを取得させる。
リスト 13.46: マイクロポストのステータスフィードを実装するための準備
class User < ApplicationRecord
.
.
.
# 試作feedの定義
# 完全な実装は次章の「ユーザーをフォローする」を参照
def feed
Micropost.where("user_id = ?", id)
end
private
.
.
.
end
ここのidはuser.idと等価である。
サンプルアプリケーションにフィード機能を導入するため、ログインユーザーのフィード用にインスタンス変数@feed_itemsを追加し、Homeページにはフィード用のパーシャルを追加する。
リスト 13.47: homeアクションにフィードのインスタンス変数を追加する
class StaticPagesController < ApplicationController
def home
if logged_in?
@micropost = current_user.microposts.build
@feed_items = current_user.feed.paginate(page: params[:page])
end
end
def help
end
def about
end
def contact
end
end
リスト 13.48: ステータスフィードのパーシャル
<% if @feed_items.any? %>
<ol class="microposts">
<%= render @feed_items %>
</ol>
<%= will_paginate @feed_items %>
<% end %>
後は、いつものようにフィードパーシャルを表示すればHomeページにフィードを追加できる。この結果はHomeページのフィードとして表示される。
リスト 13.49: 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>
<% else %>
.
.
.
<% end %>
現時点では、新しいマイクロポストの作成は図 13.15で示したように期待どおりに動作している。ただしささいなことではあるが、マイクロポストの投稿が失敗すると、 Homeページは@feed_itemsインスタンス変数を期待しているため、現状では壊れてしまう。最も簡単な解決方法は、以下のように空の配列を渡しておくことである。
リスト 13.50: createアクションに空の@feed_itemsインスタンス変数を追加する
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
@feed_items = []
render 'static_pages/home'
end
end
def destroy
end
private
def micropost_params
params.require(:micropost).permit(:content)
end
end