has_manyな関連先をまとめてINSERTする

fields_foraccepts_nested_attributes_forを使って、has_manyな関連先をまとめてINSERTする方法。ソースはgithubに上げておいた。ちなみにRails 3.0.8。

Post has_many Tags through Taggingsというモデルがあったとする。

Post has_many Tags through Taggings.

とりあえずscaffoldはこんな感じ。Taggingだけは画面が要らないのでmodelだけ。

$ rails g scaffold posts title:string text:text
$ rails g scaffold tags name:string
$ rails g model tagging post:references tag:references
$ rake db:migrate

コードの修正で重要なのは次の2点。

app/models/post.rb に accepts_nested_attributes_forを設定する。

class Post < ActiveRecord::Base
  has_many :taggings
  has_many :tags, :through => :taggings
  accepts_nested_attributes_for :taggings
end

これで次のようなコードが実行された時にTaggingもまとめて作ってくれるようになる。

Post.create(
  :title => "タイトル", :text => "本文",
  :taggings_attributes => [
    { :tag_id => 1 }, { :tag_id => 2 }
  ]
)

app/views/posts/_form.html に fields_for を使ってリレーション先についてのフォームを作る。第一引数がtaggings_attributes[]になっているのがポイント。これはaccepts_nested_attributes_forに合わせて設定する。

  <div class="field">
    Tags<br />
    <% @taggings.each do |tagging| %>
      <%= f.fields_for "taggings_attributes[]", tagging do |tf| %>
        <%= tf.select :tag_id, Tag.all.map { |x| [x.name, x.id] } %>
      <% end %>
    <% end %>
  </div>

なお、 @taggings はapp/controllers/posts_controller.rbで事前に用意しておく。 @postからリレーションで辿らないのは、新規にPostを作るフォームでは@post.taggingsが必ず空なのでeachが回らないから。

  # GET /posts/new
  # GET /posts/new.xml
  def new
    @post = Post.new
    @taggings = Array.new(3) { Tagging.new }

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

  # GET /posts/1/edit
  def edit
    @post = Post.find(params[:id])
    @taggings = @post.taggings
  end

あとはサーバを起動して/tagsからタグを登録し、/postsから記事を投稿すればok。 一応どんな感じの画面になるのかスクリーンショットを載せておこう。

作り込むなら、

  • accepts_nested_attributes_for のreject_ifオプションでtag_idがblankなら無視する
  • dependentにdestroyを設定する
  • Taggingでpost_idとtag_idのペアが一致するものが複数できないように制限をかける

などの点が気になるけど、今回は置いておく。

accepts_nested_attributes_for自体はhas_oneでも使えて、それに合わせてfields_forの引数も単数形にすれば良い。詳しくはリファレンスを参照。

追記

fields_forのなかでidも指定してればUPDATEも走らせてくれる。

  <div class="field">
    Tags<br />
    <% @taggings.each do |tagging| %>
      <%= f.fields_for "taggings_attributes[]", tagging do |tf| %>
        <% if tagging.persisted? %>
          <%= tf.hidden_field :id, value: tagging.id %>
        <% end %>
        <%= tf.select :tag_id, Tag.all.map { |x| [x.name, x.id] } %>
      <% end %>
    <% end %>
  </div>

あと、Rails3.2.3以降では親のモデルにattr_accessibleを指定する必要がある。

class Post < ActiveRecord::Base
  has_many :taggings
  has_many :tags, :through => :taggings
  accepts_nested_attributes_for :taggings
  attr_accessible :taggings_attributes
end

ついでに、fields_forで末尾に付けた[]date_selectdatetime_selectヘルパメソッドは削除してしまうっぽい(ソースは追ってない)。prefixオプションをちゃんと指定してあげる必要がある。

tf.datetime_select :created_at, prefix: "post[taggings][]"