ActiveRecordで多階層カテゴリ(Ancestry)


ActiveRecordで多階層カテゴリ という投稿をしたところ、 @jnchito さんに Ancestry なるgemを教えていただきました。

stefankroes/ancestry
Organise ActiveRecord model into a tree structure

準備

$ rails g model category name:string

$ rake db:migrate

mysql> desc categories;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255) | YES  |     | NULL    |                |
| created_at | datetime     | YES  |     | NULL    |                |
| updated_at | datetime     | YES  |     | NULL    |                |
+------------+--------------+------+-----+---------+----------------+

Ancestry

README通りに進めます。

$ rails g migration add_ancestry_to_category ancestry:string
# db/migrate/20141229064909_add_ancestry_to_category.rb

class AddAncestryToCategory < ActiveRecord::Migration
  def change
    add_column :categories, :ancestry, :string
    add_index :categories, :ancestry
  end

  def down
    remove_index :categories, :ancestry
    remove_column :categories, :ancestry
  end
end
$ rake db:migrate

mysql> desc categories;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int(11)      | NO   | PRI | NULL    | auto_increment |
| name       | varchar(255) | YES  |     | NULL    |                |
| created_at | datetime     | YES  |     | NULL    |                |
| updated_at | datetime     | YES  |     | NULL    |                |
| ancestry   | varchar(255) | YES  | MUL | NULL    |                |
+------------+--------------+------+-----+---------+----------------+
# app/models/category.rb

class Category < ActiveRecord::Base
  has_ancestry
end

カテゴリの登録

# db/seed.rb

metal, jazz = Category.create([{name: "metal"}, {name: "jazz"}])

melodic, black = metal.children.create([{name: "melodic"}, {name: "black"}])
melodic.children.create([{name: "melodic-death"}, {name: "melodic-speed"}])
black.children.create([{name: "symphonic-black"}, {name: "melodic-black"}])

swing, modern = jazz.children.create([{name: "swing"}, {name: "modern"}])
$ rake db:seed

mysql> select * from categories;
+----+-----------------+---------------------+---------------------+----------+
| id | name            | created_at          | updated_at          | ancestry |
+----+-----------------+---------------------+---------------------+----------+
|  1 | metal           | 2014-12-30 06:48:28 | 2014-12-30 06:48:28 | NULL     |
|  2 | jazz            | 2014-12-30 06:48:28 | 2014-12-30 06:48:28 | NULL     |
|  3 | melodic         | 2014-12-30 06:48:28 | 2014-12-30 06:48:28 | 1        |
|  4 | black           | 2014-12-30 06:48:28 | 2014-12-30 06:48:28 | 1        |
|  5 | melodic-death   | 2014-12-30 06:48:28 | 2014-12-30 06:48:28 | 1/3      |
|  6 | melodic-speed   | 2014-12-30 06:48:28 | 2014-12-30 06:48:28 | 1/3      |
|  7 | symphonic-black | 2014-12-30 06:48:28 | 2014-12-30 06:48:28 | 1/4      |
|  8 | melodic-black   | 2014-12-30 06:48:28 | 2014-12-30 06:48:28 | 1/4      |
|  9 | swing           | 2014-12-30 06:48:28 | 2014-12-30 06:48:28 | 2        |
| 10 | modern          | 2014-12-30 06:48:28 | 2014-12-30 06:48:28 | 2        |
+----+-----------------+---------------------+---------------------+----------+

カテゴリの取得

$ rails console
> metal = Category.find_by name: "metal"
> Category.children_of metal
  Category Load (0.3ms)  SELECT `categories`.* FROM `categories`  WHERE `categories`.`ancestry` = '1'
=> #<ActiveRecord::Relation [#<Category id: 3, name: "melodic", created_at: "2014-12-30 06:48:28", updated_at: "2014-12-30 06:48:28", ancestry: "1">, #<Category id: 4, name: "black", created_at: "2014-12-30 06:48:28", updated_at: "2014-12-30 06:48:28", ancestry: "1">]>

簡単!

Ancestry では、階層構造のパスをカラム(デフォルトはancestry)に保持しています。
これは、 SQLアンチパターン
2章 ナイーブツリーで紹介されている 経路列挙(Path Enumeration) の手法です。

なので、特定のカテゴリ配下を再帰的に取得するのも下記のようにサクッとできます。

> metal.subtree
  Category Load (0.4ms)  SELECT `categories`.* FROM `categories`  WHERE (((`categories`.`id` = 1 OR `categories`.`ancestry` LIKE '1/%') OR `categories`.`ancestry` = '1'))
=> #<ActiveRecord::Relation [
#<Category id: 1, name: "metal", created_at: "2014-12-30 06:48:28", updated_at: "2014-12-30 06:48:28", ancestry: nil>, 
#<Category id: 3, name: "melodic", created_at: "2014-12-30 06:48:28", updated_at: "2014-12-30 06:48:28", ancestry: "1">, 
#<Category id: 4, name: "black", created_at: "2014-12-30 06:48:28", updated_at: "2014-12-30 06:48:28", ancestry: "1">, 
#<Category id: 5, name: "melodic-death", created_at: "2014-12-30 06:48:28", updated_at: "2014-12-30 06:48:28", ancestry: "1/3">, 
#<Category id: 6, name: "melodic-speed", created_at: "2014-12-30 06:48:28", updated_at: "2014-12-30 06:48:28", ancestry: "1/3">, 
#<Category id: 7, name: "symphonic-black", created_at: "2014-12-30 06:48:28", updated_at: "2014-12-30 06:48:28", ancestry: "1/4">, 
#<Category id: 8, name: "melodic-black", created_at: "2014-12-30 06:48:28", updated_at: "2014-12-30 06:48:28", ancestry: "1/4">
]>

parent_id を使った既存のデータから Ancestry に移行する機能もあるみたいです。
便利ですね。