Tạo tree node model với ancestry - Rails

Giới thiệu

Hôm nay tôi sẽ giới thiệu đến các bạn một công cụ trong Rails - giúp chúng ta dễ dàng hơn trong việc phân cấp có tính kế thừa( hoặc là cấu trúc tree) cho một single model - Ancestry. Việc phân cấp này cần thiết khi chúng ta có nhiều records cùng được định nghĩa bởi một model, mà ta muốn chỉ định đâu là record cha, đâu là record con. Ancestry sử dụng một single database column. Bằng cách sử dụng Ancestry, chúng ta có thể truy vấn được tất cả quan hệ cơ bản của tree structure, đó là: ancestors, parent, root, children, siblings, descendants, và tất cả chúng đều được fetch bởi một câu truy vấn SQL. Ngoài ra còn thêm các tính năng truy vấn được các STI support, scopes, depth caching, depth constraints, dễ dàng migration từ gem cũ, kiểm tra tính toàn vẹn, sắp xếp các (sub) tree vào các hashes và phục vụ các chiến lược khác nhau với các records mồ côi (orphaned records).

Installation

Để áp dụng Ancestry vào ActiveRecord model ta có thể cài đặt như sau:

Install

Add vào gem:

# Gemfile

gem 'ancestry'

Chạy lệnh

bundle install 

Add ancestry vào như một column table:

rails g migration add_ancestry_to_[table] ancestry:string:index

migrate database:

rake db:migrate

Add ancestry vào model:

# app/models/[model.rb]

class [Model] < ActiveRecord::Base
   has_ancestry
end

model của bạn bây giờ đã có cấu trúc tree.

Sử dụng acts_as_tree thay vì has_ancestry

Trong version 1.2.0 method acts_as_tree đã được thay đổi tên thành has_ancestry để cho phép sử dụng cả hai gem acts_as_tree và gem ancestry trong cùng một application. Method acts_as_tree sẽ tiếp tục được support trong thời gian tới.

Tổ chức các records thành cấu trúc tree.

Bạn có thể sử dụng thuộc tính parent để tổ chức thành cấu trúc tree của bạn. Nếu bạn có id của record bạn muốn sử dụng như là parent và bạn không muốn fetch nó thì bạn có thể sử dụng parent_id. Giống như bất kì attributes ảo nào, parent và parent_id có thể set được giá trị bằng cách: parent=, parent_id= trên một bản ghi hoặc đưa chúng vào hash để truyền vào new, create, update_attributes và update_attributes!. Ví dụ:

TreeNode.create! :name => 'Stinky', :parent => TreeNode.create!(:name => 'Squeeky')

Bạn cũng có thể tạ ra một children thông qua relation children:

node.children.create :name => 'Stinky'

Duyệt cây (Navigating your tree)

parent           Returns the parent of the record, nil for a root node
parent_id        Returns the id of the parent of the record, nil for a root node
root             Returns the root of the tree the record is in, self for a root node
root_id          Returns the id of the root of the tree the record is in
root?, is_root?  Returns true if the record is a root node, false otherwise
ancestor_ids     Returns a list of ancestor ids, starting with the root id and ending with the parent id
ancestors        Scopes the model on ancestors of the record
path_ids         Returns a list the path ids, starting with the root id and ending with the node's own id
path             Scopes model on path records of the record
children         Scopes the model on children of the record
child_ids        Returns a list of child ids
has_parent?     Returns true if the record has a parent, false otherwise
has_children?    Returns true if the record has any children, false otherwise
is_childless?    Returns true is the record has no children, false otherwise
siblings         Scopes the model on siblings of the record, the record itself is included*
sibling_ids      Returns a list of sibling ids
has_siblings?    Returns true if the record's parent has more than one child
is_only_child?   Returns true if the record is the only child of its parent
descendants      Scopes the model on direct and indirect children of the record
descendant_ids   Returns a list of a descendant ids
subtree          Scopes the model on descendants and itself
subtree_ids      Returns a list of all ids in the record's subtree
depth            Return the depth of the node, root nodes are at depth 0

