ESM アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

Rails 6.1 で導入される予定の delegated_type をつかってみてる話

こんにちわ。はじめまして。@kajisha です。 推しの Vtuber は、因幡はねる組長 かわいいおそろしいあくまでびでび・でびる様です。

最近は、風見くくさんのものまねが好きすぎてよく観てます。 初 3D 配信はほんとにクオリティが高いのでぜひ観てみてください。

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) を読んでいただいた方がわかりやすいと思います。 (アナパタ、なんで絶版になっちゃたんですかねぇ…)

今回は、責任関係パターンで会社 -- 事業部 -- 社員 という構造をつくってみたいと思います。

f:id:kajisha:20201002093719j:plain
責任関係パターン

このパターンはあくまでも概念モデルなので、直接実装するのはややむずかしいです。 うまく説明できるかちょっとあやしいのですが、知識レベルの型は、クラスに相当する(クラスのクラス。メタクラスですね)ものであり、知識レベルのレイヤーでパーティ間の制約を表現します。 操作レベルは、知識レベルの制約を担保したオブジェクト群、と考えるとわりと近いのかもしれません。

しかし、このまま実装するのはルナティックモード*1すぎるので、ちょっと簡略化して以下のようにしてみました。

f:id:kajisha:20201002093758j:plain
実装

責任関係型は、責任関係に関連のタイプを列挙型として表現することにしました。パーティ型は、それぞれパーティのサブクラスとしています。 なお、これが責任関係パターンの正しい実装なのかというより、今回のサンプル用に変形したものである点は留意ください。 それでは、ひとつづつモデルをつくっていきます。

まず 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:responsibleforeign_keyto_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_atended_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

*1:東方Projectのゲームにおける最高難易度モード