diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddf132e..91e2c22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,23 @@ jobs: gemfile: gemfiles/rails-7.0.gemfile - ruby-version: 3.1 gemfile: gemfiles/rails-7.0.gemfile + - ruby-version: 3.2 + gemfile: gemfiles/rails-7.0.gemfile + - ruby-version: 3.3 + gemfile: gemfiles/rails-7.0.gemfile + - ruby-version: 3.1 + gemfile: gemfiles/rails-7.1.gemfile + - ruby-version: 3.2 + gemfile: gemfiles/rails-7.1.gemfile + - ruby-version: 3.3 + gemfile: gemfiles/rails-7.1.gemfile + # rails 7.2 requires ruby >= 3.1.0 + - ruby-version: 3.1 + gemfile: gemfiles/rails-7.2.gemfile + - ruby-version: 3.2 + gemfile: gemfiles/rails-7.2.gemfile + - ruby-version: 3.3 + gemfile: gemfiles/rails-7.2.gemfile runs-on: ubuntu-latest diff --git a/gemfiles/rails-7.1.gemfile b/gemfiles/rails-7.1.gemfile new file mode 100644 index 0000000..df9690d --- /dev/null +++ b/gemfiles/rails-7.1.gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "rails", "~> 7.1.0" +gem "sqlite3", "~> 1.4" + +gemspec path: "../" diff --git a/gemfiles/rails-7.2.gemfile b/gemfiles/rails-7.2.gemfile new file mode 100644 index 0000000..99cd7be --- /dev/null +++ b/gemfiles/rails-7.2.gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "rails", "~> 7.2.0" +gem "sqlite3", "~> 1.4" + +gemspec path: "../" diff --git a/lib/acts_as_tree.rb b/lib/acts_as_tree.rb index 65cb8f3..c5d2e2b 100644 --- a/lib/acts_as_tree.rb +++ b/lib/acts_as_tree.rb @@ -117,6 +117,14 @@ def acts_as_tree(options = {}) include ActsAsTree::InstanceMethods + define_singleton_method :acts_as_tree_primary_key do + configuration[:primary_key] + end + + define_singleton_method :acts_as_tree_foreign_key do + configuration[:foreign_key] + end + define_singleton_method :default_tree_order do order(configuration[:order]) end @@ -250,33 +258,62 @@ module InstanceMethods # Returns list of ancestors, starting from parent until root. # # subchild1.ancestors # => [child1, root] - def ancestors - node, nodes = self, [] - nodes << node = node.parent while node.parent - nodes + if ActiveRecord::VERSION::MAJOR > 7 || ActiveRecord::VERSION::MAJOR == 7 && ActiveRecord::VERSION::MINOR >= 2 + def ancestors + self_and_ancestors.excluding self + end + else + def ancestors + node, nodes = self, [] + nodes << node = node.parent while node.parent + nodes + end end # Returns list of descendants, starting from current node, not including current node. # # root.descendants # => [child1, child2, subchild1, subchild2, subchild3, subchild4] - def descendants - children.each_with_object(children.to_a) {|child, arr| - arr.concat child.descendants - }.uniq + if ActiveRecord::VERSION::MAJOR > 7 || ActiveRecord::VERSION::MAJOR == 7 && ActiveRecord::VERSION::MINOR >= 2 + def descendants + self_and_descendants.excluding self + end + else + def descendants + children.each_with_object(children.to_a) {|child, arr| + arr.concat child.descendants + }.uniq + end end # Returns list of descendants, starting from current node, including current node. # # root.self_and_descendants # => [root, child1, child2, subchild1, subchild2, subchild3, subchild4] - def self_and_descendants - [self] + descendants + if ActiveRecord::VERSION::MAJOR > 7 || ActiveRecord::VERSION::MAJOR == 7 && ActiveRecord::VERSION::MINOR >= 2 + def self_and_descendants + self.class.where self.class.acts_as_tree_primary_key => self.class.with_recursive( + search_tree: [ + self.class.where(self.class.acts_as_tree_primary_key => send(self.class.acts_as_tree_primary_key)), + self.class.joins("JOIN search_tree ON #{self.class.table_name}.#{self.class.acts_as_tree_foreign_key} = search_tree.#{self.class.acts_as_tree_primary_key}") + ] + ).select(self.class.acts_as_tree_primary_key).from("search_tree") + end + else + def self_and_descendants + [self] + descendants + end end # Returns the root node of the tree. - def root - node = self - node = node.parent while node.parent - node + if ActiveRecord::VERSION::MAJOR > 7 || ActiveRecord::VERSION::MAJOR == 7 && ActiveRecord::VERSION::MINOR >= 2 + def root + self_and_ancestors.find_by self.class.acts_as_tree_foreign_key => nil + end + else + def root + node = self + node = node.parent while node.parent + node + end end # Returns all siblings of the current node. @@ -307,15 +344,15 @@ def self_and_generation self.class.select {|node| node.tree_level == self.tree_level } end - # Returns the level (depth) of the current node + # Returns the level (depth) of the current node # # root1child1.tree_level # => 1 def tree_level self.ancestors.size end - # Returns the level (depth) of the current node unless level is a column on the node. - # Allows backwards compatibility with older versions of the gem. + # Returns the level (depth) of the current node unless level is a column on the node. + # Allows backwards compatibility with older versions of the gem. # Allows integration with apps using level as a column name. # # root1child1.level # => 1 @@ -337,8 +374,19 @@ def self_and_children # Returns ancestors and current node itself. # # subchild1.self_and_ancestors # => [subchild1, child1, root] - def self_and_ancestors - [self] + self.ancestors + if ActiveRecord::VERSION::MAJOR > 7 || ActiveRecord::VERSION::MAJOR == 7 && ActiveRecord::VERSION::MINOR >= 2 + def self_and_ancestors + self.class.where self.class.acts_as_tree_primary_key => self.class.with_recursive( + search_tree: [ + self.class.where(self.class.acts_as_tree_primary_key => send(self.class.acts_as_tree_primary_key)), + self.class.joins("JOIN search_tree ON #{self.class.table_name}.#{self.class.acts_as_tree_primary_key} = search_tree.#{self.class.acts_as_tree_foreign_key}") + ] + ).select(self.class.acts_as_tree_primary_key).from("search_tree") + end + else + def self_and_ancestors + [self] + self.ancestors + end end # Returns true if node has no parent, false otherwise diff --git a/test/acts_as_tree_test.rb b/test/acts_as_tree_test.rb index 2773a4f..4dd271b 100644 --- a/test/acts_as_tree_test.rb +++ b/test/acts_as_tree_test.rb @@ -3,7 +3,13 @@ require 'active_record' require 'acts_as_tree' -class ActsAsTreeTestCase < (defined?(MiniTest::Test) ? MiniTest::Test : MiniTest::Unit::TestCase) +parent_class = if defined?(Minitest::Test) + Minitest::Test +else + (defined?(MiniTest::Test) ? MiniTest::Test : MiniTest::Unit::TestCase) +end + +class ActsAsTreeTestCase < parent_class UPDATE_METHOD = ActiveRecord::VERSION::MAJOR >= 4 ? :update : :update_attributes def assert_queries(num = 1, &block) @@ -183,7 +189,11 @@ def test_insert def test_ancestors assert_equal [], @root1.ancestors assert_equal [@root1], @root_child1.ancestors - assert_equal [@root_child1, @root1], @child1_child.ancestors + if ActiveRecord::VERSION::MAJOR > 7 || ActiveRecord::VERSION::MAJOR == 7 && ActiveRecord::VERSION::MINOR >= 2 + [@root_child1, @root1].all? { |node| assert_includes @child1_child.ancestors, node } + else + assert_equal [@root_child1, @root1], @child1_child.ancestors + end assert_equal [@root1], @root_child2.ancestors assert_equal [], @root2.ancestors assert_equal [], @root3.ancestors @@ -235,18 +245,65 @@ def test_self_and_children end def test_self_and_ancestors - assert_equal [@child1_child, @root_child1, @root1], @child1_child.self_and_ancestors - assert_equal [@root2], @root2.self_and_ancestors + if ActiveRecord::VERSION::MAJOR > 7 || ActiveRecord::VERSION::MAJOR == 7 && ActiveRecord::VERSION::MINOR >= 2 + [@child1_child, @root_child1, @root1].all? { |node| assert_includes @child1_child.self_and_ancestors, node } + [@root2].all? { |node| assert_includes @root2.self_and_ancestors, node } + else + assert_equal [@child1_child, @root_child1, @root1], @child1_child.self_and_ancestors + assert_equal [@root2], @root2.self_and_ancestors + end end def test_self_and_descendants - assert_equal [@root1, @root_child1, @root_child2, @child1_child, @child1_child_child], @root1.self_and_descendants - assert_equal [@root2], @root2.self_and_descendants + if ActiveRecord::VERSION::MAJOR > 7 || ActiveRecord::VERSION::MAJOR == 7 && ActiveRecord::VERSION::MINOR >= 2 + [@root1, @root_child1, @root_child2, @child1_child, @child1_child_child].all? { |node| assert_includes @root1.self_and_descendants, node } + [@root2].all? { |node| assert_includes @root2.self_and_descendants, node } + else + assert_equal [@root1, @root_child1, @root_child2, @child1_child, @child1_child_child], @root1.self_and_descendants + assert_equal [@root2], @root2.self_and_descendants + end end def test_descendants - assert_equal [@root_child1, @root_child2, @child1_child, @child1_child_child], @root1.descendants - assert_equal [], @root2.descendants + if ActiveRecord::VERSION::MAJOR > 7 || ActiveRecord::VERSION::MAJOR == 7 && ActiveRecord::VERSION::MINOR >= 2 + [@root_child1, @root_child2, @child1_child, @child1_child_child].all? { |node| assert_includes @root1.descendants, node } + assert_empty @root2.descendants + else + assert_equal [@root_child1, @root_child2, @child1_child, @child1_child_child], @root1.descendants + assert_equal [], @root2.descendants + end + end + + if ActiveRecord::VERSION::MAJOR > 7 || ActiveRecord::VERSION::MAJOR == 7 && ActiveRecord::VERSION::MINOR >= 2 + def test_self_and_descendants_performs_one_query + assert_queries 1 do + @root2.self_and_descendants.count + end + end + + def test_descendants_performs_one_query + assert_queries 1 do + @root1.descendants.count + end + end + + def test_self_and_ancestors_performs_one_query + assert_queries 1 do + @child1_child.self_and_ancestors.count + end + end + + def test_ancestors_performs_one_query + assert_queries 1 do + @child1_child.ancestors.count + end + end + + def test_root_performs_one_query + assert_queries 1 do + @child1_child.root + end + end end def test_nullify