Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add scope for leaves #388

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ To navigate an Ancestry model, use the following instance methods:
|`is_only_child?` <br/> `only_child?` |true if the record is the only child of its parent|
|`descendants` |direct and indirect children of the record|
|`descendant_ids` |direct and indirect children's ids of the record|
|`leaves` |leaves of the record (i.e. childless descendants of the node)|
|`leaf_ids` |a list of all the leaves' ids of the record|
|`leaf?` </br> `is_leaf?` |Returns true if the record is a leaf node (ie. childless), false otherwise|
|`subtree` |the model on descendants and itself|
|`subtree_ids` |a list of all ids in the record's subtree|
|`depth` |the depth of the node, root nodes are at depth 0|
Expand All @@ -123,6 +126,7 @@ There are also instance methods to determine the relationship between 2 nodes:
|`root_of?(node)` | node's root is this record|
|`ancestor_of?(node)`| node's ancestors include this record|
|`child_of?(node)` | node is record's parent|
|`leaf_of?(node)` | node's leaves include this record|

# Options for `has_ancestry`

Expand Down Expand Up @@ -166,6 +170,7 @@ For convenience, a couple of named scopes are included at the class level:
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
leaves_of(node) Leaves of node, node can be either a record or an id

Thanks to some convenient rails magic, it is even possible to create nodes
through the children and siblings scopes:
Expand Down
2 changes: 2 additions & 0 deletions lib/ancestry/has_ancestry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@ def has_ancestry options = {}

