Commit 00b69cdc0d426fad8bdefc5652814bd780de19d8

Authored by Braulio Bhavamitra
2 parents 980d4cac 9f0569e6

Merge branch 'ai2809' into 'master'

Use materialized path for parents lookup

http://noosfero.org/Development/ActionItem2809

See merge request !7
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,11 @@
  1 +class AddAncestryToCategories < ActiveRecord::Migration
  2 + def self.up
  3 + add_column :categories, :ancestry, :text
  4 +
  5 + Category.build_ancestry
  6 + end
  7 +
  8 + def self.down
  9 + remove_column :categories, :ancestry
  10 + end
  11 +end
... ...
db/schema.rb
... ... @@ -104,6 +104,7 @@ ActiveRecord::Schema.define(:version =&gt; 20150122165042) do
104 104 add_index "article_versions", ["path", "profile_id"], :name => "index_article_versions_on_path_and_profile_id"
105 105 add_index "article_versions", ["path"], :name => "index_article_versions_on_path"
106 106 add_index "article_versions", ["published_at", "id"], :name => "index_article_versions_on_published_at_and_id"
  107 + add_index "article_versions", ["parent_id"], :name => "index_article_versions_on_parent_id"
107 108  
108 109 create_table "articles", :force => true do |t|
109 110 t.string "name"
... ... @@ -217,8 +218,11 @@ ActiveRecord::Schema.define(:version =&gt; 20150122165042) do
217 218 t.string "acronym"
218 219 t.string "abbreviation"
219 220 t.string "display_color", :limit => 6
  221 + t.text "ancestry"
220 222 end
221 223  
  224 + add_index "categories", ["parent_id"], :name => "index_categories_on_parent_id"
  225 +
222 226 create_table "categories_profiles", :id => false, :force => true do |t|
223 227 t.integer "profile_id"
224 228 t.integer "category_id"
... ... @@ -251,6 +255,7 @@ ActiveRecord::Schema.define(:version =&gt; 20150122165042) do
251 255 t.string "source_type"
252 256 t.string "user_agent"
253 257 t.string "referrer"
  258 + t.integer "group_id"
254 259 end
255 260  
256 261 add_index "comments", ["source_id", "spam"], :name => "index_comments_on_source_id_and_spam"
... ... @@ -357,6 +362,8 @@ ActiveRecord::Schema.define(:version =&gt; 20150122165042) do
357 362 t.boolean "thumbnails_processed", :default => false
358 363 end
359 364  
  365 + add_index "images", ["parent_id"], :name => "index_images_on_parent_id"
  366 +
360 367 create_table "inputs", :force => true do |t|
361 368 t.integer "product_id", :null => false
362 369 t.integer "product_category_id", :null => false
... ... @@ -649,7 +656,11 @@ ActiveRecord::Schema.define(:version =&gt; 20150122165042) do
649 656 t.boolean "pending", :default => false
650 657 end
651 658  
  659 +<<<<<<< HEAD
652 660 add_index "tags", ["name"], :name => "index_tags_on_name", :unique => true
  661 +=======
  662 + add_index "tags", ["parent_id"], :name => "index_tags_on_parent_id"
  663 +>>>>>>> Update schema.rb
653 664  
654 665 create_table "tasks", :force => true do |t|
655 666 t.text "data"
... ... @@ -689,6 +700,8 @@ ActiveRecord::Schema.define(:version =&gt; 20150122165042) do
689 700 t.string "thumbnail"
690 701 end
691 702  
  703 + add_index "thumbnails", ["parent_id"], :name => "index_thumbnails_on_parent_id"
  704 +
692 705 create_table "units", :force => true do |t|
693 706 t.string "singular", :null => false
694 707 t.string "plural", :null => false
... ...
lib/acts_as_filesystem.rb
1 1 module ActsAsFileSystem
2 2  
3   - module ClassMethods
  3 + module ActsMethods
4 4  
5 5 # Declares the ActiveRecord model to acts like a filesystem: objects are
6 6 # arranged in a tree (liks acts_as_tree), and . The underlying table must
... ... @@ -14,66 +14,81 @@ 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   - include ActsAsFileSystem::InstanceMethods
19   -
20 17 # a filesystem is a tree
21   - acts_as_tree :order => 'name', :counter_cache => :children_count
  18 + acts_as_tree :counter_cache => :children_count
