require 'document/validations' require 'document/virtualize' require 'document/util' require 'document/meta' require 'document/associations' require 'document/callback' require 'document/coercions' require 'document/index_slots' require 'document/delete' require 'document/slot' require 'document/versions' module StrokeDB # Slots which contain references to another documents are matched # with these regexps. DOCREF = /^@##{UUID_RE}$/ VERSIONREF = /^@##{UUID_RE}\.#{VERSION_RE}$/ # # Raised on unexisting document access. # # Example: # # document.slot_that_does_not_exist_ever # class SlotNotFoundError < StandardError attr_reader :slotname def initialize(slotname) @slotname = slotname end def message "Can't find slot #{@slotname}" end def inspect "#<#{self.class.name}: #{message}>" end end # # Raised when Document#save! is called on an invalid document # (for which doc.valid? returns false) # class InvalidDocumentError < StandardError #:nodoc: attr_reader :document def initialize(document) @document = document end def message "Validation failed: #{@document.errors.messages.join(", ")}" end def inspect "#<#{self.class.name}: #{message}>" end end # Document is one of the core classes. It is being used to represent database document. # # Database document is an entity that: # # * is uniquely identified with UUID # * has a number of slots, where each slot is a key-value pair (whereas pair could be a JSON object) # # Here is a simplistic example of document: # # 1e3d02cc-0769-4bd8-9113-e033b246b013: # name: "My Document" # language: "English" # authors: ["Yurii Rashkovskii","Oleg Andreev"] # class Document include StrokeDB::Validations::InstanceMethods attr_reader :callbacks #:nodoc: def store if (txns = Thread.current[:strokedb_transactions]) && !txns.nil? && !txns.empty? txns.last else @store end end def marshal_dump #:nodoc: (@new ? '1' : '0') + (@saved ? '1' : '0') + to_raw.to_json end def marshal_load(content) #:nodoc: @callbacks = {} initialize_raw_slots(JSON.parse(content[2,content.length])) @saved = content[1,1] == '1' @new = content[0,1] == '1' end # Collection of meta documents class Metas < Array #:nodoc: def initialize(document) @document = document _meta = document[:meta] concat [_meta].flatten.compact.map{|v| v.is_a?(DocumentReferenceValue) ? v.load : v} end def <<(meta) add_meta(meta, :call_initialization_callbacks => true) end alias :_delete :delete def delete(meta) case meta when Document _delete meta _module = StrokeDB::Document.collect_meta_modules(@document.store, meta).first when Meta _delete meta.document(@document.store) _module = meta else raise ArgumentError, "Meta should be either document or meta module" end @document[:meta] = self if _module @document.unextend(_module) end end def add_meta(meta, opts = {}) opts = opts.stringify_keys _module = nil # meta can be specified both as a meta document and as a module case meta when Document push meta _module = StrokeDB::Document.collect_meta_modules(@document.store, meta).first when Meta push meta.document(@document.store) _module = meta else raise ArgumentError, "Meta should be either document or meta module" end # register meta in the document @document[:meta] = self if _module @document.extend(_module) _module.send!(:setup_callbacks, @document) rescue nil if opts['call_initialization_callbacks'] @document.send!(:execute_callbacks_for, _module, :on_initialization) @document.send!(:execute_callbacks_for, _module, :on_new_document) if @document.new? end end end end # # Instantiates new document with given arguments (which are the same as in Document#new), # and saves it right away # def self.create!(*args, &block) new(*args, &block).save! end # # Instantiates new document # # Here are few ways to call it: # # Document.new(:slot_1 => slot_1_value, :slot_2 => slot_2_value) # # This way new document with slots slot_1 and slot_2 will be initialized in the # default store. # # Document.new(store,:slot_1 => slot_1_value, :slot_2 => slot_2_value) # # This way new document with slots slot_1 and slot_2 will be initialized in the # given store. # # Document.new({:slot_1 => slot_1_value, :slot_2 => slot_2_value},uuid) # # where uuid is a string with UUID. *WARNING*: this way of initializing Document should not # be used unless you know what are you doing! # def initialize(*args, &block) @initialization_block = block if args.first.is_a?(Hash) || args.empty? raise NoDefaultStoreError unless StrokeDB.default_store do_initialize(StrokeDB.default_store, *args) else do_initialize(*args) end end # # Get slot value by its name: # # document[:slot_1] # # If slot was not found, it will return nil # def [](slotname) @slots[slotname.to_s].value rescue nil end # # Set slot value by its name: # # document[:slot_1] = "some value" # def []=(slotname, value) slotname = slotname.to_s (@slots[slotname] ||= Slot.new(self, slotname)).value = value update_version!(slotname) value end # # Checks slot presence. Unlike Document#slotnames it allows you to find even 'virtual slots' that could be # computed runtime by associations or when_slot_found callbacks # # document.has_slot?(:slotname) # def has_slot?(slotname) v = send(slotname) (v.nil? && slotnames.include?(slotname.to_s)) ? true : !!v rescue SlotNotFoundError false end # # Removes slot # # document.remove_slot!(:slotname) # def remove_slot!(slotname) slotname = slotname.to_s @slots.delete slotname update_version! slotname nil end # # Returns an Array of explicitely defined slots # # document.slotnames #=> ["version","name","language","authors"] # def slotnames @slots.keys end # # Creates Diff document from from document to this document # # document.diff(original_document) #=> #2}, from: #, removed_slots: {"a"=>1}, to: #, updated_slots: {}> # def diff(from) Diff.new(store, :from => from, :to => self) end def pretty_print #:nodoc: slots = to_raw.except('meta') s = is_a?(ImmutableDocument) ? "#<(imm)" : "#<" Util.catch_circular_reference(self) do if self[:meta] && name = meta[:name] s << "#{name} " else s << "Doc " end slots.keys.sort.each do |k| if %w(version previous_version).member?(k) && v = self[k] s << "#{k}: #{v.gsub(/^(0)+/,'')[0,4]}..., " else s << "#{k}: #{self[k].inspect}, " end end s.chomp!(', ') s.chomp!(' ') s << ">" end s rescue Util::CircularReferenceCondition "#(#{(self[:meta] ? "#{meta}" : "Doc")} #{('@#'+uuid)[0,5]}...)" end alias :to_s :pretty_print alias :inspect :pretty_print # # Returns string with Document's JSON representation # def to_json to_raw.to_json end # # Returns string with Document's XML representation # def to_xml(opts = {}) to_raw.to_xml({ :root => 'document', :dasherize => true }.merge(opts)) end # # Primary serialization # def to_raw #:nodoc: raw_slots = {} @slots.each_pair do |k,v| raw_slots[k.to_s] = v.to_raw end raw_slots end def to_optimized_raw #:nodoc: __reference__ end # # Creates a document from a serialized representation # def self.from_raw(store, raw_slots, opts = {}) #:nodoc: doc = new(store, raw_slots, true) collect_meta_modules(store, raw_slots['meta']).each do |meta_module| unless doc.is_a? meta_module doc.extend(meta_module) meta_module.send!(:setup_callbacks, doc) rescue nil end end unless opts[:skip_callbacks] doc.send! :execute_callbacks, :on_initialization doc.send! :execute_callbacks, :on_load end doc end # # Find document(s) by: # # a) UUID # # Document.find(uuid) # # b) search query # # Document.find(:slot => "value") # # If first argument is Store, that particular store will be used; otherwise default store will be assumed. def self.find(*args) store = nil if (txns = Thread.current[:strokedb_transactions]) && !txns.nil? && !txns.empty? store = txns.last else if args.empty? || args.first.is_a?(String) || args.first.is_a?(Hash) || args.first.nil? store = StrokeDB.default_store else store = args.shift end end raise NoDefaultStoreError.new unless store query = args.first case query when UUID_RE store.find(query) when Hash store.search(query) else raise ArgumentError, "use UUID or query to find document(s)" end end # # Reloads head of the same document from store. All unsaved changes will be lost! # def reload new? ? self : store.find(uuid) end # # Returns true if this is a document that has never been saved. # def new? !!@new end # # Returns true if this document is a latest version of document being saved to a respective # store # def head? return false if new? || is_a?(VersionedDocument) store.head_version(uuid) == version end # # Saves the document. If validations do not pass, InvalidDocumentError # exception is raised. # def save!(perform_validation = true) execute_callbacks :before_save if perform_validation raise InvalidDocumentError.new(self) unless valid? end execute_callbacks :after_validation store.save!(self) @new = false @saved = true execute_callbacks :after_save self end # Updates slots with a specified hash and returns itself. def update_slots(hash) hash.each do |k, v| self[k] = v end self end # Same as update_slots, but also saves the document. def update_slots!(hash) update_slots(hash).save! end # Updates nil/false slots with a specified hash and returns itself. # Already set slots are not modified (||= is used). # Acts like hash1.reverse_merge(hash2) (hash2.merge(hash1)). # def reverse_update_slots(hash) hash.each do |k, v| self[k] ||= v end self end # Same as reverse_update_slots, but also saves the document. def reverse_update_slots!(hash) reverse_update_slots(hash).save! end # # Returns document's metadocument (if any). In case if document has more than one metadocument, # it will combine all metadocuments into one 'virtual' metadocument # def meta unless (m = self[:meta]).kind_of? Array # simple case return m || Document.new(@store) end return m.first if m.size == 1 mm = m.clone collected_meta = mm.shift.clone names = collected_meta[:name].split(',') rescue [] mm.each do |next_meta| next_meta = next_meta.clone collected_meta += next_meta names << next_meta.name if next_meta[:name] end collected_meta.name = names.uniq.join(',') collected_meta.make_immutable! end # # Instantiate a composite document # def +(document) original, target = [to_raw, document.to_raw].map{ |raw| raw.except(*%w(uuid version previous_version)) } Document.new(@store, original.merge(target).merge(:uuid => Util.random_uuid), true) end # # Should be used to add metadocuments on the fly: # # document.metas << Buyer # document.metas << Buyer.document # # Please not that it accept both meta modules and their documents, there is no difference # def metas @metas ||= Metas.new(self) end # # Returns document's version (which is stored in version slot) # def version self[:version] end # # Return document's uuid # def uuid @uuid ||= self[:uuid] end def raw_uuid #:nodoc: @raw_uuid ||= uuid.to_raw_uuid end # # Returns document's previous version (which is stored in previous_version slot) # def previous_version self[:previous_version] end def version=(v) #:nodoc: self[:version] = v end # # Returns an instance of Document::Versions # def versions @versions ||= Versions.new(self) end def __reference__ #:nodoc: "@##{uuid}.#{version}" end def ==(doc) #:nodoc: case doc when Document, DocumentReferenceValue doc = doc.load if doc.kind_of? DocumentReferenceValue # we make a quick UUID check here to skip two heavy to_raw calls doc.uuid == uuid && doc.to_raw == to_raw else false end end def eql?(doc) #:nodoc: self == doc end # documents are hashed by their UUID def hash #:nodoc: uuid.hash end def make_immutable! extend ImmutableDocument self end def mutable? true end def method_missing(sym, *args) #:nodoc: sym = sym.to_s return send(:[]=, sym.chomp('='), *args) if sym.ends_with? '=' return self[sym] if slotnames.include? sym return !!send(sym.chomp('?'), *args) if sym.ends_with? '?' raise SlotNotFoundError.new(sym) if (callbacks['when_slot_not_found'] || []).empty? r = execute_callbacks(:when_slot_not_found, sym) raise r if r.is_a? SlotNotFoundError # TODO: spec this behavior r end def add_callback(cbk) #:nodoc: name, uid = cbk.name, cbk.uid callbacks[name] ||= [] # if uid is specified, previous callback with the same uid is deleted if uid && old_cb = callbacks[name].find{ |cb| cb.uid == uid } callbacks[name].delete old_cb end callbacks[name] << cbk end protected # value of the last called callback is returned when executing callbacks # (or nil if none found) def execute_callbacks(name, *args) #:nodoc: (callbacks[name.to_s] || []).inject(nil) do |prev_value, callback| callback.call(self, *args) end end def execute_callbacks_for(origin, name, *args) #:nodoc: (callbacks[name.to_s] || []).inject(nil) do |prev_value, callback| callback.origin == origin ? callback.call(self, *args) : prev_value end end # initialize the document. initialize_raw is true when # document is initialized from a raw serialized form def do_initialize(store, slots={}, initialize_raw = false) #:nodoc: @callbacks = {} @store = store if initialize_raw initialize_raw_slots slots @saved = true else @new = true initialize_slots slots self[:uuid] = Util.random_uuid unless self[:uuid] generate_new_version! unless self[:version] end end # initialize slots for a new, just created document def initialize_slots(slots) #:nodoc: @slots = {} slots = slots.stringify_keys # there is a reason for meta slot is initialized separately — # we need to setup coercions before initializing actual slots if meta = slots['meta'] meta = [meta] unless meta.is_a?(Array) meta.each {|m| metas.add_meta(m) } end slots.except('meta').each {|name,value| self[name] = value } # now, when we have all slots initialized, we can run initialization callbacks execute_callbacks :on_initialization execute_callbacks :on_new_document end # initialize slots from a raw representation def initialize_raw_slots(slots) #:nodoc: @slots = {} slots.each do |name,value| s = Slot.new(self, name) s.raw_value = value @slots[name.to_s] = s end end # returns an array of meta modules (as constants) for a given something # (a document reference, a document itself, or an array of the former) def self.collect_meta_modules(store, meta) #:nodoc: meta_names = [] case meta when VERSIONREF if m = store.find($1, $2) mod = Module.find_by_nsurl(m[:nsurl]) mod = nil if mod == Module meta_names << (mod ? mod.name : "") + "::" + m[:name] end when DOCREF if m = store.find($1) mod = Module.find_by_nsurl(m[:nsurl]) mod = nil if mod == Module meta_names << (mod ? mod.name : "") + "::" + m[:name] end when Array meta_names = meta.map { |m| collect_meta_modules(store, m) }.flatten when Document meta_names << meta[:name] end meta_names.map { |m| m.is_a?(String) ? (m.constantize rescue nil) : m }.compact end def generate_new_version! self.version = Util.random_uuid end def update_version!(slotname) if @saved && slotname != 'version' && slotname != 'previous_version' self[:previous_version] = version unless version.nil? generate_new_version! @saved = nil end end end # # VersionedDocument is a module that is being added to all document's of specific version. # It should not be accessed directly # module VersionedDocument # # Reloads the same version of the same document from store. All unsaved changes will be lost! # def reload store.find(uuid, version) end end # # ImmutableDocument can't be saved # It should not be used directly, use Document#make_immutable! instead # module ImmutableDocument def mutable? false end def save! self end end end