railsのアソシエーションを学ぶ その1(導入、1対多のアソシエーション)

こんにちは!
10, 11月で、オンラインライブ2本、有観客ライブ2本、チケット取れたので、浮かれています。
楽しみをよりしっかり楽しめるように、インプット・アウトプットもしっかりしていきます。

今回は、モデル同志の関連づけであるアソシエーションについて書きます。
言葉だけだと分かりにくいことも多いと思うので、今回もミニアプリを作成していきます。
今回のミニアプリはツイッターもどきです。
要件をまとめます。

  • 短い文章(ツイート)を投稿できる
  • ログインしている人だけが、ツイートの投稿と表示ができる
  • ツイートの一覧が表示される
  • 誰のツイートか分かる
  • ツイートにいいねができる
  • フォローした人のツイートだけをみたい


そのために、以下の機能を実装します。

  • ユーザ登録機能
  • ツイート投稿機能
  • ツイート表示機能
  • いいね機能
  • フォロー機能

今回の記事では、ツイート表示機能までを実装します。
また、この時点では全員の投稿が表示される状態です。

なお、こちらの記事をオマージュさせていただきました。
qiita.com

概要

今回の記事では、テーブル設計をしながら、都度実装をしていきます。
実際は、出戻りを少なくするために、テーブル設計をきちんとしてから、実装に入ることになると思います。
ですが、本記事では以下の理由からテーブル設計と実装を並行して進めていきます。

  • 初心者に最初から完璧なテーブル設計は難しい(間違えるため結局出戻りする。それであれば、簡単なところから確実に機能を増やした方が学習効果はあると思う)
  • アソシエーションの説明に主眼をおいているため、必要なアソシエーションが出てきたタイミングで説明する方が分かりやすい

アソシエーションとは?

まず、アソシエーションとはモデルとモデルの関連づけのことです。
アソシエーションがあると便利な例を今回のアプリの機能から紹介します。
例えば、誰のツイートか分かるようにするためには、ツイート内容だけでなく、その投稿者の名前も表示すればよいです。
そこで、以下のようなテーブルを設計します。

f:id:kojiprg:20201014180024p:plain
UsersテーブルとTweewsテーブル(アソシエーションなし)

一行目がテーブル名、それ以外の行がカラムの情報を示しています。
単純に、ユーザの情報を保存するUsersテーブルと、ツイートの内容と誰の投稿かを保存するTweetsテーブルを作成しました。
なお、PKはPrimary Keyの略で、mysqlを使用している場合は、自動的にidに割り当てられます。
ここで、赤丸で囲ったカラムに注目してください。
Usersテーブルにも、Tweetsテーブルにもuser_nameのカラムがあります。
このテーブル設計では、ツイートを投稿する時に、メッセージだけでなく、投稿したユーザのユーザ名もTweetsテーブルに保存しています。
これは無駄です。
それだけでなく、不便でもあります。
例えば、user_name以外のユーザの情報をツイート表示時に使用したくなった場合を考えます。
上記の設計では、Tweetsテーブルに新しくカラムを増やして、ユーザの情報を保存し直すか、user_nameからUsersテーブルの該当する情報を検索する必要があります。
しかも、検索をかける場合にはuser_nameに対して一意性の制約をかけておいたり、検索速度を向上させるためにインデックスを貼るなどの工夫も必要です。
このような、無駄・不便を解消する方法がアソシエーションです。
以下に、アソシエーションをつけたテーブル設計を示します。

f:id:kojiprg:20201014184008p:plain
UsersテーブルとTweewsテーブル(アソシエーションあり)

Tweetsテーブルのuser_nameカラムをuser_idカラムに変更しました。
また、型をreferences型にし、オプションでforeign key: trueを設定します。
マイグレーションファイルでの書き方です。

t.references :user, foreign_key: true

まず、references型ですが、この型にすると上記のようにuserとするだけで、user_idカラムが作成され、UsersテーブルとTweetsテーブルが紐づけられます。
これとモデルでアソシエーションを宣言することにより、@tweetというインスタンスを作成しておいて、

@tweet.user.user_name

のように書くことで、対応するユーザ名を取得することができます。
もちろんユーザ名以外を取得することもできます。

次に、forein_key: trueですが、外部キー制約と呼ばれるものです。
この制約をかけることで、Tweetsテーブルへの保存時に該当するUserがいないと保存することができなくなります。
また、Userも、紐づけられているTweetがある場合に、そのUserの削除をすることができなくなります。
外部キー制約をかけておくことで、ツイートをしたユーザ名を表示→対応するユーザがいないためnilクラスを取得→エラーといったことが回避できます。
なお、外部キー制約をかけている場合に、紐づいているユーザを削除する方法は後述します。

ここまで、アソシエーションの必要性・有用性についてでした。

ユーザ登録機能の実装

ここからは、アプリの実装に入っていきます。
まず、ユーザ登録機能を実装します。
今回も、deviseを使用します。

ToDo

  • config/database.ymlから、encodingをutf8に変更
  • rails db:create
  • tweetsコントローラ+indexビューを作成
  • tweets#indexをトップページとするルーティング
  • deviseのgemを導入、インストール
  • devise経由でUserモデルを作成
  • マイグレーションファイルを編集して、user_nameカラムを作成
  • rails db:migrate
  • application_controller.rbで、deviseのストロングパラメタを設定して、user_nameカラムに保存できるようにする
  • application_controller.rbで、before_action :authenticate_user!を宣言し、未ログイン時は強制的にログインページに誘導する
  • ログインページと、ユーザ登録ページのビューを編集し、user_nameのテキストフィールドを作成する
  • トップページに、ログインしているユーザ名と、ログアウトボタンを作成する

