ピスタチオを食べながらrailsを楽しむ

ピスタチオ大好きな著者のrailsを使ったツール作成の日記です。

1対多の関係で同時に作成/更新する

1対多の関係で"1"を作成(更新)する際に"多"も一緒に作成(更新)する。

例。

  • UserとAccountがある。
  • User作成時にUserに関連するAccountを作成したい。
  • Accountは同時に複数作成したい。

以下サンプル。migrationファイルは以下のような感じ。
便宜上indexは省略。また、accountsはhas_secure_passwordを使うものとする。

# db/migrate/20150215225710_create_users.rb
class CreateUsers < ActiveRecord::Migration
  def change
    create_table :users do |t|
      t.string :username

      t.timestamps
    end
  end
end

# db/migrate/20150215225801_create_accounts.rb
class CreateAccounts < ActiveRecord::Migration
  def change
    create_table :accounts do |t|
      t.string :account
      t.string :password_digest
      t.string :remember_token
      t.integer :user_id

      t.timestamps
    end
  end
end

この状態でrake db:migrateをする。 そしてmodelを以下のようにする。validationは省略。

# app/models/user.rb
class User < ActiveRecord::Base

  has_many :accounts
  accepts_nested_attributes_for :accounts, allow_destroy: true, reject_if: all_blank
end

# app/models/account.rb
class Account < ActiveRecord::Base

  belongs_to :user

  has_secure_password

end

ここでポイントが3つ。作成/更新作業を行うmodel(この例ではUser)に

  1. accepts_nested_attributes_for
  2. reject_if
  3. allow_destroy

を記述する。これによりUserフォーム上で

  1. 指定した関連テーブルも同時に作成/更新する
  2. 指定した条件下においてAccountのvalidationを無効にする
  3. 更新時に関連するAccountを削除する

ことができるようになる。

なお、この例ではreject_if: :all_blankとしているので
Accountフィールド内が全て空の場合のみAccountのvalidationを無効にしている。
これにより関連先を同時に作成したくない場合にも対応ができる。

次にController。

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :set_user, only: [:edit, :update, :destroy]

  def new
    @user = User.new
    @user.accounts.build
  end

  def create
    @user = User.new(user_params)
    if @user.save
      flash[:success] = "登録しました"
      redirect_to users_url
    else
      render 'new'
    end
  end

  def index
    @users = User.all
  end

  def edit
  end

  def update
    if @user.update(user_params)
      flash[:success] = "更新しました"
      redirect_to users_url
    else
      render 'edit'
    end
  end

  def destroy
    @user.destroy
    redirect_to index_url
  end

  private

    def set_user
      @user = User.find(params[:id])
    end

    def user_params
      params
      .require(:user)
      .permit(
        :username,
        account_attributes: [
          :id,
          :account,
          :password,
          :password_confirmation,
          :_destroy
        ]
      )
    end

end

ここでのポイントはズバリstrong_parameters。

  • 関連先のカラムも更新出来るようにするためaccounts_attributesをpermitに追加し配列でaccount側のフィールドを渡す。
  • :idはUser更新フォームで関連するAccountも同時に更新するために必要。これがないとrailsは関連先のidが分からないため表示されず更新ができない。
  • :_destroyはUser更新フォームで関連するAccountの削除をする場合に使う。booleanで値を受け取りtrueの場合には関連先を削除してくれる。

他、newアクションではaccountも同時に作成するため@userのほかに@user.accounts.buildをする。
これによりnewアクションで関連するaccountのフォームが生成できるようになる。

次にview側。

# app/views/users/new.html.erb
<%= form_for(@user, html: { class: "form-horizontal"}) do |f| %>
  <%= render 'fields', f: f  %>
  <div class="form-group">
    <span class="col-sm-offset-2 col-sm-10">
      <%= f.submit "登録", class: "btn btn-primary" %>
    </span>
  </div>
<% end %>
# app/views/users/_fields.html.erb
<div class="form-group">
  <%= f.label :username, class: "control-label col-sm-2" %>
  <span class="col-sm-10">
    <%= f.text_field :username, class: "form-control" %>
  </span>
</div>

<%= f.fields_for :accounts do |f| %>
  <div class="form-group">
    <%= f.label :account, class: "control-label col-sm-2" %>
    <span class="col-sm-10">
      <%= f.text_field :account, class: "form-control" %>
    </span>
  </div>

  <div class="form-group">
    <%= f.label :password, class: "control-label col-sm-2" %>
    <span class="col-sm-10">
      <%= f.password_field :password, class: "form-control" %>
    </span>
  </div>

  <div class="form-group">
    <%= f.label :password_confirmation, class: "control-label col-sm-2" %>
    <span class="col-sm-10">
      <%= f.password_field :password_confirmation, class: "form-control" %>
    </span>
  </div>

  <% if @user.persisted? %>
    <div class="form-group">
      <%= f.label :_destroy, class: "control-label col-sm-2" %>
      <span class="col-sm-10">
        <%= f.check_box :_destroy %>
      </span>
    </div>
  <% end %>
<% end %>
  1. fields_forで関連するaccountを作成できる。
  2. @user.persistedな場合のみ:_destroyフィールドを用意しtrueな場合にはaccountを削除できるようにしている。

もしaccountを同時に複数作りたい場合にはfields_for以下のフィールドを切り出してrenderで呼び出しajaxで処理すれば良い。

最後にi18n関連。

# config/locales/ja.yml
ja:
  activerecord:
    attributes:
      user:
        username: ユーザー名
      user/accounts:
        account: アカウント
        password: パスワード
        password_confirmation: パスワード(確認用)

これが分からず結構ハマった。
account:配下に書いてもUserフォーム内では適用されないのでuser/accounts:として定義する必要がある。