├── .rspec ├── lib ├── acts_as_recursive_tree │ ├── version.rb │ ├── builders │ │ ├── descendants.rb │ │ ├── ancestors.rb │ │ ├── strategies │ │ │ ├── ancestor.rb │ │ │ ├── descendant.rb │ │ │ ├── subselect.rb │ │ │ └── join.rb │ │ ├── strategies.rb │ │ ├── leaves.rb │ │ └── relation_builder.rb │ ├── railtie.rb │ ├── scopes.rb │ ├── associations.rb │ ├── acts_macro.rb │ ├── options │ │ ├── query_options.rb │ │ ├── depth_condition.rb │ │ └── values.rb │ ├── config.rb │ ├── preloaders │ │ └── descendants.rb │ └── model.rb └── acts_as_recursive_tree.rb ├── Gemfile ├── gemfiles ├── ar_70.gemfile ├── ar_71.gemfile ├── ar_72.gemfile ├── ar_80.gemfile └── ar_next.gemfile ├── spec ├── db │ ├── database.yml │ ├── models.rb │ ├── schema.rb │ └── database.rb ├── acts_as_recursive_tree │ ├── builders │ │ ├── ancestors_spec.rb │ │ ├── leaves_spec.rb │ │ └── descendants_spec.rb │ ├── preloaders │ │ └── descendants_spec.rb │ └── options │ │ └── values_spec.rb ├── support │ ├── tree_methods.rb │ └── shared_examples │ │ └── builders.rb ├── model │ ├── location_spec.rb │ ├── relation_spec.rb │ └── node_spec.rb └── spec_helper.rb ├── .gitignore ├── Rakefile ├── Appraisals ├── .rubocop.yml ├── .github └── workflows │ ├── rubygem.yml │ ├── lint.yml │ └── ci.yml ├── LICENSE.txt ├── acts_as_recursive_tree.gemspec ├── CHANGELOG.md ├── .rubocop_todo.yml └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | VERSION = '4.1.0' 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in acts_as_recursive_tree.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /gemfiles/ar_70.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0" 6 | gem "activesupport", "~> 7.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/ar_71.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.1" 6 | gem "activesupport", "~> 7.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/ar_72.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.2" 6 | gem "activesupport", "~> 7.2" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/ar_80.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 8.0" 6 | gem "activesupport", "~> 8.0" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /spec/db/database.yml: -------------------------------------------------------------------------------- 1 | sqlite: 2 | database: 3 | host: localhost 4 | pool: 50 5 | timeout: 5000 6 | reaping_frequency: 1000 7 | min_messages: ERROR 8 | adapter: sqlite3 9 | database: test.sqlite3 10 | -------------------------------------------------------------------------------- /gemfiles/ar_next.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | git "https://github.com/rails/rails.git", branch: "main" do 6 | gem "activerecord" 7 | gem "activesupport" 8 | end 9 | 10 | gemspec path: "../" 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | mkmf.log 15 | /.idea 16 | db.log 17 | test.sqlite3 18 | /gemfiles/*.lock 19 | /gemfiles/.bundle 20 | /.ruby-version 21 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'acts_as_recursive_tree/railtie' if defined?(Rails) 4 | require 'zeitwerk' 5 | 6 | loader = Zeitwerk::Loader.for_gem 7 | loader.setup 8 | 9 | module ActsAsRecursiveTree 10 | # nothing special here 11 | end 12 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/builders/descendants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Builders 5 | class Descendants < RelationBuilder 6 | self.traversal_strategy = ActsAsRecursiveTree::Builders::Strategies::Descendant 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: [:spec] 9 | 10 | desc 'Deletes temporary files' 11 | task :clean_tmp_files do 12 | %w[db.log test.sqlite3].each do |file| 13 | FileUtils.rm_f(file) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | class Railtie < Rails::Railtie 5 | initializer 'acts_as_recursive_tree.active_record_initializer' do 6 | ActiveRecord::Base.class_exec do 7 | extend ActsAsRecursiveTree::ActsMacro 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/builders/ancestors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Builders 5 | class Ancestors < RelationBuilder 6 | self.traversal_strategy = ActsAsRecursiveTree::Builders::Strategies::Ancestor 7 | 8 | def get_query_options(&) 9 | opts = super 10 | opts.ensure_ordering! 11 | opts 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/builders/strategies/ancestor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Builders 5 | module Strategies 6 | # 7 | # Strategy for building ancestors relation 8 | # 9 | module Ancestor 10 | # 11 | # Builds the relation 12 | # 13 | def self.build(builder) 14 | builder.travers_loc_table[builder.parent_key].eq(builder.base_table[builder.primary_key]) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/acts_as_recursive_tree/builders/ancestors_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActsAsRecursiveTree::Builders::Ancestors do 6 | context 'without additional setup' do 7 | it_behaves_like 'build recursive query' 8 | it_behaves_like 'ancestor query' 9 | include_context 'with ordering' 10 | end 11 | 12 | context 'with options' do 13 | include_context 'with enforced ordering setup' do 14 | it_behaves_like 'is adding ordering' 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/builders/strategies/descendant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Builders 5 | module Strategies 6 | # 7 | # Strategy for building descendants relation 8 | # 9 | module Descendant 10 | # 11 | # Builds the relation 12 | # 13 | def self.build(builder) 14 | builder.base_table[builder.parent_key].eq(builder.travers_loc_table[builder.primary_key]) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/scopes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Scopes 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | scope :roots, lambda { 9 | rel = where(_recursive_tree_config.parent_key => nil) 10 | if _recursive_tree_config.parent_type_column 11 | rel = rel.or( 12 | where.not(_recursive_tree_config.parent_type_column => to_s) 13 | ) 14 | end 15 | 16 | rel 17 | } 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/acts_as_recursive_tree/builders/leaves_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActsAsRecursiveTree::Builders::Leaves do 6 | context 'without additional setup' do 7 | it_behaves_like 'build recursive query' 8 | it_behaves_like 'descendant query' 9 | include_context 'without ordering' 10 | end 11 | 12 | context 'with options' do 13 | include_context 'with enforced ordering setup' do 14 | let(:ordering) { true } 15 | it_behaves_like 'not adding ordering' 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/acts_as_recursive_tree/builders/descendants_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActsAsRecursiveTree::Builders::Descendants do 6 | context 'without additional setup' do 7 | it_behaves_like 'build recursive query' 8 | it_behaves_like 'descendant query' 9 | include_context 'without ordering' 10 | end 11 | 12 | context 'with options' do 13 | include_context 'with enforced ordering setup' do 14 | let(:ordering) { true } 15 | it_behaves_like 'is adding ordering' 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'ar-70' do 4 | gem 'activerecord', '~> 7.0' 5 | gem 'activesupport', '~> 7.0' 6 | end 7 | 8 | appraise 'ar-71' do 9 | gem 'activerecord', '~> 7.1' 10 | gem 'activesupport', '~> 7.1' 11 | end 12 | 13 | appraise 'ar-72' do 14 | gem 'activerecord', '~> 7.2' 15 | gem 'activesupport', '~> 7.2' 16 | end 17 | 18 | appraise 'ar-80' do 19 | gem 'activerecord', '~> 8.0' 20 | gem 'activesupport', '~> 8.0' 21 | end 22 | 23 | appraise 'ar-next' do 24 | git 'https://github.com/rails/rails.git', branch: 'main' do 25 | gem 'activerecord' 26 | gem 'activesupport' 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rails 3 | - rubocop-rspec 4 | 5 | inherit_from: .rubocop_todo.yml 6 | 7 | AllCops: 8 | TargetRubyVersion: 3.1 9 | NewCops: enable 10 | SuggestExtensions: false 11 | 12 | Gemspec/DevelopmentDependencies: 13 | EnforcedStyle: gemspec 14 | 15 | Gemspec/RequireMFA: 16 | Enabled: false 17 | 18 | Metrics/AbcSize: 19 | Max: 20 20 | 21 | Rails/RakeEnvironment: 22 | Enabled: false 23 | 24 | RSpec/NestedGroups: 25 | Max: 4 26 | 27 | Style/Alias: 28 | EnforcedStyle: prefer_alias 29 | 30 | Style/Documentation: 31 | Enabled: false 32 | 33 | Style/FrozenStringLiteralComment: 34 | Exclude: 35 | - 'gemfiles/**/*' 36 | 37 | Style/StringLiterals: 38 | Exclude: 39 | - 'gemfiles/**/*' 40 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/builders/strategies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Builders 5 | # 6 | # Strategy module for different strategies of how to build the resulting query. 7 | # 8 | module Strategies 9 | # 10 | # Returns a Strategy appropriate for query_opts 11 | # 12 | # @param query_opts [ActsAsRecursiveTree::Options::QueryOptions] 13 | # 14 | # @return a strategy class best suited for the opts 15 | def self.for_query_options(query_opts) 16 | if query_opts.ensure_ordering || query_opts.query_strategy == :join 17 | Join 18 | else 19 | Subselect 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/builders/leaves.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Builders 5 | class Leaves < Descendants 6 | def create_select_manger(column = nil) 7 | select_manager = super 8 | 9 | select_manager.where( 10 | travers_loc_table[primary_key].not_in( 11 | travers_loc_table.where( 12 | travers_loc_table[parent_key].not_eq(nil) 13 | ).project(travers_loc_table[parent_key]) 14 | ) 15 | ) 16 | select_manager 17 | end 18 | 19 | def get_query_options(&) 20 | # do not allow any custom options 21 | ActsAsRecursiveTree::Options::QueryOptions.new 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/builders/strategies/subselect.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Builders 5 | module Strategies 6 | # 7 | # Strategy for building a relation using an WHERE ID IN(...). 8 | # 9 | module Subselect 10 | # 11 | # Builds the relation. 12 | # 13 | # @param builder [ActsAsRecursiveTree::Builders::RelationBuilder] 14 | # @return [ActiveRecord::Relation] 15 | def self.build(builder) 16 | builder.klass.where( 17 | builder.base_table[builder.primary_key].in( 18 | builder.create_select_manger(builder.primary_key) 19 | ) 20 | ) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/support/tree_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Helper methods for simple tree creation 4 | module TreeMethods 5 | def create_tree(max_level, current_level: 0, node: nil, create_node_info: false, stop_at: -1) 6 | node ||= Node.create!(name: 'root') 7 | 8 | 1.upto(max_level - current_level) do |index| 9 | child = node.children.create!(name: "child #{index} - level #{current_level}", active: stop_at > current_level) 10 | 11 | child.create_node_info(status: stop_at > current_level ? 'foo' : 'bar') if create_node_info 12 | 13 | create_tree( 14 | max_level, 15 | current_level: current_level + 1, 16 | node: child, 17 | create_node_info:, 18 | stop_at: 19 | ) 20 | end 21 | node 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/db/models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Base test class 4 | class ApplicationRecord < ActiveRecord::Base 5 | self.abstract_class = true 6 | 7 | extend ActsAsRecursiveTree::ActsMacro 8 | end 9 | 10 | class Node < ApplicationRecord 11 | acts_as_tree 12 | has_one :node_info 13 | end 14 | 15 | class NodeInfo < ApplicationRecord 16 | belongs_to :node 17 | end 18 | 19 | class NodeWithPolymorphicParent < ApplicationRecord 20 | acts_as_tree parent_key: :other_id, parent_type_column: :other_type 21 | end 22 | 23 | class NodeWithOtherParentKey < ApplicationRecord 24 | acts_as_tree parent_key: :other_id 25 | end 26 | 27 | class Location < ApplicationRecord 28 | acts_as_tree 29 | end 30 | 31 | class Building < Location 32 | end 33 | 34 | class Floor < Location 35 | end 36 | 37 | class Room < Location 38 | end 39 | -------------------------------------------------------------------------------- /spec/db/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define(version: 0) do 4 | create_table :nodes do |t| 5 | t.integer :parent_id 6 | t.string :name 7 | t.boolean :active, default: true 8 | end 9 | 10 | add_foreign_key(:nodes, :nodes, column: :parent_id) 11 | 12 | create_table :node_infos do |t| 13 | t.belongs_to :node 14 | t.string :status 15 | end 16 | 17 | create_table :node_with_other_parent_keys do |t| 18 | t.integer :other_id 19 | end 20 | 21 | create_table :node_with_polymorphic_parents do |t| 22 | t.integer :other_id 23 | t.string :other_type 24 | end 25 | 26 | create_table :locations do |t| 27 | t.integer :parent_id 28 | t.string :name 29 | t.string :type 30 | end 31 | 32 | add_foreign_key(:locations, :locations, column: :parent_id) 33 | end 34 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/associations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/concern' 4 | 5 | module ActsAsRecursiveTree 6 | module Associations 7 | extend ActiveSupport::Concern 8 | 9 | included do 10 | belongs_to :parent, 11 | class_name: base_class.to_s, 12 | foreign_key: _recursive_tree_config.parent_key, 13 | inverse_of: :children, 14 | optional: true 15 | 16 | has_many :children, 17 | class_name: base_class.to_s, 18 | foreign_key: _recursive_tree_config.parent_key, 19 | inverse_of: :parent, 20 | dependent: _recursive_tree_config.dependent 21 | 22 | has_many :self_and_siblings, 23 | through: :parent, 24 | source: :children, 25 | class_name: base_class.to_s 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/acts_macro.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module ActsMacro 5 | ## 6 | # Configuration options are: 7 | # 8 | # * foreign_key - specifies the column name to use for tracking 9 | # of the tree (default: +parent_id+) 10 | def recursive_tree(parent_key: :parent_id, parent_type_column: nil, dependent: nil) 11 | class_attribute(:_recursive_tree_config, instance_writer: false) 12 | 13 | self._recursive_tree_config = Config.new( 14 | model_class: self, 15 | parent_key: parent_key.to_sym, 16 | parent_type_column: parent_type_column.try(:to_sym), 17 | dependent: 18 | ) 19 | 20 | include ActsAsRecursiveTree::Model 21 | include ActsAsRecursiveTree::Associations 22 | include ActsAsRecursiveTree::Scopes 23 | end 24 | 25 | alias acts_as_tree recursive_tree 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/options/query_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Options 5 | class QueryOptions 6 | STRATEGIES = %i[subselect join].freeze 7 | 8 | def self.from 9 | options = new 10 | yield(options) if block_given? 11 | options 12 | end 13 | 14 | attr_accessor :condition 15 | attr_reader :ensure_ordering, :query_strategy 16 | 17 | def depth 18 | @depth ||= DepthCondition.new 19 | end 20 | 21 | def ensure_ordering! 22 | @ensure_ordering = true 23 | end 24 | 25 | def depth_present? 26 | @depth.present? 27 | end 28 | 29 | def query_strategy=(strategy) 30 | raise "invalid strategy #{strategy} - only #{STRATEGIES} are allowed" unless STRATEGIES.include?(strategy) 31 | 32 | @query_strategy = strategy 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/db/database.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | 5 | database_folder = "#{File.dirname(__FILE__)}/../db" 6 | database_adapter = 'sqlite' 7 | 8 | # Logger setup 9 | ActiveRecord::Base.logger = nil 10 | 11 | ActiveRecord::Migration.verbose = false 12 | 13 | ActiveRecord::Base.configurations = YAML.safe_load_file("#{database_folder}/database.yml") 14 | 15 | if ActiveRecord.version >= Gem::Version.new('6.1.0') 16 | config = ActiveRecord::Base.configurations.configs_for env_name: database_adapter, name: 'primary' 17 | database = config.database 18 | else 19 | config = ActiveRecord::Base.configurations[database_adapter] 20 | database = config['database'] 21 | end 22 | 23 | # remove database if present 24 | FileUtils.rm database, force: true 25 | 26 | ActiveRecord::Base.establish_connection(database_adapter.to_sym) 27 | ActiveRecord::Base.establish_connection(config) 28 | 29 | # require schemata and models 30 | require_relative 'schema' 31 | require_relative 'models' 32 | -------------------------------------------------------------------------------- /.github/workflows/rubygem.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Ruby Gem 7 | 8 | on: 9 | # Manually publish 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | name: Build + Publish 15 | runs-on: ubuntu-latest 16 | permissions: 17 | packages: write 18 | contents: read 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: 3.1 26 | - run: bundle install 27 | - name: Publish to RubyGems 28 | env: 29 | GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_AUTH_TOKEN}}" 30 | run: | 31 | mkdir -p $HOME/.gem 32 | touch $HOME/.gem/credentials 33 | chmod 0600 $HOME/.gem/credentials 34 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 35 | gem build *.gemspec 36 | gem push *.gem 37 | 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Wolfgang Wedelich-John 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: LINT 9 | 10 | on: 11 | push: 12 | branches: [ main ] 13 | pull_request: 14 | branches: [ '**' ] 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Ruby 23 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 24 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 25 | uses: ruby/setup-ruby@v1 26 | # uses: ruby/setup-ruby@473e4d8fe5dd94ee328fdfca9f8c9c7afc9dae5e 27 | with: 28 | ruby-version: 3.1 29 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 30 | - name: Run rubocop 31 | run: bundle exec rubocop 32 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/builders/strategies/join.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Builders 5 | module Strategies 6 | # 7 | # Build a relation using an INNER JOIN. 8 | # 9 | module Join 10 | # 11 | # Builds the relation. 12 | # 13 | # @param builder [ActsAsRecursiveTree::Builders::RelationBuilder] 14 | # @return [ActiveRecord::Relation] 15 | def self.build(builder) 16 | final_select_mgr = builder.base_table.join( 17 | builder.create_select_manger.as(builder.recursive_temp_table.name) 18 | ).on( 19 | builder.base_table[builder.primary_key].eq(builder.recursive_temp_table[builder.primary_key]) 20 | ) 21 | 22 | relation = builder.klass.joins(final_select_mgr.join_sources) 23 | 24 | apply_order(builder, relation) 25 | end 26 | 27 | def self.apply_order(builder, relation) 28 | return relation unless builder.ensure_ordering 29 | 30 | relation.order(builder.recursive_temp_table[builder.depth_column].asc) 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/options/depth_condition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Options 5 | class DepthCondition 6 | def ==(other) 7 | @value = Values.create(other) 8 | @operation = true 9 | end 10 | 11 | def !=(other) 12 | @value = Values.create(other) 13 | @operation = false 14 | end 15 | 16 | def <(other) 17 | @value = other 18 | @operation = :lt 19 | end 20 | 21 | def <=(other) 22 | @value = other 23 | @operation = :lteq 24 | end 25 | 26 | def >(other) 27 | @value = other 28 | @operation = :gt 29 | end 30 | 31 | def >=(other) 32 | @value = other 33 | @operation = :gteq 34 | end 35 | 36 | def apply_to(attribute) 37 | if @value.is_a?(Values::Base) 38 | if @operation 39 | @value.apply_to(attribute) 40 | else 41 | @value.apply_negated_to(attribute) 42 | end 43 | else 44 | attribute.send(@operation, @value) 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | # 5 | # Stores the configuration of one Model class 6 | # 7 | class Config 8 | attr_reader :parent_key, :parent_type_column, :depth_column, :dependent 9 | 10 | def initialize(model_class:, parent_key:, parent_type_column:, depth_column: :recursive_depth, dependent: nil) 11 | @model_class = model_class 12 | @parent_key = parent_key 13 | @parent_type_column = parent_type_column 14 | @depth_column = depth_column 15 | @dependent = dependent 16 | end 17 | 18 | # 19 | # Returns the primary key for the model class. 20 | # @return [Symbol] 21 | def primary_key 22 | @primary_key ||= @model_class.primary_key.to_sym 23 | end 24 | 25 | # 26 | # Checks if SQL cycle detection can be used. This is currently supported only on PostgreSQL 14+. 27 | # @return [TrueClass|FalseClass] 28 | def cycle_detection? 29 | return @cycle_detection if defined?(@cycle_detection) 30 | 31 | @cycle_detection = @model_class.connection.adapter_name == 'PostgreSQL' && 32 | @model_class.connection.database_version >= 140_000 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/preloaders/descendants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Preloaders 5 | # 6 | # Preloads all descendants records for a given node and sets the parent and child associations on each record 7 | # based on the preloaded data. After this, calling #parent or #children will not trigger a database query. 8 | # 9 | class Descendants 10 | def initialize(node, includes: nil) 11 | @node = node 12 | @parent_key = node._recursive_tree_config.parent_key 13 | @includes = includes 14 | end 15 | 16 | def preload! 17 | apply_records(@node) 18 | end 19 | 20 | private 21 | 22 | def records 23 | @records ||= begin 24 | descendants = @node.descendants 25 | descendants = descendants.includes(*@includes) if @includes 26 | descendants.to_a 27 | end 28 | end 29 | 30 | def apply_records(parent_node) 31 | children = records.select { |child| child.send(@parent_key) == parent_node.id } 32 | 33 | parent_node.association(:children).target = children 34 | 35 | children.each do |child| 36 | child.association(:parent).target = parent_node 37 | apply_records(child) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/acts_as_recursive_tree/preloaders/descendants_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActsAsRecursiveTree::Preloaders::Descendants do 6 | include TreeMethods 7 | 8 | let(:preloader) { described_class.new(root.reload, includes: included_associations) } 9 | let(:included_associations) { nil } 10 | let(:root) { create_tree(2, create_node_info: true) } 11 | let(:children) { root.children } 12 | 13 | describe '#preload! will set the associations target attribute' do 14 | before do 15 | preloader.preload! 16 | end 17 | 18 | it 'sets the children association' do 19 | children.each do |child| 20 | expect(child.association(:children).target).not_to be_nil 21 | end 22 | end 23 | 24 | it 'sets the parent association' do 25 | children.each do |child| 26 | expect(child.association(:parent).target).not_to be_nil 27 | end 28 | end 29 | end 30 | 31 | describe '#preload! will include associations' do 32 | let(:included_associations) { :node_info } 33 | 34 | before do 35 | preloader.preload! 36 | end 37 | 38 | it 'sets the children association' do 39 | children.each do |child| 40 | expect(child.association(included_associations).target).not_to be_nil 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/model/location_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Location do 6 | before do 7 | @building = Building.create!(name: 'big house') 8 | 9 | 1.upto(5) do |index| 10 | floor = Floor.create!(name: "#{index}. Floor") 11 | 12 | @building.children << floor 13 | 14 | 1.upto(5) do |index_room| 15 | floor.children << Room.create!(name: "#{index_room}. Room") 16 | end 17 | end 18 | end 19 | 20 | describe 'building' do 21 | it 'has 30 descendants' do 22 | expect(@building.descendants.count).to eq(5 + (5 * 5)) 23 | end 24 | 25 | context '::descendants_of' do 26 | context 'with Room' do 27 | let(:rooms) { Room.descendants_of(@building) } 28 | 29 | it 'has 25 rooms' do 30 | expect(rooms.count).to eq(25) 31 | end 32 | 33 | it 'alls be of type Room' do 34 | expect(rooms.all).to all(be_an(Room)) 35 | end 36 | end 37 | 38 | context 'with Floor' do 39 | let(:floors) { Floor.descendants_of(@building) } 40 | 41 | it 'has 5 Floors' do 42 | expect(floors.count).to eq(5) 43 | end 44 | 45 | it 'alls be of type Floor' do 46 | expect(floors.all).to all(be_an(Floor)) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: CI 9 | 10 | on: 11 | push: 12 | branches: [ main ] 13 | pull_request: 14 | branches: [ '**' ] 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | ruby-version: ['3.1', '3.2', '3.3'] 23 | gemfile: [ar_70, ar_71, ar_72, ar_80, ar_next] 24 | exclude: 25 | - ruby-version: '3.1' 26 | gemfile: ar_80 27 | - ruby-version: '3.1' 28 | gemfile: ar_next 29 | env: # $BUNDLE_GEMFILE must be set at the job level, so it is set for all steps 30 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 31 | steps: 32 | - uses: actions/checkout@v3 33 | - name: Set up Ruby 34 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 35 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 36 | uses: ruby/setup-ruby@v1 37 | # uses: ruby/setup-ruby@473e4d8fe5dd94ee328fdfca9f8c9c7afc9dae5e 38 | with: 39 | ruby-version: ${{ matrix.ruby-version }} 40 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 41 | - name: Run tests 42 | run: bundle exec rake 43 | -------------------------------------------------------------------------------- /acts_as_recursive_tree.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'acts_as_recursive_tree/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'acts_as_recursive_tree' 9 | spec.version = ActsAsRecursiveTree::VERSION 10 | spec.authors = ['Wolfgang Wedelich-John', 'Willem Mulder'] 11 | spec.email = %w[wolfgang.wedelich@ionos.com 14mRh4X0r@gmail.com] 12 | spec.summary = 'Drop in replacement for acts_as_tree but using recursive queries' 13 | spec.description = ' 14 | This is a ruby gem that provides drop in replacement for acts_as_tree but makes use of SQL recursive statement. Be sure to have a DBMS that supports recursive queries when using this gem (e.g. PostgreSQL or SQLite). ' 15 | spec.homepage = 'https://github.com/1and1/acts_as_recursive_tree' 16 | spec.license = 'MIT' 17 | spec.metadata = { 18 | 'bug_tracker_uri' => 'https://github.com/1and1/acts_as_recursive_tree/issues', 19 | 'changelog_uri' => 'https://github.com/1and1/acts_as_recursive_tree/blob/main/CHANGELOG.md' 20 | } 21 | spec.required_ruby_version = '>= 3.1.0' 22 | spec.files = `git ls-files -z`.split("\x0") 23 | spec.require_paths = ['lib'] 24 | 25 | spec.add_dependency 'activerecord', '>= 7.0.0', '< 9' 26 | spec.add_dependency 'activesupport', '>= 7.0.0', '< 9' 27 | spec.add_dependency 'zeitwerk', '>= 2.4' 28 | 29 | spec.add_development_dependency 'appraisal', '~> 2.5' 30 | spec.add_development_dependency 'database_cleaner-active_record', '~> 2.2' 31 | spec.add_development_dependency 'rake' 32 | spec.add_development_dependency 'rspec-rails', '>= 7.1' 33 | spec.add_development_dependency 'rubocop', '~> 1.68.0' 34 | spec.add_development_dependency 'rubocop-rails', '~> 2.27.0' 35 | spec.add_development_dependency 'rubocop-rspec', '~> 3.2.0' 36 | 37 | spec.add_development_dependency 'sqlite3', '~> 2.0' 38 | end 39 | -------------------------------------------------------------------------------- /spec/model/relation_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'Relation' do 6 | include TreeMethods 7 | 8 | let(:root) { create_tree(4, stop_at: 2) } 9 | 10 | describe '#descendants' do 11 | context 'with simple relation' do 12 | let(:descendants) { root.descendants { |opts| opts.condition = Node.where(active: true) }.to_a } 13 | 14 | it 'returns only active nodes' do 15 | descendants.each do |node| 16 | expect(node.active).to be_truthy 17 | end 18 | end 19 | end 20 | 21 | context 'with condition on joined association' do 22 | let(:descendants) do 23 | root.descendants do |opts| 24 | opts.condition = Node.joins(:node_info).where.not(node_infos: { status: 'bar' }) 25 | end 26 | end 27 | 28 | it 'returns only node with condition fulfilled' do 29 | descendants.each do |node| 30 | expect(node.node_info.status).to eql('foo') 31 | end 32 | end 33 | end 34 | end 35 | 36 | describe '#ancestors' do 37 | context 'with simple_relation' do 38 | let(:ancestors) { root.leaves.first.ancestors { |opts| opts.condition = Node.where(active: false) }.to_a } 39 | 40 | it 'return only active nodes' do 41 | ancestors.each do |node| 42 | expect(node.active).to be_falsey 43 | end 44 | end 45 | 46 | it 'does not return the root node' do 47 | expect(ancestors).not_to include(root) 48 | end 49 | end 50 | 51 | context 'with condition on joined association' do 52 | let(:ancestors) do 53 | root.leaves.first.ancestors do |opts| 54 | opts.condition = Node.joins(:node_info).where.not(node_infos: { status: 'foo' }) 55 | end 56 | end 57 | 58 | it 'return only nodes for the matching condition' do 59 | ancestors.each do |node| 60 | expect(node.node_info.status).to eql('bar') 61 | end 62 | end 63 | 64 | it 'does not return the root node' do 65 | expect(ancestors).not_to include(root) 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Version 4.1.0 2 | - ADD: Support for Rails 8.0 3 | 4 | ### Version 4.0.0 5 | - ADD: Support for Rails 7.2 6 | - BREAKING: Dropped support for Rails < 7 7 | - BREAKING: Dropped support old Rubies < 3.1 8 | 9 | ### Version 3.5.0 10 | - Added :dependent option for setting explicit deletion behaviour (issue #31) 11 | - Added automatic cycle detection when supported (currently only PostgresSQL 14+) (issue #22) 12 | 13 | ### Version 3.4.0 14 | - Rails 7.1 compatibility 15 | - Added ar_next to test matrix 16 | - Added updated databasecleaner to test latest active record from git 17 | 18 | ### Version 3.3.0 19 | - added __includes:__ option to __preload_tree__ 20 | 21 | ### Version 3.2.0 22 | - Added #preload_tree method to preload the parent/child relations of a single node 23 | 24 | ### Version 3.1.0 25 | - Rails 7 support 26 | 27 | ### Version 3.0.0 28 | - BREAKING: Dropped support for Rails < 5.2 29 | - BREAKING: Increased minimum Ruby version to 2.5 30 | - ADD: initial support for Rails < 7 31 | - CHANGE: Using zeitwerk for auto loading 32 | 33 | ### Version 2.2.1 34 | - Rails 6.1 support 35 | 36 | ### Version 2.2.0 37 | - Rails 6.0 support 38 | 39 | ### Version 2.1.1 40 | - Enabled subselect query when using depth 41 | - new QueryOption query_strategy for forcing a specific strategy (:join, :subselect) 42 | 43 | ### Version 2.1.0 44 | - BUGFIX association self_and_siblings not working 45 | - BUGFIX primary_key of model is retrieved on first usage and not on setup 46 | - NEW when no ordering/depth is required, then use subselect instead of joining the temp table 47 | 48 | ### Version 2.0.2 49 | - fix for condition relation was executed before merging 50 | 51 | ### Version 2.0.1 52 | - fix for parent_type_column applied not properly 53 | 54 | ### Version 2.0.0 55 | - drop support for rails < 5.0 56 | - support for polymorphic parent relations 57 | 58 | ### Version 1.1.1 59 | - BUGFIX: not checking presence of relation with _present?_ method - this causes execution of the relation 60 | - added missing != method for depth 61 | 62 | ### Version 1.1.0 63 | - scopes and method can now be passed a Proc instance for additional modifications of the query 64 | - new option to specify the depth to query 65 | 66 | ### Version 1.0.1 67 | - BUGFIX: ordering result when querying ancestors 68 | 69 | ### Version 1.0.0 70 | - initial release using AREL 71 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/options/values.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Options 5 | module Values 6 | class Base 7 | attr_reader :value, :config 8 | 9 | def initialize(value, config) 10 | @value = value 11 | @config = config 12 | end 13 | 14 | def prepared_value 15 | value 16 | end 17 | 18 | def apply_to(attribute); end 19 | 20 | def apply_negated_to(attribute); end 21 | end 22 | 23 | class SingleValue < Base 24 | def apply_to(attribute) 25 | attribute.eq(prepared_value) 26 | end 27 | 28 | def apply_negated_to(attribute) 29 | attribute.not_eq(prepared_value) 30 | end 31 | end 32 | 33 | class ActiveRecord < SingleValue 34 | def prepared_value 35 | value.id 36 | end 37 | end 38 | 39 | class RangeValue < Base 40 | def apply_to(attribute) 41 | attribute.between(prepared_value) 42 | end 43 | 44 | def apply_negated_to(attribute) 45 | attribute.not_between(prepared_value) 46 | end 47 | end 48 | 49 | class MultiValue < Base 50 | def apply_to(attribute) 51 | attribute.in(prepared_value) 52 | end 53 | 54 | def apply_negated_to(attribute) 55 | attribute.not_in(prepared_value) 56 | end 57 | end 58 | 59 | class Relation < MultiValue 60 | def prepared_value 61 | select_manager = value.arel 62 | select_manager.projections.clear 63 | select_manager.project(select_manager.froms.last[config.primary_key]) 64 | end 65 | end 66 | 67 | def self.create(value, config = nil) 68 | klass = case value 69 | when ::Numeric, ::String 70 | SingleValue 71 | when ::ActiveRecord::Relation 72 | Relation 73 | when Range 74 | RangeValue 75 | when Enumerable 76 | MultiValue 77 | when ::ActiveRecord::Base 78 | ActiveRecord 79 | else 80 | raise "#{value.class} is not supported" 81 | end 82 | 83 | klass.new(value, config) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2021-08-02 08:58:28 UTC using RuboCop version 1.18.4. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 6 10 | # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. 11 | # IgnoredMethods: refine 12 | Metrics/BlockLength: 13 | Max: 87 14 | 15 | # Offense count: 1 16 | # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. 17 | Metrics/MethodLength: 18 | Max: 15 19 | 20 | # Offense count: 18 21 | # Configuration parameters: Prefixes. 22 | # Prefixes: when, with, without 23 | RSpec/ContextWording: 24 | Exclude: 25 | - 'spec/builders_spec.rb' 26 | - 'spec/model/location_spec.rb' 27 | - 'spec/model/node_spec.rb' 28 | - 'spec/model/relation_spec.rb' 29 | - 'spec/values_spec.rb' 30 | 31 | # Offense count: 37 32 | # Configuration parameters: AssignmentOnly. 33 | RSpec/InstanceVariable: 34 | Exclude: 35 | - 'spec/model/location_spec.rb' 36 | - 'spec/model/node_spec.rb' 37 | - 'spec/model/relation_spec.rb' 38 | 39 | # Offense count: 1 40 | RSpec/MultipleDescribes: 41 | Exclude: 42 | - 'spec/builders_spec.rb' 43 | 44 | # Offense count: 2 45 | RSpec/MultipleExpectations: 46 | Max: 2 47 | 48 | # Offense count: 17 49 | # Configuration parameters: AllowedConstants. 50 | Style/Documentation: 51 | Exclude: 52 | - 'lib/acts_as_recursive_tree.rb' 53 | - 'lib/acts_as_recursive_tree/acts_macro.rb' 54 | - 'lib/acts_as_recursive_tree/builders/ancestors.rb' 55 | - 'lib/acts_as_recursive_tree/builders/descendants.rb' 56 | - 'lib/acts_as_recursive_tree/builders/leaves.rb' 57 | - 'lib/acts_as_recursive_tree/model.rb' 58 | - 'lib/acts_as_recursive_tree/options/depth_condition.rb' 59 | - 'lib/acts_as_recursive_tree/options/query_options.rb' 60 | - 'lib/acts_as_recursive_tree/options/values.rb' 61 | - 'lib/acts_as_recursive_tree/railtie.rb' 62 | 63 | # Offense count: 9 64 | # Cop supports --auto-correct. 65 | # Configuration parameters: AutoCorrect, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. 66 | # URISchemes: http, https 67 | Layout/LineLength: 68 | Max: 291 69 | -------------------------------------------------------------------------------- /spec/model/node_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Node do 6 | include TreeMethods 7 | 8 | before do 9 | @root = create_tree(3) 10 | @child = @root.children.first 11 | end 12 | 13 | describe '#children' do 14 | it 'has 3 children' do 15 | expect(@root.children.count).to be(3) 16 | end 17 | 18 | it 'does not include root node' do 19 | expect(@root.children).not_to include(@root) 20 | end 21 | end 22 | 23 | describe '#descendants' do 24 | it 'has 15 descendants' do 25 | expect(@root.descendants.count).to eql(3 + (3 * 2) + (3 * 2 * 1)) 26 | end 27 | 28 | it 'does not include root' do 29 | expect(@root.descendants).not_to include(@root) 30 | end 31 | end 32 | 33 | describe '#self_and_descendants' do 34 | it 'has 15 descendants and self' do 35 | expect(@root.self_and_descendants.count).to eql(@root.descendants.count + 1) 36 | end 37 | 38 | it 'includes self' do 39 | expect(@root.self_and_descendants.all).to include(@root) 40 | end 41 | end 42 | 43 | describe '#root?' do 44 | it 'is true for root node' do 45 | expect(@root).to be_root 46 | end 47 | 48 | it 'is false for children' do 49 | expect(@child).not_to be_root 50 | end 51 | end 52 | 53 | describe '#leaf?' do 54 | it 'is false for root node' do 55 | expect(@root).not_to be_leaf 56 | end 57 | 58 | it 'is true for children' do 59 | expect(@root.leaves.first).to be_leaf 60 | end 61 | end 62 | 63 | describe '#leaves' do 64 | it 'has 6 leaves' do 65 | expect(@root.leaves.count).to be(6) 66 | end 67 | end 68 | 69 | describe 'child' do 70 | it 'has root as parent' do 71 | expect(@child.parent).to eql(@root) 72 | end 73 | 74 | it 'has 1 ancestor' do 75 | expect(@child.ancestors.count).to be(1) 76 | end 77 | 78 | it 'has root as only ancestor' do 79 | expect(@child.ancestors.first).to eql(@root) 80 | end 81 | 82 | it '#root should return root' do 83 | expect(@child.root).to eql(@root) 84 | end 85 | 86 | it '#root? should be false' do 87 | expect(@child.root?).to be false 88 | end 89 | 90 | it '#leaf? should be false' do 91 | expect(@child.leaf?).to be false 92 | end 93 | end 94 | 95 | describe 'scopes' do 96 | context 'roots' do 97 | it 'has only one root node' do 98 | expect(described_class.roots.count).to be(1) 99 | end 100 | 101 | it 'is the @root node' do 102 | expect(described_class.roots.first).to eql(@root) 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/acts_as_recursive_tree/options/values_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe ActsAsRecursiveTree::Options::Values do 6 | shared_examples 'single values' do 7 | subject(:value) { described_class.create(single_value) } 8 | 9 | it { is_expected.to be_a described_class::SingleValue } 10 | 11 | it 'apply_toes' do 12 | expect(value.apply_to(attribute).to_sql).to end_with " = #{single_value}" 13 | end 14 | 15 | it 'apply_negated_toes' do 16 | expect(value.apply_negated_to(attribute).to_sql).to end_with " != #{single_value}" 17 | end 18 | end 19 | 20 | let(:table) { Arel::Table.new('test_table') } 21 | let(:attribute) { table['test_attr'] } 22 | 23 | context 'with invalid agurment' do 24 | it 'raises exception' do 25 | expect { described_class.create(nil) }.to raise_exception(/is not supported/) 26 | end 27 | end 28 | 29 | context 'with single value' do 30 | let(:single_value) { 3 } 31 | 32 | it_behaves_like 'single values' do 33 | let(:value_obj) { single_value } 34 | end 35 | 36 | it_behaves_like 'single values' do 37 | let(:value_obj) { Node.new(id: single_value) } 38 | end 39 | end 40 | 41 | context 'with multi value' do 42 | context 'with Array' do 43 | subject(:value) { described_class.create(array) } 44 | 45 | let(:array) { [1, 2, 3] } 46 | 47 | it { is_expected.to be_a described_class::MultiValue } 48 | 49 | it 'apply_toes' do 50 | expect(value.apply_to(attribute).to_sql).to end_with " IN (#{array.join(', ')})" 51 | end 52 | 53 | it 'apply_negated_toes' do 54 | expect(value.apply_negated_to(attribute).to_sql).to end_with " NOT IN (#{array.join(', ')})" 55 | end 56 | end 57 | 58 | context 'with Range' do 59 | subject(:value) { described_class.create(range) } 60 | 61 | let(:range) { 1..3 } 62 | 63 | it { is_expected.to be_a described_class::RangeValue } 64 | 65 | it 'apply_toes' do 66 | expect(value.apply_to(attribute).to_sql).to end_with "BETWEEN #{range.begin} AND #{range.end}" 67 | end 68 | 69 | it 'apply_negated_toes' do 70 | expect(value.apply_negated_to(attribute).to_sql).to match(/< #{range.begin} OR.* > #{range.end}/) 71 | end 72 | end 73 | 74 | context 'with Relation' do 75 | subject(:value) { described_class.create(relation, double) } 76 | 77 | let(:relation) { Node.where(name: 'test') } 78 | let(:double) do 79 | Class.new do 80 | def self.primary_key 81 | :id 82 | end 83 | end 84 | end 85 | 86 | it { is_expected.to be_a described_class::Relation } 87 | 88 | it 'apply_toes' do 89 | expect(value.apply_to(attribute).to_sql).to match(/IN \(SELECT.*\)/) 90 | end 91 | 92 | it 'apply_negated_toes' do 93 | expect(value.apply_negated_to(attribute).to_sql).to match(/NOT IN \(SELECT.*\)/) 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/support/shared_examples/builders.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context 'with enforced ordering setup' do 4 | let(:ordering) { false } 5 | include_context 'with base_setup' do 6 | let(:proc) { ->(config) { config.ensure_ordering! } } 7 | end 8 | end 9 | 10 | RSpec.shared_context 'with base_setup' do 11 | subject(:query) { builder.build.to_sql } 12 | 13 | let(:model_id) { 1 } 14 | let(:model_class) { Node } 15 | let(:exclude_ids) { false } 16 | let(:proc) { nil } 17 | let(:builder) do 18 | described_class.new(model_class, model_id, exclude_ids:, &proc) 19 | end 20 | end 21 | 22 | RSpec.shared_examples 'basic recursive examples' do 23 | it { is_expected.to start_with "SELECT \"#{model_class.table_name}\".* FROM \"#{model_class.table_name}\"" } 24 | 25 | it { is_expected.to match(/WHERE "#{model_class.table_name}"."#{model_class.primary_key}" = #{model_id}/) } 26 | 27 | it { is_expected.to match(/WITH RECURSIVE "#{builder.travers_loc_table.name}" AS/) } 28 | 29 | it { is_expected.to match(/SELECT "#{model_class.table_name}"."#{model_class.primary_key}", "#{model_class.table_name}"."#{model_class._recursive_tree_config.parent_key}", 0 AS recursive_depth FROM "#{model_class.table_name}"/) } 30 | 31 | it { 32 | expect(subject).to match(/SELECT "#{model_class.table_name}"."#{model_class.primary_key}", "#{model_class.table_name}"."#{model_class._recursive_tree_config.parent_key}", \("#{builder.travers_loc_table.name}"."recursive_depth" \+ 1\) AS recursive_depth FROM "#{model_class.table_name}"/) 33 | } 34 | end 35 | 36 | RSpec.shared_examples 'build recursive query' do 37 | context 'with simple id' do 38 | context 'with simple class' do 39 | include_context 'with base_setup' do 40 | let(:model_class) { Node } 41 | it_behaves_like 'basic recursive examples' 42 | end 43 | end 44 | 45 | context 'with class with different parent key' do 46 | include_context 'with base_setup' do 47 | let(:model_class) { NodeWithOtherParentKey } 48 | it_behaves_like 'basic recursive examples' 49 | end 50 | end 51 | 52 | context 'with Subclass' do 53 | include_context 'with base_setup' do 54 | let(:model_class) { Floor } 55 | it_behaves_like 'basic recursive examples' 56 | end 57 | end 58 | 59 | context 'with polymorphic parent relation' do 60 | include_context 'with base_setup' do 61 | let(:model_class) { NodeWithPolymorphicParent } 62 | it_behaves_like 'basic recursive examples' 63 | end 64 | end 65 | end 66 | end 67 | 68 | RSpec.shared_examples 'ancestor query' do 69 | include_context 'with base_setup' 70 | 71 | it { is_expected.to match(/"#{builder.travers_loc_table.name}"."#{model_class._recursive_tree_config.parent_key}" = "#{model_class.table_name}"."#{model_class.primary_key}"/) } 72 | end 73 | 74 | RSpec.shared_examples 'descendant query' do 75 | include_context 'with base_setup' 76 | 77 | it { is_expected.to match(/"#{model_class.table_name}"."#{model_class._recursive_tree_config.parent_key}" = "#{builder.travers_loc_table.name}"."#{model_class.primary_key}"/) } 78 | it { is_expected.to match(/#{Regexp.escape(builder.travers_loc_table.project(builder.travers_loc_table[model_class.primary_key]).to_sql)}/) } 79 | end 80 | 81 | RSpec.shared_context 'with ordering' do 82 | include_context 'with base_setup' do 83 | it_behaves_like 'is adding ordering' 84 | end 85 | end 86 | 87 | RSpec.shared_context 'without ordering' do 88 | include_context 'with base_setup' do 89 | it_behaves_like 'not adding ordering' 90 | end 91 | end 92 | 93 | RSpec.shared_examples 'is adding ordering' do 94 | it { is_expected.to match(/ORDER BY #{Regexp.escape(builder.recursive_temp_table[model_class._recursive_tree_config.depth_column].asc.to_sql)}/) } 95 | end 96 | 97 | RSpec.shared_examples 'not adding ordering' do 98 | it { is_expected.not_to match(/ORDER BY/) } 99 | end 100 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsRecursiveTree 4 | module Model 5 | extend ActiveSupport::Concern 6 | 7 | ## 8 | # Returns list of ancestors, starting from parent until root. 9 | # 10 | # subchild1.ancestors # => [child1, root] 11 | # 12 | def ancestors(&) 13 | base_class.ancestors_of(self, &) 14 | end 15 | 16 | # Returns ancestors and current node itself. 17 | # 18 | # subchild1.self_and_ancestors # => [subchild1, child1, root] 19 | # 20 | def self_and_ancestors(&) 21 | base_class.self_and_ancestors_of(self, &) 22 | end 23 | 24 | ## 25 | # Returns list of descendants, starting from current node, not including current node. 26 | # 27 | # root.descendants # => [child1, child2, subchild1, subchild2, subchild3, subchild4] 28 | # 29 | def descendants(&) 30 | base_class.descendants_of(self, &) 31 | end 32 | 33 | ## 34 | # Returns list of descendants, starting from current node, including current node. 35 | # 36 | # root.self_and_descendants # => [root, child1, child2, subchild1, subchild2, subchild3, subchild4] 37 | # 38 | def self_and_descendants(&) 39 | base_class.self_and_descendants_of(self, &) 40 | end 41 | 42 | ## 43 | # Returns the root node of the tree. 44 | def root 45 | self_and_ancestors.where(_recursive_tree_config.parent_key => nil).first 46 | end 47 | 48 | ## 49 | # Returns all siblings of the current node. 50 | # 51 | # subchild1.siblings # => [subchild2] 52 | def siblings 53 | self_and_siblings.where.not(id:) 54 | end 55 | 56 | ## 57 | # Returns children (without subchildren) and current node itself. 58 | # 59 | # root.self_and_children # => [root, child1] 60 | def self_and_children 61 | table = self.class.arel_table 62 | id = attributes[_recursive_tree_config.primary_key.to_s] 63 | 64 | base_class.where( 65 | table[_recursive_tree_config.primary_key].eq(id).or( 66 | table[_recursive_tree_config.parent_key].eq(id) 67 | ) 68 | ) 69 | end 70 | 71 | ## 72 | # Returns all Leaves 73 | # 74 | def leaves 75 | base_class.leaves_of(self) 76 | end 77 | 78 | # Returns true if node has no parent, false otherwise 79 | # 80 | # subchild1.root? # => false 81 | # root.root? # => true 82 | def root? 83 | attributes[_recursive_tree_config.parent_key.to_s].blank? 84 | end 85 | 86 | # Returns true if node has no children, false otherwise 87 | # 88 | # subchild1.leaf? # => true 89 | # child1.leaf? # => false 90 | def leaf? 91 | children.none? 92 | end 93 | 94 | # 95 | # Fetches all descendants of this node and assigns the parent/children associations 96 | # 97 | # @param includes [Array|Hash] pass the same arguments that should be passed to the #includes() method. 98 | # 99 | def preload_tree(includes: nil) 100 | ActsAsRecursiveTree::Preloaders::Descendants.new(self, includes:).preload! 101 | true 102 | end 103 | 104 | def base_class 105 | self.class.base_class 106 | end 107 | 108 | private :base_class 109 | 110 | module ClassMethods 111 | def self_and_ancestors_of(ids, &) 112 | ActsAsRecursiveTree::Builders::Ancestors.build(self, ids, &) 113 | end 114 | 115 | def ancestors_of(ids, &) 116 | ActsAsRecursiveTree::Builders::Ancestors.build(self, ids, exclude_ids: true, &) 117 | end 118 | 119 | def roots_of(ids) 120 | self_and_ancestors_of(ids).roots 121 | end 122 | 123 | def self_and_descendants_of(ids, &) 124 | ActsAsRecursiveTree::Builders::Descendants.build(self, ids, &) 125 | end 126 | 127 | def descendants_of(ids, &) 128 | ActsAsRecursiveTree::Builders::Descendants.build(self, ids, exclude_ids: true, &) 129 | end 130 | 131 | def leaves_of(ids, &) 132 | ActsAsRecursiveTree::Builders::Leaves.build(self, ids, &) 133 | end 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /lib/acts_as_recursive_tree/builders/relation_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'securerandom' 4 | 5 | module ActsAsRecursiveTree 6 | module Builders 7 | # 8 | # Constructs the Arel necessary for recursion. 9 | # 10 | class RelationBuilder 11 | def self.build(klass, ids, exclude_ids: false, &block) 12 | new(klass, ids, exclude_ids:, &block).build 13 | end 14 | 15 | class_attribute :traversal_strategy, instance_writer: false 16 | 17 | attr_reader :klass, :ids, :without_ids 18 | 19 | # Delegators for easier accessing config and query options 20 | delegate :primary_key, :depth_column, :parent_key, :parent_type_column, to: :config 21 | delegate :depth_present?, :depth, :condition, :ensure_ordering, to: :@query_opts 22 | 23 | def initialize(klass, ids, exclude_ids: false, &block) 24 | @klass = klass 25 | @ids = ActsAsRecursiveTree::Options::Values.create(ids, klass._recursive_tree_config) 26 | @without_ids = exclude_ids 27 | 28 | @query_opts = get_query_options(&block) 29 | 30 | # random seed for the temp tables 31 | @rand_int = SecureRandom.rand(1_000_000) 32 | end 33 | 34 | def recursive_temp_table 35 | @recursive_temp_table ||= Arel::Table.new("recursive_#{klass.table_name}_#{@rand_int}_temp") 36 | end 37 | 38 | def travers_loc_table 39 | @travers_loc_table ||= Arel::Table.new("traverse_#{@rand_int}_loc") 40 | end 41 | 42 | def config 43 | klass._recursive_tree_config 44 | end 45 | 46 | # 47 | # Constructs a new QueryOptions and yield it to the proc if one is present. 48 | # Subclasses may override this method to provide sane defaults. 49 | # 50 | # @return [ActsAsRecursiveTree::Options::QueryOptions] the new QueryOptions instance 51 | def get_query_options(&) 52 | ActsAsRecursiveTree::Options::QueryOptions.from(&) 53 | end 54 | 55 | def base_table 56 | klass.arel_table 57 | end 58 | 59 | def build 60 | relation = Strategies.for_query_options(@query_opts).build(self) 61 | 62 | apply_except_id(relation) 63 | end 64 | 65 | def apply_except_id(relation) 66 | return relation unless without_ids 67 | 68 | relation.where(ids.apply_negated_to(base_table[primary_key])) 69 | end 70 | 71 | def apply_depth(select_manager) 72 | return select_manager unless depth_present? 73 | 74 | select_manager.where(depth.apply_to(travers_loc_table[depth_column])) 75 | end 76 | 77 | def create_select_manger(column = nil) 78 | projections = column ? travers_loc_table[column] : Arel.star 79 | 80 | select_mgr = travers_loc_table.project(projections).with(:recursive, build_cte_table) 81 | 82 | apply_depth(select_mgr) 83 | end 84 | 85 | def build_cte_table 86 | Arel::Nodes::As.new( 87 | travers_loc_table, 88 | add_pg_cycle_detection( 89 | build_base_select.union(build_union_select) 90 | ) 91 | ) 92 | end 93 | 94 | def add_pg_cycle_detection(union_query) 95 | return union_query unless config.cycle_detection? 96 | 97 | Arel::Nodes::InfixOperation.new( 98 | '', 99 | union_query, 100 | Arel.sql("CYCLE #{primary_key} SET is_cycle USING path") 101 | ) 102 | end 103 | 104 | # Builds SQL: 105 | # SELECT id, parent_id, 0 AS depth FROM base_table WHERE id = 123 106 | def build_base_select 107 | id_node = base_table[primary_key] 108 | 109 | base_table.where( 110 | ids.apply_to(id_node) 111 | ).project( 112 | id_node, 113 | base_table[parent_key], 114 | Arel.sql('0').as(depth_column.to_s) 115 | ) 116 | end 117 | 118 | def build_union_select 119 | join_condition = apply_parent_type_column( 120 | traversal_strategy.build(self) 121 | ) 122 | 123 | select_manager = base_table.join(travers_loc_table).on(join_condition) 124 | 125 | # need to use ActiveRecord here for merging relation 126 | relation = build_base_join_select(select_manager) 127 | 128 | relation = apply_query_opts_condition(relation) 129 | relation.arel 130 | end 131 | 132 | def apply_parent_type_column(arel_condition) 133 | return arel_condition if parent_type_column.blank? 134 | 135 | arel_condition.and(base_table[parent_type_column].eq(klass.base_class)) 136 | end 137 | 138 | def build_base_join_select(select_manager) 139 | klass.select( 140 | base_table[primary_key], 141 | base_table[parent_key], 142 | Arel.sql( 143 | (travers_loc_table[depth_column] + 1).to_sql 144 | ).as(depth_column.to_s) 145 | ).unscope(where: :type).joins(select_manager.join_sources) 146 | end 147 | 148 | def apply_query_opts_condition(relation) 149 | # check with nil? and not #present?/#blank? which will execute the query 150 | return relation if condition.nil? 151 | 152 | relation.merge(condition) 153 | end 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift("#{File.dirname(__FILE__)}/../lib") 4 | 5 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 6 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 7 | require 'active_record' 8 | 9 | require 'acts_as_recursive_tree' 10 | require_relative 'db/database' 11 | 12 | require 'database_cleaner-active_record' 13 | 14 | # Requires supporting ruby files with custom matchers and macros, etc, 15 | # in spec/support/ and its subdirectories. 16 | Dir[File.join(__dir__, 'support/**/*.rb')].each { |f| require f } 17 | 18 | # This file was generated by the `rspec --init` command. Conventionally, all 19 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 20 | # The generated `.rspec` file contains `--require spec_helper` which will cause 21 | # this file to always be loaded, without a need to explicitly require it in any 22 | # files. 23 | # 24 | # Given that it is always loaded, you are encouraged to keep this file as 25 | # light-weight as possible. Requiring heavyweight dependencies from this file 26 | # will add to the boot time of your test suite on EVERY test run, even for an 27 | # individual file that may not need all of that loaded. Instead, consider making 28 | # a separate helper file that requires the additional dependencies and performs 29 | # the additional setup, and require it from the spec files that actually need 30 | # it. 31 | # 32 | # The `.rspec` file also contains a few flags that are not defaults but that 33 | # users commonly want. 34 | # 35 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 36 | RSpec.configure do |config| 37 | # rspec-expectations config goes here. You can use an alternate 38 | # assertion/expectation library such as wrong or the stdlib/minitest 39 | # assertions if you prefer. 40 | config.expect_with :rspec do |expectations| 41 | # This option will default to `true` in RSpec 4. It makes the `description` 42 | # and `failure_message` of custom matchers include text for helper methods 43 | # defined using `chain`, e.g.: 44 | # be_bigger_than(2).and_smaller_than(4).description 45 | # # => "be bigger than 2 and smaller than 4" 46 | # ...rather than: 47 | # # => "be bigger than 2" 48 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 49 | end 50 | 51 | # rspec-mocks config goes here. You can use an alternate test double 52 | # library (such as bogus or mocha) by changing the `mock_with` option here. 53 | config.mock_with :rspec do |mocks| 54 | # Prevents you from mocking or stubbing a method that does not exist on 55 | # a real object. This is generally recommended, and will default to 56 | # `true` in RSpec 4. 57 | mocks.verify_partial_doubles = true 58 | end 59 | 60 | # The settings below are suggested to provide a good initial experience 61 | # with RSpec, but feel free to customize to your heart's content. 62 | # # These two settings work together to allow you to limit a spec run 63 | # # to individual examples or groups you care about by tagging them with 64 | # # `:focus` metadata. When nothing is tagged with `:focus`, all examples 65 | # # get run. 66 | # config.filter_run :focus 67 | # config.run_all_when_everything_filtered = true 68 | # 69 | # # Allows RSpec to persist some state between runs in order to support 70 | # # the `--only-failures` and `--next-failure` CLI options. We recommend 71 | # # you configure your source control system to ignore this file. 72 | # config.example_status_persistence_file_path = "spec/examples.txt" 73 | # 74 | # # Limits the available syntax to the non-monkey patched syntax that is 75 | # # recommended. For more details, see: 76 | # # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 77 | # # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 78 | # # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching 79 | config.disable_monkey_patching! 80 | # 81 | # # This setting enables warnings. It's recommended, but in some cases may 82 | # # be too noisy due to issues in dependencies. 83 | # config.warnings = true 84 | # 85 | # # Many RSpec users commonly either run the entire suite or an individual 86 | # # file, and it's useful to allow more verbose output when running an 87 | # # individual spec file. 88 | # if config.files_to_run.one? 89 | # # Use the documentation formatter for detailed output, 90 | # # unless a formatter has already been configured 91 | # # (e.g. via a command-line flag). 92 | # config.default_formatter = 'doc' 93 | # end 94 | # 95 | # # Print the 10 slowest examples and example groups at the 96 | # # end of the spec run, to help surface which specs are running 97 | # # particularly slow. 98 | # config.profile_examples = 10 99 | # 100 | # # Run specs in random order to surface order dependencies. If you find an 101 | # # order dependency and want to debug it, you can fix the order by providing 102 | # # the seed, which is printed after each run. 103 | # # --seed 1234 104 | # config.order = :random 105 | # 106 | # # Seed global randomization in this process using the `--seed` CLI option. 107 | # # Setting this allows you to use `--seed` to deterministically reproduce 108 | # # test failures related to randomization by passing the same `--seed` value 109 | # # as the one that triggered the failure. 110 | # Kernel.srand config.seed 111 | 112 | config.before(:suite) do 113 | DatabaseCleaner.strategy = :transaction 114 | DatabaseCleaner.clean_with(:truncation) 115 | end 116 | 117 | config.around do |example| 118 | DatabaseCleaner.cleaning do 119 | example.run 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActsAsRecursiveTree 2 | 3 | [![CI Status](https://github.com/1and1/acts_as_recursive_tree/workflows/CI/badge.svg?branch=main)](https://github.com/1and1/acts_as_recursive_tree/actions?query=workflow%3ACI+branch%3Amaster) 4 | [![Gem Version](https://badge.fury.io/rb/acts_as_recursive_tree.svg)](https://badge.fury.io/rb/acts_as_recursive_tree) 5 | 6 | Use the power of recursive SQL statements in your Rails application. 7 | 8 | When you have tree based data in your application, you always to struggle with retrieving data. There are solutions, but the always come at a price: 9 | 10 | * Nested Set is fast at retrieval, but when inserting you might have to rearrange bounds, which can be very complex 11 | * Closure_Tree stores additional data in a separate table, which has be kept up to date 12 | 13 | Luckily, there is already a SQL standard that makes it very easy to retrieve data in the traditional parent/child relation. Currently this is only supported in sqlite and Postgres. With this it is possible to query complete trees without the need of extra tables or indices. 14 | 15 | ## Supported environments 16 | ActsAsRecursiveTree currently supports following ActiveRecord versions and is tested for compatibility: 17 | * ActiveRecord 7.0.x 18 | * ActiveRecord 7.1.x 19 | * ActiveRecord 7.2.x 20 | * ActiveRecord NEXT (from git) 21 | 22 | ## Supported Rubies 23 | ActsAsRecursiveTree is tested with following rubies: 24 | * MRuby 3.1 25 | * MRuby 3.2 26 | * MRuby 3.3 27 | 28 | Other Ruby implementations are not tested, but should also work. 29 | 30 | ## Installation 31 | 32 | Add this line to your application's Gemfile: 33 | 34 | ```ruby 35 | gem 'acts_as_recursive_tree' 36 | ``` 37 | 38 | And then execute: 39 | 40 | $ bundle 41 | 42 | Or install it yourself as: 43 | 44 | $ gem install acts_as_recursive_tree 45 | 46 | 47 | In your model class add following line: 48 | 49 | ```ruby 50 | class Node < ActiveRecord::Base 51 | recursive_tree 52 | end 53 | ``` 54 | That's it. This will assume that your model has a column named `parent_id` which will be used for traversal. If your column is something different, then you can specify it in the call to `recursive_tree`: 55 | 56 | ```ruby 57 | recursive_tree parent_key: :some_other_column 58 | ``` 59 | 60 | Some extra special stuff - if your parent relation is also polymorphic, then specify the polymorphic column: 61 | 62 | ```ruby 63 | recursive_tree parent_type_column: :some_other_type_column 64 | ``` 65 | 66 | Controlling deletion behaviour: 67 | 68 | By default, it is up to the user code to delete all child nodes in a tree when a parent node gets deleted. This can be controlled by the `:dependent` option, which will be set on the `children` association (see [#has_many](https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_many) in the Rails doc). 69 | 70 | ```ruby 71 | recursive_tree dependent: :nullify # or :destroy, etc. 72 | ``` 73 | 74 | ## Usage 75 | 76 | After you set up a model for usage, there are now several methods you can use. 77 | 78 | ### Associations 79 | 80 | You have access to following associations: 81 | 82 | * `parent` - the parent of this instance 83 | * `children` - all children (parent_id = self.id) 84 | * `self_and_siblings` - all node where parent_id = self.parent_id 85 | 86 | ### Class Methods 87 | 88 | * `roots` - all root elements (parent_id = nil) 89 | * `self_and_descendants_of(reference)` - the complete tree of `reference` __including__ `reference` in the result 90 | * `descendants_of(reference)` - the complete tree of `reference` __excluding__ `reference` in the result 91 | * `leaves_of(reference)` - special case of descendants where only those elements are returned, that do not have any children 92 | * `self_and_ancestors_of(reference)` - the complete ancestor list of `reference` __including__ `reference` in the result 93 | * `ancestors_of(reference)` - the complete ancestor list of `reference` __excluding__ `reference` in the result 94 | * `roots_of(reference)` - special case of ancestors where only those elements are returned, that do not have any parent 95 | 96 | You can pass in following argument types for `reference`, that will be accepted: 97 | * `integer` - simple integer value 98 | 99 | ```ruby 100 | Node.descendants_of(1234) 101 | ``` 102 | 103 | * `array` - array of integer value 104 | 105 | ```ruby 106 | Node.descendants_of([1234, 5678]) 107 | ``` 108 | 109 | * `ActiveRecord::Base` - instance of an AR::Model class 110 | 111 | ```ruby 112 | Node.descendants_of(some_node) 113 | ``` 114 | 115 | * `ActiveRecord::Relation` - an AR::Relation form the same type 116 | 117 | ```ruby 118 | Node.descendants_of(Node.where(foo: :bar)) 119 | ``` 120 | 121 | 122 | ### Instance Methods 123 | 124 | For nearly all mentioned scopes and associations there is a corresponding instance method: 125 | 126 | * `root` - returns the root element of this node 127 | * `self_and_descendants` - the complete tree __including__ `self` in the result 128 | * `descendants` - the complete tree __excluding__ `self` in the result 129 | * `leaves` - only leaves of this node 130 | * `self_and_ancestors` - the complete ancestor list __including__ `self` in the result 131 | * `ancestors` - the complete ancestor list __excluding__ `self` in the result 132 | 133 | Those methods simply delegate to the corresponding scope and pass `self` as reference. 134 | 135 | __Additional methods:__ 136 | * `siblings` - return all elements where parent_id = self.parent_id __excluding__ `self` 137 | * `self_and_children` - return all children and self as a Relation 138 | 139 | __Utility methods:__ 140 | * `root?` - returns true if this node is a root node 141 | * `leaf?` - returns true if this node is a leave node 142 | * `preload_tree` - fetches all descendants of this node and assigns the proper parent/children associations. You are then able to traverse the tree through the children/parent association without querying the database again. You can also pass arguments to `includes` which will be forwarded when fetching records. 143 | 144 | ```ruby 145 | node.preload_tree(includes: [:association, :another_association]) 146 | ``` 147 | 148 | ## Customizing the recursion 149 | 150 | All *ancestors* and *descendants* methods/scopes can take an additional block argument. The block receives ans `opts` argument with which you are able to customize the recursion. 151 | 152 | 153 | __Depth__ 154 | 155 | Specify a depth condition. Only the elements matching the depth are returned. 156 | Supported operations are: 157 | * `==` exact match - can be Integer or Range or Array. When specifying a Range this will result in a `depth BETWEEN min AND max` query. 158 | * `!=` except - can be Integer or Array 159 | * `>` greater than - only Integer 160 | * `>=` greater than or equals - only Integer 161 | * `<` less than - only Integer 162 | * `<=` less than or equals - only Integer 163 | 164 | ```ruby 165 | Node.descendants_of(1){|opts| opts.depth == 3..6 } 166 | node_instance.descendants{ |opts| opts.depth <= 4 } 167 | node_instance.descendants{ |opts| opts.depth != [4, 7] } 168 | ``` 169 | NOTE: `depth == 1` is the same as `children/parent` 170 | 171 | __Condition__ 172 | 173 | Pass in an additional relation. Only those elements are returned where the condition query matches. 174 | 175 | ```ruby 176 | Node.descendants_of(1){|opts| opts.condition = Node.where(active: true) } 177 | node_instance.descendants{ |opts| opts.condition = Node.where(active: true) } 178 | ``` 179 | NOTE: In contrast to depth, which first gathers the complete tree and then discards all non matching results, this will stop the recursive traversal when the relation is not met. Following two lines are completely different when executed: 180 | 181 | ```ruby 182 | node_instance.descendants.where(active: true) # => returns the complete tree and filters than out only the active ones 183 | node_instance.descendants{ |opts| opts.condition = Node.where(active: true) } # => stops the recursion when encountering a non active node, which may return less results than the one above 184 | ``` 185 | 186 | __Ordering__ 187 | All the *ancestor* methods will order the result depending on the depth of the recursion. Ordering for the *descendants* methods is disabled by default, but can be enabled if needed. 188 | 189 | ```ruby 190 | Node.descendants_of(1){|opts| opts.ensure_ordering! } 191 | node_instance.descendants{ |opts| opts.ensure_ordering! } 192 | ``` 193 | 194 | NOTE: if there are many descendants this may cause a severe increase in execution time! 195 | 196 | ## Single Table Inheritance (STI) 197 | 198 | STI works out of the box. Consider following classes: 199 | 200 | ```ruby 201 | class Node < ActiveRecord::Base 202 | recursive_tree 203 | end 204 | 205 | class SubNode < Node 206 | 207 | end 208 | ``` 209 | 210 | When calling ClassMethods the results depend on the class on which you call the method: 211 | 212 | ```ruby 213 | Node.descendants_of(123) # => returns Node and SubNode instances 214 | SubNode.descendants_of(123) # => returns SubNode instances only 215 | ``` 216 | 217 | Instance Methods make no difference of the class from which they are called: 218 | 219 | ```ruby 220 | sub_node_instance.descendants # => returns Node and SubNode instances 221 | ``` 222 | 223 | ## A note on endless recursion / cycle detection 224 | 225 | ### Inserting 226 | As of now it is up to the user code to guarantee there will be no cycles created in the parent/child entries. If not, your DB might run into an endless recursion. Inserting/updating records that will cause a cycle is not prevented by some validation checks, so you have to do this by your own. This might change in a future version. 227 | 228 | ### Querying 229 | If you want to make sure to not run into an endless recursion when querying, then there are following options: 230 | 1. Add a maximum depth to the query options. If an cycle is present in your data, the recursion will stop when reaching the max depth and stop further traversing. 231 | 2. When you are on recent version of PostgreSQL (14+) you are lucky. Postgres added the CYCLE detection feature to detect cycles and prevent endless recursion. Our query builder will add this feature if your DB does support this. 232 | 233 | ## Contributing 234 | 235 | 1. Fork it ( https://github.com/1and1/acts_as_recursive_tree/fork ) 236 | 2. Create your feature branch (`git checkout -b my-new-feature`) 237 | 3. Commit your changes (`git commit -am 'Add some feature'`) 238 | 4. Push to the branch (`git push origin my-new-feature`) 239 | 5. Create a new Pull Request 240 | --------------------------------------------------------------------------------