QA@IT

Rails 4 で同じ関連テーブルを使い回して別テーブルに対する多対多関連を作りたい

5009 PV

こんばんは。Rails について余り詳しくなく、諸々調べたのですが今ひとつやり方が分からないのでご相談させて下さい。

以下のモデルがあります。

class Activity < AR::Base
class User < AR::Base
class Customer < AR::Base

ここで Activity に対して UserCustomer を多対多関連を使って紐付けたいです(例えば Activity 更新時には User および Custoemr への 参照のみ を更新し、User および Customer 自体の更新は行いたくありません)。UserCustomer は全く異なるエンティティで、異なるテーブルに永続化したいと考えています。Ruby のクラス上も関連はありません。概念的には Participant のような関連テーブルを作り、そこに UserCustomer をぶら下げれば良いと考えています。

なお、画面では Activity の編集時に User(の ID)を複数選択するコンボボックスと、Customer(の ID)を複数選択するコンボボックスが存在します。

これを実現しようと思って色々調べてみたのですが、幾つか疑問が出てきました。

has_many :through を使うことが出来るのか?

今回作ろうとしている関連を実現するには、has_many :through を使いつつ、関連テーブルとなる ParticipantUser もしくは Customer に紐付かなければなりません。これは Rails (ActiveRecord) で実現出来るのでしょうか?

class Participant < AR::Base
  belongs_to :activity
  belongs_to :#ここは一体どうすれば?

Participant にサブクラスを作れば良いのか、それとも上手く扱う方法があるのかが分かりませんでした。

どのように Controller を実装すれば良いのか?

これも Rails について調べても良く分からなかったのですが、今回のように多対多関連を更新する場合、accepts_nested_attributes_for を使うべきなのでしょうか?Scaffold されるソースコードを見ると、Rails では Strong Parameter を使って許可したパラメータをそのままマスアサイメントするのが普通のように思えます。これは多対多関連を更新する場合でもそうなのでしょうか?もしそうである場合、こんな感じになるのでしょうか?

class Activity < AR::Base
  has_many :participant_users :through => # ?
  has_many :participant_customers :through => # ?
class ActivitiesControlelr < ApplicationController

  def update
    @activity.update(activity_params)
  end

  private
    def activity_params
      params.permit(:activity).permit(: # snip ...
        :participant_users_attributes => [ :id ]
        :participant_customers_attributes => [ :id ]
      )
    end

これだと Participantactivity_id が設定されない気がします。また、例えば participant_usersparticipant_customers のように分かれるのではなく、participants のように UserCustomer を混ぜたい場合(サブクラスを持つような場合)、そのままマスアサイメントするのは難しいように思えます。これらを考えると多対多関連を使う場合はマスアサイメントせずに愚直に関連を構築していくのかな、と想像したのですが、実際のところ皆さんはどのようにされているのでしょうか?想像ですがこんな感じです。

