From 339f4956b3adb8a9d43a024db69f4bc28e09e235 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 17 Apr 2005 09:52:12 +0000 Subject: [PATCH] Added acts_as_nested_set #1000 [wschenk] git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@1185 5ecf4fe2-1ee6-0310-87b1-e25e094e27de --- activerecord/CHANGELOG | 7 + activerecord/lib/active_record.rb | 2 + .../lib/active_record/acts/nested_set.rb | 212 ++++++++++++++++++ activerecord/test/fixtures/mixin.rb | 18 ++ activerecord/test/fixtures/mixins.yml | 30 +++ activerecord/test/mixin_test.rb | 1 + 6 files changed, 270 insertions(+) create mode 100644 activerecord/lib/active_record/acts/nested_set.rb diff --git a/activerecord/CHANGELOG b/activerecord/CHANGELOG index db64507f3e..3c54b9cd2e 100644 --- a/activerecord/CHANGELOG +++ b/activerecord/CHANGELOG @@ -1,5 +1,12 @@ *SVN* +* Added acts_as_nested_set #1000 [wschenk]. Introduction: + + This acts provides Nested Set functionality. Nested Set is similiar to Tree, but with + the added feature that you can select the children and all of it's descendants with + a single query. A good use case for this is a threaded post system, where you want + to display every reply to a comment without multiple selects. + * Added insert_at(position) to acts_as_list #1083 [DeLynnB] * Removed the default order by id on has_and_belongs_to_many queries as it could kill performance on large sets (you can still specify by hand with :order) diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index 94e835bdf1..02d5968be7 100755 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -43,6 +43,7 @@ require 'active_record/timestamp' require 'active_record/acts/list' require 'active_record/acts/tree' +require 'active_record/acts/nested_set' require 'active_record/locking' require 'active_record/migration' @@ -57,6 +58,7 @@ include ActiveRecord::Reflection include ActiveRecord::Acts::Tree include ActiveRecord::Acts::List + include ActiveRecord::Acts::NestedSet end require 'active_record/connection_adapters/mysql_adapter' diff --git a/activerecord/lib/active_record/acts/nested_set.rb b/activerecord/lib/active_record/acts/nested_set.rb new file mode 100644 index 0000000000..0202ac65b4 --- /dev/null +++ b/activerecord/lib/active_record/acts/nested_set.rb @@ -0,0 +1,212 @@ +module ActiveRecord + module Acts #:nodoc: + module NestedSet #:nodoc: + def self.append_features(base) + super + base.extend(ClassMethods) + end + + # This acts provides Nested Set functionality. Nested Set is similiar to Tree, but with + # the added feature that you can select the children and all of it's descendants with + # a single query. A good use case for this is a threaded post system, where you want + # to display every reply to a comment without multiple selects. + # + # A google search for "Nested Set" should point you in the direction to explain the + # data base theory. I figured a bunch of this from + # http://threebit.net/tutorials/nestedset/tutorial1.html + # + # Instead of picturing a leaf node structure with child pointing back to their parent, + # the best way to imagine how this works is to think of the parent entity surrounding all + # of it's children, and it's parent surrounding it, etc. Assuming that they are lined up + # horizontally, we store the left and right boundries in the database. + # + # Imagine: + # root + # |_ Child 1 + # |_ Child 1.1 + # |_ Child 1.2 + # |_ Child 2 + # |_ Child 2.1 + # |_ Child 2.2 + # + # If my cirlces in circles description didn't make sense, check out this sweet + # ASCII art: + # + # ___________________________________________________________________ + # | Root | + # | ____________________________ ____________________________ | + # | | Child 1 | | Child 2 | | + # | | __________ _________ | | __________ _________ | | + # | | | C 1.1 | | C 1.2 | | | | C 2.1 | | C 2.2 | | | + # 1 2 3_________4 5________6 7 8 9_________10 11_______12 13 14 + # | |___________________________| |___________________________| | + # |___________________________________________________________________| + # + # The numbers represent the left and right boundries. The table them might + # look like this: + # ID | PARENT | LEFT | RIGHT | DATA + # 1 | 0 | 1 | 14 | root + # 2 | 1 | 2 | 7 | Child 1 + # 3 | 2 | 3 | 4 | Child 1.1 + # 4 | 2 | 5 | 6 | Child 1.2 + # 5 | 1 | 8 | 13 | Child 2 + # 6 | 5 | 9 | 10 | Child 2.1 + # 7 | 5 | 11 | 12 | Child 2.2 + # + # So, to get all children of an entry, you + # SELECT * WHERE CHILD.LEFT IS BETWEEN PARENT.LEFT AND PARENT.RIGHT + # + # To get the count, it's (LEFT - RIGHT + 1)/2, etc. + # + # To get the direct parent, it falls back to using the PARENT_ID field. + # + # There are instance methods for all of these. + # + # The structure is good if you need to group things together; the downside is that + # keeping data integrity is a pain, and both adding and removing and entry + # require a full table write. + # + # This sets up a before_destroy trigger to prune the tree correctly if one of it's + # elements gets deleted. + # + module ClassMethods + # Configuration options are: + # + # * +parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id) + # * +left_column+ - column name for left boundry data, default "lft" + # * +right_column+ - column name for right boundry data, default "rgt" + # * +scope+ - restricts what is to be considered a list. Given a symbol, it'll attach "_id" + # (if that hasn't been already) and use that as the foreign key restriction. It's also possible + # to give it an entire string that is interpolated if you need a tighter scope than just a foreign key. + # Example: acts_as_list :scope => 'todo_list_id = #{todo_list_id} AND completed = 0' + def acts_as_nested_set(options = {}) + configuration = { :parent_column => "parent_id", :left_column => "lft", :right_column => "rgt", :scope => "1 = 1" } + + configuration.update(options) if options.is_a?(Hash) + + configuration[:scope] = "#{configuration[:scope]}_id".intern if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/ + + if configuration[:scope].is_a?(Symbol) + scope_condition_method = %( + def scope_condition + if #{configuration[:scope].to_s}.nil? + "#{configuration[:scope].to_s} IS NULL" + else + "#{configuration[:scope].to_s} = \#{#{configuration[:scope].to_s}}" + end + end + ) + else + scope_condition_method = "def scope_condition() \"#{configuration[:scope]}\" end" + end + + class_eval <<-EOV + include ActiveRecord::Acts::NestedSet::InstanceMethods + + #{scope_condition_method} + + def left_col_name() "#{configuration[:left_column]}" end + + def right_col_name() "#{configuration[:right_column]}" end + + def parent_column() "#{configuration[:parent_column]}" end + + EOV + end + end + + module InstanceMethods + # Returns true is this is a root node. + def root? + parent_id = self[parent_column] + (parent_id == 0 || parent_id.nil?) && (self[left_col_name] == 1) && (self[right_col_name] > self[left_col_name]) + end + + # Returns true is this is a child node + def child? + parent_id = self[parent_column] + !(parent_id == 0 || parent_id.nil?) && (self[left_col_name] > 1) && (self[right_col_name] > self[left_col_name]) + end + + # Returns true if we have no idea what this is + def unknown? + !root? && !child? + end + + + # Added a child to this object in the tree. If this object hasn't been initialized, + # it gets set up as a root node. Otherwise, this method will update all of the + # other elements in the tree and shift them to the right. Keeping everything + # balanaced. + def add_child( child ) + self.reload + child.reload + + if child.root? + raise "Adding sub-tree isn\'t currently supported" + else + if ( (self[left_col_name] == nil) || (self[right_col_name] == nil) ) + # Looks like we're now the root node! Woo + self[left_col_name] = 1 + self[right_col_name] = 4 + + # What do to do about validation? + return nil unless self.save + + child[parent_column] = self.id + child[left_col_name] = 2 + child[right_col_name]= 3 + return child.save + else + # OK, we need to add and shift everything else to the right + child[parent_column] = self.id + right_bound = self[right_col_name] + child[left_col_name] = right_bound + child[right_col_name] = right_bound + 1 + self[right_col_name] += 2 + self.class.transaction { + self.class.update_all( "#{left_col_name} = (#{left_col_name} + 2)", "#{scope_condition} AND #{left_col_name} >= #{right_bound}" ) + self.class.update_all( "#{right_col_name} = (#{right_col_name} + 2)", "#{scope_condition} AND #{right_col_name} >= #{right_bound}" ) + self.save + child.save + } + end + end + end + + # Returns the number of nested children of this object. + def children_count + return (self[right_col_name] - self[left_col_name] - 1)/2 + end + + # Returns a set of itself and all of it's nested children + def full_set + self.class.find_all( "#{scope_condition} AND (#{left_col_name} BETWEEN #{self[left_col_name]} and #{self[right_col_name]})" ) + end + + # Returns a set of all of it's children and nested children + def all_children + self.class.find_all( "#{scope_condition} AND (#{left_col_name} > #{self[left_col_name]}) and (#{right_col_name} < #{self[right_col_name]})" ) + end + + # Returns a set of only this entries immediate children + def direct_children + self.class.find_all( "#{scope_condition} and #{parent_column} = #{self.id}") + end + + # Prunes a branch off of the tree, shifting all of the elements on the right + # back to the left so the counts still work. + def before_destroy + return if self[right_col_name].nil? || self[left_col_name].nil? + dif = self[right_col_name] - self[left_col_name] + 1 + + self.class.transaction { + self.class.delete_all( "#{scope_condition} and #{left_col_name} > #{self[left_col_name]} and #{right_col_name} < #{self[right_col_name]}" ) + self.class.update_all( "#{left_col_name} = (#{left_col_name} - #{dif})", "#{scope_condition} AND #{left_col_name} >= #{self[right_col_name]}" ) + self.class.update_all( "#{right_col_name} = (#{right_col_name} - #{dif} )", "#{scope_condition} AND #{right_col_name} >= #{self[right_col_name]}" ) + } + end + end + end + end +end \ No newline at end of file diff --git a/activerecord/test/fixtures/mixin.rb b/activerecord/test/fixtures/mixin.rb index b14b0f3132..c583fe495e 100644 --- a/activerecord/test/fixtures/mixin.rb +++ b/activerecord/test/fixtures/mixin.rb @@ -16,5 +16,23 @@ def self.table_name() "mixins" end class ListWithStringScopeMixin < ActiveRecord::Base acts_as_list :column => "pos", :scope => 'parent_id = #{parent_id}' + def self.table_name() "mixins" end +end + +class NestedSet < Mixin + acts_as_nested_set :scope => "ROOT_ID IS NULL" + + def self.table_name() "mixins" end +end + +class NestedSetWithStringScope < Mixin + acts_as_nested_set :scope => 'root_id = #{root_id}' + + def self.table_name() "mixins" end +end + +class NestedSetWithSymbolScope < Mixin + acts_as_nested_set :scope => :root + def self.table_name() "mixins" end end \ No newline at end of file diff --git a/activerecord/test/fixtures/mixins.yml b/activerecord/test/fixtures/mixins.yml index 49297cc241..9f8e1ee8de 100644 --- a/activerecord/test/fixtures/mixins.yml +++ b/activerecord/test/fixtures/mixins.yml @@ -28,3 +28,33 @@ list_<%= counter %>: type: ListMixin parent_id: 5 <% end %> + +# Nested set mixins + +<% (1..10).each do |counter| %> +set_<%= counter %>: + id: <%= counter+3000 %> + type: NestedSet +<% end %> + +# Big old set +<% +[[4001, 0, 1, 20], + [4002, 4001, 2, 7], + [4003, 4002, 3, 4], + [4004, 4002, 5, 6], + [4005, 4001, 8, 13], + [4006, 4005, 9, 10], + [4007, 4005, 11, 12], + [4008, 4001, 14, 19], + [4009, 4008, 15, 16], + [4010, 4008, 17, 18]].each do |set| %> +tree_<%= set[0] %>: + id: <%= set[0]%> + parent_id: <%= set[1]%> + type: NestedSetWithStringScope + lft: <%= set[2]%> + rgt: <%= set[3]%> + root_id: 42 + +<% end %> diff --git a/activerecord/test/mixin_test.rb b/activerecord/test/mixin_test.rb index 52aa95117b..1efce934e2 100644 --- a/activerecord/test/mixin_test.rb +++ b/activerecord/test/mixin_test.rb @@ -1,6 +1,7 @@ require 'abstract_unit' require 'active_record/acts/tree' require 'active_record/acts/list' +require 'active_record/acts/nested_set' require 'fixtures/mixin' class ListTest < Test::Unit::TestCase