ancestry 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Stefan Kroes
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,151 @@
1
+ = Ancestry
2
+
3
+ Ancestry allows the records of a ActiveRecord model to be organised in a tree structure, using a single, intuitively formatted database column. It exposes all the standard tree structure relations (ancestors, parent, root, children, siblings, descendants) and all of them can be fetched in a single sql query. Additional features are named_scopes, integrity checking, integrity restoration, arrangement of (sub)tree into hashes and different strategies for dealing with orphaned records.
4
+
5
+ = Installation
6
+
7
+ To apply Ancestry to any ActiveRecord model, follow these simple steps:
8
+
9
+ 1. Install gem
10
+ - Install gemcutter gem: sudo gem install gemcutter (maybe you need: gem update --system)
11
+ - Add gemcutter.org as default gem source: gem tumble
12
+ - Add to config/environment.rb: config.gem 'ancestry'
13
+ - Install required gems: sudo rake gems:install
14
+ - Alternatively: sudo gem install ancestry
15
+ - If you don't want gemcutter: config.gem 'ancestry', :source => 'gemcutter.org'
16
+ - Alternatively: sudo gem install ancestry --source gemcutter.org
17
+
18
+ 2. Add ancestry column to your table
19
+ - Create migration: ./script/generate migration add_ancestry_to_[table] ancestry:string
20
+ - Add index to migration: add_index [table], :ancestry / remove_index [table], :ancestry
21
+ - Migrate your database: rake db:migrate
22
+
23
+ 3. Add ancestry to your model
24
+ - Add to app/models/[model].rb: acts_as_tree
25
+
26
+ Your model is now a tree!
27
+
28
+ = Organising Records Into A Tree
29
+
30
+ You can use the parent attribute to organise your records into a tree. If you have the id of the record you want to use as a parent and don't want to fetch it, you can also use parent_id. Like any virtual model attributes, parent and parent_id can be set using parent= and parent_id= on a record or by including them in the hash passed to new, create, create!, update_attributes and update_attributes!. For example:
31
+
32
+ TreeNode.create! :name => 'Stinky', :parent => TreeNode.create!(:name => 'Squeeky')
33
+
34
+ You can also create children through the children relation on a node:
35
+
36
+ node.children.create :name => 'Stinky'
37
+
38
+ = Navigating Your Tree
39
+
40
+ To navigate an Ancestry model, use the following methods on any instance / record:
41
+
42
+ parent Returns the parent of the record
43
+ root Returns the root of the tree the record is in
44
+ root_id Returns the id of the root of the tree the record is in
45
+ is_root? Returns true if the record is a root node, false otherwise
46
+ ancestor_ids Returns a list of ancestor ids, starting with the root id and ending with the parent id
47
+ ancestors Scopes the model on ancestors of the record
48
+ path_ids Returns a list the path ids, starting with the root is and ending with the node's own id
49
+ path Scopes model on path records of the record
50
+ children Scopes the model on children of the record
51
+ child_ids Returns a list of child ids
52
+ has_children? Returns true if the record has any children, false otherwise
53
+ is_childless? Returns true is the record has no childen, false otherwise
54
+ siblings Scopes the model on siblings of the record, the record itself is included
55
+ sibling_ids Returns a list of sibling ids
56
+ has_siblings? Returns true if the record's parent has more than one child
57
+ is_only_child? Returns true if the record is the only child of its parent
58
+ descendants Scopes the model on direct and indirect children of the record
59
+ descendant_ids Returns a list of a descendant ids
60
+ subtree Scopes the model on descendants and itself
61
+ subtree_ids Returns a list of all ids in the record's subtree
62
+
63
+ = (Named) Scopes
64
+
65
+ Where possible, the navigation methods return scopes instead of records, this means additional ordering, conditions, limits, etc. can be applied and that the result can be either retrieved, counted or checked for existence. For example:
66
+
67
+ node.children.exists?(:name => 'Mary')
68
+ node.subtree.all(:order => :name, :limit => 10).each do; ...; end
69
+ node.descendants.count
70
+
71
+ For convenience, a couple of named scopes are included at the class level:
72
+
73
+ roots Only root nodes
74
+ ancestors_of(node) Only ancestors of node, node can be either a record or an id
75
+ children_of(node) Only children of node, node can be either a record or an id
76
+ descendants_of(node) Only descendants of node, node can be either a record or an id
77
+ siblings_of(node) Only siblings of node, node can be either a record or an id
78
+
79
+ Thanks to some convenient rails magic, it is even possible to create nodes through the children and siblings scopes:
80
+
81
+ node.children.create
82
+ node.siblings.create!
83
+ TestNode.children_of(node_id).new
84
+ TestNode.siblings_of(node_id).create
85
+
86
+ = acts_as_tree Options
87
+
88
+ The acts_as_tree methods supports two options:
89
+
90
+ ancestry_column Pass in a symbol to instruct Ancestry to use a different column name to store record ancestry
91
+ orphan_strategy Instruct Ancestry what to do with children of a node that is destroyed:
92
+ :destroy All children are destroyed as well (default)
93
+ :rootify The children of the destroyed node become root nodes
94
+ :restrict An AncestryException is raised if any children exist
95
+
96
+ = Arrangement
97
+
98
+ Ancestry can arrange an entire subtree into nested hashes for easy navigation after retrieval from the database. TreeNode.arrange could for example return:
99
+
100
+ { #<TreeNode id: 100018, name: "Stinky", ancestry: nil>
101
+ => { #<TreeNode id: 100019, name: "Crunchy", ancestry: "100018">
102
+ => { #<TreeNode id: 100020, name: "Squeeky", ancestry: "100018/100019">
103
+ => {}
104
+ }
105
+ }
106
+ }
107
+
108
+ The arrange method also works on a scoped class, for example:
109
+
110
+ TreeNode.find_by_name('Crunchy').subtree.arrange
111
+
112
+ = Integrity Checking and Restoration
113
+
114
+ I don't see any way Ancestry tree integrity could get compromised without explicitly setting cyclic parents or invalid ancestry and circumventing validation with update_attribute, if you do, please let me know. I did include methods for detecting integrity problems and restoring integrity just to be sure. To check integrity use: [Model].check_ancestry_integrity. An AncestryIntegrityException will be raised if there are any problems. To restore integrity use: [Model].restore_ancestry_integrity.
115
+
116
+ For example, from IRB:
117
+
118
+ >> stinky = TreeNode.create :name => 'Stinky'
119
+ $ #<TreeNode id: 1, name: "Stinky", ancestry: nil>
120
+ >> squeeky = TreeNode.create :name => 'Squeeky', :parent => stinky
121
+ $ #<TreeNode id: 2, name: "Squeeky", ancestry: "1">
122
+ >> stinky.update_attribute :parent, squeeky
123
+ $ true
124
+ >> TreeNode.all
125
+ $ [#<TreeNode id: 1, name: "Stinky", ancestry: "1/2">, #<TreeNode id: 2, name: "Squeeky", ancestry: "1/2/1">]
126
+ >> TreeNode.check_ancestry_integrity
127
+ !! Ancestry::AncestryIntegrityException: Conflicting parent id in node 1: 2 for node 1, expecting nil
128
+ >> TreeNode.restore_ancestry_integrity
129
+ $ [#<TreeNode id: 1, name: "Stinky", ancestry: 2>, #<TreeNode id: 2, name: "Squeeky", ancestry: nil>]
130
+
131
+ = Testing
132
+
133
+ The Ancestry gem comes with a unit test suite consisting of about 1500 assertions in 20 tests. It takes about 4 seconds to run on sqlite. To run it yourself, install Ancestry as a plugin, go to the ancestry folder and type 'rake'. The test suite is located in 'test/acts_as_tree_test.rb'.
134
+
135
+ = Internals
136
+
137
+ As can be seen in the previous section, Ancestry stores a path from the root to the parent for every node. This is a variation on the materialised path database pattern. It allows Ancestry to fetch any relation (siblings, descendants, etc.) in a single sql query without the complicated algorithms and incomprehensibility associated with left and right values. Additionally, any inserts, deletes and updates only affect nodes within the affected node's own subtree.
138
+
139
+ In the example above, the ancestry column is created as a string. This puts a limitation on the depth of the tree of about 40 or 50 levels, which I think may be enough for most users. To increase the maximum depth of the tree, increase the size of the string that is being used or change it to a text to remove the limitation entirely. Changing it to a text will however decrease performance because a index cannot be put on the column in that case.
140
+
141
+ = Future Work
142
+
143
+ I will try to keep Ancestry up to date with changing versions of Rails and Ruby and also with any bug reports I might receive. I will implement new features on request as I see fit. Something that definitely needs to be added in the future is constraints on depth, something like: tree_node.subtree.to_depth(4)
144
+
145
+ = Feedback
146
+
147
+ Question? Bug report? Faulty/incomplete documentation? Feature request? Please contact me at s.a.kroes[at]gmail.com
148
+
149
+
150
+
151
+ Copyright (c) 2009 Stefan Kroes, released under the MIT license
@@ -0,0 +1,22 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'rake/rdoctask'
4
+
5
+ desc 'Default: run unit tests.'
6
+ task :default => :test
7
+
8
+ desc 'Test the ancestry plugin.'
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << 'lib'
11
+ t.pattern = 'test/**/*_test.rb'
12
+ t.verbose = true
13
+ end
14
+
15
+ desc 'Generate documentation for the ancestry plugin.'
16
+ Rake::RDocTask.new(:rdoc) do |rdoc|
17
+ rdoc.rdoc_dir = 'rdoc'
18
+ rdoc.title = 'Ancestry'
19
+ rdoc.options << '--line-numbers' << '--inline-source'
20
+ rdoc.rdoc_files.include('README')
21
+ rdoc.rdoc_files.include('lib/**/*.rb')
22
+ end
@@ -0,0 +1,18 @@
1
+ require 'rake'
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'ancestry'
5
+ s.description = 'Organise ActiveRecord model into a tree structure'
6
+ s.summary = 'Ancestry allows the records of a ActiveRecord model to be organised in a tree structure, using a single, intuitively formatted database column. It exposes all the standard tree structure relations (ancestors, parent, root, children, siblings, descendants) and all of them can be fetched in a single sql query. Additional features are named_scopes, integrity checking, integrity restoration, arrangement of (sub)tree into hashes and different strategies for dealing with orphaned records.'
7
+
8
+ s.version = '1.0.0'
9
+ s.date = '2009-10-16'
10
+
11
+ s.author = 'Stefan Kroes'
12
+ s.email = 's.a.kroes@gmail.com'
13
+ s.homepage = 'http://github.com/stefankroes/ancestry'
14
+
15
+ s.files = FileList['ancestry.gemspec', '*.rb', 'lib/**/*.rb', 'rails/*', 'test/*', 'Rakefile', 'MIT-LICENSE', 'README.rdoc']
16
+
17
+ s.add_dependency 'activerecord', '>= 2.1.0'
18
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'ancestry'
@@ -0,0 +1 @@
1
+ # Install hook code here
@@ -0,0 +1 @@
1
+ require 'ancestry/acts_as_tree'
@@ -0,0 +1,302 @@
1
+ module Ancestry
2
+ class AncestryException < RuntimeError
3
+ end
4
+
5
+ class AncestryIntegrityException < AncestryException
6
+ end
7
+
8
+ def self.included base
9
+ base.send :extend, ClassMethods
10
+ end
11
+
12
+ module ClassMethods
13
+ def acts_as_tree options = {}
14
+ # Include instance methods
15
+ send :include, InstanceMethods
16
+
17
+ # Include dynamic class methods
18
+ send :extend, DynamicClassMethods
19
+
20
+ # Create ancestry column accessor and set to option or default
21
+ self.cattr_accessor :ancestry_column
22
+ self.ancestry_column = options[:ancestry_column] || :ancestry
23
+
24
+ # Create orphan strategy accessor and set to option or default (writer comes from DynamicClassMethods)
25
+ self.cattr_reader :orphan_strategy
26
+ self.orphan_strategy = options[:orphan_strategy] || :destroy
27
+
28
+ # Validate format of ancestry column value
29
+ validates_format_of ancestry_column, :with => /^[0-9]+(\/[0-9]+)*$/, :allow_nil => true
30
+
31
+ # Validate that the ancestor ids don't include own id
32
+ validate :ancestry_exclude_self
33
+
34
+ # Named scopes
35
+ named_scope :roots, :conditions => {ancestry_column => nil}
36
+ named_scope :ancestors_of, lambda{ |object| {:conditions => to_node(object).ancestor_conditions} }
37
+ named_scope :children_of, lambda{ |object| {:conditions => to_node(object).child_conditions} }
38
+ named_scope :descendants_of, lambda{ |object| {:conditions => to_node(object).descendant_conditions} }
39
+ named_scope :siblings_of, lambda{ |object| {:conditions => to_node(object).sibling_conditions} }
40
+
41
+ # Update descendants with new ancestry before save
42
+ before_save :update_descendants_with_new_ancestry
43
+
44
+ # Apply orphan strategy before destroy
45
+ before_destroy :apply_orphan_strategy
46
+ end
47
+ end
48
+
49
+ module DynamicClassMethods
50
+ # Fetch tree node if necessary
51
+ def to_node object
52
+ object.is_a?(self) ? object : find(object)
53
+ end
54
+
55
+ # Orhpan strategy writer
56
+ def orphan_strategy= orphan_strategy
57
+ # Check value of orphan strategy, only rootify, restrict or destroy is allowed
58
+ if [:rootify, :restrict, :destroy].include? orphan_strategy
59
+ class_variable_set :@@orphan_strategy, orphan_strategy
60
+ else
61
+ raise AncestryException.new("Invalid orphan strategy, valid ones are :rootify, :restrict and :destroy.")
62
+ end
63
+ end
64
+
65
+ # Arrangement
66
+ def arrange
67
+ # Get all nodes ordered by ancestry and start sorting them into an empty hash
68
+ all(:order => ancestry_column).inject({}) do |arranged_nodes, node|
69
+ # Find the insertion point for that node by going through its ancestors
70
+ node.ancestor_ids.inject(arranged_nodes) do |insertion_point, ancestor_id|
71
+ insertion_point.each do |parent, children|
72
+ # Change the insertion point to children if node is a descendant of this parent
73
+ insertion_point = children if ancestor_id == parent.id
74
+ end; insertion_point
75
+ end[node] = {}; arranged_nodes
76
+ end
77
+ end
78
+
79
+ # Integrity checking
80
+ def check_ancestry_integrity
81
+ parents = {}
82
+ # For each node ...
83
+ all.each do |node|
84
+ # ... check validity of ancestry column
85
+ if node.errors.invalid? node.class.ancestry_column
86
+ raise AncestryIntegrityException.new "Invalid format for ancestry column of node #{node.id}: #{node.read_attribute node.ancestry_column}."
87
+ end
88
+ # ... check that all ancestors exist
89
+ node.ancestor_ids.each do |node_id|
90
+ unless exists? node_id
91
+ raise AncestryIntegrityException.new "Reference to non-existent node in node #{node.id}: #{node_id}."
92
+ end
93
+ end
94
+ # ... check that all node parents are consistent with values observed earlier
95
+ node.path_ids.zip([nil] + node.path_ids).each do |node_id, parent_id|
96
+ parents[node_id] = parent_id unless parents.has_key? node_id
97
+ unless parents[node_id] == parent_id
98
+ raise AncestryIntegrityException.new "Conflicting parent id in node #{node.id}: #{parent_id || 'nil'} for node #{node_id}, expecting #{parents[node_id] || 'nil'}"
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ # Integrity restoration
105
+ def restore_ancestry_integrity
106
+ parents = {}
107
+ # For each node ...
108
+ all.each do |node|
109
+ # ... set its ancestry to nil if invalid
110
+ if node.errors.invalid? node.class.ancestry_column
111
+ node.update_attributes :ancestry => nil
112
+ end
113
+ # ... save parent of this node in parents array if it exists
114
+ parents[node.id] = node.parent_id if exists? node.parent_id
115
+
116
+ # Reset parent id in array to nil if it introduces a cycle
117
+ parent = parents[node.id]
118
+ until parent.nil? || parent == node.id
119
+ parent = parents[parent]
120
+ end
121
+ parents[node.id] = nil if parent == node.id
122
+ end
123
+ # For each node ...
124
+ all.each do |node|
125
+ # ... rebuild ancestry from parents array
126
+ ancestry, parent = nil, parents[node.id]
127
+ until parent.nil?
128
+ ancestry, parent = ancestry.nil? ? parent : "#{parent}/#{ancestry}", parents[parent]
129
+ end
130
+ node.update_attributes node.ancestry_column => ancestry
131
+ end
132
+ end
133
+ end
134
+
135
+ module InstanceMethods
136
+ # Validate that the ancestors don't include itself
137
+ def ancestry_exclude_self
138
+ errors.add_to_base "#{self.class.name.humanize} cannot be a descendant of itself." if ancestor_ids.include? self.id
139
+ end
140
+
141
+ # Update descendants with new ancestry
142
+ def update_descendants_with_new_ancestry
143
+ # If node is valid, not a new record and ancestry was updated ...
144
+ if changed.include?(self.class.ancestry_column.to_s) && !new_record? && valid?
145
+ # ... for each descendant ...
146
+ descendants.each do |descendant|
147
+ # ... replace old ancestry with new ancestry
148
+ descendant.update_attributes(
149
+ self.class.ancestry_column =>
150
+ descendant.read_attribute(descendant.class.ancestry_column).gsub(
151
+ /^#{self.child_ancestry}/,
152
+ (read_attribute(self.class.ancestry_column).blank? ? id.to_s : "#{read_attribute self.class.ancestry_column }/#{id}")
153
+ )
154
+ )
155
+ end
156
+ end
157
+ end
158
+
159
+ # Apply orphan strategy
160
+ def apply_orphan_strategy
161
+ # If this isn't a new record ...
162
+ unless new_record?
163
+ # ... make al children root if orphan strategy is rootify
164
+ if self.class.orphan_strategy == :rootify
165
+ descendants.each do |descendant|
166
+ descendant.update_attributes descendant.class.ancestry_column => descendant.ancestry == child_ancestry ? nil : descendant.ancestry.gsub(/^#{child_ancestry}\//, '')
167
+ end
168
+ # ... destroy all descendants if orphan strategy is destroy
169
+ elsif self.class.orphan_strategy == :destroy
170
+ self.class.destroy_all descendant_conditions
171
+ # ... throw an exception if it has children and orphan strategy is restrict
172
+ elsif self.class.orphan_strategy == :restrict
173
+ raise Ancestry::AncestryException.new('Cannot delete record because it has descendants.') unless is_childless?
174
+ end
175
+ end
176
+ end
177
+
178
+ # The ancestry value for this record's children
179
+ def child_ancestry
180
+ # New records cannot have children
181
+ raise Ancestry::AncestryException.new('No child ancestry for new record. Save record before performing tree operations.') if new_record?
182
+
183
+ self.send("#{self.class.ancestry_column}_was").blank? ? id.to_s : "#{self.send "#{self.class.ancestry_column}_was"}/#{id}"
184
+ end
185
+
186
+ # Ancestors
187
+ def ancestor_ids
188
+ read_attribute(self.class.ancestry_column).to_s.split('/').map(&:to_i)
189
+ end
190
+
191
+ def ancestor_conditions
192
+ {:id => ancestor_ids}
193
+ end
194
+
195
+ def ancestors
196
+ self.class.scoped :conditions => ancestor_conditions
197
+ end
198
+
199
+ def path_ids
200
+ ancestor_ids + [id]
201
+ end
202
+
203
+ def path
204
+ ancestors + [self]
205
+ end
206
+
207
+ # Parent
208
+ def parent= parent
209
+ write_attribute(self.class.ancestry_column, parent.blank? ? nil : parent.child_ancestry)
210
+ end
211
+
212
+ def parent_id= parent_id
213
+ self.parent = parent_id.blank? ? nil : self.class.find(parent_id)
214
+ end
215
+
216
+ def parent_id
217
+ ancestor_ids.empty? ? nil : ancestor_ids.last
218
+ end
219
+
220
+ def parent
221
+ parent_id.blank? ? nil : self.class.find(parent_id)
222
+ end
223
+
224
+ # Root
225
+ def root_id
226
+ ancestor_ids.empty? ? id : ancestor_ids.first
227
+ end
228
+
229
+ def root
230
+ root_id == id ? self : self.class.find(root_id)
231
+ end
232
+
233
+ def is_root?
234
+ read_attribute(self.class.ancestry_column).blank?
235
+ end
236
+
237
+ # Children
238
+ def child_conditions
239
+ {self.class.ancestry_column => child_ancestry}
240
+ end
241
+
242
+ def children
243
+ self.class.scoped :conditions => child_conditions
244
+ end
245
+
246
+ def child_ids
247
+ children.all(:select => :id).map(&:id)
248
+ end
249
+
250
+ def has_children?
251
+ self.children.exists?
252
+ end
253
+
254
+ def is_childless?
255
+ !has_children?
256
+ end
257
+
258
+ # Siblings
259
+ def sibling_conditions
260
+ {self.class.ancestry_column => read_attribute(self.class.ancestry_column)}
261
+ end
262
+
263
+ def siblings
264
+ self.class.scoped :conditions => sibling_conditions
265
+ end
266
+
267
+ def sibling_ids
268
+ siblings.all(:select => :id).collect(&:id)
269
+ end
270
+
271
+ def has_siblings?
272
+ self.siblings.count > 1
273
+ end
274
+
275
+ def is_only_child?
276
+ !has_siblings?
277
+ end
278
+
279
+ # Descendants
280
+ def descendant_conditions
281
+ ["#{self.class.ancestry_column} like ? or #{self.class.ancestry_column} = ?", "#{child_ancestry}/%", child_ancestry]
282
+ end
283
+
284
+ def descendants
285
+ self.class.scoped :conditions => descendant_conditions
286
+ end
287
+
288
+ def descendant_ids
289
+ descendants.all(:select => :id).collect(&:id)
290
+ end
291
+
292
+ def subtree
293
+ [self] + descendants
294
+ end
295
+
296
+ def subtree_ids
297
+ [self.id] + descendant_ids
298
+ end
299
+ end
300
+ end
301
+
302
+ ActiveRecord::Base.send :include, Ancestry