mongoid-ancestry-fixes 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.
- checksums.yaml +15 -0
- data/.gitignore +42 -0
- data/.travis.yml +3 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +47 -0
- data/Guardfile +10 -0
- data/MIT-LICENSE +20 -0
- data/README.md +255 -0
- data/Rakefile +21 -0
- data/init.rb +1 -0
- data/install.rb +1 -0
- data/lib/mongoid-ancestry/class_methods.rb +212 -0
- data/lib/mongoid-ancestry/exceptions.rb +6 -0
- data/lib/mongoid-ancestry/instance_methods.rb +248 -0
- data/lib/mongoid-ancestry/version.rb +5 -0
- data/lib/mongoid-ancestry.rb +15 -0
- data/log/.gitignore +4 -0
- data/mongoid-ancestry.gemspec +29 -0
- data/spec/lib/ancestry_spec.rb +110 -0
- data/spec/lib/mongoid-ancestry/class_methods_spec.rb +300 -0
- data/spec/lib/mongoid-ancestry/instance_methods_spec.rb +241 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/support/models.rb +40 -0
- metadata +106 -0
@@ -0,0 +1,248 @@
|
|
1
|
+
module Mongoid
|
2
|
+
module Ancestry
|
3
|
+
|
4
|
+
# Validate that the ancestors don't include itself
|
5
|
+
def ancestry_exclude_self
|
6
|
+
if ancestor_ids.include? id
|
7
|
+
errors.add(:base, "#{self.class.name.humanize} cannot be a descendant of itself.")
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
# Update descendants with new ancestry
|
12
|
+
def update_descendants_with_new_ancestry
|
13
|
+
# Skip this if callbacks are disabled
|
14
|
+
unless ancestry_callbacks_disabled?
|
15
|
+
# If node is valid, not a new record and ancestry was updated ...
|
16
|
+
if changed.include?(self.base_class.ancestry_field.to_s) && !new_record? && valid?
|
17
|
+
# ... for each descendant ...
|
18
|
+
descendants.each do |descendant|
|
19
|
+
# ... replace old ancestry with new ancestry
|
20
|
+
descendant.without_ancestry_callbacks do
|
21
|
+
for_replace = \
|
22
|
+
if read_attribute(self.class.ancestry_field).blank?
|
23
|
+
id.to_s
|
24
|
+
else
|
25
|
+
"#{read_attribute self.class.ancestry_field}/#{id}"
|
26
|
+
end
|
27
|
+
new_ancestry = descendant.read_attribute(descendant.class.ancestry_field).gsub(/^#{self.child_ancestry}/, for_replace)
|
28
|
+
descendant.update_attribute(self.base_class.ancestry_field, new_ancestry)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Apply orphan strategy
|
36
|
+
def apply_orphan_strategy
|
37
|
+
# Skip this if callbacks are disabled
|
38
|
+
unless ancestry_callbacks_disabled?
|
39
|
+
# If this isn't a new record ...
|
40
|
+
unless new_record?
|
41
|
+
# ... make al children root if orphan strategy is rootify
|
42
|
+
if self.base_class.orphan_strategy == :rootify
|
43
|
+
descendants.each do |descendant|
|
44
|
+
descendant.without_ancestry_callbacks do
|
45
|
+
val = \
|
46
|
+
unless descendant.ancestry == child_ancestry
|
47
|
+
descendant.read_attribute(descendant.class.ancestry_field).gsub(/^#{child_ancestry}\//, '')
|
48
|
+
end
|
49
|
+
descendant.update_attribute descendant.class.ancestry_field, val
|
50
|
+
end
|
51
|
+
end
|
52
|
+
# ... destroy all descendants if orphan strategy is destroy
|
53
|
+
elsif self.base_class.orphan_strategy == :destroy
|
54
|
+
descendants.all.each do |descendant|
|
55
|
+
descendant.without_ancestry_callbacks { descendant.destroy }
|
56
|
+
end
|
57
|
+
# ... throw an exception if it has children and orphan strategy is restrict
|
58
|
+
elsif self.base_class.orphan_strategy == :restrict
|
59
|
+
raise Error.new('Cannot delete record because it has descendants.') unless is_childless?
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# The ancestry value for this record's children
|
66
|
+
def child_ancestry
|
67
|
+
# New records cannot have children
|
68
|
+
raise Error.new('No child ancestry for new record. Save record before performing tree operations.') if new_record?
|
69
|
+
|
70
|
+
if self.send("#{self.base_class.ancestry_field}_was").blank?
|
71
|
+
id.to_s
|
72
|
+
else
|
73
|
+
"#{self.send "#{self.base_class.ancestry_field}_was"}/#{id}"
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Scope
|
78
|
+
def current_search_scope
|
79
|
+
self.embedded? ? self._parent.send(self.base_class.to_s.tableize) : self.base_class
|
80
|
+
end
|
81
|
+
|
82
|
+
# Ancestors
|
83
|
+
def ancestor_ids
|
84
|
+
read_attribute(self.base_class.ancestry_field).to_s.split('/').map { |id| cast_primary_key(id) }
|
85
|
+
end
|
86
|
+
|
87
|
+
def ancestor_conditions
|
88
|
+
{ :_id.in => ancestor_ids }
|
89
|
+
end
|
90
|
+
|
91
|
+
def ancestors depth_options = {}
|
92
|
+
self.base_class.scope_depth(depth_options, depth).where(ancestor_conditions)
|
93
|
+
end
|
94
|
+
|
95
|
+
def path_ids
|
96
|
+
ancestor_ids + [id]
|
97
|
+
end
|
98
|
+
|
99
|
+
def path_conditions
|
100
|
+
{ :_id.in => path_ids }
|
101
|
+
end
|
102
|
+
|
103
|
+
def path depth_options = {}
|
104
|
+
self.base_class.scope_depth(depth_options, depth).where(path_conditions)
|
105
|
+
end
|
106
|
+
|
107
|
+
def depth
|
108
|
+
ancestor_ids.size
|
109
|
+
end
|
110
|
+
|
111
|
+
def cache_depth
|
112
|
+
write_attribute self.base_class.depth_cache_field, depth
|
113
|
+
end
|
114
|
+
|
115
|
+
# Parent
|
116
|
+
def parent= parent
|
117
|
+
write_attribute(self.base_class.ancestry_field, parent.blank? ? nil : parent.child_ancestry)
|
118
|
+
end
|
119
|
+
|
120
|
+
def parent_id= parent_id
|
121
|
+
self.parent = parent_id.blank? ? nil : current_search_scope.find(parent_id)
|
122
|
+
end
|
123
|
+
|
124
|
+
def parent_id
|
125
|
+
ancestor_ids.empty? ? nil : ancestor_ids.last
|
126
|
+
end
|
127
|
+
|
128
|
+
def parent
|
129
|
+
parent_id.blank? ? nil : current_search_scope.find(parent_id)
|
130
|
+
end
|
131
|
+
|
132
|
+
# Root
|
133
|
+
def root_id
|
134
|
+
(root_id == id) ? self : current_search.find(root_id)
|
135
|
+
end
|
136
|
+
|
137
|
+
def root
|
138
|
+
(root_id == id) ? self : current_search_scope.find(root_id)
|
139
|
+
end
|
140
|
+
|
141
|
+
def is_root?
|
142
|
+
read_attribute(self.base_class.ancestry_field).blank?
|
143
|
+
end
|
144
|
+
|
145
|
+
# Children
|
146
|
+
def child_conditions
|
147
|
+
{self.base_class.ancestry_field => child_ancestry}
|
148
|
+
end
|
149
|
+
|
150
|
+
def children
|
151
|
+
current_search_scope.where(child_conditions)
|
152
|
+
end
|
153
|
+
|
154
|
+
def child_ids
|
155
|
+
children.only(:_id).map(&:id)
|
156
|
+
end
|
157
|
+
|
158
|
+
def has_children?
|
159
|
+
self.children.present?
|
160
|
+
end
|
161
|
+
|
162
|
+
def is_childless?
|
163
|
+
!has_children?
|
164
|
+
end
|
165
|
+
|
166
|
+
# Siblings
|
167
|
+
def sibling_conditions
|
168
|
+
{self.base_class.ancestry_field => read_attribute(self.base_class.ancestry_field)}
|
169
|
+
end
|
170
|
+
|
171
|
+
def siblings
|
172
|
+
self.base_class.where sibling_conditions
|
173
|
+
end
|
174
|
+
|
175
|
+
def sibling_ids
|
176
|
+
siblings.only(:_id).map(&:id)
|
177
|
+
end
|
178
|
+
|
179
|
+
def has_siblings?
|
180
|
+
self.siblings.count > 1
|
181
|
+
end
|
182
|
+
|
183
|
+
def is_only_child?
|
184
|
+
!has_siblings?
|
185
|
+
end
|
186
|
+
|
187
|
+
# Descendants
|
188
|
+
def descendant_conditions
|
189
|
+
[
|
190
|
+
{ self.base_class.ancestry_field => /^#{child_ancestry}\// },
|
191
|
+
{ self.base_class.ancestry_field => child_ancestry }
|
192
|
+
]
|
193
|
+
end
|
194
|
+
|
195
|
+
def descendants depth_options = {}
|
196
|
+
self.base_class.scope_depth(depth_options, depth).any_of(descendant_conditions)
|
197
|
+
end
|
198
|
+
|
199
|
+
def descendant_ids depth_options = {}
|
200
|
+
descendants(depth_options).only(:_id).map(&:id)
|
201
|
+
end
|
202
|
+
|
203
|
+
# Subtree
|
204
|
+
def subtree_conditions
|
205
|
+
[
|
206
|
+
{ :_id => id },
|
207
|
+
{ self.base_class.ancestry_field => /^#{child_ancestry}\// },
|
208
|
+
{ self.base_class.ancestry_field => child_ancestry }
|
209
|
+
]
|
210
|
+
end
|
211
|
+
|
212
|
+
def subtree depth_options = {}
|
213
|
+
self.base_class.scope_depth(depth_options, depth).any_of(subtree_conditions)
|
214
|
+
end
|
215
|
+
|
216
|
+
def subtree_ids depth_options = {}
|
217
|
+
subtree(depth_options).only(:_id).map(&:id)
|
218
|
+
end
|
219
|
+
|
220
|
+
# Callback disabling
|
221
|
+
def without_ancestry_callbacks
|
222
|
+
@disable_ancestry_callbacks = true
|
223
|
+
yield
|
224
|
+
@disable_ancestry_callbacks = false
|
225
|
+
end
|
226
|
+
|
227
|
+
def ancestry_callbacks_disabled?
|
228
|
+
!!@disable_ancestry_callbacks
|
229
|
+
end
|
230
|
+
|
231
|
+
private
|
232
|
+
|
233
|
+
def cast_primary_key(key)
|
234
|
+
if primary_key_type == Integer
|
235
|
+
key.to_i
|
236
|
+
elsif primary_key_type == BSON::ObjectId && key =~ /[a-z0-9]{24}/
|
237
|
+
BSON::ObjectId.convert(self, key)
|
238
|
+
else
|
239
|
+
key
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
def primary_key_type
|
244
|
+
@primary_key_type ||= self.base_class.fields['_id'].options[:type]
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Mongoid
|
2
|
+
module Ancestry
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
autoload :ClassMethods, 'mongoid-ancestry/class_methods'
|
6
|
+
autoload :Error, 'mongoid-ancestry/exceptions'
|
7
|
+
|
8
|
+
included do
|
9
|
+
cattr_accessor :base_class
|
10
|
+
self.base_class = self
|
11
|
+
end
|
12
|
+
|
13
|
+
require 'mongoid-ancestry/instance_methods'
|
14
|
+
end
|
15
|
+
end
|
data/log/.gitignore
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "mongoid-ancestry/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = 'mongoid-ancestry-fixes'
|
7
|
+
s.version = '0.0.1'
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Stefan Kroes", "Anton Orel"]
|
10
|
+
s.email = ["eagle.anton@gmail.com"]
|
11
|
+
s.description = %q{Organise Mongoid model into a tree structure}
|
12
|
+
s.homepage = "http://github.com/skyeagle/mongoid-ancestry"
|
13
|
+
s.summary = %q{Ancestry allows the records of a Mongoid model to be organised in a tree structure, using a single, intuitively formatted database field. It exposes all the standard tree structure relations (ancestors, parent, root, children, siblings, descendants) and all of them can be fetched in a single query. Additional features are named_scopes, integrity checking, integrity restoration, arrangement of (sub)tree into hashes and different strategies for dealing with orphaned records.}
|
14
|
+
s.licenses = ["MIT"]
|
15
|
+
|
16
|
+
s.rubyforge_project = "mongoid-ancestry-fixes"
|
17
|
+
|
18
|
+
s.files = `git ls-files`.split("\n")
|
19
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
20
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
21
|
+
s.require_paths = ["lib"]
|
22
|
+
s.extra_rdoc_files = [
|
23
|
+
"README.md"
|
24
|
+
]
|
25
|
+
|
26
|
+
s.add_dependency('mongoid', ">= 2.0")
|
27
|
+
s.add_dependency('bson_ext', ">= 1.3")
|
28
|
+
end
|
29
|
+
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'mongoid-ancestry/exceptions'
|
4
|
+
|
5
|
+
|
6
|
+
describe MongoidAncestry do
|
7
|
+
|
8
|
+
subject { MongoidAncestry }
|
9
|
+
|
10
|
+
it "should have ancestry fields" do
|
11
|
+
subject.with_model do |model|
|
12
|
+
model.fields['ancestry'].options[:type].should eql(String)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
it "should have non default ancestry field" do
|
17
|
+
subject.with_model :ancestry_field => :alternative_ancestry do |model|
|
18
|
+
model.ancestry_field.should eql(:alternative_ancestry)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
it "should set ancestry field" do
|
23
|
+
subject.with_model do |model|
|
24
|
+
model.ancestry_field = :ancestors
|
25
|
+
model.ancestry_field.should eql(:ancestors)
|
26
|
+
model.ancestry_field = :ancestry
|
27
|
+
model.ancestry_field.should eql(:ancestry)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
it "should have default orphan strategy" do
|
32
|
+
subject.with_model do |model|
|
33
|
+
model.orphan_strategy.should eql(:destroy)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
it "should have non default orphan strategy" do
|
38
|
+
subject.with_model :orphan_strategy => :rootify do |model|
|
39
|
+
model.orphan_strategy.should eql(:rootify)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
it "should set orphan strategy" do
|
44
|
+
subject.with_model do |model|
|
45
|
+
model.orphan_strategy = :rootify
|
46
|
+
model.orphan_strategy.should eql(:rootify)
|
47
|
+
model.orphan_strategy = :destroy
|
48
|
+
model.orphan_strategy.should eql(:destroy)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
it "should not set invalid orphan strategy" do
|
53
|
+
subject.with_model do |model|
|
54
|
+
expect {
|
55
|
+
model.orphan_strategy = :non_existent_orphan_strategy
|
56
|
+
}.to raise_error Mongoid::Ancestry::Error
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
it "should setup test nodes" do
|
61
|
+
subject.with_model :depth => 3, :width => 3 do |model, roots|
|
62
|
+
roots.class.should eql(Array)
|
63
|
+
roots.length.should eql(3)
|
64
|
+
roots.each do |node, children|
|
65
|
+
node.class.should eql(model)
|
66
|
+
children.class.should eql(Array)
|
67
|
+
children.length.should eql(3)
|
68
|
+
children.each do |node, children|
|
69
|
+
node.class.should eql(model)
|
70
|
+
children.class.should eql(Array)
|
71
|
+
children.length.should eql(3)
|
72
|
+
children.each do |node, children|
|
73
|
+
node.class.should eql(model)
|
74
|
+
children.class.should eql(Array)
|
75
|
+
children.length.should eql(0)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
it "should have STI support" do
|
83
|
+
subject.with_model :extra_columns => {:type => :string} do |model|
|
84
|
+
subclass1 = Object.const_set 'Subclass1', Class.new(model)
|
85
|
+
(class << subclass1; self; end).send(:define_method, :model_name) do
|
86
|
+
Struct.new(:human, :underscore).new 'Subclass1', 'subclass1'
|
87
|
+
end
|
88
|
+
subclass2 = Object.const_set 'Subclass2', Class.new(model)
|
89
|
+
(class << subclass2; self; end).send(:define_method, :model_name) do
|
90
|
+
Struct.new(:human, :underscore).new 'Subclass1', 'subclass1'
|
91
|
+
end
|
92
|
+
|
93
|
+
node1 = subclass1.create
|
94
|
+
node2 = subclass2.create :parent => node1
|
95
|
+
node3 = subclass1.create :parent => node2
|
96
|
+
node4 = subclass2.create :parent => node3
|
97
|
+
node5 = subclass1.create :parent => node4
|
98
|
+
|
99
|
+
model.all.each do |node|
|
100
|
+
[subclass1, subclass2].include?(node.class).should be_true
|
101
|
+
end
|
102
|
+
|
103
|
+
node1.descendants.map(&:id).should eql([node2.id, node3.id, node4.id, node5.id])
|
104
|
+
node1.subtree.map(&:id).should eql([node1.id, node2.id, node3.id, node4.id, node5.id])
|
105
|
+
node5.ancestors.map(&:id).should eql([node1.id, node2.id, node3.id, node4.id])
|
106
|
+
node5.path.map(&:id).should eql([node1.id, node2.id, node3.id, node4.id, node5.id])
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|