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

4 min read読了の目安(約3600字

目的

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?
    # 兄弟(同じ親をもつ別のnode)がいて、自分のorderが兄弟達の最小より大きい
    has_siblings? && order > siblings.pluck(:order).min
  end

  # 並び替え(順番上げ)が可能かを返す
  def can_order_up?
    # 兄弟(同じ親をもつ別のnode)がいて、自分のorderが兄弟達の最大より小さい
    has_siblings? && order < siblings.pluck(:order).max
  end

  # 直前のorderの兄弟を返す
  def prev_sibling
    can_order_down? ? siblings.where(order: -Float::INFINITY...order).where.not(id: id).order(:order).last : nil
  end

  # 直後のorderの兄弟を返す
  def next_sibling
    can_order_up? ? siblings.where(order: order...Float::INFINITY).where.not(id: id).order(:order).first : nil
  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

# 直後の兄弟のorderと自身のorderを入れ替える
def up_order
  target_label = Tree.find(params[:id])
  next_label = Tree.find(params[:next_id])
  target_order = target_label.order
  next_order = next_label.order
  target_label.update(order: next_order)
  next_label.update(order: target_order)
  redirect_to trees_path
end

# 直前の兄弟のorderと自身のorderを入れ替える
def down_order
  prev_label = Tree.find(params[:prev_id])
  target_label = Tree.find(params[:id])
  prev_order = prev_label.order
  target_order = target_label.order
  target_label.update(order: prev_order)
  prev_label.update(order: target_order)
  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, prev_id: @trees[i].prev_sibling), remote: true do
              |↑
          - else
            |↑

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

実装ページ