22 19  
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
  20 + extend ClassMethods
  21 + include InstanceMethods
  22 + if self.has_path?
  23 + after_update :update_children_path
  24 + before_create :set_path
  25 + include InstanceMethods::PathMethods
29 26 end
30 27  
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
  28 + before_save :set_ancestry
  29 + end
  30 +
  31 + end
  32 +
  33 + module ClassMethods
  34 +
  35 + def build_ancestry(parent_id = nil, ancestry = '')
  36 + ActiveRecord::Base.transaction do
  37 + self.base_class.all(:conditions => {:parent_id => parent_id}).each do |node|
  38 + node.ancestry = ancestry
  39 + node.send :create_or_update_without_callbacks
  40 +
  41 + build_ancestry node.id, (ancestry.empty? ? "#{node.formatted_ancestry_id}" :
  42 + "#{ancestry}#{node.ancestry_sep}#{node.formatted_ancestry_id}")
40 43 end
41   - record.recalculate_path = false
42   - true
43 44 end
44 45  
  46 + #raise "Couldn't reach and set ancestry on every record" if self.base_class.count(:conditions => ['ancestry is null']) != 0
  47 + end
  48 +
  49 + def has_path?
  50 + (['name', 'slug', 'path'] - self.column_names).blank?
45 51 end
  52 +
46 53 end
47 54  
48 55 module InstanceMethods
49   - # used to know when to trigger batch renaming
50   - attr_accessor :recalculate_path
51 56  
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)
  57 + def ancestry_column
  58 + 'ancestry'
  59 + end
  60 + def ancestry_sep
  61 + '.'
  62 + end
  63 + def has_ancestry?
  64 + self.class.column_names.include? self.ancestry_column
  65 + end
  66 +
  67 + def formatted_ancestry_id
  68 + "%010d" % self.id if self.id
63 69 end
64 70  
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)
  71 + def ancestry
  72 + self[ancestry_column]
  73 + end
  74 + def ancestor_ids
  75 + return nil if !has_ancestry? or ancestry.nil?
  76 + @ancestor_ids ||= ancestry.split(ancestry_sep).map{ |id| id.to_i }
  77 + end
  78 +
  79 + def ancestry=(value)
  80 + self[ancestry_column] = value
  81 + end
  82 + def set_ancestry
  83 + return unless self.has_ancestry?
  84 + if self.ancestry.nil? or (new_record? or parent_id_changed?) or recalculate_path
  85 + self.ancestry = self.hierarchy(true)[0...-1].map{ |p| p.formatted_ancestry_id }.join(ancestry_sep)
  86 + end
72 87 end
73 88  
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.
  89 + # calculates the level of the record in the records hierarchy. Top-level
  90 + # records have level 0; the children of the top-level records have
  91 + # level 1; the children of records with level 1 have level 2, and so on.
77 92 #
78 93 # A level 0
79 94 # / \
... ... @@ -82,61 +97,36 @@ module ActsAsFileSystem
82 97 # E F G H level 2
83 98 # ...
84 99 def level
85   - self.parent ? (self.parent.level + 1) : 0
  100 + self.hierarchy.size - 1
86 101 end
87 102  
88   - # Is this category a top-level category?
  103 + # Is this record a top-level record?
89 104 def top_level?
90 105 self.parent.nil?
91 106 end
92 107  
93   - # Is this category a leaf in the hierarchy tree of categories?
  108 + # Is this record a leaf in the hierarchy tree of records?
94 109 #
95   - # Being a leaf means that this category has no subcategories.
  110 + # Being a leaf means that this record has no subrecord.
96 111 def leaf?
97 112 self.children.empty?
98 113 end
99 114  
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
  115 + def top_ancestor
  116 + if has_ancestry? and !ancestry.nil?
  117 + self.class.base_class.find_by_id self.top_ancestor_id
  118 + else
  119 + self.hierarchy.first
120 120 end
121 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
  122 + def top_ancestor_id
  123 + if has_ancestry? and !ancestry.nil?
  124 + self.ancestor_ids.first
127 125 else
128   - self.parent.calculate_path + "/" + self.slug
  126 + self.hierarchy.first.id
