closure_tree 3.7.3 → 3.8.0
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/README.md +35 -0
- data/lib/closure_tree.rb +0 -1
- data/lib/closure_tree/acts_as_tree.rb +12 -564
- data/lib/closure_tree/columns.rb +102 -0
- data/lib/closure_tree/deterministic_ordering.rb +39 -0
- data/lib/closure_tree/model.rb +373 -0
- data/lib/closure_tree/numeric_deterministic_ordering.rb +65 -0
- data/lib/closure_tree/version.rb +1 -1
- data/lib/closure_tree/with_advisory_lock.rb +18 -0
- data/spec/label_spec.rb +36 -3
- metadata +180 -182
@@ -0,0 +1,102 @@
|
|
1
|
+
module ClosureTree
|
2
|
+
|
3
|
+
# Mixed into both classes and instances to provide easy access to the column names
|
4
|
+
module Columns
|
5
|
+
|
6
|
+
def parent_column_name
|
7
|
+
closure_tree_options[:parent_column_name]
|
8
|
+
end
|
9
|
+
|
10
|
+
def parent_column_sym
|
11
|
+
parent_column_name.to_sym
|
12
|
+
end
|
13
|
+
|
14
|
+
def has_name?
|
15
|
+
ct_class.new.attributes.include? closure_tree_options[:name_column]
|
16
|
+
end
|
17
|
+
|
18
|
+
def name_column
|
19
|
+
closure_tree_options[:name_column]
|
20
|
+
end
|
21
|
+
|
22
|
+
def name_sym
|
23
|
+
name_column.to_sym
|
24
|
+
end
|
25
|
+
|
26
|
+
def hierarchy_table_name
|
27
|
+
# We need to use the table_name, not something like ct_class.to_s.demodulize + "_hierarchies",
|
28
|
+
# because they may have overridden the table name, which is what we want to be consistent with
|
29
|
+
# in order for the schema to make sense.
|
30
|
+
tablename = closure_tree_options[:hierarchy_table_name] ||
|
31
|
+
remove_prefix_and_suffix(ct_table_name).singularize + "_hierarchies"
|
32
|
+
|
33
|
+
ActiveRecord::Base.table_name_prefix + tablename + ActiveRecord::Base.table_name_suffix
|
34
|
+
end
|
35
|
+
|
36
|
+
def hierarchy_class_name
|
37
|
+
closure_tree_options[:hierarchy_class_name] || ct_class.to_s + "Hierarchy"
|
38
|
+
end
|
39
|
+
|
40
|
+
def quoted_hierarchy_table_name
|
41
|
+
connection.quote_table_name hierarchy_table_name
|
42
|
+
end
|
43
|
+
|
44
|
+
def quoted_parent_column_name
|
45
|
+
connection.quote_column_name parent_column_name
|
46
|
+
end
|
47
|
+
|
48
|
+
def order_option
|
49
|
+
closure_tree_options[:order]
|
50
|
+
end
|
51
|
+
|
52
|
+
def with_order_option(options)
|
53
|
+
order_option ? options.merge(:order => order_option) : options
|
54
|
+
end
|
55
|
+
|
56
|
+
def append_order(order_by)
|
57
|
+
order_option ? "#{order_by}, #{order_option}" : order_by
|
58
|
+
end
|
59
|
+
|
60
|
+
def order_is_numeric
|
61
|
+
# The table might not exist yet (in the case of ActiveRecord::Observer use, see issue 32)
|
62
|
+
return false if order_option.nil? || !self.table_exists?
|
63
|
+
c = ct_class.columns_hash[order_option]
|
64
|
+
c && c.type == :integer
|
65
|
+
end
|
66
|
+
|
67
|
+
def ct_class
|
68
|
+
(self.is_a?(Class) ? self : self.class)
|
69
|
+
end
|
70
|
+
|
71
|
+
# This is the "topmost" class. This will only potentially not be ct_class if you are using STI.
|
72
|
+
def ct_base_class
|
73
|
+
ct_class.closure_tree_options[:ct_base_class]
|
74
|
+
end
|
75
|
+
|
76
|
+
def ct_subclass?
|
77
|
+
ct_class != ct_class.base_class
|
78
|
+
end
|
79
|
+
|
80
|
+
def ct_attribute_names
|
81
|
+
@ct_attr_names ||= ct_class.new.attributes.keys - ct_class.protected_attributes.to_a
|
82
|
+
end
|
83
|
+
|
84
|
+
def ct_has_type?
|
85
|
+
ct_attribute_names.include? 'type'
|
86
|
+
end
|
87
|
+
|
88
|
+
def ct_table_name
|
89
|
+
ct_class.table_name
|
90
|
+
end
|
91
|
+
|
92
|
+
def quoted_table_name
|
93
|
+
connection.quote_table_name ct_table_name
|
94
|
+
end
|
95
|
+
|
96
|
+
def remove_prefix_and_suffix(table_name)
|
97
|
+
prefix = Regexp.escape(ActiveRecord::Base.table_name_prefix)
|
98
|
+
suffix = Regexp.escape(ActiveRecord::Base.table_name_suffix)
|
99
|
+
table_name.gsub(/^#{prefix}(.+)#{suffix}$/, "\\1")
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module ClosureTree
|
2
|
+
module DeterministicOrdering
|
3
|
+
def order_column
|
4
|
+
o = order_option
|
5
|
+
o.split(' ', 2).first if o
|
6
|
+
end
|
7
|
+
|
8
|
+
def require_order_column
|
9
|
+
raise ":order value, '#{order_option}', isn't a column" if order_column.nil?
|
10
|
+
end
|
11
|
+
|
12
|
+
def order_column_sym
|
13
|
+
require_order_column
|
14
|
+
order_column.to_sym
|
15
|
+
end
|
16
|
+
|
17
|
+
def order_value
|
18
|
+
read_attribute(order_column_sym)
|
19
|
+
end
|
20
|
+
|
21
|
+
def order_value=(new_order_value)
|
22
|
+
write_attribute(order_column_sym, new_order_value)
|
23
|
+
end
|
24
|
+
|
25
|
+
def quoted_order_column(include_table_name = true)
|
26
|
+
require_order_column
|
27
|
+
prefix = include_table_name ? "#{quoted_table_name}." : ""
|
28
|
+
"#{prefix}#{connection.quote_column_name(order_column)}"
|
29
|
+
end
|
30
|
+
|
31
|
+
def siblings_before
|
32
|
+
siblings.where(["#{quoted_order_column} < ?", order_value])
|
33
|
+
end
|
34
|
+
|
35
|
+
def siblings_after
|
36
|
+
siblings.where(["#{quoted_order_column} > ?", order_value])
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,373 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
module ClosureTree
|
4
|
+
module Model
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
validate :ct_validate
|
9
|
+
before_save :ct_before_save
|
10
|
+
after_save :ct_after_save
|
11
|
+
before_destroy :ct_before_destroy
|
12
|
+
|
13
|
+
belongs_to :parent,
|
14
|
+
:class_name => ct_class.to_s,
|
15
|
+
:foreign_key => parent_column_name
|
16
|
+
|
17
|
+
unless defined?(ActiveModel::ForbiddenAttributesProtection) && ancestors.include?(ActiveModel::ForbiddenAttributesProtection)
|
18
|
+
attr_accessible :parent
|
19
|
+
end
|
20
|
+
|
21
|
+
has_many :children, with_order_option(
|
22
|
+
:class_name => ct_class.to_s,
|
23
|
+
:foreign_key => parent_column_name,
|
24
|
+
:dependent => closure_tree_options[:dependent]
|
25
|
+
)
|
26
|
+
|
27
|
+
has_many :ancestor_hierarchies,
|
28
|
+
:class_name => hierarchy_class_name,
|
29
|
+
:foreign_key => "descendant_id",
|
30
|
+
:order => "#{quoted_hierarchy_table_name}.generations asc",
|
31
|
+
:dependent => :destroy
|
32
|
+
|
33
|
+
has_many :self_and_ancestors,
|
34
|
+
:through => :ancestor_hierarchies,
|
35
|
+
:source => :ancestor,
|
36
|
+
:order => "#{quoted_hierarchy_table_name}.generations asc"
|
37
|
+
|
38
|
+
has_many :descendant_hierarchies,
|
39
|
+
:class_name => hierarchy_class_name,
|
40
|
+
:foreign_key => "ancestor_id",
|
41
|
+
:order => "#{quoted_hierarchy_table_name}.generations asc",
|
42
|
+
:dependent => :destroy
|
43
|
+
# TODO: FIXME: this collection currently ignores sort_order
|
44
|
+
# (because the quoted_table_named would need to be joined in to get to the order column)
|
45
|
+
|
46
|
+
has_many :self_and_descendants,
|
47
|
+
:through => :descendant_hierarchies,
|
48
|
+
:source => :descendant,
|
49
|
+
:order => append_order("#{quoted_hierarchy_table_name}.generations asc")
|
50
|
+
end
|
51
|
+
|
52
|
+
# Returns true if this node has no parents.
|
53
|
+
def root?
|
54
|
+
ct_parent_id.nil?
|
55
|
+
end
|
56
|
+
|
57
|
+
# Returns true if this node has a parent, and is not a root.
|
58
|
+
def child?
|
59
|
+
!parent.nil?
|
60
|
+
end
|
61
|
+
|
62
|
+
# Returns true if this node has no children.
|
63
|
+
def leaf?
|
64
|
+
children.empty?
|
65
|
+
end
|
66
|
+
|
67
|
+
# Returns the farthest ancestor, or self if +root?+
|
68
|
+
def root
|
69
|
+
self_and_ancestors.where(parent_column_name.to_sym => nil).first
|
70
|
+
end
|
71
|
+
|
72
|
+
def leaves
|
73
|
+
self_and_descendants.leaves
|
74
|
+
end
|
75
|
+
|
76
|
+
def depth
|
77
|
+
ancestors.size
|
78
|
+
end
|
79
|
+
|
80
|
+
alias :level :depth
|
81
|
+
|
82
|
+
def ancestors
|
83
|
+
without_self(self_and_ancestors)
|
84
|
+
end
|
85
|
+
|
86
|
+
def ancestor_ids
|
87
|
+
ids_from(ancestors)
|
88
|
+
end
|
89
|
+
|
90
|
+
# Returns an array, root first, of self_and_ancestors' values of the +to_s_column+, which defaults
|
91
|
+
# to the +name_column+.
|
92
|
+
# (so child.ancestry_path == +%w{grandparent parent child}+
|
93
|
+
def ancestry_path(to_s_column = name_column)
|
94
|
+
self_and_ancestors.reverse.collect { |n| n.send to_s_column.to_sym }
|
95
|
+
end
|
96
|
+
|
97
|
+
def descendants
|
98
|
+
without_self(self_and_descendants)
|
99
|
+
end
|
100
|
+
|
101
|
+
def descendant_ids
|
102
|
+
ids_from(descendants)
|
103
|
+
end
|
104
|
+
|
105
|
+
def self_and_siblings
|
106
|
+
s = ct_base_class.where(parent_column_sym => parent)
|
107
|
+
order_option.present? ? s.order(quoted_order_column) : s
|
108
|
+
end
|
109
|
+
|
110
|
+
def siblings
|
111
|
+
without_self(self_and_siblings)
|
112
|
+
end
|
113
|
+
|
114
|
+
def sibling_ids
|
115
|
+
ids_from(siblings)
|
116
|
+
end
|
117
|
+
|
118
|
+
# Alias for appending to the children collection.
|
119
|
+
# You can also add directly to the children collection, if you'd prefer.
|
120
|
+
def add_child(child_node)
|
121
|
+
children << child_node
|
122
|
+
child_node
|
123
|
+
end
|
124
|
+
|
125
|
+
# Find a child node whose +ancestry_path+ minus self.ancestry_path is +path+.
|
126
|
+
def find_by_path(path)
|
127
|
+
path = path.is_a?(Enumerable) ? path.dup : [path]
|
128
|
+
node = self
|
129
|
+
while !path.empty? && node
|
130
|
+
node = node.children.where(name_sym => path.shift).first
|
131
|
+
end
|
132
|
+
node
|
133
|
+
end
|
134
|
+
|
135
|
+
# Find a child node whose +ancestry_path+ minus self.ancestry_path is +path+
|
136
|
+
def find_or_create_by_path(path, attributes = {})
|
137
|
+
ct_with_advisory_lock do
|
138
|
+
subpath = path.is_a?(Enumerable) ? path.dup : [path]
|
139
|
+
child_name = subpath.shift
|
140
|
+
return self unless child_name
|
141
|
+
child = transaction do
|
142
|
+
attrs = {name_sym => child_name}
|
143
|
+
attrs[:type] = self.type if ct_subclass? && ct_has_type?
|
144
|
+
self.children.where(attrs).first || begin
|
145
|
+
child = self.class.new(attributes.merge(attrs))
|
146
|
+
self.children << child
|
147
|
+
child
|
148
|
+
end
|
149
|
+
end
|
150
|
+
child.find_or_create_by_path(subpath, attributes)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def find_all_by_generation(generation_level)
|
155
|
+
s = ct_base_class.joins(<<-SQL)
|
156
|
+
INNER JOIN (
|
157
|
+
SELECT descendant_id
|
158
|
+
FROM #{quoted_hierarchy_table_name}
|
159
|
+
WHERE ancestor_id = #{ct_quote(self.id)}
|
160
|
+
GROUP BY 1
|
161
|
+
HAVING MAX(#{quoted_hierarchy_table_name}.generations) = #{generation_level.to_i}
|
162
|
+
) AS descendants ON (#{quoted_table_name}.#{ct_base_class.primary_key} = descendants.descendant_id)
|
163
|
+
SQL
|
164
|
+
order_option ? s.order(order_option) : s
|
165
|
+
end
|
166
|
+
|
167
|
+
def hash_tree_scope(limit_depth = nil)
|
168
|
+
scope = self_and_descendants
|
169
|
+
if limit_depth
|
170
|
+
scope.where("#{quoted_hierarchy_table_name}.generations <= #{limit_depth - 1}")
|
171
|
+
else
|
172
|
+
scope
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def hash_tree(options = {})
|
177
|
+
self.class.build_hash_tree(hash_tree_scope(options[:limit_depth]))
|
178
|
+
end
|
179
|
+
|
180
|
+
def ct_parent_id
|
181
|
+
read_attribute(parent_column_sym)
|
182
|
+
end
|
183
|
+
|
184
|
+
def ct_validate
|
185
|
+
if changes[parent_column_name] &&
|
186
|
+
parent.present? &&
|
187
|
+
parent.self_and_ancestors.include?(self)
|
188
|
+
errors.add(parent_column_sym, "You cannot add an ancestor as a descendant")
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def ct_before_save
|
193
|
+
@was_new_record = new_record?
|
194
|
+
true # don't cancel the save
|
195
|
+
end
|
196
|
+
|
197
|
+
def ct_after_save
|
198
|
+
rebuild! if changes[parent_column_name] || @was_new_record
|
199
|
+
@was_new_record = false # we aren't new anymore.
|
200
|
+
true # don't cancel anything.
|
201
|
+
end
|
202
|
+
|
203
|
+
def rebuild!
|
204
|
+
ct_with_advisory_lock do
|
205
|
+
delete_hierarchy_references unless @was_new_record
|
206
|
+
hierarchy_class.create!(:ancestor => self, :descendant => self, :generations => 0)
|
207
|
+
unless root?
|
208
|
+
connection.execute <<-SQL
|
209
|
+
INSERT INTO #{quoted_hierarchy_table_name}
|
210
|
+
(ancestor_id, descendant_id, generations)
|
211
|
+
SELECT x.ancestor_id, #{ct_quote(id)}, x.generations + 1
|
212
|
+
FROM #{quoted_hierarchy_table_name} x
|
213
|
+
WHERE x.descendant_id = #{ct_quote(self.ct_parent_id)}
|
214
|
+
SQL
|
215
|
+
end
|
216
|
+
children.each { |c| c.rebuild! }
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
def ct_before_destroy
|
221
|
+
delete_hierarchy_references
|
222
|
+
if closure_tree_options[:dependent] == :nullify
|
223
|
+
children.each { |c| c.rebuild! }
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def delete_hierarchy_references
|
228
|
+
# The crazy double-wrapped sub-subselect works around MySQL's limitation of subselects on the same table that is being mutated.
|
229
|
+
# It shouldn't affect performance of postgresql.
|
230
|
+
# See http://dev.mysql.com/doc/refman/5.0/en/subquery-errors.html
|
231
|
+
# Also: PostgreSQL doesn't support INNER JOIN on DELETE, so we can't use that.
|
232
|
+
connection.execute <<-SQL
|
233
|
+
DELETE FROM #{quoted_hierarchy_table_name}
|
234
|
+
WHERE descendant_id IN (
|
235
|
+
SELECT DISTINCT descendant_id
|
236
|
+
FROM ( SELECT descendant_id
|
237
|
+
FROM #{quoted_hierarchy_table_name}
|
238
|
+
WHERE ancestor_id = #{ct_quote(id)}
|
239
|
+
) AS x )
|
240
|
+
OR descendant_id = #{ct_quote(id)}
|
241
|
+
SQL
|
242
|
+
end
|
243
|
+
|
244
|
+
def without_self(scope)
|
245
|
+
scope.where(["#{quoted_table_name}.#{ct_base_class.primary_key} != ?", self])
|
246
|
+
end
|
247
|
+
|
248
|
+
def ids_from(scope)
|
249
|
+
if scope.respond_to? :pluck
|
250
|
+
scope.pluck(:id)
|
251
|
+
else
|
252
|
+
scope.select(:id).collect(&:id)
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
def ct_quote(field)
|
257
|
+
self.class.connection.quote(field)
|
258
|
+
end
|
259
|
+
|
260
|
+
# TODO: _parent_id will be removed in the next major version
|
261
|
+
alias :_parent_id :ct_parent_id
|
262
|
+
|
263
|
+
module ClassMethods
|
264
|
+
def roots
|
265
|
+
where(parent_column_name => nil)
|
266
|
+
end
|
267
|
+
|
268
|
+
# Returns an arbitrary node that has no parents.
|
269
|
+
def root
|
270
|
+
roots.first
|
271
|
+
end
|
272
|
+
|
273
|
+
# There is no default depth limit. This might be crazy-big, depending
|
274
|
+
# on your tree shape. Hash huge trees at your own peril!
|
275
|
+
def hash_tree(options = {})
|
276
|
+
build_hash_tree(hash_tree_scope(options[:limit_depth]))
|
277
|
+
end
|
278
|
+
|
279
|
+
def leaves
|
280
|
+
s = joins(<<-SQL)
|
281
|
+
INNER JOIN (
|
282
|
+
SELECT ancestor_id
|
283
|
+
FROM #{quoted_hierarchy_table_name}
|
284
|
+
GROUP BY 1
|
285
|
+
HAVING MAX(#{quoted_hierarchy_table_name}.generations) = 0
|
286
|
+
) AS leaves ON (#{quoted_table_name}.#{primary_key} = leaves.ancestor_id)
|
287
|
+
SQL
|
288
|
+
order_option ? s.order(order_option) : s
|
289
|
+
end
|
290
|
+
|
291
|
+
# Rebuilds the hierarchy table based on the parent_id column in the database.
|
292
|
+
# Note that the hierarchy table will be truncated.
|
293
|
+
def rebuild!
|
294
|
+
ct_with_advisory_lock do
|
295
|
+
hierarchy_class.delete_all # not destroy_all -- we just want a simple truncate.
|
296
|
+
roots.each { |n| n.send(:rebuild!) } # roots just uses the parent_id column, so this is safe.
|
297
|
+
end
|
298
|
+
nil
|
299
|
+
end
|
300
|
+
|
301
|
+
def find_all_by_generation(generation_level)
|
302
|
+
s = joins(<<-SQL)
|
303
|
+
INNER JOIN (
|
304
|
+
SELECT #{primary_key} as root_id
|
305
|
+
FROM #{quoted_table_name}
|
306
|
+
WHERE #{quoted_parent_column_name} IS NULL
|
307
|
+
) AS roots ON (1 = 1)
|
308
|
+
INNER JOIN (
|
309
|
+
SELECT ancestor_id, descendant_id
|
310
|
+
FROM #{quoted_hierarchy_table_name}
|
311
|
+
GROUP BY 1, 2
|
312
|
+
HAVING MAX(generations) = #{generation_level.to_i}
|
313
|
+
) AS descendants ON (
|
314
|
+
#{quoted_table_name}.#{primary_key} = descendants.descendant_id
|
315
|
+
AND roots.root_id = descendants.ancestor_id
|
316
|
+
)
|
317
|
+
SQL
|
318
|
+
order_option ? s.order(order_option) : s
|
319
|
+
end
|
320
|
+
|
321
|
+
# Find the node whose +ancestry_path+ is +path+
|
322
|
+
def find_by_path(path)
|
323
|
+
subpath = path.dup
|
324
|
+
root = roots.where(name_sym => subpath.shift).first
|
325
|
+
root.find_by_path(subpath) if root
|
326
|
+
end
|
327
|
+
|
328
|
+
# Find or create nodes such that the +ancestry_path+ is +path+
|
329
|
+
def find_or_create_by_path(path, attributes = {})
|
330
|
+
subpath = path.dup
|
331
|
+
root_name = subpath.shift
|
332
|
+
ct_with_advisory_lock do
|
333
|
+
# shenanigans because find_or_create can't infer we want the same class as this:
|
334
|
+
# Note that roots will already be constrained to this subclass (in the case of polymorphism):
|
335
|
+
root = roots.where(name_sym => root_name).first
|
336
|
+
root ||= create!(attributes.merge(name_sym => root_name))
|
337
|
+
root.find_or_create_by_path(subpath, attributes)
|
338
|
+
end
|
339
|
+
end
|
340
|
+
|
341
|
+
def hash_tree_scope(limit_depth = nil)
|
342
|
+
# Deepest generation, within limit, for each descendant
|
343
|
+
# NOTE: Postgres requires HAVING clauses to always contains aggregate functions (!!)
|
344
|
+
generation_depth = <<-SQL
|
345
|
+
INNER JOIN (
|
346
|
+
SELECT descendant_id, MAX(generations) as depth
|
347
|
+
FROM #{quoted_hierarchy_table_name}
|
348
|
+
GROUP BY descendant_id
|
349
|
+
#{limit_depth ? "HAVING MAX(generations) <= #{limit_depth - 1}" : ""}
|
350
|
+
) AS generation_depth
|
351
|
+
ON #{quoted_table_name}.#{primary_key} = generation_depth.descendant_id
|
352
|
+
SQL
|
353
|
+
scoped.joins(generation_depth).order(append_order("generation_depth.depth"))
|
354
|
+
end
|
355
|
+
|
356
|
+
# Builds nested hash structure using the scope returned from the passed in scope
|
357
|
+
def build_hash_tree(tree_scope)
|
358
|
+
tree = ActiveSupport::OrderedHash.new
|
359
|
+
id_to_hash = {}
|
360
|
+
|
361
|
+
tree_scope.each do |ea|
|
362
|
+
h = id_to_hash[ea.id] = ActiveSupport::OrderedHash.new
|
363
|
+
if ea.root? || tree.empty? # We're at the top of the tree.
|
364
|
+
tree[ea] = h
|
365
|
+
else
|
366
|
+
id_to_hash[ea.ct_parent_id][ea] = h
|
367
|
+
end
|
368
|
+
end
|
369
|
+
tree
|
370
|
+
end
|
371
|
+
end
|
372
|
+
end
|
373
|
+
end
|