書き出してみると結構ありますね。。。
あとは、user_nameにバリデーションをかけとくと良いと思います。

validates :user_name, presence: true

ちなみに、deviseのビューファイルは、下記のコマンドで作成できます。

rails g devise:views

上記の場合、全てのdeviseのビューフォルダが作成されてしまいます。
今回は、registrations(ユーザ登録用)とsessions(ログイン用)フォルダのビューしか編集しないため、次のようにオプションをつけても良いです。

rails g devise:views - v registrations sessions

ここまでの実装をgifにしました。

f:id:kojiprg:20201014210843g:plain
ユーザ登録機能

ツイート機能の実装

ようやく、アソシエーションを組んでいきます。
ツイート機能でのアソシエーションは「アソシエーションとは?」で書いた通りです。

Todo

  • Tweetモデル作成
  • マイグレーションファイルを編集し、rails db:migrate
  • モデルにアソシエーションの記述
  • ルーティング
  • tweetsコントローラにcreateアクションを追加
  • トップページにtweet投稿フォームを実装
  • トップページで、投稿済みのツイートが表示されるようにビューを編集

Tweetsテーブルと、Usersテーブルのアソシエーションを作るためのマイグレーションファイルです。

class CreateTweets < ActiveRecord::Migration[6.0]
  def change
    create_table :tweets do |t|
      t.string :message, null: false 
      t.references :user, foreign_key: true

      t.timestamps
    end
  end
end
t.references :user, foreign_key:true

これで、user_idカラムが作成されます。
ちなみに、短い文章の投稿ということでmessageカラムの型はstring(255文字まで)にしています。
通常、文章を保存する場合には、text型を使用します。

続いて、モデルにアソシエーションの記述をします。
考え方としては、userは複数のtweets(複数なので複数形にする)をすることができるので、
user has many tweets

一方、tweetの立場からすると、userに持たれている、つまりuserに所属しているので、
tweet belongs to user
になります。
これを、ほぼそのままモデルに書きます。

user.rb

has_many :tweets

tweet.rb

belongs_to :user

ナチュラルな英語っぽくかけるのが、ruby, railsの強みですね。

このような、ユーザ一人に対して、複数のツイートを結びつけるようなアソシエーションを1対多のアソシエーションと呼びます。
ちなみに、一人のユーザに対して、一つだけのデータを関連づけたい時(例えば、支払い方法とか)には、has_many :複数形の代わりに、has_one :単数形を使用します。

あとは、ビューとコントローラをよしなに書いてください。

ここまでの実装結果です。

f:id:kojiprg:20201014232525g:plain
ツイート機能

せっかくなので、ボタンにcssを適用したり、何分前に投稿されたかを表示できるようにしました。
参考までに、この時点でのビューとコントローラのコードです。
ポイントは、includesでN+1問題を回避していることですかね。

index.html.erb

<h1>トップページ</h1>
<% if user_signed_in? %>
  <div>ユーザ名: <%= current_user.user_name %></div>
  <%= link_to "ログアウト", destroy_user_session_path, method: :delete %>
<% end %>

<%= form_with model: @tweet, url: tweets_path, method: :post, local: true do |f| %>
  <div>
    <%= f.text_area :message, placeholder: "メッセージを入力してください" %>
  </div>
  <%= f.submit "つぶやく", class: "button"%>
<% end %>

<div class="messages">
  <% @tweets.each do |tweet| %>
    <div class="message">
      <div class="message_head">
        <div class="message_user_name">
          投稿者:<%= tweet.user.user_name %>
        </div>
        <div class="message_created_at">
          <%= time_ago_in_words(tweet.created_at) %>
        </div>
      </div>
      <div class="message_content">
        <%= simple_format(tweet.message) %>
      </div>
    </div>
  <% end %>
</div>

tweets_controller.rb

class TweetsController < ApplicationController
  def index
    @tweet = Tweet.new
    @tweets = Tweet.includes(:user).reverse_order
  end

  def create
    @tweet = Tweet.new(tweet_params)
    if @tweet.save
      redirect_to root_path
    else
      render :index
    end
  end

  private

  def tweet_params
    params.require(:tweet).permit(:message).merge(user_id: current_user.id)
  end
end

depented: :destoryオプション

今回、foreign_key: trueの設定をしたため、ユーザだけを削除しようとするとエラーになります。
そこで、ユーザを削除すると同時に紐づいているツイートを全て削除するようにアソシエーションで宣言します。
方法は以下のように、user.rbを編集します。

has_many :tweets, dependent: :destroy

dependent: :destroyをつけるだけです。
なお、ユーザの削除のリンクは、以下のコードです。
パスの確認は、rails routesですね。

<%= link_to "ユーザを削除する!ツイートも消えます!", user_registration_path, method: :delete %>
f:id:kojiprg:20201014235837g:plain
user:testを削除すると、testが投稿したツイートも消える

まとめ

ここまでで、前半戦終了です。
今回のように一人のユーザが何回もツイートできる、そしてツイートの方から投稿したユーザの情報を得たい、という状況では、1対多のアソシエーションになります。
ポイントとしては、以下の2点です。

次回は、多対多のアソシエーションとして、いいね機能を、
自己結合型のアソシエーションとして、フォロー機能を実装します。

最後までお付き合いいただきありがとうございました!

その2の記事はこちらです↓
kojiprg.hatenablog.com