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
This commit is contained in:
David Heinemeier Hansson
2005-01-15 17:45:16 +00:00
parent 62f0512e54
commit 823554eafe
15 changed files with 817 additions and 324 deletions

View File

@@ -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

View File

@@ -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,46 +349,45 @@ 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
association_accessor_methods(association_name, association_class_name, association_class_primary_key_name, options, BelongsToAssociation)
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)
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(
"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 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})" +
" 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
@@ -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))
before_destroy_sql = "DELETE FROM #{join_table} WHERE #{association_class_primary_key_name} = \\\#{self.quoted_id}"
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 #{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
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
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
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
association.replace(new_value)
association
end
end
def require_association_class(class_name)
require_association(Inflector.underscore(class_name)) if class_name
end
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_eval
end
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
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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
end

View File

@@ -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

View File

@@ -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,30 +94,6 @@ class HasOneAssociationsTest < Test::Unit::TestCase
assert_nil Account.find(old_account_id).firm_id
end
def test_build
firm = Firm.new("name" => "GlobalMegaCorp")
firm.save
account = firm.build_account("credit_limit" => 1000)
assert account.save
assert_equal account, firm.account
end
def test_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_create
firm = Firm.new("name" => "GlobalMegaCorp")
firm.save
assert_equal firm.create_account("credit_limit" => 1000), firm.account
end
def test_dependence
firm = Firm.find(1)
assert !firm.account.nil?
@@ -131,12 +101,98 @@ class HasOneAssociationsTest < Test::Unit::TestCase
assert_equal 1, Account.find_all.length
end
def test_build
firm = Firm.new("name" => "GlobalMegaCorp")
firm.save
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.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.account.create("credit_limit" => 1000), firm.account
end
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

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +1,7 @@
class Company < ActiveRecord::Base
attr_protected :rating
validates_presence_of :name
end