class ActivitiesControlelr < ApplicationController

  def update
    @activity.attributes = activity_params # ところで attributes= で良いのでしょうか?
    activity_participants_params # を使って @activity.participants.create みたいに構築していく
    @activity.update() 
  end

  private
    def activity_params
      params.permit(:activity).permit(: # snip ...)
    end
    def activity_participants_params
      params.permit(:activity).permit(
        :participant_users_attributes => [ :id ]
        :participant_customers_attributes => [ :id ]
      )
    end

長文となり恐縮ですが教えて頂けると大変嬉しいです。「そもそも考え方が間違っている。こうした方が良いよ!」という意見もお待ちしてます。宜しくお願いします。


追記 ①

kenn さんありがとうございます!こんな関連が使えたのですね、早速試してみようと思います。ちなみに User / Customer が participants を持っていますが、これは無くても大丈夫という理解で良いでしょうか?

更新の方なのですが、やっぱり accepts_nested_attributes_for は微妙ですよね・・・。ただ Rails の作法が今ひとつ分からず、すみませんがもう少し教えて頂いて良いでしょうか?今回の場合、クライアントは画面上で User および Customer をそれぞれ複数選択し、ID を POST / PUT するイメージでいます。JSON 表現としては

"activity": {
  "foo": "bar",
  "participants_users": [ 1, 2, 3 ],
  "participants_customers": [ 10, 11, 12 ]
}

のようなイメージです。これを Rails の Controller で受けて構築する場合、

  • Strong Parameter で許可するためのメソッドは(普通の attributes と participants 用で)分ける
  • participants 用に許可したパラメータをループして Activity に追加していく

という感じになると思うのですが、このとき participants が入れ替わることがあると思います。例えば元々は [1, 4] だったものが [ 1, 2, 3 ] になる場合、4 が削除されて [ 2, 3 ] が追加されなければなりません。このような処理は ActiveRecord に任せて良いのでしょうか?つまり以下のような感じです。

@activity.participants_users.clear()
participants_users_params.each do |param|
  @activity.participants_users.build param
end

これは流石に削除されるべきエンティティだけを delete するように処理すべきなのでしょうか?

回答

まず、「ここで Activity に対して User と Customer を多対多関連を使って紐付けたい」の部分に関して。

ずばりPolymorphic Associationsが使えます。

http://guides.rubyonrails.org/association_basics.html#polymorphic-associations

class Participant < AR::Base
  belongs_to :activity
  belongs_to :participatable, polymorphic: true

class User < AR::Base
  has_many :participants, as: :participatable
  has_many :activities, through: :participants

class Customer < AR::Base
  has_many :participants, as: :participatable
  has_many :activities, through: :participants

class Activity < AR::Base
  has_many :participants
  has_many :participant_users, through: :participants
  has_many :participant_customers, through: :participants

テーブル実体としては、participantsテーブルに

id|activity_id|participatable_type|participatable_id|
--|-----------|-------------------|------------------
 1|          1|               User|                1|
 2|          1|               User|                2|
 3|          1|           Customer|                1|
 4|          1|           Customer|                2|

のように格納されます。

次にaccepts_nested_attributes_forについてですが、細かいところから先にいうとpermit(:participant_users_attributes => [ :id ])はまずいですよね。いかなる事情があってもidの更新をユーザに許すべきではないと思います。

で、本件の場合、activities側からのaccepts_nested_attributes_forで幸せになれる感じがしないので、自力でループしてUser / Customer側からparticipantsを生成するか、Reformのようなフォームオブジェクトに仕事をやらせたほうが結果的にシンプルかつ柔軟になるような気がします。

追記①への返信

User / Customerにもparticipantsを持たせるかどうかは、もちろん要件次第だとは思います。ただUser#destroyしたときに関連するparticipantsがdependent: :delete_allで消えてくれないと整合性が崩れますから、アプリ要件的にUser#destroyすることはなくとも、開発・テスト時に削除したくなったりすることがあるかもしれないので、備忘録の意味でも書いておいたほうが安心だとは思います。

それから、IDのリストを一括更新するなら、MySQLをお使いならactiverecord-importというgemを使えばINSERT IGNOREが使えます。つまり、

Participant.import [:activity_id, :participatable_type, :participatable_id], [[1,'User',1],[1,'User',2],[1,'User',3]], ignore: true

これ一発で[1,4][1,2,3,4]になります。

削除はコストの高い処理なので、もちろん必要なレコードだけ削除するほうがよいです。せっかくRubyのArrayクラスは強力で

[1,4,5] - [1,2,3] # => [4,5]

とやるだけで簡単に削除対象レコードのIDを取り出せるので、

Participant.where(id: [4,5]).delete_all

したほうが良いですよね。

編集 履歴 (3)
  • 回答ありがとうございます!
    少々追加で質問させて頂きたい点があるのですが、もし良ければ教えて頂けるでしょうか?コメント欄は字数制限があるので質問の方に追記させて頂きました。宜しくお願いします。
    -
  • 追記に対して返信しました。 -
  • kenn さん重ね重ねありがとうございます。Ruby も Rails も少々知識不足だったので大変助かりました。非常に勉強になりました。 -
ウォッチ

この質問への回答やコメントをメールでお知らせします。