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_apps
WHERE (permitted_apps
.app_id
= BINARY 100 ANDpermitted_apps
.account_id
IS NULL) LIMIT 1
PermittedApp Exists (0.1ms) SELECT 1 AS one FROMpermitted_apps
WHERE (permitted_apps
.app_id
= BINARY 100 ANDpermitted_apps
.account_id
IS 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_apps
WHERE (permitted_apps
.app_id
= BINARY 100 ANDpermitted_apps
.account_id
IS NULL) LIMIT 1
PermittedApp Exists (0.1ms) SELECT 1 AS one FROMpermitted_apps
WHERE (permitted_apps
.app_id
= BINARY 100 ANDpermitted_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 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
を指定しても解決しませんでした
-
さいごに
次回の記事では、一つのチェックボックスを選択することで、全てのチェックボックスを選択できるようにします。