RailsのScaffoldでネストしたResourceを作る

先週、Rails寺子屋に参加させていただきまして、いまさらながらRailsデビューを果たしました。もともとバックエンドにはFuelPHPを使っていたので、未だにRuby独自の記法に四苦八苦しながらも、Scaffoldの強力さに感動を覚えている次第です。お恥ずかしながら、FuelPHP使っていたときにはmigrationとか使うことはありませんでした・・・

Railsを触り始めて「とりあえず頭の中にある作りたいものを、最小構成で作ってみよう」と思いたち、複数のグループがあって、そのグループに属するユーザが複数いて・・・みたいなのを作り始めました。しかし、Resourceをネストさせる辺りで詰まったのでメモ。

やりたいこと

上でも少し書いていますが、グループウェアツールみたいなイメージで、複数のグループと、その下に複数のユーザを作成できるようなサービスを開発するイメージです。

RailsにはScaffoldというMVC設計におけるモデルをある程度自動で生成できるような仕組みが組み込まれています。Scaffoldで生成されたモデルはCRUD操作を前提としているため、今回作成したいようなグループやユーザといったモデルはScaffoldを使うことで簡単に作成することができます。また、それぞれのモデルはResourceとしてルーティング設定ができ、Resourceをネストすることでグループとユーザのような参照関係を実現することができます。例えば、以下のようなURLであるグループのあるユーザを参照できます。

http://www.example.com/groups/1/users/3

この他にも、groups/newでユーザ作成や、groups/1/users/newなどでグループ1のユーザ作成などが指定できます。

しかし、このURLの数字がどうにも気に入りません。Modelのidの数値を指しているようなのですが、できることならば、名前でURLを表示したいところです。例えば、TeamAのユーザ、Taroさんのページであれば、

http://www.example.com/groups/teama/users/taro

のように表記したいものです。これを実現するまでの流れをScaffoldから追っていきます。

バージョン情報

使用する諸々のバージョンは以下の通り。

名前 バージョン
Ruby on Rails 4.2.3
Ruby 2.2.0

環境の準備

まずはrails new、プロジェクトの名前は適当です。

$ rails new groupware

基礎が生成されたら、ディレクトリ移動してGemfileを編集します。Viewにslimを使いたいので、gemにslim-railsを追記します。

Gemfile (抜粋)

gem 'sdoc', '~> 0.4.0', group: :doc

gem 'slim-rails';

Gemfileを修正したら、インストールします。

$ bundle install

以上で環境の準備は完了です。

とりあえずScaffold

まずは親の要素であるグループをScaffoldで作っていきます。カラムは適当です。

$ rails g scaffold Group name:string description:text

これで色々自動生成されるわけですが、せっかくなので、生成されたマイグレーションファイルを見てみましょう。db/20150807125608_create_groups.rbみたいなファイルができているはずです。中身はこんなん。

db/20150807125608_create_groups.rb

class CreateGroups < ActiveRecord::Migration
  def change
    create_table :groups do |t|
       t.string :name
       t.text :description

       t.timestamps null: false
    end
  end
end

Rubyの形式で記載された、データベースのスキーマ定義です。今後、データベースの変更はこのマイグレーションファイル経由で実行し、管理することができます。この時点で、マイグレーションファイルを編集することで、もう少し細かいテーブル定義ができます。

db/20150807125608_create_groups.rb (修正版)

class CreateGroups < ActiveRecord::Migration
  def change
    create_table :groups do |t|
       t.string :name, null: false, limit: 255
       t.text :description

       t.timestamps null: false
    end
  end
end

例えば、上記の例では名前にNULLを許可せず、文字列を255文字以下にするように定義を細かく設定し直しました。

参照関係を明示してScaffold

次に子要素のユーザを作成していきます。同じく、scaffoldで作成できますが、参照関係を明示する必要がありますので注意が必要です。

$ rails g scaffold User group:references name:string profile:text

このgroup:referencesでUserというモデルはGroupモデルを参照するよ、ということを伝えることができます。生成されるマイグレーションファイルは以下の通り。

db/201507131046_create_users.rb

class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
       t.references :group, index: true, foreign_key: true
       t.string :name
       t.text :profile

       t.timestamps null: false
    end
  end
end