Nếu record là root, thì các root khác được coi là anh chị em (cùng cấp)

Options cho has_ancestry

:ancestry_column       Pass in a symbol to store ancestry in a different column
:orphan_strategy       Instruct Ancestry what to do with children of a node that is destroyed:
                       :destroy   All children are destroyed as well (default)
                       :rootify   The children of the destroyed node become root nodes
                       :restrict  An AncestryException is raised if any children exist
                       :adopt     The orphan subtree is added to the parent of the deleted node.
                                  If the deleted node is Root, then rootify the orphan subtree.
:cache_depth           Cache the depth of each node in the 'ancestry_depth' column (default: false)
                       If you turn depth_caching on for an existing model:
                       - Migrate: add_column [table], :ancestry_depth, :integer, :default => 0
                       - Build cache: TreeNode.rebuild_depth_cache!
:depth_cache_column    Pass in a symbol to store depth cache in a different column
:primary_key_format    Supply a regular expression that matches the format of your primary key.
                       By default, primary keys only match integers ([0-9]+).
:touch                 Instruct Ancestry to touch the ancestors of a node when it changes, to
                       invalidate nested key-based caches. (default: false)

Scopes

Nếu có thể, các phương pháp duyệt sẽ trả về các scopes thay vì các records, điều đó có nghĩa là thêm các điều kiện, sắp xếp, giới hạn ... Có thể được áp dụng và kết quả có thể được lấy ra, tính, hoặc kiểm tra sự tồn tại.

ví dụ:

node.children.where(:name => 'Mary').exists?
node.subtree.order(:name).limit(10).each do; ...; end
node.descendants.count

Để tiện lợi, một vài scopes được đặt ở mức class:

roots                   Root nodes
ancestors_of(node)      Ancestors of node, node can be either a record or an id
children_of(node)       Children of node, node can be either a record or an id
descendants_of(node)    Descendants of node, node can be either a record or an id
subtree_of(node)        Subtree of node, node can be either a record or an id
siblings_of(node)       Siblings of node, node can be either a record or an id

Thậm chí có thể dùng chúng như những relation của model:

node.children.create
node.siblings.create!
TestNode.children_of(node_id).new
TestNode.siblings_of(node_id).create

Select các records theo độ sâu (depth)

Khi cache độ sâu được enabled ( xem các options về has_ancestry), 5 scopes được đặt tên khác có thể được sử dụng để select các records:

before_depth(depth)     Return nodes that are less deep than depth (node.depth < depth)
to_depth(depth)         Return nodes up to a certain depth (node.depth <= depth)
at_depth(depth)         Return nodes that are at depth (node.depth == depth)
from_depth(depth)       Return nodes starting from a certain depth (node.depth >= depth)
after_depth(depth)      Return nodes that are deeper than depth (node.depth > depth)

depth scope cũng có sẵn để có thể gọi đến các hậu duệ (descendants) của node, trong trường hợp này giá trị của độ sâu được diễn giải tương đối:

node.subtree(:to_depth => 2)      Subtree of node, to a depth of node.depth + 2 (self, children and grandchildren)
node.subtree.to_depth(5)          Subtree of node to an absolute depth of 5
node.descendants(:at_depth => 2)  Descendant of node, at depth node.depth + 2 (grandchildren)
node.descendants.at_depth(10)     Descendants of node at an absolute depth of 10
node.ancestors.to_depth(3)        The oldest 4 ancestors of node (its root and 3 more)
node.path(:from_depth => -2)      The node's grandparent, parent and the node itself

node.ancestors(:from_depth => -6, :to_depth => -4)
node.path.from_depth(3).to_depth(4)
node.descendants(:from_depth => 2, :to_depth => 4)
node.subtree.from_depth(10).to_depth(12)

Xin lưu ý rằng các ràng buộc về độ sâu không thể truyền được ncestor_ids và path_ids. Lý do cho điều này đó là cả hai relations này đều có thể được fetched từ ancestry tổ tiên mà không cần truy vấn database. Bạn có thể sử dụng:

