diff options
author | David Heinemeier Hansson <david@loudthinking.com> | 2005-01-15 17:45:16 +0000 |
---|---|---|
committer | David Heinemeier Hansson <david@loudthinking.com> | 2005-01-15 17:45:16 +0000 |
commit | 823554eafef9e8ee8fe2788f6231a3e665c2cbbf (patch) | |
tree | 6059c8e2c943a7fb45a56bf80cc5786934b70de1 | |
parent | 62f0512e54d594c4bb6fcb8d16101fdeb87b89e8 (diff) | |
download | rails-823554eafef9e8ee8fe2788f6231a3e665c2cbbf.tar.gz rails-823554eafef9e8ee8fe2788f6231a3e665c2cbbf.tar.bz2 rails-823554eafef9e8ee8fe2788f6231a3e665c2cbbf.zip |
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
15 files changed, 800 insertions, 307 deletions
diff --git a/activerecord/CHANGELOG b/activerecord/CHANGELOG index 16fb14b9a5..d43e6a0537 100644 --- a/activerecord/CHANGELOG +++ b/activerecord/CHANGELOG @@ -1,5 +1,38 @@ *SVN* +* Added support for associating unsaved objects #402 [Tim Bates]. Rules that govern this addition: + + == 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. + +* 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] + +* 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] + +* Fixed that Base#== wouldn't work for multiple references to the same unsaved object #402 [Tim Bates] + * Fixed binary support for PostgreSQL #444 [alex@byzantine.no] * Added a differenciation between AssociationCollection#size and -length. Now AssociationCollection#size returns the size of the 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: - # * <tt>Project#portfolio, Project#portfolio=(portfolio), Project#portfolio.nil?, Project#portfolio?(portfolio)</tt> + # * <tt>Project#portfolio, Project#portfolio=(portfolio), Project#portfolio.nil?</tt> # * <tt>Project#project_manager, Project#project_manager=(project_manager), Project#project_manager.nil?,</tt> - # <tt>Project#project_manager?(project_manager), Project#build_project_manager, Project#create_project_manager</tt> + # <tt>Project#project_manager.build, Project#project_manager.create</tt> # * <tt>Project#milestones.empty?, Project#milestones.size, Project#milestones, Project#milestones<<(milestone),</tt> # <tt>Project#milestones.delete(milestone), Project#milestones.find(milestone_id), Project#milestones.find_all(conditions),</tt> # <tt>Project#milestones.build, Project#milestones.create</tt> @@ -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 - # <tt>has_many :clients</tt> would add among others <tt>has_clients?</tt>. + # <tt>has_many :clients</tt> would add among others <tt>clients.empty?</tt>. # * <tt>collection(force_reload = false)</tt> - returns an array of all the associated objects. # An empty array is returned if none are found. # * <tt>collection<<(object, ...)</tt> - 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 - # <tt>has_one :manager</tt> would add among others <tt>has_manager?</tt>. + # <tt>has_one :manager</tt> would add among others <tt>manager.nil?</tt>. # * <tt>association(force_reload = false)</tt> - returns the associated object. Nil is returned if none is found. # * <tt>association=(associate)</tt> - assigns the associate object, extracts the primary key, sets it as the foreign key, # and saves the associate object. - # * <tt>association?(object, force_reload = false)</tt> - returns true if the +object+ is of the same type and has the - # same id as the associated object. # * <tt>association.nil?</tt> - returns true if there is no associated object. - # * <tt>build_association(attributes = {})</tt> - returns a new object of the associated type that has been instantiated + # * <tt>association.build(attributes = {})</tt> - 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. - # * <tt>create_association(attributes = {})</tt> - returns a new object of the associated type that has been instantiated + # * <tt>association.create(attributes = {})</tt> - 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 <tt>has_one :beneficiary</tt>, which will add: # * <tt>Account#beneficiary</tt> (similar to <tt>Beneficiary.find_first "account_id = #{id}"</tt>) # * <tt>Account#beneficiary=(beneficiary)</tt> (similar to <tt>beneficiary.account_id = account.id; beneficiary.save</tt>) - # * <tt>Account#beneficiary?</tt> (similar to <tt>account.beneficiary == some_beneficiary</tt>) # * <tt>Account#beneficiary.nil?</tt> - # * <tt>Account#build_beneficiary</tt> (similar to <tt>Beneficiary.new("account_id" => id)</tt>) - # * <tt>Account#create_beneficiary</tt> (similar to <tt>b = Beneficiary.new("account_id" => id); b.save; b</tt>) + # * <tt>Account#beneficiary.build</tt> (similar to <tt>Beneficiary.new("account_id" => id)</tt>) + # * <tt>Account#beneficiary.create</tt> (similar to <tt>b = Beneficiary.new("account_id" => id); b.save; b</tt>) # 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 - # <tt>belongs_to :author</tt> would add among others <tt>has_author?</tt>. + # <tt>belongs_to :author</tt> would add among others <tt>author.nil?</tt>. # * <tt>association(force_reload = false)</tt> - returns the associated object. Nil is returned if none is found. # * <tt>association=(associate)</tt> - assigns the associate object, extracts the primary key, and sets it as the foreign key. - # * <tt>association?(object, force_reload = false)</tt> - returns true if the +object+ is of the same type and has the - # same id as the associated object. # * <tt>association.nil?</tt> - returns true if there is no associated object. + # * <tt>association.build(attributes = {})</tt> - 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. + # * <tt>association.create(attributes = {})</tt> - 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 <tt>has_one :author</tt>, which will add: # * <tt>Post#author</tt> (similar to <tt>Author.find(author_id)</tt>) # * <tt>Post#author=(author)</tt> (similar to <tt>post.author_id = author.id</tt>) # * <tt>Post#author?</tt> (similar to <tt>post.author == some_author</tt>) # * <tt>Post#author.nil?</tt> + # * <tt>Post#author.build</tt> (similar to <tt>Author.new("post_id" => id)</tt>) + # * <tt>Post#author.create</tt> (similar to <tt>b = Author.new("post_id" => id); b.save; b</tt>) # 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 - # <tt>has_and_belongs_to_many :categories</tt> would add among others +add_categories+. + # <tt>has_and_belongs_to_many :categories</tt> would add among others +categories.empty?+. # * <tt>collection(force_reload = false)</tt> - returns an array of all the associated objects. # An empty array is returned if none is found. # * <tt>collection<<(object, ...)</tt> - adds one or more objects to the collection by creating associations in the join table @@ -385,10 +416,13 @@ module ActiveRecord # * <tt>collection.clear</tt> - removes every object from the collection. This does not destroy the objects. # * <tt>collection.empty?</tt> - returns true if there are no associated objects. # * <tt>collection.size</tt> - returns the number of associated objects. + # * <tt>collection.find(id)</tt> - 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 <tt>has_and_belongs_to_many :projects</tt>, which will add: # * <tt>Developer#projects</tt> # * <tt>Developer#projects<<</tt> + # * <tt>Developer#projects.push_with_attributes</tt> # * <tt>Developer#projects.delete</tt> # * <tt>Developer#projects.clear</tt> # * <tt>Developer#projects.empty?</tt> @@ -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 diff --git a/activerecord/lib/active_record/associations/association_collection.rb b/activerecord/lib/active_record/associations/association_collection.rb index ca87fae5ff..334cdccc3d 100644 --- a/activerecord/lib/active_record/associations/association_collection.rb +++ b/activerecord/lib/active_record/associations/association_collection.rb @@ -1,51 +1,34 @@ module ActiveRecord module Associations - class AssociationCollection #:nodoc: - alias_method :proxy_respond_to?, :respond_to? - instance_methods.each { |m| undef_method m unless m =~ /(^__|^nil\?|^proxy_respond_to\?)/ } - - def initialize(owner, association_name, association_class_name, association_class_primary_key_name, options) - @owner = owner - @options = options - @association_name = association_name - @association_class = eval(association_class_name) - @association_class_primary_key_name = association_class_primary_key_name - end - - def method_missing(symbol, *args, &block) - load_collection - @collection.send(symbol, *args, &block) - end - + class AssociationCollection < AssociationProxy #:nodoc: def to_ary - load_collection - @collection.to_ary + load_target + @target.to_ary end - def respond_to?(symbol, include_priv = false) - proxy_respond_to?(symbol, include_priv) || [].respond_to?(symbol, include_priv) + def reset + @target = [] + @loaded = false end - def loaded? - !@collection.nil? - end - def reload - @collection = nil + reset end # Add +records+ to this association. Returns +self+ so method calls may be chained. # Since << flattens its argument list and inserts each record, +push+ and +concat+ behave identically. def <<(*records) + result = true + load_target @owner.transaction do flatten_deeper(records).each do |record| raise_on_type_mismatch(record) - insert_record(record) - @collection << record if loaded? + result &&= insert_record(record) unless @owner.new_record? + @target << record end end - self + result and self end alias_method :push, :<< @@ -54,11 +37,13 @@ module ActiveRecord # Remove +records+ from this association. Does not destroy +records+. def delete(*records) records = flatten_deeper(records) + records.each { |record| raise_on_type_mismatch(record) } + records.reject! { |record| @target.delete(record) if record.new_record? } + return if records.empty? @owner.transaction do - records.each { |record| raise_on_type_mismatch(record) } delete_records(records) - records.each { |record| @collection.delete(record) } if loaded? + records.each { |record| @target.delete(record) } end end @@ -67,20 +52,27 @@ module ActiveRecord each { |record| record.destroy } end - @collection = [] + @target = [] end + def create(attributes = {}) + # Can't use Base.create since the foreign key may be a protected attribute. + record = build(attributes) + record.save unless @owner.new_record? + record + end + # Returns the size of the collection by executing a SELECT COUNT(*) query if the collection hasn't been loaded and # calling collection.size if it has. If it's more likely than not that the collection does have a size larger than zero # and you need to fetch that collection afterwards, it'll take one less SELECT query if you use length. def size - if loaded? then @collection.size else count_records end + if loaded? then @target.size else count_records end end # Returns the size of the collection by loading it and calling size on the array. If you want to use this method to check # whether the collection is empty, use collection.length.zero? instead of collection.empty? def length - load_collection.size + load_target.size end def empty? @@ -91,11 +83,14 @@ module ActiveRecord collection.inject([]) { |uniq_records, record| uniq_records << record unless uniq_records.include?(record); uniq_records } end - protected - def loaded? - not @collection.nil? - end + def replace(other_array) + other_array.each{ |val| raise_on_type_mismatch(val) } + + @target = other_array + @loaded = true + end + protected def quoted_record_ids(records) records.map { |record| record.quoted_id }.join(',') end @@ -117,22 +112,14 @@ module ActiveRecord end private - def load_collection - if loaded? - @collection - else - begin - @collection = find_all_records - rescue ActiveRecord::RecordNotFound - @collection = [] - end - end - end - def raise_on_type_mismatch(record) raise ActiveRecord::AssociationTypeMismatch, "#{@association_class} expected, got #{record.class}" unless record.is_a?(@association_class) end + def target_obsolete? + false + end + # Array#flatten has problems with rescursive arrays. Going one level deeper solves the majority of the problems. def flatten_deeper(array) array.collect { |element| element.respond_to?(:flatten) ? element.flatten : element }.flatten diff --git a/activerecord/lib/active_record/associations/association_proxy.rb b/activerecord/lib/active_record/associations/association_proxy.rb new file mode 100644 index 0000000000..dcba207e20 --- /dev/null +++ b/activerecord/lib/active_record/associations/association_proxy.rb @@ -0,0 +1,49 @@ +module ActiveRecord + module Associations + class AssociationProxy #:nodoc: + alias_method :proxy_respond_to?, :respond_to? + instance_methods.each { |m| undef_method m unless m =~ /(^__|^nil\?|^proxy_respond_to\?|^send)/ } + + def initialize(owner, association_name, association_class_name, association_class_primary_key_name, options) + @owner = owner + @options = options + @association_name = association_name + @association_class = eval(association_class_name) + @association_class_primary_key_name = association_class_primary_key_name + + reset + end + + def method_missing(symbol, *args, &block) + load_target + @target.send(symbol, *args, &block) + end + + def respond_to?(symbol, include_priv = false) + load_target + proxy_respond_to?(symbol, include_priv) || @target.respond_to?(symbol, include_priv) + end + + def loaded? + @loaded + end + + private + def load_target + unless @owner.new_record? + begin + @target = find_target if not loaded? + rescue ActiveRecord::RecordNotFound + reset + end + end + @loaded = true + @target + end + + def raise_on_type_mismatch(record) + raise ActiveRecord::AssociationTypeMismatch, "#{@association_class} expected, got #{record.class}" unless record.is_a?(@association_class) + end + end + end +end diff --git a/activerecord/lib/active_record/associations/belongs_to_association.rb b/activerecord/lib/active_record/associations/belongs_to_association.rb new file mode 100644 index 0000000000..aa627f7495 --- /dev/null +++ b/activerecord/lib/active_record/associations/belongs_to_association.rb @@ -0,0 +1,70 @@ +module ActiveRecord + module Associations + class BelongsToAssociation < AssociationProxy #:nodoc: + + def reset + @target = nil + @loaded = false + end + + def reload + reset + load_target + end + + def create(attributes = {}) + record = build(attributes) + record.save + record + end + + def build(attributes = {}) + record = @association_class.new(attributes) + replace(record, true) + record + end + + def replace(obj, dont_save = false) + if obj.nil? + @target = @owner[@association_class_primary_key_name] = nil + else + raise_on_type_mismatch(obj) unless obj.nil? + + @target = obj + @owner[@association_class_primary_key_name] = obj.id unless obj.new_record? + end + @loaded = true + end + + # Ugly workaround - .nil? is done in C and the method_missing trick doesn't work when we pretend to be nil + def nil? + load_target + @target.nil? + end + + private + def find_target + if @options[:conditions] + @association_class.find_on_conditions(@owner[@association_class_primary_key_name], @options[:conditions]) + else + @association_class.find(@owner[@association_class_primary_key_name]) + end + end + + def target_obsolete? + @owner[@association_class_primary_key_name] != @target.id + end + + def construct_sql + # no sql to construct + end + end + end +end + +class NilClass #:nodoc: + # Ugly workaround - nil comparison is usually done in C and so a proxy object pretending to be nil doesn't work. + def ==(other) + other.nil? + end +end diff --git a/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb b/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb index 1152846df2..83b87547ee 100644 --- a/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb +++ b/activerecord/lib/active_record/associations/has_and_belongs_to_many_association.rb @@ -1,26 +1,27 @@ module ActiveRecord module Associations class HasAndBelongsToManyAssociation < AssociationCollection #:nodoc: - def initialize(owner, association_name, association_class_name, association_class_primary_key_name, join_table, options) - super(owner, association_name, association_class_name, association_class_primary_key_name, options) + def initialize(owner, association_name, association_class_name, association_class_primary_key_name, options) + super @association_foreign_key = options[:association_foreign_key] || Inflector.underscore(Inflector.demodulize(association_class_name)) + "_id" - association_table_name = options[:table_name] || @association_class.table_name - @join_table = join_table + @association_table_name = options[:table_name] || @association_class.table_name + @join_table = options[:join_table] @order = options[:order] || "t.#{@association_class.primary_key}" - interpolate_sql_options!(options, :finder_sql, :delete_sql) - @finder_sql = options[:finder_sql] || - "SELECT t.*, j.* FROM #{association_table_name} t, #{@join_table} j " + - "WHERE t.#{@association_class.primary_key} = j.#{@association_foreign_key} AND " + - "j.#{association_class_primary_key_name} = #{@owner.quoted_id} " + - (options[:conditions] ? " AND " + interpolate_sql(options[:conditions]) : "") + " " + - "ORDER BY #{@order}" + construct_sql end + def build(attributes = {}) + load_target + record = @association_class.new(attributes) + @target << record + record + end + # Removes all records from this association. Returns +self+ so method calls may be chained. def clear - return self if size == 0 # forces load_collection if hasn't happened already + return self if size == 0 # forces load_target if hasn't happened already if sql = @options[:delete_sql] each { |record| @owner.connection.execute(sql) } @@ -34,12 +35,12 @@ module ActiveRecord @owner.connection.execute(sql) end - @collection = [] + @target = [] self end def find_first - load_collection.first + load_target.first end def find(*args) @@ -56,16 +57,16 @@ module ActiveRecord elsif @options[:finder_sql] if ids.size == 1 id = ids.first - record = load_collection.detect { |record| id == record.id } + record = load_target.detect { |record| id == record.id } expects_array? ? [record] : record else - load_collection.select { |record| ids.include?(record.id) } + load_target.select { |record| ids.include?(record.id) } end # Otherwise, construct a query. else ids_list = ids.map { |id| @owner.send(:quote, id) }.join(',') - records = find_all_records(@finder_sql.sub(/ORDER BY/, "AND j.#{@association_foreign_key} IN (#{ids_list}) ORDER BY")) + records = find_target(@finder_sql.sub(/ORDER BY/, "AND j.#{@association_foreign_key} IN (#{ids_list}) ORDER BY")) if records.size == ids.size if ids.size == 1 and !expects_array records.first @@ -82,7 +83,7 @@ module ActiveRecord raise_on_type_mismatch(record) insert_record_with_join_attributes(record, join_attributes) join_attributes.each { |key, value| record.send(:write_attribute, key, value) } - @collection << record if loaded? + @target << record self end @@ -93,16 +94,17 @@ module ActiveRecord end protected - def find_all_records(sql = @finder_sql) + def find_target(sql = @finder_sql) records = @association_class.find_by_sql(sql) @options[:uniq] ? uniq(records) : records end def count_records - load_collection.size + load_target.size end def insert_record(record) + return false unless record.save if @options[:insert_sql] @owner.connection.execute(interpolate_sql(@options[:insert_sql], record)) else @@ -110,6 +112,7 @@ module ActiveRecord "VALUES (#{@owner.quoted_id},#{record.quoted_id})" @owner.connection.execute(sql) end + true end def insert_record_with_join_attributes(record, join_attributes) @@ -129,6 +132,16 @@ module ActiveRecord @owner.connection.execute(sql) end end - end + + def construct_sql + interpolate_sql_options!(@options, :finder_sql, :delete_sql) + @finder_sql = @options[:finder_sql] || + "SELECT t.*, j.* FROM #{@association_table_name} t, #{@join_table} j " + + "WHERE t.#{@association_class.primary_key} = j.#{@association_foreign_key} AND " + + "j.#{@association_class_primary_key_name} = #{@owner.quoted_id} " + + (@options[:conditions] ? " AND " + interpolate_sql(@options[:conditions]) : "") + " " + + "ORDER BY #{@order}" + end + end end end diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index f2652f55cc..92f6f4c262 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -2,37 +2,17 @@ module ActiveRecord module Associations class HasManyAssociation < AssociationCollection #:nodoc: def initialize(owner, association_name, association_class_name, association_class_primary_key_name, options) - super(owner, association_name, association_class_name, association_class_primary_key_name, options) + super @conditions = sanitize_sql(options[:conditions]) - if options[:finder_sql] - @finder_sql = interpolate_sql(options[:finder_sql]) - else - @finder_sql = "#{@association_class_primary_key_name} = #{@owner.quoted_id}" - @finder_sql << " AND #{interpolate_sql(@conditions)}" if @conditions - end - - if options[:counter_sql] - @counter_sql = interpolate_sql(options[:counter_sql]) - elsif options[:finder_sql] - options[:counter_sql] = options[:finder_sql].gsub(/SELECT (.*) FROM/i, "SELECT COUNT(*) FROM") - @counter_sql = interpolate_sql(options[:counter_sql]) - else - @counter_sql = "#{@association_class_primary_key_name} = #{@owner.quoted_id}#{@conditions ? " AND " + interpolate_sql(@conditions) : ""}" - end - end - - def create(attributes = {}) - # Can't use Base.create since the foreign key may be a protected attribute. - record = build(attributes) - record.save - @collection << record if loaded? - record + construct_sql end def build(attributes = {}) + load_target record = @association_class.new(attributes) - record[@association_class_primary_key_name] = @owner.id + record[@association_class_primary_key_name] = @owner.id unless @owner.new_record? + @target << record record end @@ -77,10 +57,10 @@ module ActiveRecord elsif @options[:finder_sql] if ids.size == 1 id = ids.first - record = load_collection.detect { |record| id == record.id } + record = load_target.detect { |record| id == record.id } expects_array? ? [record] : record else - load_collection.select { |record| ids.include?(record.id) } + load_target.select { |record| ids.include?(record.id) } end # Otherwise, delegate to association class with conditions. @@ -94,12 +74,12 @@ module ActiveRecord # method calls may be chained. def clear @association_class.update_all("#{@association_class_primary_key_name} = NULL", "#{@association_class_primary_key_name} = #{@owner.quoted_id}") - @collection = [] + @target = [] self end protected - def find_all_records + def find_target find_all end @@ -122,7 +102,8 @@ module ActiveRecord end def insert_record(record) - record.update_attribute(@association_class_primary_key_name, @owner.id) + record[@association_class_primary_key_name] = @owner.id + record.save end def delete_records(records) @@ -132,6 +113,29 @@ module ActiveRecord "#{@association_class_primary_key_name} = #{@owner.quoted_id} AND #{@association_class.primary_key} IN (#{ids})" ) end + + def target_obsolete? + false + end + + def construct_sql + if @options[:finder_sql] + @finder_sql = interpolate_sql(@options[:finder_sql]) + else + @finder_sql = "#{@association_class_primary_key_name} = #{@owner.quoted_id}" + @finder_sql << " AND #{interpolate_sql(@conditions)}" if @conditions + end + + if @options[:counter_sql] + @counter_sql = interpolate_sql(@options[:counter_sql]) + elsif @options[:finder_sql] + @options[:counter_sql] = @options[:finder_sql].gsub(/SELECT (.*) FROM/i, "SELECT COUNT(*) FROM") + @counter_sql = interpolate_sql(@options[:counter_sql]) + else + @counter_sql = "#{@association_class_primary_key_name} = #{@owner.quoted_id}" + @counter_sql << " AND #{interpolate_sql(@conditions)}" if @conditions + end + end end end end diff --git a/activerecord/lib/active_record/associations/has_one_association.rb b/activerecord/lib/active_record/associations/has_one_association.rb new file mode 100644 index 0000000000..74e82f146a --- /dev/null +++ b/activerecord/lib/active_record/associations/has_one_association.rb @@ -0,0 +1,48 @@ +module ActiveRecord + module Associations + class HasOneAssociation < BelongsToAssociation #:nodoc: + def initialize(owner, association_name, association_class_name, association_class_primary_key_name, options) + super + + construct_sql + end + + def replace(obj, dont_save = false) + load_target + unless @target.nil? + @target[@association_class_primary_key_name] = nil + @target.save unless @owner.new_record? + end + + if obj.nil? + @target = nil + else + raise_on_type_mismatch(obj) + + obj[@association_class_primary_key_name] = @owner.id unless @owner.new_record? + @target = obj + end + + @loaded = true + unless @owner.new_record? or obj.nil? or dont_save + return (obj.save ? obj : false) + else + return obj + end + end + + private + def find_target + @association_class.find_first(@finder_sql, @options[:order]) + end + + def target_obsolete? + false + end + + def construct_sql + @finder_sql = "#{@association_class_primary_key_name} = #{@owner.quoted_id}#{@options[:conditions] ? " AND " + @options[:conditions] : ""}" + end + end + end +end diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index 8b022ef6d4..ae743b086a 100755 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -789,7 +789,6 @@ module ActiveRecord #:nodoc: # * A record does exist: Updates the record with values matching those of the object attributes. def save create_or_update - return true end # Deletes the record in the database and freezes this instance to reflect that no changes should @@ -928,9 +927,9 @@ module ActiveRecord #:nodoc: self.class.columns_hash[name.to_s] end - # Returns true if the +comparison_object+ is of the same type and has the same id. + # Returns true if the +comparison_object+ is the same object, or is of the same type and has the same id. def ==(comparison_object) - comparison_object.instance_of?(self.class) && comparison_object.id == id + comparison_object.equal?(self) or (comparison_object.instance_of?(self.class) and comparison_object.id == id) end # Delegates to == @@ -956,6 +955,7 @@ module ActiveRecord #:nodoc: private def create_or_update if new_record? then create else update end + return true end # Updates the associated record with values matching those of the instant attributes. diff --git a/activerecord/lib/active_record/callbacks.rb b/activerecord/lib/active_record/callbacks.rb index 715efe723f..b70d27f413 100755 --- a/activerecord/lib/active_record/callbacks.rb +++ b/activerecord/lib/active_record/callbacks.rb @@ -153,6 +153,12 @@ module ActiveRecord # to implement a simple performance constraint (50% more speed on a simple test case). Unlike all the other callbacks, after_find and # after_initialize can only be declared using an explicit implementation. So using the inheritable callback queue for after_find and # after_initialize won't work. + # + # == Cancelling callbacks + # + # 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. module Callbacks CALLBACKS = %w( after_find after_initialize before_save after_save before_create after_create before_update after_update before_validation @@ -227,7 +233,7 @@ module ActiveRecord # Is called _after_ Base.save (regardless of whether it's a create or update save). def after_save() end def create_or_update_with_callbacks #:nodoc: - callback(:before_save) + return false if callback(:before_save) == false result = create_or_update_without_callbacks callback(:after_save) result @@ -239,7 +245,7 @@ module ActiveRecord # Is called _after_ Base.save on new objects that haven't been saved yet (no record exists). def after_create() end def create_with_callbacks #:nodoc: - callback(:before_create) + return false if callback(:before_create) == false result = create_without_callbacks callback(:after_create) result @@ -252,7 +258,7 @@ module ActiveRecord def after_update() end def update_with_callbacks #:nodoc: - callback(:before_update) + return false if callback(:before_update) == false result = update_without_callbacks callback(:after_update) result @@ -281,8 +287,9 @@ module ActiveRecord def after_validation_on_update() end def valid_with_callbacks #:nodoc: - callback(:before_validation) - if new_record? then callback(:before_validation_on_create) else callback(:before_validation_on_update) end + return false if callback(:before_validation) == false + if new_record? then result = callback(:before_validation_on_create) else result = callback(:before_validation_on_update) end + return false if result == false result = valid_without_callbacks @@ -298,7 +305,7 @@ module ActiveRecord # Is called _after_ Base.destroy (and all the attributes have been frozen). def after_destroy() end def destroy_with_callbacks #:nodoc: - callback(:before_destroy) + return false if callback(:before_destroy) == false result = destroy_without_callbacks callback(:after_destroy) result @@ -307,7 +314,7 @@ module ActiveRecord private def callback(method) callbacks_for(method).each do |callback| - case callback + result = case callback when Symbol self.send(callback) when String @@ -321,9 +328,11 @@ module ActiveRecord raise ActiveRecordError, "Callbacks must be a symbol denoting the method to call, a string to be evaluated, a block to be invoked, or an object responding to the callback method." end end + return false if result == false end invoke_and_notify(method) + true end def callbacks_for(method) @@ -340,4 +349,4 @@ module ActiveRecord self.class.notify_observers(method, self) end end -end
\ No newline at end of file +end diff --git a/activerecord/lib/active_record/deprecated_associations.rb b/activerecord/lib/active_record/deprecated_associations.rb index 481b66bf0a..7a595eb216 100644 --- a/activerecord/lib/active_record/deprecated_associations.rb +++ b/activerecord/lib/active_record/deprecated_associations.rb @@ -18,7 +18,7 @@ module ActiveRecord end_eval end - def deprecated_remove_association_relation(association_name)# :nodoc: + def deprecated_remove_association_relation(association_name)# :nodoc: module_eval <<-"end_eval", __FILE__, __LINE__ def remove_#{association_name}(*items) #{association_name}.delete(items) @@ -50,7 +50,7 @@ module ActiveRecord end_eval end - def deprecated_create_method(collection_name)# :nodoc: + def deprecated_collection_create_method(collection_name)# :nodoc: module_eval <<-"end_eval", __FILE__, __LINE__ def create_in_#{collection_name}(attributes = {}) #{collection_name}.create(attributes) @@ -58,13 +58,51 @@ module ActiveRecord end_eval end - def deprecated_build_method(collection_name)# :nodoc: - module_eval <<-"end_eval", __FILE__, __LINE__ - def build_to_#{collection_name}(attributes = {}) - #{collection_name}.build(attributes) + def deprecated_collection_build_method(collection_name)# :nodoc: + module_eval <<-"end_eval", __FILE__, __LINE__ + def build_to_#{collection_name}(attributes = {}) + #{collection_name}.build(attributes) + end + end_eval + end + + def deprecated_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 - end_eval - end + end + end_eval + end + + def deprecated_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 deprecated_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 deprecated_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 end end diff --git a/activerecord/test/associations_test.rb b/activerecord/test/associations_test.rb index 26640d0048..0001eebe9f 100755 --- a/activerecord/test/associations_test.rb +++ b/activerecord/test/associations_test.rb @@ -24,14 +24,14 @@ class AssociationsTest < Test::Unit::TestCase end def test_force_reload - firm = Firm.new + firm = Firm.new("name" => "A New Firm, Inc") firm.save firm.clients.each {|c|} # forcing to load all clients assert firm.clients.empty?, "New firm shouldn't have client objects" assert !firm.has_clients?, "New firm shouldn't have clients" assert_equal 0, firm.clients.size, "New firm should have 0 clients" - client = Client.new("firm_id" => firm.id) + client = Client.new("name" => "TheClient.com", "firm_id" => firm.id) client.save assert firm.clients.empty?, "New firm should have cached no client objects" @@ -72,12 +72,6 @@ class HasOneAssociationsTest < Test::Unit::TestCase def test_has_one assert_equal @signals37.account, Account.find(1) assert_equal Account.find(1).credit_limit, @signals37.account.credit_limit - assert @signals37.has_account?, "37signals should have an account" - assert Account.find(1).firm?(@signals37), "37signals account should be able to backtrack" - assert Account.find(1).has_firm?, "37signals account should be able to backtrack" - - assert !Account.find(2).has_firm?, "Unknown isn't linked" - assert !Account.find(2).firm?(@signals37), "Unknown isn't linked" end def test_type_mismatch @@ -100,43 +94,105 @@ class HasOneAssociationsTest < Test::Unit::TestCase assert_nil Account.find(old_account_id).firm_id end + def test_dependence + firm = Firm.find(1) + assert !firm.account.nil? + firm.destroy + assert_equal 1, Account.find_all.length + end + def test_build firm = Firm.new("name" => "GlobalMegaCorp") firm.save - - account = firm.build_account("credit_limit" => 1000) + + account = firm.account.build("credit_limit" => 1000) + assert_equal account, firm.account assert account.save assert_equal account, firm.account end + def test_build_before_child_saved + firm = Firm.find(1) + + account = firm.account.build("credit_limit" => 1000) + assert_equal account, firm.account + assert account.new_record? + assert firm.save + assert_equal account, firm.account + assert !account.new_record? + end + + def test_build_before_either_saved + firm = Firm.new("name" => "GlobalMegaCorp") + + account = firm.account.build("credit_limit" => 1000) + assert_equal account, firm.account + assert account.new_record? + assert firm.save + assert_equal account, firm.account + assert !account.new_record? + end + def test_failing_build_association firm = Firm.new("name" => "GlobalMegaCorp") firm.save - account = firm.build_account + account = firm.account.build + assert_equal account, firm.account assert !account.save + assert_equal account, firm.account assert_equal "can't be empty", account.errors.on("credit_limit") end def test_create firm = Firm.new("name" => "GlobalMegaCorp") firm.save - assert_equal firm.create_account("credit_limit" => 1000), firm.account + assert_equal firm.account.create("credit_limit" => 1000), firm.account end - - def test_dependence - firm = Firm.find(1) - assert !firm.account.nil? - firm.destroy - assert_equal 1, Account.find_all.length + + def test_create_before_save + firm = Firm.new("name" => "GlobalMegaCorp") + assert_equal firm.account.create("credit_limit" => 1000), firm.account end def test_dependence_with_missing_association Account.destroy_all - firm = Firm.find(1) - assert !firm.has_account? + firm = Firm.find(1) + assert firm.account.nil? firm.destroy end + + def test_assignment_before_parent_saved + firm = Firm.new("name" => "GlobalMegaCorp") + firm.account = a = Account.find(1) + assert firm.new_record? + assert_equal a, firm.account + assert firm.save + assert_equal a, firm.account + assert_equal a, firm.account(true) + end + + def test_assignment_before_child_saved + firm = Firm.find(1) + firm.account = a = Account.new("credit_limit" => 1000) + assert !a.new_record? + assert_equal a, firm.account + assert_equal a, firm.account + assert_equal a, firm.account(true) + end + + def test_assignment_before_either_saved + firm = Firm.new("name" => "GlobalMegaCorp") + firm.account = a = Account.new("credit_limit" => 1000) + assert firm.new_record? + assert a.new_record? + assert_equal a, firm.account + assert firm.save + assert !firm.new_record? + assert !a.new_record? + assert_equal a, firm.account + assert_equal a, firm.account(true) + end end @@ -257,16 +313,72 @@ class HasManyAssociationsTest < Test::Unit::TestCase assert_equal 3, @signals37.clients_of_firm(true).size end + def test_adding_before_save + no_of_firms = Firm.count + no_of_clients = Client.count + new_firm = Firm.new("name" => "A New Firm, Inc") + new_firm.clients_of_firm.push Client.new("name" => "Natural Company") + new_firm.clients_of_firm << (c = Client.new("name" => "Apple")) + assert new_firm.new_record? + assert c.new_record? + assert_equal 2, new_firm.clients_of_firm.size + assert_equal no_of_firms, Firm.count # Firm was not saved to database. + assert_equal no_of_clients, Client.count # Clients were not saved to database. + assert new_firm.save + assert !new_firm.new_record? + assert !c.new_record? + assert_equal new_firm, c.firm + assert_equal no_of_firms+1, Firm.count # Firm was saved to database. + assert_equal no_of_clients+2, Client.count # Clients were saved to database. + assert_equal 2, new_firm.clients_of_firm.size + assert_equal 2, new_firm.clients_of_firm(true).size + end + + def test_invalid_adding + firm = Firm.find(1) + assert !(firm.clients_of_firm << c = Client.new) + assert c.new_record? + assert !firm.save + assert c.new_record? + end + + def test_invalid_adding_before_save + no_of_firms = Firm.count + no_of_clients = Client.count + new_firm = Firm.new("name" => "A New Firm, Inc") + new_firm.clients_of_firm.concat([c = Client.new, Client.new("name" => "Apple")]) + assert c.new_record? + assert !c.valid? + assert new_firm.valid? + assert !new_firm.save + assert c.new_record? + assert new_firm.new_record? + end + def test_build new_client = @signals37.clients_of_firm.build("name" => "Another Client") assert_equal "Another Client", new_client.name - assert new_client.save + assert new_client.new_record? + assert_equal new_client, @signals37.clients_of_firm.last + assert @signals37.save + assert !new_client.new_record? assert_equal 2, @signals37.clients_of_firm(true).size end + + def test_invalid_build + new_client = @signals37.clients_of_firm.build + assert new_client.new_record? + assert !new_client.valid? + assert_equal new_client, @signals37.clients_of_firm.last + assert !@signals37.save + assert new_client.new_record? + assert_equal 1, @signals37.clients_of_firm(true).size + end def test_create force_signal37_to_load_all_clients_of_firm new_client = @signals37.clients_of_firm.create("name" => "Another Client") + assert !new_client.new_record? assert_equal new_client, @signals37.clients_of_firm.last assert_equal new_client, @signals37.clients_of_firm(true).last end @@ -278,6 +390,14 @@ class HasManyAssociationsTest < Test::Unit::TestCase assert_equal 0, @signals37.clients_of_firm(true).size end + def test_deleting_before_save + new_firm = Firm.new("name" => "A New Firm, Inc.") + new_client = new_firm.clients_of_firm.build("name" => "Another Client") + assert_equal 1, new_firm.clients_of_firm.size + new_firm.clients_of_firm.delete(new_client) + assert_equal 0, new_firm.clients_of_firm.size + end + def test_deleting_a_collection force_signal37_to_load_all_clients_of_firm @signals37.clients_of_firm.create("name" => "Another Client") @@ -419,6 +539,44 @@ class BelongsToAssociationsTest < Test::Unit::TestCase assert_equal 0, Topic.find(debate.id).send(:read_attribute, "replies_count"), "First reply deleted" end + def test_assignment_before_parent_saved + client = Client.find_first + apple = Firm.new("name" => "Apple") + client.firm = apple + assert_equal apple, client.firm + assert apple.new_record? + assert client.save + assert apple.save + assert !apple.new_record? + assert_equal apple, client.firm + assert_equal apple, client.firm(true) + end + + def test_assignment_before_child_saved + final_cut = Client.new("name" => "Final Cut") + firm = Firm.find(1) + final_cut.firm = firm + assert final_cut.new_record? + assert final_cut.save + assert !final_cut.new_record? + assert !firm.new_record? + assert_equal firm, final_cut.firm + assert_equal firm, final_cut.firm(true) + end + + def test_assignment_before_either_saved + final_cut = Client.new("name" => "Final Cut") + apple = Firm.new("name" => "Apple") + final_cut.firm = apple + assert final_cut.new_record? + assert apple.new_record? + assert final_cut.save + assert !final_cut.new_record? + assert !apple.new_record? + assert_equal apple, final_cut.firm + assert_equal apple, final_cut.firm(true) + end + def test_field_name_same_as_foreign_key computer = Computer.find 1 assert_not_nil computer.developer, ":foreign key == attribute didn't lock up" @@ -514,6 +672,38 @@ class HasAndBelongsToManyAssociationsTest < Test::Unit::TestCase assert_equal 2, aridridel.projects.size assert_equal 2, aridridel.projects(true).size end + + def test_habtm_adding_before_save + no_of_devels = Developer.count + no_of_projects = Project.count + aridridel = Developer.new("name" => "Aridridel") + aridridel.projects.concat([Project.find(1), p = Project.new("name" => "Projekt")]) + assert aridridel.new_record? + assert p.new_record? + assert aridridel.save + assert !aridridel.new_record? + assert_equal no_of_devels+1, Developer.count + assert_equal no_of_projects+1, Project.count + assert_equal 2, aridridel.projects.size + assert_equal 2, aridridel.projects(true).size + end + + def test_build + devel = Developer.find(1) + proj = devel.projects.build("name" => "Projekt") + assert_equal devel.projects.last, proj + assert proj.new_record? + devel.save + assert !proj.new_record? + assert_equal devel.projects.last, proj + end + + def test_create + devel = Developer.find(1) + proj = devel.projects.create("name" => "Projekt") + assert_equal devel.projects.last, proj + assert !proj.new_record? + end def test_uniq_after_the_fact @developers["jamis"].find.projects << @projects["active_record"].find diff --git a/activerecord/test/callbacks_test.rb b/activerecord/test/callbacks_test.rb index 3992c0a6a0..b92cbe7798 100644 --- a/activerecord/test/callbacks_test.rb +++ b/activerecord/test/callbacks_test.rb @@ -227,4 +227,20 @@ class CallbacksTest < Test::Unit::TestCase [ :after_initialize, :method ] ], david.history end + + def test_zzz_callback_returning_false # must be run last since we modify CallbackDeveloper + david = CallbackDeveloper.find(1) + CallbackDeveloper.before_validation proc { |model| model.history << [:before_validation, :returning_false]; return false } + CallbackDeveloper.before_validation proc { |model| model.history << [:before_validation, :should_never_get_here] } + david.save + assert_equal [ + [ :after_find, :method ], + [ :after_initialize, :method ], + [ :before_validation, :string ], + [ :before_validation, :proc ], + [ :before_validation, :object ], + [ :before_validation, :block ], + [ :before_validation, :returning_false ] + ], david.history + end end diff --git a/activerecord/test/deprecated_associations_test.rb b/activerecord/test/deprecated_associations_test.rb index ed1d3c4055..f746275215 100755 --- a/activerecord/test/deprecated_associations_test.rb +++ b/activerecord/test/deprecated_associations_test.rb @@ -138,14 +138,14 @@ class DeprecatedAssociationsTest < Test::Unit::TestCase end def test_force_reload - firm = Firm.new + firm = Firm.new("name" => "A New Firm, Inc") firm.save firm.clients.each {|c|} # forcing to load all clients assert firm.clients.empty?, "New firm shouldn't have client objects" assert !firm.has_clients?, "New firm shouldn't have clients" assert_equal 0, firm.clients_count, "New firm should have 0 clients" - client = Client.new("firm_id" => firm.id) + client = Client.new("name" => "TheClient.com", "firm_id" => firm.id) client.save assert firm.clients.empty?, "New firm should have cached no client objects" @@ -340,4 +340,38 @@ class DeprecatedAssociationsTest < Test::Unit::TestCase assert_equal 2, Firm.find_first.find_all_in_clients("type = 'Client'").length assert_equal 1, Firm.find_first.find_all_in_clients("name = 'Summit'").length end + + def test_has_one + assert @signals37.account?(Account.find(1)) + assert @signals37.has_account?, "37signals should have an account" + assert Account.find(1).firm?(@signals37), "37signals account should be able to backtrack" + assert Account.find(1).has_firm?, "37signals account should be able to backtrack" + + assert !Account.find(2).has_firm?, "Unknown isn't linked" + assert !Account.find(2).firm?(@signals37), "Unknown isn't linked" + end + + def test_has_one_build + firm = Firm.new("name" => "GlobalMegaCorp") + assert firm.save + + account = firm.build_account("credit_limit" => 1000) + assert account.save + assert_equal account, firm.account + end + + def test_has_one_failing_build_association + firm = Firm.new("name" => "GlobalMegaCorp") + firm.save + + account = firm.build_account + assert !account.save + assert_equal "can't be empty", account.errors.on("credit_limit") + end + + def test_has_one_create + firm = Firm.new("name" => "GlobalMegaCorp") + firm.save + assert_equal firm.create_account("credit_limit" => 1000), firm.account + end end diff --git a/activerecord/test/fixtures/company.rb b/activerecord/test/fixtures/company.rb index 7d9a6053d4..579590e909 100755 --- a/activerecord/test/fixtures/company.rb +++ b/activerecord/test/fixtures/company.rb @@ -1,5 +1,7 @@ class Company < ActiveRecord::Base attr_protected :rating + + validates_presence_of :name end |