Blob of strokedb-ruby/lib/document/document.rb (raw blob data)

1 module StrokeDB
2 # Raised on unexisting document access.
3 #
4 # Example:
5 #
6 # document.slot_that_does_not_exist_ever
7 #
8 class SlotNotFoundError < StandardError
9 attr_reader :slotname
10 def initialize(slotname)
11 @slotname = slotname
12 end
13 def message
14 "SlotNotFoundError: Can't find slot #{@slotname}"
15 end
16 end
17
18 # Document is one of the core classes. It is being used to represent database document.
19 #
20 # Database document is an entity that:
21 #
22 # * is uniquely identified with UUID
23 # * has a number of slots, where each slot is a key-value pair (whereas pair could be a JSON object)
24 #
25 # Here is a simplistic example of document:
26 #
27 # <tt>1e3d02cc-0769-4bd8-9113-e033b246b013:</tt>
28 # name: "My Document"
29 # language: "English"
30 # authors: ["Yurii Rashkovskii","Oleg Andreev"]
31 #
32 class Document
33
34 attr_reader :store, :callbacks #:nodoc:
35
36 def marshal_dump #:nodoc:
37 (@new ? '1' : '0') + (@saved ? '1' : '0') + to_raw.to_json
38 end
39 def marshal_load(content) #:nodoc:
40 @callbacks = {}
41 initialize_raw_slots(JSON.parse(content[2,content.length]))
42 @saved = content[1,1] == '1'
43 @new = content[0,1] == '1'
44 end
45
46 # Collection of meta documents
47 class Metas < Array #:nodoc:
48 def initialize(document)
49 @document = document
50 _meta = document[:meta]
51 concat [_meta].flatten.compact.map{|v| v.is_a?(DocumentReferenceValue) ? v.load : v}
52 end
53
54 def <<(meta)
55 _module = nil
56 case meta
57 when Document
58 push meta
59 _module = StrokeDB::Document.collect_meta_modules(@document.store,meta).first
60 when Meta
61 push meta.document(@document.store)
62 _module = meta
63 else
64 raise ArgumentError.new("Meta should be either document or meta module")
65 end
66 if _module
67 @document.extend(_module)
68 _module.send!(:setup_callbacks,@document) rescue nil
69 @document.send!(:execute_callbacks_for, _module, :on_initialization)
70 @document.send!(:execute_callbacks_for, _module, :on_new_document) if @document.new?
71 end
72 @document[:meta] = self
73 end
74
75 end
76
77 #
78 # Instantiates new document with given arguments (which are the same as in Document#new),
79 # and saves it right away
80 #
81 def self.create!(*args,&block)
82 new(*args,&block).save!
83 end
84
85 #
86 # Instantiates new document
87 #
88 # Here are few ways to call it:
89 #
90 # Document.new(:slot_1 => slot_1_value, :slot_2 => slot_2_value)
91 #
92 # This way new document with slots <tt>slot_1</tt> and <tt>slot_2</tt> will be initialized in the
93 # default store.
94 #
95 # Document.new(store,:slot_1 => slot_1_value, :slot_2 => slot_2_value)
96 #
97 # This way new document with slots <tt>slot_1</tt> and <tt>slot_2</tt> will be initialized in the
98 # given <tt>store</tt>.
99 #
100 # Document.new({:slot_1 => slot_1_value, :slot_2 => slot_2_value},uuid)
101 #
102 # where <tt>uuid</tt> is a string with UUID. *WARNING*: this way of initializing Document should not
103 # be used unless you know what are you doing!
104 #
105 def initialize(*args,&block)
106 @initialization_block = block
107 if args.first.is_a?(Hash) || args.empty?
108 raise NoDefaultStoreError.new unless StrokeDB.default_store
109 do_initialize(StrokeDB.default_store,*args)
110 else
111 do_initialize(*args)
112 end
113 end
114
115
116 #
117 # Get slot value by its name:
118 #
119 # document[:slot_1]
120 #
121 # If slot was not found, it will return <tt>nil</tt>
122 #
123 def [](slotname)
124 if slot = @slots[slotname.to_s]
125 slot.value
126 end
127 end
128
129 #
130 # Set slot value by its name:
131 #
132 # document[:slot_1] = "some value"
133 #
134 def []=(slotname,value)
135 slotname = slotname.to_s
136 slot = @slots[slotname] || @slots[slotname] = Slot.new(self)
137 slot.value = value
138 update_version!(slotname)
139 slot.value
140 end
141
142 #
143 # Checks slot presence. Unlike Document#slotnames it allows you to find even 'virtual slots' that could be
144 # computed runtime by associations or <tt>when_slot_found</tt> callbacks
145 #
146 # document.has_slot?(:slotname)
147 #
148 def has_slot?(slotname)
149 v = send(slotname)
150 return true if v.nil? && slotnames.include?(slotname.to_s)
151 !!v
152 rescue SlotNotFoundError
153 false
154 end
155
156 #
157 # Removes slot
158 #
159 # document.remove_slot!(:slotname)
160 #
161 def remove_slot!(slotname)
162 @slots.delete slotname.to_s
163 update_version!(slotname)
164 nil
165 end
166
167 #
168 # Returns an <tt>Array</tt> of explicitely defined slots
169 #
170 # document.slotnames #=> ["version","name","language","authors"]
171 #
172 def slotnames
173 @slots.keys
174 end
175
176 #
177 # Creates Diff document from <tt>from</tt> document to this document
178 #
179 # document.diff(original_document) #=> #<StrokeDB::Diff added_slots: {"b"=>2}, from: #<Doc a: 1>, removed_slots: {"a"=>1}, to: #<Doc b: 2>, updated_slots: {}>
180 #
181 def diff(from)
182 Diff.new(store,:from => from, :to => self)
183 end
184
185 def pretty_print #:nodoc:
186 slots = to_raw.except('meta')
187 if is_a?(ImmutableDocument)
188 s = "#<(imm)"
189 else
190 s = "#<"
191 end
192 Util.catch_circular_reference(self) do
193 if self[:meta] && name = meta[:name]
194 s << "#{name} "
195 else
196 s << "Doc "
197 end
198 slots.keys.sort.each do |k|
199 if %w(version previous_version).member?(k) && v=self[k]
200 s << "#{k}: #{v.gsub(/^(0)+/,'')[0,4]}..., "
201 else
202 s << "#{k}: #{self[k].inspect}, "
203 end
204 end
205 s.chomp!(', ')
206 s.chomp!(' ')
207 s << ">"
208 s
209 end
210 s
211 rescue Util::CircularReferenceCondition
212 "#(#{(self[:meta] ? "#{meta}" : "Doc")} #{('@#'+uuid)[0,5]}...)"
213 end
214
215 alias :to_s :pretty_print
216 alias :inspect :pretty_print
217
218
219 #
220 # Returns string with Document's JSON representation
221 #
222 def to_json
223 to_raw.to_json
224 end
225
226 #
227 # Returns string with Document's XML representation
228 #
229 def to_xml(opts={})
230 to_raw.to_xml({ :root => 'document', :dasherize => true}.merge(opts))
231 end
232
233 # Primary serialization
234
235 def to_raw #:nodoc:
236 raw_slots = {}
237 @slots.each_pair do |k,v|
238 raw_slots[k.to_s] = v.to_raw
239 end
240 raw_slots
241 end
242
243 def to_optimized_raw #:nodoc:
244 __reference__
245 end
246
247 def self.from_raw(store,raw_slots,opts = {}) #:nodoc:
248 doc = new(store, raw_slots, true)
249 meta_modules = collect_meta_modules(store,raw_slots['meta'])
250 meta_modules.each do |meta_module|
251 unless doc.is_a?(meta_module)
252 doc.extend(meta_module)
253 meta_module.send!(:setup_callbacks,doc) rescue nil
254 end
255 end
256 doc.send!(:execute_callbacks,:on_initialization) unless opts[:skip_callbacks]
257 doc
258 end
259
260 #
261 # Find document(s) by:
262 #
263 # a) UUID
264 #
265 # Document.find(uuid)
266 #
267 # b) search query
268 #
269 # Document.find(:slot => "value")
270 #
271 # If first argument is Store, that particular store will be used; otherwise default store will be assumed.
272 def self.find(*args)
273 store = nil
274 if args.empty? || args.first.is_a?(String) || args.first.is_a?(Hash)
275 store = StrokeDB.default_store
276 else
277 store = args.shift
278 end
279 raise NoDefaultStoreError.new unless store
280 query = args.first
281 case query
282 when /#{UUID_RE}/
283 store.find(query)
284 when Hash
285 store.search(query)
286 else
287 raise TypeError
288 end
289 end
290
291 #
292 # Reloads head of the same document from store. All unsaved changes will be lost!
293 #
294 def reload
295 new? ? self : store.find(uuid)
296 end
297
298 #
299 # Returns <tt>true</tt> if this is a document that has never been saved.
300 #
301 def new?
302 !!@new
303 end
304
305 #
306 # Returns <tt>true</tt> if this document is a latest version of document being saved to a respective
307 # store
308 #
309 def head?
310 return false if new? || is_a?(VersionedDocument)
311 store.head_version(uuid) == version
312 end
313
314 #
315 # Saves the document
316 #
317 def save!
318 execute_callbacks :before_save
319 store.save!(self)
320 @new = false
321 @saved = true
322 execute_callbacks :after_save
323 self
324 end
325
326 #
327 # Updates slots with specified <tt>hash</tt> and returns itself.
328 #
329 def update_slots(hash)
330 hash.each do |k, v|
331 self[k] = v
332 end
333 self
334 end
335
336 #
337 # Same as update_slots, but also saves the document.
338 #
339 def update_slots!(hash)
340 update_slots(hash).save!
341 end
342
343 #
344 # Returns document's metadocument (if any). In case if document has more than one metadocument,
345 # it will combine all metadocuments into one 'virtual' metadocument
346 #
347 def meta
348 _meta = self[:meta]
349 return _meta || Document.new(@store) unless _meta.kind_of?(Array)
350 return _meta.first if _meta.size == 1
351 _metas = _meta.clone
352 collected_meta = _metas.shift.clone
353 names = []
354 names = collected_meta.name.split(',') if collected_meta && collected_meta[:name]
355 _metas.each do |next_meta|
356 next_meta = next_meta.clone
357 collected_meta += next_meta
358 names << next_meta.name if next_meta[:name]
359 end
360 collected_meta.name = names.uniq.join(',')
361 collected_meta.make_immutable!
362 end
363
364 #
365 # Instantiate a composite document
366 #
367 def +(document)
368 original, target = [to_raw,document.to_raw].map{|raw| raw.except('uuid','version','previous_version')}
369 Document.new(@store,original.merge(target).merge(:uuid => Util.random_uuid),true)
370 end
371
372 #
373 # Should be used to add metadocuments on the fly:
374 #
375 # document.metas << Buyer
376 # document.metas << Buyer.document
377 #
378 # Please not that it accept both meta modules and their documents, there is no difference
379 #
380 def metas
381 Metas.new(self)
382 end
383
384
385 #
386 # Returns document's version (which is stored in <tt>version</tt> slot)
387 #
388 def version
389 self[:version]
390 end
391
392 #
393 # Return document's uuid
394 #
395 def uuid
396 @uuid ||= self[:uuid]
397 end
398
399 #
400 # Returns document's previous version (which is stored in <tt>previous_version</tt> slot)
401 #
402 def previous_version
403 self[:previous_version]
404 end
405
406 def version=(v) #:nodoc:
407 self[:version] = v
408 end
409
410 #
411 # Returns an instance of Document::Versions
412 #
413 def versions
414 @versions ||= Versions.new(self)
415 end
416
417 def __reference__ #:nodoc:
418 "@##{uuid}.#{version}"
419 end
420
421 def ==(doc) #:nodoc:
422 case doc
423 when Document
424 doc.uuid == uuid && doc.to_raw == to_raw
425 when DocumentReferenceValue
426 self == doc.load
427 else
428 false
429 end
430 end
431
432 def make_immutable!
433 extend(ImmutableDocument)
434 self
435 end
436
437 def mutable?
438 true
439 end
440
441 def method_missing(sym,*args,&block) #:nodoc:
442 sym = sym.to_s
443 if sym.ends_with?('=')
444 send(:[]=,sym.chomp('='),*args)
445 else
446 unless slotnames.include?(sym)
447 if sym.ends_with?('?')
448 !!send(sym.chomp('?'),*args,&block)
449 else
450 raise SlotNotFoundError.new(sym) if (callbacks['when_slot_not_found']||[]).empty?
451 r = execute_callbacks(:when_slot_not_found,sym)
452 raise r if r.is_a?(SlotNotFoundError) # TODO: spec this behavior
453 r
454 end
455 else
456 send(:[],sym)
457 end
458 end
459 end
460
461 def add_callback(callback) #:nodoc:
462 self.callbacks[callback.name] ||= []
463 if callback.uid && old_cb = self.callbacks[callback.name].find{|cb| cb.uid == callback.uid}
464 self.callbacks[callback.name].delete old_cb
465 end
466 self.callbacks[callback.name] << callback
467 end
468
469 protected
470
471 def execute_callbacks(name,*args) #:nodoc:
472 val = nil
473 (callbacks[name.to_s]||[]).each do |callback|
474 val = callback.call(self,*args)
475 end
476 val
477 end
478
479 def execute_callbacks_for(origin,name,*args) #:nodoc:
480 val = nil
481 (callbacks[name.to_s]||[]).each do |callback|
482 val = callback.call(self,*args) if callback.origin == origin
483 end
484 val
485 end
486
487 def do_initialize(store, slots={}, initialize_raw = false) #:nodoc:
488 @callbacks = {}
489 @store = store
490 if initialize_raw
491 initialize_raw_slots(slots)
492 @saved = true
493 else
494 @new = true
495 initialize_slots(slots)
496 self[:uuid] = Util.random_uuid unless self[:uuid]
497 generate_new_version! unless self[:version]
498 end
499 end
500
501 def initialize_slots(slots) #:nodoc:
502 @slots = {}
503 slots.each {|name,value| self[name] = value }
504 end
505
506 def initialize_raw_slots(slots) #:nodoc:
507 @slots = {}
508 slots.each do |name,value|
509 s = Slot.new(self)
510 s.raw_value = value
511 @slots[name.to_s] = s
512 end
513 end
514
515 def self.collect_meta_modules(store,meta) #:nodoc:
516 meta_names = []
517 case meta
518 when /@##{UUID_RE}.#{VERSION_RE}/
519 if m = store.find($1,$2); meta_names << m[:name]; end
520 when /@##{UUID_RE}/
521 if m = store.find($1); meta_names << m[:name]; end
522 when Array
523 meta_names = meta.map {|m| collect_meta_modules(store,m) }.flatten
524 when Document
525 meta_names << meta[:name]
526 end
527 meta_names.collect {|m| m.is_a?(String) ? (m.constantize rescue nil) : m }.compact
528 end
529
530 def generate_new_version!
531 self.version = Util.random_uuid
532 end
533
534 def update_version!(slotname)
535 if @saved && slotname != 'version' && slotname != 'previous_version'
536 self[:previous_version] = version unless version.nil?
537 generate_new_version!
538 @saved = nil
539 end
540 end
541 end
542
543 #
544 # VersionedDocument is a module that is being added to all document's of specific version.
545 # It should not be accessed directly
546 #
547 module VersionedDocument