# Named scopes
scope :roots, lambda { where(root_conditions) }
scope :leaves, lambda { where(leaf_conditions) }
scope :ancestors_of, lambda { |object| where(ancestor_conditions(object)) }
scope :children_of, lambda { |object| where(child_conditions(object)) }
scope :descendants_of, lambda { |object| where(descendant_conditions(object)) }
scope :subtree_of, lambda { |object| where(subtree_conditions(object)) }
scope :siblings_of, lambda { |object| where(sibling_conditions(object)) }
scope :leaves_of, lambda { |object| leaves.subtree_of(object) }
scope :ordered_by_ancestry, Proc.new { |order|
if %w(mysql mysql2 sqlite sqlite3 postgresql).include?(connection.adapter_name.downcase) && ActiveRecord::VERSION::MAJOR >= 5
reorder(Arel.sql("coalesce(#{connection.quote_table_name(table_name)}.#{connection.quote_column_name(ancestry_column)}, '')"), order)
Expand Down
25 changes: 25 additions & 0 deletions lib/ancestry/instance_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,31 @@ def descendant_of?(node)
ancestor_ids.include?(node.id)
end

# Leaves

# FIXME do we want keep this public API for leaves?

# def leaf_conditions
# self.ancestry_base_class.leaf_conditions
# end

def leaves
self.ancestry_base_class.leaves_of(self)
end

def leaf_ids
leaves.pluck(self.ancestry_base_class.primary_key)
end

def is_leaf?
self.leaves.to_a == [self]
end
alias_method :leaf?, :is_leaf?

def leaf_of?(node)
node.leaf_ids.include?(self.id)
end

# Subtree

def subtree_conditions
Expand Down
61 changes: 61 additions & 0 deletions lib/ancestry/materialized_path.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module MaterializedPath
def self.extended(base)
base.validates_format_of base.ancestry_column, :with => Ancestry::ANCESTRY_PATTERN, :allow_nil => true
base.send(:include, InstanceMethods)
base.send(:define_concat_strategy)
end

def root_conditions
Expand Down Expand Up @@ -50,6 +51,66 @@ def sibling_conditions(object)
t[ancestry_column].eq(node[ancestry_column])
end

# equivalent to..
# SELECT tables.* IN (
# SELECT tables.id FROM tables
# LEFT OUTER JOIN test_nodes children ON
# test_nodes.ancestry || '/' || test_nodes.id = children.ancestry (*)
# OR test_nodes.id = children.ancestry
# GROUP BY test_nodes.id HAVING COUNT(children.id) = 0
# )
#
# * this part is detabese dependent, and potentially affected by material path implementation.
# (meaning, this should be placed in this module fur the time being)
def leaf_conditions
t = arel_table
t2 = arel_table.alias('children')
parent_path = concat_all(t[ancestry_column], path_delimitor, t[primary_key])

t[primary_key].in(
t.project(t[primary_key])
.outer_join(t2)
.on(parent_path.eq(t2[ancestry_column]).or(t[primary_key].eq(t2[ancestry_column])))
.group(t[primary_key])
.having(t2[primary_key].count.eq(0))
)
end

private
def path_delimitor
if ActiveRecord::VERSION::STRING >= '4.2.0' # >= Arel 6.0.0
Arel::Nodes.build_quoted('/')
else
'/'
end
end

def concat_all(node1, node2, *extra_nodes)
if extra_nodes.empty?
concat_nodes node1, node2
else
concat_all concat_nodes(node1, node2), *extra_nodes
end
end

def define_concat_strategy
if ActiveRecord::VERSION::STRING >= '5.1' # >= Arel 7.1.0
def concat_nodes(left, right)
Arel::Nodes::Concat(left, right)
end
else
if ActiveRecord::Base.connection.adapter_name.downcase == 'sqlite'
def concat_nodes(left, right)
Arel::Nodes::InfixOperation.new('||',left,right)
end
else
def concat_nodes(left, right)
Arel::Nodes::NamedFunction.new('concat', left, right)
end
end
end
end

module InstanceMethods
# Validates the ancestry, but can also be applied if validation is bypassed to determine if children should be affected
def sane_ancestry?
Expand Down
3 changes: 3 additions & 0 deletions test/concerns/scopes_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ def test_scopes
# Assertions for siblings_of named scope
assert_equal test_node.siblings.to_a, model.siblings_of(test_node).to_a
assert_equal test_node.siblings.to_a, model.siblings_of(test_node.id).to_a
# Assertions for leaves_of named scope
assert_equal test_node.leaves.to_a, model.leaves_of(test_node).to_a
assert_equal test_node.leaves.to_a, model.leaves_of(test_node.id).to_a
# Assertions for path_of named scope
assert_equal test_node.path.to_a, model.path_of(test_node).to_a
assert_equal test_node.path.to_a, model.path_of(test_node.id).to_a
Expand Down
26 changes: 25 additions & 1 deletion test/concerns/tree_navigration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ def test_tree_navigation
assert_equal descendants.map(&:id), lvl0_node.descendant_ids
assert_equal descendants, lvl0_node.descendants
assert_equal [lvl0_node] + descendants, lvl0_node.subtree
# Leaves assertions
leaf_ids = model.all.map(&:id) - model.all.map(&:ancestor_ids).flatten.uniq
leaves = model.all.find_all do |node|
leaf_ids.include?(node.id) && node.path_ids.include?(lvl0_node.id)
end
assert_equal leaves.map(&:id), lvl0_node.leaf_ids
assert_equal leaves, lvl0_node.leaves
assert !lvl0_node.is_leaf?

lvl0_children.each do |lvl1_node, lvl1_children|
# Ancestors assertions
Expand Down Expand Up @@ -68,6 +76,14 @@ def test_tree_navigation
assert_equal descendants.map(&:id), lvl1_node.descendant_ids
assert_equal descendants, lvl1_node.descendants
assert_equal [lvl1_node] + descendants, lvl1_node.subtree
# Leaves assertions
leaf_ids = model.all.map(&:id) - model.all.map(&:ancestor_ids).flatten.uniq
leaves = model.all.find_all do |node|
leaf_ids.include?(node.id) && node.path_ids.include?(lvl1_node.id)
end
assert_equal leaves.map(&:id), lvl1_node.leaf_ids
assert_equal leaves, lvl1_node.leaves
assert !lvl1_node.is_leaf?

lvl1_children.each do |lvl2_node, lvl2_children|
# Ancestors assertions
Expand Down Expand Up @@ -101,9 +117,17 @@ def test_tree_navigation
assert_equal descendants.map(&:id), lvl2_node.descendant_ids
assert_equal descendants, lvl2_node.descendants
assert_equal [lvl2_node] + descendants, lvl2_node.subtree
# Leaves assertions
leaf_ids = model.all.map(&:id) - model.all.map(&:ancestor_ids).flatten.uniq
leaves = model.all.find_all do |node|
leaf_ids.include?(node.id) && node.path_ids.include?(lvl2_node.id)
end
assert_equal leaves.map(&:id), lvl2_node.leaf_ids
assert_equal leaves, lvl2_node.leaves
assert lvl2_node.is_leaf?
end
end
end
end
end
end
end
5 changes: 5 additions & 0 deletions test/concerns/tree_predicate_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ def test_tree_predicates
# Descendants assertions
assert children.map { |n| !root.descendant_of?(n) }.all?
assert children.map { |n| n.descendant_of?(root) }.all?
# Leaves assertions
assert !root.is_leaf?
assert children.map { |n| n.is_leaf? }.all?
assert children.map { |n| !root.leaf_of?(n) }.all?
assert children.map { |n| n.leaf_of?(root) }.all?
end
end
end
Expand Down