├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── bench ├── benchmark_helper.rb ├── database.yml ├── database_structure.sql └── forest_find.rb ├── edge.gemspec ├── gemfiles └── 5.0.gemfile ├── lib ├── edge.rb └── edge │ ├── forest.rb │ └── version.rb └── spec ├── database.yml ├── database.yml.travis ├── database_structure.sql ├── forest_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.5.3 4 | - 2.4.5 5 | - 2.3.8 6 | gemfile: 7 | - gemfiles/5.0.gemfile 8 | 9 | addons: 10 | postgresql: "9.2" 11 | 12 | before_script: 13 | - cp spec/database.yml.travis spec/database.yml 14 | - bundle exec rake db:setup 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.6.1 (February 20, 2021) 2 | 3 | * Add Ruby 3 compatibility (Martins Polakovs) 4 | 5 | # 0.6.0 (December 13, 2018) 6 | 7 | * Fix usage when table name includes schema 8 | * Drop Rails 4.x support 9 | 10 | # 0.5.1 (August 4, 2017) 11 | 12 | * Allow use of 'optional' option for belongs_to (Yuji Yaginuma) 13 | 14 | # 0.5.0 (May 14, 2016) 15 | 16 | * Add Rails 5.0 support 17 | * Drop Rails 4.0 support 18 | * Replace using internal arel with SQL string building 19 | 20 | # 0.4.4 (January 29, 2016) 21 | 22 | * Fix JRuby compatibility (jackc) 23 | 24 | # 0.4.3 (October 23, 2015) 25 | 26 | * Allow dependent option to acts_as_forest (WANG QUANG) 27 | 28 | # 0.4.2 (May 21, 2015) 29 | 30 | * Fixed premature SQL-ization that could result in PG protocol violation errors (Neil E. Pearson) 31 | * Require Rails 4.0+ 32 | * Document ancestors method (science) 33 | 34 | # 0.4.1 (January 15, 2015) 35 | 36 | * Include rake as development dependency 37 | * Fix for not passing string to belongs_to class_name (davekaro) 38 | 39 | # 0.4.0 (December 23, 2014) 40 | 41 | * Fix failure with bind_values 42 | * Fix README typos (y-yagi) 43 | * Improve performance by using flat_map (TheKidCoder) 44 | 45 | # 0.3.2 (March 1, 2014) 46 | 47 | * Set inverse_of on parent and children associations (Systho) 48 | 49 | # 0.3.1 (February 7, 2014) 50 | 51 | * Allow includes and with_descendents to work together (Systho) 52 | 53 | # 0.3.0 (December 17, 2013) 54 | 55 | * Rails 4 support 56 | * Fix for incomparable column types 57 | 58 | # 0.2.1 (March 6, 2013) 59 | 60 | * Fix: acts_as_forest survives multiple calls 61 | 62 | # 0.2.0 (February 22, 2013) 63 | 64 | * Added with_descendents 65 | 66 | # 0.1.0 (March 11, 2012) 67 | 68 | * Initial release 69 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in edge.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # A sample Guardfile 2 | # More info at https://github.com/guard/guard#readme 3 | 4 | guard 'rspec', :version => 2 do 5 | watch(%r{^spec/.+_spec\.rb$}) 6 | watch(%r{^lib/edge/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 7 | watch('spec/spec_helper.rb') { "spec" } 8 | end 9 | 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Jack Christensen 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Edge 2 | 3 | [![Build Status](https://travis-ci.org/jackc/edge.svg?branch=master)](https://travis-ci.org/jackc/edge) 4 | 5 | Edge provides graph functionality to ActiveRecord using recursive common table 6 | expressions. It has only been tested with PostgreSQL, but it uses Arel for 7 | SQL generation so it should work with any database and adapter that support 8 | recursive CTEs. 9 | 10 | acts_as_forest enables an entire tree or even an entire forest of trees to 11 | be loaded in a single query. All parent and children associations are 12 | preloaded. 13 | 14 | ## Installation 15 | 16 | Add this line to your application's Gemfile: 17 | 18 | gem 'edge' 19 | 20 | And then execute: 21 | 22 | $ bundle 23 | 24 | Or install it yourself as: 25 | 26 | $ gem install edge 27 | 28 | ## Usage 29 | 30 | acts_as_forest adds tree / multi-tree functionality. All it needs a parent_id 31 | column. This can be overridden by passing a :foreign_key option to 32 | acts_as_forest. 33 | 34 | class Location < ActiveRecord::Base 35 | acts_as_forest :order => "name" 36 | end 37 | 38 | usa = Location.create! :name => "USA" 39 | illinois = usa.children.create! :name => "Illinois" 40 | chicago = illinois.children.create! :name => "Chicago" 41 | indiana = usa.children.create! :name => "Indiana" 42 | canada = Location.create! :name => "Canada" 43 | british_columbia = canada.children.create! :name => "British Columbia" 44 | 45 | Location.root.all # [usa, canada] 46 | Location.find_forest # [usa, canada] with all children and parents preloaded 47 | Location.find_tree usa.id # load a single tree. 48 | 49 | It also provides the with_descendants scope to get all currently selected 50 | nodes and all their descendents. It can be chained after where scopes, but 51 | must not be used after any other type of scope. 52 | 53 | Location.where(name: "Illinois").with_descendants.all # [illinois, chicago] 54 | 55 | Also supported is `ancestors` instance method which returns an array of all ancestors ordered by nearest ancestors first. 56 | 57 | city = Location.where(name: 'Chicago') 58 | ancestors = city.ancestors # [illinois, usa] 59 | 60 | ## Benchmarks 61 | 62 | Edge includes a performance benchmarks. You can create test forests with a 63 | configurable number of trees, depth, number of children per node, and 64 | size of payload per node. 65 | 66 | jack@moya:~/work/edge$ ruby -I lib -I bench bench/forest_find.rb --help 67 | Usage: forest_find [options] 68 | -t, --trees NUM Number of trees to create 69 | -d, --depth NUM Depth of trees 70 | -c, --children NUM Number of children per node 71 | -p, --payload NUM Characters of payload per node 72 | 73 | Even on slower machines entire trees can be loaded quickly. 74 | 75 | jack@moya:~/work/edge$ ruby -I lib -I bench bench/forest_find.rb 76 | Trees: 50 77 | Depth: 3 78 | Children per node: 10 79 | Payload characters per node: 16 80 | Descendants per tree: 110 81 | Total records: 5550 82 | user system total real 83 | Load entire forest 10 times 4.260000 0.010000 4.270000 ( 4.422442) 84 | Load one tree 100 times 0.830000 0.040000 0.870000 ( 0.984642) 85 | 86 | ### Running the benchmarks 87 | 88 | 1. Create a database such as edge_bench. 89 | 2. Configure bench/database.yml to connect to it. 90 | 3. Load bench/database_structure.sql into your bench database. 91 | 4. Run benchmark scripts from root of gem directory (remember to pass ruby 92 | the include paths for lib and bench) 93 | 94 | ## Contributing 95 | 96 | 1. Fork it 97 | 2. Create your feature branch (`git checkout -b my-new-feature`) 98 | 3. Commit your changes (`git commit -am 'Added some feature'`) 99 | 4. Push to the branch (`git push origin my-new-feature`) 100 | 5. Create new Pull Request 101 | 102 | ## License 103 | 104 | MIT 105 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require "bundler/gem_tasks" 3 | require "rspec/core/rake_task" 4 | 5 | RSpec::Core::RakeTask.new 6 | task :default => :spec 7 | 8 | namespace :db do 9 | desc 'bootstrap database' 10 | task :setup do 11 | sh "createdb edge_test || true" 12 | sh "psql edge_test < spec/database_structure.sql" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /bench/benchmark_helper.rb: -------------------------------------------------------------------------------- 1 | require 'edge' 2 | require 'benchmark' 3 | require 'securerandom' 4 | require 'optparse' 5 | 6 | database_config = YAML.load_file(File.expand_path("../database.yml", __FILE__)) 7 | ActiveRecord::Base.establish_connection database_config["bench"] 8 | 9 | class ActsAsForestRecord < ActiveRecord::Base 10 | acts_as_forest 11 | end 12 | 13 | def clean_database 14 | ActsAsForestRecord.delete_all 15 | end 16 | 17 | def vacuum_analyze 18 | ActiveRecord::Base.connection.execute "VACUUM ANALYZE acts_as_forest_records" 19 | end 20 | -------------------------------------------------------------------------------- /bench/database.yml: -------------------------------------------------------------------------------- 1 | bench: 2 | adapter: postgresql 3 | encoding: unicode 4 | database: edge_bench 5 | -------------------------------------------------------------------------------- /bench/database_structure.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS acts_as_forest_records; 2 | 3 | CREATE TABLE acts_as_forest_records( 4 | id serial PRIMARY KEY, 5 | parent_id integer REFERENCES acts_as_forest_records, 6 | payload varchar NOT NULL 7 | ); 8 | 9 | CREATE INDEX ON acts_as_forest_records (parent_id); 10 | -------------------------------------------------------------------------------- /bench/forest_find.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark_helper' 2 | 3 | options = {} 4 | optparse = OptionParser.new do |opts| 5 | options[:num_trees] = 50 6 | opts.on '-t NUM', '--trees NUM', Integer, 'Number of trees to create' do |n| 7 | options[:num_trees] = n 8 | end 9 | 10 | options[:depth] = 3 11 | opts.on '-d NUM', '--depth NUM', Integer, 'Depth of trees' do |n| 12 | options[:depth] = n 13 | end 14 | 15 | options[:num_children] = 10 16 | opts.on '-c NUM', '--children NUM', Integer, 'Number of children per node' do |n| 17 | options[:num_children] = n 18 | end 19 | 20 | options[:payload_size] = 16 21 | opts.on '-p NUM', '--payload NUM', Integer, 'Characters of payload per node' do |n| 22 | options[:payload_size] = n 23 | end 24 | end 25 | 26 | optparse.parse! 27 | 28 | NUM_TREES = options[:num_trees] 29 | DEPTH = options[:depth] 30 | NUM_CHILDREN = options[:num_children] 31 | PAYLOAD_SIZE = options[:payload_size] 32 | 33 | 34 | def create_forest_tree(current_depth = 1, parent = nil) 35 | node = ActsAsForestRecord.create! :parent => parent, :payload => "z" * PAYLOAD_SIZE 36 | unless current_depth == DEPTH 37 | NUM_CHILDREN.times { create_forest_tree current_depth + 1, node } 38 | end 39 | node 40 | end 41 | 42 | clean_database 43 | ActsAsForestRecord.transaction do 44 | NUM_TREES.times { create_forest_tree } 45 | end 46 | vacuum_analyze 47 | 48 | puts "Trees: #{NUM_TREES}" 49 | puts "Depth: #{DEPTH}" 50 | puts "Children per node: #{NUM_CHILDREN}" 51 | puts "Payload characters per node: #{PAYLOAD_SIZE}" 52 | puts "Descendants per tree: #{ActsAsForestRecord.find_tree(ActsAsForestRecord.root.first.id).descendants.size}" 53 | puts "Total records: #{ActsAsForestRecord.count}" 54 | 55 | 56 | Benchmark.bm(40) do |x| 57 | load_entire_forest_times = 10 58 | x.report("Load entire forest #{load_entire_forest_times} times") do 59 | load_entire_forest_times.times do 60 | ActsAsForestRecord.find_forest 61 | end 62 | end 63 | 64 | load_one_tree_times = 100 65 | first_tree_id = ActsAsForestRecord.root.first.id 66 | x.report("Load one tree #{load_one_tree_times} times") do 67 | load_one_tree_times.times do 68 | ActsAsForestRecord.find_tree first_tree_id 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /edge.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.expand_path('../lib/edge/version', __FILE__) 3 | 4 | Gem::Specification.new do |gem| 5 | gem.authors = ["Jack Christensen"] 6 | gem.email = ["jack@jackchristensen.com"] 7 | gem.description = %q{Graph functionality for ActiveRecord} 8 | gem.summary = %q{Graph functionality for ActiveRecord. Provides tree/forest modeling structure that can load entire trees in a single query.} 9 | gem.homepage = "https://github.com/JackC/edge" 10 | 11 | gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 12 | gem.files = `git ls-files`.split("\n") 13 | gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 14 | gem.name = "edge" 15 | gem.require_paths = ["lib"] 16 | gem.version = Edge::VERSION 17 | 18 | gem.add_dependency 'activerecord', ">= 5.0.0" 19 | 20 | gem.add_development_dependency 'pg' 21 | gem.add_development_dependency 'pry' 22 | gem.add_development_dependency 'rake' 23 | gem.add_development_dependency 'rspec', "~> 3.6.0" 24 | end 25 | -------------------------------------------------------------------------------- /gemfiles/5.0.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "activerecord", "~> 5.0.0" 4 | 5 | gemspec :path=>"../" 6 | -------------------------------------------------------------------------------- /lib/edge.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | require "edge/forest" 4 | require "edge/version" 5 | 6 | module Edge 7 | # Your code goes here... 8 | end 9 | -------------------------------------------------------------------------------- /lib/edge/forest.rb: -------------------------------------------------------------------------------- 1 | module Edge 2 | module Forest 3 | # acts_as_forest models a tree/multi-tree structure. 4 | module ActsAsForest 5 | # options: 6 | # 7 | # * dependent - passed to children has_many (default: none) 8 | # * foreign_key - column name to use for parent foreign_key (default: parent_id) 9 | # * order - how to order children (default: none) 10 | # * optional - passed to belongs_to (default: none) 11 | def acts_as_forest(options={}) 12 | options.assert_valid_keys :foreign_key, :order, :dependent, :optional 13 | 14 | class_attribute :forest_foreign_key 15 | self.forest_foreign_key = options[:foreign_key] || "parent_id" 16 | 17 | class_attribute :forest_order 18 | self.forest_order = options[:order] || nil 19 | 20 | common_options = { 21 | :class_name => self.name, 22 | :foreign_key => forest_foreign_key 23 | } 24 | 25 | dependent_options = options[:dependent] ? { dependent: options[:dependent] } : {} 26 | 27 | optional_options = options[:optional] ? { optional: options[:optional] } : {} 28 | 29 | belongs_to :parent, **common_options.merge(inverse_of: :children).merge(optional_options) 30 | 31 | if forest_order 32 | has_many :children, -> { order(forest_order) }, **common_options.merge(inverse_of: :parent).merge(dependent_options) 33 | else 34 | has_many :children, **common_options.merge(inverse_of: :parent).merge(dependent_options) 35 | end 36 | 37 | scope :root, -> { where(forest_foreign_key => nil) } 38 | 39 | include Edge::Forest::InstanceMethods 40 | extend Edge::Forest::ClassMethods 41 | end 42 | end 43 | 44 | module ClassMethods 45 | # Finds entire forest and preloads all associations. It can be used at 46 | # the end of an ActiveRecord finder chain. 47 | # 48 | # Example: 49 | # # loads all locations 50 | # Location.find_forest 51 | # 52 | # # loads all nodes with matching names and all there descendants 53 | # Category.where(:name => %w{clothing books electronics}).find_forest 54 | def find_forest 55 | new_scope = unscoped.joins("INNER JOIN all_nodes USING(#{connection.quote_column_name primary_key})") 56 | new_scope = new_scope.order(forest_order) if forest_order 57 | 58 | sql = <<-SQL 59 | #{cte_sql} 60 | #{new_scope.to_sql} 61 | SQL 62 | records = find_by_sql sql 63 | 64 | records_by_id = records.each_with_object({}) { |r, h| h[r.id] = r } 65 | 66 | # Set all children associations to an empty array 67 | records.each do |r| 68 | children_association = r.association(:children) 69 | children_association.target = [] 70 | end 71 | 72 | top_level_records = [] 73 | 74 | records.each do |r| 75 | parent = records_by_id[r[forest_foreign_key]] 76 | if parent 77 | r.association(:parent).target = parent 78 | parent.association(:children).target.push(r) 79 | else 80 | top_level_records.push(r) 81 | end 82 | end 83 | 84 | top_level_records 85 | end 86 | 87 | # Finds an a tree or trees by id. 88 | # 89 | # If any requested ids are not found it raises 90 | # ActiveRecord::RecordNotFound. 91 | def find_tree(id_or_ids) 92 | trees = where(:id => id_or_ids).find_forest 93 | if id_or_ids.kind_of?(Array) 94 | raise ActiveRecord::RecordNotFound unless trees.size == id_or_ids.size 95 | trees 96 | else 97 | raise ActiveRecord::RecordNotFound if trees.empty? 98 | trees.first 99 | end 100 | end 101 | 102 | # Returns a new scope that includes previously scoped records and their descendants by subsuming the previous scope into a subquery 103 | # 104 | # Only where scopes can precede this in a scope chain 105 | def with_descendants 106 | subquery_scope = unscoped 107 | .joins("INNER JOIN all_nodes USING(#{connection.quote_column_name primary_key})") 108 | .select(primary_key) 109 | 110 | subquery_sql = <<-SQL 111 | #{cte_sql} 112 | #{subquery_scope.to_sql} 113 | SQL 114 | 115 | unscoped.where <<-SQL 116 | #{connection.quote_column_name primary_key} IN (#{subquery_sql}) 117 | SQL 118 | end 119 | 120 | private 121 | def cte_sql 122 | quoted_table_name = '"locations"' 123 | original_scope = (current_scope || all).select(primary_key, forest_foreign_key) 124 | iterated_scope = unscoped.select(primary_key, forest_foreign_key) 125 | .joins("INNER JOIN all_nodes ON #{connection.quote_table_name table_name}.#{connection.quote_column_name forest_foreign_key}=all_nodes.#{connection.quote_column_name primary_key}") 126 | <<-SQL 127 | WITH RECURSIVE all_nodes AS ( 128 | #{original_scope.to_sql} 129 | UNION 130 | #{iterated_scope.to_sql} 131 | ) 132 | SQL 133 | end 134 | end 135 | 136 | module InstanceMethods 137 | # Returns the root of this node. If this node is root returns self. 138 | def root 139 | parent ? parent.root : self 140 | end 141 | 142 | # Returns true is this node is a root or false otherwise 143 | def root? 144 | !self[forest_foreign_key] 145 | end 146 | 147 | # Returns all sibling nodes (nodes that have the same parent). If this 148 | # node is a root node it returns an empty array. 149 | def siblings 150 | parent ? parent.children - [self] : [] 151 | end 152 | 153 | # Returns all ancestors ordered by nearest ancestors first. 154 | def ancestors 155 | _ancestors = [] 156 | node = self 157 | while(node = node.parent) 158 | _ancestors.push(node) 159 | end 160 | 161 | _ancestors 162 | end 163 | 164 | # Returns all descendants 165 | def descendants 166 | if children.present? 167 | children + children.flat_map(&:descendants) 168 | else 169 | [] 170 | end 171 | end 172 | end 173 | end 174 | end 175 | 176 | ActiveRecord::Base.extend Edge::Forest::ActsAsForest 177 | -------------------------------------------------------------------------------- /lib/edge/version.rb: -------------------------------------------------------------------------------- 1 | module Edge 2 | VERSION = "0.6.1" 3 | end 4 | -------------------------------------------------------------------------------- /spec/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: postgresql 3 | encoding: unicode 4 | database: edge_test 5 | -------------------------------------------------------------------------------- /spec/database.yml.travis: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: postgresql 3 | encoding: unicode 4 | username: postgres 5 | database: edge_test 6 | -------------------------------------------------------------------------------- /spec/database_structure.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS locations; 2 | DROP TABLE IF EXISTS body_parts; 3 | 4 | CREATE TABLE locations( 5 | id serial PRIMARY KEY, 6 | parent_id integer REFERENCES locations, 7 | name varchar NOT NULL, 8 | attrs json DEFAULT NULL -- include a column that does not have an operator defined that can be used with union 9 | ); 10 | 11 | CREATE TABLE body_parts( 12 | id serial PRIMARY KEY, 13 | body_part_id integer REFERENCES body_parts -- something that uses a non-standard parent ID 14 | ); 15 | -------------------------------------------------------------------------------- /spec/forest_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | class Location < ActiveRecord::Base 4 | acts_as_forest :order => "name" 5 | end 6 | 7 | class BodyPart < ActiveRecord::Base 8 | acts_as_forest :foreign_key => "body_part_id" 9 | end 10 | 11 | Location.delete_all 12 | 13 | describe "Edge::Forest" do 14 | let(:skeleton) { BodyPart.create! } 15 | let!(:usa) { Location.create! :name => "USA" } 16 | let!(:illinois) { Location.create! :parent => usa, :name => "Illinois" } 17 | let!(:chicago) { Location.create! :parent => illinois, :name => "Chicago" } 18 | let!(:indiana) { Location.create! :parent => usa, :name => "Indiana" } 19 | let!(:canada) { Location.create! :name => "Canada" } 20 | let!(:british_columbia) { Location.create! :parent => canada, :name => "British Columbia" } 21 | 22 | describe "root?" do 23 | context "of root node" do 24 | it "should be true" do 25 | expect(usa.root?).to eq true 26 | end 27 | end 28 | 29 | context "of model with custom foreign key" do 30 | it "should be true" do 31 | expect(skeleton.root?).to eq true 32 | end 33 | end 34 | 35 | context "of child node" do 36 | it "should be false" do 37 | expect(illinois.root?).to eq false 38 | end 39 | end 40 | 41 | context "of leaf node" do 42 | it "should be root node" do 43 | expect(chicago.root?).to eq false 44 | end 45 | end 46 | end 47 | 48 | describe "root" do 49 | context "of root node" do 50 | it "should be self" do 51 | expect(usa.root).to eq usa 52 | end 53 | end 54 | 55 | context "of child node" do 56 | it "should be root node" do 57 | expect(illinois.root).to eq usa 58 | end 59 | end 60 | 61 | context "of leaf node" do 62 | it "should be root node" do 63 | expect(chicago.root).to eq usa 64 | end 65 | end 66 | end 67 | 68 | describe "parent" do 69 | context "of root node" do 70 | it "should be nil" do 71 | expect(usa.parent).to eq nil 72 | end 73 | end 74 | 75 | context "of child node" do 76 | it "should be parent" do 77 | expect(illinois.parent).to eq usa 78 | end 79 | end 80 | 81 | context "of leaf node" do 82 | it "should be parent" do 83 | expect(chicago.parent).to eq illinois 84 | end 85 | end 86 | end 87 | 88 | describe "ancestors" do 89 | context "of root node" do 90 | it "should be empty" do 91 | expect(usa.ancestors).to be_empty 92 | end 93 | end 94 | 95 | context "of leaf node" do 96 | it "should be ancestors ordered by ascending distance" do 97 | expect(chicago.ancestors).to eq [illinois, usa] 98 | end 99 | end 100 | end 101 | 102 | describe "siblings" do 103 | context "of root node" do 104 | it "should be empty" do 105 | expect(usa.siblings).to be_empty 106 | end 107 | end 108 | 109 | context "of child node" do 110 | it "should be other children of parent" do 111 | expect(illinois.siblings).to include(indiana) 112 | end 113 | end 114 | end 115 | 116 | describe "children" do 117 | it "should be children" do 118 | expect(usa.children).to include(illinois, indiana) 119 | end 120 | 121 | it "should be ordered" do 122 | alabama = Location.create! :parent => usa, :name => "Alabama" 123 | expect(usa.children).to eq [alabama, illinois, indiana] 124 | end 125 | 126 | context "of leaf" do 127 | it "should be empty" do 128 | expect(chicago.children).to be_empty 129 | end 130 | end 131 | end 132 | 133 | describe "descendants" do 134 | it "should be all descendants" do 135 | expect(usa.descendants).to include(illinois, indiana, chicago) 136 | end 137 | 138 | context "of leaf" do 139 | it "should be empty" do 140 | expect(chicago.descendants).to be_empty 141 | end 142 | end 143 | end 144 | 145 | describe "root scope" do 146 | it "returns only root nodes" do 147 | expect(Location.root).to include(usa, canada) 148 | end 149 | end 150 | 151 | describe "find_forest" do 152 | it "preloads all parents and children" do 153 | forest = Location.find_forest 154 | 155 | Location.where("purposely fail if any Location find happens here").scoping do 156 | forest.each do |tree| 157 | tree.descendants.each do |node| 158 | expect(node.parent).to be 159 | expect(node.children).to be_kind_of(ActiveRecord::Associations::CollectionProxy) 160 | end 161 | end 162 | end 163 | end 164 | 165 | it "works when scoped" do 166 | forest = Location.where(:name => "USA").find_forest 167 | expect(forest).to match_array([usa]) 168 | expect(forest.first.children).to match_array([illinois, indiana]) 169 | end 170 | 171 | it "preloads children in proper order" do 172 | alabama = Location.create! :parent => usa, :name => "Alabama" 173 | forest = Location.find_forest 174 | tree = forest.find { |l| l.id == usa.id } 175 | expect(tree.children).to eq [alabama, illinois, indiana] 176 | end 177 | 178 | context "with an infinite loop" do 179 | before do 180 | usa.update_attribute(:parent, chicago) 181 | end 182 | 183 | it "does not re-loop" do 184 | Location.find_forest 185 | end 186 | end 187 | end 188 | 189 | describe "find_tree" do 190 | it "finds by id" do 191 | tree = Location.find_tree usa.id 192 | expect(tree).to eq usa 193 | end 194 | 195 | it "finds multiple trees by id" do 196 | trees = Location.find_tree [indiana.id, illinois.id] 197 | expect(trees).to include(indiana, illinois) 198 | end 199 | 200 | it "raises ActiveRecord::RecordNotFound when id is not found" do 201 | expect{Location.find_tree -1}.to raise_error(ActiveRecord::RecordNotFound) 202 | end 203 | 204 | it "raises ActiveRecord::RecordNotFound when not all ids are not found" do 205 | expect{Location.find_tree [indiana.id, -1]}.to raise_error(ActiveRecord::RecordNotFound) 206 | end 207 | 208 | end 209 | 210 | describe "with_descendants" do 211 | context "unscoped" do 212 | it "returns all records" do 213 | rows = Location.with_descendants.to_a 214 | expect(rows).to match_array Location.all 215 | end 216 | end 217 | 218 | context "scoped" do 219 | it "returns a new scope that includes previously scoped records and their descendants" do 220 | rows = Location.where(id: canada.id).with_descendants.to_a 221 | expect(rows).to match_array [canada, british_columbia] 222 | end 223 | 224 | it "is not commutative" do 225 | rows = Location.with_descendants.where(id: canada.id).to_a 226 | expect(rows).to eq [canada] 227 | end 228 | end 229 | end 230 | 231 | describe "self.acts_as_forest" do 232 | it 'can be used twice' do 233 | class Location2 < ActiveRecord::Base 234 | self.table_name = 'locations' 235 | acts_as_forest :order => "name" 236 | end 237 | 238 | Location2.find_forest 239 | end 240 | end 241 | 242 | describe "dependent destroy" do 243 | it 'cascades destroys' do 244 | class Location3 < ActiveRecord::Base 245 | self.table_name = 'locations' 246 | acts_as_forest dependent: :destroy 247 | end 248 | 249 | Location3.find(usa.id).destroy 250 | 251 | expect(Location.exists?(usa.id)).to eq false 252 | expect(Location.exists?(illinois.id)).to eq false 253 | expect(Location.exists?(chicago.id)).to eq false 254 | expect(Location.exists?(indiana.id)).to eq false 255 | 256 | expect(Location.exists?(canada.id)).to eq true 257 | expect(Location.exists?(british_columbia.id)).to eq true 258 | end 259 | 260 | end 261 | 262 | if ActiveRecord::VERSION::MAJOR >= 5 263 | describe "optional option" do 264 | before do 265 | @original_value = ActiveRecord::Base.belongs_to_required_by_default 266 | ActiveRecord::Base.belongs_to_required_by_default = true 267 | end 268 | 269 | after do 270 | ActiveRecord::Base.belongs_to_required_by_default = @original_value 271 | end 272 | 273 | it 'parent can be nil' do 274 | class Location4 < ActiveRecord::Base 275 | self.table_name = "locations" 276 | acts_as_forest optional: true 277 | end 278 | 279 | expect(Location4.new(name: "Iceland").valid?).to eq true 280 | end 281 | end 282 | end 283 | end 284 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'edge' 2 | require 'yaml' 3 | 4 | require 'rspec' 5 | 6 | database_config = YAML.load_file(File.expand_path("../database.yml", __FILE__)) 7 | ActiveRecord::Base.establish_connection database_config["test"] 8 | 9 | RSpec.configure do |config| 10 | config.before(:all) do |example| 11 | ActiveRecord::Base.connection.execute <<-SQL 12 | truncate body_parts; 13 | truncate locations; 14 | SQL 15 | end 16 | 17 | config.around do |example| 18 | ActiveRecord::Base.transaction do 19 | example.call 20 | raise ActiveRecord::Rollback 21 | end 22 | end 23 | end 24 | --------------------------------------------------------------------------------