Ruby on Rails Advent Calendar 2019の18日目が未投稿だったので代わりにでも。
はじめに
Railsを用いたサービス開発中に、中間テーブルとポリモーフィック関連を組み合わせる機会があり、ググってもあまり情報がないのでここに書く。
前提
- とあるプラットフォームが複数のサブスクリプションサービスを持っている(サービスA, サービスB, etc.)
- ユーザーは一つのアカウントで複数のサービスを契約できる。また同一サービスに対して複数契約もできる。
- それぞれのサービスに対して、
申し込み情報
と契約情報
を別テーブルで保持する。これらはユーザー
テーブルと関連づけられる。 - ビジネスフロー例
- サービスAへの申し込み発生 → 申し込みレコードを発行
- 申し込みの妥当性確認などビジネスサイドの手続き。
- 2の処理完了 → 申し込み情報レコードを元に契約情報レコードを発行
- 各申し込み/契約に対して、
支払い情報
と請求先情報
が別テーブルで関連づいている。- これらの情報はサービスをまたいで共有することができる。例えば、とあるユーザーがサービスAへの申し込みで登録した支払い/請求先情報を、サービスBへの申し込みにも利用することができる(→中間テーブルを採用)。
ER図を眺めてみる
サービスAのことだけ考えた場合、ER図は下記のようになる。
(各テーブルのフィールド、また本題と関係ないリレーションは省略)
サービスA向けの申し込み情報テーブルをServiceAOrder
, 契約情報をServiceAContract
として、中間テーブルを介してそれぞれが支払い情報Payment
、請求先情報BillRecipient
を持つ。
この時点で中間テーブルが4つ作られているが、サービスが増えるごとに4つずつ新しく中間テーブルを増やすのは非常にだるい、、
ここで気がつく。
中間テーブルにポリモーフィック関連って使えるのかな?
もしかして両サイドポリモーフィックもいける、、?
もしかして中間テーブルダブルポリモーフィックできちゃう、、?
実装
というわけでやってみた。ソースコードはこちら
検証環境はRuby 2.6.3, Rails 6.0.2
方針としてはSubscription
モデルを中間テーブルとして、holder
とtarget
という2つのポリモーフィックなbelongs_toを作る。やろうとすれば割り当てるモデルに制限はないが、意味合い的に次のような割り当てをする。
- holder: SeviceAOrder, ServiceAContract, 他のサービスを追加していく
- target: Payment, BillRecipient
またシンプルに中間テーブルを利用する場合だと次のような指定をすれば良いが、ポリモーフィック関連を利用する場合はhas_manyにオプションでsource
, source_type
をつけてテーブルを明示的に指定する必要がある(Railsガイドのこのあたり)。
has_many :subscriptions
has_many :payments, through: :subscriptions
モデルのリレーション一覧↓
# app/models/subscription.rb
class Subscription < ApplicationRecord
belongs_to :holder, polymorphic: true
belongs_to :target, polymorphic: true
end
# app/models/service_a_order.rb
class ServeceAOrder < ApplicationRecord
has_many :subscriptions, as: :holder
has_many :payments, through: :subscriptions, source: :target, source_type: 'Payment'
has_many :bill_recipients, through: :subscriptions, source: :target, source_type: 'BillRecipient'
end
# app/models/service_a_contract.rb
class ServeceAContract < ApplicationRecord
has_many :subscriptions, as: :holder
has_many :payments, through: :subscriptions, source: :target, source_type: 'Payment'
has_many :bill_recipients, through: :subscriptions, source: :target, source_type: 'BillRecipient'
end
# app/models/bill_recipient.rb
class BillRecipient < ApplicationRecord
has_many :subscriptions, as: :target
has_many :service_a_orders, through: :subscriptions, source: :holder, source_type: 'ServiceAOrder'
has_many :service_a_contracts, through: :payment_bill_recipient_holders, source: :holder, source_type: 'ServiceAContract'
end
# app/models/payment.rb
class Payment < ApplicationRecord
has_many :subscriptions, as: :target
has_many :service_a_orders, through: :subscriptions, source: :holder, source_type: 'ServiceAOrder'
has_many :service_a_contracts, through: :subscriptions, source: :holder, source_type: 'ServiceAContract'
end
簡単なテスト↓
# spec/models/service_a_order_spec.rb
require 'rails_helper'
RSpec.describe ServiceAOrder, type: :model do
describe '中間テーブル機能チェック' do
it 'service_a_orderレコードに対して、ポリモーフィックなpaymentとbill_paymentレコードがきちんと紐づく' do
user = User.new(name: 'Dan', email: 'test@example.com')
user.save!
service_a_order = ServiceAOrder.new(plan_type: 1, user: user)
service_a_order.save!
payment = Payment.new(payment_type: 1)
service_a_order.subscriptions.create(target: payment)
# service_a_order.payments << payment # こっちの書き方でもOK
bill_recipient = BillRecipient.new(address: 'Chiyoda')
service_a_order.subscriptions.create(target: bill_recipient)
# service_a_order.bill_recipients << bill_recipient # こっちの書き方でもOK
expect(service_a_order.payments.count).to eq 1
expect(service_a_order.payments.first).to eq(payment)
expect(service_a_order.bill_recipients.count).to eq 1
expect(service_a_order.bill_recipients.first).to eq(bill_recipient)
end
end
end
ちなみにソースコードの方ではServiceBOrder
とServiceBContract
を追加しているが、いけてそう。
参考
Railsのポリモーフィック関連とはなんなのか
Combining has_many :through with polymorphic associations in ActiveRecord
まとめ
そのうち地雷を踏みそうだけど後悔はしていない。