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から追っていきます。
環境の準備
まずは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にアクセスしてみましょう。
こんな感じで、そのままアクセスすると"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から新規のユーザを作成すれば、これでアクセスできるように・・・
ならない。
ポイントは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にアクセスすると・・・
できました!
まとめ
- 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の指定の仕方の参考になりました。