# = Persistable # # == Introduction # # The persistable mixin let's you save and load your classes # to and from CoachDB using the instance method +save+ and # the class level method +get_by_id+. # Documentation and examples can be found in the README file. # The specs also contain lots of examples. # # # = TODOs and known issues: # # * TODO: create a way to solve saving conflicts. Should also be possible # to chose if only conflicts for certain variables should be handled. # # * TODO: If real world usage shows that it is needed then create a reload method # that reloads the content from the db. Might be useful if some other # process has changed the content of the document in which case the local # copy can't be stored to the DB. Should local changes be overwritten? # How should differences between the version on the server and the local # version be handeled? # # * ISSUE: # When creating a new document the server does not return other values # than the new documents ID and REVISION number. The local representation # of the created_at and updated_at attributes are therefore set right before # the call to the server is issued. Their values are therefore not # guaranteed to be completely in sync with the values stored on the server! # # * ISSUE: # When loading multiple objects from a view using the get_from_view # mehtod that normally would have been in a belongs_to or has_many relation, # their relations aren't set. # # * ISSUE: # Given the following scenario: B is in a has_many/has_one relationship to A # and both have been saved to the database. B is removed from the # relationship and A is then saved. Saving A wont save the changed version # of B where the relationship has been ended. Therefore: the next time A # is loaded from the database B will be loaded as a relative! # # Possible sollutions: # 1) keep a list of objects that need to be saved in A so that B gets # saved when A is saved althouh the relationship has ended. # 2) save B as it is removed from the relationship with A. # # I belive 1) to be the better solution as it doesn't prematurely save B # in cases where you don't want the changes you do to be saved to the # database. # MAKE SURE: that the object that is added doesn't have the same id # and revision number ass the object that is replaced. That happens # when a class loads its belongs_to relations! In cases like that # the old class should NOT be tracked and saved later! # $:.unshift File.dirname(__FILE__) require 'persistable/has_many_relations_array' require 'persistable/meta_classes' require 'persistable/overloaded_methods' module CouchObject module Persistable module ClassMethods # # Loads a document from the database # # Aliases: # * +get+ # # Takes: # * +id+: the ID of the document that should be loaded # * +db_uri+: the uri to the database. Is optional if the database has # been defined on class level: # # class SomeClass # include CouchObject::Persistable # database 'http://localhost:5984' # end # # Returns: # * a fully initialized class of type self # # Raises: # * CouchObject::Errors::NoDatabaseLocationSet if +db_uri+ # is blank AND has not been set on class level # * CouchObject::Errors::DocumentNotFound if the document doesn't # exist or has been deleted # def get_by_id(id, db_uri = self.location) # Raises an error if the location variable hasn't been set raise CouchObject::Errors::NoDatabaseLocationSet unless db_uri db = CouchObject::Database.open(db_uri) response = JSON.parse(db.get(id).body) if response["error"] case response["reason"] when "deleted" raise CouchObject::Errors::DocumentNotFound, "The document has been deleted" else raise CouchObject::Errors::DocumentNotFound, "The document could not be found" end end # creates a new object and initialize all its sub objects new_object = couch_load_object(response) # set the storage location it was loaded from so it can be saved # back directly without having to supply the db_uri again new_object.instance_variable_set("@location", db_uri) # return the new couch object new_object end # # Alias for get_by_id # alias get get_by_id # # Takes, returns and raises the same things as +get_by_id+ # # Creates a new object that is forced into smart save mode # although the class it is stemming from might not have smart # saving enabled. # def get_with_smart_save(id, db_uri = self.location) new_object = self.get_by_id(id, db_uri) # Force it into smart save mode new_object.couch_force_smart_save # Initialize the original state. new_object.couch_set_initial_state new_object end # # Loads all document from a given view from the database # # Takes: # * +view+ (string): the name of the view to call # * [+params+] (hash): a hash of URL query arguments supported # by couchDB. If omitted it defaults to not use a key # and not update the view. # Additionally the +db_uri+ can be set as a parameter if # it hasn't been defined at class level. # # Example: # # AppleTree.get_from_view("foo_view", # { :db_uri => "http://localhost:5984/mydb", # :update => false, :key => "bar"}) => Array # # Returns: # * a array of initialized classes # (if the view includes the documents full content) # # Raises: # * CouchObject::Errors::NoDatabaseLocationSet if +db_uri+ # is blank AND has not been set on class level # * CouchObject::Errors::MissingView if the view doesn't exist # def get_from_view( view, params = {:key => nil, :update => true, :db_uri => self.location}) # Raise an error if the location variable hasn't been set db_uri = params[:db_uri] || self.location raise CouchObject::Errors::NoDatabaseLocationSet unless db_uri db = CouchObject::Database.open(db_uri) params.delete(:db_uri) #Create a querystring with the parameters passed inn querystring = "?" params.each_pair do |key, value| querystring += \ "#{key}=#{Utils.encode_querystring_parameter(value)}&" end querystring = querystring[0...-1] view_with_parameters = view + querystring objects_to_return = [] response = JSON.parse(db.get(view_with_parameters).body) raise CouchObject::Errors::MissingView, \ "The view '#{view}' doesn't exist on the server" \ if response["error"] == "not_found" raise CouchObject::Errors::CouchDBError, \ "CouchDB returned and error and described the problem as #{response['reason']}. \n" + \ "There might be something wrong with one of your views, or it might be missing!" \ if response["error"] response["rows"].each do |params_for_object| objects_to_return << couch_load_object(params_for_object["value"]) end objects_to_return end protected # # This recursive method initializes new instances of self and # makes sure all sub classes are also initialized. # # Takes: # * A hash of parameters loaded from CoachDB # # Returns: # * An fully initialized object # # Raises: # * Does currently not raise any error # def couch_load_object(parameters) # Getting the values that shouldn't be passed on to the initializer id = parameters["_id"] revision = parameters["_rev"] created_at = parameters["created_at"] updated_at = parameters["updated_at"] class_type = parameters["class"] belongs_to = parameters["belongs_to"] new_object_from_couch = nil # Two possible routes: # * the class has implemented a from_couch class method, in which # case we use it, or # * there is no from_couch method, so we just add the attributes # as instance variables if they aren't part of a class... if eval("#{class_type}.respond_to?(:from_couch)") new_object_from_couch = eval("#{class_type}.send(:from_couch," \ " parameters[\"attributes\"])") else new_object_from_couch = eval("#{class_type}.new") unless parameters["attributes"] == nil parameters["attributes"].each_key do |key| # Check if the key value pair is a class if parameters["attributes"][key].class == Hash && \ parameters["attributes"][key]["class"] != nil new_object_from_couch.instance_variable_set("@#{key}", \ self.couch_load_object(parameters["attributes"][key])) else # Add the value to the class new_object_from_couch. instance_variable_set("@#{key}", \ parameters["attributes"][key]) end end end end # Sets couch_object related values new_object_from_couch. instance_variable_set("@revision", revision) if revision new_object_from_couch.instance_variable_set("@id", id) if id new_object_from_couch.instance_variable_set("@created_at", \ created_at) if eval("#{class_type}." \ "couch_object_timestamp_on_create?") new_object_from_couch.instance_variable_set("@updated_at", \ updated_at) if eval("#{class_type}." \ "couch_object_timestamp_on_update?") new_object_from_couch. instance_variable_set("@belongs_to", belongs_to) \ if belongs_to new_object_from_couch.couch_set_initial_state # Returns the new object new_object_from_couch end end public # # Accessors for instance variables specific to the persistable # CouchObject # attr_reader :id, :revision, :updated_at, :created_at # # Returns: # * +true+ if the object hasn't been saved # * +false+ if the object has previously been stored or is loaded from the # document store # def new? id.nil? || revision.nil? end # # Stores the initial value of the instance to a variable # for later reference by the +unsaved_changes?+ method # def couch_set_initial_state # For the unsaved_changes? instance method to work, we have to # supply a snapshot of what the fresh object looked like. # BUT ONLY if the user has activated the smart_save option if use_smart_save @couch_initial_load = true @couch_object_original_state = to_json @couch_initial_load = false end end # # Forces the instance object into smart save mode # def couch_force_smart_save def self.use_smart_save true end end # # Saves the object to the db_uri supplied, or if not set, to the # location the object has previously been saved to. # # Takes: # * +db_uri+ as string which is the location of the database: # Example: http://localhost:5984/mydb # # Raises: # * CouchObject::Errors::NoDatabaseLocationSet error # if the object doesn't have a previously set location and # the +db_uri+ is nil # # Returns: # * Hash with the id and revision: # {:id => "1234", :revision => "ABC123"} # # Sub methods might raise: # * CouchObject::Errors::DatabaseSaveFailed if the save fails # def save(db_uri = location) # if the location hasn't been set, set it @location ||= db_uri # Raises an error if the location variable hasn't been set raise CouchObject::Errors::NoDatabaseLocationSet unless location # If it's belongs_to relationships haven't been saved # then it has be done first. # Saving the master will also automatically save the child performed_save = false # If the belongs_to relationships haven't already been loaded, # there is reason to believe that: # * The object is new and doesn't have any relation set # * The object already knows about it's belongs_to relations # and doesn't need aditional information about them for saving # We therefore deactivate the loading of belongs_to relations original_state_of_load_belongs_to_relations = \ @do_not_load_belongs_to_relations @do_not_load_belongs_to_relations = true belongs_to.each do |what_it_belongs_to| master_class = self.send(what_it_belongs_to) unless master_class == nil || !master_class.new? master_class.save performed_save = true end end # Reset the do_not_load_belongs_to_relations variable to its # original state @do_not_load_belongs_to_relations = \ original_state_of_load_belongs_to_relations # If none of the master classes were saved, meaning they weren't new # or didn't exist, then we have to manually save this object. couch_perform_save unless performed_save {:id => @id, :revision => @revision} end protected def couch_perform_save perform_callback(:before_save) # Go... action the_return_value = new? ? couch_create : couch_update # Save all the has_many relations # But only the has_many relations that have already # been loaded! No need to load the relations from # the db to save them back again! state_before_wants_to_load_relations = @do_not_load_has_many_relations @do_not_load_has_many_relations = true # We thread the save process in case the relations do # some funky time consuming stuff in their call backs threads = [] has_many.each do |thing_it_has_many_of| self.send(thing_it_has_many_of).each do |related_object| threads << Thread.new(related_object) do |object_to_save| object_to_save.save(location) end end end threads.each {|thr| thr.join} # Save all the has_one relations has_one.each do |thing_it_has_one_of| related_object = self.send(thing_it_has_one_of) unless related_object == nil related_object.save(location) end end # Reset the do_not_load_has_many_relations variable # to it's original state @do_not_load_has_many_relations = state_before_wants_to_load_relations perform_callback(:after_save) return the_return_value end # # Saves the class as a new document in the database # # Returns: # * CouchObject::Response # # Raises: # * CouchObject::Errors::DatabaseSaveFailed if the save fails # def couch_create perform_callback(:before_create) db = CouchObject::Database.open(location) json_value = self.to_json unless (response = db.post("", json_value)).to_document["error"] response_document = response.to_document @id = response_document.id @revision = response_document.revision @created_at = Time.now if self. class::couch_object_timestamp_on_create? @updated_at = Time.now if self. class::couch_object_timestamp_on_update? # If the user has activated smart_save, we should set the state # to the new contents of this instance! @couch_object_original_state = json_value if use_smart_save perform_callback(:after_create) # Returns a hash with the ID and Revision {:id => @id, :revision => @revision} else raise CouchObject::Errors::DatabaseSaveFailed, "The document " + \ "couldn't be created.\n" + \ "CouchDB reported: #{response.to_document["error"]}" end end # # Updates a document in the database based on the id and revision # # Returns: # * CouchObject::Response # # Raises: # * CouchObject::Errors::DatabaseSaveFailed if the update fails # def couch_update # Only save if it has unsaved changes! if unsaved_changes? perform_callback(:before_update) # Please notice the following: # The response from CouchDB only includes the revision number # and the ID so the updated_at value in the document store # differs from the updated_at value in the class @updated_at = Time.now if self. class::couch_object_timestamp_on_update? db = CouchObject::Database.open(location) json_value = self.to_json unless (response = db.put(id, json_value)).to_document["error"] @revision = response.to_document.revision # If the user has activated smart_save, we should set the state # to the new contents of this instance! @couch_object_original_state = json_value if use_smart_save perform_callback(:after_update) # Returns a hash with the ID and Revision {:id => @id, :revision => @revision} else raise CouchObject::Errors::DatabaseSaveFailed, "The document " + \ "couldn't be updated.\n" + \ "The reason might be a revision number conflict.\n" + \ "CouchDB reported: #{response.to_document["reason"]}" end end end public # # Classes WITH smart_save activated: # Any instance should be able to know if it has unsaved changes or not. # When an instance is loaded from the DB it creates a snapshot of what # its variables contain. Based on a comparison between the snapshot # and the contents of the instance this method returns true or false. # # Classes WITHOUT smart_save activated: # Will always return true regardless of what state it is in # # A new object will always return true # # Returns: # * true: if it has changes that haven't been saved to the database # * fase: if the nothing has changed since it was loaded from the # database. # def unsaved_changes? return true if new? return true unless use_smart_save @couch_object_original_state == self.to_json ? false : true end # # Any instance should be able to delete itself # # Takes: # * +db_uri+ if not set in the location variable # # Returns: # * true on success # * false on failure # # Note: # * it also deletes all has_many relations from the database # * it removes itself from object it belongs to # # Raises: # * CouchObject::Errors::NoDatabaseLocationSet if +db_uri+ # is blank AND has not been set on class level # def delete(db_uri = location) perform_callback(:before_delete) unless new? # Raises an error if the location variable hasn't been set raise CouchObject::Errors::NoDatabaseLocationSet unless db_uri db = CouchObject::Database.open(db_uri) # Removes itself from the database db.delete(id, revision) end # Remove all relations has_many.each do |what_it_has| self.send(what_it_has).dup.each do |related_object| related_object.delete end end # Remove the relationship with it's has many master object belongs_to.each do |what_it_belongs_to| self_belongs_to_classtype = what_it_belongs_to self_belongs_to_classtype_as = self. send("belongs_to_#{what_it_belongs_to}_as") self_belongs_to_class = self.send(self_belongs_to_classtype) unless \ self_belongs_to_classtype.nil? self_belongs_to_class.end_relationsship_with(self, self_belongs_to_classtype_as) unless self_belongs_to_class.nil? # Remove the relationship with it's belongs_to master from itself remove_call = "#{self_belongs_to_classtype.to_s}" + \ "_without_call_back=" self.send(remove_call.to_sym, nil) if self_belongs_to_classtype end # Reset itself @id = nil @revision = nil @location = nil perform_callback(:after_delete) true end # # Breaks relations if existing # # Takes: # * +undesired_object+ as a reference to the object the # relationship should be broken with # * +which_is_stored_as+ (string) which is what the relation # is stored as. # def end_relationsship_with(undersired_object, which_is_stored_as) self.send(which_is_stored_as.to_sym).perform_remove(undersired_object) end # # Sets the location variable manually # # Takes: # * +db_uri+ as string which is the location of the database: # Example: http://localhost:5984/mydb # def set_location=(db_uri) @location = db_uri == "" ? nil : db_uri end alias set_storage_location= set_location= # # serializes this object into JSON # # Returns: # * The values of the class in json format # # Example # {"class":"Bike","attributes":{"wheels":2}} # def to_json parameters = {} parameters["class"] = self.class if respond_to?(:to_couch) parameters["attributes"] = self.to_couch else # Find all the instance variables and add them to the # attributes parameter p_attributes = {} instance_variables = \ self.instance_variables - ["@location", "@created_at", "@updated_at", "@id", "@revision", "@do_not_load_has_many_relations", "@do_not_load_belongs_to_relations", "@couch_object_original_state", "@couch_initial_load", "@belongs_to"] # We also have to remove all the objects that are related # through belongs_to and has_many relations. They have to be called # saved separately has.each do |thing_it_has| instance_variables = instance_variables - \ ["@couch_object_#{thing_it_has.to_s}"] end belongs_to.each do |things_it_belongs_to| instance_variables = instance_variables - \ ["@couch_object_#{things_it_belongs_to.to_s}"] end instance_variables.each do |var| p_attributes[var[1..(var.length)]] = self.instance_variable_get(var) end parameters["attributes"] = p_attributes end parameters["updated_at"] = Time.now \ if self.class::couch_object_timestamp_on_update? unless new? parameters["_id"] = id parameters["_rev"] = revision parameters["created_at"] = created_at \ if self.class::couch_object_timestamp_on_create? else parameters["created_at"] = Time.now \ if self.class::couch_object_timestamp_on_create? end # If it is in belongs_to relationship(s), then that fact # has to be stored in the database if belongs_to != [] and !@couch_initial_load # the couch_initial_load # is to get the initial # state of the object # without loading the # belongs_to relations # which would start an # infinite loop. # NOTE: this is only for # cases where smart_save # has been activated. # No reason to load the belongs_to relations if they haven't # already been loaded from the database! original_state_of_load_belongs_to_relations = \ @do_not_load_belongs_to_relations @do_not_load_belongs_to_relations = true what_it_belongs_to = {} self.send(:belongs_to).each do |relation| as_what = self.send("belongs_to_#{relation}_as") object_it_belongs_to = self.send(relation) # Unless the relation is unset, in which case it will be of # type NilClass, set it. what_it_belongs_to[as_what.to_s] = object_it_belongs_to.id || "new" \ unless object_it_belongs_to.class == NilClass end # Reset the value so they are loaded the next time when needed # if that is what the user wants. @do_not_load_belongs_to_relations = \ original_state_of_load_belongs_to_relations # We have to make sure the @belongs_to variable contains all changes # and all the original values for the keys that haven't changed/ # relations that haven't been loaded original_belongs_to = @belongs_to || {} @belongs_to = what_it_belongs_to times_through = 1 # LOOP 1... see below for problem description original_belongs_to.each_pair do |key, value| times_through += 1 @belongs_to[key.to_s] = value unless @belongs_to[key.to_s] end # FIXME: # Now... this is a really hacky way to solve this problem # and should be improved... Feel free to come up with sollutions # Case: # If it has a belongs_to relationship that is new and therefore # doesn't have an ID it would normally be written in the @belongs_to # variable as nil. The problem is that if self has previously # been saved with another parent object this ID would still come # through in the @belongs_to variable updater (see "LOOP 1" above). # We therefore assign the ID "new" to all unsaved relations, which we # now have to nilify. If we don't the smart save wont work for this # type of cases. end parameters["belongs_to"] = @belongs_to parameters.delete("belongs_to") if @belongs_to == {} or @belongs_to == nil # if @couch_initial_load && @belongs_to begin parameters.to_json rescue JSON::GeneratorError # All strings aren't encoded properly, so we have to force them into # UTF-8. # FIXME: The kconv library has some weird artefacts though where # a lot of Norwegian (Scandianavian?) letters get turned into # asian characters of some sort! CouchObject::Utils::decode_strings(parameters).to_json end end # # Sometimes you might want to add an object to # a has_many relation without interacting with the other relations at all. # In cases like that, when loading all the relations would just # cause unnecessary traffic to the database, you can tell the object # not to load it has_many relations using this method # def do_not_load_has_many_relations @do_not_load_has_many_relations = true end def do_load_has_many_relations @do_not_load_has_many_relations = false end alias do_not_load_has_one_relations do_not_load_has_many_relations alias do_load_has_one_relations do_load_has_many_relations alias do_not_load_has_one_relation do_not_load_has_many_relations alias do_load_has_one_relation do_load_has_many_relations # # If you need to access the belongs_to variable without loading the # relation if it hasn't already been loaded, you can call the instance # method +do_not_load_belongs_to_relations+. To reactivate loading so # the relation is loaded the next time it is needed, call the instance # method +do_load_belongs_to_relations+. # def do_not_load_belongs_to_relations @do_not_load_belongs_to_relations = true end def do_load_belongs_to_relations @do_not_load_belongs_to_relations = false end alias do_not_load_has_many_relation do_not_load_has_many_relations alias do_load_belongs_to_relation do_load_belongs_to_relations protected # # Loads has_many relations # def couch_load_has_many_relations(which_relation) # If it is a new and unsaved object it wont have # relations in the DB. Return a blank array results = CouchObject::Persistable::HasManyRelation.new(self) # If it is loading a has_one relation, the relation name has to # be changed which_relation = which_relation[8..-1] \ if which_relation[0..7] == "has_one_" return results if new? || @do_not_load_has_many_relations view_name = "couch_object_has_many_relations" begin new_objects = self.class. get_from_view("_view/#{view_name}/related_documents", \ {:key => [self.id, which_relation], :db_uri => location} ) # Disable the call back function of HasManyRelations so we don't # get an infinite loop results.disable_call_back_on_add new_objects.each do |new_object| results << new_object end # Reanable the call back function again so it works like normal # from now on results.enable_call_back_on_add rescue CouchObject::Errors::MissingView, CouchObject::Errors::CouchDBError # The view doesn't exist. It means this is the first # time this script is used for a given database, or the user # has deleted the view view_code_query = JSON.unparse( { "_id" => "_design/#{view_name}", "language" => "text/javascript", "views" => { "related_documents" => "function(doc){if (doc.belongs_to){" + \ "for (var i in doc.belongs_to) {map([doc.belongs_to[i],i], " + \ "doc);}}}" } } ) db = CouchObject::Database.open(location) if (response = db.put("_design%2F#{view_name}", \ view_code_query)) results = couch_load_has_many_relations(which_relation) else raise CouchObject::Errors::StandardError, "Couldn't create the view..." end end return results end # # Loads the belongs_to relation if the class has a previously # save relation # def couch_load_belongs_to_relation(that_is_called) return nil if new? || @do_not_load_belongs_to_relations # If it doesn't have a belongs to ID then there is no # related object in the database if @belongs_to && @belongs_to[that_is_called] # If it is a new and unsaved object it wont have # relations in the DB. Return a blank array return nil if new? # Raises an error if the location variable hasn't been set raise CouchObject::Errors::NoDatabaseLocationSet unless location db = CouchObject::Database.open(location) class_self_belongs_to = self. class.get_by_id(@belongs_to[that_is_called]) # We have to add self to the other end of the relation # Adds itself to the relationship masters_relation = class_self_belongs_to.send(that_is_called) if masters_relation.class == CouchObject::Persistable::HasManyRelation # it is a has_many relations # remove the object in the relation that is the freshly loaded # copy of self, and then add self instead. masters_relation.each do |has_one| if has_one.id == self.id && has_one.revision == self.revision masters_relation.perform_remove(has_one) break end end masters_relation.disable_call_back_on_add masters_relation << self masters_relation.enable_call_back_on_add else # it is a has_one relation, just add itself class_self_belongs_to.send("#{that_is_called}=".to_sym, self) end return class_self_belongs_to else nil end end # # Performs callbacks before and after these events: # * create # * update # * save # * delete # def perform_callback(the_callback) self.send(the_callback) if self.respond_to?(the_callback) end end end