diff options
Diffstat (limited to 'activerecord')
79 files changed, 2443 insertions, 546 deletions
diff --git a/activerecord/CHANGELOG b/activerecord/CHANGELOG index 95fdd93f65..12295ac799 100644 --- a/activerecord/CHANGELOG +++ b/activerecord/CHANGELOG @@ -1,5 +1,11 @@ *Edge* +* before_save, before_validation and before_destroy callbacks that return false will now ROLLBACK the transaction. Previously this would have been committed before the processing was aborted. #891 [Xavier Noria] + +* Transactional migrations for databases which support them. #834 [divoxx, Adam Wiggins, Tarmo Tänav] + +* Set config.active_record.timestamped_migrations = false to have migrations with numeric prefix instead of UTC timestamp. #446. [Andrew Stone, Nik Wakelin] + * change_column_default preserves the not-null constraint. #617 [Tarmo Tänav] * Fixed that create database statements would always include "DEFAULT NULL" (Nick Sieger) [#334] diff --git a/activerecord/README b/activerecord/README index d68eb28a64..d68eb28a64 100755..100644 --- a/activerecord/README +++ b/activerecord/README diff --git a/activerecord/Rakefile b/activerecord/Rakefile index 60b17e02b9..983528aff7 100755..100644 --- a/activerecord/Rakefile +++ b/activerecord/Rakefile @@ -30,7 +30,7 @@ desc 'Run mysql, sqlite, and postgresql tests by default' task :default => :test desc 'Run mysql, sqlite, and postgresql tests' -task :test => %w(test_mysql test_sqlite test_sqlite3 test_postgresql) +task :test => %w(test_mysql test_sqlite3 test_postgresql) for adapter in %w( mysql postgresql sqlite sqlite3 firebird db2 oracle sybase openbase frontbase ) Rake::TestTask.new("test_#{adapter}") { |t| diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index d4f7170305..c47ca486c8 100755..100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -21,17 +21,12 @@ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #++ -$:.unshift(File.dirname(__FILE__)) unless - $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__))) - -unless defined? ActiveSupport - active_support_path = File.dirname(__FILE__) + "/../../activesupport/lib" - if File.exist?(active_support_path) - $:.unshift active_support_path - require 'active_support' - else - require 'rubygems' - gem 'activesupport' +begin + require 'active_support' +rescue LoadError + activesupport_path = "#{File.dirname(__FILE__)}/../../activesupport/lib" + if File.directory?(activesupport_path) + $:.unshift activesupport_path require 'active_support' end end @@ -56,6 +51,7 @@ require 'active_record/calculations' require 'active_record/serialization' require 'active_record/attribute_methods' require 'active_record/dirty' +require 'active_record/dynamic_finder_match' ActiveRecord::Base.class_eval do extend ActiveRecord::QueryCache @@ -80,3 +76,8 @@ end require 'active_record/connection_adapters/abstract_adapter' require 'active_record/schema_dumper' + +I18n.backend.populate do + I18n.load_translations File.dirname(__FILE__) + '/active_record/locale/en-US.yml' +end + diff --git a/activerecord/lib/active_record/association_preload.rb b/activerecord/lib/active_record/association_preload.rb index 174ec95de2..61fa34ac39 100644 --- a/activerecord/lib/active_record/association_preload.rb +++ b/activerecord/lib/active_record/association_preload.rb @@ -34,7 +34,7 @@ module ActiveRecord class_to_reflection = {} # Not all records have the same class, so group then preload # group on the reflection itself so that if various subclass share the same association then we do not split them - # unncessarily + # unnecessarily records.group_by {|record| class_to_reflection[record.class] ||= record.class.reflections[association]}.each do |reflection, records| raise ConfigurationError, "Association named '#{ association }' was not found; perhaps you misspelled it?" unless reflection send("preload_#{reflection.macro}_association", records, reflection, preload_options) @@ -51,9 +51,7 @@ module ActiveRecord def add_preloaded_record_to_collection(parent_records, reflection_name, associated_record) parent_records.each do |parent_record| - association_proxy = parent_record.send(reflection_name) - association_proxy.loaded - association_proxy.target = associated_record + parent_record.send("set_#{reflection_name}_target", associated_record) end end @@ -112,8 +110,8 @@ module ActiveRecord def preload_has_one_association(records, reflection, preload_options={}) id_to_record_map, ids = construct_id_map(records) options = reflection.options + records.each {|record| record.send("set_#{reflection.name}_target", nil)} if options[:through] - records.each {|record| record.send(reflection.name) && record.send(reflection.name).loaded} through_records = preload_through_records(records, reflection, options[:through]) through_reflection = reflections[options[:through]] through_primary_key = through_reflection.primary_key_name @@ -126,8 +124,6 @@ module ActiveRecord end end else - records.each {|record| record.send("set_#{reflection.name}_target", nil)} - set_association_single_records(id_to_record_map, reflection.name, find_associated_records(ids, reflection, preload_options), reflection.primary_key_name) end end @@ -252,7 +248,7 @@ module ActiveRecord table_name = reflection.klass.quoted_table_name if interface = reflection.options[:as] - conditions = "#{reflection.klass.quoted_table_name}.#{connection.quote_column_name "#{interface}_id"} IN (?) and #{reflection.klass.quoted_table_name}.#{connection.quote_column_name "#{interface}_type"} = '#{self.base_class.name.demodulize}'" + conditions = "#{reflection.klass.quoted_table_name}.#{connection.quote_column_name "#{interface}_id"} IN (?) and #{reflection.klass.quoted_table_name}.#{connection.quote_column_name "#{interface}_type"} = '#{self.base_class.sti_name}'" else foreign_key = reflection.primary_key_name conditions = "#{reflection.klass.quoted_table_name}.#{foreign_key} IN (?)" diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 7ad7802cbc..f915daafba 100755 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -73,6 +73,7 @@ module ActiveRecord end end + # See ActiveRecord::Associations::ClassMethods for documentation. module Associations # :nodoc: def self.included(base) base.extend(ClassMethods) @@ -150,6 +151,7 @@ module ActiveRecord # #others.destroy_all | X | X | X # #others.find(*args) | X | X | X # #others.find_first | X | | + # #others.exist? | X | X | X # #others.uniq | X | X | X # #others.reset | X | X | X # @@ -582,12 +584,13 @@ module ActiveRecord # has_many :clients # end # - # class Company < ActiveRecord::Base; end + # class Client < ActiveRecord::Base; end # end # end # - # When Firm#clients is called, it will in turn call <tt>MyApplication::Business::Company.find(firm.id)</tt>. If you want to associate - # with a class in another module scope, this can be done by specifying the complete class name. Example: + # When <tt>Firm#clients</tt> is called, it will in turn call <tt>MyApplication::Business::Client.find_all_by_firm_id(firm.id)</tt>. + # If you want to associate with a class in another module scope, this can be done by specifying the complete class name. + # Example: # # module MyApplication # module Business @@ -611,31 +614,53 @@ module ActiveRecord # All of the association macros can be specialized through options. This makes cases more complex than the simple and guessable ones # possible. 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>clients.empty?</tt>. - # * <tt>collection(force_reload = false)</tt> - Returns an array of all the associated objects. + # Specifies a one-to-many association. The following methods for retrieval and query of + # collections of associated objects will be added: + # + # [collection(force_reload = false)] + # 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. - # * <tt>collection.delete(object, ...)</tt> - Removes one or more objects from the collection by setting their foreign keys to +NULL+. + # [collection<<(object, ...)] + # Adds one or more objects to the collection by setting their foreign keys to the collection's primary key. + # [collection.delete(object, ...)] + # Removes one or more objects from the collection by setting their foreign keys to +NULL+. # This will also destroy the objects if they're declared as +belongs_to+ and dependent on this model. - # * <tt>collection=objects</tt> - Replaces the collections content by deleting and adding objects as appropriate. - # * <tt>collection_singular_ids</tt> - Returns an array of the associated objects' ids - # * <tt>collection_singular_ids=ids</tt> - Replace the collection with the objects identified by the primary keys in +ids+ - # * <tt>collection.clear</tt> - Removes every object from the collection. This destroys the associated objects if they - # are associated with <tt>:dependent => :destroy</tt>, deletes them directly from the database if <tt>:dependent => :delete_all</tt>, - # otherwise sets their foreign keys to +NULL+. - # * <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</tt> - Finds an associated object according to the same rules as Base.find. - # * <tt>collection.build(attributes = {}, ...)</tt> - Returns one or more new objects of the collection type that have been instantiated - # with +attributes+ and linked to this object through a foreign key, but have not yet been saved. *Note:* This only works if an - # associated object already exists, not if it's +nil+! - # * <tt>collection.create(attributes = {})</tt> - Returns a new object of the collection type that has been instantiated - # with +attributes+, linked to this object through a foreign key, and that has already been saved (if it passed the validation). - # *Note:* This only works if an associated object already exists, not if it's +nil+! + # [collection=objects] + # Replaces the collections content by deleting and adding objects as appropriate. + # [collection_singular_ids] + # Returns an array of the associated objects' ids + # [collection_singular_ids=ids] + # Replace the collection with the objects identified by the primary keys in +ids+ + # [collection.clear] + # Removes every object from the collection. This destroys the associated objects if they + # are associated with <tt>:dependent => :destroy</tt>, deletes them directly from the + # database if <tt>:dependent => :delete_all</tt>, otherwise sets their foreign keys to +NULL+. + # [collection.empty?] + # Returns +true+ if there are no associated objects. + # [collection.size] + # Returns the number of associated objects. + # [collection.find(...)] + # Finds an associated object according to the same rules as ActiveRecord::Base.find. + # [collection.exist?(...)] + # Checks whether an associated object with the given conditions exists. + # Uses the same rules as ActiveRecord::Base.exists?. + # [collection.build(attributes = {}, ...)] + # Returns one or more new objects of the collection type that have been instantiated + # with +attributes+ and linked to this object through a foreign key, but have not yet + # been saved. <b>Note:</b> This only works if an associated object already exists, not if + # it's +nil+! + # [collection.create(attributes = {})] + # Returns a new object of the collection type that has been instantiated + # with +attributes+, linked to this object through a foreign key, and that has already + # been saved (if it passed the validation). <b>Note:</b> This only works if an associated + # object already exists, not if it's +nil+! + # + # (*Note*: +collection+ is replaced with the symbol passed as the first argument, so + # <tt>has_many :clients</tt> would add among others <tt>clients.empty?</tt>.) # - # Example: A Firm class declares <tt>has_many :clients</tt>, which will add: + # === Example + # + # A Firm class declares <tt>has_many :clients</tt>, which will add: # * <tt>Firm#clients</tt> (similar to <tt>Clients.find :all, :conditions => "firm_id = #{id}"</tt>) # * <tt>Firm#clients<<</tt> # * <tt>Firm#clients.delete</tt> @@ -646,54 +671,77 @@ module ActiveRecord # * <tt>Firm#clients.empty?</tt> (similar to <tt>firm.clients.size == 0</tt>) # * <tt>Firm#clients.size</tt> (similar to <tt>Client.count "firm_id = #{id}"</tt>) # * <tt>Firm#clients.find</tt> (similar to <tt>Client.find(id, :conditions => "firm_id = #{id}")</tt>) + # * <tt>Firm#clients.exist?(:name => 'ACME')</tt> (similar to <tt>Client.exist?(:name => 'ACME', :firm_id => firm.id)</tt>) # * <tt>Firm#clients.build</tt> (similar to <tt>Client.new("firm_id" => id)</tt>) # * <tt>Firm#clients.create</tt> (similar to <tt>c = Client.new("firm_id" => id); c.save; c</tt>) # The declaration can also include an options hash to specialize the behavior of the association. # - # Options are: - # * <tt>:class_name</tt> - Specify the class name of the association. Use it only if that name can't be inferred + # === Supported options + # [:class_name] + # Specify the class name of the association. Use it only if that name can't be inferred # from the association name. So <tt>has_many :products</tt> will by default be linked to the Product class, but # if the real class name is SpecialProduct, you'll have to specify it with this option. - # * <tt>:conditions</tt> - Specify the conditions that the associated objects must meet in order to be included as a +WHERE+ + # [:conditions] + # Specify the conditions that the associated objects must meet in order to be included as a +WHERE+ # SQL fragment, such as <tt>price > 5 AND name LIKE 'B%'</tt>. Record creations from the association are scoped if a hash # is used. <tt>has_many :posts, :conditions => {:published => true}</tt> will create published posts with <tt>@blog.posts.create</tt> # or <tt>@blog.posts.build</tt>. - # * <tt>:order</tt> - Specify the order in which the associated objects are returned as an <tt>ORDER BY</tt> SQL fragment, + # [:order] + # Specify the order in which the associated objects are returned as an <tt>ORDER BY</tt> SQL fragment, # such as <tt>last_name, first_name DESC</tt>. - # * <tt>:foreign_key</tt> - Specify the foreign key used for the association. By default this is guessed to be the name + # [:foreign_key] + # Specify the foreign key used for the association. By default this is guessed to be the name # of this class in lower-case and "_id" suffixed. So a Person class that makes a +has_many+ association will use "person_id" # as the default <tt>:foreign_key</tt>. - # * <tt>:primary_key</tt> - Specify the method that returns the primary key used for the association. By default this is +id+. - # * <tt>:dependent</tt> - If set to <tt>:destroy</tt> all the associated objects are destroyed + # [:primary_key] + # Specify the method that returns the primary key used for the association. By default this is +id+. + # [:dependent] + # If set to <tt>:destroy</tt> all the associated objects are destroyed # alongside this object by calling their +destroy+ method. If set to <tt>:delete_all</tt> all associated # objects are deleted *without* calling their +destroy+ method. If set to <tt>:nullify</tt> all associated # objects' foreign keys are set to +NULL+ *without* calling their +save+ callbacks. *Warning:* This option is ignored when also using # the <tt>:through</tt> option. - # * <tt>:finder_sql</tt> - Specify a complete SQL statement to fetch the association. This is a good way to go for complex + # [:finder_sql] + # Specify a complete SQL statement to fetch the association. This is a good way to go for complex # associations that depend on multiple tables. Note: When this option is used, +find_in_collection+ is _not_ added. - # * <tt>:counter_sql</tt> - Specify a complete SQL statement to fetch the size of the association. If <tt>:finder_sql</tt> is + # [:counter_sql] + # Specify a complete SQL statement to fetch the size of the association. If <tt>:finder_sql</tt> is # specified but not <tt>:counter_sql</tt>, <tt>:counter_sql</tt> will be generated by replacing <tt>SELECT ... FROM</tt> with <tt>SELECT COUNT(*) FROM</tt>. - # * <tt>:extend</tt> - Specify a named module for extending the proxy. See "Association extensions". - # * <tt>:include</tt> - Specify second-order associations that should be eager loaded when the collection is loaded. - # * <tt>:group</tt> - An attribute name by which the result should be grouped. Uses the <tt>GROUP BY</tt> SQL-clause. - # * <tt>:limit</tt> - An integer determining the limit on the number of rows that should be returned. - # * <tt>:offset</tt> - An integer determining the offset from where the rows should be fetched. So at 5, it would skip the first 4 rows. - # * <tt>:select</tt> - By default, this is <tt>*</tt> as in <tt>SELECT * FROM</tt>, but can be changed if you, for example, want to do a join + # [:extend] + # Specify a named module for extending the proxy. See "Association extensions". + # [:include] + # Specify second-order associations that should be eager loaded when the collection is loaded. + # [:group] + # An attribute name by which the result should be grouped. Uses the <tt>GROUP BY</tt> SQL-clause. + # [:limit] + # An integer determining the limit on the number of rows that should be returned. + # [:offset] + # An integer determining the offset from where the rows should be fetched. So at 5, it would skip the first 4 rows. + # [:select] + # By default, this is <tt>*</tt> as in <tt>SELECT * FROM</tt>, but can be changed if you, for example, want to do a join # but not include the joined columns. Do not forget to include the primary and foreign keys, otherwise it will raise an error. - # * <tt>:as</tt> - Specifies a polymorphic interface (See <tt>belongs_to</tt>). - # * <tt>:through</tt> - Specifies a Join Model through which to perform the query. Options for <tt>:class_name</tt> and <tt>:foreign_key</tt> + # [:as] + # Specifies a polymorphic interface (See <tt>belongs_to</tt>). + # [:through] + # Specifies a Join Model through which to perform the query. Options for <tt>:class_name</tt> and <tt>:foreign_key</tt> # are ignored, as the association uses the source reflection. You can only use a <tt>:through</tt> query through a <tt>belongs_to</tt> # or <tt>has_many</tt> association on the join model. - # * <tt>:source</tt> - Specifies the source association name used by <tt>has_many :through</tt> queries. Only use it if the name cannot be + # [:source] + # Specifies the source association name used by <tt>has_many :through</tt> queries. Only use it if the name cannot be # inferred from the association. <tt>has_many :subscribers, :through => :subscriptions</tt> will look for either <tt>:subscribers</tt> or # <tt>:subscriber</tt> on Subscription, unless a <tt>:source</tt> is given. - # * <tt>:source_type</tt> - Specifies type of the source association used by <tt>has_many :through</tt> queries where the source + # [:source_type] + # Specifies type of the source association used by <tt>has_many :through</tt> queries where the source # association is a polymorphic +belongs_to+. - # * <tt>:uniq</tt> - If true, duplicates will be omitted from the collection. Useful in conjunction with <tt>:through</tt>. - # * <tt>:readonly</tt> - If true, all the associated objects are readonly through the association. - # * <tt>:validate</tt> - If false, don't validate the associated objects when saving the parent object. true by default. - # * <tt>:accessible</tt> - Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>). - # + # [:uniq] + # If true, duplicates will be omitted from the collection. Useful in conjunction with <tt>:through</tt>. + # [:readonly] + # If true, all the associated objects are readonly through the association. + # [:validate] + # If false, don't validate the associated objects when saving the parent object. true by default. + # [:accessible] + # Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>). + # Option examples: # has_many :comments, :order => "posted_on" # has_many :comments, :include => :author @@ -724,58 +772,91 @@ module ActiveRecord end 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>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, + # Specifies a one-to-one association with another class. This method should only be used + # if the other class contains the foreign key. If the current class contains the foreign key, + # then you should use +belongs_to+ instead. See also ActiveRecord::Associations::ClassMethods's overview + # on when to use has_one and when to use belongs_to. + # + # The following methods for retrieval and query of a single associated object will be added: + # + # [association(force_reload = false)] + # Returns the associated object. +nil+ is returned if none is found. + # [association=(associate)] + # Assigns the associate object, extracts the primary key, sets it as the foreign key, # and saves the associate object. - # * <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 - # with +attributes+ and linked to this object through a foreign key, but has not yet been saved. Note: This ONLY works if - # an association already exists. It will NOT work if the association is +nil+. - # * <tt>create_association(attributes = {})</tt> - Returns a new object of the associated type that has been instantiated - # with +attributes+, linked to this object through a foreign key, and that has already been saved (if it passed the validation). + # [association.nil?] + # Returns +true+ if there is no associated object. + # [build_association(attributes = {})] + # Returns a new object of the associated type that has been instantiated + # with +attributes+ and linked to this object through a foreign key, but has not + # yet been saved. <b>Note:</b> This ONLY works if an association already exists. + # It will NOT work if the association is +nil+. + # [create_association(attributes = {})] + # Returns a new object of the associated type that has been instantiated + # with +attributes+, 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: + # (+association+ is replaced with the symbol passed as the first argument, so + # <tt>has_one :manager</tt> would add among others <tt>manager.nil?</tt>.) + # + # === Example + # + # An Account class declares <tt>has_one :beneficiary</tt>, which will add: # * <tt>Account#beneficiary</tt> (similar to <tt>Beneficiary.find(:first, :conditions => "account_id = #{id}")</tt>) # * <tt>Account#beneficiary=(beneficiary)</tt> (similar to <tt>beneficiary.account_id = account.id; beneficiary.save</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>) # + # === Options + # # The declaration can also include an options hash to specialize the behavior of the association. # # Options are: - # * <tt>:class_name</tt> - Specify the class name of the association. Use it only if that name can't be inferred + # [:class_name] + # Specify the class name of the association. Use it only if that name can't be inferred # from the association name. So <tt>has_one :manager</tt> will by default be linked to the Manager class, but # if the real class name is Person, you'll have to specify it with this option. - # * <tt>:conditions</tt> - Specify the conditions that the associated object must meet in order to be included as a +WHERE+ + # [:conditions] + # Specify the conditions that the associated object must meet in order to be included as a +WHERE+ # SQL fragment, such as <tt>rank = 5</tt>. - # * <tt>:order</tt> - Specify the order in which the associated objects are returned as an <tt>ORDER BY</tt> SQL fragment, + # [:order] + # Specify the order in which the associated objects are returned as an <tt>ORDER BY</tt> SQL fragment, # such as <tt>last_name, first_name DESC</tt>. - # * <tt>:dependent</tt> - If set to <tt>:destroy</tt>, the associated object is destroyed when this object is. If set to + # [:dependent] + # If set to <tt>:destroy</tt>, the associated object is destroyed when this object is. If set to # <tt>:delete</tt>, the associated object is deleted *without* calling its destroy method. If set to <tt>:nullify</tt>, the associated # object's foreign key is set to +NULL+. Also, association is assigned. - # * <tt>:foreign_key</tt> - Specify the foreign key used for the association. By default this is guessed to be the name + # [:foreign_key] + # Specify the foreign key used for the association. By default this is guessed to be the name # of this class in lower-case and "_id" suffixed. So a Person class that makes a +has_one+ association will use "person_id" # as the default <tt>:foreign_key</tt>. - # * <tt>:primary_key</tt> - Specify the method that returns the primary key used for the association. By default this is +id+. - # * <tt>:include</tt> - Specify second-order associations that should be eager loaded when this object is loaded. - # * <tt>:as</tt> - Specifies a polymorphic interface (See <tt>belongs_to</tt>). - # * <tt>:select</tt> - By default, this is <tt>*</tt> as in <tt>SELECT * FROM</tt>, but can be changed if, for example, you want to do a join + # [:primary_key] + # Specify the method that returns the primary key used for the association. By default this is +id+. + # [:include] + # Specify second-order associations that should be eager loaded when this object is loaded. + # [:as] + # Specifies a polymorphic interface (See <tt>belongs_to</tt>). + # [:select] + # By default, this is <tt>*</tt> as in <tt>SELECT * FROM</tt>, but can be changed if, for example, you want to do a join # but not include the joined columns. Do not forget to include the primary and foreign keys, otherwise it will raise an error. - # * <tt>:through</tt>: Specifies a Join Model through which to perform the query. Options for <tt>:class_name</tt> and <tt>:foreign_key</tt> + # [:through] + # Specifies a Join Model through which to perform the query. Options for <tt>:class_name</tt> and <tt>:foreign_key</tt> # are ignored, as the association uses the source reflection. You can only use a <tt>:through</tt> query through a # <tt>has_one</tt> or <tt>belongs_to</tt> association on the join model. - # * <tt>:source</tt> - Specifies the source association name used by <tt>has_one :through</tt> queries. Only use it if the name cannot be + # [:source] + # Specifies the source association name used by <tt>has_one :through</tt> queries. Only use it if the name cannot be # inferred from the association. <tt>has_one :favorite, :through => :favorites</tt> will look for a # <tt>:favorite</tt> on Favorite, unless a <tt>:source</tt> is given. - # * <tt>:source_type</tt> - Specifies type of the source association used by <tt>has_one :through</tt> queries where the source + # [:source_type] + # Specifies type of the source association used by <tt>has_one :through</tt> queries where the source # association is a polymorphic +belongs_to+. - # * <tt>:readonly</tt> - If true, the associated object is readonly through the association. - # * <tt>:validate</tt> - If false, don't validate the associated object when saving the parent object. +false+ by default. - # * <tt>:accessible</tt> - Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>). + # [:readonly] + # If true, the associated object is readonly through the association. + # [:validate] + # If false, don't validate the associated object when saving the parent object. +false+ by default. + # [:accessible] + # Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>). # # Option examples: # has_one :credit_card, :dependent => :destroy # destroys the associated credit card @@ -815,18 +896,34 @@ module ActiveRecord end end - # Adds the following methods for retrieval and query for a single associated object for which this object holds an id: - # +association+ is replaced with the symbol passed as the first argument, so - # <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.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 + # Specifies a one-to-one association with another class. This method should only be used + # if this class contains the foreign key. If the other class contains the foreign key, + # then you should use +has_one+ instead. See also ActiveRecord::Associations::ClassMethods's overview + # on when to use +has_one+ and when to use +belongs_to+. + # + # Methods will be added for retrieval and query for a single associated object, for which + # this object holds an id: + # + # [association(force_reload = false)] + # Returns the associated object. +nil+ is returned if none is found. + # [association=(associate)] + # Assigns the associate object, extracts the primary key, and sets it as the foreign key. + # [association.nil?] + # Returns +true+ if there is no associated object. + # [build_association(attributes = {})] + # Returns a new object of the associated type that has been instantiated # with +attributes+ and linked to this object through a foreign key, but has not yet been saved. - # * <tt>create_association(attributes = {})</tt> - Returns a new object of the associated type that has been instantiated - # with +attributes+, linked to this object through a foreign key, and that has already been saved (if it passed the validation). + # [create_association(attributes = {})] + # Returns a new object of the associated type that has been instantiated + # with +attributes+, linked to this object through a foreign key, and that + # has already been saved (if it passed the validation). + # + # (+association+ is replaced with the symbol passed as the first argument, so + # <tt>belongs_to :author</tt> would add among others <tt>author.nil?</tt>.) # - # Example: A Post class declares <tt>belongs_to :author</tt>, which will add: + # === Example + # + # A Post class declares <tt>belongs_to :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>) @@ -835,23 +932,30 @@ module ActiveRecord # * <tt>Post#create_author</tt> (similar to <tt>post.author = Author.new; post.author.save; post.author</tt>) # The declaration can also include an options hash to specialize the behavior of the association. # - # Options are: - # * <tt>:class_name</tt> - Specify the class name of the association. Use it only if that name can't be inferred + # === Options + # + # [:class_name] + # Specify the class name of the association. Use it only if that name can't be inferred # from the association name. So <tt>has_one :author</tt> will by default be linked to the Author class, but # if the real class name is Person, you'll have to specify it with this option. - # * <tt>:conditions</tt> - Specify the conditions that the associated object must meet in order to be included as a +WHERE+ + # [:conditions] + # Specify the conditions that the associated object must meet in order to be included as a +WHERE+ # SQL fragment, such as <tt>authorized = 1</tt>. - # * <tt>:select</tt> - By default, this is <tt>*</tt> as in <tt>SELECT * FROM</tt>, but can be changed if, for example, you want to do a join + # [:select] + # By default, this is <tt>*</tt> as in <tt>SELECT * FROM</tt>, but can be changed if, for example, you want to do a join # but not include the joined columns. Do not forget to include the primary and foreign keys, otherwise it will raise an error. - # * <tt>:foreign_key</tt> - Specify the foreign key used for the association. By default this is guessed to be the name + # [:foreign_key] + # Specify the foreign key used for the association. By default this is guessed to be the name # of the association with an "_id" suffix. So a class that defines a <tt>belongs_to :person</tt> association will use # "person_id" as the default <tt>:foreign_key</tt>. Similarly, <tt>belongs_to :favorite_person, :class_name => "Person"</tt> # will use a foreign key of "favorite_person_id". - # * <tt>:dependent</tt> - If set to <tt>:destroy</tt>, the associated object is destroyed when this object is. If set to + # [:dependent] + # If set to <tt>:destroy</tt>, the associated object is destroyed when this object is. If set to # <tt>:delete</tt>, the associated object is deleted *without* calling its destroy method. This option should not be specified when # <tt>belongs_to</tt> is used in conjunction with a <tt>has_many</tt> relationship on another class because of the potential to leave # orphaned records behind. - # * <tt>:counter_cache</tt> - Caches the number of belonging objects on the associate class through the use of +increment_counter+ + # [:counter_cache] + # Caches the number of belonging objects on the associate class through the use of +increment_counter+ # and +decrement_counter+. The counter cache is incremented when an object of this class is created and decremented when it's # destroyed. This requires that a column named <tt>#{table_name}_count</tt> (such as +comments_count+ for a belonging Comment class) # is used on the associate class (such as a Post class). You can also specify a custom counter cache column by providing @@ -859,13 +963,18 @@ module ActiveRecord # When creating a counter cache column, the database statement or migration must specify a default value of <tt>0</tt>, failing to do # this results in a counter with +NULL+ value, which will never increment. # Note: Specifying a counter cache will add it to that model's list of readonly attributes using +attr_readonly+. - # * <tt>:include</tt> - Specify second-order associations that should be eager loaded when this object is loaded. - # * <tt>:polymorphic</tt> - Specify this association is a polymorphic association by passing +true+. + # [:include] + # Specify second-order associations that should be eager loaded when this object is loaded. + # [:polymorphic] + # Specify this association is a polymorphic association by passing +true+. # Note: If you've enabled the counter cache, then you may want to add the counter cache attribute # to the +attr_readonly+ list in the associated classes (e.g. <tt>class Post; attr_readonly :comments_count; end</tt>). - # * <tt>:readonly</tt> - If true, the associated object is readonly through the association. - # * <tt>:validate</tt> - If false, don't validate the associated objects when saving the parent object. +false+ by default. - # * <tt>:accessible</tt> - Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>). + # [:readonly] + # If true, the associated object is readonly through the association. + # [:validate] + # If false, don't validate the associated objects when saving the parent object. +false+ by default. + # [:accessible] + # Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>). # # Option examples: # belongs_to :firm, :foreign_key => "client_of" @@ -951,8 +1060,9 @@ module ActiveRecord configure_dependency_for_belongs_to(reflection) 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 + # Specifies a many-to-many relationship with another class. This 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". Note that this precedence # is calculated using the <tt><</tt> operator for String. This means that if the strings are of different lengths, # and the strings are equal when compared up to the shortest length, then the longer string is considered of higher @@ -967,28 +1077,48 @@ module ActiveRecord # associations with attributes to a real join model (see introduction). # # 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 <tt>categories.empty?</tt>. - # * <tt>collection(force_reload = false)</tt> - Returns an array of all the associated objects. + # + # [collection(force_reload = false)] + # 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 creating associations in the join table + # [collection<<(object, ...)] + # Adds one or more objects to the collection by creating associations in the join table # (<tt>collection.push</tt> and <tt>collection.concat</tt> are aliases to this method). - # * <tt>collection.delete(object, ...)</tt> - Removes one or more objects from the collection by removing their associations from the join table. + # [collection.delete(object, ...)] + # Removes one or more objects from the collection by removing their associations from the join table. # This does not destroy the objects. - # * <tt>collection=objects</tt> - Replaces the collection's content by deleting and adding objects as appropriate. - # * <tt>collection_singular_ids</tt> - Returns an array of the associated objects' ids. - # * <tt>collection_singular_ids=ids</tt> - Replace the collection by the objects identified by the primary keys in +ids+. - # * <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 + # [collection=objects] + # Replaces the collection's content by deleting and adding objects as appropriate. + # [collection_singular_ids] + # Returns an array of the associated objects' ids. + # [collection_singular_ids=ids] + # Replace the collection by the objects identified by the primary keys in +ids+. + # [collection.clear] + # Removes every object from the collection. This does not destroy the objects. + # [collection.empty?] + # Returns +true+ if there are no associated objects. + # [collection.size] + # Returns the number of associated objects. + # [collection.find(id)] + # Finds an associated object responding to the +id+ and that # meets the condition that it has to be associated with this object. - # * <tt>collection.build(attributes = {})</tt> - Returns a new object of the collection type that has been instantiated + # Uses the same rules as ActiveRecord::Base.find. + # [collection.exist?(...)] + # Checks whether an associated object with the given conditions exists. + # Uses the same rules as ActiveRecord::Base.exists?. + # [collection.build(attributes = {})] + # Returns a new object of the collection type that has been instantiated # with +attributes+ and linked to this object through the join table, but has not yet been saved. - # * <tt>collection.create(attributes = {})</tt> - Returns a new object of the collection type that has been instantiated + # [collection.create(attributes = {})] + # Returns a new object of the collection type that has been instantiated # with +attributes+, linked to this object through the join table, and that has already been saved (if it passed the validation). # - # Example: A Developer class declares <tt>has_and_belongs_to_many :projects</tt>, which will add: + # (+collection+ is replaced with the symbol passed as the first argument, so + # <tt>has_and_belongs_to_many :categories</tt> would add among others <tt>categories.empty?</tt>.) + # + # === Example + # + # A 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.delete</tt> @@ -999,45 +1129,66 @@ module ActiveRecord # * <tt>Developer#projects.empty?</tt> # * <tt>Developer#projects.size</tt> # * <tt>Developer#projects.find(id)</tt> + # * <tt>Developer#clients.exist?(...)</tt> # * <tt>Developer#projects.build</tt> (similar to <tt>Project.new("project_id" => id)</tt>) # * <tt>Developer#projects.create</tt> (similar to <tt>c = Project.new("project_id" => id); c.save; c</tt>) # The declaration may include an options hash to specialize the behavior of the association. # - # Options are: - # * <tt>:class_name</tt> - Specify the class name of the association. Use it only if that name can't be inferred + # === Options + # + # [:class_name] + # Specify the class name of the association. Use it only if that name can't be inferred # from the association name. So <tt>has_and_belongs_to_many :projects</tt> will by default be linked to the # Project class, but if the real class name is SuperProject, you'll have to specify it with this option. - # * <tt>:join_table</tt> - Specify the name of the join table if the default based on lexical order isn't what you want. - # WARNING: If you're overwriting the table name of either class, the +table_name+ method MUST be declared underneath any - # +has_and_belongs_to_many+ declaration in order to work. - # * <tt>:foreign_key</tt> - Specify the foreign key used for the association. By default this is guessed to be the name + # [:join_table] + # Specify the name of the join table if the default based on lexical order isn't what you want. + # <b>WARNING:</b> If you're overwriting the table name of either class, the +table_name+ method + # MUST be declared underneath any +has_and_belongs_to_many+ declaration in order to work. + # [:foreign_key] + # Specify the foreign key used for the association. By default this is guessed to be the name # of this class in lower-case and "_id" suffixed. So a Person class that makes a +has_and_belongs_to_many+ association # will use "person_id" as the default <tt>:foreign_key</tt>. - # * <tt>:association_foreign_key</tt> - Specify the association foreign key used for the association. By default this is + # [:association_foreign_key] + # Specify the association foreign key used for the association. By default this is # guessed to be the name of the associated class in lower-case and "_id" suffixed. So if the associated class is Project, # the +has_and_belongs_to_many+ association will use "project_id" as the default <tt>:association_foreign_key</tt>. - # * <tt>:conditions</tt> - Specify the conditions that the associated object must meet in order to be included as a +WHERE+ + # [:conditions] + # Specify the conditions that the associated object must meet in order to be included as a +WHERE+ # SQL fragment, such as <tt>authorized = 1</tt>. Record creations from the association are scoped if a hash is used. # <tt>has_many :posts, :conditions => {:published => true}</tt> will create published posts with <tt>@blog.posts.create</tt> # or <tt>@blog.posts.build</tt>. - # * <tt>:order</tt> - Specify the order in which the associated objects are returned as an <tt>ORDER BY</tt> SQL fragment, + # [:order] + # Specify the order in which the associated objects are returned as an <tt>ORDER BY</tt> SQL fragment, # such as <tt>last_name, first_name DESC</tt> - # * <tt>:uniq</tt> - If true, duplicate associated objects will be ignored by accessors and query methods. - # * <tt>:finder_sql</tt> - Overwrite the default generated SQL statement used to fetch the association with a manual statement - # * <tt>:delete_sql</tt> - Overwrite the default generated SQL statement used to remove links between the associated + # [:uniq] + # If true, duplicate associated objects will be ignored by accessors and query methods. + # [:finder_sql] + # Overwrite the default generated SQL statement used to fetch the association with a manual statement + # [:delete_sql] + # Overwrite the default generated SQL statement used to remove links between the associated # classes with a manual statement. - # * <tt>:insert_sql</tt> - Overwrite the default generated SQL statement used to add links between the associated classes + # [:insert_sql] + # Overwrite the default generated SQL statement used to add links between the associated classes # with a manual statement. - # * <tt>:extend</tt> - Anonymous module for extending the proxy, see "Association extensions". - # * <tt>:include</tt> - Specify second-order associations that should be eager loaded when the collection is loaded. - # * <tt>:group</tt> - An attribute name by which the result should be grouped. Uses the <tt>GROUP BY</tt> SQL-clause. - # * <tt>:limit</tt> - An integer determining the limit on the number of rows that should be returned. - # * <tt>:offset</tt> - An integer determining the offset from where the rows should be fetched. So at 5, it would skip the first 4 rows. - # * <tt>:select</tt> - By default, this is <tt>*</tt> as in <tt>SELECT * FROM</tt>, but can be changed if, for example, you want to do a join + # [:extend] + # Anonymous module for extending the proxy, see "Association extensions". + # [:include] + # Specify second-order associations that should be eager loaded when the collection is loaded. + # [:group] + # An attribute name by which the result should be grouped. Uses the <tt>GROUP BY</tt> SQL-clause. + # [:limit] + # An integer determining the limit on the number of rows that should be returned. + # [:offset] + # An integer determining the offset from where the rows should be fetched. So at 5, it would skip the first 4 rows. + # [:select] + # By default, this is <tt>*</tt> as in <tt>SELECT * FROM</tt>, but can be changed if, for example, you want to do a join # but not include the joined columns. Do not forget to include the primary and foreign keys, otherwise it will raise an error. - # * <tt>:readonly</tt> - If true, all the associated objects are readonly through the association. - # * <tt>:validate</tt> - If false, don't validate the associated objects when saving the parent object. +true+ by default. - # * <tt>:accessible</tt> - Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>). + # [:readonly] + # If true, all the associated objects are readonly through the association. + # [:validate] + # If false, don't validate the associated objects when saving the parent object. +true+ by default. + # [:accessible<] + # Mass assignment is allowed for this assocation (similar to <tt>ActiveRecord::Base#attr_accessible</tt>). # # Option examples: # has_and_belongs_to_many :projects @@ -1528,19 +1679,19 @@ module ActiveRecord else all << cond end end - conditions.join(' ').scan(/([\.\w]+).?\./).flatten + conditions.join(' ').scan(/([\.a-zA-Z_]+).?\./).flatten end def order_tables(options) order = [options[:order], scope(:find, :order) ].join(", ") return [] unless order && order.is_a?(String) - order.scan(/([\.\w]+).?\./).flatten + order.scan(/([\.a-zA-Z_]+).?\./).flatten end def selects_tables(options) select = options[:select] return [] unless select && select.is_a?(String) - select.scan(/"?([\.\w]+)"?.?\./).flatten + select.scan(/"?([\.a-zA-Z_]+)"?.?\./).flatten end # Checks if the conditions reference a table other than the current model table @@ -1742,6 +1893,7 @@ module ActiveRecord collection.target.push(association) when :has_one return if record.id.to_s != join.parent.record_id(row).to_s + return if record.instance_variable_defined?("@#{join.reflection.name}") association = join.instantiate(row) unless row[join.aliased_primary_key].nil? record.send("set_#{join.reflection.name}_target", association) when :belongs_to @@ -1823,7 +1975,7 @@ module ActiveRecord @aliased_join_table_name = aliased_table_name_for(reflection.options[:join_table], "_join") end - if reflection.macro == :has_many && reflection.options[:through] + if [:has_many, :has_one].include?(reflection.macro) && reflection.options[:through] @aliased_join_table_name = aliased_table_name_for(reflection.through_reflection.klass.table_name, "_join") end end @@ -1847,7 +1999,7 @@ module ActiveRecord ] when :has_many, :has_one case - when reflection.macro == :has_many && reflection.options[:through] + when reflection.options[:through] through_conditions = through_reflection.options[:conditions] ? "AND #{interpolate_sql(sanitize_sql(through_reflection.options[:conditions]))}" : '' jt_foreign_key = jt_as_extra = jt_source_extra = jt_sti_extra = nil @@ -1883,7 +2035,7 @@ module ActiveRecord jt_sti_extra = " AND %s.%s = %s" % [ connection.quote_table_name(aliased_join_table_name), connection.quote_column_name(through_reflection.active_record.inheritance_column), - through_reflection.klass.quote_value(through_reflection.klass.name.demodulize)] + through_reflection.klass.quote_value(through_reflection.klass.sti_name)] end when :belongs_to first_key = primary_key @@ -1948,10 +2100,8 @@ module ActiveRecord else "" end || '' - join << %(AND %s.%s = %s ) % [ - connection.quote_table_name(aliased_table_name), - connection.quote_column_name(klass.inheritance_column), - klass.quote_value(klass.name.demodulize)] unless klass.descends_from_active_record? + join << %(AND %s) % [ + klass.send(:type_condition, aliased_table_name)] unless klass.descends_from_active_record? [through_reflection, reflection].each do |ref| join << "AND #{interpolate_sql(sanitize_sql(ref.options[:conditions]))} " if ref && ref.options[:conditions] diff --git a/activerecord/lib/active_record/associations/association_collection.rb b/activerecord/lib/active_record/associations/association_collection.rb index a28be9eed1..9061037b39 100644 --- a/activerecord/lib/active_record/associations/association_collection.rb +++ b/activerecord/lib/active_record/associations/association_collection.rb @@ -344,7 +344,7 @@ module ActiveRecord callback(:before_add, record) yield(record) if block_given? @target ||= [] unless loaded? - @target << record + @target << record unless @reflection.options[:uniq] && @target.include?(record) callback(:after_add, record) record end diff --git a/activerecord/lib/active_record/associations/association_proxy.rb b/activerecord/lib/active_record/associations/association_proxy.rb index 77fc827e11..99b8748a48 100644 --- a/activerecord/lib/active_record/associations/association_proxy.rb +++ b/activerecord/lib/active_record/associations/association_proxy.rb @@ -131,10 +131,6 @@ module ActiveRecord records.map { |record| record.quoted_id }.join(',') end - def interpolate_sql_options!(options, *keys) - keys.each { |key| options[key] &&= interpolate_sql(options[key]) } - end - def interpolate_sql(sql, record = nil) @owner.send(:interpolate_sql, sql, record) end @@ -217,7 +213,7 @@ module ActiveRecord # Array#flatten has problems with recursive 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 + array.collect { |element| (element.respond_to?(:flatten) && !element.is_a?(Hash)) ? element.flatten : element }.flatten end def owner_quoted_id diff --git a/activerecord/lib/active_record/associations/belongs_to_association.rb b/activerecord/lib/active_record/associations/belongs_to_association.rb index 7c28cbdd07..7c28cbdd07 100755..100644 --- a/activerecord/lib/active_record/associations/belongs_to_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_association.rb diff --git a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb index d8146daa54..d8146daa54 100755..100644 --- a/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_polymorphic_association.rb 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 d516d54151..e7e433b6b6 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 @@ -70,10 +70,8 @@ module ActiveRecord end def construct_sql - interpolate_sql_options!(@reflection.options, :finder_sql) - if @reflection.options[:finder_sql] - @finder_sql = @reflection.options[:finder_sql] + @finder_sql = interpolate_sql(@reflection.options[:finder_sql]) else @finder_sql = "#{@owner.connection.quote_table_name @reflection.options[:join_table]}.#{@reflection.primary_key_name} = #{owner_quoted_id} " @finder_sql << " AND (#{conditions})" if conditions diff --git a/activerecord/lib/active_record/associations/has_many_association.rb b/activerecord/lib/active_record/associations/has_many_association.rb index e6fa15c173..ce62127505 100644 --- a/activerecord/lib/active_record/associations/has_many_association.rb +++ b/activerecord/lib/active_record/associations/has_many_association.rb @@ -35,8 +35,11 @@ module ActiveRecord else @reflection.klass.count(:conditions => @counter_sql, :include => @reflection.options[:include]) end - - @target = [] and loaded if count == 0 + + # If there's nothing in the database and @target has no new records + # we are certain the current target is an empty array. This is a + # documented side-effect of the method that may avoid an extra SELECT. + @target ||= [] and loaded if count == 0 if @reflection.options[:limit] count = [ @reflection.options[:limit], count ].min diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index e1bfff5923..24b02efc35 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -237,7 +237,7 @@ module ActiveRecord end def build_sti_condition - "#{@reflection.through_reflection.quoted_table_name}.#{@reflection.through_reflection.klass.inheritance_column} = #{@reflection.klass.quote_value(@reflection.through_reflection.klass.sti_name)}" + @reflection.through_reflection.klass.send(:type_condition) end alias_method :sql_conditions, :conditions diff --git a/activerecord/lib/active_record/associations/has_one_association.rb b/activerecord/lib/active_record/associations/has_one_association.rb index fdc0fa52c9..18733255d2 100755..100644 --- a/activerecord/lib/active_record/associations/has_one_association.rb +++ b/activerecord/lib/active_record/associations/has_one_association.rb @@ -21,8 +21,8 @@ module ActiveRecord def replace(obj, dont_save = false) load_target - unless @target.nil? - if dependent? && !dont_save && @target != obj + unless @target.nil? || @target == obj + if dependent? && !dont_save @target.destroy unless @target.new_record? @owner.clear_association_cache else diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index 962c2b36d9..5c30f80555 100755..100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -6,7 +6,7 @@ module ActiveRecord #:nodoc: class ActiveRecordError < StandardError end - # Raised when the single-table inheritance mechanism failes to locate the subclass + # Raised when the single-table inheritance mechanism fails to locate the subclass # (for example due to improper usage of column that +inheritance_column+ points to). class SubclassNotFound < ActiveRecordError #:nodoc: end @@ -83,8 +83,33 @@ module ActiveRecord #:nodoc: class ReadOnlyRecord < ActiveRecordError end - # Used by Active Record transaction mechanism to distinguish rollback from other exceptional situations. - # You can use it to roll your transaction back explicitly in the block passed to +transaction+ method. + # ActiveRecord::Transactions::ClassMethods.transaction uses this exception + # to distinguish a deliberate rollback from other exceptional situations. + # Normally, raising an exception will cause the +transaction+ method to rollback + # the database transaction *and* pass on the exception. But if you raise an + # ActiveRecord::Rollback exception, then the database transaction will be rolled back, + # without passing on the exception. + # + # For example, you could do this in your controller to rollback a transaction: + # + # class BooksController < ActionController::Base + # def create + # Book.transaction do + # book = Book.new(params[:book]) + # book.save! + # if today_is_friday? + # # The system must fail on Friday so that our support department + # # won't be out of job. We silently rollback this transaction + # # without telling the user. + # raise ActiveRecord::Rollback, "Call tech support!" + # end + # end + # # ActiveRecord::Rollback is the only exception that won't be passed on + # # by ActiveRecord::Base.transaction, so this line will still be reached + # # even on Friday. + # redirect_to root_url + # end + # end class Rollback < ActiveRecordError end @@ -97,7 +122,11 @@ module ActiveRecord #:nodoc: class MissingAttributeError < NoMethodError end - # Raised when an error occured while doing a mass assignment to an attribute through the + # Raised when unknown attributes are supplied via mass assignment. + class UnknownAttributeError < NoMethodError + end + + # Raised when an error occurred while doing a mass assignment to an attribute through the # <tt>attributes=</tt> method. The exception has an +attribute+ property that is the name of the # offending attribute. class AttributeAssignmentError < ActiveRecordError @@ -271,7 +300,7 @@ module ActiveRecord #:nodoc: # # Now 'Bob' exist and is an 'admin' # User.find_or_create_by_name('Bob', :age => 40) { |u| u.admin = true } # - # Use the <tt>find_or_initialize_by_</tt> finder if you want to return a new record without saving it first. Protected attributes won't be setted unless they are given in a block. For example: + # Use the <tt>find_or_initialize_by_</tt> finder if you want to return a new record without saving it first. Protected attributes won't be set unless they are given in a block. For example: # # # No 'Winter' tag exists # winter = Tag.find_or_initialize_by_name("Winter") @@ -439,6 +468,10 @@ module ActiveRecord #:nodoc: cattr_accessor :schema_format , :instance_writer => false @@schema_format = :ruby + # Specify whether or not to use timestamps for migration numbers + cattr_accessor :timestamped_migrations , :instance_writer => false + @@timestamped_migrations = true + # Determine whether to store the full constant name including namespace when using STI superclass_delegating_accessor :store_full_sti_class self.store_full_sti_class = false @@ -724,8 +757,7 @@ module ActiveRecord #:nodoc: # ==== Attributes # # * +updates+ - A String of column and value pairs that will be set on any records that match conditions. - # * +conditions+ - An SQL fragment like "administrator = 1" or [ "user_name = ?", username ]. - # See conditions in the intro for more info. + # * +conditions+ - An SQL fragment like "administrator = 1" or [ "user_name = ?", username ]. See conditions in the intro for more info. # * +options+ - Additional options are <tt>:limit</tt> and/or <tt>:order</tt>, see the examples for usage. # # ==== Examples @@ -828,7 +860,7 @@ module ActiveRecord #:nodoc: def update_counters(id, counters) updates = counters.inject([]) { |list, (counter_name, increment)| sign = increment < 0 ? "-" : "+" - list << "#{connection.quote_column_name(counter_name)} = #{connection.quote_column_name(counter_name)} #{sign} #{increment.abs}" + list << "#{connection.quote_column_name(counter_name)} = COALESCE(#{connection.quote_column_name(counter_name)}, 0) #{sign} #{increment.abs}" }.join(", ") update_all(updates, "#{connection.quote_column_name(primary_key)} = #{quote_value(id)}") end @@ -1188,11 +1220,46 @@ module ActiveRecord #:nodoc: subclasses.each { |klass| klass.reset_inheritable_attributes; klass.reset_column_information } end + def self_and_descendents_from_active_record#nodoc: + klass = self + classes = [klass] + while klass != klass.base_class + classes << klass = klass.superclass + end + classes + rescue + # OPTIMIZE this rescue is to fix this test: ./test/cases/reflection_test.rb:56:in `test_human_name_for_column' + # Appearantly the method base_class causes some trouble. + # It now works for sure. + [self] + end + # Transforms attribute key names into a more humane format, such as "First name" instead of "first_name". Example: # Person.human_attribute_name("first_name") # => "First name" - # Deprecated in favor of just calling "first_name".humanize - def human_attribute_name(attribute_key_name) #:nodoc: - attribute_key_name.humanize + # This used to be depricated in favor of humanize, but is now preferred, because it automatically uses the I18n + # module now. + # Specify +options+ with additional translating options. + def human_attribute_name(attribute_key_name, options = {}) + defaults = self_and_descendents_from_active_record.map do |klass| + :"#{klass.name.underscore}.#{attribute_key_name}" + end + defaults << options[:default] if options[:default] + defaults.flatten! + defaults << attribute_key_name.humanize + options[:count] ||= 1 + I18n.translate(defaults.shift, options.merge(:default => defaults, :scope => [:activerecord, :attributes])) + end + + # Transform the modelname into a more humane format, using I18n. + # Defaults to the basic humanize method. + # Default scope of the translation is activerecord.models + # Specify +options+ with additional translating options. + def human_name(options = {}) + defaults = self_and_descendents_from_active_record.map do |klass| + :"#{klass.name.underscore}" + end + defaults << self.name.humanize + I18n.translate(defaults.shift, {:scope => [:activerecord, :models], :count => 1, :default => defaults}.merge(options)) end # True if this isn't a concrete subclass needing a STI type condition. @@ -1287,8 +1354,8 @@ module ActiveRecord #:nodoc: end def respond_to?(method_id, include_private = false) - if match = matches_dynamic_finder?(method_id) || matches_dynamic_finder_with_initialize_or_create?(method_id) - return true if all_attributes_exists?(extract_attribute_names_from_match(match)) + if match = DynamicFinderMatch.match(method_id) + return true if all_attributes_exists?(match.attribute_names) end super end @@ -1577,10 +1644,11 @@ module ActiveRecord #:nodoc: sql << "WHERE #{merged_conditions} " unless merged_conditions.blank? end - def type_condition + def type_condition(table_alias=nil) + quoted_table_alias = self.connection.quote_table_name(table_alias || table_name) quoted_inheritance_column = connection.quote_column_name(inheritance_column) - type_condition = subclasses.inject("#{quoted_table_name}.#{quoted_inheritance_column} = '#{sti_name}' ") do |condition, subclass| - condition << "OR #{quoted_table_name}.#{quoted_inheritance_column} = '#{subclass.sti_name}' " + type_condition = subclasses.inject("#{quoted_table_alias}.#{quoted_inheritance_column} = '#{sti_name}' ") do |condition, subclass| + condition << "OR #{quoted_table_alias}.#{quoted_inheritance_column} = '#{subclass.sti_name}' " end " (#{type_condition}) " @@ -1606,88 +1674,67 @@ module ActiveRecord #:nodoc: # Each dynamic finder or initializer/creator is also defined in the class after it is first invoked, so that future # attempts to use it do not run through method_missing. def method_missing(method_id, *arguments) - if match = matches_dynamic_finder?(method_id) - finder = determine_finder(match) - - attribute_names = extract_attribute_names_from_match(match) + if match = DynamicFinderMatch.match(method_id) + attribute_names = match.attribute_names super unless all_attributes_exists?(attribute_names) - - self.class_eval %{ - def self.#{method_id}(*args) - options = args.extract_options! - attributes = construct_attributes_from_arguments([:#{attribute_names.join(',:')}], args) - finder_options = { :conditions => attributes } - validate_find_options(options) - set_readonly_option!(options) - - if options[:conditions] - with_scope(:find => finder_options) do - ActiveSupport::Deprecation.silence { send(:#{finder}, options) } + if match.finder? + finder = match.finder + bang = match.bang? + self.class_eval %{ + def self.#{method_id}(*args) + options = args.extract_options! + attributes = construct_attributes_from_arguments([:#{attribute_names.join(',:')}], args) + finder_options = { :conditions => attributes } + validate_find_options(options) + set_readonly_option!(options) + + #{'result = ' if bang}if options[:conditions] + with_scope(:find => finder_options) do + ActiveSupport::Deprecation.silence { send(:#{finder}, options) } + end + else + ActiveSupport::Deprecation.silence { send(:#{finder}, options.merge(finder_options)) } end - else - ActiveSupport::Deprecation.silence { send(:#{finder}, options.merge(finder_options)) } - end - end - }, __FILE__, __LINE__ - send(method_id, *arguments) - elsif match = matches_dynamic_finder_with_initialize_or_create?(method_id) - instantiator = determine_instantiator(match) - attribute_names = extract_attribute_names_from_match(match) - super unless all_attributes_exists?(attribute_names) - - self.class_eval %{ - def self.#{method_id}(*args) - guard_protected_attributes = false - - if args[0].is_a?(Hash) - guard_protected_attributes = true - attributes = args[0].with_indifferent_access - find_attributes = attributes.slice(*[:#{attribute_names.join(',:')}]) - else - find_attributes = attributes = construct_attributes_from_arguments([:#{attribute_names.join(',:')}], args) + #{'result || raise(RecordNotFound)' if bang} end + }, __FILE__, __LINE__ + send(method_id, *arguments) + elsif match.instantiator? + instantiator = match.instantiator + self.class_eval %{ + def self.#{method_id}(*args) + guard_protected_attributes = false + + if args[0].is_a?(Hash) + guard_protected_attributes = true + attributes = args[0].with_indifferent_access + find_attributes = attributes.slice(*[:#{attribute_names.join(',:')}]) + else + find_attributes = attributes = construct_attributes_from_arguments([:#{attribute_names.join(',:')}], args) + end - options = { :conditions => find_attributes } - set_readonly_option!(options) + options = { :conditions => find_attributes } + set_readonly_option!(options) - record = find_initial(options) + record = find_initial(options) - if record.nil? - record = self.new { |r| r.send(:attributes=, attributes, guard_protected_attributes) } - #{'yield(record) if block_given?'} - #{'record.save' if instantiator == :create} - record - else - record + if record.nil? + record = self.new { |r| r.send(:attributes=, attributes, guard_protected_attributes) } + #{'yield(record) if block_given?'} + #{'record.save' if instantiator == :create} + record + else + record + end end - end - }, __FILE__, __LINE__ - send(method_id, *arguments) + }, __FILE__, __LINE__ + send(method_id, *arguments) + end else super end end - def matches_dynamic_finder?(method_id) - /^find_(all_by|by)_([_a-zA-Z]\w*)$/.match(method_id.to_s) - end - - def matches_dynamic_finder_with_initialize_or_create?(method_id) - /^find_or_(initialize|create)_by_([_a-zA-Z]\w*)$/.match(method_id.to_s) - end - - def determine_finder(match) - match.captures.first == 'all_by' ? :find_every : :find_initial - end - - def determine_instantiator(match) - match.captures.first == 'initialize' ? :new : :create - end - - def extract_attribute_names_from_match(match) - match.captures.last.split('_and_') - end - def construct_attributes_from_arguments(attribute_names, arguments) attributes = {} attribute_names.each_with_index { |name, idx| attributes[name] = arguments[idx] } @@ -1717,7 +1764,7 @@ module ActiveRecord #:nodoc: def attribute_condition(argument) case argument when nil then "IS ?" - when Array, ActiveRecord::Associations::AssociationCollection then "IN (?)" + when Array, ActiveRecord::Associations::AssociationCollection, ActiveRecord::NamedScope::Scope then "IN (?)" when Range then "BETWEEN ? AND ?" else "= ?" end @@ -2372,7 +2419,11 @@ module ActiveRecord #:nodoc: attributes = remove_attributes_protected_from_mass_assignment(attributes) if guard_protected_attributes attributes.each do |k, v| - k.include?("(") ? multi_parameter_attributes << [ k, v ] : send(k + "=", v) + if k.include?("(") + multi_parameter_attributes << [ k, v ] + else + respond_to?(:"#{k}=") ? send(:"#{k}=", v) : raise(UnknownAttributeError, "unknown attribute: #{k}") + end end assign_multiparameter_attributes(multi_parameter_attributes) @@ -2535,11 +2586,14 @@ module ActiveRecord #:nodoc: end def convert_number_column_value(value) - case value - when FalseClass; 0 - when TrueClass; 1 - when ''; nil - else value + if value == false + 0 + elsif value == true + 1 + elsif value.is_a?(String) && value.blank? + nil + else + value end end @@ -2558,7 +2612,7 @@ module ActiveRecord #:nodoc: removed_attributes = attributes.keys - safe_attributes.keys if removed_attributes.any? - logger.debug "WARNING: Can't mass-assign these protected attributes: #{removed_attributes.join(', ')}" + log_protected_attribute_removal(removed_attributes) end safe_attributes @@ -2573,6 +2627,10 @@ module ActiveRecord #:nodoc: end end + def log_protected_attribute_removal(*attributes) + logger.debug "WARNING: Can't mass-assign these protected attributes: #{attributes.join(', ')}" + end + # The primary key and inheritance column can never be set by mass-assignment for security reasons. def attributes_protected_by_default default = [ self.class.primary_key, self.class.inheritance_column ] @@ -2586,8 +2644,15 @@ module ActiveRecord #:nodoc: quoted = {} connection = self.class.connection attribute_names.each do |name| - if column = column_for_attribute(name) - quoted[name] = connection.quote(read_attribute(name), column) unless !include_primary_key && column.primary + if (column = column_for_attribute(name)) && (include_primary_key || !column.primary) + value = read_attribute(name) + + # We need explicit to_yaml because quote() does not properly convert Time/Date fields to YAML. + if value && self.class.serialized_attributes.has_key?(name) && (value.acts_like?(:date) || value.acts_like?(:time)) + value = value.to_yaml + end + + quoted[name] = connection.quote(value, column) end end include_readonly_attributes ? quoted : remove_readonly_attributes(quoted) diff --git a/activerecord/lib/active_record/calculations.rb b/activerecord/lib/active_record/calculations.rb index 2ca1a0aaa3..246f87b7a9 100644 --- a/activerecord/lib/active_record/calculations.rb +++ b/activerecord/lib/active_record/calculations.rb @@ -211,7 +211,7 @@ module ActiveRecord sql << " ORDER BY #{options[:order]} " if options[:order] add_limit!(sql, options, scope) - sql << ')' if use_workaround + sql << ') AS #{aggregate_alias}_subquery' if use_workaround sql end diff --git a/activerecord/lib/active_record/callbacks.rb b/activerecord/lib/active_record/callbacks.rb index 1e385fb128..eec531c514 100755..100644 --- a/activerecord/lib/active_record/callbacks.rb +++ b/activerecord/lib/active_record/callbacks.rb @@ -50,7 +50,7 @@ module ActiveRecord # # == Inheritable callback queues # - # Besides the overwriteable callback methods, it's also possible to register callbacks through the use of the callback macros. + # Besides the overwritable callback methods, it's also possible to register callbacks through the use of the callback macros. # Their main advantage is that the macros add behavior into a callback queue that is kept intact down through an inheritance # hierarchy. Example: # @@ -169,6 +169,18 @@ module ActiveRecord # If a <tt>before_*</tt> callback returns +false+, all the later callbacks and the associated action are cancelled. If an <tt>after_*</tt> 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. + # + # == Transactions + # + # The entire callback chain of a +save+, <tt>save!</tt>, or +destroy+ call runs + # within a transaction. That includes <tt>after_*</tt> hooks. If everything + # goes fine a COMMIT is executed once the chain has been completed. + # + # If a <tt>before_*</tt> callback cancels the action a ROLLBACK is issued. You + # can also trigger a ROLLBACK raising an exception in any of the callbacks, + # including <tt>after_*</tt> hooks. Note, however, that in that case the client + # needs to be aware of it because an ordinary +save+ will raise such exception + # instead of quietly returning +false+. module Callbacks CALLBACKS = %w( after_find after_initialize before_save after_save before_create after_create before_update after_update before_validation diff --git a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb index 5358491cde..aaf9e2e73f 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -149,6 +149,10 @@ module ActiveRecord "INSERT INTO #{quote_table_name(table_name)} VALUES(DEFAULT)" end + def case_sensitive_equality_operator + "=" + end + protected # Returns an array of record hashes with the column names as keys and # column values as values. diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb index 31d6c7942c..75032efe57 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -138,7 +138,11 @@ module ActiveRecord # convert something to a boolean def value_to_boolean(value) - TRUE_VALUES.include?(value) + if value.is_a?(String) && value.blank? + nil + else + TRUE_VALUES.include?(value) + end end # convert something to a BigDecimal @@ -443,9 +447,10 @@ module ActiveRecord # Appends <tt>:datetime</tt> columns <tt>:created_at</tt> and # <tt>:updated_at</tt> to the table. - def timestamps - column(:created_at, :datetime) - column(:updated_at, :datetime) + def timestamps(*args) + options = args.extract_options! + column(:created_at, :datetime, options) + column(:updated_at, :datetime, options) end def references(*args) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb index 7d8530ebef..bececf82a0 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -383,13 +383,9 @@ module ActiveRecord def add_column_options!(sql, options) #:nodoc: sql << " DEFAULT #{quote(options[:default], options[:column])}" if options_include_default?(options) - # must explcitly check for :null to allow change_column to work on migrations - if options.has_key? :null - if options[:null] == false - sql << " NOT NULL" - else - sql << " NULL" - end + # must explicitly check for :null to allow change_column to work on migrations + if options[:null] == false + sql << " NOT NULL" end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index f48b107a2a..6924bb7e6f 100755..100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -51,6 +51,13 @@ module ActiveRecord true end + # Does this adapter support DDL rollbacks in transactions? That is, would + # CREATE TABLE or ALTER TABLE get rolled back by a transaction? PostgreSQL, + # SQL Server, and others support this. MySQL and others do not. + def supports_ddl_transactions? + false + end + # Should primary key values be selected from their corresponding # sequence before the insert statement? If true, next_sequence_value # is called before each insert to set the record's primary key. @@ -118,6 +125,19 @@ module ActiveRecord @connection end + def open_transactions + @open_transactions ||= 0 + end + + def increment_open_transactions + @open_transactions ||= 0 + @open_transactions += 1 + end + + def decrement_open_transactions + @open_transactions -= 1 + end + def log_info(sql, name, runtime) if @logger && @logger.debug? name = "#{name.nil? ? "SQL" : name} (#{sprintf("%f", runtime)})" diff --git a/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb index 35b9ed4746..204ebaa2e2 100755..100644 --- a/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql_adapter.rb @@ -511,6 +511,10 @@ module ActiveRecord keys.length == 1 ? [keys.first, nil] : nil end + def case_sensitive_equality_operator + "= BINARY" + end + private def connect @connection.reconnect = true if @connection.respond_to?(:reconnect=) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 6d16d72dea..55c7da5b4f 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -23,8 +23,8 @@ module ActiveRecord config = config.symbolize_keys host = config[:host] port = config[:port] || 5432 - username = config[:username].to_s - password = config[:password].to_s + username = config[:username].to_s if config[:username] + password = config[:password].to_s if config[:password] if config.has_key?(:database) database = config[:database] @@ -182,8 +182,8 @@ module ActiveRecord def self.extract_value_from_default(default) case default # Numeric types - when /\A-?\d+(\.\d*)?\z/ - default + when /\A\(?(-?\d+(\.\d*)?\)?)\z/ + $1 # Character types when /\A'(.*)'::(?:character varying|bpchar|text)\z/m $1 @@ -335,6 +335,10 @@ module ActiveRecord postgresql_version >= 80200 end + def supports_ddl_transactions? + true + end + # Returns the configured supported identifier length supported by PostgreSQL, # or report the default of 63 on PostgreSQL 7.x. def table_alias_length @@ -534,13 +538,13 @@ module ActiveRecord option_string = options.symbolize_keys.sum do |key, value| case key when :owner - " OWNER = '#{value}'" + " OWNER = \"#{value}\"" when :template - " TEMPLATE = #{value}" + " TEMPLATE = \"#{value}\"" when :encoding " ENCODING = '#{value}'" when :tablespace - " TABLESPACE = #{value}" + " TABLESPACE = \"#{value}\"" when :connection_limit " CONNECTION LIMIT = #{value}" else @@ -761,7 +765,8 @@ module ActiveRecord begin execute "ALTER TABLE #{quoted_table_name} ALTER COLUMN #{quote_column_name(column_name)} TYPE #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}" - rescue ActiveRecord::StatementInvalid + rescue ActiveRecord::StatementInvalid => e + raise e if postgresql_version > 80000 # This is PostgreSQL 7.x, so we have to use a more arcane way of doing it. begin begin_db_transaction @@ -867,7 +872,7 @@ module ActiveRecord end private - # The internal PostgreSQL identifer of the money data type. + # The internal PostgreSQL identifier of the money data type. MONEY_COLUMN_TYPE_OID = 790 #:nodoc: # Connects to a PostgreSQL server and sets up the adapter depending on the diff --git a/activerecord/lib/active_record/dirty.rb b/activerecord/lib/active_record/dirty.rb index a7d767486c..63bf8c8f5b 100644 --- a/activerecord/lib/active_record/dirty.rb +++ b/activerecord/lib/active_record/dirty.rb @@ -62,7 +62,7 @@ module ActiveRecord changed_attributes.keys end - # Map of changed attrs => [original value, new value] + # Map of changed attrs => [original value, new value]. # person.changes # => {} # person.name = 'bob' # person.changes # => { 'name' => ['bill', 'bob'] } @@ -93,27 +93,27 @@ module ActiveRecord end private - # Map of change attr => original value. + # Map of change <tt>attr => original value</tt>. def changed_attributes @changed_attributes ||= {} end - # Handle *_changed? for method_missing. + # Handle <tt>*_changed?</tt> for +method_missing+. def attribute_changed?(attr) changed_attributes.include?(attr) end - # Handle *_change for method_missing. + # Handle <tt>*_change</tt> for +method_missing+. def attribute_change(attr) [changed_attributes[attr], __send__(attr)] if attribute_changed?(attr) end - # Handle *_was for method_missing. + # Handle <tt>*_was</tt> for +method_missing+. def attribute_was(attr) attribute_changed?(attr) ? changed_attributes[attr] : __send__(attr) end - # Handle *_will_change! for method_missing. + # Handle <tt>*_will_change!</tt> for +method_missing+. def attribute_will_change!(attr) changed_attributes[attr] = clone_attribute_value(:read_attribute, attr) end @@ -134,7 +134,9 @@ module ActiveRecord def update_with_dirty if partial_updates? - update_without_dirty(changed) + # Serialized attributes should always be written in case they've been + # changed in place. + update_without_dirty(changed | self.class.serialized_attributes.keys) else update_without_dirty end diff --git a/activerecord/lib/active_record/dynamic_finder_match.rb b/activerecord/lib/active_record/dynamic_finder_match.rb new file mode 100644 index 0000000000..b105b919f5 --- /dev/null +++ b/activerecord/lib/active_record/dynamic_finder_match.rb @@ -0,0 +1,40 @@ +module ActiveRecord + class DynamicFinderMatch + def self.match(method) + df_match = self.new(method) + df_match.finder ? df_match : nil + end + + def initialize(method) + @finder = :find_initial + case method.to_s + when /^find_(all_by|by)_([_a-zA-Z]\w*)$/ + @finder = :find_every if $1 == 'all_by' + names = $2 + when /^find_by_([_a-zA-Z]\w*)\!$/ + @bang = true + names = $1 + when /^find_or_(initialize|create)_by_([_a-zA-Z]\w*)$/ + @instantiator = $1 == 'initialize' ? :new : :create + names = $2 + else + @finder = nil + end + @attribute_names = names && names.split('_and_') + end + + attr_reader :finder, :attribute_names, :instantiator + + def finder? + !@finder.nil? && @instantiator.nil? + end + + def instantiator? + @finder == :find_initial && !@instantiator.nil? + end + + def bang? + @bang + end + end +end diff --git a/activerecord/lib/active_record/fixtures.rb b/activerecord/lib/active_record/fixtures.rb index 17fb9355c4..622cfc3c3f 100755..100644 --- a/activerecord/lib/active_record/fixtures.rb +++ b/activerecord/lib/active_record/fixtures.rb @@ -515,7 +515,7 @@ class Fixtures < (RUBY_VERSION < '1.9' ? YAML::Omap : Hash) all_loaded_fixtures.update(fixtures_map) - connection.transaction(Thread.current['open_transactions'].to_i == 0) do + connection.transaction(connection.open_transactions.zero?) do fixtures.reverse.each { |fixture| fixture.delete_existing_fixtures } fixtures.each { |fixture| fixture.insert_fixtures } @@ -930,7 +930,7 @@ module Test #:nodoc: load_fixtures @@already_loaded_fixtures[self.class] = @loaded_fixtures end - ActiveRecord::Base.send :increment_open_transactions + ActiveRecord::Base.connection.increment_open_transactions ActiveRecord::Base.connection.begin_db_transaction # Load fixtures for every test. else @@ -951,9 +951,9 @@ module Test #:nodoc: end # Rollback changes if a transaction is active. - if use_transactional_fixtures? && Thread.current['open_transactions'] != 0 + if use_transactional_fixtures? && ActiveRecord::Base.connection.open_transactions != 0 ActiveRecord::Base.connection.rollback_db_transaction - Thread.current['open_transactions'] = 0 + ActiveRecord::Base.connection.decrement_open_transactions end ActiveRecord::Base.verify_active_connections! end diff --git a/activerecord/lib/active_record/locale/en-US.yml b/activerecord/lib/active_record/locale/en-US.yml new file mode 100644 index 0000000000..8148f31a81 --- /dev/null +++ b/activerecord/lib/active_record/locale/en-US.yml @@ -0,0 +1,33 @@ +en-US: + activerecord: + errors: + # The values :model, :attribute and :value are always available for interpolation + # The value :count is available when applicable. Can be used for pluralization. + messages: + inclusion: "is not included in the list" + exclusion: "is reserved" + invalid: "is invalid" + confirmation: "doesn't match confirmation" + accepted: "must be accepted" + empty: "can't be empty" + blank: "can't be blank" + too_long: "is too long (maximum is {{count}} characters)" + too_short: "is too short (minimum is {{count}} characters)" + wrong_length: "is the wrong length (should be {{count}} characters)" + taken: "has already been taken" + not_a_number: "is not a number" + greater_than: "must be greater than {{count}}" + greater_than_or_equal_to: "must be greater than or equal to {{count}}" + equal_to: "must be equal to {{count}}" + less_than: "must be less than {{count}}" + less_than_or_equal_to: "must be less than or equal to {{count}}" + odd: "must be odd" + even: "must be even" + # Append your own errors here or at the model/attributes scope. + + models: + # Overrides default messages + + attributes: + # Overrides model and default messages. + diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index e095b3c766..1d843fff28 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -238,6 +238,22 @@ module ActiveRecord # lower than the current schema version: when migrating up, those # never-applied "interleaved" migrations will be automatically applied, and # when migrating down, never-applied "interleaved" migrations will be skipped. + # + # == Timestamped Migrations + # + # By default, Rails generates migrations that look like: + # + # 20080717013526_your_migration_name.rb + # + # The prefix is a generation timestamp (in UTC). + # + # If you'd prefer to use numeric prefixes, you can turn timestamped migrations + # off by setting: + # + # config.active_record.timestamped_migrations = false + # + # In environment.rb. + # class Migration @@verbose = true cattr_accessor :verbose @@ -333,6 +349,27 @@ module ActiveRecord end end + # MigrationProxy is used to defer loading of the actual migration classes + # until they are needed + class MigrationProxy + + attr_accessor :name, :version, :filename + + delegate :migrate, :announce, :write, :to=>:migration + + private + + def migration + @migration ||= load_migration + end + + def load_migration + load(filename) + name.constantize + end + + end + class Migrator#:nodoc: class << self def migrate(migrations_path, target_version = nil) @@ -369,11 +406,17 @@ module ActiveRecord Base.table_name_prefix + 'schema_migrations' + Base.table_name_suffix end + def get_all_versions + Base.connection.select_values("SELECT version FROM #{schema_migrations_table_name}").map(&:to_i).sort + end + def current_version - version = Base.connection.select_values( - "SELECT version FROM #{schema_migrations_table_name}" - ).map(&:to_i).max rescue nil - version || 0 + sm_table = schema_migrations_table_name + if Base.connection.table_exists?(sm_table) + get_all_versions.max || 0 + else + 0 + end end def proper_table_name(name) @@ -389,7 +432,7 @@ module ActiveRecord end def current_version - self.class.current_version + migrated.last || 0 end def current_migration @@ -421,17 +464,25 @@ module ActiveRecord runnable.pop if down? && !target.nil? runnable.each do |migration| - Base.logger.info "Migrating to #{migration} (#{migration.version})" + Base.logger.info "Migrating to #{migration.name} (#{migration.version})" # On our way up, we skip migrating the ones we've already migrated - # On our way down, we skip reverting the ones we've never migrated next if up? && migrated.include?(migration.version.to_i) + # On our way down, we skip reverting the ones we've never migrated if down? && !migrated.include?(migration.version.to_i) migration.announce 'never migrated, skipping'; migration.write - else - migration.migrate(@direction) - record_version_state_after_migrating(migration.version) + next + end + + begin + ddl_transaction do + migration.migrate(@direction) + record_version_state_after_migrating(migration.version) + end + rescue => e + canceled_msg = Base.connection.supports_ddl_transactions? ? "this and " : "" + raise StandardError, "An error has occurred, #{canceled_msg}all later migrations canceled:\n\n#{e}", e.backtrace end end end @@ -454,11 +505,10 @@ module ActiveRecord raise DuplicateMigrationNameError.new(name.camelize) end - load(file) - - klasses << returning(name.camelize.constantize) do |klass| - class << klass; attr_accessor :version end - klass.version = version + klasses << returning(MigrationProxy.new) do |migration| + migration.name = name.camelize + migration.version = version + migration.filename = file end end @@ -473,17 +523,19 @@ module ActiveRecord end def migrated - sm_table = self.class.schema_migrations_table_name - Base.connection.select_values("SELECT version FROM #{sm_table}").map(&:to_i).sort + @migrated_versions ||= self.class.get_all_versions end private def record_version_state_after_migrating(version) sm_table = self.class.schema_migrations_table_name + @migrated_versions ||= [] if down? + @migrated_versions.delete(version.to_i) Base.connection.update("DELETE FROM #{sm_table} WHERE version = '#{version}'") else + @migrated_versions.push(version.to_i).sort! Base.connection.insert("INSERT INTO #{sm_table} (version) VALUES ('#{version}')") end end @@ -495,5 +547,14 @@ module ActiveRecord def down? @direction == :down end + + # Wrap the migration in a transaction only if supported by the adapter. + def ddl_transaction(&block) + if Base.connection.supports_ddl_transactions? + Base.transaction { block.call } + else + block.call + end + end end end diff --git a/activerecord/lib/active_record/named_scope.rb b/activerecord/lib/active_record/named_scope.rb index 080e3d0f5e..c99c4beca9 100644 --- a/activerecord/lib/active_record/named_scope.rb +++ b/activerecord/lib/active_record/named_scope.rb @@ -103,7 +103,7 @@ module ActiveRecord attr_reader :proxy_scope, :proxy_options [].methods.each do |m| - unless m =~ /(^__|^nil\?|^send|^object_id$|class|extend|find|count|sum|average|maximum|minimum|paginate|first|last|empty?)/ + unless m =~ /(^__|^nil\?|^send|^object_id$|class|extend|^find$|count|sum|average|maximum|minimum|paginate|first|last|empty?|any?|respond_to?)/ delegate m, :to => :proxy_found end end @@ -140,6 +140,18 @@ module ActiveRecord @found ? @found.empty? : count.zero? end + def respond_to?(method, include_private = false) + super || @proxy_scope.respond_to?(method, include_private) + end + + def any? + if block_given? + proxy_found.any? { |*block_args| yield(*block_args) } + else + !empty? + end + end + protected def proxy_found @found || load_found diff --git a/activerecord/lib/active_record/observer.rb b/activerecord/lib/active_record/observer.rb index 25e0e61c69..b35e407cc1 100644 --- a/activerecord/lib/active_record/observer.rb +++ b/activerecord/lib/active_record/observer.rb @@ -20,7 +20,7 @@ module ActiveRecord # ActiveRecord::Base.observers = Cacher, GarbageCollector # # Note: Setting this does not instantiate the observers yet. +instantiate_observers+ is - # called during startup, and before each development request. + # called during startup, and before each development request. def observers=(*observers) @observers = observers.flatten end @@ -130,11 +130,11 @@ module ActiveRecord # Observers register themselves in the model class they observe, since it is the class that # notifies them of events when they occur. As a side-effect, when an observer is loaded its # corresponding model class is loaded. - # + # # Up to (and including) Rails 2.0.2 observers were instantiated between plugins and - # application initializers. Now observers are loaded after application initializers, + # application initializers. Now observers are loaded after application initializers, # so observed models can make use of extensions. - # + # # If by any chance you are using observed models in the initialization you can still # load their observers by calling <tt>ModelObserver.instance</tt> before. Observers are # singletons and that call instantiates and registers them. @@ -189,7 +189,9 @@ module ActiveRecord def add_observer!(klass) klass.add_observer(self) - klass.class_eval 'def after_find() end' unless klass.method_defined?(:after_find) + if respond_to?(:after_find) && !klass.method_defined?(:after_find) + klass.class_eval 'def after_find() end' + end end end end diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 3f74c03714..935b1939d8 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -109,7 +109,7 @@ module ActiveRecord # Returns +true+ if +self+ and +other_aggregation+ have the same +name+ attribute, +active_record+ attribute, # and +other_aggregation+ has an options hash assigned to it. def ==(other_aggregation) - name == other_aggregation.name && other_aggregation.options && active_record == other_aggregation.active_record + other_aggregation.kind_of?(self.class) && name == other_aggregation.name && other_aggregation.options && active_record == other_aggregation.active_record end def sanitized_conditions #:nodoc: diff --git a/activerecord/lib/active_record/test_case.rb b/activerecord/lib/active_record/test_case.rb index ca5591ae35..ffaa41282f 100644 --- a/activerecord/lib/active_record/test_case.rb +++ b/activerecord/lib/active_record/test_case.rb @@ -37,7 +37,7 @@ module ActiveRecord $queries_executed = [] yield ensure - assert_equal num, $queries_executed.size, "#{$queries_executed.size} instead of #{num} queries were executed." + assert_equal num, $queries_executed.size, "#{$queries_executed.size} instead of #{num} queries were executed.#{$queries_executed.size == 0 ? '' : "\nQueries:\n#{$queries_executed.join("\n")}"}" end def assert_no_queries(&block) diff --git a/activerecord/lib/active_record/transactions.rb b/activerecord/lib/active_record/transactions.rb index 3b6835762c..81462a2917 100644 --- a/activerecord/lib/active_record/transactions.rb +++ b/activerecord/lib/active_record/transactions.rb @@ -66,32 +66,24 @@ module ActiveRecord # will happen under the protected cover of a transaction. So you can use validations to check for values that the transaction # depends on or you can raise exceptions in the callbacks to rollback. # - # == Exception handling + # == Exception handling and rolling back # # Also have in mind that exceptions thrown within a transaction block will be propagated (after triggering the ROLLBACK), so you - # should be ready to catch those in your application code. One exception is the ActiveRecord::Rollback exception, which will - # trigger a ROLLBACK when raised, but not be re-raised by the transaction block. + # should be ready to catch those in your application code. + # + # One exception is the ActiveRecord::Rollback exception, which will trigger a ROLLBACK when raised, + # but not be re-raised by the transaction block. module ClassMethods + # See ActiveRecord::Transactions::ClassMethods for detailed documentation. def transaction(&block) - increment_open_transactions + connection.increment_open_transactions begin - connection.transaction(Thread.current['start_db_transaction'], &block) + connection.transaction(connection.open_transactions == 1, &block) ensure - decrement_open_transactions + connection.decrement_open_transactions end end - - private - def increment_open_transactions #:nodoc: - open = Thread.current['open_transactions'] ||= 0 - Thread.current['start_db_transaction'] = open.zero? - Thread.current['open_transactions'] = open + 1 - end - - def decrement_open_transactions #:nodoc: - Thread.current['open_transactions'] -= 1 - end end def transaction(&block) @@ -99,11 +91,11 @@ module ActiveRecord end def destroy_with_transactions #:nodoc: - transaction { destroy_without_transactions } + with_transaction_returning_status(:destroy_without_transactions) end def save_with_transactions(perform_validation = true) #:nodoc: - rollback_active_record_state! { transaction { save_without_transactions(perform_validation) } } + rollback_active_record_state! { with_transaction_returning_status(:save_without_transactions, perform_validation) } end def save_with_transactions! #:nodoc: @@ -126,5 +118,17 @@ module ActiveRecord end raise end + + # Executes +method+ within a transaction and captures its return value as a + # status flag. If the status is true the transaction is committed, otherwise + # a ROLLBACK is issued. In any case the status flag is returned. + def with_transaction_returning_status(method, *args) + status = nil + transaction do + status = send(method, *args) + raise ActiveRecord::Rollback unless status + end + status + end end end diff --git a/activerecord/lib/active_record/validations.rb b/activerecord/lib/active_record/validations.rb index 1035308aa5..8fe4336bbc 100755..100644 --- a/activerecord/lib/active_record/validations.rb +++ b/activerecord/lib/active_record/validations.rb @@ -1,5 +1,5 @@ module ActiveRecord - # Raised by save! and create! when the record is invalid. Use the + # Raised by <tt>save!</tt> and <tt>create!</tt> when the record is invalid. Use the # +record+ method to retrieve the record which did not validate. # begin # complex_operation_that_calls_save!_internally @@ -18,69 +18,95 @@ module ActiveRecord # determine whether the object is in a valid state to be saved. See usage example in Validations. class Errors include Enumerable + + class << self + def default_error_messages + ActiveSupport::Deprecation.warn("ActiveRecord::Errors.default_error_messages has been deprecated. Please use I18n.translate('activerecord.errors.messages').") + I18n.translate 'activerecord.errors.messages' + end + end def initialize(base) # :nodoc: @base, @errors = base, {} end - @@default_error_messages = { - :inclusion => "is not included in the list", - :exclusion => "is reserved", - :invalid => "is invalid", - :confirmation => "doesn't match confirmation", - :accepted => "must be accepted", - :empty => "can't be empty", - :blank => "can't be blank", - :too_long => "is too long (maximum is %d characters)", - :too_short => "is too short (minimum is %d characters)", - :wrong_length => "is the wrong length (should be %d characters)", - :taken => "has already been taken", - :not_a_number => "is not a number", - :greater_than => "must be greater than %d", - :greater_than_or_equal_to => "must be greater than or equal to %d", - :equal_to => "must be equal to %d", - :less_than => "must be less than %d", - :less_than_or_equal_to => "must be less than or equal to %d", - :odd => "must be odd", - :even => "must be even" - } - - # Holds a hash with all the default error messages that can be replaced by your own copy or localizations. - cattr_accessor :default_error_messages - - # Adds an error to the base object instead of any particular attribute. This is used # to report errors that don't tie to any specific attribute, but rather to the object # as a whole. These error messages don't get prepended with any field name when iterating - # with each_full, so they should be complete sentences. + # with +each_full+, so they should be complete sentences. def add_to_base(msg) add(:base, msg) end - # Adds an error message (+msg+) to the +attribute+, which will be returned on a call to <tt>on(attribute)</tt> + # Adds an error message (+messsage+) to the +attribute+, which will be returned on a call to <tt>on(attribute)</tt> # for the same attribute and ensure that this error object returns false when asked if <tt>empty?</tt>. More than one # error can be added to the same +attribute+ in which case an array will be returned on a call to <tt>on(attribute)</tt>. - # If no +msg+ is supplied, "invalid" is assumed. - def add(attribute, msg = @@default_error_messages[:invalid]) - @errors[attribute.to_s] = [] if @errors[attribute.to_s].nil? - @errors[attribute.to_s] << msg + # If no +messsage+ is supplied, :invalid is assumed. + # If +message+ is a Symbol, it will be translated, using the appropriate scope (see translate_error). + def add(attribute, message = nil, options = {}) + message ||= :invalid + message = generate_message(attribute, message, options) if message.is_a?(Symbol) + @errors[attribute.to_s] ||= [] + @errors[attribute.to_s] << message end # Will add an error message to each of the attributes in +attributes+ that is empty. - def add_on_empty(attributes, msg = @@default_error_messages[:empty]) + def add_on_empty(attributes, custom_message = nil) for attr in [attributes].flatten value = @base.respond_to?(attr.to_s) ? @base.send(attr.to_s) : @base[attr.to_s] - is_empty = value.respond_to?("empty?") ? value.empty? : false - add(attr, msg) unless !value.nil? && !is_empty + is_empty = value.respond_to?("empty?") ? value.empty? : false + add(attr, :empty, :default => custom_message) unless !value.nil? && !is_empty end end # Will add an error message to each of the attributes in +attributes+ that is blank (using Object#blank?). - def add_on_blank(attributes, msg = @@default_error_messages[:blank]) + def add_on_blank(attributes, custom_message = nil) for attr in [attributes].flatten value = @base.respond_to?(attr.to_s) ? @base.send(attr.to_s) : @base[attr.to_s] - add(attr, msg) if value.blank? + add(attr, :blank, :default => custom_message) if value.blank? + end + end + + # Translates an error message in it's default scope (<tt>activerecord.errrors.messages</tt>). + # Error messages are first looked up in <tt>models.MODEL.attributes.ATTRIBUTE.MESSAGE</tt>, if it's not there, + # it's looked up in <tt>models.MODEL.MESSAGE</tt> and if that is not there it returns the translation of the + # default message (e.g. <tt>activerecord.errors.messages.MESSAGE</tt>). The translated model name, + # translated attribute name and the value are available for interpolation. + # + # When using inheritence in your models, it will check all the inherited models too, but only if the model itself + # hasn't been found. Say you have <tt>class Admin < User; end</tt> and you wanted the translation for the <tt>:blank</tt> + # error +message+ for the <tt>title</tt> +attribute+, it looks for these translations: + # + # <ol> + # <li><tt>activerecord.errors.models.admin.attributes.title.blank</tt></li> + # <li><tt>activerecord.errors.models.admin.blank</tt></li> + # <li><tt>activerecord.errors.models.user.attributes.title.blank</tt></li> + # <li><tt>activerecord.errors.models.user.blank</tt></li> + # <li><tt>activerecord.errors.messages.blank</tt></li> + # <li>any default you provided through the +options+ hash (in the activerecord.errors scope)</li> + # </ol> + def generate_message(attribute, message = :invalid, options = {}) + + defaults = @base.class.self_and_descendents_from_active_record.map do |klass| + [ :"models.#{klass.name.underscore}.attributes.#{attribute}.#{message}", + :"models.#{klass.name.underscore}.#{message}" ] end + + defaults << options.delete(:default) + defaults = defaults.compact.flatten << :"messages.#{message}" + + model_name = @base.class.name + key = defaults.shift + value = @base.respond_to?(attribute) ? @base.send(attribute) : nil + + options = { :default => defaults, + :model => @base.class.human_name, + :attribute => @base.class.human_attribute_name(attribute.to_s), + :value => value, + :scope => [:activerecord, :errors] + }.merge(options) + + I18n.translate(key, options) end # Returns true if the specified +attribute+ has errors associated with it. @@ -97,7 +123,7 @@ module ActiveRecord !@errors[attribute.to_s].nil? end - # Returns nil, if no errors are associated with the specified +attribute+. + # Returns +nil+, if no errors are associated with the specified +attribute+. # Returns the error message, if one error is associated with the specified +attribute+. # Returns an array of error messages, if more than one error is associated with the specified +attribute+. # @@ -118,7 +144,7 @@ module ActiveRecord alias :[] :on - # Returns errors assigned to the base object through add_to_base according to the normal rules of on(attribute). + # Returns errors assigned to the base object through +add_to_base+ according to the normal rules of <tt>on(attribute)</tt>. def on_base on(:base) end @@ -131,15 +157,15 @@ module ActiveRecord # end # # company = Company.create(:address => '123 First St.') - # company.errors.each{|attr,msg| puts "#{attr} - #{msg}" } # => - # name - is too short (minimum is 5 characters) - # name - can't be blank - # address - can't be blank + # company.errors.each{|attr,msg| puts "#{attr} - #{msg}" } + # # => name - is too short (minimum is 5 characters) + # # name - can't be blank + # # address - can't be blank def each @errors.each_key { |attr| @errors[attr].each { |msg| yield attr, msg } } end - # Yields each full error message added. So Person.errors.add("first_name", "can't be empty") will be returned + # Yields each full error message added. So <tt>Person.errors.add("first_name", "can't be empty")</tt> will be returned # through iteration as "First name can't be empty". # # class Company < ActiveRecord::Base @@ -148,10 +174,10 @@ module ActiveRecord # end # # company = Company.create(:address => '123 First St.') - # company.errors.each_full{|msg| puts msg } # => - # Name is too short (minimum is 5 characters) - # Name can't be blank - # Address can't be blank + # company.errors.each_full{|msg| puts msg } + # # => Name is too short (minimum is 5 characters) + # # Name can't be blank + # # Address can't be blank def each_full full_messages.each { |msg| yield msg } end @@ -166,22 +192,24 @@ module ActiveRecord # company = Company.create(:address => '123 First St.') # company.errors.full_messages # => # ["Name is too short (minimum is 5 characters)", "Name can't be blank", "Address can't be blank"] - def full_messages + def full_messages(options = {}) full_messages = [] @errors.each_key do |attr| - @errors[attr].each do |msg| - next if msg.nil? + @errors[attr].each do |message| + next unless message if attr == "base" - full_messages << msg + full_messages << message else - full_messages << @base.class.human_attribute_name(attr) + " " + msg + #key = :"activerecord.att.#{@base.class.name.underscore.to_sym}.#{attr}" + attr_name = @base.class.human_attribute_name(attr) + full_messages << attr_name + ' ' + message end end end full_messages - end + end # Returns true if no errors have been added. def empty? @@ -209,13 +237,13 @@ module ActiveRecord # end # # company = Company.create(:address => '123 First St.') - # company.errors.to_xml # => - # <?xml version="1.0" encoding="UTF-8"?> - # <errors> - # <error>Name is too short (minimum is 5 characters)</error> - # <error>Name can't be blank</error> - # <error>Address can't be blank</error> - # </errors> + # company.errors.to_xml + # # => <?xml version="1.0" encoding="UTF-8"?> + # # <errors> + # # <error>Name is too short (minimum is 5 characters)</error> + # # <error>Name can't be blank</error> + # # <error>Address can't be blank</error> + # # </errors> def to_xml(options={}) options[:root] ||= "errors" options[:indent] ||= 2 @@ -226,6 +254,7 @@ module ActiveRecord full_messages.each { |msg| e.error(msg) } end end + end @@ -261,7 +290,7 @@ module ActiveRecord # person.errors.on "phone_number" # => "has invalid format" # person.errors.each_full { |msg| puts msg } # # => "Last name can't be empty\n" + - # "Phone number has invalid format" + # # "Phone number has invalid format" # # person.attributes = { "last_name" => "Heinemeier", "phone_number" => "555-555" } # person.save # => true (and person is now saved in the database) @@ -300,7 +329,7 @@ module ActiveRecord :odd => 'odd?', :even => 'even?' }.freeze # Adds a validation method or block to the class. This is useful when - # overriding the +validate+ instance method becomes too unwieldly and + # overriding the +validate+ instance method becomes too unwieldy and # you're looking for more descriptive declaration of your validations. # # This can be done with a symbol pointing to a method: @@ -388,13 +417,15 @@ module ActiveRecord # not occur (e.g. <tt>:unless => :skip_validation</tt>, or <tt>:unless => Proc.new { |user| user.signup_step <= 2 }</tt>). The # method, proc or string should return or evaluate to a true or false value. def validates_confirmation_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:confirmation], :on => :save } + configuration = { :on => :save } configuration.update(attr_names.extract_options!) attr_accessor(*(attr_names.map { |n| "#{n}_confirmation" })) validates_each(attr_names, configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message]) unless record.send("#{attr_name}_confirmation").nil? or value == record.send("#{attr_name}_confirmation") + unless record.send("#{attr_name}_confirmation").nil? or value == record.send("#{attr_name}_confirmation") + record.errors.add(attr_name, :confirmation, :default => configuration[:message]) + end end end @@ -422,7 +453,7 @@ module ActiveRecord # not occur (e.g. <tt>:unless => :skip_validation</tt>, or <tt>:unless => Proc.new { |user| user.signup_step <= 2 }</tt>). The # method, proc or string should return or evaluate to a true or false value. def validates_acceptance_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:accepted], :on => :save, :allow_nil => true, :accept => "1" } + configuration = { :on => :save, :allow_nil => true, :accept => "1" } configuration.update(attr_names.extract_options!) db_cols = begin @@ -434,7 +465,9 @@ module ActiveRecord attr_accessor(*names) validates_each(attr_names,configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message]) unless value == configuration[:accept] + unless value == configuration[:accept] + record.errors.add(attr_name, :accepted, :default => configuration[:message]) + end end end @@ -461,7 +494,7 @@ module ActiveRecord # method, proc or string should return or evaluate to a true or false value. # def validates_presence_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:blank], :on => :save } + configuration = { :on => :save } configuration.update(attr_names.extract_options!) # can't use validates_each here, because it cannot cope with nonexistent attributes, @@ -509,10 +542,7 @@ module ActiveRecord def validates_length_of(*attrs) # Merge given options with defaults. options = { - :too_long => ActiveRecord::Errors.default_error_messages[:too_long], - :too_short => ActiveRecord::Errors.default_error_messages[:too_short], - :wrong_length => ActiveRecord::Errors.default_error_messages[:wrong_length], - :tokenizer => lambda {|value| value.split(//)} + :tokenizer => lambda {|value| value.split(//)} }.merge(DEFAULT_VALIDATION_OPTIONS) options.update(attrs.extract_options!.symbolize_keys) @@ -535,15 +565,12 @@ module ActiveRecord when :within, :in raise ArgumentError, ":#{option} must be a Range" unless option_value.is_a?(Range) - too_short = options[:too_short] % option_value.begin - too_long = options[:too_long] % option_value.end - validates_each(attrs, options) do |record, attr, value| value = options[:tokenizer].call(value) if value.kind_of?(String) if value.nil? or value.size < option_value.begin - record.errors.add(attr, too_short) + record.errors.add(attr, :too_short, :default => options[:too_short], :count => option_value.begin) elsif value.size > option_value.end - record.errors.add(attr, too_long) + record.errors.add(attr, :too_long, :default => options[:too_long], :count => option_value.end) end end when :is, :minimum, :maximum @@ -553,11 +580,13 @@ module ActiveRecord validity_checks = { :is => "==", :minimum => ">=", :maximum => "<=" } message_options = { :is => :wrong_length, :minimum => :too_short, :maximum => :too_long } - message = (options[:message] || options[message_options[option]]) % option_value - validates_each(attrs, options) do |record, attr, value| value = options[:tokenizer].call(value) if value.kind_of?(String) - record.errors.add(attr, message) unless !value.nil? and value.size.method(validity_checks[option])[option_value] + unless !value.nil? and value.size.method(validity_checks[option])[option_value] + key = message_options[option] + custom_message = options[:message] || options[key] + record.errors.add(attr, key, :default => custom_message, :count => option_value) + end end end end @@ -599,7 +628,7 @@ module ActiveRecord # not occur (e.g. <tt>:unless => :skip_validation</tt>, or <tt>:unless => Proc.new { |user| user.signup_step <= 2 }</tt>). The # method, proc or string should return or evaluate to a true or false value. def validates_uniqueness_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:taken], :case_sensitive => true } + configuration = { :case_sensitive => true } configuration.update(attr_names.extract_options!) validates_each(attr_names,configuration) do |record, attr_name, value| @@ -617,13 +646,24 @@ module ActiveRecord # class (which has a database table to query from). finder_class = class_hierarchy.detect { |klass| !klass.abstract_class? } - if value.nil? || (configuration[:case_sensitive] || !finder_class.columns_hash[attr_name.to_s].text?) - condition_sql = "#{record.class.quoted_table_name}.#{attr_name} #{attribute_condition(value)}" + is_text_column = finder_class.columns_hash[attr_name.to_s].text? + + if value.nil? + comparison_operator = "IS ?" + elsif is_text_column + comparison_operator = "#{connection.case_sensitive_equality_operator} ?" + value = value.to_s + else + comparison_operator = "= ?" + end + + sql_attribute = "#{record.class.quoted_table_name}.#{connection.quote_column_name(attr_name)}" + + if value.nil? || (configuration[:case_sensitive] || !is_text_column) + condition_sql = "#{sql_attribute} #{comparison_operator}" condition_params = [value] else - # sqlite has case sensitive SELECT query, while MySQL/Postgresql don't. - # Hence, this is needed only for sqlite. - condition_sql = "LOWER(#{record.class.quoted_table_name}.#{attr_name}) #{attribute_condition(value)}" + condition_sql = "LOWER(#{sql_attribute}) #{comparison_operator}" condition_params = [value.downcase] end @@ -640,26 +680,10 @@ module ActiveRecord condition_params << record.send(:id) end - results = finder_class.with_exclusive_scope do - connection.select_all( - construct_finder_sql( - :select => "#{connection.quote_column_name(attr_name)}", - :from => "#{finder_class.quoted_table_name}", - :conditions => [condition_sql, *condition_params] - ) - ) - end - - unless results.length.zero? - found = true - - # As MySQL/Postgres don't have case sensitive SELECT queries, we try to find duplicate - # column in ruby when case sensitive option - if configuration[:case_sensitive] && finder_class.columns_hash[attr_name.to_s].text? - found = results.any? { |a| a[attr_name.to_s] == value } + finder_class.with_exclusive_scope do + if finder_class.exists?([condition_sql, *condition_params]) + record.errors.add(attr_name, :taken, :default => configuration[:message], :value => value) end - - record.errors.add(attr_name, configuration[:message]) if found end end end @@ -689,13 +713,15 @@ module ActiveRecord # not occur (e.g. <tt>:unless => :skip_validation</tt>, or <tt>:unless => Proc.new { |user| user.signup_step <= 2 }</tt>). The # method, proc or string should return or evaluate to a true or false value. def validates_format_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:invalid], :on => :save, :with => nil } + configuration = { :on => :save, :with => nil } configuration.update(attr_names.extract_options!) raise(ArgumentError, "A regular expression must be supplied as the :with option of the configuration hash") unless configuration[:with].is_a?(Regexp) validates_each(attr_names, configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message] % value) unless value.to_s =~ configuration[:with] + unless value.to_s =~ configuration[:with] + record.errors.add(attr_name, :invalid, :default => configuration[:message], :value => value) + end end end @@ -719,7 +745,7 @@ module ActiveRecord # not occur (e.g. <tt>:unless => :skip_validation</tt>, or <tt>:unless => Proc.new { |user| user.signup_step <= 2 }</tt>). The # method, proc or string should return or evaluate to a true or false value. def validates_inclusion_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:inclusion], :on => :save } + configuration = { :on => :save } configuration.update(attr_names.extract_options!) enum = configuration[:in] || configuration[:within] @@ -727,7 +753,9 @@ module ActiveRecord raise(ArgumentError, "An object with the method include? is required must be supplied as the :in option of the configuration hash") unless enum.respond_to?("include?") validates_each(attr_names, configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message] % value) unless enum.include?(value) + unless enum.include?(value) + record.errors.add(attr_name, :inclusion, :default => configuration[:message], :value => value) + end end end @@ -751,7 +779,7 @@ module ActiveRecord # not occur (e.g. <tt>:unless => :skip_validation</tt>, or <tt>:unless => Proc.new { |user| user.signup_step <= 2 }</tt>). The # method, proc or string should return or evaluate to a true or false value. def validates_exclusion_of(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:exclusion], :on => :save } + configuration = { :on => :save } configuration.update(attr_names.extract_options!) enum = configuration[:in] || configuration[:within] @@ -759,7 +787,9 @@ module ActiveRecord raise(ArgumentError, "An object with the method include? is required must be supplied as the :in option of the configuration hash") unless enum.respond_to?("include?") validates_each(attr_names, configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message] % value) if enum.include?(value) + if enum.include?(value) + record.errors.add(attr_name, :exclusion, :default => configuration[:message], :value => value) + end end end @@ -795,12 +825,13 @@ module ActiveRecord # not occur (e.g. <tt>:unless => :skip_validation</tt>, or <tt>:unless => Proc.new { |user| user.signup_step <= 2 }</tt>). The # method, proc or string should return or evaluate to a true or false value. def validates_associated(*attr_names) - configuration = { :message => ActiveRecord::Errors.default_error_messages[:invalid], :on => :save } + configuration = { :on => :save } configuration.update(attr_names.extract_options!) validates_each(attr_names, configuration) do |record, attr_name, value| - record.errors.add(attr_name, configuration[:message]) unless - (value.is_a?(Array) ? value : [value]).inject(true) { |v, r| (r.nil? || r.valid?) && v } + unless (value.is_a?(Array) ? value : [value]).inject(true) { |v, r| (r.nil? || r.valid?) && v } + record.errors.add(attr_name, :invalid, :default => configuration[:message], :value => value) + end end end @@ -848,15 +879,15 @@ module ActiveRecord if configuration[:only_integer] unless raw_value.to_s =~ /\A[+-]?\d+\Z/ - record.errors.add(attr_name, configuration[:message] || ActiveRecord::Errors.default_error_messages[:not_a_number]) + record.errors.add(attr_name, :not_a_number, :value => raw_value, :default => configuration[:message]) next end raw_value = raw_value.to_i else - begin + begin raw_value = Kernel.Float(raw_value) rescue ArgumentError, TypeError - record.errors.add(attr_name, configuration[:message] || ActiveRecord::Errors.default_error_messages[:not_a_number]) + record.errors.add(attr_name, :not_a_number, :value => raw_value, :default => configuration[:message]) next end end @@ -864,11 +895,11 @@ module ActiveRecord numericality_options.each do |option| case option when :odd, :even - record.errors.add(attr_name, configuration[:message] || ActiveRecord::Errors.default_error_messages[option]) unless raw_value.to_i.method(ALL_NUMERICALITY_CHECKS[option])[] + unless raw_value.to_i.method(ALL_NUMERICALITY_CHECKS[option])[] + record.errors.add(attr_name, option, :value => raw_value, :default => configuration[:message]) + end else - message = configuration[:message] || ActiveRecord::Errors.default_error_messages[option] - message = message % configuration[option] if configuration[option] - record.errors.add(attr_name, message) unless raw_value.method(ALL_NUMERICALITY_CHECKS[option])[configuration[option]] + record.errors.add(attr_name, option, :default => configuration[:message], :value => raw_value, :count => configuration[option]) unless raw_value.method(ALL_NUMERICALITY_CHECKS[option])[configuration[option]] end end end diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb index 9c718c4fef..9c718c4fef 100755..100644 --- a/activerecord/test/cases/associations/belongs_to_associations_test.rb +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb index 3631be76a0..8c9ae8a031 100644 --- a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -9,7 +9,7 @@ require 'models/topic' require 'models/reply' class CascadedEagerLoadingTest < ActiveRecord::TestCase - fixtures :authors, :mixins, :companies, :posts, :topics + fixtures :authors, :mixins, :companies, :posts, :topics, :accounts, :comments, :categorizations def test_eager_association_loading_with_cascaded_two_levels authors = Author.find(:all, :include=>{:posts=>:comments}, :order=>"authors.id") @@ -68,6 +68,18 @@ class CascadedEagerLoadingTest < ActiveRecord::TestCase end end + def test_eager_association_loading_with_has_many_sti_and_subclasses + silly = SillyReply.new(:title => "gaga", :content => "boo-boo", :parent_id => 1) + silly.parent_id = 1 + assert silly.save + + topics = Topic.find(:all, :include => :replies, :order => 'topics.id, replies_topics.id') + assert_no_queries do + assert_equal 2, topics[0].replies.size + assert_equal 0, topics[1].replies.size + end + end + def test_eager_association_loading_with_belongs_to_sti replies = Reply.find(:all, :include => :topic, :order => 'topics.id') assert replies.include?(topics(:second)) diff --git a/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb new file mode 100644 index 0000000000..7c470616a5 --- /dev/null +++ b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb @@ -0,0 +1,36 @@ +require 'cases/helper' +require 'models/post' +require 'models/tagging' + +module Namespaced + class Post < ActiveRecord::Base + set_table_name 'posts' + has_one :tagging, :as => :taggable, :class_name => 'Tagging' + end +end + +class EagerLoadIncludeFullStiClassNamesTest < ActiveRecord::TestCase + + def setup + generate_test_objects + end + + def generate_test_objects + post = Namespaced::Post.create( :title => 'Great stuff', :body => 'This is not', :author_id => 1 ) + tagging = Tagging.create( :taggable => post ) + end + + def test_class_names + old = ActiveRecord::Base.store_full_sti_class + + ActiveRecord::Base.store_full_sti_class = false + post = Namespaced::Post.find_by_title( 'Great stuff', :include => :tagging ) + assert_nil post.tagging + + ActiveRecord::Base.store_full_sti_class = true + post = Namespaced::Post.find_by_title( 'Great stuff', :include => :tagging ) + assert_equal 'Tagging', post.tagging.class.name + ensure + ActiveRecord::Base.store_full_sti_class = old + end +end diff --git a/activerecord/test/cases/associations/eager_load_nested_include_test.rb b/activerecord/test/cases/associations/eager_load_nested_include_test.rb index 80cfc84b32..12dec5ccd1 100644 --- a/activerecord/test/cases/associations/eager_load_nested_include_test.rb +++ b/activerecord/test/cases/associations/eager_load_nested_include_test.rb @@ -1,5 +1,20 @@ require 'cases/helper' +module Remembered + def self.included(base) + base.extend ClassMethods + base.class_eval do + after_create :remember + protected + def remember; self.class.remembered << self; end + end + end + + module ClassMethods + def remembered; @@remembered ||= []; end + def rand; @@remembered.rand; end + end +end class ShapeExpression < ActiveRecord::Base belongs_to :shape, :polymorphic => true @@ -8,26 +23,33 @@ end class Circle < ActiveRecord::Base has_many :shape_expressions, :as => :shape + include Remembered end class Square < ActiveRecord::Base has_many :shape_expressions, :as => :shape + include Remembered end class Triangle < ActiveRecord::Base has_many :shape_expressions, :as => :shape + include Remembered end class PaintColor < ActiveRecord::Base has_many :shape_expressions, :as => :paint belongs_to :non_poly, :foreign_key => "non_poly_one_id", :class_name => "NonPolyOne" + include Remembered end class PaintTexture < ActiveRecord::Base has_many :shape_expressions, :as => :paint belongs_to :non_poly, :foreign_key => "non_poly_two_id", :class_name => "NonPolyTwo" + include Remembered end class NonPolyOne < ActiveRecord::Base has_many :paint_colors + include Remembered end class NonPolyTwo < ActiveRecord::Base has_many :paint_textures + include Remembered end @@ -49,23 +71,19 @@ class EagerLoadPolyAssocsTest < ActiveRecord::TestCase end - # meant to be supplied as an ID, never returns 0 - def rand_simple - val = (NUM_SIMPLE_OBJS * rand).round - val == 0 ? 1 : val - end - def generate_test_object_graphs 1.upto(NUM_SIMPLE_OBJS) do [Circle, Square, Triangle, NonPolyOne, NonPolyTwo].map(&:create!) end - 1.upto(NUM_SIMPLE_OBJS) do |i| - PaintColor.create!(:non_poly_one_id => rand_simple) - PaintTexture.create!(:non_poly_two_id => rand_simple) + 1.upto(NUM_SIMPLE_OBJS) do + PaintColor.create!(:non_poly_one_id => NonPolyOne.rand.id) + PaintTexture.create!(:non_poly_two_id => NonPolyTwo.rand.id) end - 1.upto(NUM_SHAPE_EXPRESSIONS) do |i| - ShapeExpression.create!(:shape_type => [Circle, Square, Triangle].rand.to_s, :shape_id => rand_simple, - :paint_type => [PaintColor, PaintTexture].rand.to_s, :paint_id => rand_simple) + 1.upto(NUM_SHAPE_EXPRESSIONS) do + shape_type = [Circle, Square, Triangle].rand + paint_type = [PaintColor, PaintTexture].rand + ShapeExpression.create!(:shape_type => shape_type.to_s, :shape_id => shape_type.rand.id, + :paint_type => paint_type.to_s, :paint_id => paint_type.rand.id) end end diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb index f65ada550b..e78624a98d 100644 --- a/activerecord/test/cases/associations/eager_test.rb +++ b/activerecord/test/cases/associations/eager_test.rb @@ -21,7 +21,7 @@ class EagerAssociationTest < ActiveRecord::TestCase fixtures :posts, :comments, :authors, :categories, :categories_posts, :companies, :accounts, :tags, :taggings, :people, :readers, :owners, :pets, :author_favorites, :jobs, :references, :subscribers, :subscriptions, :books, - :developers, :projects + :developers, :projects, :developers_projects def test_loading_with_one_association posts = Post.find(:all, :include => :comments) @@ -38,6 +38,12 @@ class EagerAssociationTest < ActiveRecord::TestCase assert_equal Post.find(1).last_comment, post.last_comment end + def test_loading_with_one_association_with_non_preload + posts = Post.find(:all, :include => :last_comment, :order => 'comments.id DESC') + post = posts.find { |p| p.id == 1 } + assert_equal Post.find(1).last_comment, post.last_comment + end + def test_loading_conditions_with_or posts = authors(:david).posts.find(:all, :include => :comments, :conditions => "comments.body like 'Normal%' OR comments.#{QUOTED_TYPE} = 'SpecialComment'") assert_nil posts.detect { |p| p.author_id != authors(:david).id }, @@ -254,9 +260,9 @@ class EagerAssociationTest < ActiveRecord::TestCase end def test_eager_with_has_many_through - posts_with_comments = people(:michael).posts.find(:all, :include => :comments) - posts_with_author = people(:michael).posts.find(:all, :include => :author ) - posts_with_comments_and_author = people(:michael).posts.find(:all, :include => [ :comments, :author ]) + posts_with_comments = people(:michael).posts.find(:all, :include => :comments, :order => 'posts.id') + posts_with_author = people(:michael).posts.find(:all, :include => :author, :order => 'posts.id') + posts_with_comments_and_author = people(:michael).posts.find(:all, :include => [ :comments, :author ], :order => 'posts.id') assert_equal 2, posts_with_comments.inject(0) { |sum, post| sum += post.comments.size } assert_equal authors(:david), assert_no_queries { posts_with_author.first.author } assert_equal authors(:david), assert_no_queries { posts_with_comments_and_author.first.author } @@ -559,6 +565,13 @@ class EagerAssociationTest < ActiveRecord::TestCase assert_nothing_raised { Post.find(:all, :include => 'comments') } end + def test_eager_with_floating_point_numbers + assert_queries(2) do + # Before changes, the floating point numbers will be interpreted as table names and will cause this to run in one query + Comment.find :all, :conditions => "123.456 = 123.456", :include => :post + end + end + def test_preconfigured_includes_with_belongs_to author = posts(:welcome).author_with_posts assert_no_queries {assert_equal 5, author.posts.size} diff --git a/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb index b29df68d22..0572418e3b 100644 --- a/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb @@ -70,7 +70,7 @@ end class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase fixtures :accounts, :companies, :categories, :posts, :categories_posts, :developers, :projects, :developers_projects, - :parrots, :pirates, :treasures, :price_estimates + :parrots, :pirates, :treasures, :price_estimates, :tags, :taggings def test_has_and_belongs_to_many david = Developer.find(1) @@ -299,6 +299,17 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase assert_equal 3, projects(:active_record, :reload).developers.size end + def test_uniq_option_prevents_duplicate_push + project = projects(:active_record) + project.developers << developers(:jamis) + project.developers << developers(:david) + assert_equal 3, project.developers.size + + project.developers << developers(:david) + project.developers << developers(:jamis) + assert_equal 3, project.developers.size + end + def test_deleting david = Developer.find(1) active_record = Project.find(1) @@ -439,6 +450,13 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase assert_equal developers(:david), active_record.developers_with_finder_sql.find(developers(:david).id), "Ruby find" end + def test_find_in_association_with_custom_finder_sql_and_multiple_interpolations + # interpolate once: + assert_equal [developers(:david), developers(:jamis), developers(:poor_jamis)], projects(:active_record).developers_with_finder_sql, "first interpolation" + # interpolate again, for a different project id + assert_equal [developers(:david)], projects(:action_controller).developers_with_finder_sql, "second interpolation" + end + def test_find_in_association_with_custom_finder_sql_and_string_id assert_equal developers(:david), projects(:active_record).developers_with_finder_sql.find(developers(:david).id.to_s), "SQL find" end @@ -629,8 +647,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase developer.save developer.reload assert_equal 2, developer.projects.length - assert_equal projects(:active_record), developer.projects[0] - assert_equal projects(:action_controller), developer.projects[1] + assert_equal [projects(:active_record), projects(:action_controller)].map(&:id).sort, developer.project_ids.sort end def test_assign_ids_ignoring_blanks @@ -639,8 +656,7 @@ class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase developer.save developer.reload assert_equal 2, developer.projects.length - assert_equal projects(:active_record), developer.projects[0] - assert_equal projects(:action_controller), developer.projects[1] + assert_equal [projects(:active_record), projects(:action_controller)].map(&:id).sort, developer.project_ids.sort end def test_select_limited_ids_list diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb index b9c7ec6377..da3c8fb28e 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -14,7 +14,7 @@ require 'models/reader' class HasManyAssociationsTest < ActiveRecord::TestCase fixtures :accounts, :categories, :companies, :developers, :projects, :developers_projects, :topics, :authors, :comments, :author_addresses, - :people, :posts + :people, :posts, :readers def setup Client.destroyed_client_ids.clear @@ -395,6 +395,18 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_equal 3, company.clients_of_firm.size end + def test_collection_size_twice_for_regressions + post = posts(:thinking) + assert_equal 0, post.readers.size + # This test needs a post that has no readers, we assert it to ensure it holds, + # but need to reload the post because the very call to #size hides the bug. + post.reload + post.readers.build + size1 = post.readers.size + size2 = post.readers.size + assert_equal size1, size2 + end + def test_build_many company = companies(:first_firm) new_clients = assert_no_queries { company.clients_of_firm.build([{"name" => "Another Client"}, {"name" => "Another Client II"}]) } @@ -999,4 +1011,22 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert firm.clients.loaded? end + def test_joins_with_namespaced_model_should_use_correct_type + old = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = true + + firm = Namespaced::Firm.create({ :name => 'Some Company' }) + firm.clients.create({ :name => 'Some Client' }) + + stats = Namespaced::Firm.find(firm.id, { + :select => "#{Namespaced::Firm.table_name}.id, COUNT(#{Namespaced::Client.table_name}.id) AS num_clients", + :joins => :clients, + :group => "#{Namespaced::Firm.table_name}.id" + }) + assert_equal 1, stats.num_clients.to_i + + ensure + ActiveRecord::Base.store_full_sti_class = old + end + end diff --git a/activerecord/test/cases/associations/has_many_through_associations_test.rb b/activerecord/test/cases/associations/has_many_through_associations_test.rb index be5170f830..d51a3c7e1c 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -2,15 +2,18 @@ require "cases/helper" require 'models/post' require 'models/person' require 'models/reader' +require 'models/comment' class HasManyThroughAssociationsTest < ActiveRecord::TestCase - fixtures :posts, :readers, :people + fixtures :posts, :readers, :people, :comments def test_associate_existing assert_queries(2) { posts(:thinking);people(:david) } - + + posts(:thinking).people + assert_queries(1) do - posts(:thinking).people << people(:david) + posts(:thinking).people << people(:david) end assert_queries(1) do diff --git a/activerecord/test/cases/associations/has_one_associations_test.rb b/activerecord/test/cases/associations/has_one_associations_test.rb index 99639849a5..ec06be5eba 100755..100644 --- a/activerecord/test/cases/associations/has_one_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_associations_test.rb @@ -79,6 +79,16 @@ class HasOneAssociationsTest < ActiveRecord::TestCase assert_raises(ActiveRecord::RecordNotFound) { Account.find(old_account_id) } end + def test_natural_assignment_to_already_associated_record + company = companies(:first_firm) + account = accounts(:signals37) + assert_equal company.account, account + company.account = account + company.reload + account.reload + assert_equal company.account, account + end + def test_assignment_without_replacement apple = Firm.create("name" => "Apple") citibank = Account.create("credit_limit" => 10) diff --git a/activerecord/test/cases/associations/has_one_through_associations_test.rb b/activerecord/test/cases/associations/has_one_through_associations_test.rb index 3eb66bc941..b61a3711e3 100644 --- a/activerecord/test/cases/associations/has_one_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_through_associations_test.rb @@ -44,19 +44,23 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase def test_has_one_through_polymorphic assert_equal clubs(:moustache_club), @member.sponsor_club end - + def has_one_through_to_has_many assert_equal 2, @member.fellow_members.size end - + def test_has_one_through_eager_loading - members = Member.find(:all, :include => :club, :conditions => ["name = ?", "Groucho Marx"]) + members = assert_queries(3) do #base table, through table, clubs table + Member.find(:all, :include => :club, :conditions => ["name = ?", "Groucho Marx"]) + end assert_equal 1, members.size assert_not_nil assert_no_queries {members[0].club} end - + def test_has_one_through_eager_loading_through_polymorphic - members = Member.find(:all, :include => :sponsor_club, :conditions => ["name = ?", "Groucho Marx"]) + members = assert_queries(3) do #base table, through table, clubs table + Member.find(:all, :include => :sponsor_club, :conditions => ["name = ?", "Groucho Marx"]) + end assert_equal 1, members.size assert_not_nil assert_no_queries {members[0].sponsor_club} end @@ -71,4 +75,30 @@ class HasOneThroughAssociationsTest < ActiveRecord::TestCase assert_not_nil assert_no_queries {clubs[0].sponsored_member} end + def test_has_one_through_nonpreload_eagerloading + members = assert_queries(1) do + Member.find(:all, :include => :club, :conditions => ["members.name = ?", "Groucho Marx"], :order => 'clubs.name') #force fallback + end + assert_equal 1, members.size + assert_not_nil assert_no_queries {members[0].club} + end + + def test_has_one_through_nonpreload_eager_loading_through_polymorphic + members = assert_queries(1) do + Member.find(:all, :include => :sponsor_club, :conditions => ["members.name = ?", "Groucho Marx"], :order => 'clubs.name') #force fallback + end + assert_equal 1, members.size + assert_not_nil assert_no_queries {members[0].sponsor_club} + end + + def test_has_one_through_nonpreload_eager_loading_through_polymorphic_with_more_than_one_through_record + Sponsor.new(:sponsor_club => clubs(:crazy_club), :sponsorable => members(:groucho)).save! + members = assert_queries(1) do + Member.find(:all, :include => :sponsor_club, :conditions => ["members.name = ?", "Groucho Marx"], :order => 'clubs.name DESC') #force fallback + end + assert_equal 1, members.size + assert_not_nil assert_no_queries { members[0].sponsor_club } + assert_equal clubs(:crazy_club), members[0].sponsor_club + end + end diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb index 9e79d9c8a1..7a0427aabc 100644 --- a/activerecord/test/cases/associations/join_model_test.rb +++ b/activerecord/test/cases/associations/join_model_test.rb @@ -694,6 +694,13 @@ class AssociationsJoinModelTest < ActiveRecord::TestCase assert ! david.categories.include?(category) end + def test_has_many_through_goes_through_all_sti_classes + sub_sti_post = SubStiPost.create!(:title => 'test', :body => 'test', :author_id => 1) + new_comment = sub_sti_post.comments.create(:body => 'test') + + assert_equal [9, 10, new_comment.id], authors(:david).sti_post_comments.map(&:id).sort + end + private # create dynamic Post models to allow different dependency options def find_post_with_dependency(post_id, association, association_name, dependency) diff --git a/activerecord/test/cases/associations_test.rb b/activerecord/test/cases/associations_test.rb index 4904feeb7d..0b2731ecd7 100755..100644 --- a/activerecord/test/cases/associations_test.rb +++ b/activerecord/test/cases/associations_test.rb @@ -27,7 +27,7 @@ require 'models/sponsor' class AssociationsTest < ActiveRecord::TestCase fixtures :accounts, :companies, :developers, :projects, :developers_projects, - :computers + :computers, :people, :readers def test_include_with_order_works assert_nothing_raised {Account.find(:first, :order => 'id', :include => :firm)} @@ -45,7 +45,7 @@ class AssociationsTest < ActiveRecord::TestCase assert_equal [], person.readers.find(:all) person.save! reader = Reader.create! :person => person, :post => Post.new(:title => "foo", :body => "bar") - assert_equal [reader], person.readers.find(:all) + assert person.readers.find(reader.id) end def test_force_reload diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index 7999e29264..7999e29264 100755..100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb index a4be629fbd..c8111358e3 100755..100644 --- a/activerecord/test/cases/base_test.rb +++ b/activerecord/test/cases/base_test.rb @@ -19,6 +19,7 @@ require 'models/warehouse_thing' require 'rexml/document' class Category < ActiveRecord::Base; end +class Categorization < ActiveRecord::Base; end class Smarts < ActiveRecord::Base; end class CreditCard < ActiveRecord::Base class PinNumber < ActiveRecord::Base @@ -75,7 +76,7 @@ class TopicWithProtectedContentAndAccessibleAuthorName < ActiveRecord::Base end class BasicsTest < ActiveRecord::TestCase - fixtures :topics, :companies, :developers, :projects, :computers, :accounts, :minimalistics, 'warehouse-things', :authors + fixtures :topics, :companies, :developers, :projects, :computers, :accounts, :minimalistics, 'warehouse-things', :authors, :categorizations, :categories def test_table_exists assert !NonExistentTable.table_exists? @@ -130,14 +131,14 @@ class BasicsTest < ActiveRecord::TestCase def test_read_attributes_before_type_cast category = Category.new({:name=>"Test categoty", :type => nil}) - category_attrs = {"name"=>"Test categoty", "type" => nil} + category_attrs = {"name"=>"Test categoty", "type" => nil, "categorizations_count" => nil} assert_equal category_attrs , category.attributes_before_type_cast end if current_adapter?(:MysqlAdapter) def test_read_attributes_before_type_cast_on_boolean bool = Booleantest.create({ "value" => false }) - assert_equal 0, bool.attributes_before_type_cast["value"] + assert_equal "0", bool.reload.attributes_before_type_cast["value"] end end @@ -614,6 +615,22 @@ class BasicsTest < ActiveRecord::TestCase assert_equal -2, Topic.find(2).replies_count end + def test_update_counter + category = categories(:general) + assert_nil category.categorizations_count + assert_equal 2, category.categorizations.count + + Category.update_counters(category.id, "categorizations_count" => category.categorizations.count) + category.reload + assert_not_nil category.categorizations_count + assert_equal 2, category.categorizations_count + + Category.update_counters(category.id, "categorizations_count" => category.categorizations.count) + category.reload + assert_not_nil category.categorizations_count + assert_equal 4, category.categorizations_count + end + def test_update_all assert_equal Topic.count, Topic.update_all("content = 'bulk updated!'") assert_equal "bulk updated!", Topic.find(1).content @@ -887,6 +904,14 @@ class BasicsTest < ActiveRecord::TestCase assert_nil keyboard.id end + def test_mass_assigning_invalid_attribute + firm = Firm.new + + assert_raises(ActiveRecord::UnknownAttributeError) do + firm.attributes = { "id" => 5, "type" => "Client", "i_dont_even_exist" => 20 } + end + end + def test_mass_assignment_protection_on_defaults firm = Firm.new firm.attributes = { "id" => 5, "type" => "Client" } @@ -1089,11 +1114,15 @@ class BasicsTest < ActiveRecord::TestCase end def test_boolean + b_nil = Booleantest.create({ "value" => nil }) + nil_id = b_nil.id b_false = Booleantest.create({ "value" => false }) false_id = b_false.id b_true = Booleantest.create({ "value" => true }) true_id = b_true.id + b_nil = Booleantest.find(nil_id) + assert_nil b_nil.value b_false = Booleantest.find(false_id) assert !b_false.value? b_true = Booleantest.find(true_id) @@ -1101,11 +1130,15 @@ class BasicsTest < ActiveRecord::TestCase end def test_boolean_cast_from_string + b_blank = Booleantest.create({ "value" => "" }) + blank_id = b_blank.id b_false = Booleantest.create({ "value" => "0" }) false_id = b_false.id b_true = Booleantest.create({ "value" => "1" }) true_id = b_true.id + b_blank = Booleantest.find(blank_id) + assert_nil b_blank.value b_false = Booleantest.find(false_id) assert !b_false.value? b_true = Booleantest.find(true_id) @@ -1336,6 +1369,12 @@ class BasicsTest < ActiveRecord::TestCase assert_equal(myobj, topic.content) end + def test_serialized_time_attribute + myobj = Time.local(2008,1,1,1,0) + topic = Topic.create("content" => myobj).reload + assert_equal(myobj, topic.content) + end + def test_nil_serialized_attribute_with_class_constraint myobj = MyObject.new('value1', 'value2') topic = Topic.new diff --git a/activerecord/test/cases/column_definition_test.rb b/activerecord/test/cases/column_definition_test.rb index 540f42f4b6..98abc8eac8 100644 --- a/activerecord/test/cases/column_definition_test.rb +++ b/activerecord/test/cases/column_definition_test.rb @@ -9,13 +9,13 @@ class ColumnDefinitionTest < ActiveRecord::TestCase end # Avoid column definitions in create table statements like: - # `title` varchar(255) DEFAULT NULL NULL + # `title` varchar(255) DEFAULT NULL def test_should_not_include_default_clause_when_default_is_null column = ActiveRecord::ConnectionAdapters::Column.new("title", nil, "varchar(20)") column_def = ActiveRecord::ConnectionAdapters::ColumnDefinition.new( @adapter, column.name, "string", column.limit, column.precision, column.scale, column.default, column.null) - assert_equal "title varchar(20) NULL", column_def.to_sql + assert_equal "title varchar(20)", column_def.to_sql end def test_should_include_default_clause_when_default_is_present @@ -23,7 +23,7 @@ class ColumnDefinitionTest < ActiveRecord::TestCase column_def = ActiveRecord::ConnectionAdapters::ColumnDefinition.new( @adapter, column.name, "string", column.limit, column.precision, column.scale, column.default, column.null) - assert_equal %Q{title varchar(20) DEFAULT 'Hello' NULL}, column_def.to_sql + assert_equal %Q{title varchar(20) DEFAULT 'Hello'}, column_def.to_sql end def test_should_specify_not_null_if_null_option_is_false @@ -33,4 +33,4 @@ class ColumnDefinitionTest < ActiveRecord::TestCase column.limit, column.precision, column.scale, column.default, column.null) assert_equal %Q{title varchar(20) DEFAULT 'Hello' NOT NULL}, column_def.to_sql end -end
\ No newline at end of file +end diff --git a/activerecord/test/cases/defaults_test.rb b/activerecord/test/cases/defaults_test.rb index 2ea85417da..3473b846a0 100644 --- a/activerecord/test/cases/defaults_test.rb +++ b/activerecord/test/cases/defaults_test.rb @@ -5,7 +5,7 @@ require 'models/entrant' class DefaultTest < ActiveRecord::TestCase def test_nil_defaults_for_not_null_columns column_defaults = - if current_adapter?(:MysqlAdapter) && Mysql.client_version < 50051 + if current_adapter?(:MysqlAdapter) && (Mysql.client_version < 50051 || (50100..50122).include?(Mysql.client_version)) { 'id' => nil, 'name' => '', 'course_id' => nil } else { 'id' => nil, 'name' => nil, 'course_id' => nil } diff --git a/activerecord/test/cases/deprecated_finder_test.rb b/activerecord/test/cases/deprecated_finder_test.rb index 2afc91b769..2afc91b769 100755..100644 --- a/activerecord/test/cases/deprecated_finder_test.rb +++ b/activerecord/test/cases/deprecated_finder_test.rb diff --git a/activerecord/test/cases/dirty_test.rb b/activerecord/test/cases/dirty_test.rb index e5e022050d..feb47a15a8 100644 --- a/activerecord/test/cases/dirty_test.rb +++ b/activerecord/test/cases/dirty_test.rb @@ -191,6 +191,18 @@ class DirtyTest < ActiveRecord::TestCase assert !pirate.changed? end + def test_save_should_store_serialized_attributes_even_with_partial_updates + with_partial_updates(Topic) do + topic = Topic.create!(:content => {:a => "a"}) + topic.content[:b] = "b" + #assert topic.changed? # Known bug, will fail + topic.save! + assert_equal "b", topic.content[:b] + topic.reload + assert_equal "b", topic.content[:b] + end + end + private def with_partial_updates(klass, on = true) old = klass.partial_updates? diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index b97db73b68..2ce49ed76f 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -12,6 +12,57 @@ require 'models/customer' require 'models/job' require 'models/categorization' +class DynamicFinderMatchTest < ActiveRecord::TestCase + def test_find_no_match + assert_nil ActiveRecord::DynamicFinderMatch.match("not_a_finder") + end + + def test_find_by + match = ActiveRecord::DynamicFinderMatch.match("find_by_age_and_sex_and_location") + assert_not_nil match + assert match.finder? + assert_equal :find_initial, match.finder + assert_equal %w(age sex location), match.attribute_names + end + + def find_by_bang + match = ActiveRecord::DynamicFinderMatch.match("find_by_age_and_sex_and_location!") + assert_not_nil match + assert match.finder? + assert match.bang? + assert_equal :find_initial, match.finder + assert_equal %w(age sex location), match.attribute_names + end + + def test_find_all_by + match = ActiveRecord::DynamicFinderMatch.match("find_all_by_age_and_sex_and_location") + assert_not_nil match + assert match.finder? + assert_equal :find_every, match.finder + assert_equal %w(age sex location), match.attribute_names + end + + def test_find_or_initialize_by + match = ActiveRecord::DynamicFinderMatch.match("find_or_initialize_by_age_and_sex_and_location") + assert_not_nil match + assert !match.finder? + assert match.instantiator? + assert_equal :find_initial, match.finder + assert_equal :new, match.instantiator + assert_equal %w(age sex location), match.attribute_names + end + + def test_find_or_create_by + match = ActiveRecord::DynamicFinderMatch.match("find_or_create_by_age_and_sex_and_location") + assert_not_nil match + assert !match.finder? + assert match.instantiator? + assert_equal :find_initial, match.finder + assert_equal :create, match.instantiator + assert_equal %w(age sex location), match.attribute_names + end +end + class FinderTest < ActiveRecord::TestCase fixtures :companies, :topics, :entrants, :developers, :developers_projects, :posts, :comments, :accounts, :authors, :customers @@ -440,6 +491,11 @@ class FinderTest < ActiveRecord::TestCase assert_nil Topic.find_by_title("The First Topic!") end + def test_find_by_one_attribute_bang + assert_equal topics(:first), Topic.find_by_title!("The First Topic") + assert_raises(ActiveRecord::RecordNotFound) { Topic.find_by_title!("The First Topic!") } + end + def test_find_by_one_attribute_caches_dynamic_finder # ensure this test can run independently of order class << Topic; self; end.send(:remove_method, :find_by_title) if Topic.public_methods.any? { |m| m.to_s == 'find_by_title' } diff --git a/activerecord/test/cases/fixtures_test.rb b/activerecord/test/cases/fixtures_test.rb index 0ea24868f1..6ba7597f56 100755..100644 --- a/activerecord/test/cases/fixtures_test.rb +++ b/activerecord/test/cases/fixtures_test.rb @@ -461,11 +461,11 @@ class FixturesBrokenRollbackTest < ActiveRecord::TestCase alias_method :teardown, :blank_teardown def test_no_rollback_in_teardown_unless_transaction_active - assert_equal 0, Thread.current['open_transactions'] + assert_equal 0, ActiveRecord::Base.connection.open_transactions assert_raise(RuntimeError) { ar_setup_fixtures } - assert_equal 0, Thread.current['open_transactions'] + assert_equal 0, ActiveRecord::Base.connection.open_transactions assert_nothing_raised { ar_teardown_fixtures } - assert_equal 0, Thread.current['open_transactions'] + assert_equal 0, ActiveRecord::Base.connection.open_transactions end private diff --git a/activerecord/test/cases/helper.rb b/activerecord/test/cases/helper.rb index 0530ba9bd9..f30d58546e 100644 --- a/activerecord/test/cases/helper.rb +++ b/activerecord/test/cases/helper.rb @@ -1,4 +1,5 @@ $:.unshift(File.dirname(__FILE__) + '/../../lib') +$:.unshift(File.dirname(__FILE__) + '/../../../activesupport/lib') require 'config' require 'test/unit' diff --git a/activerecord/test/cases/i18n_test.rb b/activerecord/test/cases/i18n_test.rb new file mode 100644 index 0000000000..06036733f5 --- /dev/null +++ b/activerecord/test/cases/i18n_test.rb @@ -0,0 +1,46 @@ +require "cases/helper" +require 'models/topic' +require 'models/reply' + +class ActiveRecordI18nTests < Test::Unit::TestCase + + def setup + reset_translations + end + + def test_translated_model_attributes + I18n.store_translations 'en-US', :activerecord => {:attributes => {:topic => {:title => 'topic title attribute'} } } + assert_equal 'topic title attribute', Topic.human_attribute_name('title') + end + + def test_translated_model_attributes_with_sti + I18n.store_translations 'en-US', :activerecord => {:attributes => {:reply => {:title => 'reply title attribute'} } } + assert_equal 'reply title attribute', Reply.human_attribute_name('title') + end + + def test_translated_model_attributes_with_sti_fallback + I18n.store_translations 'en-US', :activerecord => {:attributes => {:topic => {:title => 'topic title attribute'} } } + assert_equal 'topic title attribute', Reply.human_attribute_name('title') + end + + def test_translated_model_names + I18n.store_translations 'en-US', :activerecord => {:models => {:topic => 'topic model'} } + assert_equal 'topic model', Topic.human_name + end + + def test_translated_model_names_with_sti + I18n.store_translations 'en-US', :activerecord => {:models => {:reply => 'reply model'} } + assert_equal 'reply model', Reply.human_name + end + + def test_translated_model_names_with_sti_fallback + I18n.store_translations 'en-US', :activerecord => {:models => {:topic => 'topic model'} } + assert_equal 'topic model', Reply.human_name + end + + private + def reset_translations + I18n.backend = I18n::Backend::Simple.new + end +end + diff --git a/activerecord/test/cases/inheritance_test.rb b/activerecord/test/cases/inheritance_test.rb index 4fd38bfbc9..4fd38bfbc9 100755..100644 --- a/activerecord/test/cases/inheritance_test.rb +++ b/activerecord/test/cases/inheritance_test.rb diff --git a/activerecord/test/cases/lifecycle_test.rb b/activerecord/test/cases/lifecycle_test.rb index 258f7c7a0f..54fb3d8c39 100755..100644 --- a/activerecord/test/cases/lifecycle_test.rb +++ b/activerecord/test/cases/lifecycle_test.rb @@ -74,7 +74,7 @@ class MultiObserver < ActiveRecord::Observer end class LifecycleTest < ActiveRecord::TestCase - fixtures :topics, :developers + fixtures :topics, :developers, :minimalistics def test_before_destroy original_count = Topic.count @@ -143,7 +143,7 @@ class LifecycleTest < ActiveRecord::TestCase assert_equal developer.name, multi_observer.record.name end - def test_observing_after_find_when_not_defined_on_the_model + def test_after_find_can_be_observed_when_its_not_defined_on_the_model observer = MinimalisticObserver.instance assert_equal Minimalistic, MinimalisticObserver.observed_class @@ -151,6 +151,42 @@ class LifecycleTest < ActiveRecord::TestCase assert_equal minimalistic, observer.minimalistic end + def test_after_find_can_be_observed_when_its_defined_on_the_model + observer = TopicObserver.instance + assert_equal Topic, TopicObserver.observed_class + + topic = Topic.find(1) + assert_equal topic, observer.topic + end + + def test_after_find_is_not_created_if_its_not_used + # use a fresh class so an observer can't have defined an + # after_find on it + model_class = Class.new(ActiveRecord::Base) + observer_class = Class.new(ActiveRecord::Observer) + observer_class.observe(model_class) + + observer = observer_class.instance + + assert !model_class.method_defined?(:after_find) + end + + def test_after_find_is_not_clobbered_if_it_already_exists + # use a fresh observer class so we can instantiate it (Observer is + # a Singleton) + model_class = Class.new(ActiveRecord::Base) do + def after_find; end + end + original_method = model_class.instance_method(:after_find) + observer_class = Class.new(ActiveRecord::Observer) do + def after_find; end + end + observer_class.observe(model_class) + + observer = observer_class.instance + assert_equal original_method, model_class.instance_method(:after_find) + end + def test_invalid_observer assert_raise(ArgumentError) { Topic.observers = Object.new; Topic.instantiate_observers } end diff --git a/activerecord/test/cases/method_scoping_test.rb b/activerecord/test/cases/method_scoping_test.rb index d6b3e341df..ee66ac948d 100644 --- a/activerecord/test/cases/method_scoping_test.rb +++ b/activerecord/test/cases/method_scoping_test.rb @@ -6,7 +6,7 @@ require 'models/post' require 'models/category' class MethodScopingTest < ActiveRecord::TestCase - fixtures :developers, :projects, :comments, :posts + fixtures :developers, :projects, :comments, :posts, :developers_projects def test_set_conditions Developer.with_scope(:find => { :conditions => 'just a test...' }) do diff --git a/activerecord/test/cases/migration_test.rb b/activerecord/test/cases/migration_test.rb index 7ecf755ef8..c1a8da2270 100644 --- a/activerecord/test/cases/migration_test.rb +++ b/activerecord/test/cases/migration_test.rb @@ -237,6 +237,39 @@ if ActiveRecord::Base.connection.supports_migrations? end end + def test_create_table_with_timestamps_should_create_datetime_columns + table_name = :testings + + Person.connection.create_table table_name do |t| + t.timestamps + end + created_columns = Person.connection.columns(table_name) + + created_at_column = created_columns.detect {|c| c.name == 'created_at' } + updated_at_column = created_columns.detect {|c| c.name == 'updated_at' } + + assert created_at_column.null + assert updated_at_column.null + ensure + Person.connection.drop_table table_name rescue nil + end + + def test_create_table_with_timestamps_should_create_datetime_columns_with_options + table_name = :testings + + Person.connection.create_table table_name do |t| + t.timestamps :null => false + end + created_columns = Person.connection.columns(table_name) + + created_at_column = created_columns.detect {|c| c.name == 'created_at' } + updated_at_column = created_columns.detect {|c| c.name == 'updated_at' } + + assert !created_at_column.null + assert !updated_at_column.null + ensure + Person.connection.drop_table table_name rescue nil + end # SQL Server, Sybase, and SQLite3 will not allow you to add a NOT NULL # column to a table without a default value. @@ -409,10 +442,7 @@ if ActiveRecord::Base.connection.supports_migrations? ActiveRecord::Migration.add_column :people, :intelligence_quotient, :tinyint Person.reset_column_information - Person.create :intelligence_quotient => 300 - jonnyg = Person.find(:first) - assert_equal 127, jonnyg.intelligence_quotient - jonnyg.destroy + assert_match /tinyint/, Person.columns_hash['intelligence_quotient'].sql_type ensure ActiveRecord::Migration.remove_column :people, :intelligence_quotient rescue nil end @@ -904,6 +934,21 @@ if ActiveRecord::Base.connection.supports_migrations? assert_equal(0, ActiveRecord::Migrator.current_version) end + if current_adapter?(:PostgreSQLAdapter) + def test_migrator_one_up_with_exception_and_rollback + assert !Person.column_methods_hash.include?(:last_name) + + e = assert_raises(StandardError) do + ActiveRecord::Migrator.up(MIGRATIONS_ROOT + "/broken", 100) + end + + assert_equal "An error has occurred, this and all later migrations canceled:\n\nSomething broke", e.message + + Person.reset_column_information + assert !Person.column_methods_hash.include?(:last_name) + end + end + def test_finds_migrations migrations = ActiveRecord::Migrator.new(:up, MIGRATIONS_ROOT + "/valid").migrations [['1', 'people_have_last_names'], @@ -922,6 +967,26 @@ if ActiveRecord::Base.connection.supports_migrations? migrations[0].name == 'innocent_jointable' end + def test_only_loads_pending_migrations + # migrate up to 1 + ActiveRecord::Migrator.up(MIGRATIONS_ROOT + "/valid", 1) + + # now unload the migrations that have been defined + PeopleHaveLastNames.unloadable + ActiveSupport::Dependencies.remove_unloadable_constants! + + ActiveRecord::Migrator.migrate(MIGRATIONS_ROOT + "/valid", nil) + + assert !defined? PeopleHaveLastNames + + %w(WeNeedReminders, InnocentJointable).each do |migration| + assert defined? migration + end + + ensure + load(MIGRATIONS_ROOT + "/valid/1_people_have_last_names.rb") + end + def test_migrator_interleaved_migrations ActiveRecord::Migrator.up(MIGRATIONS_ROOT + "/interleaved/pass_1") @@ -1172,8 +1237,8 @@ if ActiveRecord::Base.connection.supports_migrations? def test_timestamps_creates_updated_at_and_created_at with_new_table do |t| - t.expects(:column).with(:created_at, :datetime) - t.expects(:column).with(:updated_at, :datetime) + t.expects(:column).with(:created_at, :datetime, kind_of(Hash)) + t.expects(:column).with(:updated_at, :datetime, kind_of(Hash)) t.timestamps end end diff --git a/activerecord/test/cases/multiple_db_test.rb b/activerecord/test/cases/multiple_db_test.rb index eb3e43c8ac..7c3e0f2ca6 100644 --- a/activerecord/test/cases/multiple_db_test.rb +++ b/activerecord/test/cases/multiple_db_test.rb @@ -57,4 +57,29 @@ class MultipleDbTest < ActiveRecord::TestCase assert Course.connection end + + def test_transactions_across_databases + c1 = Course.find(1) + e1 = Entrant.find(1) + + begin + Course.transaction do + Entrant.transaction do + c1.name = "Typo" + e1.name = "Typo" + c1.save + e1.save + raise "No I messed up." + end + end + rescue + # Yup caught it + end + + assert_equal "Typo", c1.name + assert_equal "Typo", e1.name + + assert_equal "Ruby Development", Course.find(1).name + assert_equal "Ruby Developer", Entrant.find(1).name + end end diff --git a/activerecord/test/cases/named_scope_test.rb b/activerecord/test/cases/named_scope_test.rb index 0c1eb23428..6f6ea1cbe9 100644 --- a/activerecord/test/cases/named_scope_test.rb +++ b/activerecord/test/cases/named_scope_test.rb @@ -4,6 +4,7 @@ require 'models/topic' require 'models/comment' require 'models/reply' require 'models/author' +require 'models/developer' class NamedScopeTest < ActiveRecord::TestCase fixtures :posts, :authors, :topics, :comments, :author_addresses @@ -45,6 +46,17 @@ class NamedScopeTest < ActiveRecord::TestCase assert_equal Topic.average(:replies_count), Topic.base.average(:replies_count) end + def test_scope_should_respond_to_own_methods_and_methods_of_the_proxy + assert Topic.approved.respond_to?(:proxy_found) + assert Topic.approved.respond_to?(:count) + assert Topic.approved.respond_to?(:length) + end + + def test_respond_to_respects_include_private_parameter + assert !Topic.approved.respond_to?(:load_found) + assert Topic.approved.respond_to?(:load_found, true) + end + def test_subclasses_inherit_scopes assert Topic.scopes.include?(:base) @@ -184,6 +196,29 @@ class NamedScopeTest < ActiveRecord::TestCase end end + def test_any_should_not_load_results + topics = Topic.base + assert_queries(2) do + topics.any? # use count query + topics.collect # force load + topics.any? # use loaded (no query) + end + end + + def test_any_should_call_proxy_found_if_using_a_block + topics = Topic.base + assert_queries(1) do + topics.expects(:empty?).never + topics.any? { true } + end + end + + def test_any_should_not_fire_query_if_named_scope_loaded + topics = Topic.base + topics.collect # force load + assert_no_queries { assert topics.any? } + end + def test_should_build_with_proxy_options topic = Topic.approved.build({}) assert topic.approved @@ -209,4 +244,12 @@ class NamedScopeTest < ActiveRecord::TestCase assert topic.approved assert_equal 'lifo', topic.author_name end + + def test_find_all_should_behave_like_select + assert_equal Topic.base.select(&:approved), Topic.base.find_all(&:approved) + end + + def test_should_use_where_in_query_for_named_scope + assert_equal Developer.find_all_by_name('Jamis'), Developer.find_all_by_id(Developer.jamises) + end end diff --git a/activerecord/test/cases/query_cache_test.rb b/activerecord/test/cases/query_cache_test.rb index dc9eeec281..171d0e6dae 100644 --- a/activerecord/test/cases/query_cache_test.rb +++ b/activerecord/test/cases/query_cache_test.rb @@ -58,7 +58,7 @@ end uses_mocha 'QueryCacheExpiryTest' do class QueryCacheExpiryTest < ActiveRecord::TestCase - fixtures :tasks + fixtures :tasks, :posts, :categories, :categories_posts def test_find Task.connection.expects(:clear_query_cache).times(1) @@ -116,8 +116,9 @@ class QueryCacheExpiryTest < ActiveRecord::TestCase def test_cache_is_expired_by_habtm_delete ActiveRecord::Base.connection.expects(:clear_query_cache).times(2) ActiveRecord::Base.cache do - c = Category.find(:first) - p = Post.find(:first) + c = Category.find(1) + p = Post.find(1) + assert p.categories.any? p.categories.delete_all end end diff --git a/activerecord/test/cases/readonly_test.rb b/activerecord/test/cases/readonly_test.rb index b921cbdc9c..b921cbdc9c 100755..100644 --- a/activerecord/test/cases/readonly_test.rb +++ b/activerecord/test/cases/readonly_test.rb diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index 723062e3b8..4b86e32dbf 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -166,6 +166,10 @@ class ReflectionTest < ActiveRecord::TestCase assert_equal 0, Firm.reflect_on_all_associations(:belongs_to).size end + def test_reflection_should_not_raise_error_when_compared_to_other_object + assert_nothing_raised { Firm.reflections[:clients] == Object.new } + end + private def assert_reflection(klass, association, options) assert reflection = klass.reflect_on_association(association) diff --git a/activerecord/test/cases/transactions_test.rb b/activerecord/test/cases/transactions_test.rb index 06a76eacc3..af3ee6ddba 100644 --- a/activerecord/test/cases/transactions_test.rb +++ b/activerecord/test/cases/transactions_test.rb @@ -2,6 +2,7 @@ require "cases/helper" require 'models/topic' require 'models/reply' require 'models/developer' +require 'models/book' class TransactionTest < ActiveRecord::TestCase self.use_transactional_fixtures = false @@ -86,8 +87,7 @@ class TransactionTest < ActiveRecord::TestCase assert Topic.find(2).approved?, "Second should still be approved" end - - def test_callback_rollback_in_save + def test_raising_exception_in_callback_rollbacks_in_save add_exception_raising_after_save_callback_to_topic begin @@ -102,6 +102,54 @@ class TransactionTest < ActiveRecord::TestCase end end + def test_cancellation_from_before_destroy_rollbacks_in_destroy + add_cancelling_before_destroy_with_db_side_effect_to_topic + begin + nbooks_before_destroy = Book.count + status = @first.destroy + assert !status + assert_nothing_raised(ActiveRecord::RecordNotFound) { @first.reload } + assert_equal nbooks_before_destroy, Book.count + ensure + remove_cancelling_before_destroy_with_db_side_effect_to_topic + end + end + + def test_cancellation_from_before_filters_rollbacks_in_save + %w(validation save).each do |filter| + send("add_cancelling_before_#{filter}_with_db_side_effect_to_topic") + begin + nbooks_before_save = Book.count + original_author_name = @first.author_name + @first.author_name += '_this_should_not_end_up_in_the_db' + status = @first.save + assert !status + assert_equal original_author_name, @first.reload.author_name + assert_equal nbooks_before_save, Book.count + ensure + send("remove_cancelling_before_#{filter}_with_db_side_effect_to_topic") + end + end + end + + def test_cancellation_from_before_filters_rollbacks_in_save! + %w(validation save).each do |filter| + send("add_cancelling_before_#{filter}_with_db_side_effect_to_topic") + begin + nbooks_before_save = Book.count + original_author_name = @first.author_name + @first.author_name += '_this_should_not_end_up_in_the_db' + @first.save! + flunk + rescue => e + assert_equal original_author_name, @first.reload.author_name + assert_equal nbooks_before_save, Book.count + ensure + send("remove_cancelling_before_#{filter}_with_db_side_effect_to_topic") + end + end + end + def test_callback_rollback_in_create new_topic = Topic.new( :title => "A new topic", @@ -221,6 +269,16 @@ class TransactionTest < ActiveRecord::TestCase def remove_exception_raising_after_create_callback_to_topic Topic.class_eval { remove_method :after_create } end + + %w(validation save destroy).each do |filter| + define_method("add_cancelling_before_#{filter}_with_db_side_effect_to_topic") do + Topic.class_eval "def before_#{filter}() Book.create; false end" + end + + define_method("remove_cancelling_before_#{filter}_with_db_side_effect_to_topic") do + Topic.class_eval "remove_method :before_#{filter}" + end + end end if current_adapter?(:PostgreSQLAdapter) diff --git a/activerecord/test/cases/unconnected_test.rb b/activerecord/test/cases/unconnected_test.rb index 23ad10f3f9..23ad10f3f9 100755..100644 --- a/activerecord/test/cases/unconnected_test.rb +++ b/activerecord/test/cases/unconnected_test.rb diff --git a/activerecord/test/cases/validations_i18n_test.rb b/activerecord/test/cases/validations_i18n_test.rb new file mode 100644 index 0000000000..43592bcee3 --- /dev/null +++ b/activerecord/test/cases/validations_i18n_test.rb @@ -0,0 +1,858 @@ +require "cases/helper" +require 'models/topic' +require 'models/reply' + +class ActiveRecordValidationsI18nTests < Test::Unit::TestCase + def setup + reset_callbacks Topic + @topic = Topic.new + I18n.backend.store_translations('en-US', :activerecord => {:errors => {:messages => {:custom => nil}}}) + end + + def teardown + reset_callbacks Topic + I18n.load_translations File.dirname(__FILE__) + '/../../lib/active_record/locale/en-US.yml' + end + + def unique_topic + @unique ||= Topic.create :title => 'unique!' + end + + def replied_topic + @replied_topic ||= begin + topic = Topic.create(:title => "topic") + topic.replies << Reply.new + topic + end + end + + def reset_callbacks(*models) + models.each do |model| + model.instance_variable_set("@validate_callbacks", ActiveSupport::Callbacks::CallbackChain.new) + model.instance_variable_set("@validate_on_create_callbacks", ActiveSupport::Callbacks::CallbackChain.new) + model.instance_variable_set("@validate_on_update_callbacks", ActiveSupport::Callbacks::CallbackChain.new) + end + end + + def test_default_error_messages_is_deprecated + assert_deprecated('ActiveRecord::Errors.default_error_messages') do + ActiveRecord::Errors.default_error_messages + end + end + + # ActiveRecord::Errors + uses_mocha 'ActiveRecord::Errors' do + + def test_errors_generate_message_translates_custom_model_attribute_key + + I18n.expects(:translate).with( + :topic, + { :count => 1, + :default => ['Topic'], + :scope => [:activerecord, :models] + } + ).returns('Topic') + + I18n.expects(:translate).with( + :"topic.title", + { :count => 1, + :default => ['Title'], + :scope => [:activerecord, :attributes] + } + ).returns('Title') + + I18n.expects(:translate).with( + :"models.topic.attributes.title.invalid", + :value => nil, + :scope => [:activerecord, :errors], + :default => [ + :"models.topic.invalid", + 'default from class def error 1', + :"messages.invalid"], + :attribute => "Title", + :model => "Topic" + ).returns('default from class def error 1') + + @topic.errors.generate_message :title, :invalid, :default => 'default from class def error 1' + end + + def test_errors_generate_message_translates_custom_model_attribute_keys_with_sti + + I18n.expects(:translate).with( + :reply, + { :count => 1, + :default => [:topic, 'Reply'], + :scope => [:activerecord, :models] + } + ).returns('Reply') + + I18n.expects(:translate).with( + :"reply.title", + { :count => 1, + :default => [:'topic.title', 'Title'], + :scope => [:activerecord, :attributes] + } + ).returns('Title') + + I18n.expects(:translate).with( + :"models.reply.attributes.title.invalid", + :value => nil, + :scope => [:activerecord, :errors], + :default => [ + :"models.reply.invalid", + :"models.topic.attributes.title.invalid", + :"models.topic.invalid", + 'default from class def', + :"messages.invalid"], + :model => 'Reply', + :attribute => 'Title' + ).returns("default from class def") + + Reply.new.errors.generate_message :title, :invalid, :default => 'default from class def' + + end + + def test_errors_add_on_empty_generates_message + @topic.errors.expects(:generate_message).with(:title, :empty, {:default => nil}) + @topic.errors.add_on_empty :title + end + + def test_errors_add_on_empty_generates_message_with_custom_default_message + @topic.errors.expects(:generate_message).with(:title, :empty, {:default => 'custom'}) + @topic.errors.add_on_empty :title, 'custom' + end + + def test_errors_add_on_blank_generates_message + @topic.errors.expects(:generate_message).with(:title, :blank, {:default => nil}) + @topic.errors.add_on_blank :title + end + + def test_errors_add_on_blank_generates_message_with_custom_default_message + @topic.errors.expects(:generate_message).with(:title, :blank, {:default => 'custom'}) + @topic.errors.add_on_blank :title, 'custom' + end + + def test_errors_full_messages_translates_human_attribute_name_for_model_attributes + @topic.errors.instance_variable_set :@errors, { 'title' => ['empty'] } + I18n.expects(:translate).with(:"topic.title", :default => ['Title'], :scope => [:activerecord, :attributes], :count => 1).returns('Title') + @topic.errors.full_messages :locale => 'en-US' + end + end + + # ActiveRecord::Validations + uses_mocha 'ActiveRecord::Validations' do + # validates_confirmation_of w/ mocha + + def test_validates_confirmation_of_generates_message + Topic.validates_confirmation_of :title + @topic.title_confirmation = 'foo' + @topic.errors.expects(:generate_message).with(:title, :confirmation, {:default => nil}) + @topic.valid? + end + + def test_validates_confirmation_of_generates_message_with_custom_default_message + Topic.validates_confirmation_of :title, :message => 'custom' + @topic.title_confirmation = 'foo' + @topic.errors.expects(:generate_message).with(:title, :confirmation, {:default => 'custom'}) + @topic.valid? + end + + # validates_acceptance_of w/ mocha + + def test_validates_acceptance_of_generates_message + Topic.validates_acceptance_of :title, :allow_nil => false + @topic.errors.expects(:generate_message).with(:title, :accepted, {:default => nil}) + @topic.valid? + end + + def test_validates_acceptance_of_generates_message_with_custom_default_message + Topic.validates_acceptance_of :title, :message => 'custom', :allow_nil => false + @topic.errors.expects(:generate_message).with(:title, :accepted, {:default => 'custom'}) + @topic.valid? + end + + # validates_presence_of w/ mocha + + def test_validates_presence_of_generates_message + Topic.validates_presence_of :title + @topic.errors.expects(:generate_message).with(:title, :blank, {:default => nil}) + @topic.valid? + end + + def test_validates_presence_of_generates_message_with_custom_default_message + Topic.validates_presence_of :title, :message => 'custom' + @topic.errors.expects(:generate_message).with(:title, :blank, {:default => 'custom'}) + @topic.valid? + end + + def test_validates_length_of_within_generates_message_with_title_too_short + Topic.validates_length_of :title, :within => 3..5 + @topic.errors.expects(:generate_message).with(:title, :too_short, {:count => 3, :default => nil}) + @topic.valid? + end + + def test_validates_length_of_within_generates_message_with_title_too_short_and_custom_default_message + Topic.validates_length_of :title, :within => 3..5, :too_short => 'custom' + @topic.errors.expects(:generate_message).with(:title, :too_short, {:count => 3, :default => 'custom'}) + @topic.valid? + end + + def test_validates_length_of_within_generates_message_with_title_too_long + Topic.validates_length_of :title, :within => 3..5 + @topic.title = 'this title is too long' + @topic.errors.expects(:generate_message).with(:title, :too_long, {:count => 5, :default => nil}) + @topic.valid? + end + + def test_validates_length_of_within_generates_message_with_title_too_long_and_custom_default_message + Topic.validates_length_of :title, :within => 3..5, :too_long => 'custom' + @topic.title = 'this title is too long' + @topic.errors.expects(:generate_message).with(:title, :too_long, {:count => 5, :default => 'custom'}) + @topic.valid? + end + + # validates_length_of :within w/ mocha + + def test_validates_length_of_within_generates_message_with_title_too_short + Topic.validates_length_of :title, :within => 3..5 + @topic.errors.expects(:generate_message).with(:title, :too_short, {:count => 3, :default => nil}) + @topic.valid? + end + + def test_validates_length_of_within_generates_message_with_title_too_short_and_custom_default_message + Topic.validates_length_of :title, :within => 3..5, :too_short => 'custom' + @topic.errors.expects(:generate_message).with(:title, :too_short, {:count => 3, :default => 'custom'}) + @topic.valid? + end + + def test_validates_length_of_within_generates_message_with_title_too_long + Topic.validates_length_of :title, :within => 3..5 + @topic.title = 'this title is too long' + @topic.errors.expects(:generate_message).with(:title, :too_long, {:count => 5, :default => nil}) + @topic.valid? + end + + def test_validates_length_of_within_generates_message_with_title_too_long_and_custom_default_message + Topic.validates_length_of :title, :within => 3..5, :too_long => 'custom' + @topic.title = 'this title is too long' + @topic.errors.expects(:generate_message).with(:title, :too_long, {:count => 5, :default => 'custom'}) + @topic.valid? + end + + # validates_length_of :is w/ mocha + + def test_validates_length_of_is_generates_message + Topic.validates_length_of :title, :is => 5 + @topic.errors.expects(:generate_message).with(:title, :wrong_length, {:count => 5, :default => nil}) + @topic.valid? + end + + def test_validates_length_of_is_generates_message_with_custom_default_message + Topic.validates_length_of :title, :is => 5, :message => 'custom' + @topic.errors.expects(:generate_message).with(:title, :wrong_length, {:count => 5, :default => 'custom'}) + @topic.valid? + end + + # validates_uniqueness_of w/ mocha + + def test_validates_uniqueness_of_generates_message + Topic.validates_uniqueness_of :title + @topic.title = unique_topic.title + @topic.errors.expects(:generate_message).with(:title, :taken, {:default => nil, :value => 'unique!'}) + @topic.valid? + end + + def test_validates_uniqueness_of_generates_message_with_custom_default_message + Topic.validates_uniqueness_of :title, :message => 'custom' + @topic.title = unique_topic.title + @topic.errors.expects(:generate_message).with(:title, :taken, {:default => 'custom', :value => 'unique!'}) + @topic.valid? + end + + # validates_format_of w/ mocha + + def test_validates_format_of_generates_message + Topic.validates_format_of :title, :with => /^[1-9][0-9]*$/ + @topic.title = '72x' + @topic.errors.expects(:generate_message).with(:title, :invalid, {:value => '72x', :default => nil}) + @topic.valid? + end + + def test_validates_format_of_generates_message_with_custom_default_message + Topic.validates_format_of :title, :with => /^[1-9][0-9]*$/, :message => 'custom' + @topic.title = '72x' + @topic.errors.expects(:generate_message).with(:title, :invalid, {:value => '72x', :default => 'custom'}) + @topic.valid? + end + + # validates_inclusion_of w/ mocha + + def test_validates_inclusion_of_generates_message + Topic.validates_inclusion_of :title, :in => %w(a b c) + @topic.title = 'z' + @topic.errors.expects(:generate_message).with(:title, :inclusion, {:value => 'z', :default => nil}) + @topic.valid? + end + + def test_validates_inclusion_of_generates_message_with_custom_default_message + Topic.validates_inclusion_of :title, :in => %w(a b c), :message => 'custom' + @topic.title = 'z' + @topic.errors.expects(:generate_message).with(:title, :inclusion, {:value => 'z', :default => 'custom'}) + @topic.valid? + end + + # validates_exclusion_of w/ mocha + + def test_validates_exclusion_of_generates_message + Topic.validates_exclusion_of :title, :in => %w(a b c) + @topic.title = 'a' + @topic.errors.expects(:generate_message).with(:title, :exclusion, {:value => 'a', :default => nil}) + @topic.valid? + end + + def test_validates_exclusion_of_generates_message_with_custom_default_message + Topic.validates_exclusion_of :title, :in => %w(a b c), :message => 'custom' + @topic.title = 'a' + @topic.errors.expects(:generate_message).with(:title, :exclusion, {:value => 'a', :default => 'custom'}) + @topic.valid? + end + + # validates_numericality_of without :only_integer w/ mocha + + def test_validates_numericality_of_generates_message + Topic.validates_numericality_of :title + @topic.title = 'a' + @topic.errors.expects(:generate_message).with(:title, :not_a_number, {:value => 'a', :default => nil}) + @topic.valid? + end + + def test_validates_numericality_of_generates_message_with_custom_default_message + Topic.validates_numericality_of :title, :message => 'custom' + @topic.title = 'a' + @topic.errors.expects(:generate_message).with(:title, :not_a_number, {:value => 'a', :default => 'custom'}) + @topic.valid? + end + + # validates_numericality_of with :only_integer w/ mocha + + def test_validates_numericality_of_only_integer_generates_message + Topic.validates_numericality_of :title, :only_integer => true + @topic.title = 'a' + @topic.errors.expects(:generate_message).with(:title, :not_a_number, {:value => 'a', :default => nil}) + @topic.valid? + end + + def test_validates_numericality_of_only_integer_generates_message_with_custom_default_message + Topic.validates_numericality_of :title, :only_integer => true, :message => 'custom' + @topic.title = 'a' + @topic.errors.expects(:generate_message).with(:title, :not_a_number, {:value => 'a', :default => 'custom'}) + @topic.valid? + end + + # validates_numericality_of :odd w/ mocha + + def test_validates_numericality_of_odd_generates_message + Topic.validates_numericality_of :title, :only_integer => true, :odd => true + @topic.title = 0 + @topic.errors.expects(:generate_message).with(:title, :odd, {:value => 0, :default => nil}) + @topic.valid? + end + + def test_validates_numericality_of_odd_generates_message_with_custom_default_message + Topic.validates_numericality_of :title, :only_integer => true, :odd => true, :message => 'custom' + @topic.title = 0 + @topic.errors.expects(:generate_message).with(:title, :odd, {:value => 0, :default => 'custom'}) + @topic.valid? + end + + # validates_numericality_of :less_than w/ mocha + + def test_validates_numericality_of_less_than_generates_message + Topic.validates_numericality_of :title, :only_integer => true, :less_than => 0 + @topic.title = 1 + @topic.errors.expects(:generate_message).with(:title, :less_than, {:value => 1, :count => 0, :default => nil}) + @topic.valid? + end + + def test_validates_numericality_of_odd_generates_message_with_custom_default_message + Topic.validates_numericality_of :title, :only_integer => true, :less_than => 0, :message => 'custom' + @topic.title = 1 + @topic.errors.expects(:generate_message).with(:title, :less_than, {:value => 1, :count => 0, :default => 'custom'}) + @topic.valid? + end + + # validates_associated w/ mocha + + def test_validates_associated_generates_message + Topic.validates_associated :replies + replied_topic.errors.expects(:generate_message).with(:replies, :invalid, {:value => replied_topic.replies, :default => nil}) + replied_topic.valid? + end + + def test_validates_associated_generates_message_with_custom_default_message + Topic.validates_associated :replies + replied_topic.errors.expects(:generate_message).with(:replies, :invalid, {:value => replied_topic.replies, :default => nil}) + replied_topic.valid? + end + end + + # validates_confirmation_of w/o mocha + + def test_validates_confirmation_of_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:confirmation => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:confirmation => 'global message'}}} + + Topic.validates_confirmation_of :title + @topic.title_confirmation = 'foo' + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_confirmation_of_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:confirmation => 'global message'}}} + + Topic.validates_confirmation_of :title + @topic.title_confirmation = 'foo' + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + # validates_acceptance_of w/o mocha + + def test_validates_acceptance_of_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:accepted => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:accepted => 'global message'}}} + + Topic.validates_acceptance_of :title, :allow_nil => false + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_acceptance_of_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:accepted => 'global message'}}} + + Topic.validates_acceptance_of :title, :allow_nil => false + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + # validates_presence_of w/o mocha + + def test_validates_presence_of_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:blank => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:blank => 'global message'}}} + + Topic.validates_presence_of :title + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_presence_of_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:blank => 'global message'}}} + + Topic.validates_presence_of :title + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + # validates_length_of :within w/o mocha + + def test_validates_length_of_within_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:too_short => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:too_short => 'global message'}}} + + Topic.validates_length_of :title, :within => 3..5 + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_length_of_within_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:too_short => 'global message'}}} + + Topic.validates_length_of :title, :within => 3..5 + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + # validates_length_of :is w/o mocha + + def test_validates_length_of_within_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:wrong_length => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:wrong_length => 'global message'}}} + + Topic.validates_length_of :title, :is => 5 + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_length_of_within_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:wrong_length => 'global message'}}} + + Topic.validates_length_of :title, :is => 5 + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + # validates_uniqueness_of w/o mocha + + def test_validates_length_of_within_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:wrong_length => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:wrong_length => 'global message'}}} + + Topic.validates_length_of :title, :is => 5 + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_length_of_within_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:wrong_length => 'global message'}}} + + Topic.validates_length_of :title, :is => 5 + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + + # validates_format_of w/o mocha + + def test_validates_format_of_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:invalid => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:invalid => 'global message'}}} + + Topic.validates_format_of :title, :with => /^[1-9][0-9]*$/ + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_format_of_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:invalid => 'global message'}}} + + Topic.validates_format_of :title, :with => /^[1-9][0-9]*$/ + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + # validates_inclusion_of w/o mocha + + def test_validates_inclusion_of_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:inclusion => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:inclusion => 'global message'}}} + + Topic.validates_inclusion_of :title, :in => %w(a b c) + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_inclusion_of_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:inclusion => 'global message'}}} + + Topic.validates_inclusion_of :title, :in => %w(a b c) + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + # validates_exclusion_of w/o mocha + + def test_validates_exclusion_of_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:exclusion => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:exclusion => 'global message'}}} + + Topic.validates_exclusion_of :title, :in => %w(a b c) + @topic.title = 'a' + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_exclusion_of_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:exclusion => 'global message'}}} + + Topic.validates_exclusion_of :title, :in => %w(a b c) + @topic.title = 'a' + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + # validates_numericality_of without :only_integer w/o mocha + + def test_validates_numericality_of_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:not_a_number => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:not_a_number => 'global message'}}} + + Topic.validates_numericality_of :title + @topic.title = 'a' + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_numericality_of_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:not_a_number => 'global message'}}} + + Topic.validates_numericality_of :title, :only_integer => true + @topic.title = 'a' + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + # validates_numericality_of with :only_integer w/o mocha + + def test_validates_numericality_of_only_integer_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:not_a_number => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:not_a_number => 'global message'}}} + + Topic.validates_numericality_of :title, :only_integer => true + @topic.title = 'a' + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_numericality_of_only_integer_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:not_a_number => 'global message'}}} + + Topic.validates_numericality_of :title, :only_integer => true + @topic.title = 'a' + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + # validates_numericality_of :odd w/o mocha + + def test_validates_numericality_of_odd_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:odd => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:odd => 'global message'}}} + + Topic.validates_numericality_of :title, :only_integer => true, :odd => true + @topic.title = 0 + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_numericality_of_odd_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:odd => 'global message'}}} + + Topic.validates_numericality_of :title, :only_integer => true, :odd => true + @topic.title = 0 + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + # validates_numericality_of :less_than w/o mocha + + def test_validates_numericality_of_less_than_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:title => {:less_than => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:less_than => 'global message'}}} + + Topic.validates_numericality_of :title, :only_integer => true, :less_than => 0 + @topic.title = 1 + @topic.valid? + assert_equal 'custom message', @topic.errors.on(:title) + end + + def test_validates_numericality_of_less_than_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:less_than => 'global message'}}} + + Topic.validates_numericality_of :title, :only_integer => true, :less_than => 0 + @topic.title = 1 + @topic.valid? + assert_equal 'global message', @topic.errors.on(:title) + end + + + # validates_associated w/o mocha + + def test_validates_associated_finds_custom_model_key_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:models => {:topic => {:attributes => {:replies => {:invalid => 'custom message'}}}}}} + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:invalid => 'global message'}}} + + Topic.validates_associated :replies + replied_topic.valid? + assert_equal 'custom message', replied_topic.errors.on(:replies) + end + + def test_validates_associated_finds_global_default_translation + I18n.backend.store_translations 'en-US', :activerecord => {:errors => {:messages => {:invalid => 'global message'}}} + + Topic.validates_associated :replies + replied_topic.valid? + assert_equal 'global message', replied_topic.errors.on(:replies) + end +end + +class ActiveRecordValidationsGenerateMessageI18nTests < Test::Unit::TestCase + def setup + reset_callbacks Topic + @topic = Topic.new + I18n.backend.store_translations :'en-US', { + :activerecord => { + :errors => { + :messages => { + :inclusion => "is not included in the list", + :exclusion => "is reserved", + :invalid => "is invalid", + :confirmation => "doesn't match confirmation", + :accepted => "must be accepted", + :empty => "can't be empty", + :blank => "can't be blank", + :too_long => "is too long (maximum is {{count}} characters)", + :too_short => "is too short (minimum is {{count}} characters)", + :wrong_length => "is the wrong length (should be {{count}} characters)", + :taken => "has already been taken", + :not_a_number => "is not a number", + :greater_than => "must be greater than {{count}}", + :greater_than_or_equal_to => "must be greater than or equal to {{count}}", + :equal_to => "must be equal to {{count}}", + :less_than => "must be less than {{count}}", + :less_than_or_equal_to => "must be less than or equal to {{count}}", + :odd => "must be odd", + :even => "must be even" + } + } + } + } + end + + def reset_callbacks(*models) + models.each do |model| + model.instance_variable_set("@validate_callbacks", ActiveSupport::Callbacks::CallbackChain.new) + model.instance_variable_set("@validate_on_create_callbacks", ActiveSupport::Callbacks::CallbackChain.new) + model.instance_variable_set("@validate_on_update_callbacks", ActiveSupport::Callbacks::CallbackChain.new) + end + end + + # validates_inclusion_of: generate_message(attr_name, :inclusion, :default => configuration[:message], :value => value) + def test_generate_message_inclusion_with_default_message + assert_equal 'is not included in the list', @topic.errors.generate_message(:title, :inclusion, :default => nil, :value => 'title') + end + + def test_generate_message_inclusion_with_custom_message + assert_equal 'custom message title', @topic.errors.generate_message(:title, :inclusion, :default => 'custom message {{value}}', :value => 'title') + end + + # validates_exclusion_of: generate_message(attr_name, :exclusion, :default => configuration[:message], :value => value) + def test_generate_message_exclusion_with_default_message + assert_equal 'is reserved', @topic.errors.generate_message(:title, :exclusion, :default => nil, :value => 'title') + end + + def test_generate_message_exclusion_with_custom_message + assert_equal 'custom message title', @topic.errors.generate_message(:title, :exclusion, :default => 'custom message {{value}}', :value => 'title') + end + + # validates_associated: generate_message(attr_name, :invalid, :default => configuration[:message], :value => value) + # validates_format_of: generate_message(attr_name, :invalid, :default => configuration[:message], :value => value) + def test_generate_message_invalid_with_default_message + assert_equal 'is invalid', @topic.errors.generate_message(:title, :invalid, :default => nil, :value => 'title') + end + + def test_generate_message_invalid_with_custom_message + assert_equal 'custom message title', @topic.errors.generate_message(:title, :invalid, :default => 'custom message {{value}}', :value => 'title') + end + + # validates_confirmation_of: generate_message(attr_name, :confirmation, :default => configuration[:message]) + def test_generate_message_confirmation_with_default_message + assert_equal "doesn't match confirmation", @topic.errors.generate_message(:title, :confirmation, :default => nil) + end + + def test_generate_message_confirmation_with_custom_message + assert_equal 'custom message', @topic.errors.generate_message(:title, :confirmation, :default => 'custom message') + end + + # validates_acceptance_of: generate_message(attr_name, :accepted, :default => configuration[:message]) + def test_generate_message_accepted_with_default_message + assert_equal "must be accepted", @topic.errors.generate_message(:title, :accepted, :default => nil) + end + + def test_generate_message_accepted_with_custom_message + assert_equal 'custom message', @topic.errors.generate_message(:title, :accepted, :default => 'custom message') + end + + # add_on_empty: generate_message(attr, :empty, :default => custom_message) + def test_generate_message_empty_with_default_message + assert_equal "can't be empty", @topic.errors.generate_message(:title, :empty, :default => nil) + end + + def test_generate_message_empty_with_custom_message + assert_equal 'custom message', @topic.errors.generate_message(:title, :empty, :default => 'custom message') + end + + # add_on_blank: generate_message(attr, :blank, :default => custom_message) + def test_generate_message_blank_with_default_message + assert_equal "can't be blank", @topic.errors.generate_message(:title, :blank, :default => nil) + end + + def test_generate_message_blank_with_custom_message + assert_equal 'custom message', @topic.errors.generate_message(:title, :blank, :default => 'custom message') + end + + # validates_length_of: generate_message(attr, :too_long, :default => options[:too_long], :count => option_value.end) + def test_generate_message_too_long_with_default_message + assert_equal "is too long (maximum is 10 characters)", @topic.errors.generate_message(:title, :too_long, :default => nil, :count => 10) + end + + def test_generate_message_too_long_with_custom_message + assert_equal 'custom message 10', @topic.errors.generate_message(:title, :too_long, :default => 'custom message {{count}}', :count => 10) + end + + # validates_length_of: generate_message(attr, :too_short, :default => options[:too_short], :count => option_value.begin) + def test_generate_message_too_short_with_default_message + assert_equal "is too short (minimum is 10 characters)", @topic.errors.generate_message(:title, :too_short, :default => nil, :count => 10) + end + + def test_generate_message_too_short_with_custom_message + assert_equal 'custom message 10', @topic.errors.generate_message(:title, :too_short, :default => 'custom message {{count}}', :count => 10) + end + + # validates_length_of: generate_message(attr, key, :default => custom_message, :count => option_value) + def test_generate_message_wrong_length_with_default_message + assert_equal "is the wrong length (should be 10 characters)", @topic.errors.generate_message(:title, :wrong_length, :default => nil, :count => 10) + end + + def test_generate_message_wrong_length_with_custom_message + assert_equal 'custom message 10', @topic.errors.generate_message(:title, :wrong_length, :default => 'custom message {{count}}', :count => 10) + end + + # validates_uniqueness_of: generate_message(attr_name, :taken, :default => configuration[:message]) + def test_generate_message_taken_with_default_message + assert_equal "has already been taken", @topic.errors.generate_message(:title, :taken, :default => nil, :value => 'title') + end + + def test_generate_message_taken_with_custom_message + assert_equal 'custom message title', @topic.errors.generate_message(:title, :taken, :default => 'custom message {{value}}', :value => 'title') + end + + # validates_numericality_of: generate_message(attr_name, :not_a_number, :value => raw_value, :default => configuration[:message]) + def test_generate_message_not_a_number_with_default_message + assert_equal "is not a number", @topic.errors.generate_message(:title, :not_a_number, :default => nil, :value => 'title') + end + + def test_generate_message_not_a_number_with_custom_message + assert_equal 'custom message title', @topic.errors.generate_message(:title, :not_a_number, :default => 'custom message {{value}}', :value => 'title') + end + + # validates_numericality_of: generate_message(attr_name, option, :value => raw_value, :default => configuration[:message]) + def test_generate_message_greater_than_with_default_message + assert_equal "must be greater than 10", @topic.errors.generate_message(:title, :greater_than, :default => nil, :value => 'title', :count => 10) + end + + def test_generate_message_greater_than_or_equal_to_with_default_message + assert_equal "must be greater than or equal to 10", @topic.errors.generate_message(:title, :greater_than_or_equal_to, :default => nil, :value => 'title', :count => 10) + end + + def test_generate_message_equal_to_with_default_message + assert_equal "must be equal to 10", @topic.errors.generate_message(:title, :equal_to, :default => nil, :value => 'title', :count => 10) + end + + def test_generate_message_less_than_with_default_message + assert_equal "must be less than 10", @topic.errors.generate_message(:title, :less_than, :default => nil, :value => 'title', :count => 10) + end + + def test_generate_message_less_than_or_equal_to_with_default_message + assert_equal "must be less than or equal to 10", @topic.errors.generate_message(:title, :less_than_or_equal_to, :default => nil, :value => 'title', :count => 10) + end + + def test_generate_message_odd_with_default_message + assert_equal "must be odd", @topic.errors.generate_message(:title, :odd, :default => nil, :value => 'title', :count => 10) + end + + def test_generate_message_even_with_default_message + assert_equal "must be even", @topic.errors.generate_message(:title, :even, :default => nil, :value => 'title', :count => 10) + end +end diff --git a/activerecord/test/cases/validations_test.rb b/activerecord/test/cases/validations_test.rb index 0742e2c632..a40bda2533 100755..100644 --- a/activerecord/test/cases/validations_test.rb +++ b/activerecord/test/cases/validations_test.rb @@ -477,6 +477,15 @@ class ValidationsTest < ActiveRecord::TestCase assert_not_equal "has already been taken", t3.errors.on(:title) end + def test_validate_case_sensitive_uniqueness_with_attribute_passed_as_integer + Topic.validates_uniqueness_of(:title, :case_sensitve => true) + t = Topic.create!('title' => 101) + + t2 = Topic.new('title' => 101) + assert !t2.valid? + assert t2.errors.on(:title) + end + def test_validate_uniqueness_with_non_standard_table_names i1 = WarehouseThing.create(:value => 1000) assert !i1.valid?, "i1 should not be valid" @@ -853,7 +862,9 @@ class ValidationsTest < ActiveRecord::TestCase end def test_validates_length_with_globally_modified_error_message - ActiveRecord::Errors.default_error_messages[:too_short] = 'tu est trops petit hombre %d' + ActiveSupport::Deprecation.silence do + ActiveRecord::Errors.default_error_messages[:too_short] = 'tu est trops petit hombre %d' + end Topic.validates_length_of :title, :minimum => 10 t = Topic.create(:title => 'too short') assert !t.valid? @@ -1409,8 +1420,8 @@ class ValidatesNumericalityTest < ActiveRecord::TestCase def test_validates_numericality_of_with_nil_allowed Topic.validates_numericality_of :approved, :allow_nil => true - invalid!(BLANK + JUNK) - valid!(NIL + FLOATS + INTEGERS + BIGDECIMAL + INFINITY) + invalid!(JUNK) + valid!(NIL + BLANK + FLOATS + INTEGERS + BIGDECIMAL + INFINITY) end def test_validates_numericality_of_with_integer_only @@ -1423,8 +1434,8 @@ class ValidatesNumericalityTest < ActiveRecord::TestCase def test_validates_numericality_of_with_integer_only_and_nil_allowed Topic.validates_numericality_of :approved, :only_integer => true, :allow_nil => true - invalid!(BLANK + JUNK + FLOATS + BIGDECIMAL + INFINITY) - valid!(NIL + INTEGERS) + invalid!(JUNK + FLOATS + BIGDECIMAL + INFINITY) + valid!(NIL + BLANK + INTEGERS) end def test_validates_numericality_with_greater_than diff --git a/activerecord/test/connections/native_mysql/connection.rb b/activerecord/test/connections/native_mysql/connection.rb index 1fab444e58..140e06d631 100644 --- a/activerecord/test/connections/native_mysql/connection.rb +++ b/activerecord/test/connections/native_mysql/connection.rb @@ -2,9 +2,7 @@ print "Using native MySQL\n" require_dependency 'models/course' require 'logger' -RAILS_DEFAULT_LOGGER = Logger.new('debug.log') -RAILS_DEFAULT_LOGGER.level = Logger::DEBUG -ActiveRecord::Base.logger = RAILS_DEFAULT_LOGGER +ActiveRecord::Base.logger = Logger.new("debug.log") # GRANT ALL PRIVILEGES ON activerecord_unittest.* to 'rails'@'localhost'; # GRANT ALL PRIVILEGES ON activerecord_unittest2.* to 'rails'@'localhost'; diff --git a/activerecord/test/migrations/broken/100_migration_that_raises_exception.rb b/activerecord/test/migrations/broken/100_migration_that_raises_exception.rb new file mode 100644 index 0000000000..ffb224dad9 --- /dev/null +++ b/activerecord/test/migrations/broken/100_migration_that_raises_exception.rb @@ -0,0 +1,10 @@ +class MigrationThatRaisesException < ActiveRecord::Migration + def self.up + add_column "people", "last_name", :string + raise 'Something broke' + end + + def self.down + remove_column "people", "last_name" + end +end diff --git a/activerecord/test/models/author.rb b/activerecord/test/models/author.rb index 136dc39cf3..c6aa0293c2 100644 --- a/activerecord/test/models/author.rb +++ b/activerecord/test/models/author.rb @@ -32,6 +32,9 @@ class Author < ActiveRecord::Base has_many :special_posts has_many :special_post_comments, :through => :special_posts, :source => :comments + has_many :sti_posts, :class_name => 'StiPost' + has_many :sti_post_comments, :through => :sti_posts, :source => :comments + has_many :special_nonexistant_posts, :class_name => "SpecialPost", :conditions => "posts.body = 'nonexistant'" has_many :special_nonexistant_post_comments, :through => :special_nonexistant_posts, :source => :comments, :conditions => "comments.post_id = 0" has_many :nonexistant_comments, :through => :posts diff --git a/activerecord/test/models/company.rb b/activerecord/test/models/company.rb index e6aa810146..cd435948a1 100755..100644 --- a/activerecord/test/models/company.rb +++ b/activerecord/test/models/company.rb @@ -18,6 +18,13 @@ end module Namespaced class Company < ::Company end + + class Firm < ::Company + has_many :clients, :class_name => 'Namespaced::Client' + end + + class Client < ::Company + end end class Firm < Company diff --git a/activerecord/test/models/developer.rb b/activerecord/test/models/developer.rb index 9f26cacdec..c08476f728 100644 --- a/activerecord/test/models/developer.rb +++ b/activerecord/test/models/developer.rb @@ -43,6 +43,8 @@ class Developer < ActiveRecord::Base has_many :audit_logs + named_scope :jamises, :conditions => {:name => 'Jamis'} + validates_inclusion_of :salary, :in => 50000..200000 validates_length_of :name, :within => 3..20 diff --git a/activerecord/test/models/project.rb b/activerecord/test/models/project.rb index e1ab89eca5..44c692b5e7 100644 --- a/activerecord/test/models/project.rb +++ b/activerecord/test/models/project.rb @@ -7,7 +7,7 @@ class Project < ActiveRecord::Base has_and_belongs_to_many :developers_named_david, :class_name => "Developer", :conditions => "name = 'David'", :uniq => true has_and_belongs_to_many :developers_named_david_with_hash_conditions, :class_name => "Developer", :conditions => { :name => 'David' }, :uniq => true has_and_belongs_to_many :salaried_developers, :class_name => "Developer", :conditions => "salary > 0" - has_and_belongs_to_many :developers_with_finder_sql, :class_name => "Developer", :finder_sql => 'SELECT t.*, j.* FROM developers_projects j, developers t WHERE t.id = j.developer_id AND j.project_id = #{id}' + has_and_belongs_to_many :developers_with_finder_sql, :class_name => "Developer", :finder_sql => 'SELECT t.*, j.* FROM developers_projects j, developers t WHERE t.id = j.developer_id AND j.project_id = #{id} ORDER BY t.id' has_and_belongs_to_many :developers_by_sql, :class_name => "Developer", :delete_sql => "DELETE FROM developers_projects WHERE project_id = \#{id} AND developer_id = \#{record.id}" has_and_belongs_to_many :developers_with_callbacks, :class_name => "Developer", :before_add => Proc.new {|o, r| o.developers_log << "before_adding#{r.id || '<new>'}"}, :after_add => Proc.new {|o, r| o.developers_log << "after_adding#{r.id || '<new>'}"}, diff --git a/activerecord/test/models/reply.rb b/activerecord/test/models/reply.rb index 812bc1f535..812bc1f535 100755..100644 --- a/activerecord/test/models/reply.rb +++ b/activerecord/test/models/reply.rb diff --git a/activerecord/test/models/topic.rb b/activerecord/test/models/topic.rb index 39ca1bf42a..39ca1bf42a 100755..100644 --- a/activerecord/test/models/topic.rb +++ b/activerecord/test/models/topic.rb diff --git a/activerecord/test/schema/mysql_specific_schema.rb b/activerecord/test/schema/mysql_specific_schema.rb index 5ae062c97c..f44c33ae67 100644 --- a/activerecord/test/schema/mysql_specific_schema.rb +++ b/activerecord/test/schema/mysql_specific_schema.rb @@ -1,5 +1,5 @@ ActiveRecord::Schema.define do - create_table :binary_fields, :force => true do |t| + create_table :binary_fields, :force => true, :options => 'CHARACTER SET latin1' do |t| t.binary :tiny_blob, :limit => 255 t.binary :normal_blob, :limit => 65535 t.binary :medium_blob, :limit => 16777215 @@ -9,4 +9,4 @@ ActiveRecord::Schema.define do t.text :medium_text, :limit => 16777215 t.text :long_text, :limit => 2147483647 end -end
\ No newline at end of file +end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 29c91a4464..ab5c7c520b 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -60,12 +60,13 @@ ActiveRecord::Schema.define do end create_table :booleantests, :force => true do |t| - t.integer :value + t.boolean :value end create_table :categories, :force => true do |t| t.string :name, :null => false t.string :type + t.integer :categorizations_count end create_table :categories_posts, :force => true, :id => false do |t| |