Skip to content
This repository
Browse code

Added acts_as_nested_set #1000 [wschenk]

git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@1185 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
  • Loading branch information...
commit 339f4956b3adb8a9d43a024db69f4bc28e09e235 1 parent 8e8bf37
David Heinemeier Hansson authored April 17, 2005
7  activerecord/CHANGELOG
... ...
@@ -1,5 +1,12 @@
1 1
 *SVN*
2 2
 
  3
+* Added acts_as_nested_set #1000 [wschenk]. Introduction:
  4
+
  5
+    This acts provides Nested Set functionality.  Nested Set is similiar to Tree, but with
  6
+    the added feature that you can select the children and all of it's descendants with
  7
+    a single query.  A good use case for this is a threaded post system, where you want
  8
+    to display every reply to a comment without multiple selects.
  9
+
3 10
 * Added insert_at(position) to acts_as_list #1083 [DeLynnB]
4 11
 
5 12
 * 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)
2  activerecord/lib/active_record.rb
@@ -43,6 +43,7 @@
43 43
 require 'active_record/timestamp'
44 44
 require 'active_record/acts/list'
45 45
 require 'active_record/acts/tree'
  46
+require 'active_record/acts/nested_set'
46 47
 require 'active_record/locking'
47 48
 require 'active_record/migration'
48 49
 
@@ -57,6 +58,7 @@
57 58
   include ActiveRecord::Reflection
58 59
   include ActiveRecord::Acts::Tree
59 60
   include ActiveRecord::Acts::List
  61
+  include ActiveRecord::Acts::NestedSet
60 62
 end
61 63
 
62 64
 require 'active_record/connection_adapters/mysql_adapter'
212  activerecord/lib/active_record/acts/nested_set.rb
... ...
@@ -0,0 +1,212 @@
  1
+module ActiveRecord
  2
+  module Acts #:nodoc:
  3
+    module NestedSet #:nodoc:
  4
+      def self.append_features(base)
  5
+        super        
  6
+        base.extend(ClassMethods)              
  7
+      end  
  8
+
  9
+      # This acts provides Nested Set functionality.  Nested Set is similiar to Tree, but with
  10
+      # the added feature that you can select the children and all of it's descendants with
  11
+      # a single query.  A good use case for this is a threaded post system, where you want
  12
+      # to display every reply to a comment without multiple selects.
  13
+      #
  14
+      # A google search for "Nested Set" should point you in the direction to explain the
  15
+      # data base theory.  I figured a bunch of this from
  16
+      # http://threebit.net/tutorials/nestedset/tutorial1.html
  17
+      #
  18
+      # Instead of picturing a leaf node structure with child pointing back to their parent,
  19
+      # the best way to imagine how this works is to think of the parent entity surrounding all
  20
+      # of it's children, and it's parent surrounding it, etc.  Assuming that they are lined up
  21
+      # horizontally, we store the left and right boundries in the database.
  22
+      #
  23
+      # Imagine:
  24
+      #   root
  25
+      #     |_ Child 1
  26
+      #       |_ Child 1.1
  27
+      #       |_ Child 1.2
  28
+      #     |_ Child 2
  29
+      #       |_ Child 2.1
  30
+      #       |_ Child 2.2
  31
+      #
  32
+      # If my cirlces in circles description didn't make sense, check out this sweet
  33
+      # ASCII art:
  34
+      #
  35
+      #     ___________________________________________________________________
  36
+      #    |  Root                                                             |
  37
+      #    |    ____________________________    ____________________________   |
  38
+      #    |   |  Child 1                  |   |  Child 2                  |   |
  39
+      #    |   |   __________   _________  |   |   __________   _________  |   |
  40
+      #    |   |  |  C 1.1  |  |  C 1.2 |  |   |  |  C 2.1  |  |  C 2.2 |  |   |
  41
+      #    1   2  3_________4  5________6  7   8  9_________10 11_______12 13  14
  42
+      #    |   |___________________________|   |___________________________|   |
  43
+      #    |___________________________________________________________________| 
  44
+      #
  45
+      # The numbers represent the left and right boundries.  The table them might
  46
+      # look like this:
  47
+      #    ID | PARENT | LEFT | RIGHT | DATA
  48
+      #     1 |      0 |    1 |    14 | root
  49
+      #     2 |      1 |    2 |     7 | Child 1
  50
+      #     3 |      2 |    3 |     4 | Child 1.1
  51
+      #     4 |      2 |    5 |     6 | Child 1.2
  52
+      #     5 |      1 |    8 |    13 | Child 2
  53
+      #     6 |      5 |    9 |    10 | Child 2.1
  54
+      #     7 |      5 |   11 |    12 | Child 2.2
  55
+      #
  56
+      # So, to get all children of an entry, you
  57
+      #     SELECT * WHERE CHILD.LEFT IS BETWEEN PARENT.LEFT AND PARENT.RIGHT
  58
+      #
  59
+      # To get the count, it's (LEFT - RIGHT + 1)/2, etc.
  60
+      #
  61
