arboreal 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2010 Mike Williams <mdub@dogbiscuit.org>
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.
data/README.rdoc ADDED
@@ -0,0 +1,7 @@
1
+ = Arboreal
2
+
3
+ Arboreal is yet another extension to ActiveRecord to support tree-shaped data structures.
4
+
5
+ Internally, Arboreal maintains a computed "ancestry_string" column, which caches the path from the root of a tree to each node, allowing efficient retrieval of both ancestors and descendants.
6
+
7
+ Arboreal surfaces relationships within the tree like +children+, +ancestors+, +descendants+, and +siblings+ as scopes, so that additional filtering/pagination can be performed.
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ task :default => :spec
2
+
3
+ require "spec/rake/spectask"
4
+
5
+ Spec::Rake::SpecTask.new(:spec) do |spec|
6
+ spec.libs << 'lib' << 'spec'
7
+ spec.spec_files = FileList['spec/**/*_spec.rb']
8
+ spec.spec_opts << "-fnested" << "--color"
9
+ end
data/lib/arboreal.rb ADDED
@@ -0,0 +1,4 @@
1
+ require 'arboreal/active_record_extensions'
2
+ require 'arboreal/class_methods'
3
+ require 'arboreal/instance_methods'
4
+ require 'arboreal/version'
@@ -0,0 +1,31 @@
1
+ module Arboreal
2
+ module ActiveRecordExtensions
3
+
4
+ def acts_arboreal
5
+
6
+ belongs_to :parent, :class_name => self.name
7
+ has_many :children, :class_name => self.name, :foreign_key => :parent_id
8
+
9
+ extend Arboreal::ClassMethods
10
+ include Arboreal::InstanceMethods
11
+
12
+ before_validation :populate_ancestry_string
13
+
14
+ validate do |record|
15
+ record.send(:validate_parent_not_ancestor)
16
+ end
17
+
18
+ before_save :detect_ancestry_change
19
+ after_save :apply_ancestry_change_to_descendants
20
+
21
+ named_scope :roots, {
22
+ :conditions => ["parent_id IS NULL"]
23
+ }
24
+
25
+ end
26
+
27
+ end
28
+ end
29
+
30
+ require 'active_record'
31
+ ActiveRecord::Base.extend(Arboreal::ActiveRecordExtensions)
@@ -0,0 +1,34 @@
1
+ module Arboreal
2
+ module ClassMethods
3
+
4
+ def rebuild_ancestry
5
+ clear_ancestry_strings
6
+ populate_root_ancestry_strings
7
+ begin
8
+ n_changes = extend_ancestry_strings
9
+ end until n_changes.zero?
10
+ end
11
+
12
+ private
13
+
14
+ def clear_ancestry_strings
15
+ connection.update("UPDATE #{table_name} SET ancestry_string = NULL")
16
+ end
17
+
18
+ def populate_root_ancestry_strings
19
+ connection.update("UPDATE #{table_name} SET ancestry_string = '-' WHERE parent_id IS NULL")
20
+ end
21
+
22
+ def extend_ancestry_strings
23
+ connection.update(<<-SQL.squish)
24
+ UPDATE #{table_name}
25
+ SET ancestry_string =
26
+ (SELECT (parent.ancestry_string || CAST(#{table_name}.parent_id AS varchar) || '-')
27
+ FROM #{table_name} AS parent
28
+ WHERE parent.id = #{table_name}.parent_id)
29
+ WHERE ancestry_string IS NULL
30
+ SQL
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,94 @@
1
+ module Arboreal
2
+ module InstanceMethods
3
+
4
+ def path_string
5
+ "#{ancestry_string}#{id}-"
6
+ end
7
+
8
+ def ancestry_string
9
+ read_attribute(:ancestry_string) || "-"
10
+ end
11
+
12
+ def ancestor_ids
13
+ ancestry_string.sub(/^-/, "").split("-").map { |x| x.to_i }
14
+ end
15
+
16
+ def ancestors
17
+ base_class.scoped(
18
+ :conditions => ["id in (?)", ancestor_ids],
19
+ :order => [:ancestry_string]
20
+ )
21
+ end
22
+
23
+ def descendants
24
+ ancestry_pattern =
25
+ base_class.scoped(
26
+ :conditions => ["#{base_class.table_name}.ancestry_string like ?", path_string + "%"]
27
+ )
28
+ end
29
+
30
+ def subtree
31
+ ancestry_pattern = path_string + "%"
32
+ base_class.scoped(
33
+ :conditions => [
34
+ "#{base_class.table_name}.id = ? OR #{base_class.table_name}.ancestry_string like ?",
35
+ id, path_string + "%"
36
+ ]
37
+ )
38
+ end
39
+
40
+ def siblings
41
+ base_class.scoped(
42
+ :conditions => [
43
+ "#{base_class.table_name}.id <> ? AND #{base_class.table_name}.parent_id = ?",
44
+ id, parent_id
45
+ ]
46
+ )
47
+ end
48
+
49
+ def root
50
+ ancestors.first || self
51
+ end
52
+
53
+ private
54
+
55
+ def base_class
56
+ self.class.base_class
57
+ end
58
+
59
+ def populate_ancestry_string
60
+ self.ancestry_string = (parent.path_string unless parent.nil?)
61
+ end
62
+
63
+ def validate_parent_not_ancestor
64
+ if self.id
65
+ if parent_id == self.id
66
+ errors.add(:parent, "can't be the record itself")
67
+ end
68
+ if ancestor_ids.include?(self.id)
69
+ errors.add(:parent, "can't be an ancestor")
70
+ end
71
+ end
72
+ end
73
+
74
+ def detect_ancestry_change
75
+ if ancestry_string_changed? && !new_record?
76
+ old_path_string = "#{ancestry_string_was}#{id}-"
77
+ @ancestry_change = [old_path_string, path_string]
78
+ end
79
+ end
80
+
81
+ def apply_ancestry_change_to_descendants
82
+ if @ancestry_change
83
+ old_ancestry_string, new_ancestry_string = *@ancestry_change
84
+ connection.update(<<-SQL.squish)
85
+ UPDATE #{base_class.table_name}
86
+ SET ancestry_string = REPLACE(ancestry_string, '#{old_ancestry_string}', '#{new_ancestry_string}')
87
+ WHERE ancestry_string LIKE '#{old_ancestry_string}%'
88
+ SQL
89
+ @ancestry_change = nil
90
+ end
91
+ end
92
+
93
+ end
94
+ end
@@ -0,0 +1,3 @@
1
+ module Arboreal
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,250 @@
1
+ require 'spec_helper'
2
+
3
+ require 'arboreal'
4
+
5
+ class Node < ActiveRecord::Base
6
+
7
+ acts_arboreal
8
+
9
+ class Migration < ActiveRecord::Migration
10
+
11
+ def self.up
12
+ create_table "nodes", :force => true do |t|
13
+ t.string "name"
14
+ t.integer "parent_id"
15
+ t.string "ancestry_string"
16
+ end
17
+ end
18
+
19
+ def self.down
20
+ drop_table "nodes"
21
+ end
22
+
23
+ end
24
+
25
+ end
26
+
27
+ describe "{Arboreal}" do
28
+
29
+ before(:all) do
30
+ Node::Migration.up
31
+ end
32
+
33
+ after(:all) do
34
+ Node::Migration.down
35
+ end
36
+
37
+ before do
38
+ @australia = Node.create!(:name => "Australia")
39
+ @victoria = Node.create!(:name => "Victoria", :parent => @australia)
40
+ @melbourne = Node.create!(:name => "Melbourne", :parent => @victoria)
41
+ @nsw = Node.create!(:name => "New South Wales", :parent => @australia)
42
+ @sydney = Node.create!(:name => "Sydney", :parent => @nsw)
43
+ end
44
+
45
+ describe "node" do
46
+
47
+ describe "#parent" do
48
+ it "returns the parent" do
49
+ @victoria.parent.should == @australia
50
+ end
51
+ end
52
+
53
+ describe "#children" do
54
+ it "returns the children" do
55
+ @australia.children.to_set.should == [@victoria, @nsw].to_set
56
+ end
57
+ end
58
+
59
+ describe "#siblings" do
60
+ it "returns other nodes with the same parent" do
61
+ @victoria.siblings.should == [@nsw]
62
+ end
63
+ end
64
+
65
+ it "cannot be it's own parent" do
66
+ lambda do
67
+ @australia.update_attributes!(:parent => @australia)
68
+ end.should raise_error(ActiveRecord::RecordInvalid)
69
+ end
70
+
71
+ it "cannot be it's own ancestor" do
72
+ lambda do
73
+ @australia.update_attributes!(:parent => @melbourne)
74
+ end.should raise_error(ActiveRecord::RecordInvalid)
75
+ end
76
+
77
+ end
78
+
79
+ describe "root node" do
80
+
81
+ describe "#parent" do
82
+ it "returns nil" do
83
+ @australia.parent.should == nil
84
+ end
85
+ end
86
+
87
+ describe "#ancestors" do
88
+ it "is empty" do
89
+ @australia.ancestors.should be_empty
90
+ end
91
+ end
92
+
93
+ describe "#ancestry_string" do
94
+ it "is a single dash" do
95
+ @australia.ancestry_string.should == "-"
96
+ end
97
+ end
98
+
99
+ describe "#path_string" do
100
+ it "contains only the id of the root" do
101
+ @australia.path_string.should == "-#{@australia.id}-"
102
+ end
103
+ end
104
+
105
+ describe "#descendants" do
106
+
107
+ it "includes children" do
108
+ @australia.descendants.should include(@victoria)
109
+ @australia.descendants.should include(@nsw)
110
+ end
111
+
112
+ it "includes grand-children" do
113
+ @australia.descendants.should include(@melbourne)
114
+ @australia.descendants.should include(@sydney)
115
+ end
116
+
117
+ it "excludes self" do
118
+ @australia.descendants.should_not include(@australia)
119
+ end
120
+
121
+ end
122
+
123
+ describe "#subtree" do
124
+
125
+ it "includes children" do
126
+ @australia.subtree.should include(@victoria)
127
+ @australia.subtree.should include(@nsw)
128
+ end
129
+
130
+ it "includes grand-children" do
131
+ @australia.subtree.should include(@melbourne)
132
+ @australia.subtree.should include(@sydney)
133
+ end
134
+
135
+ it "includes self" do
136
+ @australia.subtree.should include(@australia)
137
+ end
138
+
139
+ end
140
+
141
+ describe "#root" do
142
+
143
+ it "is itself" do
144
+ @australia.root.should == @australia
145
+ end
146
+
147
+ end
148
+
149
+ end
150
+
151
+ describe "leaf node" do
152
+
153
+ describe "#ancestry_string" do
154
+ it "contains ids of all ancestors" do
155
+ @melbourne.ancestry_string.should == "-#{@australia.id}-#{@victoria.id}-"
156
+ end
157
+ end
158
+
159
+ describe "#path_string" do
160
+ it "contains ids of all ancestors, plus self" do
161
+ @melbourne.path_string.should == "-#{@australia.id}-#{@victoria.id}-#{@melbourne.id}-"
162
+ end
163
+ end
164
+
165
+ describe "#ancestors" do
166
+
167
+ it "returns all ancestors, depth-first" do
168
+ @melbourne.ancestors.all.should == [@australia, @victoria]
169
+ end
170
+
171
+ end
172
+
173
+ describe "#children" do
174
+ it "returns an empty collection" do
175
+ @melbourne.children.should be_empty
176
+ end
177
+ end
178
+
179
+ describe "#descendants" do
180
+ it "returns an empty collection" do
181
+ @melbourne.children.should be_empty
182
+ end
183
+ end
184
+
185
+ describe "#root" do
186
+
187
+ it "is the root of the tree" do
188
+ @melbourne.root.should == @australia
189
+ end
190
+
191
+ end
192
+
193
+ end
194
+
195
+ describe ".roots" do
196
+
197
+ it "returns root nodes" do
198
+ @nz = Node.create!(:name => "New Zealand")
199
+ Node.roots.to_set.should == [@australia, @nz].to_set
200
+ end
201
+
202
+ end
203
+
204
+ describe "when a node changes parent" do
205
+
206
+ before do
207
+ @box_hill = Node.create!(:name => "Box Hill", :parent => @melbourne)
208
+ @nz = Node.create!(:name => "New Zealand")
209
+ @victoria.update_attributes!(:parent => @nz)
210
+ end
211
+
212
+ describe "each descendant" do
213
+
214
+ it "follows" do
215
+
216
+ @melbourne.reload
217
+ @melbourne.ancestors.should include(@nz, @victoria)
218
+ @melbourne.ancestors.should_not include(@australia)
219
+
220
+ @box_hill.reload
221
+ @box_hill.ancestors.should include(@nz, @victoria, @melbourne)
222
+ @box_hill.ancestors.should_not include(@australia)
223
+
224
+ end
225
+
226
+ end
227
+
228
+ end
229
+
230
+ describe ".rebuild_ancestry" do
231
+
232
+ before do
233
+ Node.connection.update("UPDATE nodes SET ancestry_string = 'corrupt'")
234
+ Node.rebuild_ancestry
235
+ end
236
+
237
+ it "re-populates all ancestry_strings" do
238
+ Node.count(:conditions => {:ancestry_string => 'corrupt'}).should == 0
239
+ end
240
+
241
+ it "fixes the hierarchy" do
242
+ @melbourne.reload.ancestors.should == [@australia, @victoria]
243
+ @sydney.reload.ancestors.should == [@australia, @nsw]
244
+ end
245
+
246
+ end
247
+
248
+ end
249
+
250
+