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