Rails2.1でhas_manyな関連を設定してscaffold出来たとしたら...

scaffoldはモデル同士の関連は無視して*1、最も基本的なwebアプリケーションとしての骨格を生成してくれる。自分ではそれをお手本に、少しずつ拡張していくことが多い。しかし、モデル同士が無関係という状況はほとんどなく、ほとんど例外なくモデルにhas_manyやhas_one、belongs_toと書いて、毎回同じような修正をして行くことが多い。(そして、ちょっとした間違いでエラーをもらうことが多い。)そんな時、もしscaffoldがやってくれたら、どんなコードを生成してくれるのだろう?Rails2.1でサンプルプロジェクト「Q&Aサイト」を作りながら、試行錯誤してみた。

いつもの手順

$ rails QandA
$ script/generate scaffold question name:string body:text
$ script/generate scaffold answer name:string body:text question_id:integer
$ rake db:migrate
$ script/server
  • 以上で、まだ質問と答えは無関係な状態だが、動くものは出来てしまう。

migrationの書き方

  • migrationファイルで外部キーを追加する時、以下の書き方でもOKになった。
class CreateAnswers < ActiveRecord::Migration
  def self.up
    create_table :answers do |t|
      t.string :name
      t.text :body

      # t.integer :question_id
      # 上記は以下と同等
      t.references :question

      t.timestamps
    end
  end
...(中略)...
  • しかし、scaffoldの引数で使うと、コントローラーやビューの中で「@answers.question」と展開されてしまい、エラーになる。(本来は「@answers.question_id」となって欲しい。)今イチな感じ。

rake db:migrateによるDB操作

  • rake db:migrateのバージョン管理コードが14桁の日付と時刻*2を羅列した数字になり、以下の操作が可能になった。
# バージョン指定したマイグレーションファイルのみdownする
$ rake db:migrate:down VERSION=20080720072122

# バージョン指定したマイグレーションファイルのみupする
$ rake db:migrate:up VERSION=20080720072122
  • これは個人的には嬉しい変更だ。その他のテーブルの保存状態には影響を与えずに、指定したテーブルだけ修正できる。
  • バージョン管理コードは長ったらしいので、ファイル名をコピーしている。ファイル名そのままを指定してもOKだった。
# ファイル名に拡張子まで含まれていても正常に処理された
$ rake db:migrate:up VERSION=20080720072122_create_questions.rb
  • rake db:migrate:reset
    • マイグレーションを最初からやり直して、DBを上書きする。(つまり、保存されているデータはすべて削除される)
  • db:migrate:redo
    • rake db:rollbackとrake db:migrateの合わせ技らしい。しかし、自分の環境ではdb:migrateの処理がschema.rbに反映されないようだ。(DBには反映されている。改めてrake db:migrateを実行するとschema.rbはDBの状態と一致する。これじゃredoの意味が無い。)
参考ページ
マイグレーション関連のRakeタスクの再確認 - ひげろぐ(感謝です!)

モデルの修正 app/models/question.rb, answer.rb

class Question < ActiveRecord::Base
  has_many :answers
end

class Answer < ActiveRecord::Base
  belongs_to :question
  attr_protected :question_id
  # または
  # attr_accessible :name, :body
end

ルートの変更 config/routes.rb

  • 「質問」あっての「答え」なので、ルート設定を:has_manyな関係に変更した。
ActionController::Routing::Routes.draw do |map|
  # map.resources :answers

  map.resources :questions, :has_many=>answers

  # Install the default routes as the lowest priority.
  map.connect ':controller/:action/:id'
  map.connect ':controller/:action/:id.:format'
