From 823554eafef9e8ee8fe2788f6231a3e665c2cbbf Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 15 Jan 2005 17:45:16 +0000 Subject: Added support for associating unsaved objects #402 [Tim Bates] Added replace to associations, so you can do project.manager.replace(new_manager) or project.milestones.replace(new_milestones) #402 [Tim Bates] Added build and create methods to has_one and belongs_to associations, so you can now do project.manager.build(attributes) #402 [Tim Bates] Fixed that Base#== wouldn't work for multiple references to the same unsaved object #402 [Tim Bates] Added that if a before_* callback returns false, all the later callbacks and the associated action are cancelled. If an after_* callback returns false, all the later callbacks are cancelled. Callbacks are generally run in the order they are defined, with the exception of callbacks defined as methods on the model, which are called last. #402 [Tim Bates] git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@417 5ecf4fe2-1ee6-0310-87b1-e25e094e27de --- activerecord/lib/active_record/associations.rb | 328 ++++++++++++------------- 1 file changed, 164 insertions(+), 164 deletions(-) (limited to 'activerecord/lib/active_record/associations.rb') diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index e7e5a7d71d..882baffbe4 100755 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -1,4 +1,7 @@ +require 'active_record/associations/association_proxy' require 'active_record/associations/association_collection' +require 'active_record/associations/belongs_to_association' +require 'active_record/associations/has_one_association' require 'active_record/associations/has_many_association' require 'active_record/associations/has_and_belongs_to_many_association' require 'active_record/deprecated_associations' @@ -30,9 +33,9 @@ module ActiveRecord # end # # The project class now has the following methods (and more) to ease the traversal and manipulation of its relationships: - # * Project#portfolio, Project#portfolio=(portfolio), Project#portfolio.nil?, Project#portfolio?(portfolio) + # * Project#portfolio, Project#portfolio=(portfolio), Project#portfolio.nil? # * Project#project_manager, Project#project_manager=(project_manager), Project#project_manager.nil?, - # Project#project_manager?(project_manager), Project#build_project_manager, Project#create_project_manager + # Project#project_manager.build, Project#project_manager.create # * Project#milestones.empty?, Project#milestones.size, Project#milestones, Project#milestones<<(milestone), # Project#milestones.delete(milestone), Project#milestones.find(milestone_id), Project#milestones.find_all(conditions), # Project#milestones.build, Project#milestones.create @@ -71,6 +74,29 @@ module ActiveRecord # PRIMARY KEY (id) # ) # + # == Unsaved objects and associations + # + # You can manipulate objects and associations before they are saved to the database, but there is some special behaviour you should be + # aware of, mostly involving the saving of associated objects. + # + # === One-to-one associations + # + # * Assigning an object to a has_one association automatically saves that object, and the object being replaced (if there is one), in + # order to update their primary keys - except if the parent object is unsaved (new_record? == true). + # * If either of these saves fail (due to one of the objects being invalid) the assignment statement returns false and the assignment + # is cancelled. + # * If you wish to assign an object to a has_one association without saving it, use the #association.build method (documented below). + # * Assigning an object to a belongs_to association does not save the object, since the foreign key field belongs on the parent. It does + # not save the parent either. + # + # === Collections + # + # * Adding an object to a collection (has_many or has_and_belongs_to_many) automatically saves that object, except if the parent object + # (the owner of the collection) is not yet stored in the database. + # * If saving any of the objects being added to a collection (via #push or similar) fails, then #push returns false. + # * You can add an object to a collection without automatically saving it by using the #collection.build method (documented below). + # * All unsaved (new_record? == true) members of the collection are automatically saved when the parent is saved. + # # == Caching # # All of the methods are built on a simple caching principle that will keep the result of the last query around unless specifically @@ -124,7 +150,7 @@ module ActiveRecord module ClassMethods # Adds the following methods for retrieval and query of collections of associated objects. # +collection+ is replaced with the symbol passed as the first argument, so - # has_many :clients would add among others has_clients?. + # has_many :clients would add among others clients.empty?. # * collection(force_reload = false) - returns an array of all the associated objects. # An empty array is returned if none are found. # * collection<<(object, ...) - adds one or more objects to the collection by setting their foreign keys to the collection's primary key. @@ -200,18 +226,9 @@ module ActiveRecord module_eval "before_destroy { |record| #{association_class_name}.delete_all(%(#{association_class_primary_key_name} = \#{record.quoted_id})) }" end - define_method(association_name) do |*params| - force_reload = params.first unless params.empty? - association = instance_variable_get("@#{association_name}") - if association.nil? - association = HasManyAssociation.new(self, - association_name, association_class_name, - association_class_primary_key_name, options) - instance_variable_set("@#{association_name}", association) - end - association.reload if force_reload - association - end + add_multiple_associated_save_callbacks(association_name) + + association_accessor_methods(association_name, association_class_name, association_class_primary_key_name, options, HasManyAssociation) # deprecated api deprecated_collection_count_method(association_name) @@ -220,31 +237,28 @@ module ActiveRecord deprecated_has_collection_method(association_name) deprecated_find_in_collection_method(association_name) deprecated_find_all_in_collection_method(association_name) - deprecated_create_method(association_name) - deprecated_build_method(association_name) + deprecated_collection_create_method(association_name) + deprecated_collection_build_method(association_name) end # Adds the following methods for retrieval and query of a single associated object. # +association+ is replaced with the symbol passed as the first argument, so - # has_one :manager would add among others has_manager?. + # has_one :manager would add among others manager.nil?. # * association(force_reload = false) - returns the associated object. Nil is returned if none is found. # * association=(associate) - assigns the associate object, extracts the primary key, sets it as the foreign key, # and saves the associate object. - # * association?(object, force_reload = false) - returns true if the +object+ is of the same type and has the - # same id as the associated object. # * association.nil? - returns true if there is no associated object. - # * build_association(attributes = {}) - returns a new object of the associated type that has been instantiated + # * association.build(attributes = {}) - returns a new object of the associated type that has been instantiated # with +attributes+ and linked to this object through a foreign key but has not yet been saved. - # * create_association(attributes = {}) - returns a new object of the associated type that has been instantiated + # * association.create(attributes = {}) - returns a new object of the associated type that has been instantiated # with +attributes+ and linked to this object through a foreign key and that has already been saved (if it passed the validation). # # Example: An Account class declares has_one :beneficiary, which will add: # * Account#beneficiary (similar to Beneficiary.find_first "account_id = #{id}") # * Account#beneficiary=(beneficiary) (similar to beneficiary.account_id = account.id; beneficiary.save) - # * Account#beneficiary? (similar to account.beneficiary == some_beneficiary) # * Account#beneficiary.nil? - # * Account#build_beneficiary (similar to Beneficiary.new("account_id" => id)) - # * Account#create_beneficiary (similar to b = Beneficiary.new("account_id" => id); b.save; b) + # * Account#beneficiary.build (similar to Beneficiary.new("account_id" => id)) + # * Account#beneficiary.create (similar to b = Beneficiary.new("account_id" => id); b.save; b) # The declaration can also include an options hash to specialize the behavior of the association. # # Options are: @@ -265,35 +279,53 @@ module ActiveRecord # has_one :last_comment, :class_name => "Comment", :order => "posted_on" # has_one :project_manager, :class_name => "Person", :conditions => "role = 'project_manager'" def has_one(association_id, options = {}) - options.merge!({ :remote => true }) - belongs_to(association_id, options) + validate_options([ :class_name, :foreign_key, :remote, :conditions, :order, :dependent, :counter_cache ], options.keys) - association_name, association_class_name, class_primary_key_name = + association_name, association_class_name, association_class_primary_key_name = associate_identification(association_id, options[:class_name], options[:foreign_key], false) require_association_class(association_class_name) - has_one_writer_method(association_name, association_class_name, class_primary_key_name) - build_method("build_", association_name, association_class_name, class_primary_key_name) - create_method("create_", association_name, association_class_name, class_primary_key_name) + module_eval do + after_save <<-EOF + association = instance_variable_get("@#{association_name}") + if (true or @new_record_before_save) and association.respond_to?(:loaded?) and not association.nil? + association["#{association_class_primary_key_name}"] = id + association.save(true) + association.send(:construct_sql) + end + EOF + end + + association_accessor_methods(association_name, association_class_name, association_class_primary_key_name, options, HasOneAssociation) - module_eval "before_destroy '#{association_name}.destroy if has_#{association_name}?'" if options[:dependent] + module_eval "before_destroy '#{association_name}.destroy unless #{association_name}.nil?'" if options[:dependent] + + # deprecated api + deprecated_has_association_method(association_name) + deprecated_build_method("build_", association_name, association_class_name, association_class_primary_key_name) + deprecated_create_method("create_", association_name, association_class_name, association_class_primary_key_name) + deprecated_association_comparison_method(association_name, association_class_name) end # Adds the following methods for retrieval and query for a single associated object that this object holds an id to. # +association+ is replaced with the symbol passed as the first argument, so - # belongs_to :author would add among others has_author?. + # belongs_to :author would add among others author.nil?. # * association(force_reload = false) - returns the associated object. Nil is returned if none is found. # * association=(associate) - assigns the associate object, extracts the primary key, and sets it as the foreign key. - # * association?(object, force_reload = false) - returns true if the +object+ is of the same type and has the - # same id as the associated object. # * association.nil? - returns true if there is no associated object. + # * association.build(attributes = {}) - returns a new object of the associated type that has been instantiated + # with +attributes+ and linked to this object through a foreign key but has not yet been saved. + # * association.create(attributes = {}) - returns a new object of the associated type that has been instantiated + # with +attributes+ and linked to this object through a foreign key and that has already been saved (if it passed the validation). # # Example: A Post class declares has_one :author, which will add: # * Post#author (similar to Author.find(author_id)) # * Post#author=(author) (similar to post.author_id = author.id) # * Post#author? (similar to post.author == some_author) # * Post#author.nil? + # * Post#author.build (similar to Author.new("post_id" => id)) + # * Post#author.create (similar to b = Author.new("post_id" => id); b.save; b) # The declaration can also include an options hash to specialize the behavior of the association. # # Options are: @@ -317,47 +349,46 @@ module ActiveRecord # belongs_to :author, :class_name => "Person", :foreign_key => "author_id" # belongs_to :valid_coupon, :class_name => "Coupon", :foreign_key => "coupon_id", # :conditions => 'discounts > #{payments_count}' - def belongs_to(association_id, options = {}) - validate_options([ :class_name, :foreign_key, :remote, :conditions, :order, :dependent, :counter_cache ], options.keys) + def belongs_to(association_id, options = {}) + validate_options([ :class_name, :foreign_key, :remote, :conditions, :order, :dependent, :counter_cache ], options.keys) - association_name, association_class_name, class_primary_key_name = - associate_identification(association_id, options[:class_name], options[:foreign_key], false) + association_name, association_class_name, class_primary_key_name = + associate_identification(association_id, options[:class_name], options[:foreign_key], false) - require_association_class(association_class_name) + require_association_class(association_class_name) - association_class_primary_key_name = options[:foreign_key] || Inflector.underscore(Inflector.demodulize(association_class_name)) + "_id" + association_class_primary_key_name = options[:foreign_key] || Inflector.underscore(Inflector.demodulize(association_class_name)) + "_id" - if options[:remote] - association_finder = <<-"end_eval" - #{association_class_name}.find_first( - "#{class_primary_key_name} = \#{quoted_id}#{options[:conditions] ? " AND " + options[:conditions] : ""}", - #{options[:order] ? "\"" + options[:order] + "\"" : "nil" } - ) - end_eval - else - association_finder = options[:conditions] ? - "#{association_class_name}.find_on_conditions(read_attribute(\"#{association_class_primary_key_name}\"), \"#{options[:conditions]}\")" : - "#{association_class_name}.find(read_attribute(\"#{association_class_primary_key_name}\"))" - end - - has_association_method(association_name) - association_reader_method(association_name, association_finder) - belongs_to_writer_method(association_name, association_class_name, association_class_primary_key_name) - association_comparison_method(association_name, association_class_name) + association_accessor_methods(association_name, association_class_name, association_class_primary_key_name, options, BelongsToAssociation) - if options[:counter_cache] - module_eval( - "after_create '#{association_class_name}.increment_counter(\"#{Inflector.pluralize(self.to_s.downcase). + "_count"}\", #{association_class_primary_key_name})" + - " if has_#{association_name}?'" - ) + module_eval do + before_save <<-EOF + association = instance_variable_get("@#{association_name}") + if association.respond_to?(:loaded?) and not association.nil? and association.new_record? + association.save(true) + self["#{association_class_primary_key_name}"] = association.id + association.send(:construct_sql) + end + EOF + end + + if options[:counter_cache] + module_eval( + "after_create '#{association_class_name}.increment_counter(\"#{Inflector.pluralize(self.to_s.downcase). + "_count"}\", #{association_class_primary_key_name})" + + " unless #{association_name}.nil?'" + ) - module_eval( - "before_destroy '#{association_class_name}.decrement_counter(\"#{Inflector.pluralize(self.to_s.downcase) + "_count"}\", #{association_class_primary_key_name})" + - " if has_#{association_name}?'" - ) - end + module_eval( + "before_destroy '#{association_class_name}.decrement_counter(\"#{Inflector.pluralize(self.to_s.downcase) + "_count"}\", #{association_class_primary_key_name})" + + " unless #{association_name}.nil?'" + ) end + # deprecated api + deprecated_has_association_method(association_name) + deprecated_association_comparison_method(association_name, association_class_name) + end + # Associates two classes via an intermediate join table. Unless the join table is explicitly specified as # an option, it is guessed using the lexical order of the class names. So a join between Developer and Project # will give the default join table name of "developers_projects" because "D" outranks "P". @@ -371,7 +402,7 @@ module ActiveRecord # # Adds the following methods for retrieval and query. # +collection+ is replaced with the symbol passed as the first argument, so - # has_and_belongs_to_many :categories would add among others +add_categories+. + # has_and_belongs_to_many :categories would add among others +categories.empty?+. # * collection(force_reload = false) - returns an array of all the associated objects. # An empty array is returned if none is found. # * collection<<(object, ...) - adds one or more objects to the collection by creating associations in the join table @@ -385,10 +416,13 @@ module ActiveRecord # * collection.clear - removes every object from the collection. This does not destroy the objects. # * collection.empty? - returns true if there are no associated objects. # * collection.size - returns the number of associated objects. + # * collection.find(id) - finds an associated object responding to the +id+ and that + # meets the condition that it has to be associated with this object. # # Example: An Developer class declares has_and_belongs_to_many :projects, which will add: # * Developer#projects # * Developer#projects<< + # * Developer#projects.push_with_attributes # * Developer#projects.delete # * Developer#projects.clear # * Developer#projects.empty? @@ -431,23 +465,13 @@ module ActiveRecord require_association_class(association_class_name) - join_table = options[:join_table] || - join_table_name(undecorated_table_name(self.to_s), undecorated_table_name(association_class_name)) - - define_method(association_name) do |*params| - force_reload = params.first unless params.empty? - association = instance_variable_get("@#{association_name}") - if association.nil? - association = HasAndBelongsToManyAssociation.new(self, - association_name, association_class_name, - association_class_primary_key_name, join_table, options) - instance_variable_set("@#{association_name}", association) - end - association.reload if force_reload - association - end + options[:join_table] ||= join_table_name(undecorated_table_name(self.to_s), undecorated_table_name(association_class_name)) + + add_multiple_associated_save_callbacks(association_name) + + association_accessor_methods(association_name, association_class_name, association_class_primary_key_name, options, HasAndBelongsToManyAssociation) - before_destroy_sql = "DELETE FROM #{join_table} WHERE #{association_class_primary_key_name} = \\\#{self.quoted_id}" + before_destroy_sql = "DELETE FROM #{options[:join_table]} WHERE #{association_class_primary_key_name} = \\\#{self.quoted_id}" module_eval(%{before_destroy "self.connection.delete(%{#{before_destroy_sql}})"}) # " # deprecated api @@ -487,94 +511,70 @@ module ActiveRecord return association_id.id2name, association_class_name, primary_key_name end - def association_comparison_method(association_name, association_class_name) - module_eval <<-"end_eval", __FILE__, __LINE__ - def #{association_name}?(comparison_object, force_reload = false) - if comparison_object.kind_of?(#{association_class_name}) - #{association_name}(force_reload) == comparison_object - else - raise "Comparison object is a #{association_class_name}, should have been \#{comparison_object.class.name}" - end + def association_accessor_methods(association_name, association_class_name, association_class_primary_key_name, options, association_proxy_class) + define_method(association_name) do |*params| + force_reload = params.first unless params.empty? + association = instance_variable_get("@#{association_name}") + unless association.respond_to?(:loaded?) + association = association_proxy_class.new(self, + association_name, association_class_name, + association_class_primary_key_name, options) + instance_variable_set("@#{association_name}", association) end - end_eval - end + association.reload if force_reload + association + end - def association_reader_method(association_name, association_finder) - module_eval <<-"end_eval", __FILE__, __LINE__ - def #{association_name}(force_reload = false) - if @#{association_name}.nil? || force_reload - begin - @#{association_name} = #{association_finder} - rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordNotFound - nil - end - end - - return @#{association_name} + define_method("#{association_name}=") do |new_value| + association = instance_variable_get("@#{association_name}") + unless association.respond_to?(:loaded?) + association = association_proxy_class.new(self, + association_name, association_class_name, + association_class_primary_key_name, options) + instance_variable_set("@#{association_name}", association) end - end_eval + association.replace(new_value) + association + end end - def has_one_writer_method(association_name, association_class_name, class_primary_key_name) - module_eval <<-"end_eval", __FILE__, __LINE__ - def #{association_name}=(association) - if association.nil? - @#{association_name}.#{class_primary_key_name} = nil - @#{association_name}.save(false) - @#{association_name} = nil - else - raise ActiveRecord::AssociationTypeMismatch unless #{association_class_name} === association - association.#{class_primary_key_name} = id - association.save(false) - @#{association_name} = association - end - end - end_eval + def require_association_class(class_name) + require_association(Inflector.underscore(class_name)) if class_name end - def belongs_to_writer_method(association_name, association_class_name, association_class_primary_key_name) - module_eval <<-"end_eval", __FILE__, __LINE__ - def #{association_name}=(association) - if association.nil? - @#{association_name} = self.#{association_class_primary_key_name} = nil - else - raise ActiveRecord::AssociationTypeMismatch unless #{association_class_name} === association - @#{association_name} = association - self.#{association_class_primary_key_name} = association.id + def add_multiple_associated_save_callbacks(association_name) + module_eval do + before_save <<-end_eval + @new_record_before_save = new_record? + association = instance_variable_get("@#{association_name}") + if association.respond_to?(:loaded?) + if new_record? + records_to_save = association + else + records_to_save = association.select{ |record| record.new_record? } + end + records_to_save.inject(true) do |result,record| + result &&= record.valid? + end end - end - end_eval - end - - def has_association_method(association_name) - module_eval <<-"end_eval", __FILE__, __LINE__ - def has_#{association_name}?(force_reload = false) - !#{association_name}(force_reload).nil? - end - end_eval - end - - def build_method(method_prefix, collection_name, collection_class_name, class_primary_key_name) - module_eval <<-"end_eval", __FILE__, __LINE__ - def #{method_prefix + collection_name}(attributes = {}) - association = #{collection_class_name}.new - association.attributes = attributes.merge({ "#{class_primary_key_name}" => id}) - association - end - end_eval - end - - def create_method(method_prefix, collection_name, collection_class_name, class_primary_key_name) - module_eval <<-"end_eval", __FILE__, __LINE__ - def #{method_prefix + collection_name}(attributes = nil) - #{collection_class_name}.create((attributes || {}).merge({ "#{class_primary_key_name}" => id})) - end - end_eval - end + end_eval + end - def require_association_class(class_name) - require_association(Inflector.underscore(class_name)) if class_name + module_eval do + after_save <<-end_eval + association = instance_variable_get("@#{association_name}") + if association.respond_to?(:loaded?) + if @new_record_before_save + records_to_save = association + else + records_to_save = association.select{ |record| record.new_record? } + end + records_to_save.each{ |record| association.send(:insert_record, record) } + association.send(:construct_sql) # reconstruct the SQL queries now that we know the owner's id + end + end_eval + end end end end -end \ No newline at end of file +end -- cgit v1.2.3