+      # To get the direct parent, it falls back to using the PARENT_ID field.   
  62
+      #
  63
+      # There are instance methods for all of these.
  64
+      #
  65
+      # The structure is good if you need to group things together; the downside is that
  66
+      # keeping data integrity is a pain, and both adding and removing and entry
  67
+      # require a full table write.        
  68
+      #
  69
+      # This sets up a before_destroy trigger to prune the tree correctly if one of it's
  70
+      # elements gets deleted.
  71
+      #
  72
+      module ClassMethods                      
  73
+        # Configuration options are:
  74
+        #
  75
+        # * +parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id)
  76
+        # * +left_column+ - column name for left boundry data, default "lft"
  77
+        # * +right_column+ - column name for right boundry data, default "rgt"
  78
+        # * +scope+ - restricts what is to be considered a list. Given a symbol, it'll attach "_id" 
  79
+        #   (if that hasn't been already) and use that as the foreign key restriction. It's also possible 
  80
+        #   to give it an entire string that is interpolated if you need a tighter scope than just a foreign key.
  81
+        #   Example: <tt>acts_as_list :scope => 'todo_list_id = #{todo_list_id} AND completed = 0'</tt>
  82
+        def acts_as_nested_set(options = {})
  83
+          configuration = { :parent_column => "parent_id", :left_column => "lft", :right_column => "rgt", :scope => "1 = 1" }
  84
+          
  85
+          configuration.update(options) if options.is_a?(Hash)
  86
+          
  87
+          configuration[:scope] = "#{configuration[:scope]}_id".intern if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
  88
+          
  89
+          if configuration[:scope].is_a?(Symbol)
  90
+            scope_condition_method = %(
  91
+              def scope_condition
  92
+                if #{configuration[:scope].to_s}.nil?
  93
+                  "#{configuration[:scope].to_s} IS NULL"
  94
+                else
  95
+                  "#{configuration[:scope].to_s} = \#{#{configuration[:scope].to_s}}"
  96
+                end
  97
+              end
  98
+            )
  99
+          else
  100
+            scope_condition_method = "def scope_condition() \"#{configuration[:scope]}\" end"
  101
+          end
  102
+        
  103
+          class_eval <<-EOV
  104
+            include ActiveRecord::Acts::NestedSet::InstanceMethods
  105
+
  106
+            #{scope_condition_method}
  107
+            
  108
+            def left_col_name() "#{configuration[:left_column]}" end
  109
+
  110
+            def right_col_name() "#{configuration[:right_column]}" end
  111
+              
  112
+            def parent_column() "#{configuration[:parent_column]}" end
  113
+
  114
+          EOV
  115
+        end
  116
+      end
  117
+      
  118
+      module InstanceMethods
  119
+        # Returns true is this is a root node.  
  120
+        def root?
  121
+          parent_id = self[parent_column]
  122
+          (parent_id == 0 || parent_id.nil?) && (self[left_col_name] == 1) && (self[right_col_name] > self[left_col_name])
  123
+        end                                                                                             
  124
+                                    
  125
+        # Returns true is this is a child node
  126
+        def child?                          
  127
+          parent_id = self[parent_column]
  128
+          !(parent_id == 0 || parent_id.nil?) && (self[left_col_name] > 1) && (self[right_col_name] > self[left_col_name])
  129
+        end     
  130
+        
  131
+        # Returns true if we have no idea what this is
  132
+        def unknown?
  133
+          !root? && !child?
  134
+        end
  135
+
  136
+                     
  137
+        # Added a child to this object in the tree.  If this object hasn't been initialized,
  138
+        # it gets set up as a root node.  Otherwise, this method will update all of the
  139
+        # other elements in the tree and shift them to the right. Keeping everything
  140
+        # balanaced. 
  141
+        def add_child( child )     
  142
+          self.reload
  143
+          child.reload
  144
+
  145
+          if child.root?
  146
+            raise "Adding sub-tree isn\'t currently supported"
  147
+          else
  148
+            if ( (self[left_col_name] == nil) || (self[right_col_name] == nil) )
  149
+              # Looks like we're now the root node!  Woo
  150
+              self[left_col_name] = 1
  151
+              self[right_col_name] = 4
  152
+              
  153
+              # What do to do about validation?
  154
+              return nil unless self.save
  155
+              
  156
+              child[parent_column] = self.id
  157
+              child[left_col_name] = 2
  158
+              child[right_col_name]= 3
  159
+              return child.save
  160
+            else
  161
+              # OK, we need to add and shift everything else to the right
  162
+              child[parent_column] = self.id
  163
+              right_bound = self[right_col_name]
  164
+              child[left_col_name] = right_bound
  165
+              child[right_col_name] = right_bound + 1
  166
+              self[right_col_name] += 2
  167
+              self.class.transaction {
  168
+                self.class.update_all( "#{left_col_name} = (#{left_col_name} + 2)",  "#{scope_condition} AND #{left_col_name} >= #{right_bound}" )
  169
+                self.class.update_all( "#{right_col_name} = (#{right_col_name} + 2)",  "#{scope_condition} AND #{right_col_name} >= #{right_bound}" )
  170
+                self.save
  171
+                child.save
  172
+              }
  173
