こんにちわ。はじめまして。@kajisha です。
推しの Vtuber は、因幡はねる 組長 かわいいおそろしいあくまでびでび・でびる 様です。
最近は、風見くく さんのものまねが好きすぎてよく観てます。
初 3D 配信はほんとにクオリティが高いのでぜひ観てみてください。
VIDEO www.youtube.com
このエントリでは、現プロジェクトでつかっている delegated_type
について少し書いてみたいと思います。
delegated_type
とは何か
以下の Pull Request で Active Record に導入されたものです。
github.com
Active Record にはクラス継承のしくみとして STI が提供されていますが、 単一のテーブルにサブクラスに必要な属性ももたせることになるので
それぞれのサブクラスに必要な(そしてそれらはほとんどが nullable な)カラムを持つ巨大でスカスカなテーブルを作らざるを得ません。
もちろん、サブクラスの属性がほぼ同じ場合は STI でもあまり問題はないと思います。
しかしながら、現実はそうもいかないので、サブクラスごとに違う属性を持たせたくなることはままあります。
delegated_type
はこのケースに対しスーパクラスに対応するテーブルとサブクラスに対応するテーブルのレコードの2つで継承関係を表現します。
以降で delegated_type
を使って弊社組織の構造を実装してみるサンプルをもとにどんな感じなのか書いていきたいと思います。
弊社の組織構造を delegated_type
をつかって表現してみる
それでは実際に弊社の組織構造をモデリング してみます。今回は、組織構造を柔軟に扱うことのできる責任関係パターンで表わしてみます。
責任関係パターンの詳細は、Martin Fowler のアナリシスパターン―再利用可能なオブジェクトモデル (Object Technology Series) を読んでいただいた方がわかりやすいと思います。
(アナパタ、なんで絶版になっちゃたんですかねぇ…)
今回は、責任関係パターンで会社 -- 事業部 -- 社員 という構造をつくってみたいと思います。
責任関係パターン
このパターンはあくまでも概念モデルなので、直接実装するのはややむずかしいです。
うまく説明できるかちょっとあやしいのですが、知識レベルの型は、クラスに相当する(クラスのクラス。メタクラス ですね)ものであり、知識レベルのレイヤーでパーティ間の制約を表現します。
操作レベルは、知識レベルの制約を担保したオブジェクト群、と考えるとわりと近いのかもしれません。
しかし、このまま実装するのはルナティックモード*1 すぎるので、ちょっと簡略化して以下のようにしてみました。
実装
責任関係型は、責任関係に関連のタイプを列挙型として表現することにしました。パーティ型は、それぞれパーティのサブクラスとしています。
なお、これが責任関係パターンの正しい実装なのかというより、今回のサンプル用に変形したものである点は留意ください。
それでは、ひとつづつモデルをつくっていきます。
まず Rails アプリケーションを準備します。
bundle init
delegated_type
はまだリリースされていないので master の Rails を指定します。
gem "rails", github: "rails/rails"
bundle exec rails new -d sqlite3 .
Party(パーティ) モデルの作成
bin/rails g model Party partyable:references{polymorphic}
ここで、polymorphic
アソシエーションとして partyable
を作成していますが直接 delegated_type
なアソシエーションを一発で作成できないので一旦、ポリモーフィック 関連として作成します。
なぜ polymorphic
アソシエーションとして作成するのかと言うと、delegated_type
は内部でサブクラスの情報を polymorphic
アソシエーションとして扱っているからです。
ここはのちほど、delegated_type
で書き直します。
すると以下のようなモデルが生成されます。
class Party < ApplicationRecord
belongs_to :partyable , polymorphic : true
end
Compnay/Department/Employee(パーティのサブクラス) モデルの作成
次に Party のサブクラスをそれぞれ作成します。
Company モデル
bin/rails g model Company name:string
class Company < ApplicationRecord
end
Department モデル
bin/rails g model Department name:string
class Department < ApplicationRecord
end
Employee モデル
bin/rails g model Employee name:string email:string
class Employee < ApplicationRecord
end
delegated_type
を Party モデルに指定する
class Party < ApplicationRecord
belongs_to :partyable , polymorphic : true
end
を次のように書き換えます。
class Party < ApplicationRecord
delegated_type :partyable , types : %w( Company Department Employee )
end
そして Company/Department/Employee が Party として振舞うことを示すために、Partyable concern を作成してサブクラス側に include させます。
module Partyable
extend ActiveSupport ::Concern
included do
has_one :party , as : :partyable , touch : true
end
end
この concern を Company/Department/Employee に include させます。
class Company < ApplicationRecord
include Partyable
end
class Department < ApplicationRecord
include Partyable
end
class Employee < ApplicationRecord
include Partyable
end
Accountability(責任関係) モデルの作成
責任関係モデルを作成します。責任関係型の列挙型はのちほど追加していきます。
bin/rails g model Accountability accountability_type:integer started_at:datetime ended_at:datetime commissioner:references responsible:references
このままでは commissioner
, responsible
に対応するテーブルが解決できないので、マイグレーション を以下のように修正します。
:commissioner
と :responsible
の foreign_key
の to_table
オプションがミソです。これで parties
テーブルを参照できるようになります。
これを付けないと、それぞれ commissioners
, responsibles
テーブルがあると判断されてしまうので、parties
テーブルとのアソシエーションであることをおしえてあげます。
class CreateAccountabilities < ActiveRecord ::Migration [6.1 ]
def change
create_table :accountabilities do |t |
t.integer :accountability_type , null : false
t.datetime :started_at , null : false
t.datetime :ended_at , null : false , default : ' 9999-12-31 23:59:59.999Z '
t.references :commissioner , null : false , foreign_key : {to_table : :parties }
t.references :responsible , null : false , foreign_key : {to_table : :parties }
t.timestamps
end
end
Accountability にパーティ間の有効期間を表す started_at
と ended_at
を追加していますが、今回のサンプルでは本質ではないので使ってません。
つぎに、Party モデルと Accountability モデルのアソシエーションを追加します。
class Accountability < ApplicationRecord
attribute :started_at , default : -> { Time .current }
belongs_to :commissioner , class_name : ' Party ' , inverse_of : :commissioners
belongs_to :responsible , class_name : ' Party ' , inverse_of : :responsibles
end
class Party < ApplicationRecord
delegated_type :partyable , types : %w( Company Department Employee )
has_many :commissioners , class_name : ' Accountability ' , inverse_of : :commissioner , foreign_key : :responsible_id , dependent : :destroy
has_many :responsibles , class_name : ' Accountability ' , inverse_of : :responsible , foreign_key : :commissioner_id , dependent : :destroy
end
delegated_type
により、いくつかの scope や predicate メソッドが生成されますが、ここでは説明を省略します。
実際にオブジェクトを作って確かめてみる
さて、準備はできました。それでは実際にパーティ間のアソシエーションをつくってみましょう。
なお、以下のシナリオでおもむろに Accountability に accountability_type の値を追加していますが、
実際は責任関係型がみたすべき制約をバリデーションとして書くべきです。
シナリオ1 会社と事業部の関係をつくる
永和システムマネジメントにアジャイル 事業部がある、という関係をつくってみます。
さきほど責任関係のタイプは、列挙型で表現することにしたので、会社と事業部の関連をあらわす enum を Accountability に追加します。
企業と事業部の責任関係型を organization_structure
としてみます。
class Accountability < ApplicationRecord
attribute :started_at , default : -> { Time .current }
belongs_to :commissioner , class_name : ' Party ' , inverse_of : :commissioners
belongs_to :responsible , class_name : ' Party ' , inverse_of : :responsibles
enum accountability_type : {organization_structure : 0 }
end
では永和システムマネジメントにアジャイル 事業部をぶらさげてみましょう。
company = Party .create!(partyable : Company .create!(name : ' 永和システムマネジメント ' ))
department = Party .create!(partyable : Department .create!(name : ' アジャイル事業部 ' ))
Accountability .organization_structure.create!(commissioner : department, responsible : company)
シナリオ2 事業部と社員の関係をつくる
OK. それでは、アジャイル 事業部に社員をひとり追加してみましょう。この責任関係型は、belong
としてみましょう。ちょっと名前がイマイチですね…ゆるしてください。
employee = Party .create!(partyable : Employee .create!(name : ' wat-aro ' , email : ' wat-aro@example.com ' ))
Accountability .belong.create!(commissioner : employee, responsible : department)
よさそうじゃないですか。
関連をたどっていろいろデータが欲しいんだ!
ここからはいくつかのテーブルを経由して取得していくことになるので、ちょっと一筋縄でいきません。いくつか準備が必要です。
必要な道具は has_many through:, source:
です。
企業にある事業部の一覧を取得したい
Company にある Department は、[:Company] <-- [:Party] <-responsible- [organization_structure:Accountability] -commissioner-> [:Party] --> [:Department]
をたどって取得します。
最終的には
class Company < ApplicationRecord
...
has_many :departments , ...
end
のようなアソシエーションで取得できることを目指しますが、 一気呵成にアソシエーションをたどれないので、ひとつづつ解決していきます。なぜたどれないのかは、最後の例で説明します。
organization_structure な Accountability を取得する
次のアソシエーションを取得します。
[:Company] <-- [:Party] <-responsible- [organization_structure:Accountability]
class Company < ApplicationRecord
include Partyable
has_many :organization_structure_accoutabilities , -> { organization_structure }, through : :commissioners , source : :party
end
organization_structure_accoutabilities から Party を取得する
上記で作成したアソシエーションからさらに以下のアソシエーションを取得します。
[organization_structure:Accountability] -commissioner-> [:Party]
class Company < ApplicationRecord
include Partyable
has_many :organization_structure_accoutabilities , -> { organization_structure }, through : :commissioners , source : :party
has_many :organization_structures , through : :organization_structure_accoutabilities , source : :commissioner
end
Department を取得する
さいごに :organization_structures
を経由して Department
を取得します。
class Company < ApplicationRecord
include Partyable
has_many :organization_structure_accoutabilities , -> { organization_structure }, through : :commissioners , source : :party
has_many :organization_structures , through : :organization_structure_accoutabilities , source : :commissioner
has_many :departments , through : :organization_structures , source : :partyable , source_type : ' Department '
end
事業部に所属する社員の一覧を取得したい
Department に所属している Employee は、[:Department] --> [:Party] <-responsible- [belong:Accountability] -commissioner-> [:Party] --> [:Employee]
をたどります。
Company の例と同様に、Accountability, Party, Employee と解決していきます。
class Department < ApplicationRecord
include Partyable
has_many :belong_accountabilities , -> { belong }, through : :party , source : :commissioners
has_many :belongs , through : :belong_accountabilities , source : :commissioner
has_many :employees , through : :belongs , source : :partyable , source_type : ' Employee '
end
企業に所属する社員の一覧を取得したい
ここまでで、企業の事業部、事業部に所属する社員を取得できるようになりました。これらのアソシエーションをつかって企業に所属する社員、というのも取れそうです。
これは Company モデルに以下のような employees アソシエーションを追加することで取得できます。
class Company < ApplicationRecord
...
has_many :departments , through : :organization_structures , source : :partyable , source_type : ' Department '
has_many :employees , through : :departments , source : :employees , dependent : :destroy
end
なぜアソシエーションを段階的に辿ったのか?
Company のインスタンス から employees を取得するときの Active Record が実行する SQL に理由があります。
SELECT " employees " .*
FROM " employees " INNER JOIN " parties " ON " employees " ." id " = " parties " ." partyable_id "
INNER JOIN " accountabilities " ON " parties " ." id " = " accountabilities " ." commissioner_id "
INNER JOIN " parties " " parties_employees " ON " accountabilities " ." responsible_id " = " parties_employees " ." id "
INNER JOIN " departments " ON " parties_employees " ." partyable_id " = " departments " ." id "
INNER JOIN " parties " " organization_structures_employees " ON " departments " ." id " = " organization_structures_employees " ." partyable_id "
INNER JOIN " accountabilities " " organization_structure_accountabilities_employees " ON " organization_structures_employees " ." id " = " organization_structure_accountabilities_employees " ." commissioner_id "
INNER JOIN " parties " " parties_employees_2 " ON " organization_structure_accountabilities_employees " ." responsible_id " = " parties_employees_2 " ." id "
WHERE " parties_employees_2 " ." partyable_id " = 1
AND " parties_employees_2 " ." partyable_type " = ' Company '
AND " parties_employees " ." partyable_type " = ' Department '
AND " organization_structure_accountabilities_employees " ." accountability_type " = 0
AND " organization_structures_employees " ." partyable_type " = ' Department '
AND " accountabilities " ." accountability_type " = 1
AND " parties " ." partyable_type " = ' Employee '
has_many through
するときの INNER JOIN
に同じテーブルがある場合、別名を付けるためです。もし、これを where
で書いていた場合は、どのテーブルに対しクエリを書いているのかが、わからないため正しくレコードを取得できないことになります。
ここの部分は、わたしもこれでいいのかまだ自信がないので、もっとよい方法があればぜひ教えてください。
さいごに
いかがでしたでしょうか?わたしもまだ手探り状態なのでもっと良い方法があるかもしれません。そのときはまたなにか書ければいいなと思います。
以下にこのエントリで作成したサンプルを置いておきますので、ご興味あるかたはご覧いただければと思います。
github.com