module CouchPillow class Document extend TypeDirective extend TypePrefixDirective extend AttributeDirective extend MultiDBDirective extend RenameDirective extend MigrateDirective extend TTLDirective attr_accessor :id EVENTS = [ :cas_conflict ].freeze CAS_CONFLICT_RETRY_COUNT = 5 type_prefix true attribute :_created_at do required type Time auto_convert default { Time.now.utc } end attribute :_updated_at do required type Time auto_convert default { Time.now.utc } end # Constructor. # @param hash The document # @param id The id of the document # @param cas CAS value of the document, from the CB client. Optional. # def initialize hash = {}, id = SecureRandom.hex, cas = nil @data = CouchPillow.symbolize(hash) @original = Marshal.load(Marshal.dump(@data)) @id = self.class._is_type_prefixed? ? self.class._sanitize_id(id) : id time = Time.now.utc @data[:_created_at] ||= time @data[:_updated_at] ||= time @cas = cas rename! whitelist! assign_defaults! migrate! auto_convert! end def [] key @data[key.to_s.to_sym] end def []= key, value @data[key.to_s.to_sym] = value end # Save this document to the server # def save! opts = {} result = nil # write to the primary db first result = _cas_handler do whitelist! sort! _timestamp! validate! opts[:cas] = @cas opts[:ttl] ||= self.class.ttl_value self.class._default_db.set(db_id, _to_save, opts) end # write to the secondary only if the primary succeeds # and ignore CAS for secondary DBs. self.class._write_to_secondary_dbs do |db| db.set(db_id, _to_save) end if result result end # Delete this document from the server. # def delete! result = self.class._default_db.delete(db_id) # write to the secondary only if the primary succeeds self.class._write_to_secondary_dbs do |db| db.delete(db_id) end if result result end # Attempt to update this Document. Fails if this Document does not yet # exist in the database. # def update! opts = {} # write to the primary db first result = _cas_handler do whitelist! sort! _timestamp! validate! opts[:cas] = @cas result = self.class._default_db.replace(db_id, _to_save, opts) end # write to the secondary only if the primary succeeds # and ignore CAS for secondary DBs. opts.delete :cas self.class._write_to_secondary_dbs do |db| db.replace(db_id, _to_save) end if result result end # Run the migration directive # def migrate! self.class.migrate_keys.each do |k, pr| if @data.has_key? k @data[k] = pr.call @data[k] end end end # Updates the attributes in the document. # Existing attributes will be overwritten and new ones will be added. # Any other existing attributes that are not present in the hash will be ignored. # def update hash hash.each do |k,v| @data[k.to_sym] = v end rename! whitelist! auto_convert! end # Check if this Document has the key # def has? key @data.has_key?(key) end # Convert this Document to a JSON string # def to_json *a to_hash.to_json(*a) end # Convert this Document to a Hash # def to_hash { :_id => @id, :_type => doc_type }.merge!(@data) end # Helper to get the type of this Document. # Can't really name this `type`. Need to avoid name conflict with Ruby's own `type` method. # def doc_type self.class._doc_type end # Rename the keys in this Document as specified by the {rename} directive. # def rename! self.class.rename_keys.each do |from, to| @data.has_key?(from) and @data[to] = @data[from] and @data.delete(from) end end # Cleanup the @data hash so it only contains relevant fields. # def whitelist! @data.delete_if do |k, v| !self.class.attributes.has_key?(k) end end # Assign default values. # def assign_defaults! self.class.attributes.each do |k, attr| @data[k] = attr.trigger_default_directive if !has?(k) && attr.has_default? end end # Auto convert. # def auto_convert! self.class.attributes.each do |k, attr| @data[k] = attr.trigger_auto_convert_directive(@data[k]) if has?(k) end end # Go through each attribute, and validate the values. # Validation also perform auto-conversion if auto-conversion is enabled for that attribute. # def validate! self.class.attributes.each do |k, attr| if has?(k) @data[k] = attr.validate(@data[k]) else @data[k] = attr.trigger_default_directive if attr.has_default? raise ValidationError, "Attribute '#{k}' is required" if attr.required? && !has?(k) end end end # Sort keys on this document. # def sort! @data = @data.sort.to_h end # Get a Document given an id or multiple ids. # # @return nil if not found or Document is of a different type. # If multiple ids are passed, returns an array. # def self.get *ids results = [] if ids.length == 1 id = _sanitize_id(ids.first) tid = _is_type_prefixed? ? "#{_doc_type}::#{id}" : id result, _, cas = _default_db.get(tid, extended: true) results << create(result, id, cas) elsif ids.length > 1 # Sanitize ids first sanitized_ids = ids.map do |id| id = _sanitize_id(id) _is_type_prefixed? ? "#{_doc_type}::#{id}" : id end # Query the db db_result = _default_db.get(*sanitized_ids, extended: true) results = sanitized_ids.map do |k| result, _, cas = db_result[k] create(result, k, cas) end end results.size == 1 ? results.first : results end # Registers a listener on a specific event. # See {EVENTS} constant for a list of accepted events. # def self.on event, &block event_listeners[event] = block if EVENTS.include?(event) end # Inherit directives def self.inherited subclass attributes.each do |k, v| subclass.attributes[k] = v end subclass.type_prefix(_is_type_prefixed?) subclass.type(_doc_type) end private # Timestamp this document # def _timestamp! @data[:_updated_at] = Time.now.utc end def _cas_handler &block # write to the primary db first rtcount = CAS_CONFLICT_RETRY_COUNT begin block.call rescue ValidationError raise rescue Couchbase::Error::KeyExists other_doc, _, newcas = self.class._default_db.get(db_id, extended: true) raise CASError, "There is a CAS conflict, but DB does not yield a document" unless other_doc raise CASError, "There is a CAS conflict, but no :cas_conflict handler has been defined. See 'on' directive." unless self.class.event_listeners[:cas_conflict] # resolve conflict other_doc = CouchPillow.symbolize(other_doc) @data = self.class.event_listeners[:cas_conflict].call(@original, other_doc, _to_save) @cas = newcas rtcount -= 1 raise CASError, "Exhausted retries" if rtcount == 0 retry end end # Get the final hash that will be saved to the database. # Check if data has been modified. # def _to_save hash = @data.hash return @tos if @tos && @toshash && @toshash == hash @tos = @data.merge({ :_type => doc_type }) @toshash = @data.hash @tos end # Get the final id value that will be saved to the database. # def db_id @id = self.class._sanitize_id(@id) self.class._is_type_prefixed? ? "#{doc_type}::#{@id}" : @id end def self.create result, id, cas result and type = result[:_type] || result["_type"] and type == _doc_type and new(result, id, cas) or nil end def self.event_listeners @event_listeners ||= {} end end end