+            end
  174
+          end                                   
  175
+        end
  176
+                                   
  177
+        # Returns the number of nested children of this object.
  178
+        def children_count
  179
+          return (self[right_col_name] - self[left_col_name] - 1)/2
  180
+        end
  181
+                                                               
  182
+        # Returns a set of itself and all of it's nested children
  183
+        def full_set
  184
+          self.class.find_all( "#{scope_condition} AND (#{left_col_name} BETWEEN #{self[left_col_name]} and #{self[right_col_name]})" )
  185
+        end
  186
+                  
  187
+        # Returns a set of all of it's children and nested children
  188
+        def all_children
  189
+          self.class.find_all( "#{scope_condition} AND (#{left_col_name} > #{self[left_col_name]}) and (#{right_col_name} < #{self[right_col_name]})" )
  190
+        end
  191
+                                  
  192
+        # Returns a set of only this entries immediate children
  193
+        def direct_children
  194
+          self.class.find_all( "#{scope_condition} and #{parent_column} = #{self.id}")
  195
+        end
  196
+                                      
  197
+        # Prunes a branch off of the tree, shifting all of the elements on the right
  198
+        # back to the left so the counts still work.
  199
+        def before_destroy
  200
+          return if self[right_col_name].nil? || self[left_col_name].nil?
  201
+          dif = self[right_col_name] - self[left_col_name] + 1
  202
+
  203
+          self.class.transaction {
  204
+            self.class.delete_all( "#{scope_condition} and #{left_col_name} > #{self[left_col_name]} and #{right_col_name} < #{self[right_col_name]}" )
  205
+            self.class.update_all( "#{left_col_name} = (#{left_col_name} - #{dif})",  "#{scope_condition} AND #{left_col_name} >= #{self[right_col_name]}" )
  206
+            self.class.update_all( "#{right_col_name} = (#{right_col_name} - #{dif} )",  "#{scope_condition} AND #{right_col_name} >= #{self[right_col_name]}" )
  207
+          }
  208
+        end
  209
+      end
  210
+    end
  211
+  end
  212
+end
18  activerecord/test/fixtures/mixin.rb
@@ -17,4 +17,22 @@ class ListWithStringScopeMixin < ActiveRecord::Base
17 17
   acts_as_list :column => "pos", :scope => 'parent_id = #{parent_id}'
18 18
 
19 19
   def self.table_name() "mixins" end
  20
+end
  21
+
  22
+class NestedSet < Mixin
  23
+  acts_as_nested_set :scope => "ROOT_ID IS NULL"
  24
+  
  25
+  def self.table_name() "mixins" end
  26
+end
  27
+
  28
+class NestedSetWithStringScope < Mixin
  29
+  acts_as_nested_set :scope => 'root_id = #{root_id}'
  30
+  
  31
+  def self.table_name() "mixins" end
  32
+end
  33
+
  34
+class NestedSetWithSymbolScope < Mixin
  35
+  acts_as_nested_set :scope => :root
  36
+  
  37
+  def self.table_name() "mixins" end
20 38
 end
30  activerecord/test/fixtures/mixins.yml
@@ -28,3 +28,33 @@ list_<%= counter %>:
28 28
   type: ListMixin
29 29
   parent_id: 5
30 30
 <% end %>
  31
+
  32
+# Nested set mixins
  33
+
  34
+<% (1..10).each do |counter| %>  
  35
+set_<%= counter %>:
  36
+  id: <%= counter+3000 %>
  37
+  type: NestedSet
  38
+<% end %>
  39
+
  40
+# Big old set
  41
+<%
  42
+[[4001, 0, 1, 20],
  43
+  [4002, 4001, 2, 7],
  44
+  [4003, 4002, 3, 4],
  45
+  [4004, 4002, 5, 6],
  46
+  [4005, 4001, 8, 13],
  47
+  [4006, 4005, 9, 10],
  48
+  [4007, 4005, 11, 12],
  49
+  [4008, 4001, 14, 19],
  50
+  [4009, 4008, 15, 16],
  51
+  [4010, 4008, 17, 18]].each do |set| %>
  52
+tree_<%= set[0] %>:
  53
+  id: <%= set[0]%>
  54
+  parent_id: <%= set[1]%>
  55
+  type: NestedSetWithStringScope
  56
+  lft: <%= set[2]%>
  57
+  rgt: <%= set[3]%>
  58
+  root_id: 42
  59
+
  60
+<% end %>
1  activerecord/test/mixin_test.rb
... ...
@@ -1,6 +1,7 @@
1 1
 require 'abstract_unit'
2 2
 require 'active_record/acts/tree'
3 3
 require 'active_record/acts/list'
  4
+require 'active_record/acts/nested_set'
4 5
 require 'fixtures/mixin'
5 6
 
6 7
 class ListTest < Test::Unit::TestCase

0 notes on commit 339f495

Please sign in to comment.
Something went wrong with that request. Please try again.