参照関係が明示されていますね。ちなみに、各カラムの先頭にあるstringやtextはデータの型になります。参照はreferencesですね。
以上でScaffoldの作業は完了になりますので、後は生成されたマイグレーションファイルを使ってマイグレーションを実行するだけです。環境に応じて実行コマンドが異なるかもしれませんが、zshの環境では以下のコマンドで動きました。

$ bundle exec rake db:migrate

エラーが発生しなければ、無事グループとユーザのモデルが完成です。

routes.rbでResourceをネストする

さて、この時点で参照関係にあるモデルが生成されましたが、あくまでもそれぞれ別のリソースとして生成しているので、ルーティングはデフォルトのまま、それぞれ

http://www.example.com/groups/1
http://www.example.com/users/1

のようなアクセスの仕方しかできません。
これを目的のようなルーティングにするためには、Railsでルーティングの設定を行っている、config/routes.rbを編集する必要があります。Scaffold後ならば以下のようにデフォルトで追記されているはずです。

config/routes.rb

Rails.application.routes.draw do
  resources :users
  resources :groups
# 略 

ここで記載されているResourceがキモで、CRUD操作に必要な様々なルーティングがこの一行に集約されています。これらはネストすることができ、ネストすることで、目的とするルーティングが実現できます。具体的には以下のように直します。

config/routes.rb (修正版)

Rails.application.routes.draw do
  resources :groups do
    resources :users
  end
# 略

このように記載することで、RailsはGroupsの配下にUsersを持つようなルーティングをよしなに実現してくれます。試しに/groupsにアクセスし、適当なgroupを作成して、/groups/1/usersにアクセスしてみましょう。

f:id:watass:20150809115023p:plain

こんな感じで、そのままアクセスすると"undefined local variable or method `new_user_path' for ~"というエラーが出ます。ルーティングをネストして修正したため、ビューがScaffold時に生成されたものと辻褄があわなくなっているわけですね。ここからはそれらを修正していきます。

ネストされたResourceのコントローラー

まず、ネストされたResourceである、Userのコントローラーを修正していきます。app/controllers/users_controller.rbを見るとわかりますが、デフォルトではnewアクションを実行する際に、単純にUserモデルから新しいインスタンスを生成しています。

app/controllers/users_controller.rb (抜粋)

def new
  @user = User.new
end

しかし、これではUserモデルのインスタンスはどのGroupに属するかの情報を持っていません。そのため、以下のように作成したいユーザの所属するGroupを探索した上で、その要素として@userを作成します。

app/controllers/users_controller.rb (修正版:抜粋)

def new
  @group = Group.where(:id => params[:group_id]).first
  @user = @group.users.build
end

これにより、@userは自分が所属すべきGroupを知ることができます。/rails/info/routeを見てもわかるように、この場合には所属するべきURLのグループの値はgroup_idとしてparamsから参照することができますので、それでGroupモデルから探索します。見つけられた@groupのusersを作成します。
ただし、GroupモデルのインスタンスはUserモデルを複数持ちえることをまだ知らないので、app/models/group.rbを以下のように修正しておきます。

app/models/group.rb

class Group < ActiveRecord::Base
  has_many :users
end

has_many句を記載することで、GroupモデルはUserモデルのインスタンス複数持ちえることをRailsに伝えることができます。ちなみに、Userモデルにも、Groupモデルを参照することをapp/models/user.rbに記載する必要があるのですが、これはScaffold時点で生成されています。せっかくなので見ておきましょう。

app/models/user.rb

class User < ActiveRecord::Base
  belongs_to :group
end

こんな感じでbelongs_to句が記載されています。Scaffold時のgroup:referencesがこれを吐き出してくれるんですね。
そんなわけで、Userモデルのインスタンス作成や、探索時にまず作成するべきUserがどのGroupに所属するかを探索するように全部修正します。

