🌲

ancestryで並び替え可能な木構造モデルを作る

2021/06/05に公開

目的

ancestryを使い、orderカラムの値によって並び替えを行う木構造モデルを作る。

環境

  • Ruby: 2.5.7
  • Rails: 6.0.2.1
  • ancestry: 4.0.0

https://github.com/stefankroes/ancestry

まえがき

今回使うancestry経路列挙モデルを採用している。
そのため、通常であればジェイウォークと呼ばれる典型的なSQLアンチパターンの諸問題を抱えてしまうことになる。
しかしこのgemはその諸問題を回避しながらうまく参照、操作することを目的に作られているため、ancestryのメソッド経由で関連モデルをいじる分には特に意識しなくてもいい。
ただ1点、需要が高いと思う並び替え機能(ソート機能)については現時点(2021/06/05)では

The sort_by_ancestry class method: TreeNode.sort_by_ancestry(array_of_nodes) can be used to sort an array of nodes as if traversing in preorder. (Note that since materialised path trees do not support ordering within a rank, the order of siblings is dependant upon their original array order.)

ということで、よしなにはやってくれないらしい。
いろいろ調べても再帰処理クエリを書いて地道に木構造を作っていたり、独自のメソッドを組み合わせて実装している記事しか見当たらなかった。
しかしgemの中身を掘ってみるとブロック変数をメソッドに渡せば指定カラムの値で並び替えができるようだった。
せっかくなのでこのgemの強みを生かしてシンプルに実装していきたい。

導入

木構造モデルの作成

$ rails g scaffold Tree name:text order:integer ancestry:string:index

Gemfileにancestryを追加

Gemfile
gem 'ancestry'

bundle install、migration

$ bundle install
$ rails db:migrate

木構造モデルにancestry機能を付与

app/models/tree.rb
class Tree < ActiveRecord::Base
  has_ancestry
  validates :name, presence: true
  validates :order, presence: true, uniqueness: true
  
  ...
end

実装

app/models/tree.rb
# 並び替え(順番下げ)が可能かを返す
def can_order_down?
  lower_bound_sibling_order.present?
end

# 並び替え(順番上げ)が可能かを返す
def can_order_up?
  upper_bound_sibling_order.present?
end

# 直前のorderの兄弟を返す
def prev_sibling
  siblings.where('order < ?', order).order(order: :desc).first if can_order_down?
end

# 直後のorderの兄弟を返す
def next_sibling
  siblings.where('order > ?', order).order(order: :asc).first if can_order_up?
end

# 兄弟のorderを入れ替える
def swap_order_with_sibling
  sibling_tree = yield(self)
  return unless sibling_tree

  self_order, sibling_order = order, sibling_tree.order
  transaction do
    update!(order: sibling_order)
    sibling_tree.update!(order: self_order)
  end
end

private

  def lower_bound_sibling_order
    siblings.pluck(:order).reject { |o| o >= order }.max
  end

  def upper_bound_sibling_order
    siblings.pluck(:order).reject { |o| o <= order }.min
  end
config/routes.rb
resources :trees do
  collection do
    get     :up_order
    get     :down_order
  end
end
app/controllers/trees_controller.rb
# order順を含めながら、木構造でTreeモデルを並べる
def index
  sort = -> (a, b) { a.order <=> b.order }
  @trees = Tree.sort_by_ancestry(Tree.all, &sort)
end

def up_order
  Tree.find(params[:id]).swap_order_with_sibling { |tree| tree.next_sibling }
  redirect_to trees_path
end

def down_order
  Tree.find(params[:id]).swap_order_with_sibling { |tree| tree.prev_sibling }
  redirect_to trees_path
end
index.html.slim
table
  thead
    tr
      td order
      td name
  tbody
    - @trees.each.with_index do |tree, i|
      tr
        td
          - if i > 0 && @trees[i].can_order_down?
            = link_to down_order_trees_path(id: tree.id), remote: true do
              |↑
          - else
            |↑

          - if @trees[i + 1].present? && @trees[i].can_order_up?
            = link_to up_order_trees_path(id: tree.id), remote: true do
              |↓
          - else
            |↓
        td
          = "ー"  * tree.depth + tree.name

実装画面スクリーンショット

あとがき

こういった実装はSiblingOrderableのようなconcernとして切り出して再利用できる形にしたくなるとは思うが、この記事の趣旨からは外れるので割愛

Discussion