acts as section map(better nested setで組織図を書く)

「better nested setで組織図を書きたい」
前からやりたいと思っていた手軽にできる組織図表。
ついに完成しました。
ライセンスはMITです。

まずbetter nested setを使用したモデルを作成します。

./script/plugin source svn://rubyforge.org/var/svn/betternestedset/trunk
./script/plugin install betternestedset

モデルはSectionという名前にすることにして、使用するカラムはnameだけにします。
better nested setにはparent_id、lft、rgtという:integerなカラムが必要です。

class CreateSections < ActiveRecord::Migration
  def self.up
    create_table :sections do |t|
      t.column :name, :string, :null=>false
      t.column :parent_id, :integer
      t.column :lft,       :integer
      t.column :rgt,       :integer
    end
  end

  def self.down
    drop_table :sections
  end
end

app/models/section.rbの中は

class Section < ActiveRecord::Base
  acts_as_nested_set
end

これだけです。
データを作成し、適当な組織図のツリーを作ってみました。

% ./script/runner 'Section.create(:name=>"本社")'
% ./script/runner 'Section.create(:name=>"東北支社").move_to_child_of Section.find_by_name("本社")'
% ./script/runner 'Section.create(:name=>"札幌支社").move_to_child_of Section.find_by_name("本社")'
% ./script/runner 'Section.create(:name=>"山形営業所").move_to_child_of Section.find_by_name("東北支社")'
% ./script/runner 'Section.create(:name=>"福島営業所").move_to_child_of Section.find_by_name("東北支社")'
% ./script/runner 'Section.create(:name=>"盛岡営業所").move_to_child_of Section.find_by_name("東北支社")'
% ./script/runner 'Section.create(:name=>"函館営業所").move_to_child_of Section.find_by_name("札幌支社")'
% ./script/runner 'Section.create(:name=>"釧路営業所").move_to_child_of Section.find_by_name("札幌支社")'
% ./script/runner 'Section.create(:name=>"旭川営業所").move_to_child_of Section.find_by_name("札幌支社")'

DBの中を見ると、

# select * from sections;
 id |    name    | parent_id | lft | rgt 
----+------------+-----------+-----+-----
  1 | 本社       |           |   1 |  18
  2 | 東北支社   |         1 |   2 |   9
  3 | 札幌支社   |         1 |  10 |  17
  4 | 山形営業所 |         2 |   3 |   4
  5 | 福島営業所 |         2 |   5 |   6
  6 | 盛岡営業所 |         2 |   7 |   8
  7 | 函館営業所 |         3 |  11 |  12
  8 | 釧路営業所 |         3 |  13 |  14
  9 | 旭川営業所 |         3 |  15 |  16
(9 rows)

こんな感じになっています。ツリーになっているのはがんばって読めばわかります。

acts as section mapのインストール

そして、acts as section mapをインストールします。

./script/plugin install http://xibbar.net/svn/rails/plugins/trunk/acts_as_section_map/

使い方

モデルには一行追加する必要が有ります。

class Section < ActiveRecord::Base
  acts_as_nested_set
  acts_as_section_map
end

acts_as_section_mapはacts_as_nested_setの下に書かなければいけません。
ビューのindex.rhtmlはこれだけです。

<% section_map(:section) do |table| %>
  <%=table.name%> 
<% end %>

これだけで組織図が表示されます。
ブロックの中が個々のtdタグの中身になります。
:sectionというのはクラス名です。
Sectionと書くのはよろしくないと思って、Rails流に
シンボルでオブジェクト名を与えることにしました。

追加されるメソッド

acts_as_section_map一行で以下のメソッドがモデルに追加されます。
クラスメソッド

  • set_depth(全ての:depthカラムに深さをセットします。)
  • leaves(葉の全て:葉というのは枝の先端部分のことです。)
  • table(モデルを2次元配列にしたものです。)
  • table2(さらにtableを表示しやすいように変形したものです。)

インスタンスメソッド

  • left?(枝の一番左側かどうか)
  • right?(枝の一番右側かどうか)
  • move_to_last_child_of(node)(ある親の枝の一番下側(右とも言う)にぶら下げます)
  • up 一つ上に順番をあげる
  • down 一つ下に順番を下げる
  • leaves ぶら下がっている全ての葉
  • colspan tableの中のtrで使うcolspan
  • rowspan tableの中のtdで使うrowspan