end
# 以下、Answerモデルにアクセスするhtmlのルートのみ。xmlのルートは省略した。
              question_answers GET    /questions/:question_id/answers                  {:controller=>"answers", :action=>"index"}
                               POST   /questions/:question_id/answers                  {:controller=>"answers", :action=>"create"}
           new_question_answer GET    /questions/:question_id/answers/new              {:controller=>"answers", :action=>"new"}
          edit_question_answer GET    /questions/:question_id/answers/:id/edit         {:controller=>"answers", :action=>"edit"}
               question_answer GET    /questions/:question_id/answers/:id              {:controller=>"answers", :action=>"show"}
                               PUT    /questions/:question_id/answers/:id              {:controller=>"answers", :action=>"update"}
                               DELETE /questions/:question_id/answers/:id              {:controller=>"answers", :action=>"destroy"}

answer側の変更

上記のルート設定に合わせて修正すると、以下のようになった。

コントローラー
  • コメントマーク「##」二つの行が修正箇所。次の行で修正している。
  • before_filterで常に@questionを取得するようにした。
  • オプジェクトをそのままキーとする場合は、配列で[@question, @answer]とすることで「questions/1/answers/1」というパスを取得できた。(@question.id=1, @answer.id=1の場合)
  • answersコントローラー内(同一コントローラー内)であれば、question_answers_url(@question)は、引数を省略してquestion_answers_urlでもOKだった。
  • create, updateアクションではパラメーター詐称防止のため、以下のように書いている。
    • 「if @question.answers << @answer」
    • 「if @answer.update_attributes(params[:answer].merge(:question_id=>params[:question_id]))」
# ---------- app/controllers/answers_controller.rb ----------
class AnswersController < ApplicationController
  before_filter :set_up
  
  # GET /answers
  # GET /answers.xml
  def index
  ##@answers = Answer.find(:all)
    @answers = @question.answers

    respond_to do |format|
      format.html # index.html.erb
      format.xml  { render :xml => @answers }
    end
  end

  # GET /answers/1
  # GET /answers/1.xml
  def show
  ##@answer = Answer.find(params[:id])
    @answer = @question.answers.find(params[:id])

    respond_to do |format|
      format.html # show.html.erb
      format.xml  { render :xml => @answer }
    end
  end

  # GET /answers/new
  # GET /answers/new.xml
  def new
    @answer = Answer.new

    respond_to do |format|
      format.html # new.html.erb
      format.xml  { render :xml => @answer }
    end
  end

  # GET /answers/1/edit
  def edit
  ##@answer = Answer.find(params[:id])
    @answer = @question.answers.find(params[:id])
  end

  # POST /answers
  # POST /answers.xml
  def create
    @answer = Answer.new(params[:answer])

    respond_to do |format|
    ##if @answer.save
      if @question.answers << @answer
        flash[:notice] = 'Answer was successfully created.'
      ##format.html { redirect_to(@answer) }
        format.html { redirect_to([@question, @answer]) }
      ##format.xml  { render :xml => @answer, :status => :created, :location => @answer }
        format.xml  { render :xml => @answer, :status => :created, :location => [@question, @answer] }
      else
        format.html { render :action => "new" }
        format.xml  { render :xml => @answer.errors, :status => :unprocessable_entity }
      end
    end
  end

  # PUT /answers/1
  # PUT /answers/1.xml
  def update
  ##@answer = Answer.find(params[:id])
    @answer = @question.answers.find(params[:id])

    respond_to do |format|
    ##if @answer.update_attributes(params[:answer].merge(:question_id=>params[:question_id]))
    # attr_protected :question_idを利用することで.merge(:question_id=>params[:question_id])は不要になった
    if @answer.update_attributes(params[:answer])
        flash[:notice] = 'Answer was successfully updated.'
      ##format.html { redirect_to(@answer) }
        format.html { redirect_to([@question, @answer]) }
        format.xml  { head :ok }
      else
        format.html { render :action => "edit" }
        format.xml  { render :xml => @answer.errors, :status => :unprocessable_entity }
      end
    end
  end

  # DELETE /answers/1
  # DELETE /answers/1.xml
  def destroy
  ##@answer = Answer.find(params[:id])
    @answer = @question.answers.find(params[:id])
    @answer.destroy

    respond_to do |format|
    ##format.html { redirect_to(answers_url) }
      format.html { redirect_to(question_answers_url) } # 引数(@question)は省略してOK
      format.xml  { head :ok }
    end
  end

