1アクションで複数のモデルを同時に保存するには?

今更ながら基本的なことかもしれないが(自分では今も悩み続けている)、業務システムで入力する部分を作っていると、しばしば2つ以上のモデルを同時に保存したい状況に陥る。例えば、伝票を入力する場合であれば、伝票は複数の明細行を持っているので、共通情報を保持する伝票(Slip)クラスと、明細行情報を保持する明細行(Journal)クラスに分けている。そのようなシステムで、Submitボタンを押した後の処理は、模範的にはどのようにすべきなのだろうか?
保存する時には、以下の仕様を満たしておきたい。

  • class Slip {has_many :journals}; class Journal {belongs_to :slip}; な関連である。
  • 検証エラーが発生したら...
    • 保存は一切しない。
    • すべてのモデルの検証エラー情報を表示する。
  • Journalモデルの件数は可変である。

悩みのポイントは、Submitボタンを押した時に、2つの異なるモデルと、複数のJournalモデルを同時に保存すること。よくRailsの紹介で例題にあがるblogシステム開発などでは、最初に記事モデルを保存して、その後、投稿があればコメントモデルが一つずつ増えていくようなシステムだ。1アクションで保存するモデルは一つだけだ。二つ以上のモデルを同時に保存する例題は、今までほとんど見たことがなかった...。

scaffoldが生成するコード

まずは、モデルが一つだけの場合の標準的なコードを思い出してみる。(scaffoldが生成するコードを参考にして。)これなら、とてもシンプルで分かり易い。

<%# ビュー %>
<%= start_form_tag :action => 'update', :id => @slip %>
  <p><label for="slip_number">伝票No.</label><br/>
  <%= text_field 'slip', 'number'  %></p>

  <p><label for="slip_executed_on">実行日</label><br/>
  <%= text_field 'slip', 'executed_on'  %></p>

  <p><label for="slip_total_yen">合計金額</label><br/>
  <%= text_field 'slip', 'total_yen'  %></p>

  <%= submit_tag %>
<%= end_form_tag %>
# コントローラー
class SlipsController < ApplicationController
...(途中省略)...
  def new
    @slip = Slip.new
  end

  def create
    @slip = Slip.new(params[:slip])
    if @slip.save
      flash[:notice] = '新規作成しました。'
      redirect_to :action => 'list'
    else
      render :action => 'new'
    end
  end

  def edit
    @slip = Slip.find(params[:id])
  end

  def update
    @slip = Slip.find(params[:id])
    if @slip.update_attributes(params[:slip])
      flash[:notice] = _('変更を保存しました。')
      redirect_to :action => 'show', :id => @slip
    else
      render :action => 'edit'
    end
  end
...(途中省略)...
end

二つのモデルを同時に保存するコード(1:1の場合に限定)

次に伝票一つに1行だけ明細がある場合に限定して考えてみた。「RailsによるアジャイルWebアプリケーション開発 第2版*1を参考にして、以下のようにしてみた。

# モデル
class Slip
  has_one :journal
end

class Journal
  belongs_to :slip
end
<%# ビュー %>
<%= start_form_tag :action => 'update', :id => @slip %>
  <p><label for="slip_number">伝票No.</label><br/>
  <%= text_field 'slip', 'number'  %></p>

  <p><label for="slip_executed_on">実行日</label><br/>
  <%= text_field 'slip', 'executed_on'  %></p>

  <p><label for="slip_total_yen">合計金額</label><br/>
  <%= text_field 'slip', 'total_yen'  %></p>

  <table>
    <tr>
      <td>
        <label for="journal_comment">摘要</label><br/>
        <%= text_area 'journal', 'comment'  %>
      </td>

      <td>
        <label for="journal_yen">金額</label><br/>
        <%= text_field 'journal', 'yen'  %>
      </td>
    </tr>
  </table>

  <%= submit_tag %>
<%= end_form_tag %>

コントローラーでは、transactionとsave!を利用するところがポイントだ。transaction do 〜 endブロックの中の出来事は、すべての処理が成功するか、または一つでも例外エラーがあれば、すべての処理を無効にすることを保証してくれる。
つまり、SlipモデルとJournalモデルの両方に例外エラーがない時だけしか、データベースには保存されないのだ。例外エラーが片方だけにしかないときでも、例外エラーのないもう一方のモデルが保存されるということはない。transactionブロックの中の出来事は何もなかったことになるのだ。
save!は、validationエラーが発生した時、例外エラーを発生させてくれる。(通常のsaveでは、戻り値にfalseが設定されるだけ。)ここではtransactionに保存に失敗したことを知らせるためにsave!が必要になるのだ。通常のsaveではvalidationエラーが発生しても、そのまま処理が続いてしまう。その結果、片方のモデルだけ保存されるということになってしまう。

  • どちらでも結果は同じだと思うが、@slip.journal = @journalではなく、@journal.slip = @slipとするのは、無駄なDBアクセスをしないためだろうか?
  • rescueの中で、@journal.valid?を実行しているのは、すべてのモデルを検証するため。これがないと、Slipモデルで検証エラーが発生したとき、Journalモデルを未検証の状態でページを更新することになるので、ユーザーは伝票だけにしか検証エラーがないもとの勘違いしてしまうのだ。もしJournalモデルに検証エラーがあると、Slipモデルの検証エラーを修正した後に、再びJournalモデルの検証エラーが表示されて、ユーザーはがっかりしてしまうかもしれない...。
# コントローラー
class SlipsController < ApplicationController
...(途中省略)...
  def new
    @slip = Slip.new
    @journal = Journal.new
  end

  def create
    @slip = Slip.new(params[:slip])
    @journal = Journal.new(params[:journal])

    Slip.transaction do
      @journal.slip = @slip
      @slip.save!
      @journal.save!
      flash[:notice] = '新規作成しました。'
      redirect_to :action => 'list'
    end

  rescue
    @journal.valid?
    render :action => 'new'
  end

  def edit
    @slip = Slip.find(params[:id])
    @journal = @slip.journal
  end

  def update
    @slip = Slip.find(params[:id])
    @journal = @slip.journal

    Slip.transaction do
      @journal.slip = @slip
      @slip.save!
      @journal.save!
      flash[:notice] = '変更を保存しました。'
      redirect_to :action => 'show', :id => @slip
    end

  rescue
    @journal.valid?
    render :action => 'edit'
  end
...(途中省略)...
end

しかし、まだ目的の動作は達成できていない...。伝票には複数の明細があり、明細の行数は伝票によってマチマチなのだ。一度に複数行を管理しようとすると、益々複雑になってくる...。どのように書くのがシンプルかつ、拡張性のある書き方なのか...。長くなりそうなので、続きは後日。

*1:2つのモデルを同時に保存する方法を勉強したいが為に購入してしまった...。P455「1つのフォーム内の複数モデル」の項に、お手本となるサンプルコードがズバリ載っていたので。そして、購入して大正解。2つのモデルを同時に保存する問題だけに留まらず、今まで曖昧に理解していたことが、とても分かり易くその理由や仕組みと供に説明されていた!Railsではどうしてこのように書くのか、書くべきなのか、そのポリシーを自分なりに感じることができた。