diff --git a/README.md b/README.md index b1fb6bfe..7e135834 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,9 @@ To navigate an Ancestry model, use the following instance methods: |`is_only_child?`
`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?`
`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| @@ -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` @@ -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: diff --git a/lib/ancestry/has_ancestry.rb b/lib/ancestry/has_ancestry.rb index 34becaba..5046b3fd 100644 --- a/lib/ancestry/has_ancestry.rb +++ b/lib/ancestry/has_ancestry.rb @@ -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) diff --git a/lib/ancestry/instance_methods.rb b/lib/ancestry/instance_methods.rb index 09d65ae8..886481b6 100644 --- a/lib/ancestry/instance_methods.rb +++ b/lib/ancestry/instance_methods.rb @@ -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 diff --git a/lib/ancestry/materialized_path.rb b/lib/ancestry/materialized_path.rb index f40911ed..40533fd4 100644 --- a/lib/ancestry/materialized_path.rb +++ b/lib/ancestry/materialized_path.rb @@ -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 @@ -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? diff --git a/test/concerns/scopes_test.rb b/test/concerns/scopes_test.rb index dd7093aa..639669d3 100644 --- a/test/concerns/scopes_test.rb +++ b/test/concerns/scopes_test.rb @@ -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 diff --git a/test/concerns/tree_navigration_test.rb b/test/concerns/tree_navigration_test.rb index 496663c6..b3491fcc 100644 --- a/test/concerns/tree_navigration_test.rb +++ b/test/concerns/tree_navigration_test.rb @@ -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 @@ -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 @@ -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 \ No newline at end of file +end diff --git a/test/concerns/tree_predicate_test.rb b/test/concerns/tree_predicate_test.rb index b439004f..074319d1 100644 --- a/test/concerns/tree_predicate_test.rb +++ b/test/concerns/tree_predicate_test.rb @@ -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