app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :set_user, only: [:show, :edit, :update, :destroy]

  # GET /users
  # GET /users.json
  def index
    #@users = User.all
    @group = Group.where(:id => params[:group_id]).first
    @users = @group.users.all
  end

  # GET /users/1
  # GET /users/1.json
  def show
  end

  # GET /users/new
  def new
    #@user = User.new
    @group = Group.where(:id => params[:group_id]).first
    @user = @group.users.build
  end

  # GET /users/1/edit
  def edit
  end

  # POST /users
  # POST /users.json
  def create
    #@user = User.new(user_params)
    @group = Group.where(:id => params[:group_id]).first
    @user = @group.users.build(user_params)

    respond_to do |format|
      if @user.save
        #format.html { redirect_to @user, notice: 'User was successfully created.' }
        format.html { redirect_to [@group,@user], notice: 'User was successfully created.' }
        format.json { render :show, status: :created, location: @user }
      else
        format.html { render :new }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /users/1
  # PATCH/PUT /users/1.json
  def update
    respond_to do |format|
      if @user.update(user_params)
        #format.html { redirect_to @user, notice: 'User was successfully updated.' }
        format.html { redirect_to [@group,@user], notice: 'User was successfully updated.' }
        format.json { render :show, status: :ok, location: @user }
      else
        format.html { render :edit }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /users/1
  # DELETE /users/1.json
  def destroy
    @user.destroy
    respond_to do |format|
      #format.html { redirect_to users_url, notice: 'User was successfully destroyed.' }
      format.html { redirect_to group_users_url, notice: 'User was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_user
      #@user = User.find(params[:id])
      @group = Group.where(:id => params[:group_id]).first
      @user = @group.users.where(:id => params[:id]).first
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def user_params
      params.require(:user).permit(:group_id, :name, :profile)
    end
end

show,edit,update,destroyの操作は事前にset_userメソッドが実行されますので、そちらでまとめて探索の操作を記述しておきます。

また、create,updateの操作では、処理を行った後にredirect_toで元のユーザのページへリダイレクトを掛けるのですが、単純に@userを指定すると、またどのGroupに所属するユーザへリダイレクトすればいいかわからなくなってしまいます。そのため、redirect_toの引数には@userと@groupの配列を与えます。
さらに、destroyの操作では、redirect_toの引数にusers_urlというurl_helperを与えていますが、これもどのGroupのユーザリストへリダイレクトすればいいかわからないので、group_users_urlを指定します。

ちなみに正確には、どのGroupに所属するかわからないというか、該当するurl_helperが定義されていないためにエラーになっています。これは/rails/info/routeを見れば明らかで、users_pathが存在しておらず、group_users_pathが存在していることがわかるでしょう。

ネストされたResourceのビュー

以上でcontrollerおよびmodelの修正は完了なんですが、ビューの方も問題になってきます。Scaffoldで生成されるビューにはindex,edit,new,showなどがあります。また、フォームは共通テンプレートとして切りだされています。まずはindexを直します。

app/views/users/index.html.slim

h1 Listing users

table
  thead
    tr
      th Group
      th Name
      th Profile
      th
      th
      th

  tbody
    - @users.each do |user|
      tr
        td = user.group
        td = user.name
        td = user.profile
        <!-- td = link_to 'Show', user -->
        td = link_to 'Show' , group_user_path(@group, user)
        <!-- td = link_to 'Edit', edit_user_path(user) -->
        td = link_to 'Edit', edit_group_user_path(@group, user)
        <!-- td = link_to 'Destroy', user, data: {:confirm => 'Are you sure?'}, :method => :delete -->
        td = link_to 'Destroy', group_user_path(@group, user) , data: {:confirm => 'Are you sure?'}, :method => :delete

br

<!-- = link_to 'New User', new_user_path -->
link_to 'New User', new_group_user_path

new_user_pathはResourceをネストしたことによって存在しないurl_helperになりましたので、/rails/info/routeを参考にnew_group_user_pathを指定します。また、link_toのShowで引数に与えているuserは、そのままだと、解決できるurl_helperが存在しませんので、group_user_path(@group, user)のようにurl_helperをしっかり明示します。

同様にedit,new,showも以下のように修正していきます。

app/views/users/edit.html.slim

h1 Editing user

== render 'form'

<!-- = link_to 'Show', @user -->
= link_to 'Show' , group_user_path(@group, @user)
'|
<!-- = link_to 'Back' , users_path -->
= link_to 'Back', group_users_path

app/views/users/new.html.slim

h1 New User

== render 'form'

<!-- =link_to 'Back', users_path -->
= link_to 'Back', group_users_path

app/views/users/show.html.slim

p#notice = notice

p
  strong Group:
  = @user.group
p
  strong Name:
  = @user.name
p
  strong Profile:
  = @user.profile

<!-- = link_to 'Edit', edit_user_path(@user) -->
= link_to 'Edit' , edit_group_user_path(@group, @user)
'|
<!-- = link_to 'Back', users_path -->
= link_to 'Back' , group_users_path

最後に、_form.html.slimを修正します。

app/views/users/_form.html.slim

<!-- = form_for @user do |f| -->
= form_for [@group,@user] do |f|
  - if @user.errors.any?
    #error_explanation
      h2 = "#{pluralize(@user.errors.count, "error")} prohibited this user from being saved:"
      ul
        - @user.errors.full_messages.each do |message|
          li = message

  .field
    = f.label :group
    = f.text_field :group
  .field
    = f.label :name
    = f.text_field :name
  .field
    = f.label :profile
    = f.text_area :profile
  .actions = f.submit

form_forに@userを指定すると、Groupについて不明な解決できないhelperを指定してしまいますので、form_forの引数には@groupとの配列を指定します。

以上でネストしたResourceの基本部分は完成です。

URLのIDを名前に変える

ここまでで、/groups/1/users/3のようなURLでアクセスすることができるようになりましたが、本来の目的は/groups/teama/users/taroのようなURLでアクセスすることでした。まぁ、URLのパラメータはgroup_idとしてcontrollerで参照できますので、users_controllerで以下のように修正することで、名前ベースのルーティングも実現できるようになるはずです。

app/controllers/users_controller.rb (抜粋)

def index
  #@group = Group.where(:id => params[:group_id]).first
  @group = Group.where(:name => params[:group_id]).first
  @user = @group.users.all
end

同様に他の、:id => params[:group_id]を:name => params[:group_id]に変えていきます。後は/groups/teama/usersにアクセスして、New Userから新規のユーザを作成すれば、これでアクセスできるように・・・

f:id:watass:20150809161419p:plain

ならない。

ポイントはURL。なぜか/groups/4/usersのようにgroupsの名前が消えてしまっています。これはform_forで生成されるPOST先のURLがそのようになっているためです。これでは本来の目的が達成できません・・・
これを何とかするためには、Modelに対して、何をパラメータとして使用するか宣言する必要があります。具体的には、それぞれ以下のように修正していきます。

app/models/group.rb

class Group < ActiveRecord::Base
  has_many :users
  def to_param
    name
  end
end

app/models/user.rb

class User < ActiveRecord::Base
  belongs_to :group
  def to_param
    name
  end
end

to_paramメソッドをModelでオーバーライドすることによって、id以外のカラムをURLのパラメータに指定することができます。なお、パラメータをid以外に指定した場合、app/controllers/groups_controller.rbのset_groupのfindメソッドでGroupを探索できないので、編集しておきましょう。

app/controllers/groups_controller.rb (抜粋)

  def set_group
    #@group = Group.find(params[:id])
    @group = Group.where(:name => params[:id]).first

これで目的とするURLにアクセスすると・・・

f:id:watass:20150809164415p:plain

できました!

まとめ

  • Scaffoldで生成したResourceはネストできる
  • ネストしたResourceはurl_helperがそのままでは解決できないので修正する
  • id以外をURLのパラメータに使用したい場合には、モデルのto_paramメソッドをオーバライドする

Rails楽しいです

Ruby on Railsには「設定より規約」という思想がありますが、FuelPHPを触っていた身からすると、確かにその恩恵とデメリットをよく感じました。ちなみに、FuelPHPは「規約より設定」が設計思想にあります。

慣れてしまえば、きっと開発コストを大幅に下げることができるのでしょうが、最初に規約というか、作法を学ぶ部分にコストがかかってくる感じですね。好みの問題もあると思いますが、未だに最前線で活躍しているフレームワークですし、追従するフレームワークRailsに倣う形になる可能性が高いことを考えると、慣れておいて損は無い気がします。

参考

Railsでリソースを入れ子にする - Qiita
# 一番参考にさせてもらいました。ありがとうございました。
Ruby on Railsのルーティングでresourcesが生成するURLにおけるID以外によるレコード指定 | Articles@www.e-mist.com
# to_paramメソッドのことを知りました。調べる限りだと、parameterizeなどしなくてもよさそうでした。
rails routing show action nested resources - Stack Overflow
# ネスト時のurl_helperの指定の仕方の参考になりました。