diff options
Diffstat (limited to 'activerecord/lib/active_record')
10 files changed, 502 insertions, 284 deletions
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 |