Commit 95ebdaf6cdf408c42692148482de6d8257c3b419

Authored by Braulio Bhavamitra
Committed by Braulio Bhavamitra
1 parent 77aa69cd

Optimize category tree lookup with a materialized path

db/migrate/20120820120000_index_parent_id_from_all_tables.rb 0 → 100644
@@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
  1 +class IndexParentIdFromAllTables < ActiveRecord::Migration
  2 + def self.up
  3 + add_index :article_versions, :parent_id
  4 + add_index :categories, :parent_id
  5 + add_index :images, :parent_id
  6 + add_index :tags, :parent_id
  7 + add_index :thumbnails, :parent_id
  8 + end
  9 +
  10 + def self.down
  11 + remove_index :article_versions, :parent_id
  12 + remove_index :categories, :parent_id
  13 + remove_index :images, :parent_id
  14 + remove_index :tags, :parent_id
  15 + remove_index :thumbnails, :parent_id
  16 + end
  17 +end
db/migrate/20120820142056_add_ancestry_to_categories.rb 0 → 100644
@@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
  1 +class AddAncestryToCategories < ActiveRecord::Migration
  2 + def self.up
  3 + add_column :categories, :ancestry, :text
  4 +
  5 + Category.all.each do |category|
  6 + category.set_ancestry
  7 + category.save!
  8 + end
  9 + end
  10 +
  11 + def self.down
  12 + remove_column :categories, :ancestry
  13 + end
  14 +end
@@ -217,6 +217,7 @@ ActiveRecord::Schema.define(:version =&gt; 20150122165042) do @@ -217,6 +217,7 @@ ActiveRecord::Schema.define(:version =&gt; 20150122165042) do
217 t.string "acronym" 217 t.string "acronym"
218 t.string "abbreviation" 218 t.string "abbreviation"
219 t.string "display_color", :limit => 6 219 t.string "display_color", :limit => 6
  220 + t.text "ancestry"
220 end 221 end
221 222
222 create_table "categories_profiles", :id => false, :force => true do |t| 223 create_table "categories_profiles", :id => false, :force => true do |t|
lib/acts_as_filesystem.rb
@@ -14,66 +14,72 @@ module ActsAsFileSystem @@ -14,66 +14,72 @@ module ActsAsFileSystem
14 # the parent, a "/" and the slug of the object) 14 # the parent, a "/" and the slug of the object)
15 # * children_count - a cache of the number of children elements. 15 # * children_count - a cache of the number of children elements.
16 def acts_as_filesystem 16 def acts_as_filesystem
17 -  
18 include ActsAsFileSystem::InstanceMethods 17 include ActsAsFileSystem::InstanceMethods
19 18
20 # a filesystem is a tree 19 # a filesystem is a tree
21 - acts_as_tree :order => 'name', :counter_cache => :children_count  
22 -  
23 - # calculate the right path  
24 - before_create do |record|  
25 - if record.path == record.slug && (! record.top_level?)  
26 - record.path = record.calculate_path  
27 - end  
28 - true  
29 - end  
30 -  
31 - # when renaming a category, all children categories must have their paths  
32 - # recalculated  
33 - after_update do |record|  
34 - if record.recalculate_path  
35 - record.children.each do |item|  
36 - item.path = item.calculate_path  
37 - item.recalculate_path = true  
38 - item.save!  
39 - end  
40 - end  
41 - record.recalculate_path = false  
42 - true  
43 - end 20 + acts_as_tree :counter_cache => :children_count
44 21
  22 + before_create :set_path
  23 + before_save :set_ancestry
  24 + after_update :update_children_path
45 end 25 end
  26 +
46 end 27 end
47 28
48 module InstanceMethods 29 module InstanceMethods
  30 +
