[Rails]Ancestryで階層構造のデータを実装する

15 min read読了の目安(約13600字

なにこれ?

Ancestryが経路列挙モデルを実装するのに簡単そうだったので試してみました。

環境

・ ruby (2.6.5)
・ rails (5.1.7)
・ ancestry (3.2.1)
・ PostgreSQL (9.6.18)

テーブル

すごい雑ですが、以下のようなテーブル構成です。
一応、部署に所属しない従業員が発生しても問題ないようなリレーションにしておきました。
・ companies
・ departments (多階層表現したいテーブル)
・ employees

app/models/company.rb
class Company < ApplicationRecord
  has_many :departments
  has_many :employees
end
app/models/department.rb
class Department < ApplicationRecord
  belongs_to :company
  has_many :employees
end
app/models/employee.rb
class Employee < ApplicationRecord
  belongs_to :company
  belongs_to :department
end

Ancestryの導入

README.mdそのままだとdepth cachingが有効にならないので少し変更して実行します。

インストール

Gemfile
gem 'ancestry'

コマンド実行

bundle install

マイグレーションファイルの生成

今回は既存テーブルのテーブルに対してancestryを取り込みたいと思います。

rails g migration add_ancestry_to_departments ancestry:string:index

生成されたマイグレーションファイル

db/migrate/20210127091930_add_ancestry_to_departments.rb
class AddAncestryToDepartments < ActiveRecord::Migration[5.1]
  def change
    add_column :departments, :ancestry, :string
    add_index :departments, :ancestry
  end
end

このままだと、depth caching が有効にできないので書き換えます。
不要な方は書き換えなくて大丈夫です。

db/migrate/20210127091930_add_ancestry_to_departments.rb
 class AddAncestryToDepartments < ActiveRecord::Migration[5.1]
   def change
     add_column :departments, :ancestry, :string
+    add_column :departments, :ancestry_depth, :integer, :default => 0
     add_index :departments, :ancestry
   end
 end

モデルにancestryを取り込む

cache_depth: trueのオプションも一緒に追加しておきます。
不要な方はhas_ancestryだけで問題ないです。

app/models/department.rb
 class Department < ApplicationRecord
   belongs_to :company
   has_many :employees
+  has_ancestry cache_depth: true
 end

データを投入する

db/seed.rb
# 会社
company = Company.create(name: "システム開発株式会社")

# 部署
department_1 = Department.create(company: company, name: "人事部")
department_2 = Department.create(company: company, name: "営業部")
department_3 = Department.create(company: company, name: "開発部")
department_3_1 = department_3.children.create(company: company, name: "Webアプリケーション開発部")
department_3_2 = department_3.children.create(company: company, name: "ネイティブアプリケーション開発部")
department_3_1_1 = department_3_1.children.create(company: company, name: "フロントエンド")
department_3_1_2 = department_3_1.children.create(company: company, name: "バックエンド")
department_3_1_3 = department_3_1.children.create(company: company, name: "インフラ")
department_3_2_1 = department_3_2.children.create(company: company, name: "フロントエンド")
department_3_2_2 = department_3_2.children.create(company: company, name: "バックエンド")
department_3_2_3 = department_3_2.children.create(company: company, name: "インフラ")

# 従業員
# 人事部
Employee.create(name: 'A', company: company, department: department_1)
Employee.create(name: 'B', company: company, department: department_1)
# 営業部
Employee.create(name: 'C', company: company, department: department_2)
Employee.create(name: 'D', company: company, department: department_2)
# 開発部
Employee.create(name: 'E', company: company, department: department_3)
#  Webアプリケーション開発部
Employee.create(name: 'F', company: company, department: department_3_1)
Employee.create(name: 'G', company: company, department: department_3_1_1) # フロントエンド
Employee.create(name: 'H', company: company, department: department_3_1_1) # フロントエンド
Employee.create(name: 'I', company: company, department: department_3_1_2) # バックエンド
Employee.create(name: 'J', company: company, department: department_3_1_2) # バックエンド
Employee.create(name: 'K', company: company, department: department_3_1_3) # インフラ
Employee.create(name: 'L', company: company, department: department_3_1_3) # インフラ
#  ネイティブアプリケーション開発部
Employee.create(name: 'M',company: company, department: department_3_2)
Employee.create(name: 'N',company: company, department: department_3_2_1) # フロントエンド
Employee.create(name: 'O',company: company, department: department_3_2_2) # バックエンド
Employee.create(name: 'P',company: company, department: department_3_2_3) # インフラ
# 無所属
Employee.create(name: 'Q',company: company)

使ってみる

部署レコードを取得する

部署レコード全体

bin/rails c
pry(main)> Department.all
=>   Department Load (0.6ms)  SELECT "departments".* FROM "departments"
[#<Department:0x000055c8b0597990 id: 1, name: "人事部", company_id: 1, ancestry: nil, ancestry_depth: 0>,
 #<Department:0x000055c8b0594498 id: 2, name: "営業部", company_id: 1, ancestry: nil, ancestry_depth: 0>,
 #<Department:0x000055c8b05941f0 id: 3, name: "開発部", company_id: 1, ancestry: nil, ancestry_depth: 0>,
 #<Department:0x000055c8b0594038 id: 4, name: "Webアプリケーション開発部", company_id: 1, ancestry: "3", ancestry_depth: 1>,
 #<Department:0x00007ff6a28bfda8 id: 5, name: "ネイティブアプリケーション開発部", company_id: 1, ancestry: "3", ancestry_depth: 1>,
 #<Department:0x00007ff6a28bfad8 id: 6, name: "フロントエンド", company_id: 1, ancestry: "3/4", ancestry_depth: 2>,
 #<Department:0x00007ff6a28bf8a8 id: 7, name: "バックエンド", company_id: 1, ancestry: "3/4", ancestry_depth: 2>,
 #<Department:0x00007ff6a28bf740 id: 8, name: "インフラ", company_id: 1, ancestry: "3/4", ancestry_depth: 2>,
 #<Department:0x00007ff6a28bf538 id: 9, name: "フロントエンド", company_id: 1, ancestry: "3/5", ancestry_depth: 2>,
 #<Department:0x00007ff6a28bf358 id: 10, name: "バックエンド", company_id: 1, ancestry: "3/5", ancestry_depth: 2>,
 #<Department:0x00007ff6a28bef98 id: 11, name: "インフラ", company_id: 1, ancestry: "3/5", ancestry_depth: 2>]

開発部配下のレコードを取得する

pry(main)> Department.find_by(name: '開発部').subtree
  Department Load (0.4ms)  SELECT  "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "開発部"], ["LIMIT", 1]]
=>   Department Load (0.4ms)  SELECT "departments".* FROM "departments" WHERE (("departments"."ancestry" LIKE '3/%' OR "departments"."ancestry" = '3') OR "departments"."id" = 3)
[#<Department:0x000055c8a6251150 id: 3, name: "開発部", company_id: 1, ancestry: nil, ancestry_depth: 0>,
 #<Department:0x000055c8a6250fe8 id: 4, name: "Webアプリケーション開発部", company_id: 1, ancestry: "3", ancestry_depth: 1>,
 #<Department:0x000055c8a6250e58 id: 5, name: "ネイティブアプリケーション開発部", company_id: 1, ancestry: "3", ancestry_depth: 1>,
 #<Department:0x000055c8a6250cc8 id: 6, name: "フロントエンド", company_id: 1, ancestry: "3/4", ancestry_depth: 2>,
 #<Department:0x000055c8a6250b38 id: 7, name: "バックエンド", company_id: 1, ancestry: "3/4", ancestry_depth: 2>,
 #<Department:0x000055c8a62509f8 id: 8, name: "インフラ", company_id: 1, ancestry: "3/4", ancestry_depth: 2>,
 #<Department:0x000055c8a6250818 id: 9, name: "フロントエンド", company_id: 1, ancestry: "3/5", ancestry_depth: 2>,
 #<Department:0x000055c8a6250638 id: 10, name: "バックエンド", company_id: 1, ancestry: "3/5", ancestry_depth: 2>,
 #<Department:0x000055c8a6250430 id: 11, name: "インフラ", company_id: 1, ancestry: "3/5", ancestry_depth: 2>]

開発部配下のdepthが2の部署を取得する

 Department.find_by(name: '開発部').subtree.from_depth(2)
  Department Load (0.5ms)  SELECT  "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "開発部"], ["LIMIT", 1]]
=>   Department Load (0.4ms)  SELECT "departments".* FROM "departments" WHERE (("departments"."ancestry" LIKE '3/%' OR "departments"."ancestry" = '3') OR "departments"."id" = 3) AND (ancestry_depth >= 2)
[#<Department:0x000055c8b07109c0 id: 6, name: "フロントエンド", company_id: 1, ancestry: "3/4", ancestry_depth: 2>,
 #<Department:0x000055c8b0710808 id: 7, name: "バックエンド", company_id: 1, ancestry: "3/4", ancestry_depth: 2>,
 #<Department:0x000055c8b0710628 id: 8, name: "インフラ", company_id: 1, ancestry: "3/4", ancestry_depth: 2>,
 #<Department:0x000055c8b07104e8 id: 9, name: "フロントエンド", company_id: 1, ancestry: "3/5", ancestry_depth: 2>,
 #<Department:0x000055c8b07103a8 id: 10, name: "バックエンド", company_id: 1, ancestry: "3/5", ancestry_depth: 2>,
 #<Department:0x000055c8b0710218 id: 11, name: "インフラ", company_id: 1, ancestry: "3/5", ancestry_depth: 2>]

従業員レコードを取得する

従業員全体

pry(main)> Employee.all
=>   Employee Load (0.7ms)  SELECT "employees".* FROM "employees"
[#<Employee:0x00007ff6a35aa770 id: 1, name: "A", company_id: 1, department_id: 1>,
 #<Employee:0x00007ff6a35aa540 id: 2, name: "B", company_id: 1, department_id: 1>,
 #<Employee:0x00007ff6a35aa2c0 id: 3, name: "C", company_id: 1, department_id: 2>,
 #<Employee:0x00007ff6a35aa130 id: 4, name: "D", company_id: 1, department_id: 2>,
 #<Employee:0x00007ff6a35a9f00 id: 5, name: "E", company_id: 1, department_id: 3>,
 #<Employee:0x00007ff6a35a9d70 id: 6, name: "F", company_id: 1, department_id: 4>,
 #<Employee:0x00007ff6a35a9b40 id: 7, name: "G", company_id: 1, department_id: 6>,
 #<Employee:0x00007ff6a35a9870 id: 8, name: "H", company_id: 1, department_id: 6>,
 #<Employee:0x00007ff6a35a9730 id: 9, name: "I", company_id: 1, department_id: 7>,
 #<Employee:0x00007ff6a35a95a0 id: 10, name: "J", company_id: 1, department_id: 7>,
 #<Employee:0x00007ff6a35a92a8 id: 11, name: "K", company_id: 1, department_id: 8>,
 #<Employee:0x00007ff6a35a8e70 id: 12, name: "L", company_id: 1, department_id: 8>,
 #<Employee:0x00007ff6a35a8cb8 id: 13, name: "M", company_id: 1, department_id: 5>,
 #<Employee:0x00007ff6a35a8ab0 id: 14, name: "N", company_id: 1, department_id: 9>,
 #<Employee:0x00007ff6a35a8858 id: 15, name: "O", company_id: 1, department_id: 10>,
 #<Employee:0x00007ff6a35a86c8 id: 16, name: "P", company_id: 1, department_id: 11>,
 #<Employee:0x00007ff6a35a8510 id: 17, name: "Q", company_id: 1, department_id: nil>]

開発部配下の従業員レコードを取得する

pry(main)> Employee.joins(:department).where('departments.id' => Department.find_by(name: '開発部').subtree)
  Department Load (0.6ms)  SELECT  "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "開発部"], ["LIMIT", 1]]
=>   Employee Load (1.0ms)  SELECT "employees".* FROM "employees" INNER JOIN "departments" ON "departments"."id" = "employees"."department_id" WHERE "departments"."id" IN (SELECT "departments"."id" FROM "departments" WHERE (("departments"."ancestry" LIKE '3/%' OR "departments"."ancestry" = '3') OR "departments"."id" = 3))
[#<Employee:0x00007ff6a3b32468 id: 5, name: "E", company_id: 1, department_id: 3>,
 #<Employee:0x00007ff6a3b32328 id: 6, name: "F", company_id: 1, department_id: 4>,
 #<Employee:0x00007ff6a3b32148 id: 13, name: "M", company_id: 1, department_id: 5>,
 #<Employee:0x00007ff6a3b32008 id: 8, name: "H", company_id: 1, department_id: 6>,
 #<Employee:0x00007ff6a3b31ec8 id: 7, name: "G", company_id: 1, department_id: 6>,
 #<Employee:0x00007ff6a3b31ce8 id: 10, name: "J", company_id: 1, department_id: 7>,
 #<Employee:0x00007ff6a3b31b80 id: 9, name: "I", company_id: 1, department_id: 7>,
 #<Employee:0x00007ff6a3b31950 id: 12, name: "L", company_id: 1, department_id: 8>,
 #<Employee:0x00007ff6a3b317c0 id: 11, name: "K", company_id: 1, department_id: 8>,
 #<Employee:0x00007ff6a3b31608 id: 14, name: "N", company_id: 1, department_id: 9>,
 #<Employee:0x00007ff6a3b31450 id: 15, name: "O", company_id: 1, department_id: 10>,
 #<Employee:0x00007ff6a3b31248 id: 16, name: "P", company_id: 1, department_id: 11>]

部署レコードの階層の付け替え

pry(main)> Department.find_by(name: 'ネイティブアプリケーション開発部')
  Department Load (0.7ms)  SELECT  "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "ネイティブアプリケーション開発部"], ["LIMIT", 1]]
=> #<Department:0x00007ff6a3d9f570 id: 5, name: "ネイティブアプリケーション開発部", company_id: 1, ancestry: "3", ancestry_depth: 1>

pry(main)> Department.find_by(name: 'ネイティブアプリケーション開発部').update_attributes(:parent => Department.find_by(name: '人事部'))
  Department Load (0.8ms)  SELECT  "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "ネイティブアプリケーション開発部"], ["LIMIT", 1]]
  Department Load (0.4ms)  SELECT  "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "人事部"], ["LIMIT", 1]]
   (0.2ms)  BEGIN
  Department Load (0.3ms)  SELECT "departments".* FROM "departments" WHERE ("departments"."ancestry" LIKE '3/5/%' OR "departments"."ancestry" = '3/5')
  SQL (0.4ms)  UPDATE "departments" SET "ancestry" = $1 WHERE "departments"."id" = $2  [["ancestry", "1/5"], ["id", 9]]
  SQL (0.3ms)  UPDATE "departments" SET "ancestry" = $1 WHERE "departments"."id" = $2  [["ancestry", "1/5"], ["id", 10]]
  SQL (0.3ms)  UPDATE "departments" SET "ancestry" = $1 WHERE "departments"."id" = $2  [["ancestry", "1/5"], ["id", 11]]
  SQL (0.3ms)  UPDATE "departments" SET "ancestry" = $1 WHERE "departments"."id" = $2  [["ancestry", "1"], ["id", 5]]
   (0.8ms)  COMMIT
=> true

pry(main)> Department.find_by(name: 'ネイティブアプリケーション開発部').subtree
  Department Load (0.6ms)  SELECT  "departments".* FROM "departments" WHERE "departments"."name" = $1 LIMIT $2  [["name", "ネイティブアプリケーション開発部"], ["LIMIT", 1]]
=>   Department Load (0.4ms)  SELECT "departments".* FROM "departments" WHERE (("departments"."ancestry" LIKE '1/5/%' OR "departments"."ancestry" = '1/5') OR "departments"."id" = 5)
[#<Department:0x00007ff6a3ed13d0 id: 9, name: "フロントエンド", company_id: 1, ancestry: "1/5", ancestry_depth: 2>,
 #<Department:0x00007ff6a3ed11a0 id: 10, name: "バックエンド", company_id: 1, ancestry: "1/5", ancestry_depth: 2>,
 #<Department:0x00007ff6a3ed0fc0 id: 11, name: "インフラ", company_id: 1, ancestry: "1/5", ancestry_depth: 2>,
 #<Department:0x00007ff6a3ed0cc8 id: 5, name: "ネイティブアプリケーション開発部", company_id: 1, ancestry: "1", ancestry_depth: 1>]

まとめ

まず導入が簡単でした、README.mdがすごく充実しているので他の導入記事を探しに行かずとも実装できました。
すでに隣接リストモデルで多階層を実装されている場合でもREADME.mdで移行の手順を記載してくれています。
あと今回のように経路列挙モデルで多階層を表現する際に発生してしまうレコード更新の煩わしさがなくて良いです。