今のところ、left?とupとかが混在しているのですが、
整理していきたいと思っています。
なぜこのようになっているかというと、Treeというのものは
上が頂点になるようになっているのですが、
組織図は左が頂点の方が表現しやすいためです。

tableやtrにstyleやclassなどを与えたい

ビューでsection_mapメソッドにオプションでstyleやクラスなどを渡すことができます。

<% section_map(:section,:table=>{:cellspacing=>"0",:style=>"border-collapse:collapse;"},
                 :td=>{:valign=>"top",:style=>"border:1px solid #666666;color:navy;font-size:10px"}) do |section| %>
  <%=section.name%> 
<% end %>

以上のような感じでHashの入れ子としてstyleやcellspacingやclassなどを指定することができます。

関連テーブルを使う

sectionにuserがぶら下がっている場合も簡単です。
section_idを持っているUserモデルを作成します。
Userのmigrationファイル

class CreateUsers < ActiveRecord::Migration
  def self.up
    create_table :users do |t|
      t.column :name, :string,  :null=>false
      t.column :section_id, :integer
    end
  end
  def self.down
    drop_table :users
  end
end

app/models/user.rb

class User < ActiveRecord::Base
  belongs_to :section
end

Sectionモデルもhas_manyを追加します。
app/models/section.rb

class Section < ActiveRecord::Base
  acts_as_nested_set
  acts_as_section_map
  has_many :users
end

Userのデータを突っ込みます。

% ./script/runner 'Section.find_by_name("福島営業所").users << User.create(:name=>"xibbar")'
% ./script/runner 'Section.find_by_name("東北支社").users << User.create(:name=>"yuichi_katahira")'
% ./script/runner 'Section.find_by_name("福島営業所").users << User.create(:name=>"Doggie")'       
% ./script/runner 'Section.find_by_name("東北支社").users << User.create(:name=>"monyakata")'      
% ./script/runner 'Section.find_by_name("山形営業所").users << User.create(:name=>"takedasoft")'
% ./script/runner 'Section.find_by_name("東北支社").users << User.create(:name=>"h_mori")'   

ビューも修正します。

<% section_map(:section,:table=>{:cellspacing=>"0",:style=>"border-collapse:collapse;"},
                 :td=>{:valign=>"top",:style=>"border:1px solid #666666;color:navy;font-size:10px"}) do |section| %>
  <%=section.name%> 
  <ul>
    <% section.users.each do |user|%>
      <li style="color:green"><%=user.name%></li>
    <%end%>
  </ul>
<% end %>

このように関連テーブルを呼んで表示することもできます。

ツリーを構築するのが遅い場合

ちょっとしたツリーだったら表示するのが早いのですが、
100個を超えるような組織図を構築する場合は極端に遅くなります。
そういう場合はmigrateファイルに

class AddDepthToSections < ActiveRecord::Migration
  def self.up
    add_column :sections,:depth,:integer
    Section.set_depth
  end

  def self.down
    remove_column :sections,:depth
  end
end

を書いて実行し、depthというカラムを追加してください。
.set_depthでは現状の値を修正してくれます。(levelを呼び出して上書き)
これは何かというとのろさの原因はsectionが
自分自身の深さを知るためにselect count(*)していることにあります。
(levelメソッドを呼んでいる)
これをカラムとして持つとツリーの構築の早さが圧倒的に違うことにあります。
(つまりdepthカラム)
depthカラムがある場合は組織図に変化があった場合に
自動的にdepthカラムの中身も変更するようにプラグインを作ってあります。(再起動が必要)
ベンチマークの結果です。(120のツリーを構築した場合)

% ./script/performance/benchmarker 100 'Section.find(:all).map(&:level)'
            user     system      total        real
#1      6.180000   0.530000   6.710000 ( 20.693455)

20秒かかっていたものが、rgt、lft、parent_idにインデックスを張って、

% ./script/performance/benchmarker 100 'Section.find(:all).map(&:level)'
            user     system      total        real
#1      5.790000   0.470000   6.260000 ( 10.331682)

10秒になり、depthカラムを追加することで、

% ./script/performance/benchmarker 100 'Section.find(:all).map(&:depth)'
            user     system      total        real
#1      0.610000   0.010000   0.620000 (  0.784886)

0.78秒になりました。