simply_couch 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +182 -0
- data/LICENSE.txt +15 -0
- data/README.md +294 -0
- data/lib/core_ext/date.rb +15 -0
- data/lib/core_ext/time.rb +23 -0
- data/lib/simply_couch/class_methods_base.rb +72 -0
- data/lib/simply_couch/has_attachment.rb +225 -0
- data/lib/simply_couch/include_relation.rb +160 -0
- data/lib/simply_couch/instance_methods.rb +356 -0
- data/lib/simply_couch/locale/en.yml +5 -0
- data/lib/simply_couch/model/ancestry.rb +307 -0
- data/lib/simply_couch/model/association_property.rb +26 -0
- data/lib/simply_couch/model/attachments.rb +90 -0
- data/lib/simply_couch/model/belongs_to.rb +140 -0
- data/lib/simply_couch/model/database.rb +209 -0
- data/lib/simply_couch/model/embedded_in.rb +196 -0
- data/lib/simply_couch/model/find_by.rb +202 -0
- data/lib/simply_couch/model/finders.rb +77 -0
- data/lib/simply_couch/model/has_and_belongs_to_many.rb +223 -0
- data/lib/simply_couch/model/has_many.rb +177 -0
- data/lib/simply_couch/model/has_many_embedded.rb +187 -0
- data/lib/simply_couch/model/has_one.rb +75 -0
- data/lib/simply_couch/model/pagination.rb +25 -0
- data/lib/simply_couch/model/pagination_options.rb +55 -0
- data/lib/simply_couch/model/persistence.rb +411 -0
- data/lib/simply_couch/model/properties.rb +11 -0
- data/lib/simply_couch/model/validations.rb +28 -0
- data/lib/simply_couch/model/view/base_view_spec.rb +115 -0
- data/lib/simply_couch/model/view/custom_view_spec.rb +49 -0
- data/lib/simply_couch/model/view/custom_views.rb +50 -0
- data/lib/simply_couch/model/view/lists.rb +25 -0
- data/lib/simply_couch/model/view/model_view_spec.rb +106 -0
- data/lib/simply_couch/model/view/properties_view_spec.rb +53 -0
- data/lib/simply_couch/model/view/raw_view_spec.rb +30 -0
- data/lib/simply_couch/model/view/view_query.rb +98 -0
- data/lib/simply_couch/model/view.rb +8 -0
- data/lib/simply_couch/model/views/array_property_view_spec.rb +26 -0
- data/lib/simply_couch/model/views/deleted_model_view_spec.rb +43 -0
- data/lib/simply_couch/model/views.rb +2 -0
- data/lib/simply_couch/model.rb +195 -0
- data/lib/simply_couch/rake.rb +23 -0
- data/lib/simply_couch/storage.rb +147 -0
- data/lib/simply_couch.rb +26 -0
- metadata +144 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
module SimplyCouch
|
|
2
|
+
module InstanceMethods
|
|
3
|
+
def initialize(attributes = {}, &blk)
|
|
4
|
+
super(_remove_protected_attributes(attributes))
|
|
5
|
+
blk.call(self) if blk
|
|
6
|
+
end
|
|
7
|
+
def ==(other)
|
|
8
|
+
other.kind_of?(SimplyCouch::Model) && other._id == _id && other._rev == _rev
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def eql?(other)
|
|
12
|
+
self.==(other)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def save(validate = true)
|
|
16
|
+
validate = validate[:validate] if validate.is_a?(Hash) && validate.has_key?(:validate)
|
|
17
|
+
result = retry_on_conflict do
|
|
18
|
+
retry_on_connection_error do
|
|
19
|
+
self.class.database.save_document(self, validate)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
# ActiveModel implementations
|
|
23
|
+
@previously_changed = changes
|
|
24
|
+
#@changed_attributes.clear
|
|
25
|
+
return result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def save!
|
|
29
|
+
result = retry_on_conflict do
|
|
30
|
+
retry_on_connection_error do
|
|
31
|
+
self.class.database.save_document!(self)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
# ActiveModel implementations
|
|
35
|
+
@previously_changed = changes
|
|
36
|
+
#@changed_attributes.clear
|
|
37
|
+
return result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def destroy(override_soft_delete=false)
|
|
41
|
+
check_and_destroy_dependents
|
|
42
|
+
if self.class.soft_deleting_enabled? && !override_soft_delete
|
|
43
|
+
# soft-delete
|
|
44
|
+
_mark_as_deleted
|
|
45
|
+
else
|
|
46
|
+
if self.class.soft_deleting_enabled? && deleted?
|
|
47
|
+
# really deleting a previously soft-deleted object - skipping callbacks
|
|
48
|
+
self.class.database.destroy_document(self, false)
|
|
49
|
+
else # deleting a normal object or a soft-deletable object that was not soft-deleted before
|
|
50
|
+
self.class.database.destroy_document(self, true)
|
|
51
|
+
end
|
|
52
|
+
freeze
|
|
53
|
+
end
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
alias :delete :destroy
|
|
57
|
+
|
|
58
|
+
def update_attributes(attributes = {})
|
|
59
|
+
parent_id_present = attributes.delete(:parent_id) || attributes.delete('parent_id')
|
|
60
|
+
self.attributes = attributes
|
|
61
|
+
if parent_id_present
|
|
62
|
+
if valid?
|
|
63
|
+
self.parent_id = parent_id_present
|
|
64
|
+
save # only for return value, revalidate etc.
|
|
65
|
+
else
|
|
66
|
+
return false
|
|
67
|
+
end
|
|
68
|
+
else
|
|
69
|
+
save
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
alias_method :update, :update_attributes
|
|
73
|
+
|
|
74
|
+
def attributes=(attr)
|
|
75
|
+
super(_remove_protected_attributes(attr))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reload
|
|
79
|
+
@children = nil
|
|
80
|
+
@descendants = nil
|
|
81
|
+
instance = self.class.find(_id, :with_deleted => true)
|
|
82
|
+
instance.attributes.each do |attribute, value|
|
|
83
|
+
send "#{attribute}=", value
|
|
84
|
+
end
|
|
85
|
+
self._rev = instance._rev
|
|
86
|
+
self._attachments = instance._attachments # CouchDB internal, not a property
|
|
87
|
+
clear_changes_information
|
|
88
|
+
reset_association_caches
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def deleted?
|
|
93
|
+
if self.class.soft_deleting_enabled?
|
|
94
|
+
!send(self.class.soft_delete_attribute).nil?
|
|
95
|
+
else
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def serializable_hash(*)
|
|
101
|
+
{'id' => self.id}.merge attributes
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
protected
|
|
105
|
+
|
|
106
|
+
def retry_on_connection_error(max_retries = 2, &blk)
|
|
107
|
+
retry_count = 0
|
|
108
|
+
begin
|
|
109
|
+
blk.call
|
|
110
|
+
rescue Errno::ECONNREFUSED => e
|
|
111
|
+
if retry_count < max_retries
|
|
112
|
+
retry_count += 1
|
|
113
|
+
retry
|
|
114
|
+
else
|
|
115
|
+
raise e
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def retry_on_conflict(max_retries = 2, &blk)
|
|
121
|
+
retry_count = 0
|
|
122
|
+
begin
|
|
123
|
+
_reset_conflict_information
|
|
124
|
+
blk.call
|
|
125
|
+
rescue SimplyCouch::Conflict => e
|
|
126
|
+
if self.class.auto_conflict_resolution_on_save && retry_count < max_retries && try_to_merge_conflict
|
|
127
|
+
retry_count += 1
|
|
128
|
+
retry
|
|
129
|
+
else
|
|
130
|
+
_decorate_with_conflict_details(e) if e.is_a?(SimplyCouch::Conflict)
|
|
131
|
+
raise e
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def _decorate_with_conflict_details(exception)
|
|
137
|
+
if @_conflict_information.present?
|
|
138
|
+
def exception.metaclass
|
|
139
|
+
class << self
|
|
140
|
+
self
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
local_conflict_information = @_conflict_information
|
|
144
|
+
exception.metaclass.send(:define_method, :message){ "409 Conflict - conflict on attributes: #{local_conflict_information.inspect}" }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def try_to_merge_conflict
|
|
149
|
+
original = self.class.find(id)
|
|
150
|
+
our_attributes = self.attributes.dup
|
|
151
|
+
their_attributes = original.attributes.dup
|
|
152
|
+
_clear_non_relevant_attributes(our_attributes)
|
|
153
|
+
_clear_non_relevant_attributes(their_attributes)
|
|
154
|
+
if _merge_possible?(our_attributes, their_attributes)
|
|
155
|
+
_copy_non_conflicting_attributes(our_attributes, their_attributes)
|
|
156
|
+
self._rev = original._rev
|
|
157
|
+
@_document['_rev'] = original._rev if @_document # keep CouchDB cache in sync
|
|
158
|
+
true
|
|
159
|
+
else
|
|
160
|
+
@_conflict_information = _conflicting_attributes(our_attributes, their_attributes)
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def _reset_conflict_information
|
|
166
|
+
@_conflict_information = nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def _clear_non_relevant_attributes(attr_list)
|
|
170
|
+
[:updated_at, :created_at, :id, :rev, :_id, :_rev].each do |skipped_attribute|
|
|
171
|
+
attr_list.delete(skipped_attribute)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def _copy_non_conflicting_attributes(our_attributes, their_attributes)
|
|
176
|
+
their_attributes.each do |attr_name, their_value|
|
|
177
|
+
if !self.public_send("#{attr_name}_changed?") && our_attributes[attr_name] != their_value
|
|
178
|
+
self.public_send("#{attr_name}=", their_value)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def _merge_possible?(our_attributes, their_attributes)
|
|
184
|
+
_conflicting_attributes(our_attributes, their_attributes).empty?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def _conflicting_attributes(our_attributes, their_attributes)
|
|
188
|
+
their_attributes.keys.delete_if do |attr_name|
|
|
189
|
+
_attribute_not_in_conflict?(attr_name, our_attributes[attr_name], their_attributes[attr_name])
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def _attribute_not_in_conflict?(attr_name, our_value, their_value)
|
|
194
|
+
our_value == their_value || # same
|
|
195
|
+
!self.public_send("#{attr_name}_changed?") || # we didn't change
|
|
196
|
+
self.public_send("#{attr_name}_changed?") && their_value == self.public_send("#{attr_name}_was") # we changed and they kept the original
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def reset_association_caches
|
|
200
|
+
self.class.properties.each do |property|
|
|
201
|
+
if property.respond_to?(:association?) && property.association?
|
|
202
|
+
instance_variable_set("@#{property.name}", nil)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def _remove_protected_attributes(attrs)
|
|
208
|
+
return {} if attrs.blank?
|
|
209
|
+
attrs = attrs.dup.stringify_keys
|
|
210
|
+
(self.class.instance_variable_get(:@_protected_attributes) || []).map(&:to_s).each do |protected_attribute|
|
|
211
|
+
attrs.delete(protected_attribute)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
accessible_attributes = (self.class.instance_variable_get(:@_accessible_attributes) || []).map(&:to_s)
|
|
215
|
+
|
|
216
|
+
if accessible_attributes.present?
|
|
217
|
+
attrs.each do |attr_key, attr_value|
|
|
218
|
+
attrs.delete(attr_key) unless accessible_attributes.include?(attr_key)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
attrs
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def check_and_destroy_dependents
|
|
226
|
+
self.class.properties.each do |property|
|
|
227
|
+
if property.respond_to?(:association?) and property.association?
|
|
228
|
+
if property.is_a?(SimplyCouch::Model::HasAndBelongsToMany::Property)
|
|
229
|
+
has_and_belongs_to_many_clean_up_after_destroy(property)
|
|
230
|
+
else
|
|
231
|
+
next unless property.options[:dependent]
|
|
232
|
+
next if property.options[:through]
|
|
233
|
+
dependents = send(property.name, :force_reload => true)
|
|
234
|
+
dependents = [dependents] unless dependents.is_a?(Array)
|
|
235
|
+
dependents.reject{|d| d.nil?}.each do |dependent|
|
|
236
|
+
case property.options[:dependent]
|
|
237
|
+
when :destroy
|
|
238
|
+
dependent.destroy
|
|
239
|
+
when :ignore
|
|
240
|
+
# skip
|
|
241
|
+
else
|
|
242
|
+
# nullify
|
|
243
|
+
unless dependent.class.soft_deleting_enabled? && dependent.deleted?
|
|
244
|
+
foreign_property = self.class.foreign_property
|
|
245
|
+
foreign_property_without_namespace = foreign_property.to_s.split('__').last
|
|
246
|
+
if dependent.respond_to?("#{foreign_property}=")
|
|
247
|
+
dependent.public_send("#{foreign_property}=", nil)
|
|
248
|
+
elsif dependent.respond_to?("#{foreign_property_without_namespace}=")
|
|
249
|
+
dependent.public_send("#{foreign_property_without_namespace}=", nil)
|
|
250
|
+
end
|
|
251
|
+
dependent.save(false)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def _default_view_options(options = {})
|
|
261
|
+
view_options = {:include_docs => true, :reduce => false}
|
|
262
|
+
view_options[:descending] = options[:descending] if options[:descending]
|
|
263
|
+
if view_options[:descending]
|
|
264
|
+
view_options[:startkey] = ["#{id}\u9999"]
|
|
265
|
+
view_options[:endkey] = [id]
|
|
266
|
+
else
|
|
267
|
+
view_options[:startkey] = [id]
|
|
268
|
+
view_options[:endkey] = ["#{id}\u9999"]
|
|
269
|
+
end
|
|
270
|
+
view_options[:limit] = options[:limit] if options[:limit]
|
|
271
|
+
view_options
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def find_one_associated(from, to, options = {})
|
|
275
|
+
options = {
|
|
276
|
+
:limit => 1,
|
|
277
|
+
:descending => true
|
|
278
|
+
}.update(options)
|
|
279
|
+
find_associated(from, to, options).first
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def find_associated(from, to, options = {})
|
|
283
|
+
foreign_key = (options.delete(:foreign_key) || self.class.name.singularize.property_name.foreign_key).to_s.gsub(/_id$/, '')
|
|
284
|
+
view_options = _default_view_options(options)
|
|
285
|
+
|
|
286
|
+
if options[:with_deleted]
|
|
287
|
+
self.class.database.view(
|
|
288
|
+
self.class.get_class_from_name(from).public_send(
|
|
289
|
+
"association_#{from.to_s.singularize.property_name}_belongs_to_#{foreign_key}_with_deleted", view_options))
|
|
290
|
+
else
|
|
291
|
+
self.class.database.view(
|
|
292
|
+
self.class.get_class_from_name(from).public_send(
|
|
293
|
+
"association_#{from.to_s.singularize.property_name}_belongs_to_#{foreign_key}", view_options))
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def get_embedded(*args)
|
|
298
|
+
puts args.inspect
|
|
299
|
+
[]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def count_associated(from, to, options = {})
|
|
303
|
+
view_options = _default_view_options(options)
|
|
304
|
+
view_options[:reduce] = true
|
|
305
|
+
view_options[:include_docs] = false
|
|
306
|
+
|
|
307
|
+
if options[:with_deleted]
|
|
308
|
+
self.class.database.view(
|
|
309
|
+
self.class.get_class_from_name(from).public_send(
|
|
310
|
+
"association_#{from.to_s.singularize.property_name}_belongs_to_#{to.name.singularize.property_name}_with_deleted", view_options))
|
|
311
|
+
else
|
|
312
|
+
self.class.database.view(
|
|
313
|
+
self.class.get_class_from_name(from).public_send(
|
|
314
|
+
"association_#{from.to_s.singularize.property_name}_belongs_to_#{to.name.singularize.property_name}", view_options))
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def find_associated_via_join_view(from, to, options = {})
|
|
319
|
+
foreign_key = options.delete(:foreign_key).to_s.gsub(/_ids$/, '').pluralize
|
|
320
|
+
view_options = _default_view_options(options)
|
|
321
|
+
|
|
322
|
+
if options[:with_deleted]
|
|
323
|
+
self.class.database.view(
|
|
324
|
+
self.class.get_class_from_name(from).public_send(
|
|
325
|
+
"association_#{from.to_s.singularize.property_name}_has_and_belongs_to_many_#{to.name.pluralize.property_name}_with_deleted", view_options))
|
|
326
|
+
else
|
|
327
|
+
self.class.database.view(
|
|
328
|
+
self.class.get_class_from_name(from).public_send(
|
|
329
|
+
"association_#{from.to_s.singularize.property_name}_has_and_belongs_to_many_#{to.name.pluralize.property_name}", view_options))
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def count_associated_via_join_view(from, to, options = {})
|
|
334
|
+
view_options = _default_view_options(options)
|
|
335
|
+
view_options[:reduce] = true
|
|
336
|
+
view_options[:include_docs] = false
|
|
337
|
+
|
|
338
|
+
if options[:with_deleted]
|
|
339
|
+
self.class.database.view(
|
|
340
|
+
self.class.get_class_from_name(from).public_send(
|
|
341
|
+
"association_#{from.to_s.singularize.property_name}_has_and_belongs_to_many_#{to.name.pluralize.property_name}_with_deleted", view_options))
|
|
342
|
+
else
|
|
343
|
+
self.class.database.view(
|
|
344
|
+
self.class.get_class_from_name(from).public_send(
|
|
345
|
+
"association_#{from.to_s.singularize.property_name}_has_and_belongs_to_many_#{to.name.pluralize.property_name}", view_options))
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def _mark_as_deleted
|
|
350
|
+
run_callbacks :destroy do
|
|
351
|
+
send("#{self.class.soft_delete_attribute}=", Time.now)
|
|
352
|
+
save(false)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
module SimplyCouch
|
|
2
|
+
module Model
|
|
3
|
+
module Ancestry
|
|
4
|
+
module InstanceMethods
|
|
5
|
+
def children
|
|
6
|
+
return @children if @children
|
|
7
|
+
if root_property = self.class.ancestry_by_property
|
|
8
|
+
@children = self.class.database.view(self.class.children_view(startkey: [send(root_property), id], endkey: [send(root_property), id, {}], reduce: false))
|
|
9
|
+
else
|
|
10
|
+
@children = self.class.database.view(self.class.children_view(startkey: [id], endkey: [id, {}], reduce: false))
|
|
11
|
+
end
|
|
12
|
+
@children
|
|
13
|
+
end
|
|
14
|
+
def children=(val)
|
|
15
|
+
@old_descendants = descendants
|
|
16
|
+
@children = val
|
|
17
|
+
@children.map(&:subtree) # preload children hierarchy
|
|
18
|
+
@new_descendants = []
|
|
19
|
+
for child in @children
|
|
20
|
+
for descendant in child.descendants
|
|
21
|
+
@new_descendants << descendant
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
@old_descendants.each{|d| d.instance_variable_set('@parent', nil); d.path_ids = [d.id]} # old descendants become root
|
|
25
|
+
(@old_descendants - @new_descendants).map(&:save) # Persist old descendants not present in new descendants
|
|
26
|
+
|
|
27
|
+
# update by_property of children if it differs from parent (locale or ... orther field is required to have the same values)
|
|
28
|
+
if root_property = self.class.ancestry_by_property
|
|
29
|
+
self_by_property = send(root_property)
|
|
30
|
+
for child in @children | @new_descendants
|
|
31
|
+
child.send("#{root_property}=", self_by_property) if self_by_property != child.send(root_property)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
self.class.set_parent(self, @children) # recurring update of parent in subtree of children
|
|
36
|
+
@descendants = (@children | @new_descendants)
|
|
37
|
+
clear_cached_ancestors
|
|
38
|
+
|
|
39
|
+
# Return wether all children can be saved :)
|
|
40
|
+
(@descendants).map(&:save).all?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# reset cache attributes for ancestors
|
|
44
|
+
def clear_cached_ancestors
|
|
45
|
+
ansi = self
|
|
46
|
+
while ancestor = ansi.instance_variable_get('@parent').presence
|
|
47
|
+
ancestor.instance_variable_set('@descendants', nil)
|
|
48
|
+
ancestor.instance_variable_set('@children', nil)
|
|
49
|
+
ansi = ancestor
|
|
50
|
+
end
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def add_child(child)
|
|
55
|
+
unless children.include?(child)
|
|
56
|
+
@children ||= []
|
|
57
|
+
@children += [child]
|
|
58
|
+
child.parent = self
|
|
59
|
+
|
|
60
|
+
# update by_property of child if it differs from parent
|
|
61
|
+
if root_property = self.class.ancestry_by_property
|
|
62
|
+
child.send("#{root_property}=", send(root_property)) if send(root_property) != child.send(root_property)
|
|
63
|
+
end
|
|
64
|
+
@descendants = nil # reload descendants if requested
|
|
65
|
+
end
|
|
66
|
+
child
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get all descendants
|
|
70
|
+
def descendants
|
|
71
|
+
return @descendants if @descendants
|
|
72
|
+
return @descendants = [] if id.blank?
|
|
73
|
+
if root_property = self.class.ancestry_by_property
|
|
74
|
+
@descendants = self.class.database.view(self.class.subtree_view(startkey: [send(root_property), id], endkey: [send(root_property), id, {}], reduce: false)).sort_by!{|d| [d.path_ids.size, d.position]}
|
|
75
|
+
else
|
|
76
|
+
@descendants = self.class.database.view(self.class.subtree_view(startkey: [id], endkey: [id, {}], reduce: false)).sort_by{|d| [d.path_ids.size, d.position]}
|
|
77
|
+
end
|
|
78
|
+
@children = self.class.build_tree(@descendants)
|
|
79
|
+
@descendants
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Find subtree of a given page and set children with the result (important to get same children object ids as in descendants)
|
|
83
|
+
def subtree
|
|
84
|
+
@children = self.class.build_tree(descendants)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Triggered before save
|
|
88
|
+
def update_tree_path
|
|
89
|
+
return false unless id
|
|
90
|
+
newpath = ( (parent && parent.path_ids) || []) + [id]
|
|
91
|
+
if path_ids != newpath
|
|
92
|
+
path_ids_will_change!
|
|
93
|
+
self.path_ids = newpath
|
|
94
|
+
end
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Triggered after create, because needs id
|
|
99
|
+
def create_tree_path
|
|
100
|
+
return false unless id
|
|
101
|
+
newpath = ( (parent && parent.path_ids) || []) + [id]
|
|
102
|
+
return true if path_ids == newpath
|
|
103
|
+
self.path_ids = newpath
|
|
104
|
+
save
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parent
|
|
108
|
+
return @parent if @parent
|
|
109
|
+
return @parent = self.class.find(parent_id) if parent_id.present?
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def parent=(val)
|
|
114
|
+
if @parent != val
|
|
115
|
+
@parent = val
|
|
116
|
+
update_tree_path
|
|
117
|
+
@parent.children += [self] unless @parent.children.include?(self)
|
|
118
|
+
save
|
|
119
|
+
end
|
|
120
|
+
val
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parent_id=(val)
|
|
124
|
+
if parent_id != val.presence
|
|
125
|
+
@parent = nil
|
|
126
|
+
@parent_id = val.presence
|
|
127
|
+
subtree if persisted?
|
|
128
|
+
path_ids_will_change!
|
|
129
|
+
if @parent_id
|
|
130
|
+
@parent = parent
|
|
131
|
+
self.path_ids = parent.path_ids + [id]
|
|
132
|
+
self.class.set_parent(self, @children) if persisted?
|
|
133
|
+
else
|
|
134
|
+
self.path_ids = [id]
|
|
135
|
+
self.class.set_parent(self, @children) if persisted?
|
|
136
|
+
end
|
|
137
|
+
@descendants.map(&:save) if persisted? && save
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def parent_id
|
|
142
|
+
@parent_id ||= parent_ids.last
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parent_ids
|
|
146
|
+
(path_ids || [])[0..-2]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def ancestors
|
|
150
|
+
return [] unless parent_ids.any?
|
|
151
|
+
return [parent] if parent_ids.size == 1 # optimization, parent is pre-loaded many times
|
|
152
|
+
(self.class.database.couchrest_database.bulk_load(parent_ids)['rows'] || []).map{|h| h['doc']}.compact
|
|
153
|
+
end
|
|
154
|
+
def parents
|
|
155
|
+
ancestors
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def tree_path
|
|
159
|
+
ancestors + [self]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def tree_depth
|
|
163
|
+
(path_ids || [nil]).size - 1
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
module TreeBuilder
|
|
168
|
+
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Add ancestry logic to your model
|
|
172
|
+
# class Page
|
|
173
|
+
# include SimplyCouch::Model
|
|
174
|
+
# has_ancestry
|
|
175
|
+
# end
|
|
176
|
+
# or
|
|
177
|
+
# class Page
|
|
178
|
+
# include SimplyCouch::Model
|
|
179
|
+
# property :locale
|
|
180
|
+
# has_ancestry by_property: :locale
|
|
181
|
+
# end
|
|
182
|
+
def has_ancestry(options = {})
|
|
183
|
+
@ancestry_by_property = nil
|
|
184
|
+
def self.ancestry_by_property
|
|
185
|
+
@ancestry_by_property
|
|
186
|
+
end
|
|
187
|
+
options = {order_by: :position}.merge(options)
|
|
188
|
+
order_by = case options[:order_by]
|
|
189
|
+
when Symbol then "doc['#{options[:order_by]}']"
|
|
190
|
+
when Array then "[#{options[:order_by].map{|o| "doc['#{o}']"}.join(', ')}]"
|
|
191
|
+
else "doc['position']"
|
|
192
|
+
end
|
|
193
|
+
property :path_ids, type: Array, default: []
|
|
194
|
+
property :position, type: Integer, default: 0
|
|
195
|
+
if options[:by_property].present?
|
|
196
|
+
property options[:by_property] unless property_names.include?(options[:by_property])
|
|
197
|
+
@ancestry_by_property = options[:by_property]
|
|
198
|
+
by_property_view_prefix = options[:by_property].present? ? "doc['#{options[:by_property]}'], " : ''
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
view :subtree_view, type: :custom, include_docs: true, map_function: %|function(doc){
|
|
202
|
+
if(doc['ruby_class'] == '#{name}' && doc.path_ids){
|
|
203
|
+
for(var i = 0; i < doc.path_ids.length - 1; i++){
|
|
204
|
+
emit([#{by_property_view_prefix}doc.path_ids[i], #{order_by}], 1);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}|, reduce_function: "_sum"
|
|
208
|
+
|
|
209
|
+
view :children_view, type: :custom, include_docs: true, map_function: %|function(doc){
|
|
210
|
+
if(doc['ruby_class'] == '#{name}' && doc.path_ids){
|
|
211
|
+
emit([#{by_property_view_prefix}doc.path_ids.slice(-2,-1)[0], #{order_by}], 1);
|
|
212
|
+
}
|
|
213
|
+
}|, reduce_function: "_sum"
|
|
214
|
+
view :roots_view, conditions: "doc.path_ids && doc.path_ids.length == 1", key: [options[:by_property].presence, options[:order_by]].compact
|
|
215
|
+
include SimplyCouch::Model::Ancestry::InstanceMethods
|
|
216
|
+
extend SimplyCouch::Model::Ancestry::ClassMethods
|
|
217
|
+
before_update :update_tree_path
|
|
218
|
+
after_create :create_tree_path
|
|
219
|
+
end
|
|
220
|
+
module ClassMethods
|
|
221
|
+
def roots(options = {})
|
|
222
|
+
if root_property = ancestry_by_property
|
|
223
|
+
if options.blank?
|
|
224
|
+
options = {}
|
|
225
|
+
elsif options.is_a?(Symbol)
|
|
226
|
+
options = {startkey: [options.to_s], endkey: [options.to_s, {}]}
|
|
227
|
+
elsif options.is_a?(String)
|
|
228
|
+
options = {startkey: [options], endkey: [options, {}]}
|
|
229
|
+
elsif options.keys.include?(root_property)
|
|
230
|
+
root_key = options.delete(root_property)
|
|
231
|
+
options[:startkey] = [root_key.to_s]
|
|
232
|
+
options[:endkey] = [root_key.to_s, {}]
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
options[:reduce] = false
|
|
236
|
+
database.view(roots_view(options))
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def full_tree(options = {})
|
|
240
|
+
if root_property = ancestry_by_property
|
|
241
|
+
if options.blank?
|
|
242
|
+
records = all
|
|
243
|
+
elsif options.is_a?(Array)
|
|
244
|
+
records = options
|
|
245
|
+
elsif (options.is_a?(Symbol) || options.is_a?(String))
|
|
246
|
+
records = send("find_all_by_#{root_property}", options.to_s)
|
|
247
|
+
elsif options.is_a?(Hash) && options.keys.include?(root_property)
|
|
248
|
+
root_key = options.delete(root_property)
|
|
249
|
+
records = send("find_all_by_#{root_property}", root_key.to_s)
|
|
250
|
+
else
|
|
251
|
+
records = options[:records].presence || all
|
|
252
|
+
end
|
|
253
|
+
else
|
|
254
|
+
records = options.is_a?(Array) ? options : (options.present? && options[:records].presence) || all
|
|
255
|
+
end
|
|
256
|
+
build_tree(records) #.first.children
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Build a tree from a flat set of pages making use of the path attribute
|
|
260
|
+
def build_tree(pages = nil)
|
|
261
|
+
pages ||= all
|
|
262
|
+
return pages if pages.empty? # Do not process empty array
|
|
263
|
+
@tree_wrapper = Struct.new(:children).new([]) # Dummy container as traversing begin, contains roots as children
|
|
264
|
+
old_tree_slice = @tree_wrapper.children
|
|
265
|
+
new_tree_slice = []
|
|
266
|
+
pages.sort_by!{|p| [p.path_ids.size, p.position]}
|
|
267
|
+
root_depth = pages.first.path_ids.size
|
|
268
|
+
current_depth = root_depth + 1 # Start counting/swapping from one depth deeper than first one
|
|
269
|
+
for page in pages
|
|
270
|
+
old_tree_slice << page and next if page.path_ids.size == root_depth # fill first slice with roots
|
|
271
|
+
|
|
272
|
+
# Move further if new depth is reached
|
|
273
|
+
if page.path_ids.size > current_depth
|
|
274
|
+
old_tree_slice = new_tree_slice
|
|
275
|
+
new_tree_slice = []
|
|
276
|
+
current_depth = page.path_ids.size
|
|
277
|
+
end
|
|
278
|
+
parent = old_tree_slice.find{|r| r.id == page.path_ids[-2]} # path id before last is parent id
|
|
279
|
+
next unless parent # page is not associated in tree
|
|
280
|
+
|
|
281
|
+
# Initialize children if needed
|
|
282
|
+
parent.instance_variable_set('@children', []) unless parent.instance_variable_get('@children').is_a?(Array)
|
|
283
|
+
|
|
284
|
+
# Avoid database call on deepest children
|
|
285
|
+
page.instance_variable_set('@children', []) unless page.instance_variable_get('@children').is_a?(Array)
|
|
286
|
+
parent.instance_variable_get('@children') << page
|
|
287
|
+
page.instance_variable_set('@parent', parent)
|
|
288
|
+
page.instance_variable_set('@parent_id', parent.id)
|
|
289
|
+
new_tree_slice << page
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
#TODO: setting @descendants from cache as option to avoid database call when descendants is required
|
|
293
|
+
@tree_wrapper.children
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Recursive set parents
|
|
297
|
+
def set_parent(parent, children)
|
|
298
|
+
for child in children.sort_by!(&:position)
|
|
299
|
+
child.instance_variable_set('@parent', parent)
|
|
300
|
+
child.update_tree_path
|
|
301
|
+
set_parent child, child.children
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module SimplyCouch
|
|
2
|
+
module Model
|
|
3
|
+
class AssociationProperty
|
|
4
|
+
attr_reader :name, :options
|
|
5
|
+
|
|
6
|
+
def dirty?(object)
|
|
7
|
+
false
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def build(object, json)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def serialize(json, object)
|
|
14
|
+
end
|
|
15
|
+
alias :value :serialize
|
|
16
|
+
|
|
17
|
+
def supports_dirty?
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def association?
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|