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

前回までのhas_oneな関連に限定した、複数のモデルを同時に保存するコード

まず、前回のコードは、あまりにも手抜きだったので、もう少しscaffoldに習った形式に書き直してみた。この後has_manyな関連に対応させることを目指して、以下のようにしてみた。

      • 既にRails2.0もリリースされ、Rails1.1.6時代の書き方では動作しない状況(end_form_tagなど)になりつつありますが、自分の頭が追いついていないので、ここではRails1.1.6仕様なコードを書いて、Rails1.2.6環境で動作確認しています。
  • まずはモデルを生成して...
script/generate model slip
script/generate model journal
# db/migrate/001_create_slips.rb
class CreateSlips < ActiveRecord::Migration
  def self.up
    create_table :slips do |t|
      t.column :number, :integer
      t.column :executed_on, :string
      t.column :total_yen, :integer
    end
  end

  def self.down
    drop_table :slips
  end
end

# db/migrate/002_create_journals.rb
class CreateJournals < ActiveRecord::Migration
  def self.up
    create_table :journals do |t|
      t.column :comment, :string
      t.column :yen, :integer
      t.column :slip_id, :integer
    end
  end

  def self.down
    drop_table :journals
  end
end
  • 上記設定をしてから、rake migrateを実行した。
  • その後、scaffoldの実行する。
script/generate scaffold slip
script/generate scaffold journal
  • 以下、ビューのコーディング
<%# ビュー: app/views/slips/new.rhtml %>

<h1>New slip</h1>

<%= start_form_tag :action => 'create' %>
  <%= render :partial=>'form' %>
  <%= submit_tag %>
<%= end_form_tag %>

<%= link_to 'Back', :action => 'list' %>
<%# ビュー: app/views/slips/edit.rhtml %>
<h1>Editing slip</h1>

<%= start_form_tag :action => 'update', :id => @slip %>
  <%= render :partial=>'form' %>
  <%= submit_tag %>
<%= end_form_tag %>

<%= link_to 'Show', :action => 'show', :id => @journal %> |
<%= link_to 'Back', :action => 'list' %>
<%# ビュー: app/views/slips/_form.rhtml %>
<%= error_messages_for 'slip' %>

<!--[form: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 class="journal">
    <%= render :partial=>'journals/header' %>
    <%= render :partial=>'journals/form' %>
  </table>
<!--[eoform:slip]-->
<%# ビュー: app/views/journals/_header.rhtml %>

<!--[header:journal]-->
<tr>
  <th>摘要</th>
  <th>金額</th>
</tr>
<!--[eoheader:journal]-->
<%# ビュー: app/views/journals/_form.rhtml %>

<tr>
  <td colspan="2">
    <%= error_messages_for 'journal' %>
  </td>
</tr>

<!--[form:journal]-->
<tr>
  <td>
    <%= text_field 'journal', 'comment'  %>
  </td>
  <td>
    <%= text_field 'journal', 'yen'  %>
  </td>
</tr>
<!--[eoform:journal]-->
  • 以下、コントローラーのコーディング
# コントローラー: app/controllers/slips_controller.rb
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
  • 以下、モデルのコーディング
# モデル: app/models/slip.rb
class Slip < ActiveRecord::Base
  has_one :journals, :dependent=>:destroy
  validates_presence_of :number, :executed_on, :total_yen
end

# モデル: app/models/journal.rb
class Journal < ActiveRecord::Base
  belongs_to :slip
  validates_presence_of :comment, :yen
end
/* スタイルシート: public/stylesheets/scaffold.css */
.journal {
  background-color: #eee;
  }
.journal #errorExplanation {
  margin-top: 20px;
  margin-bottom: 0;
  }


以上のコードを元に、has_manyな関連に対応してみる。

has_manyな関連に対応した、複数のモデルを同時に保存するコード

  • Slipモデルの関連をhas_manyに変更して...
# モデル: app/models/slip.rb
class Slip < ActiveRecord::Base
  has_many :journals, :dependent=>:destroy
  validates_presence_of :number, :executed_on, :total_yen
end
  • Journalモデルのインスタンスは配列として扱うことにする。(ここでは新規作成のデフォルトは4明細に設定)
# コントローラー: app/controllers/slips_controller.rb
class SlipsController < ApplicationController
...(途中省略)...
  def new
    @slip = Slip.new
    @journals = (1..4).map {Journal.new}
  end
...(途中省略)...
  • 伝票のrender :partial=>'journals/form'には、:collection=>@journalsオプションを追加して、繰り返し描画する。
<%# ビュー: app/views/slips/_form.rhtml %>
<%= error_messages_for 'slip' %>

