arboreal 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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
+