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

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

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

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

## 動作環境
- 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`を削除できます

![スクリーンショット 2024-05-31 14.01.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542845/c5e25e6c-6a9a-b1db-f1f3-4e43df6f6060.png)


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

  validates :name, presence: true
end
```
```ruby: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`を指定する必要があります。
```json:ActiveAdminでレコード作成・更新時に扱われるパラメータ
"user"=>{"name"=>"ユーザ1", "book_reservations_attributes"=>{"0"=>{"book_id"=>"1"}}}
```
```ruby: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_params`に`book_reservation_attributes`を追加し、`id`とその他の操作する属性を指定します。
削除ができるようにする場合は、`_destroy` も指定するようにしてください。
```ruby: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542845/2bd0ca4b-88be-762d-996d-8a13404aed00.png)


`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 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542845/df025420-516e-ec25-4427-db93069f8003.png)

```rb: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542845/c47dff62-e085-8ddd-bbab-59c25817822c.png)


### 実装
* `has_many`を使うケースでは必要な`accepts_nested_attributes_for`は必要ありません
```diff:app/models/users.rb
-  accepts_nested_attributes_for :book_reservations, update_only: true, allow_destroy: true
```
* `permit_params` には、`book_reservation_attributes:`の代わりに、`:book_ids`を指定します
* コントローラーをオーバーライドして、`User`や`BookReservation`の作成・更新・削除を行います
```ruby: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` を指定しても解決しませんでした

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