2
1

ActiveAdminで、多対多の関連付け(中間テーブル)レコードを追加する

Last updated at Posted at 2024-08-19

ActiveAdminで1対多や多対多の関係のあるモデルでは、has_many メソッドを使うことで、多のモデルをもう一方のモデルのフォームで追加・更新できるようにできます。(https://activeadmin.info/5-forms.html)

本記事では、そのhas_manyを使う方法と、もう一つ別のアプローチで、多対多のモデルを関連づける中間テーブルのレコードを作成・更新できるようにします。

ここでは、以下のようなモデル構成での実装を示します。
Userのページで、Bookとの中間テーブルである、複数のBookReservationを作成・更新・削除できるようにします。

動作環境

  • ruby 3.2.2
  • rails 7.0.8
  • activeadmin 2.14.0

has_manyを使用する場合

画面

  • 「Add New Book reservation」をクリックすると、続々とBookの選択フォームが追加されます。「Remove」をクリックすると選択フォームが1つ消えます
  • Userの作成・編集ページで、Bookをセレクトボックスから選択してから保存・更新すると、選択したBookUserを関連付けるBookReservationを作成できます
  • 削除したいBookUserの関連付けBookReservationの部分に「Delete」にチェックボックスを入れた状態で更新すると、該当するBookReservationを削除できます

スクリーンショット 2024-05-31 14.01.19.png

実装

app/models/user.rb
class User < ApplicationRecord
  has_many :book_reservations, dependent: :destroy
  has_many :books, through: :book_reservations
end
app/models/book.rb
class Book < ApplicationRecord
  has_many :book_reservations, dependent: :destroy
  has_many :users, through: :users

  validates :name, presence: true
end
app/models/book_reservation.rb
class BookReservation < ApplicationRecord
  belongs_to :user
  belongs_to :book

  validates :user_id, uniqueness: { scope: :book_id }
end

ActiveAdminでは、has_manyを使ってモデルの作成や更新、削除をできるようにする場合、ネステッド属性を使用するため、モデルにaccepts_nested_attributes_forを指定する必要があります。

ActiveAdminでレコード作成・更新時に扱われるパラメータ
"user"=>{"name"=>"ユーザ1", "book_reservations_attributes"=>{"0"=>{"book_id"=>"1"}}}
app/models/users.rb
class User < ApplicationRecord
  has_many :book_reservations, dependent: :destroy
  has_many :books, through: :book_reservations

  validates :name, presence: true

  accepts_nested_attributes_for :book_reservations, update_only: true, allow_destroy: true
end

また、ネステッド属性を利用するために、ActiveAdminのpermit_paramsbook_reservation_attributesを追加し、idとその他の操作する属性を指定します。
削除ができるようにする場合は、_destroy も指定するようにしてください。

app/admin/user.rb
permit_params :name, book_reservation_attributes: %i[id name _destroy]

form do |f|
    inputs do
      input :name
    end

    inputs do
      f.has_many :book_reservations, allow_destroy: true, remove_record: true, new_record: true do |b|
        b.input :book, as: :select, collection: Book.all, include_blank: false
      end
    end

    f.actions
end

問題点

  • 同じBookを複数選択した状態で、Userの作成を試みると、PostgreSQLのエラーが例の真っ赤なエラー画面で表示されてびっくりします
  • 既存のUserの更新を試みた場合は、バリデーションエラーが何も表示されずに、編集画面に戻されます
    スクリーンショット 2024-05-31 14.29.41.png

BookReservationには、モデルにuser_idbook_idの組み合わせでユニーク制約を指定しているのですが、そのバリデーションエラーが発生していないようです。

これと同様の問題に関する質問が、ActiveAdminのリポジトリにもありました。

the reason of that is that AR try to create nested attributes in bulk query (not one by one) after performing all validations.

so validations

PermittedApp Exists (0.2ms) SELECT 1 AS one FROM permitted_apps WHERE (permitted_apps.app_id = BINARY 100 AND permitted_apps.account_id IS NULL) LIMIT 1
PermittedApp Exists (0.1ms) SELECT 1 AS one FROM permitted_apps WHERE (permitted_apps.app_id = BINARY 100 AND permitted_apps.account_id IS NULL) LIMIT 1
And only than inserts

SQL (4.8ms) INSERT INTO permitted_apps (account_id, app_id, created_at, updated_at) VALUES (23, 100, 1430981968, 1430981968)
SQL (1.1ms) INSERT INTO permitted_apps (account_id, app_id, created_at, updated_at) VALUES (23, 100, 1430981968, 1430981968)
What you should do is add additional validator in your User model which will check uniqueness of nested accessible_apps

(引用元: https://github.com/activeadmin/activeadmin/issues/3935#issuecomment-104564150

以下は、Google翻訳で日本語化したものです。

その理由は、AR がすべての検証を実行した後、一括クエリ (1 つずつではなく) でネストされた属性を作成しようとするためです。

検証

PermittedApp Exists (0.2ms) SELECT 1 AS one FROM permitted_apps WHERE (permitted_apps.app_id = BINARY 100 AND permitted_apps.account_id IS NULL) LIMIT 1
PermittedApp Exists (0.1ms) SELECT 1 AS one FROM permitted_apps WHERE (permitted_apps.app_id = BINARY 100 AND permitted_apps.account_id IS NULL) LIMIT 1
そして挿入物よりも

SQL (4.8ms) INSERT INTO permitted_apps (account_id, app_id, created_at, updated_at) VALUES (23, 100, 1430981968, 1430981968)
SQL (1.1ms) INSERT INTO permitted_apps (account_id, app_id, created_at, updated_at) VALUES (23, 100, 1430981968, 1430981968)
ネストされた accessible_apps の一意性をチェックするバリデーターをユーザーモデルに追加する必要があります。

今回の場合に当てはめてみると、User のバリデーションを実行した後に、BookReservationのユニーク制約をチェックするバリデーションを実行することなく、作成・更新するBookReservation#book_idに値を入れているため、SQLのエラーが発生してこのようなことになっていると考えられます。

参考にした先ほどのコメントでは、Userのバリデーション内で、BookReservation のユニーク制約をチェックするようにすることが答えになっていました。

対応

  • モデルで対応しても良かったのですが、ActiveAdminのコントローラーをオーバーライドして対応しました。
  • 利用者にエラーであることを伝えるために、ActiveAdminのコントローラをオーバーライドし、諸々の処理の前にフォームの入力内容book_reservations_attributesをチェックし、book_idが重複している場合は保存せずエラーを追加して表示するようにしました。

スクリーンショット 2024-05-31 14.46.29.png

app/admin/users.rb
form do |f|
   f.semantic_errors(:base)

    ...
end

controller do
    def update
        book_reservations = permitted_params[:user][:book_reservations_attributes]

        if book_reservations
            book_ids = book_reservations.values.pluck('book_id')

            if book_ids.length != book_ids.uniq.length
                resource.errors.add(:base, '一つの本につき予約は1回のみです')
                render :edit
                return
            end
        end
        
        super
    end

    def create
        book_reservations = permitted_params[:user][:book_reservations_attributes]

        if book_reservations
            book_ids = book_reservations.values.pluck('book_id')

            if book_ids.length != book_ids.uniq.length
                build_resource.errors.add(:base, '一つの本につき予約は1回のみです')
                render :new
                return
            end
        end

        super
    end
 end

has_manyを使用しない方法

画面

  • has_manyを使うと、Userに関連づけるBookの数だけ選択フォームが増えていきますが、1つの選択フォームで、チェックボックスで複数選択できるようになります
  • Userのページで、チェックを入れたBookとのBookReservationを作成できます
  • 既に関連付けがされているBookにはチェックが入っている状態になります

スクリーンショット 2024-05-31 17.41.23.png

実装

  • has_manyを使うケースでは必要なaccepts_nested_attributes_forは必要ありません
app/models/users.rb
-  accepts_nested_attributes_for :book_reservations, update_only: true, allow_destroy: true
  • permit_params には、book_reservation_attributes:の代わりに、:book_idsを指定します
  • コントローラーをオーバーライドして、UserBookReservationの作成・更新・削除を行います
app/admin/users.rb
  permit_params :name, :book_ids
  
  form do |f|
    inputs do
      f.input :books, 
              as: :check_boxes, 
              collection: Book.pluck(:name, :id), 
              include_blank: false, 
              input_html: { multiple: true }
    end
    f.actions
  end

  controller do  
   def update
      super
      
      return if resource.invalid?
      
      # Unpermitted parameter なので、permitted_paramsではなく、paramsから取得する
      # book_idの重複は取り除く
      # パラメータparams[:user][:book_ids]の先頭要素に空文字が含まれるので除去する
      book_ids = params[:user][:book_ids].uniq.compact_blank

      if book_ids.present?
        # Userと指定されたBookのBookReservationが作成されてない場合は作成する
        book_ids.each do |book_id|
          resource.book_reservations.find_or_create_by!(book_id:)
        end
      end
      
      # 指定されてないBookとのBookReservationを全て削除する
      remove_reservations = resource.book_reservations.where.not(book_id: book_ids)
      remove_reservations.each(&:destroy!) if remove_reservations.present?
    end

    def create
      super
      
      return unless build_resource.persisted?
      
      # Unpermitted parameter なので、permitted_paramsではなく、paramsから取得する
      # book_idの重複は取り除く
      # パラメータの先頭要素に空文字が含まれるので除去する
      book_ids = params[:user][:book_ids].uniq.compact_blank
      
      return if book_ids.blank?
      
      # Userと指定されたBookのBookReservationを作成する
      book_ids.each do |book_id|
        build_resource.book_reservations.create!(book_id:)
      end
    end
  end
 end

問題点

  • ActiveAdminの本来のパラメータの使い方と異なるので、作成・更新を実行するたびに、このパラメータはUnpermitted parameterです、という内容の警告メッセージがログに出力されます
    • permit_params:book_ids を指定しても解決しませんでした

さいごに

次回の記事では、一つのチェックボックスを選択することで、全てのチェックボックスを選択できるようにします。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1