<!--[form: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 class="journal">
    <%= render :partial=>'journals/header' %>
    <%= render :partial=>'journals/form', :collection=>@journals %>
  </table>
<!--[eoform:slip]-->
  • 明細行のtext_fieldには:indexオプションを追加して、パラメーターを階層化されたハッシュとして渡す。
    • auto_indexを利用した<%= text_field 'journal[]', 'comment' %>のような書き方では、新規作成する時にidがnilのため、うまく動かなかった。
<%# ビュー: app/views/journals/_form.rhtml %>
<% @journal = form %>

<tr>
  <td colspan="2">
    <%= error_messages_for 'journal' %>
  </td>
</tr>

<!--[form:journal]-->
<tr>
  <td>
    <%= text_field "journal", 'comment', :index=>@journal.index  %>
  </td>
  <td>
    <%= text_field 'journal', 'yen', :index=>@journal.index  %>
  </td>
</tr>
<!--[eoform:journal]-->
  • Journalモデルの中で、@journal.indexは以下のように定義しておいた。
    • 最初はTime.now.to_fとして、現在時刻を小数に変換したキーを利用していたが、タイミングによっては、CPUの処理が高速で同じキーがいくつも生成されてしまう*1ことがあったので、最終的にTime.now.hashに落ち着いた。
class Journal < ActiveRecord::Base
  belongs_to :slip
  validates_presence_of :comment, :yen
  
  # Time.now.hashで、現在の時刻をキーにした重複しない整数値が取得できると思っている。
  # この例では必要ないが、数値がマイナスだと、auto_complete_fieldがうまく機能しなかったので、絶対値(abs)を取ることにした。
  def initialize(*attr)
    super
    @index = Time.now.hash.abs
  end
  
  def index
    id || @index
  end
end
  • 以上の設定をしておくと、何も入力しない状態でsubmitボタンを押した時、params[:journal]は以下のようなハッシュ値として取得できる。
"{"948528444"=>{"yen"=>"", "comment"=>""}, 
  "948528790"=>{"yen"=>"", "comment"=>""}, 
  "948528880"=>{"yen"=>"", "comment"=>""}, 
  "948528850"=>{"yen"=>"", "comment"=>""}}
  • 取得したパラメーターは、コントローラーで以下のように処理する。
# コントローラー: app/controllers/slips_controller.rb
class SlipsController < ApplicationController
...(途中省略)...
  # params[:journal].map {}ブロックのindex_attrは、以下のように変化していく。
  # ["948528444", {"yen"=>"", "comment"=>""}]
  # ["948528790", {"yen"=>"", "comment"=>""}]
  # ["948528880", {"yen"=>"", "comment"=>""}]
  # ["948528850", {"yen"=>"", "comment"=>""}]
  #
  # つまり、[key, value]のペア配列として取得できるのだ。(valueの中身は属性のハッシュ)
  # よって、index_attr[1]で{"yen"=>"", "comment"=>""}が取得できることになる。
  def create
    @slip = Slip.new(params[:slip])
    @journals = params[:journal].map {|index_attr| Journal.new(index_attr[1])}

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

  rescue
    @journals.each {|journal| journal.valid?}
    render :action => 'new'
  end


以上で、とりあえずの目標である、has_manyな関連に対応して1アクションで2モデルを保存すること、は可能になった!(ホントに基本的な部分だけ)しかし、可能は可能になったが、果たしてこのような処理方法で問題ないのだろうか?業務システムでは似たような処理は少なからず必要になると思うが、皆さん、どのような方法で実現しているのだろう...。

今、感じている疑問や問題

  • 複数のインスタンスを一度に保存しようとすると、インスタンス同士を識別するキーが必要になると思うが、新規作成のidがnilの状況で、どのように処理するのが効率的なのだろうか?
    • 今回の例ではTime.now.hashを使ったが、カウンタを1ずつ増やす方法、ランダムな値を使う方法等も考えられる。
    • ちなみに現状では、もしもjournalのレコード数が10億に近づくと、idの値とダブってしまう可能性もある。(英字と組み合わせたキーにしておく方が良いかもしれない。)
  • 現状では、4明細固定の保存しかできない。入力がない行(現状では未入力エラーになるが)は保存すべきではない。(...かといって、全く明細がない伝票でも登録できてしまうのは問題だ。)
  • Ruby1.8までは、ハッシュは順序を保存することができない。しかし、伝票の明細行の順序は重要だ。現状では、明細行の順序が崩れてしまう可能性がある。
  • 伝票を入力していると、明細行の挿入・削除・コピーもやりたくなるのが人情。(acts_as_listを利用したいが、データベース保存前の状態で利用できるのだろうか?)
  • さらに明細行(Journal)が、has_manyなアイテムを保持している場合はどうしようか?その場合のparamsのキー表現はどうなる?
  • slips_controllerなのにjournalをコントロールしているような感じ。このような役割分担で、果たして良いのだろうか?

...んー、考え始めると疑問が尽きない...。

*1:Time.now.to_fは、例えば1199478407.50248のような小数を生成する。小数点の精度は常に5桁程度のようなので、これだと10万分の1秒のうちに2回以上Journal.newが繰り返されると、同じキーのJournalインスタンスが複数、生成されることになってしまう...。もっと小数の精度を上げて現在時刻を数値に変換する方法ってあるのだろうか?