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をセレクトボックスから選択してから保存・更新すると、選択したBookとUserを関連付けるBookReservationを作成できます - 削除したい
BookとUserの関連付けBookReservationの部分に「Delete」にチェックボックスを入れた状態で更新すると、該当するBookReservationを削除できます
実装
class User < ApplicationRecord
has_many :book_reservations, dependent: :destroy
has_many :books, through: :book_reservations
end
class Book < ApplicationRecord
has_many :book_reservations, dependent: :destroy
has_many :users, through: :users
validates :name, presence: true
end
class BookReservation < ApplicationRecord
belongs_to :user
belongs_to :book
validates :user_id, uniqueness: { scope: :book_id }
end
ActiveAdminでは、has_manyを使ってモデルの作成や更新、削除をできるようにする場合、ネステッド属性を使用するため、モデルにaccepts_nested_attributes_forを指定する必要があります。
"user"=>{"name"=>"ユーザ1", "book_reservations_attributes"=>{"0"=>{"book_id"=>"1"}}}
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_paramsにbook_reservation_attributesを追加し、idとその他の操作する属性を指定します。
削除ができるようにする場合は、_destroy も指定するようにしてください。
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の更新を試みた場合は、バリデーションエラーが何も表示されずに、編集画面に戻されます
BookReservationには、モデルにuser_idとbook_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_appsWHERE (permitted_apps.app_id= BINARY 100 ANDpermitted_apps.account_idIS NULL) LIMIT 1
PermittedApp Exists (0.1ms) SELECT 1 AS one FROMpermitted_appsWHERE (permitted_apps.app_id= BINARY 100 ANDpermitted_apps.account_idIS NULL) LIMIT 1
And only than insertsSQL (4.8ms) INSERT INTO
permitted_apps(account_id,app_id,created_at,updated_at) VALUES (23, 100, 1430981968, 1430981968)
SQL (1.1ms) INSERT INTOpermitted_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_appsWHERE (permitted_apps.app_id= BINARY 100 ANDpermitted_apps.account_idIS NULL) LIMIT 1
PermittedApp Exists (0.1ms) SELECT 1 AS one FROMpermitted_appsWHERE (permitted_apps.app_id= BINARY 100 ANDpermitted_apps.account_idIS 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 INTOpermitted_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が重複している場合は保存せずエラーを追加して表示するようにしました。
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にはチェックが入っている状態になります
実装
-
has_manyを使うケースでは必要なaccepts_nested_attributes_forは必要ありません
- accepts_nested_attributes_for :book_reservations, update_only: true, allow_destroy: true
-
permit_paramsには、book_reservation_attributes:の代わりに、:book_idsを指定します - コントローラーをオーバーライドして、
UserやBookReservationの作成・更新・削除を行います
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を指定しても解決しませんでした
-
さいごに
次回の記事では、一つのチェックボックスを選択することで、全てのチェックボックスを選択できるようにします。


