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 +20 -0
- data/README.rdoc +7 -0
- data/Rakefile +9 -0
- data/lib/arboreal.rb +4 -0
- data/lib/arboreal/active_record_extensions.rb +31 -0
- data/lib/arboreal/class_methods.rb +34 -0
- data/lib/arboreal/instance_methods.rb +94 -0
- data/lib/arboreal/version.rb +3 -0
- data/spec/arboreal_spec.rb +250 -0
- data/spec/spec_helper.rb +32 -0
- data/spec/test.log +8531 -0
- data/spec/test.sqlite +0 -0
- metadata +92 -0
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
data/lib/arboreal.rb
ADDED
@@ -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,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
|
+
|