49 # used to know when to trigger batch renaming 31 # used to know when to trigger batch renaming
50 attr_accessor :recalculate_path 32 attr_accessor :recalculate_path
51 33
52 - # calculates the full name of a category by accessing the name of all its  
53 - # ancestors.  
54 - #  
55 - # If you have this category hierarchy:  
56 - # Category "A"  
57 - # Category "B"  
58 - # Category "C"  
59 - #  
60 - # Then Category "C" will have "A/B/C" as its full name.  
61 - def full_name(sep = '/')  
62 - self.hierarchy.map {|item| item.name || '?' }.join(sep) 34 + # calculates the full path to this record using parent's path.
  35 + def calculate_path
  36 + self.hierarchy.map{ |obj| obj.slug }.join('/')
  37 + end
  38 + def set_path
  39 + if self.path == self.slug && !self.top_level?
  40 + self.path = self.calculate_path
  41 + end
  42 + end
  43 + def explode_path
  44 + path.split(/\//)
63 end 45 end
64 46
65 - # gets the name without leading parents. Usefull when dividing categories  
66 - # in top-level groups and full names must not include the top-level  
67 - # category which is already a emphasized label  
68 - def full_name_without_leading(count, sep = '/')  
69 - parts = self.full_name(sep).split(sep)  
70 - count.times { parts.shift }  
71 - parts.join(sep) 47 + def has_ancestry?
  48 + self.class.column_names.include? 'ancestry'
  49 + end
  50 + def ancestry
  51 + self['ancestry']
  52 + end
  53 + def ancestry=(value)
  54 + self['ancestry'] = value
  55 + end
  56 + # get the serialized tree from database column 'ancetry'
  57 + # and convert it to an array
  58 + def ancestry_ids
  59 + return nil if !has_ancestry? or ancestry.nil?
  60 + @ancestry_ids ||= ancestry.split('.').map{ |id| id.to_i }
  61 + end
  62 + def set_ancestry
  63 + return unless self.has_ancestry?
  64 + if self.ancestry.nil? or (new_record? or parent_id_changed?) or recalculate_path
  65 + self.ancestry = self.hierarchy[0...-1].map{ |p| "%010d" % p.id }.join('.')
  66 + end
72 end 67 end
73 68
74 - # calculates the level of the category in the category hierarchy. Top-level  
75 - # categories have level 0; the children of the top-level categories have  
76 - # level 1; the children of categories with level 1 have level 2, and so on. 69 + def update_children_path
  70 + if self.recalculate_path
  71 + self.children.each do |child|
  72 + child.path = child.calculate_path
  73 + child.recalculate_path = true
  74 + child.save!
  75 + end
  76 + end
  77 + self.recalculate_path = false
  78 + end
  79 +
  80 + # calculates the level of the record in the records hierarchy. Top-level
  81 + # records have level 0; the children of the top-level records have
  82 + # level 1; the children of records with level 1 have level 2, and so on.
77 # 83 #
78 # A level 0 84 # A level 0
79 # / \ 85 # / \
@@ -82,59 +88,26 @@ module ActsAsFileSystem @@ -82,59 +88,26 @@ module ActsAsFileSystem
82 # E F G H level 2 88 # E F G H level 2
83 # ... 89 # ...
84 def level 90 def level
85 - self.parent ? (self.parent.level + 1) : 0 91 + self.hierarchy.size - 1
86 end 92 end
87 93
88 - # Is this category a top-level category? 94 + # Is this record a top-level record?
89 def top_level? 95 def top_level?
90 self.parent.nil? 96 self.parent.nil?
91 end 97 end
92 98
93 - # Is this category a leaf in the hierarchy tree of categories? 99 + # Is this record a leaf in the hierarchy tree of records?
94 # 100 #
95 - # Being a leaf means that this category has no subcategories. 101 + # Being a leaf means that this record has no subrecord.
96 def leaf? 102 def leaf?
97 self.children.empty? 103 self.children.empty?
98 end 104 end
99 105
100 - def set_name(value)  
101 - if self.name != value  
102 - self.recalculate_path = true  
103 - end  
104 - self[:name] = value  
105 - end  
106 -  
107 - # sets the name of the category. Also sets #slug accordingly.  
108 - def name=(value)  
109 - self.set_name(value)  
110 - unless self.name.blank?  
111 - self.slug = self.name.to_slug  
112 - end  
113 - end  
114 -  
115 - # sets the slug of the category. Also sets the path with the new slug value.  
116 - def slug=(value)  
117 - self[:slug] = value  
118 - unless self.slug.blank?  
119 - self.path = self.calculate_path  
120 - end  
121 - end  
122 -  
123 - # calculates the full path to this category using parent's path.  
124 - def calculate_path  
125 - if self.top_level?  
126 - self.slug  
127 - else  
128 - self.parent.calculate_path + "/" + self.slug  
129 - end  
130 - end  
131 -  
132 def top_ancestor 106 def top_ancestor
133 - self.top_level? ? self : self.parent.top_ancestor 107 + self.hierarchy.first
134 end 108 end
135 -  
136 - def explode_path  
137 - path.split(/\//) 109 + def top_ancestor_id
  110 + self.ancestry_ids.first
138 end 111 end
139 112
140 # returns the full hierarchy from the top-level item to this one. For 113 # returns the full hierarchy from the top-level item to this one. For
@@ -145,16 +118,21 @@ module ActsAsFileSystem @@ -145,16 +118,21 @@ module ActsAsFileSystem
145 # when the ActiveRecord object was modified in some way, or just after 118 # when the ActiveRecord object was modified in some way, or just after
146 # changing parent) 119 # changing parent)
147 def hierarchy(reload = false) 120 def hierarchy(reload = false)
148 - if reload  
149 - @hierarchy = nil  
150 - end 121 + @hierarchy = nil if reload or recalculate_path
151 122
152 - unless @hierarchy 123 + if @hierarchy.nil?
153 @hierarchy = [] 124 @hierarchy = []
154 - item = self  
155 - while item  
156 - @hierarchy.unshift(item)  
157 - item = item.parent 125 +
  126 + if ancestry_ids
  127 + objects = self.class.base_class.all(:conditions => {:id => ancestry_ids})
  128 + ancestry_ids.each{ |id| @hierarchy << objects.find{ |t| t.id == id } }
  129 + @hierarchy << self
  130 + else
  131 + item = self
  132 + while item
  133 + @hierarchy.unshift(item)
  134 + item = item.parent
  135 + end
158 end 136 end
159 end 137 end
160 138
@@ -181,6 +159,51 @@ module ActsAsFileSystem @@ -181,6 +159,51 @@ module ActsAsFileSystem
181 res 159 res
182 end 160 end
183 161
  162 + # calculates the full name of a record by accessing the name of all its
  163 + # ancestors.
  164 + #
  165 + # If you have this record hierarchy:
  166 + # Record "A"
  167 + # Record "B"
  168 + # Record "C"
  169 + #
  170 + # Then Record "C" will have "A/B/C" as its full name.
  171 + def full_name(sep = '/')
  172 + self.hierarchy.map {|item| item.name || '?' }.join(sep)
  173 + end
  174 +
  175 + # gets the name without leading parents. Useful when dividing records
  176 + # in top-level groups and full names must not include the top-level
  177 + # record which is already a emphasized label
  178 + def full_name_without_leading(count, sep = '/')
  179 + parts = self.full_name(sep).split(sep)
  180 + count.times { parts.shift }
  181 + parts.join(sep)
  182 + end
  183 +
  184 + def set_name(value)
  185 + if self.name != value
  186 + self.recalculate_path = true
  187 + end
  188 + self[:name] = value
  189 + end
  190 +
  191 + # sets the name of the record. Also sets #slug accordingly.
  192 + def name=(value)
  193 + self.set_name(value)
  194 + unless self.name.blank?
  195 + self.slug = self.name.to_slug
  196 + end
  197 + end
  198 +
  199 + # sets the slug of the record. Also sets the path with the new slug value.
  200 + def slug=(value)
  201 + self[:slug] = value
  202 + unless self.slug.blank?
  203 + self.path = self.calculate_path
  204 + end
  205 + end
  206 +
184 end 207 end
185 end 208 end
186 209
test/unit/acts_as_filesystem_test.rb
@@ -7,13 +7,34 @@ class ActsAsFilesystemTest &lt; ActiveSupport::TestCase @@ -7,13 +7,34 @@ class ActsAsFilesystemTest &lt; ActiveSupport::TestCase
7 should 'provide a hierarchy list' do 7 should 'provide a hierarchy list' do
8 profile = create_user('testinguser').person 8 profile = create_user('testinguser').person
9 9
10 - a1 = profile.articles.build(:name => 'a1'); a1.save!  
11 - a2 = profile.articles.build(:name => 'a2'); a2.parent = a1; a2.save!  
12 - a3 = profile.articles.build(:name => 'a3'); a3.parent = a2; a3.save! 10 + a1 = profile.articles.create(:name => 'a1')
  11 + a2 = profile.articles.create(:name => 'a2', :parent => a1)
  12 + a3 = profile.articles.create(:name => 'a3', :parent => a2)
13 13
14 assert_equal [a1, a2, a3], a3.hierarchy 14 assert_equal [a1, a2, a3], a3.hierarchy
15 end 15 end
16 16
  17 + should 'set ancestry' do
  18 + c1 = create(Category, :name => 'c1')
  19 + c2 = create(Category, :name => 'c2', :parent => c1)
  20 + c3 = create(Category, :name => 'c3', :parent => c2)
  21 +
  22 + assert_not_nil c1.ancestry
  23 + assert_not_nil c2.ancestry
  24 + assert_equal "%010d.%010d" % [c1.id, c2.id], c3.ancestry
  25 + assert_equal [c1, c2, c3], c3.hierarchy
  26 + end
  27 +
  28 + should 'provide the level' do
  29 + c1 = create(Category, :name => 'c1')
  30 + c2 = create(Category, :name => 'c2', :parent => c1)
  31 + c3 = create(Category, :name => 'c3', :parent => c2)
  32 +
  33 + assert_equal 0, c1.level
  34 + assert_equal 1, c2.level
  35 + assert_equal 2, c3.level
  36 + end
  37 +
17 should 'be able to optionally reload the hierarchy' do 38 should 'be able to optionally reload the hierarchy' do
18 a = Article.new 39 a = Article.new
19 list = a.hierarchy 40 list = a.hierarchy