129 127 end
130 128 end
131 129  
132   - def top_ancestor
133   - self.top_level? ? self : self.parent.top_ancestor
134   - end
135   -
136   - def explode_path
137   - path.split(/\//)
138   - end
139   -
140 130 # returns the full hierarchy from the top-level item to this one. For
141 131 # example, if item1 has a children item2 and item2 has a children item3,
142 132 # then item3's hierarchy would be [item1, item2, item3].
... ... @@ -145,16 +135,21 @@ module ActsAsFileSystem
145 135 # when the ActiveRecord object was modified in some way, or just after
146 136 # changing parent)
147 137 def hierarchy(reload = false)
148   - if reload
149   - @hierarchy = nil
150   - end
  138 + @hierarchy = nil if reload or recalculate_path
151 139  
152   - unless @hierarchy
  140 + if @hierarchy.nil?
153 141 @hierarchy = []
154   - item = self
155   - while item
156   - @hierarchy.unshift(item)
157   - item = item.parent
  142 +
  143 + if !reload and !recalculate_path and ancestor_ids
  144 + objects = self.class.base_class.all(:conditions => {:id => ancestor_ids})
  145 + ancestor_ids.each{ |id| @hierarchy << objects.find{ |t| t.id == id } }
  146 + @hierarchy << self
  147 + else
  148 + item = self
  149 + while item
  150 + @hierarchy.unshift(item)
  151 + item = item.parent
  152 + end
158 153 end
159 154 end
160 155  
... ... @@ -181,8 +176,86 @@ module ActsAsFileSystem
181 176 res
182 177 end
183 178  
  179 + #####
  180 + # Path methods
  181 + # These methods are used when _path_, _name_ and _slug_ attributes exist
  182 + # and should be calculated based on the tree
  183 + #####
  184 + module PathMethods
  185 + # used to know when to trigger batch renaming
  186 + attr_accessor :recalculate_path
  187 +
  188 + # calculates the full path to this record using parent's path.
  189 + def calculate_path
  190 + self.hierarchy.map{ |obj| obj.slug }.join('/')
  191 + end
  192 + def set_path
  193 + if self.path == self.slug && !self.top_level?
  194 + self.path = self.calculate_path
  195 + end
  196 + end
  197 + def explode_path
  198 + path.split(/\//)
  199 + end
  200 +
  201 + def update_children_path
  202 + if self.recalculate_path
  203 + self.children.each do |child|
  204 + child.path = child.calculate_path
  205 + child.recalculate_path = true
  206 + child.save!
  207 + end
  208 + end
  209 + self.recalculate_path = false
  210 + end
  211 +
  212 + # calculates the full name of a record by accessing the name of all its
  213 + # ancestors.
  214 + #
  215 + # If you have this record hierarchy:
  216 + # Record "A"
  217 + # Record "B"
  218 + # Record "C"
  219 + #
  220 + # Then Record "C" will have "A/B/C" as its full name.
  221 + def full_name(sep = '/')
  222 + self.hierarchy.map {|item| item.name || '?' }.join(sep)
  223 + end
  224 +
  225 + # gets the name without leading parents. Useful when dividing records
  226 + # in top-level groups and full names must not include the top-level
  227 + # record which is already a emphasized label
  228 + def full_name_without_leading(count, sep = '/')
  229 + parts = self.full_name(sep).split(sep)
  230 + count.times { parts.shift }
  231 + parts.join(sep)
  232 + end
  233 +
  234 + def set_name(value)
  235 + if self.name != value
  236 + self.recalculate_path = true
  237 + end
  238 + self[:name] = value
  239 + end
  240 +
  241 + # sets the name of the record. Also sets #slug accordingly.
  242 + def name=(value)
  243 + self.set_name(value)
  244 + unless self.name.blank?
  245 + self.slug = self.name.to_slug
  246 + end
  247 + end
  248 +
  249 + # sets the slug of the record. Also sets the path with the new slug value.
  250 + def slug=(value)
  251 + self[:slug] = value
  252 + unless self.slug.blank?
  253 + self.path = self.calculate_path
  254 + end
  255 + end
  256 + end
184 257 end
185 258 end
186 259  
187   -ActiveRecord::Base.extend ActsAsFileSystem::ClassMethods
  260 +ActiveRecord::Base.extend ActsAsFileSystem::ActsMethods
188 261  
... ...
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
... ...