こんにちわ。はじめまして。@kajisha です。
推しの Vtuber は、因幡はねる組長 かわいいおそろしいあくまでびでび・でびる様です。
最近は、風見くくさんのものまねが好きすぎてよく観てます。 初 3D 配信はほんとにクオリティが高いのでぜひ観てみてください。
このエントリでは、現プロジェクトでつかっている delegated_type
について少し書いてみたいと思います。
delegated_type
とは何か
以下の Pull Request で Active Record に導入されたものです。
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
で書いていた場合は、どのテーブルに対しクエリを書いているのかが、わからないため正しくレコードを取得できないことになります。
ここの部分は、わたしもこれでいいのかまだ自信がないので、もっとよい方法があればぜひ教えてください。
さいごに
いかがでしたでしょうか?わたしもまだ手探り状態なのでもっと良い方法があるかもしれません。そのときはまたなにか書ければいいなと思います。
以下にこのエントリで作成したサンプルを置いておきますので、ご興味あるかたはご覧いただければと思います。