private
  def set_up
    @question = Question.find(params[:question_id])
  end
end
ビュー
  • link_toのリンク先を、has_manyなルート設定に合わせて修正した。
<%# --------- app/views/answers/index.html.erb ---------- %>
<h1>Listing answers</h1>

<table>
...(中略)...
<% for answer in @answers %>
  <tr>
    <td><%=h answer.name %></td>
    <td><%=h answer.body %></td>
    <td><%=h answer.question_id %></td>

        <%#=link_to 'Show', answer %>
    <td><%= link_to 'Show', [@question, answer] %></td>
        <%#=link_to 'Edit', edit_answer_path(answer) %>
    <td><%= link_to 'Edit', edit_question_answer_path(@question, answer) %></td>
        <%#=link_to 'Destroy', answer, :confirm => 'Are you sure?', :method => :delete %>
    <td><%= link_to 'Destroy', [@question, answer], :confirm => 'Are you sure?', :method => :delete %></td>
  </tr>
<% end %>
</table>

<br />

<%#=link_to 'New answer', new_answer_path %>
<%= link_to 'New answer', new_question_answer_path %><%# 引数(@question)は省略してOK %>
  • form_forでも配列を使って[@question, @answer]と書ける。
<%# --------- app/views/answers/edit.html.erb ---------- %>
<h1>Editing answer</h1>

<%#form_for(@answer) do |f| %>
<% form_for([@question, @answer]) do |f| %>
...(中略)...
<% end %>

<%#=link_to 'Show', @answer %>
<%= link_to 'Show', [@question, @answer] %> |
<%#=link_to 'Back', answers_path %>
<%= link_to 'Back', question_answers_path %><%# 引数(@question)は省略してOK %>
  • new.html.erbは、上記のedit.html.erbとほとんど同じなので、サンプルコードは省略。
<%# --------- app/views/answers/show.html.erb ---------- %>
...(中略)...
<%#=link_to 'Edit', edit_answer_path(@answer) %>
<%= link_to 'Edit', edit_question_answer_path(@question, @answer) %> |
<%#=link_to 'Back', answers_path %>
<%= link_to 'Back', question_answers_path %><%# 引数(@question)は省略してOK %>

question側の変更

「質問」から「答え」へのリンクを作成してみた。

<%# ---------- app/views/questions/index.html.erb ---------- %>
<h1>Listing questions</h1>

...(中略)...
<% for question in @questions %>
  <tr>
    <td><%=h question.name %></td>
    <td><%=h question.body %></td>
    <td><%= link_to 'Answers', question_answers_path(question) %></td><%# <---追記 %>
    <td><%= link_to 'Show', question %></td>
    <td><%= link_to 'Edit', edit_question_path(question) %></td>
    <td><%= link_to 'Destroy', question, :confirm => 'Are you sure?', :method => :delete %></td>
  </tr>
<% end %>
...(中略)...
  • questionsコントローラーからanswersコントローラーへのパス指定(異なるコントローラーへのパス指定)については、引数を省略することは出来なかった。
<%# ---------- app/views/questions/show.html.erb ---------- %>
...(中略)...
<%= link_to 'Edit', edit_question_path(@question) %> |
<%= link_to 'Back', questions_path %> |
<%= link_to 'Answers', question_answers_path(@question) %>


以上で完了。全く実用的じゃないけど、:has_manyなルート設定のパスの指定方法は分かってきた!

*1:と思っている。もし、モデル同士の関連も踏まえてコードを展開してくれたら、相当嬉しい。

*2:UTC(日本の時刻は9時間進んでいる。)