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 @@
  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 @@
  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
... ...
db/schema.rb
... ... @@ -217,6 +217,7 @@ ActiveRecord::Schema.define(:version =&gt; 20150122165042) do
217 217 t.string "acronym"
218 218 t.string "abbreviation"
219 219 t.string "display_color", :limit => 6
  220 + t.text "ancestry"
220 221 end
221 222  
222 223 create_table "categories_profiles", :id => false, :force => true do |t|
... ...
lib/acts_as_filesystem.rb
... ... @@ -14,66 +14,72 @@ module ActsAsFileSystem
14 14 # the parent, a "/" and the slug of the object)
15 15 # * children_count - a cache of the number of children elements.
16 16 def acts_as_filesystem
17   -
18 17 include ActsAsFileSystem::InstanceMethods
19 18  
20 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 25 end
  26 +
46 27 end
47 28  
48 29 module InstanceMethods
  30 +
49 31 # used to know when to trigger batch renaming
50 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 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 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 84 # A level 0
79 85 # / \
... ... @@ -82,59 +88,26 @@ module ActsAsFileSystem
82 88 # E F G H level 2
83 89 # ...
84 90 def level
85   - self.parent ? (self.parent.level + 1) : 0
  91 + self.hierarchy.size - 1
86 92 end
87 93  
88   - # Is this category a top-level category?
  94 + # Is this record a top-level record?
89 95 def top_level?
90 96 self.parent.nil?
91 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 102 def leaf?
97 103 self.children.empty?
98 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 106 def top_ancestor
133   - self.top_level? ? self : self.parent.top_ancestor
  107 + self.hierarchy.first
134 108 end
135   -
136   - def explode_path
137   - path.split(/\//)
  109 + def top_ancestor_id
  110 + self.ancestry_ids.first
138 111 end
139 112  
140 113 # returns the full hierarchy from the top-level item to this one. For
... ... @@ -145,16 +118,21 @@ module ActsAsFileSystem
145 118 # when the ActiveRecord object was modified in some way, or just after
146 119 # changing parent)
147 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 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 136 end
159 137 end
160 138  
... ... @@ -181,6 +159,51 @@ module ActsAsFileSystem
181 159 res
182 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 207 end
185 208 end
186 209  
... ...
test/unit/acts_as_filesystem_test.rb
... ... @@ -7,13 +7,34 @@ class ActsAsFilesystemTest &lt; ActiveSupport::TestCase
7 7 should 'provide a hierarchy list' do
8 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 14 assert_equal [a1, a2, a3], a3.hierarchy
15 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 38 should 'be able to optionally reload the hierarchy' do
18 39 a = Article.new
19 40 list = a.hierarchy
... ...