ancestors(depth_options).map(&:id) 

or

Arrangement

Ancestry dễ dàng sắp xếp các subtree vào các nested hashes để dễ dàng cho việc quản lý và điều hướng sau khi lấy ra từ database. Có một điều lưu ý đó là khi dịch sang tiếng Việt thì ArrangementSort đều có nghĩa là sắp xếp, tuy nhiên ở đây ý nghĩa lại khác nhau.

TreeNode sau khi sắp xếp trả về:

{ #<TreeNode id: 100018, name: "Stinky", ancestry: nil>
  => { #<TreeNode id: 100019, name: "Crunchy", ancestry: "100018">
    => { #<TreeNode id: 100020, name: "Squeeky", ancestry: "100018/100019">
      => {}
    }
  }
}

arrange method cũng hoạt động với scoped class:

TreeNode.find_by_name('Crunchy').subtree.arrange

Method arrange lấy ActiveRecord tìm các options. Nếu bạn muốn các hashes được ordered, bạn bên truyền order vào arrange thay vì dùng scope:

TreeNode.find_by_name('Crunchy').subtree.arrange(:order => :name)

Để sắp xếp các hashes lồng nhau: gọi TreeNode.arrange_serializable:

[
  {
    "ancestry" => nil, "id" => 1, "children" => [
      { "ancestry" => "1", "id" => 2, "children" => [] }
    ]
  }
]

Bạn cũng có thể cung cấp serialization logic của riêng bạn bằng cách sử dụng blocks: Sử dụng ActiveModel Serializers:

TreeNode.arrange_serializable do |parent, children|
  MySerializer.new(parent, children: children)
end

Hoặc tạo hashes đơn giản:

TreeNode.arrange_serializable do |parent, children|
  {
     my_id: parent.id
     my_children: children
  }
end

Dễ dàng đưa về dạng json:

TreeNode.arrange_serializable.to_json

Bạn cũng có thể truyền order vào arrange_serializable như với arrange:

TreeNode.arrange_serializable(:order => :name)

Sorting

Sort:

TreeNode.sort_by_ancestry(array_of_nodes)

Kiểm tra và phục hồi tính toàn vẹn

Ancestry có hỗ trợ một số method giúp có thể phát hiện vấn đề về tính toàn vẹn ( ví dụ như trường hợp ancestry thành vòng tròn) và restoring đảm bảo tính toàn vẹn. Để check thì có thể dùng:

[Model].check_ancestry_integrity!.

Lỗi AncestryIntegrityException sẽ raise nếu vi phạm. bạn có thể định nghĩa :report => :list để lưu trữ một list các exceptions, hoặc :report => :echo để xuất ra các exceptions đấy. Để restore có thể sử dụng:

[Model].restore_ancestry_integrity!

Dưới đây là ví dụ:

>> stinky = TreeNode.create :name => 'Stinky'
$  #<TreeNode id: 1, name: "Stinky", ancestry: nil>
>> squeeky = TreeNode.create :name => 'Squeeky', :parent => stinky
$  #<TreeNode id: 2, name: "Squeeky", ancestry: "1">
>> stinky.update_attribute :parent, squeeky
$  true
>> TreeNode.all
$  [#<TreeNode id: 1, name: "Stinky", ancestry: "1/2">, #<TreeNode id: 2, name: "Squeeky", ancestry: "1/2/1">]
>> TreeNode.check_ancestry_integrity!
!! Ancestry::AncestryIntegrityException: Conflicting parent id in node 1: 2 for node 1, expecting nil
>> TreeNode.restore_ancestry_integrity!
$  [#<TreeNode id: 1, name: "Stinky", ancestry: 2>, #<TreeNode id: 2, name: "Squeeky", ancestry: nil>]

Bổ sung thêm, nếu bạn cảm thấy có gì đó sai sai về depth:

TreeNode.rebuild_depth_cache!