diff options
Diffstat (limited to 'activerecord/test/cases')
218 files changed, 49397 insertions, 0 deletions
diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb new file mode 100644 index 0000000000..6f84bae432 --- /dev/null +++ b/activerecord/test/cases/adapter_test.rb @@ -0,0 +1,249 @@ +require "cases/helper" +require "models/book" +require "models/post" +require "models/author" + +module ActiveRecord + class AdapterTest < ActiveRecord::TestCase + def setup + @connection = ActiveRecord::Base.connection + end + + ## + # PostgreSQL does not support null bytes in strings + unless current_adapter?(:PostgreSQLAdapter) + def test_update_prepared_statement + b = Book.create(name: "my \x00 book") + b.reload + assert_equal "my \x00 book", b.name + b.update_attributes(name: "my other \x00 book") + b.reload + assert_equal "my other \x00 book", b.name + end + end + + def test_tables + tables = @connection.tables + assert tables.include?("accounts") + assert tables.include?("authors") + assert tables.include?("tasks") + assert tables.include?("topics") + end + + def test_table_exists? + assert @connection.table_exists?("accounts") + assert !@connection.table_exists?("nonexistingtable") + assert !@connection.table_exists?(nil) + end + + def test_indexes + idx_name = "accounts_idx" + + if @connection.respond_to?(:indexes) + indexes = @connection.indexes("accounts") + assert indexes.empty? + + @connection.add_index :accounts, :firm_id, :name => idx_name + indexes = @connection.indexes("accounts") + assert_equal "accounts", indexes.first.table + assert_equal idx_name, indexes.first.name + assert !indexes.first.unique + assert_equal ["firm_id"], indexes.first.columns + else + warn "#{@connection.class} does not respond to #indexes" + end + + ensure + @connection.remove_index(:accounts, :name => idx_name) rescue nil + end + + def test_current_database + if @connection.respond_to?(:current_database) + assert_equal ARTest.connection_config['arunit']['database'], @connection.current_database + end + end + + if current_adapter?(:MysqlAdapter) + def test_charset + assert_not_nil @connection.charset + assert_not_equal 'character_set_database', @connection.charset + assert_equal @connection.show_variable('character_set_database'), @connection.charset + end + + def test_collation + assert_not_nil @connection.collation + assert_not_equal 'collation_database', @connection.collation + assert_equal @connection.show_variable('collation_database'), @connection.collation + end + + def test_show_nonexistent_variable_returns_nil + assert_nil @connection.show_variable('foo_bar_baz') + end + + def test_not_specifying_database_name_for_cross_database_selects + begin + assert_nothing_raised do + ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations['arunit'].except(:database)) + + config = ARTest.connection_config + ActiveRecord::Base.connection.execute( + "SELECT #{config['arunit']['database']}.pirates.*, #{config['arunit2']['database']}.courses.* " \ + "FROM #{config['arunit']['database']}.pirates, #{config['arunit2']['database']}.courses" + ) + end + ensure + ActiveRecord::Base.establish_connection :arunit + end + end + end + + def test_table_alias + def @connection.test_table_alias_length() 10; end + class << @connection + alias_method :old_table_alias_length, :table_alias_length + alias_method :table_alias_length, :test_table_alias_length + end + + assert_equal 'posts', @connection.table_alias_for('posts') + assert_equal 'posts_comm', @connection.table_alias_for('posts_comments') + assert_equal 'dbo_posts', @connection.table_alias_for('dbo.posts') + + class << @connection + remove_method :table_alias_length + alias_method :table_alias_length, :old_table_alias_length + end + end + + # test resetting sequences in odd tables in PostgreSQL + if ActiveRecord::Base.connection.respond_to?(:reset_pk_sequence!) + require 'models/movie' + require 'models/subscriber' + + def test_reset_empty_table_with_custom_pk + Movie.delete_all + Movie.connection.reset_pk_sequence! 'movies' + assert_equal 1, Movie.create(:name => 'fight club').id + end + + def test_reset_table_with_non_integer_pk + Subscriber.delete_all + Subscriber.connection.reset_pk_sequence! 'subscribers' + sub = Subscriber.new(:name => 'robert drake') + sub.id = 'bob drake' + assert_nothing_raised { sub.save! } + end + end + + def test_uniqueness_violations_are_translated_to_specific_exception + @connection.execute "INSERT INTO subscribers(nick) VALUES('me')" + assert_raises(ActiveRecord::RecordNotUnique) do + @connection.execute "INSERT INTO subscribers(nick) VALUES('me')" + end + end + + unless current_adapter?(:SQLite3Adapter) + def test_foreign_key_violations_are_translated_to_specific_exception + assert_raises(ActiveRecord::InvalidForeignKey) do + # Oracle adapter uses prefetched primary key values from sequence and passes them to connection adapter insert method + if @connection.prefetch_primary_key? + id_value = @connection.next_sequence_value(@connection.default_sequence_name("fk_test_has_fk", "id")) + @connection.execute "INSERT INTO fk_test_has_fk (id, fk_id) VALUES (#{id_value},0)" + else + @connection.execute "INSERT INTO fk_test_has_fk (fk_id) VALUES (0)" + end + end + end + + def test_foreign_key_violations_are_translated_to_specific_exception_with_validate_false + klass_has_fk = Class.new(ActiveRecord::Base) do + self.table_name = 'fk_test_has_fk' + end + + assert_raises(ActiveRecord::InvalidForeignKey) do + has_fk = klass_has_fk.new + has_fk.fk_id = 1231231231 + has_fk.save(validate: false) + end + end + end + + def test_disable_referential_integrity + assert_nothing_raised do + @connection.disable_referential_integrity do + # Oracle adapter uses prefetched primary key values from sequence and passes them to connection adapter insert method + if @connection.prefetch_primary_key? + id_value = @connection.next_sequence_value(@connection.default_sequence_name("fk_test_has_fk", "id")) + @connection.execute "INSERT INTO fk_test_has_fk (id, fk_id) VALUES (#{id_value},0)" + else + @connection.execute "INSERT INTO fk_test_has_fk (fk_id) VALUES (0)" + end + # should delete created record as otherwise disable_referential_integrity will try to enable constraints after executed block + # and will fail (at least on Oracle) + @connection.execute "DELETE FROM fk_test_has_fk" + end + end + end + + def test_select_all_always_return_activerecord_result + result = @connection.select_all "SELECT * FROM posts" + assert result.is_a?(ActiveRecord::Result) + end + + def test_select_methods_passing_a_association_relation + author = Author.create!(name: 'john') + Post.create!(author: author, title: 'foo', body: 'bar') + query = author.posts.where(title: 'foo').select(:title) + assert_equal({"title" => "foo"}, @connection.select_one(query.arel, nil, query.bind_values)) + assert_equal({"title" => "foo"}, @connection.select_one(query)) + assert @connection.select_all(query).is_a?(ActiveRecord::Result) + assert_equal "foo", @connection.select_value(query) + assert_equal ["foo"], @connection.select_values(query) + end + + def test_select_methods_passing_a_relation + Post.create!(title: 'foo', body: 'bar') + query = Post.where(title: 'foo').select(:title) + assert_equal({"title" => "foo"}, @connection.select_one(query.arel, nil, query.bind_values)) + assert_equal({"title" => "foo"}, @connection.select_one(query)) + assert @connection.select_all(query).is_a?(ActiveRecord::Result) + assert_equal "foo", @connection.select_value(query) + assert_equal ["foo"], @connection.select_values(query) + end + + test "type_to_sql returns a String for unmapped types" do + assert_equal "special_db_type", @connection.type_to_sql(:special_db_type) + end + end + + class AdapterTestWithoutTransaction < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + class Klass < ActiveRecord::Base + end + + def setup + Klass.establish_connection :arunit + @connection = Klass.connection + end + + teardown do + Klass.remove_connection + end + + unless in_memory_db? + test "transaction state is reset after a reconnect" do + @connection.begin_transaction + assert @connection.transaction_open? + @connection.reconnect! + assert !@connection.transaction_open? + end + + test "transaction state is reset after a disconnect" do + @connection.begin_transaction + assert @connection.transaction_open? + @connection.disconnect! + assert !@connection.transaction_open? + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql/active_schema_test.rb b/activerecord/test/cases/adapters/mysql/active_schema_test.rb new file mode 100644 index 0000000000..7c0f11b033 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/active_schema_test.rb @@ -0,0 +1,155 @@ +require "cases/helper" +require 'support/connection_helper' + +class ActiveSchemaTest < ActiveRecord::TestCase + include ConnectionHelper + + def setup + ActiveRecord::Base.connection.singleton_class.class_eval do + alias_method :execute_without_stub, :execute + def execute(sql, name = nil) return sql end + end + end + + teardown do + reset_connection + end + + def test_add_index + # add_index calls table_exists? and index_name_exists? which can't work since execute is stubbed + def (ActiveRecord::Base.connection).table_exists?(*); true; end + def (ActiveRecord::Base.connection).index_name_exists?(*); false; end + + expected = "CREATE INDEX `index_people_on_last_name` ON `people` (`last_name`) " + assert_equal expected, add_index(:people, :last_name, :length => nil) + + expected = "CREATE INDEX `index_people_on_last_name` ON `people` (`last_name`(10)) " + assert_equal expected, add_index(:people, :last_name, :length => 10) + + expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`(15)) " + assert_equal expected, add_index(:people, [:last_name, :first_name], :length => 15) + + expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`) " + assert_equal expected, add_index(:people, [:last_name, :first_name], :length => {:last_name => 15}) + + expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`(10)) " + assert_equal expected, add_index(:people, [:last_name, :first_name], :length => {:last_name => 15, :first_name => 10}) + + %w(SPATIAL FULLTEXT UNIQUE).each do |type| + expected = "CREATE #{type} INDEX `index_people_on_last_name` ON `people` (`last_name`) " + assert_equal expected, add_index(:people, :last_name, :type => type) + end + + %w(btree hash).each do |using| + expected = "CREATE INDEX `index_people_on_last_name` USING #{using} ON `people` (`last_name`) " + assert_equal expected, add_index(:people, :last_name, :using => using) + end + + expected = "CREATE INDEX `index_people_on_last_name` USING btree ON `people` (`last_name`(10)) " + assert_equal expected, add_index(:people, :last_name, :length => 10, :using => :btree) + + expected = "CREATE INDEX `index_people_on_last_name` USING btree ON `people` (`last_name`(10)) ALGORITHM = COPY" + assert_equal expected, add_index(:people, :last_name, :length => 10, using: :btree, algorithm: :copy) + + assert_raise ArgumentError do + add_index(:people, :last_name, algorithm: :coyp) + end + + expected = "CREATE INDEX `index_people_on_last_name_and_first_name` USING btree ON `people` (`last_name`(15), `first_name`(15)) " + assert_equal expected, add_index(:people, [:last_name, :first_name], :length => 15, :using => :btree) + end + + def test_drop_table + assert_equal "DROP TABLE `people`", drop_table(:people) + end + + if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + def test_create_mysql_database_with_encoding + assert_equal "CREATE DATABASE `matt` DEFAULT CHARACTER SET `utf8`", create_database(:matt) + assert_equal "CREATE DATABASE `aimonetti` DEFAULT CHARACTER SET `latin1`", create_database(:aimonetti, {:charset => 'latin1'}) + assert_equal "CREATE DATABASE `matt_aimonetti` DEFAULT CHARACTER SET `big5` COLLATE `big5_chinese_ci`", create_database(:matt_aimonetti, {:charset => :big5, :collation => :big5_chinese_ci}) + end + + def test_recreate_mysql_database_with_encoding + create_database(:luca, {:charset => 'latin1'}) + assert_equal "CREATE DATABASE `luca` DEFAULT CHARACTER SET `latin1`", recreate_database(:luca, {:charset => 'latin1'}) + end + end + + def test_add_column + assert_equal "ALTER TABLE `people` ADD `last_name` varchar(255)", add_column(:people, :last_name, :string) + end + + def test_add_column_with_limit + assert_equal "ALTER TABLE `people` ADD `key` varchar(32)", add_column(:people, :key, :string, :limit => 32) + end + + def test_drop_table_with_specific_database + assert_equal "DROP TABLE `otherdb`.`people`", drop_table('otherdb.people') + end + + def test_add_timestamps + with_real_execute do + begin + ActiveRecord::Base.connection.create_table :delete_me + ActiveRecord::Base.connection.add_timestamps :delete_me + assert column_present?('delete_me', 'updated_at', 'datetime') + assert column_present?('delete_me', 'created_at', 'datetime') + ensure + ActiveRecord::Base.connection.drop_table :delete_me rescue nil + end + end + end + + def test_remove_timestamps + with_real_execute do + begin + ActiveRecord::Base.connection.create_table :delete_me do |t| + t.timestamps + end + ActiveRecord::Base.connection.remove_timestamps :delete_me + assert !column_present?('delete_me', 'updated_at', 'datetime') + assert !column_present?('delete_me', 'created_at', 'datetime') + ensure + ActiveRecord::Base.connection.drop_table :delete_me rescue nil + end + end + end + + def test_indexes_in_create + ActiveRecord::Base.connection.stubs(:table_exists?).with(:temp).returns(false) + ActiveRecord::Base.connection.stubs(:index_name_exists?).with(:index_temp_on_zip).returns(false) + + expected = "CREATE TEMPORARY TABLE `temp` ( INDEX `index_temp_on_zip` (`zip`) ) ENGINE=InnoDB AS SELECT id, name, zip FROM a_really_complicated_query" + actual = ActiveRecord::Base.connection.create_table(:temp, temporary: true, as: "SELECT id, name, zip FROM a_really_complicated_query") do |t| + t.index :zip + end + + assert_equal expected, actual + end + + private + def with_real_execute + ActiveRecord::Base.connection.singleton_class.class_eval do + alias_method :execute_with_stub, :execute + remove_method :execute + alias_method :execute, :execute_without_stub + end + + yield + ensure + ActiveRecord::Base.connection.singleton_class.class_eval do + remove_method :execute + alias_method :execute, :execute_with_stub + end + end + + def method_missing(method_symbol, *arguments) + ActiveRecord::Base.connection.send(method_symbol, *arguments) + end + + def column_present?(table_name, column_name, type) + results = ActiveRecord::Base.connection.select_all("SHOW FIELDS FROM #{table_name} LIKE '#{column_name}'") + results.first && results.first['Type'] == type + end +end diff --git a/activerecord/test/cases/adapters/mysql/case_sensitivity_test.rb b/activerecord/test/cases/adapters/mysql/case_sensitivity_test.rb new file mode 100644 index 0000000000..340fc95503 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/case_sensitivity_test.rb @@ -0,0 +1,55 @@ +require "cases/helper" +require 'models/person' + +class MysqlCaseSensitivityTest < ActiveRecord::TestCase + class CollationTest < ActiveRecord::Base + end + + repair_validations(CollationTest) + + def test_columns_include_collation_different_from_table + assert_equal 'utf8_bin', CollationTest.columns_hash['string_cs_column'].collation + assert_equal 'utf8_general_ci', CollationTest.columns_hash['string_ci_column'].collation + end + + def test_case_sensitive + assert !CollationTest.columns_hash['string_ci_column'].case_sensitive? + assert CollationTest.columns_hash['string_cs_column'].case_sensitive? + end + + def test_case_insensitive_comparison_for_ci_column + CollationTest.validates_uniqueness_of(:string_ci_column, :case_sensitive => false) + CollationTest.create!(:string_ci_column => 'A') + invalid = CollationTest.new(:string_ci_column => 'a') + queries = assert_sql { invalid.save } + ci_uniqueness_query = queries.detect { |q| q.match(/string_ci_column/) } + assert_no_match(/lower/i, ci_uniqueness_query) + end + + def test_case_insensitive_comparison_for_cs_column + CollationTest.validates_uniqueness_of(:string_cs_column, :case_sensitive => false) + CollationTest.create!(:string_cs_column => 'A') + invalid = CollationTest.new(:string_cs_column => 'a') + queries = assert_sql { invalid.save } + cs_uniqueness_query = queries.detect { |q| q.match(/string_cs_column/) } + assert_match(/lower/i, cs_uniqueness_query) + end + + def test_case_sensitive_comparison_for_ci_column + CollationTest.validates_uniqueness_of(:string_ci_column, :case_sensitive => true) + CollationTest.create!(:string_ci_column => 'A') + invalid = CollationTest.new(:string_ci_column => 'A') + queries = assert_sql { invalid.save } + ci_uniqueness_query = queries.detect { |q| q.match(/string_ci_column/) } + assert_match(/binary/i, ci_uniqueness_query) + end + + def test_case_sensitive_comparison_for_cs_column + CollationTest.validates_uniqueness_of(:string_cs_column, :case_sensitive => true) + CollationTest.create!(:string_cs_column => 'A') + invalid = CollationTest.new(:string_cs_column => 'A') + queries = assert_sql { invalid.save } + cs_uniqueness_query = queries.detect { |q| q.match(/string_cs_column/) } + assert_no_match(/binary/i, cs_uniqueness_query) + end +end diff --git a/activerecord/test/cases/adapters/mysql/connection_test.rb b/activerecord/test/cases/adapters/mysql/connection_test.rb new file mode 100644 index 0000000000..b0759dffde --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/connection_test.rb @@ -0,0 +1,179 @@ +require "cases/helper" +require 'support/connection_helper' +require 'support/ddl_helper' + +class MysqlConnectionTest < ActiveRecord::TestCase + include ConnectionHelper + include DdlHelper + + class Klass < ActiveRecord::Base + end + + def setup + super + @connection = ActiveRecord::Base.connection + end + + def test_mysql_reconnect_attribute_after_connection_with_reconnect_true + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.merge({:reconnect => true})) + assert ActiveRecord::Base.connection.raw_connection.reconnect + end + end + + unless ARTest.connection_config['arunit']['socket'] + def test_connect_with_url + run_without_connection do + ar_config = ARTest.connection_config['arunit'] + + url = "mysql://#{ar_config["username"]}@localhost/#{ar_config["database"]}" + Klass.establish_connection(url) + assert_equal ar_config['database'], Klass.connection.current_database + end + end + end + + def test_mysql_reconnect_attribute_after_connection_with_reconnect_false + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.merge({:reconnect => false})) + assert !ActiveRecord::Base.connection.raw_connection.reconnect + end + end + + def test_no_automatic_reconnection_after_timeout + assert @connection.active? + @connection.update('set @@wait_timeout=1') + sleep 2 + assert !@connection.active? + + # Repair all fixture connections so other tests won't break. + @fixture_connections.each do |c| + c.verify! + end + end + + def test_successful_reconnection_after_timeout_with_manual_reconnect + assert @connection.active? + @connection.update('set @@wait_timeout=1') + sleep 2 + @connection.reconnect! + assert @connection.active? + end + + def test_successful_reconnection_after_timeout_with_verify + assert @connection.active? + @connection.update('set @@wait_timeout=1') + sleep 2 + @connection.verify! + assert @connection.active? + end + + def test_bind_value_substitute + bind_param = @connection.substitute_at('foo', 0) + assert_equal Arel.sql('?'), bind_param + end + + def test_exec_no_binds + with_example_table do + result = @connection.exec_query('SELECT id, data FROM ex') + assert_equal 0, result.rows.length + assert_equal 2, result.columns.length + assert_equal %w{ id data }, result.columns + + @connection.exec_query('INSERT INTO ex (id, data) VALUES (1, "foo")') + + # if there are no bind parameters, it will return a string (due to + # the libmysql api) + result = @connection.exec_query('SELECT id, data FROM ex') + assert_equal 1, result.rows.length + assert_equal 2, result.columns.length + + assert_equal [['1', 'foo']], result.rows + end + end + + def test_exec_with_binds + with_example_table do + @connection.exec_query('INSERT INTO ex (id, data) VALUES (1, "foo")') + result = @connection.exec_query( + 'SELECT id, data FROM ex WHERE id = ?', nil, [[nil, 1]]) + + assert_equal 1, result.rows.length + assert_equal 2, result.columns.length + + assert_equal [[1, 'foo']], result.rows + end + end + + def test_exec_typecasts_bind_vals + with_example_table do + @connection.exec_query('INSERT INTO ex (id, data) VALUES (1, "foo")') + column = @connection.columns('ex').find { |col| col.name == 'id' } + + result = @connection.exec_query( + 'SELECT id, data FROM ex WHERE id = ?', nil, [[column, '1-fuu']]) + + assert_equal 1, result.rows.length + assert_equal 2, result.columns.length + + assert_equal [[1, 'foo']], result.rows + end + end + + # Test that MySQL allows multiple results for stored procedures + if defined?(Mysql) && Mysql.const_defined?(:CLIENT_MULTI_RESULTS) + def test_multi_results + rows = ActiveRecord::Base.connection.select_rows('CALL ten();') + assert_equal 10, rows[0][0].to_i, "ten() did not return 10 as expected: #{rows.inspect}" + assert @connection.active?, "Bad connection use by 'MysqlAdapter.select_rows'" + end + end + + def test_mysql_default_in_strict_mode + result = @connection.exec_query "SELECT @@SESSION.sql_mode" + assert_equal [["STRICT_ALL_TABLES"]], result.rows + end + + def test_mysql_strict_mode_disabled + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.merge({:strict => false})) + result = ActiveRecord::Base.connection.exec_query "SELECT @@SESSION.sql_mode" + assert_equal [['']], result.rows + end + end + + def test_mysql_set_session_variable + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.deep_merge({:variables => {:default_week_format => 3}})) + session_mode = ActiveRecord::Base.connection.exec_query "SELECT @@SESSION.DEFAULT_WEEK_FORMAT" + assert_equal 3, session_mode.rows.first.first.to_i + end + end + + def test_mysql_sql_mode_variable_overides_strict_mode + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { 'sql_mode' => 'ansi' })) + result = ActiveRecord::Base.connection.exec_query 'SELECT @@SESSION.sql_mode' + assert_not_equal [['STRICT_ALL_TABLES']], result.rows + end + end + + def test_mysql_set_session_variable_to_default + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.deep_merge({:variables => {:default_week_format => :default}})) + global_mode = ActiveRecord::Base.connection.exec_query "SELECT @@GLOBAL.DEFAULT_WEEK_FORMAT" + session_mode = ActiveRecord::Base.connection.exec_query "SELECT @@SESSION.DEFAULT_WEEK_FORMAT" + assert_equal global_mode.rows, session_mode.rows + end + end + + private + + def with_example_table(&block) + definition ||= <<-SQL + `id` int(11) auto_increment PRIMARY KEY, + `data` varchar(255) + SQL + super(@connection, 'ex', definition, &block) + end +end diff --git a/activerecord/test/cases/adapters/mysql/consistency_test.rb b/activerecord/test/cases/adapters/mysql/consistency_test.rb new file mode 100644 index 0000000000..083d533bb2 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/consistency_test.rb @@ -0,0 +1,48 @@ +require "cases/helper" + +class MysqlConsistencyTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + class Consistency < ActiveRecord::Base + self.table_name = "mysql_consistency" + end + + setup do + @old_emulate_booleans = ActiveRecord::ConnectionAdapters::MysqlAdapter.emulate_booleans + ActiveRecord::ConnectionAdapters::MysqlAdapter.emulate_booleans = false + + @connection = ActiveRecord::Base.connection + @connection.create_table("mysql_consistency") do |t| + t.boolean "a_bool" + t.string "a_string" + end + Consistency.reset_column_information + Consistency.create! + end + + teardown do + ActiveRecord::ConnectionAdapters::MysqlAdapter.emulate_booleans = @old_emulate_booleans + @connection.drop_table "mysql_consistency" + end + + test "boolean columns with random value type cast to 0 when emulate_booleans is false" do + with_new = Consistency.new + with_last = Consistency.last + with_new.a_bool = 'wibble' + with_last.a_bool = 'wibble' + + assert_equal 0, with_new.a_bool + assert_equal 0, with_last.a_bool + end + + test "string columns call #to_s" do + with_new = Consistency.new + with_last = Consistency.last + thing = Object.new + with_new.a_string = thing + with_last.a_string = thing + + assert_equal thing.to_s, with_new.a_string + assert_equal thing.to_s, with_last.a_string + end +end diff --git a/activerecord/test/cases/adapters/mysql/enum_test.rb b/activerecord/test/cases/adapters/mysql/enum_test.rb new file mode 100644 index 0000000000..f4e7a3ef0a --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/enum_test.rb @@ -0,0 +1,10 @@ +require "cases/helper" + +class MysqlEnumTest < ActiveRecord::TestCase + class EnumTest < ActiveRecord::Base + end + + def test_enum_limit + assert_equal 6, EnumTest.columns.first.limit + end +end diff --git a/activerecord/test/cases/adapters/mysql/mysql_adapter_test.rb b/activerecord/test/cases/adapters/mysql/mysql_adapter_test.rb new file mode 100644 index 0000000000..28106d3772 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/mysql_adapter_test.rb @@ -0,0 +1,147 @@ +# encoding: utf-8 + +require "cases/helper" +require 'support/ddl_helper' + +module ActiveRecord + module ConnectionAdapters + class MysqlAdapterTest < ActiveRecord::TestCase + include DdlHelper + + def setup + @conn = ActiveRecord::Base.connection + end + + def test_bad_connection_mysql + assert_raise ActiveRecord::NoDatabaseError do + configuration = ActiveRecord::Base.configurations['arunit'].merge(database: 'inexistent_activerecord_unittest') + connection = ActiveRecord::Base.mysql_connection(configuration) + connection.exec_query('drop table if exists ex') + end + end + + def test_valid_column + with_example_table do + column = @conn.columns('ex').find { |col| col.name == 'id' } + assert @conn.valid_type?(column.type) + end + end + + def test_invalid_column + assert_not @conn.valid_type?(:foobar) + end + + def test_client_encoding + assert_equal Encoding::UTF_8, @conn.client_encoding + end + + def test_exec_insert_number + with_example_table do + insert(@conn, 'number' => 10) + + result = @conn.exec_query('SELECT number FROM ex WHERE number = 10') + + assert_equal 1, result.rows.length + # if there are no bind parameters, it will return a string (due to + # the libmysql api) + assert_equal '10', result.rows.last.last + end + end + + def test_exec_insert_string + with_example_table do + str = 'いただきます!' + insert(@conn, 'number' => 10, 'data' => str) + + result = @conn.exec_query('SELECT number, data FROM ex WHERE number = 10') + + value = result.rows.last.last + + # FIXME: this should probably be inside the mysql AR adapter? + value.force_encoding(@conn.client_encoding) + + # The strings in this file are utf-8, so transcode to utf-8 + value.encode!(Encoding::UTF_8) + + assert_equal str, value + end + end + + def test_tables_quoting + @conn.tables(nil, "foo-bar", nil) + flunk + rescue => e + # assertion for *quoted* database properly + assert_match(/database 'foo-bar'/, e.inspect) + end + + def test_pk_and_sequence_for + with_example_table do + pk, seq = @conn.pk_and_sequence_for('ex') + assert_equal 'id', pk + assert_equal @conn.default_sequence_name('ex', 'id'), seq + end + end + + def test_pk_and_sequence_for_with_non_standard_primary_key + with_example_table '`code` INT(11) auto_increment, PRIMARY KEY (`code`)' do + pk, seq = @conn.pk_and_sequence_for('ex') + assert_equal 'code', pk + assert_equal @conn.default_sequence_name('ex', 'code'), seq + end + end + + def test_pk_and_sequence_for_with_custom_index_type_pk + with_example_table '`id` INT(11) auto_increment, PRIMARY KEY USING BTREE (`id`)' do + pk, seq = @conn.pk_and_sequence_for('ex') + assert_equal 'id', pk + assert_equal @conn.default_sequence_name('ex', 'id'), seq + end + end + + def test_tinyint_integer_typecasting + with_example_table '`status` TINYINT(4)' do + insert(@conn, { 'status' => 2 }, 'ex') + + result = @conn.exec_query('SELECT status FROM ex') + + assert_equal 2, result.column_types['status'].type_cast_from_database(result.last['status']) + end + end + + def test_supports_extensions + assert_not @conn.supports_extensions?, 'does not support extensions' + end + + def test_respond_to_enable_extension + assert @conn.respond_to?(:enable_extension) + end + + def test_respond_to_disable_extension + assert @conn.respond_to?(:disable_extension) + end + + private + def insert(ctx, data, table='ex') + binds = data.map { |name, value| + [ctx.columns(table).find { |x| x.name == name }, value] + } + columns = binds.map(&:first).map(&:name) + + sql = "INSERT INTO #{table} (#{columns.join(", ")}) + VALUES (#{(['?'] * columns.length).join(', ')})" + + ctx.exec_insert(sql, 'SQL', binds) + end + + def with_example_table(definition = nil, &block) + definition ||= <<-SQL + `id` int(11) auto_increment PRIMARY KEY, + `number` integer, + `data` varchar(255) + SQL + super(@conn, 'ex', definition, &block) + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql/quoting_test.rb b/activerecord/test/cases/adapters/mysql/quoting_test.rb new file mode 100644 index 0000000000..d8a954efa8 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/quoting_test.rb @@ -0,0 +1,25 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class MysqlAdapter + class QuotingTest < ActiveRecord::TestCase + def setup + @conn = ActiveRecord::Base.connection + end + + def test_type_cast_true + c = Column.new(nil, 1, Type::Boolean.new) + assert_equal 1, @conn.type_cast(true, nil) + assert_equal 1, @conn.type_cast(true, c) + end + + def test_type_cast_false + c = Column.new(nil, 1, Type::Boolean.new) + assert_equal 0, @conn.type_cast(false, nil) + assert_equal 0, @conn.type_cast(false, c) + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql/reserved_word_test.rb b/activerecord/test/cases/adapters/mysql/reserved_word_test.rb new file mode 100644 index 0000000000..61ae0abfd1 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/reserved_word_test.rb @@ -0,0 +1,153 @@ +require "cases/helper" + +class Group < ActiveRecord::Base + Group.table_name = 'group' + belongs_to :select + has_one :values +end + +class Select < ActiveRecord::Base + Select.table_name = 'select' + has_many :groups +end + +class Values < ActiveRecord::Base + Values.table_name = 'values' +end + +class Distinct < ActiveRecord::Base + Distinct.table_name = 'distinct' + has_and_belongs_to_many :selects + has_many :values, :through => :groups +end + +# a suite of tests to ensure the ConnectionAdapters#MysqlAdapter can handle tables with +# reserved word names (ie: group, order, values, etc...) +class MysqlReservedWordTest < ActiveRecord::TestCase + def setup + @connection = ActiveRecord::Base.connection + + # we call execute directly here (and do similar below) because ActiveRecord::Base#create_table() + # will fail with these table names if these test cases fail + + create_tables_directly 'group'=>'id int auto_increment primary key, `order` varchar(255), select_id int', + 'select'=>'id int auto_increment primary key', + 'values'=>'id int auto_increment primary key, group_id int', + 'distinct'=>'id int auto_increment primary key', + 'distinct_select'=>'distinct_id int, select_id int' + end + + teardown do + drop_tables_directly ['group', 'select', 'values', 'distinct', 'distinct_select', 'order'] + end + + # create tables with reserved-word names and columns + def test_create_tables + assert_nothing_raised { + @connection.create_table :order do |t| + t.column :group, :string + end + } + end + + # rename tables with reserved-word names + def test_rename_tables + assert_nothing_raised { @connection.rename_table(:group, :order) } + end + + # alter column with a reserved-word name in a table with a reserved-word name + def test_change_columns + assert_nothing_raised { @connection.change_column_default(:group, :order, 'whatever') } + #the quoting here will reveal any double quoting issues in change_column's interaction with the column method in the adapter + assert_nothing_raised { @connection.change_column('group', 'order', :Int, :default => 0) } + assert_nothing_raised { @connection.rename_column(:group, :order, :values) } + end + + # introspect table with reserved word name + def test_introspect + assert_nothing_raised { @connection.columns(:group) } + assert_nothing_raised { @connection.indexes(:group) } + end + + #fixtures + self.use_instantiated_fixtures = true + self.use_transactional_fixtures = false + + #activerecord model class with reserved-word table name + def test_activerecord_model + create_test_fixtures :select, :distinct, :group, :values, :distinct_select + x = nil + assert_nothing_raised { x = Group.new } + x.order = 'x' + assert_nothing_raised { x.save } + x.order = 'y' + assert_nothing_raised { x.save } + assert_nothing_raised { Group.find_by_order('y') } + assert_nothing_raised { Group.find(1) } + Group.find(1) + end + + # has_one association with reserved-word table name + def test_has_one_associations + create_test_fixtures :select, :distinct, :group, :values, :distinct_select + v = nil + assert_nothing_raised { v = Group.find(1).values } + assert_equal 2, v.id + end + + # belongs_to association with reserved-word table name + def test_belongs_to_associations + create_test_fixtures :select, :distinct, :group, :values, :distinct_select + gs = nil + assert_nothing_raised { gs = Select.find(2).groups } + assert_equal gs.length, 2 + assert(gs.collect{|x| x.id}.sort == [2, 3]) + end + + # has_and_belongs_to_many with reserved-word table name + def test_has_and_belongs_to_many + create_test_fixtures :select, :distinct, :group, :values, :distinct_select + s = nil + assert_nothing_raised { s = Distinct.find(1).selects } + assert_equal s.length, 2 + assert(s.collect{|x|x.id}.sort == [1, 2]) + end + + # activerecord model introspection with reserved-word table and column names + def test_activerecord_introspection + assert_nothing_raised { Group.table_exists? } + assert_nothing_raised { Group.columns } + end + + # Calculations + def test_calculations_work_with_reserved_words + assert_nothing_raised { Group.count } + end + + def test_associations_work_with_reserved_words + assert_nothing_raised { Select.all.merge!(:includes => [:groups]).to_a } + end + + #the following functions were added to DRY test cases + + private + # custom fixture loader, uses FixtureSet#create_fixtures and appends base_path to the current file's path + def create_test_fixtures(*fixture_names) + ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/reserved_words", fixture_names) + end + + # custom drop table, uses execute on connection to drop a table if it exists. note: escapes table_name + def drop_tables_directly(table_names, connection = @connection) + table_names.each do |name| + connection.execute("DROP TABLE IF EXISTS `#{name}`") + end + end + + # custom create table, uses execute on connection to create a table, note: escapes table_name, does NOT escape columns + def create_tables_directly (tables, connection = @connection) + tables.each do |table_name, column_properties| + connection.execute("CREATE TABLE `#{table_name}` ( #{column_properties} )") + end + end + +end diff --git a/activerecord/test/cases/adapters/mysql/schema_test.rb b/activerecord/test/cases/adapters/mysql/schema_test.rb new file mode 100644 index 0000000000..87c5277e64 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/schema_test.rb @@ -0,0 +1,100 @@ +require "cases/helper" +require 'models/post' +require 'models/comment' + +module ActiveRecord + module ConnectionAdapters + class MysqlSchemaTest < ActiveRecord::TestCase + fixtures :posts + + def setup + @connection = ActiveRecord::Base.connection + db = Post.connection_pool.spec.config[:database] + table = Post.table_name + @db_name = db + + @omgpost = Class.new(ActiveRecord::Base) do + self.table_name = "#{db}.#{table}" + def self.name; 'Post'; end + end + + @connection.create_table "mysql_doubles" + end + + teardown do + @connection.execute "drop table if exists mysql_doubles" + end + + class MysqlDouble < ActiveRecord::Base + self.table_name = "mysql_doubles" + end + + def test_float_limits + @connection.add_column :mysql_doubles, :float_no_limit, :float + @connection.add_column :mysql_doubles, :float_short, :float, limit: 5 + @connection.add_column :mysql_doubles, :float_long, :float, limit: 53 + + @connection.add_column :mysql_doubles, :float_23, :float, limit: 23 + @connection.add_column :mysql_doubles, :float_24, :float, limit: 24 + @connection.add_column :mysql_doubles, :float_25, :float, limit: 25 + MysqlDouble.reset_column_information + + column_no_limit = MysqlDouble.columns.find { |c| c.name == 'float_no_limit' } + column_short = MysqlDouble.columns.find { |c| c.name == 'float_short' } + column_long = MysqlDouble.columns.find { |c| c.name == 'float_long' } + + column_23 = MysqlDouble.columns.find { |c| c.name == 'float_23' } + column_24 = MysqlDouble.columns.find { |c| c.name == 'float_24' } + column_25 = MysqlDouble.columns.find { |c| c.name == 'float_25' } + + # Mysql floats are precision 0..24, Mysql doubles are precision 25..53 + assert_equal 24, column_no_limit.limit + assert_equal 24, column_short.limit + assert_equal 53, column_long.limit + + assert_equal 24, column_23.limit + assert_equal 24, column_24.limit + assert_equal 53, column_25.limit + end + + def test_schema + assert @omgpost.first + end + + def test_primary_key + assert_equal 'id', @omgpost.primary_key + end + + def test_table_exists? + name = @omgpost.table_name + assert @connection.table_exists?(name), "#{name} table should exist" + end + + def test_table_exists_wrong_schema + assert(!@connection.table_exists?("#{@db_name}.zomg"), "table should not exist") + end + + def test_dump_indexes + index_a_name = 'index_key_tests_on_snack' + index_b_name = 'index_key_tests_on_pizza' + index_c_name = 'index_key_tests_on_awesome' + + table = 'key_tests' + + indexes = @connection.indexes(table).sort_by {|i| i.name} + assert_equal 3,indexes.size + + index_a = indexes.select{|i| i.name == index_a_name}[0] + index_b = indexes.select{|i| i.name == index_b_name}[0] + index_c = indexes.select{|i| i.name == index_c_name}[0] + assert_equal :btree, index_a.using + assert_nil index_a.type + assert_equal :btree, index_b.using + assert_nil index_b.type + + assert_nil index_c.using + assert_equal :fulltext, index_c.type + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql/sp_test.rb b/activerecord/test/cases/adapters/mysql/sp_test.rb new file mode 100644 index 0000000000..3ca2917ca4 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/sp_test.rb @@ -0,0 +1,15 @@ +require "cases/helper" +require 'models/topic' + +class StoredProcedureTest < ActiveRecord::TestCase + fixtures :topics + + # Test that MySQL allows multiple results for stored procedures + if Mysql.const_defined?(:CLIENT_MULTI_RESULTS) + def test_multi_results_from_find_by_sql + topics = Topic.find_by_sql 'CALL topics();' + assert_equal 1, topics.size + assert ActiveRecord::Base.connection.active?, "Bad connection use by 'MysqlAdapter.select'" + end + end +end diff --git a/activerecord/test/cases/adapters/mysql/sql_types_test.rb b/activerecord/test/cases/adapters/mysql/sql_types_test.rb new file mode 100644 index 0000000000..1ddb1b91c9 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/sql_types_test.rb @@ -0,0 +1,14 @@ +require "cases/helper" + +class SqlTypesTest < ActiveRecord::TestCase + def test_binary_types + assert_equal 'varbinary(64)', type_to_sql(:binary, 64) + assert_equal 'varbinary(4095)', type_to_sql(:binary, 4095) + assert_equal 'blob(4096)', type_to_sql(:binary, 4096) + assert_equal 'blob', type_to_sql(:binary) + end + + def type_to_sql(*args) + ActiveRecord::Base.connection.type_to_sql(*args) + end +end diff --git a/activerecord/test/cases/adapters/mysql/statement_pool_test.rb b/activerecord/test/cases/adapters/mysql/statement_pool_test.rb new file mode 100644 index 0000000000..209a0cf464 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql/statement_pool_test.rb @@ -0,0 +1,23 @@ +require 'cases/helper' + +module ActiveRecord::ConnectionAdapters + class MysqlAdapter + class StatementPoolTest < ActiveRecord::TestCase + if Process.respond_to?(:fork) + def test_cache_is_per_pid + cache = StatementPool.new nil, 10 + cache['foo'] = 'bar' + assert_equal 'bar', cache['foo'] + + pid = fork { + lookup = cache['foo']; + exit!(!lookup) + } + + Process.waitpid pid + assert $?.success?, 'process should exit successfully' + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/active_schema_test.rb b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb new file mode 100644 index 0000000000..cefc3e3c7e --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb @@ -0,0 +1,155 @@ +require "cases/helper" +require 'support/connection_helper' + +class ActiveSchemaTest < ActiveRecord::TestCase + include ConnectionHelper + + def setup + ActiveRecord::Base.connection.singleton_class.class_eval do + alias_method :execute_without_stub, :execute + def execute(sql, name = nil) return sql end + end + end + + teardown do + reset_connection + end + + def test_add_index + # add_index calls table_exists? and index_name_exists? which can't work since execute is stubbed + def (ActiveRecord::Base.connection).table_exists?(*); true; end + def (ActiveRecord::Base.connection).index_name_exists?(*); false; end + + expected = "CREATE INDEX `index_people_on_last_name` ON `people` (`last_name`) " + assert_equal expected, add_index(:people, :last_name, :length => nil) + + expected = "CREATE INDEX `index_people_on_last_name` ON `people` (`last_name`(10)) " + assert_equal expected, add_index(:people, :last_name, :length => 10) + + expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`(15)) " + assert_equal expected, add_index(:people, [:last_name, :first_name], :length => 15) + + expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`) " + assert_equal expected, add_index(:people, [:last_name, :first_name], :length => {:last_name => 15}) + + expected = "CREATE INDEX `index_people_on_last_name_and_first_name` ON `people` (`last_name`(15), `first_name`(10)) " + assert_equal expected, add_index(:people, [:last_name, :first_name], :length => {:last_name => 15, :first_name => 10}) + + %w(SPATIAL FULLTEXT UNIQUE).each do |type| + expected = "CREATE #{type} INDEX `index_people_on_last_name` ON `people` (`last_name`) " + assert_equal expected, add_index(:people, :last_name, :type => type) + end + + %w(btree hash).each do |using| + expected = "CREATE INDEX `index_people_on_last_name` USING #{using} ON `people` (`last_name`) " + assert_equal expected, add_index(:people, :last_name, :using => using) + end + + expected = "CREATE INDEX `index_people_on_last_name` USING btree ON `people` (`last_name`(10)) " + assert_equal expected, add_index(:people, :last_name, :length => 10, :using => :btree) + + expected = "CREATE INDEX `index_people_on_last_name` USING btree ON `people` (`last_name`(10)) ALGORITHM = COPY" + assert_equal expected, add_index(:people, :last_name, :length => 10, using: :btree, algorithm: :copy) + + assert_raise ArgumentError do + add_index(:people, :last_name, algorithm: :coyp) + end + + expected = "CREATE INDEX `index_people_on_last_name_and_first_name` USING btree ON `people` (`last_name`(15), `first_name`(15)) " + assert_equal expected, add_index(:people, [:last_name, :first_name], :length => 15, :using => :btree) + end + + def test_drop_table + assert_equal "DROP TABLE `people`", drop_table(:people) + end + + if current_adapter?(:Mysql2Adapter) + def test_create_mysql_database_with_encoding + assert_equal "CREATE DATABASE `matt` DEFAULT CHARACTER SET `utf8`", create_database(:matt) + assert_equal "CREATE DATABASE `aimonetti` DEFAULT CHARACTER SET `latin1`", create_database(:aimonetti, {:charset => 'latin1'}) + assert_equal "CREATE DATABASE `matt_aimonetti` DEFAULT CHARACTER SET `big5` COLLATE `big5_chinese_ci`", create_database(:matt_aimonetti, {:charset => :big5, :collation => :big5_chinese_ci}) + end + + def test_recreate_mysql_database_with_encoding + create_database(:luca, {:charset => 'latin1'}) + assert_equal "CREATE DATABASE `luca` DEFAULT CHARACTER SET `latin1`", recreate_database(:luca, {:charset => 'latin1'}) + end + end + + def test_add_column + assert_equal "ALTER TABLE `people` ADD `last_name` varchar(255)", add_column(:people, :last_name, :string) + end + + def test_add_column_with_limit + assert_equal "ALTER TABLE `people` ADD `key` varchar(32)", add_column(:people, :key, :string, :limit => 32) + end + + def test_drop_table_with_specific_database + assert_equal "DROP TABLE `otherdb`.`people`", drop_table('otherdb.people') + end + + def test_add_timestamps + with_real_execute do + begin + ActiveRecord::Base.connection.create_table :delete_me + ActiveRecord::Base.connection.add_timestamps :delete_me + assert column_present?('delete_me', 'updated_at', 'datetime') + assert column_present?('delete_me', 'created_at', 'datetime') + ensure + ActiveRecord::Base.connection.drop_table :delete_me rescue nil + end + end + end + + def test_remove_timestamps + with_real_execute do + begin + ActiveRecord::Base.connection.create_table :delete_me do |t| + t.timestamps + end + ActiveRecord::Base.connection.remove_timestamps :delete_me + assert !column_present?('delete_me', 'updated_at', 'datetime') + assert !column_present?('delete_me', 'created_at', 'datetime') + ensure + ActiveRecord::Base.connection.drop_table :delete_me rescue nil + end + end + end + + def test_indexes_in_create + ActiveRecord::Base.connection.stubs(:table_exists?).with(:temp).returns(false) + ActiveRecord::Base.connection.stubs(:index_name_exists?).with(:index_temp_on_zip).returns(false) + + expected = "CREATE TEMPORARY TABLE `temp` ( INDEX `index_temp_on_zip` (`zip`) ) ENGINE=InnoDB AS SELECT id, name, zip FROM a_really_complicated_query" + actual = ActiveRecord::Base.connection.create_table(:temp, temporary: true, as: "SELECT id, name, zip FROM a_really_complicated_query") do |t| + t.index :zip + end + + assert_equal expected, actual + end + + private + def with_real_execute + ActiveRecord::Base.connection.singleton_class.class_eval do + alias_method :execute_with_stub, :execute + remove_method :execute + alias_method :execute, :execute_without_stub + end + + yield + ensure + ActiveRecord::Base.connection.singleton_class.class_eval do + remove_method :execute + alias_method :execute, :execute_with_stub + end + end + + def method_missing(method_symbol, *arguments) + ActiveRecord::Base.connection.send(method_symbol, *arguments) + end + + def column_present?(table_name, column_name, type) + results = ActiveRecord::Base.connection.select_all("SHOW FIELDS FROM #{table_name} LIKE '#{column_name}'") + results.first && results.first['Type'] == type + end +end diff --git a/activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb b/activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb new file mode 100644 index 0000000000..5e8065d80d --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb @@ -0,0 +1,50 @@ +require "cases/helper" +require 'models/topic' + +module ActiveRecord + module ConnectionAdapters + class Mysql2Adapter + class BindParameterTest < ActiveRecord::TestCase + fixtures :topics + + def test_update_question_marks + str = "foo?bar" + x = Topic.first + x.title = str + x.content = str + x.save! + x.reload + assert_equal str, x.title + assert_equal str, x.content + end + + def test_create_question_marks + str = "foo?bar" + x = Topic.create!(:title => str, :content => str) + x.reload + assert_equal str, x.title + assert_equal str, x.content + end + + def test_update_null_bytes + str = "foo\0bar" + x = Topic.first + x.title = str + x.content = str + x.save! + x.reload + assert_equal str, x.title + assert_equal str, x.content + end + + def test_create_null_bytes + str = "foo\0bar" + x = Topic.create!(:title => str, :content => str) + x.reload + assert_equal str, x.title + assert_equal str, x.content + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/boolean_test.rb b/activerecord/test/cases/adapters/mysql2/boolean_test.rb new file mode 100644 index 0000000000..f3c711a64b --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/boolean_test.rb @@ -0,0 +1,91 @@ +require "cases/helper" + +class Mysql2BooleanTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + class BooleanType < ActiveRecord::Base + self.table_name = "mysql_booleans" + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table("mysql_booleans") do |t| + t.boolean "archived" + t.string "published", limit: 1 + end + BooleanType.reset_column_information + + @emulate_booleans = ActiveRecord::ConnectionAdapters::Mysql2Adapter.emulate_booleans + end + + teardown do + emulate_booleans @emulate_booleans + @connection.drop_table "mysql_booleans" + end + + test "column type with emulated booleans" do + emulate_booleans true + + assert_equal :boolean, boolean_column.type + assert_equal :string, string_column.type + end + + test "column type without emulated booleans" do + emulate_booleans false + + assert_equal :integer, boolean_column.type + assert_equal :string, string_column.type + end + + test "test type casting with emulated booleans" do + emulate_booleans true + + boolean = BooleanType.create!(archived: true, published: true) + attributes = boolean.reload.attributes_before_type_cast + + assert_equal 1, attributes["archived"] + assert_equal "1", attributes["published"] + + assert_equal 1, @connection.type_cast(true, boolean_column) + assert_equal "1", @connection.type_cast(true, string_column) + end + + test "test type casting without emulated booleans" do + emulate_booleans false + + boolean = BooleanType.create!(archived: true, published: true) + attributes = boolean.reload.attributes_before_type_cast + + assert_equal 1, attributes["archived"] + assert_equal "1", attributes["published"] + + assert_equal 1, @connection.type_cast(true, boolean_column) + assert_equal "1", @connection.type_cast(true, string_column) + end + + test "with booleans stored as 1 and 0" do + @connection.execute "INSERT INTO mysql_booleans(archived, published) VALUES(1, '1')" + boolean = BooleanType.first + assert_equal true, boolean.archived + assert_equal "1", boolean.published + end + + test "with booleans stored as t" do + @connection.execute "INSERT INTO mysql_booleans(published) VALUES('t')" + boolean = BooleanType.first + assert_equal "t", boolean.published + end + + def boolean_column + BooleanType.columns.find { |c| c.name == 'archived' } + end + + def string_column + BooleanType.columns.find { |c| c.name == 'published' } + end + + def emulate_booleans(value) + ActiveRecord::ConnectionAdapters::Mysql2Adapter.emulate_booleans = value + BooleanType.reset_column_information + end +end diff --git a/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb b/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb new file mode 100644 index 0000000000..09bebf3071 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb @@ -0,0 +1,55 @@ +require "cases/helper" +require 'models/person' + +class Mysql2CaseSensitivityTest < ActiveRecord::TestCase + class CollationTest < ActiveRecord::Base + end + + repair_validations(CollationTest) + + def test_columns_include_collation_different_from_table + assert_equal 'utf8_bin', CollationTest.columns_hash['string_cs_column'].collation + assert_equal 'utf8_general_ci', CollationTest.columns_hash['string_ci_column'].collation + end + + def test_case_sensitive + assert !CollationTest.columns_hash['string_ci_column'].case_sensitive? + assert CollationTest.columns_hash['string_cs_column'].case_sensitive? + end + + def test_case_insensitive_comparison_for_ci_column + CollationTest.validates_uniqueness_of(:string_ci_column, :case_sensitive => false) + CollationTest.create!(:string_ci_column => 'A') + invalid = CollationTest.new(:string_ci_column => 'a') + queries = assert_sql { invalid.save } + ci_uniqueness_query = queries.detect { |q| q.match(/string_ci_column/) } + assert_no_match(/lower/i, ci_uniqueness_query) + end + + def test_case_insensitive_comparison_for_cs_column + CollationTest.validates_uniqueness_of(:string_cs_column, :case_sensitive => false) + CollationTest.create!(:string_cs_column => 'A') + invalid = CollationTest.new(:string_cs_column => 'a') + queries = assert_sql { invalid.save } + cs_uniqueness_query = queries.detect { |q| q.match(/string_cs_column/)} + assert_match(/lower/i, cs_uniqueness_query) + end + + def test_case_sensitive_comparison_for_ci_column + CollationTest.validates_uniqueness_of(:string_ci_column, :case_sensitive => true) + CollationTest.create!(:string_ci_column => 'A') + invalid = CollationTest.new(:string_ci_column => 'A') + queries = assert_sql { invalid.save } + ci_uniqueness_query = queries.detect { |q| q.match(/string_ci_column/) } + assert_match(/binary/i, ci_uniqueness_query) + end + + def test_case_sensitive_comparison_for_cs_column + CollationTest.validates_uniqueness_of(:string_cs_column, :case_sensitive => true) + CollationTest.create!(:string_cs_column => 'A') + invalid = CollationTest.new(:string_cs_column => 'A') + queries = assert_sql { invalid.save } + cs_uniqueness_query = queries.detect { |q| q.match(/string_cs_column/) } + assert_no_match(/binary/i, cs_uniqueness_query) + end +end diff --git a/activerecord/test/cases/adapters/mysql2/connection_test.rb b/activerecord/test/cases/adapters/mysql2/connection_test.rb new file mode 100644 index 0000000000..3b35e69e0d --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/connection_test.rb @@ -0,0 +1,116 @@ +require "cases/helper" +require 'support/connection_helper' + +class MysqlConnectionTest < ActiveRecord::TestCase + include ConnectionHelper + + def setup + super + @subscriber = SQLSubscriber.new + @subscription = ActiveSupport::Notifications.subscribe('sql.active_record', @subscriber) + @connection = ActiveRecord::Base.connection + end + + def teardown + ActiveSupport::Notifications.unsubscribe(@subscription) + super + end + + def test_bad_connection + assert_raise ActiveRecord::NoDatabaseError do + configuration = ActiveRecord::Base.configurations['arunit'].merge(database: 'inexistent_activerecord_unittest') + connection = ActiveRecord::Base.mysql2_connection(configuration) + connection.exec_query('drop table if exists ex') + end + end + + def test_no_automatic_reconnection_after_timeout + assert @connection.active? + @connection.update('set @@wait_timeout=1') + sleep 2 + assert !@connection.active? + + # Repair all fixture connections so other tests won't break. + @fixture_connections.each do |c| + c.verify! + end + end + + def test_successful_reconnection_after_timeout_with_manual_reconnect + assert @connection.active? + @connection.update('set @@wait_timeout=1') + sleep 2 + @connection.reconnect! + assert @connection.active? + end + + def test_successful_reconnection_after_timeout_with_verify + assert @connection.active? + @connection.update('set @@wait_timeout=1') + sleep 2 + @connection.verify! + assert @connection.active? + end + + # TODO: Below is a straight up copy/paste from mysql/connection_test.rb + # I'm not sure what the correct way is to share these tests between + # adapters in minitest. + def test_mysql_default_in_strict_mode + result = @connection.exec_query "SELECT @@SESSION.sql_mode" + assert_equal [["STRICT_ALL_TABLES"]], result.rows + end + + def test_mysql_strict_mode_disabled + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.merge({:strict => false})) + result = ActiveRecord::Base.connection.exec_query "SELECT @@SESSION.sql_mode" + assert_equal [['']], result.rows + end + end + + def test_mysql_set_session_variable + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.deep_merge({:variables => {:default_week_format => 3}})) + session_mode = ActiveRecord::Base.connection.exec_query "SELECT @@SESSION.DEFAULT_WEEK_FORMAT" + assert_equal 3, session_mode.rows.first.first.to_i + end + end + + def test_mysql_sql_mode_variable_overides_strict_mode + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { 'sql_mode' => 'ansi' })) + result = ActiveRecord::Base.connection.exec_query 'SELECT @@SESSION.sql_mode' + assert_not_equal [['STRICT_ALL_TABLES']], result.rows + end + end + + def test_mysql_set_session_variable_to_default + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.deep_merge({:variables => {:default_week_format => :default}})) + global_mode = ActiveRecord::Base.connection.exec_query "SELECT @@GLOBAL.DEFAULT_WEEK_FORMAT" + session_mode = ActiveRecord::Base.connection.exec_query "SELECT @@SESSION.DEFAULT_WEEK_FORMAT" + assert_equal global_mode.rows, session_mode.rows + end + end + + def test_logs_name_show_variable + @connection.show_variable 'foo' + assert_equal "SCHEMA", @subscriber.logged[0][1] + end + + def test_logs_name_rename_column_sql + @connection.execute "CREATE TABLE `bar_baz` (`foo` varchar(255))" + @subscriber.logged.clear + @connection.send(:rename_column_sql, 'bar_baz', 'foo', 'foo2') + assert_equal "SCHEMA", @subscriber.logged[0][1] + ensure + @connection.execute "DROP TABLE `bar_baz`" + end + + if mysql_56? + def test_quote_time_usec + assert_equal "'1970-01-01 00:00:00.000000'", @connection.quote(Time.at(0)) + assert_equal "'1970-01-01 00:00:00.000000'", @connection.quote(Time.at(0).to_datetime) + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/enum_test.rb b/activerecord/test/cases/adapters/mysql2/enum_test.rb new file mode 100644 index 0000000000..6dd9a5ec87 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/enum_test.rb @@ -0,0 +1,10 @@ +require "cases/helper" + +class Mysql2EnumTest < ActiveRecord::TestCase + class EnumTest < ActiveRecord::Base + end + + def test_enum_limit + assert_equal 6, EnumTest.columns.first.limit + end +end diff --git a/activerecord/test/cases/adapters/mysql2/explain_test.rb b/activerecord/test/cases/adapters/mysql2/explain_test.rb new file mode 100644 index 0000000000..675703caa1 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/explain_test.rb @@ -0,0 +1,26 @@ +require "cases/helper" +require 'models/developer' + +module ActiveRecord + module ConnectionAdapters + class Mysql2Adapter + class ExplainTest < ActiveRecord::TestCase + fixtures :developers + + def test_explain_for_one_query + explain = Developer.where(:id => 1).explain + assert_match %(EXPLAIN for: SELECT `developers`.* FROM `developers` WHERE `developers`.`id` = 1), explain + assert_match %r(developers |.* const), explain + end + + def test_explain_with_eager_loading + explain = Developer.where(:id => 1).includes(:audit_logs).explain + assert_match %(EXPLAIN for: SELECT `developers`.* FROM `developers` WHERE `developers`.`id` = 1), explain + assert_match %r(developers |.* const), explain + assert_match %(EXPLAIN for: SELECT `audit_logs`.* FROM `audit_logs` WHERE `audit_logs`.`developer_id` IN (1)), explain + assert_match %r(audit_logs |.* ALL), explain + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/reserved_word_test.rb b/activerecord/test/cases/adapters/mysql2/reserved_word_test.rb new file mode 100644 index 0000000000..799d927ee4 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/reserved_word_test.rb @@ -0,0 +1,152 @@ +require "cases/helper" + +class Group < ActiveRecord::Base + Group.table_name = 'group' + belongs_to :select + has_one :values +end + +class Select < ActiveRecord::Base + Select.table_name = 'select' + has_many :groups +end + +class Values < ActiveRecord::Base + Values.table_name = 'values' +end + +class Distinct < ActiveRecord::Base + Distinct.table_name = 'distinct' + has_and_belongs_to_many :selects + has_many :values, :through => :groups +end + +# a suite of tests to ensure the ConnectionAdapters#MysqlAdapter can handle tables with +# reserved word names (ie: group, order, values, etc...) +class MysqlReservedWordTest < ActiveRecord::TestCase + def setup + @connection = ActiveRecord::Base.connection + + # we call execute directly here (and do similar below) because ActiveRecord::Base#create_table() + # will fail with these table names if these test cases fail + + create_tables_directly 'group'=>'id int auto_increment primary key, `order` varchar(255), select_id int', + 'select'=>'id int auto_increment primary key', + 'values'=>'id int auto_increment primary key, group_id int', + 'distinct'=>'id int auto_increment primary key', + 'distinct_select'=>'distinct_id int, select_id int' + end + + teardown do + drop_tables_directly ['group', 'select', 'values', 'distinct', 'distinct_select', 'order'] + end + + # create tables with reserved-word names and columns + def test_create_tables + assert_nothing_raised { + @connection.create_table :order do |t| + t.column :group, :string + end + } + end + + # rename tables with reserved-word names + def test_rename_tables + assert_nothing_raised { @connection.rename_table(:group, :order) } + end + + # alter column with a reserved-word name in a table with a reserved-word name + def test_change_columns + assert_nothing_raised { @connection.change_column_default(:group, :order, 'whatever') } + #the quoting here will reveal any double quoting issues in change_column's interaction with the column method in the adapter + assert_nothing_raised { @connection.change_column('group', 'order', :Int, :default => 0) } + assert_nothing_raised { @connection.rename_column(:group, :order, :values) } + end + + # introspect table with reserved word name + def test_introspect + assert_nothing_raised { @connection.columns(:group) } + assert_nothing_raised { @connection.indexes(:group) } + end + + #fixtures + self.use_instantiated_fixtures = true + self.use_transactional_fixtures = false + + #activerecord model class with reserved-word table name + def test_activerecord_model + create_test_fixtures :select, :distinct, :group, :values, :distinct_select + x = nil + assert_nothing_raised { x = Group.new } + x.order = 'x' + assert_nothing_raised { x.save } + x.order = 'y' + assert_nothing_raised { x.save } + assert_nothing_raised { Group.find_by_order('y') } + assert_nothing_raised { Group.find(1) } + end + + # has_one association with reserved-word table name + def test_has_one_associations + create_test_fixtures :select, :distinct, :group, :values, :distinct_select + v = nil + assert_nothing_raised { v = Group.find(1).values } + assert_equal 2, v.id + end + + # belongs_to association with reserved-word table name + def test_belongs_to_associations + create_test_fixtures :select, :distinct, :group, :values, :distinct_select + gs = nil + assert_nothing_raised { gs = Select.find(2).groups } + assert_equal gs.length, 2 + assert(gs.collect{|x| x.id}.sort == [2, 3]) + end + + # has_and_belongs_to_many with reserved-word table name + def test_has_and_belongs_to_many + create_test_fixtures :select, :distinct, :group, :values, :distinct_select + s = nil + assert_nothing_raised { s = Distinct.find(1).selects } + assert_equal s.length, 2 + assert(s.collect{|x|x.id}.sort == [1, 2]) + end + + # activerecord model introspection with reserved-word table and column names + def test_activerecord_introspection + assert_nothing_raised { Group.table_exists? } + assert_nothing_raised { Group.columns } + end + + # Calculations + def test_calculations_work_with_reserved_words + assert_nothing_raised { Group.count } + end + + def test_associations_work_with_reserved_words + assert_nothing_raised { Select.all.merge!(:includes => [:groups]).to_a } + end + + #the following functions were added to DRY test cases + + private + # custom fixture loader, uses FixtureSet#create_fixtures and appends base_path to the current file's path + def create_test_fixtures(*fixture_names) + ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/reserved_words", fixture_names) + end + + # custom drop table, uses execute on connection to drop a table if it exists. note: escapes table_name + def drop_tables_directly(table_names, connection = @connection) + table_names.each do |name| + connection.execute("DROP TABLE IF EXISTS `#{name}`") + end + end + + # custom create table, uses execute on connection to create a table, note: escapes table_name, does NOT escape columns + def create_tables_directly (tables, connection = @connection) + tables.each do |table_name, column_properties| + connection.execute("CREATE TABLE `#{table_name}` ( #{column_properties} )") + end + end + +end diff --git a/activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb b/activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb new file mode 100644 index 0000000000..9c49599d34 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb @@ -0,0 +1,39 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class Mysql2Adapter + class SchemaMigrationsTest < ActiveRecord::TestCase + def test_renaming_index_on_foreign_key + connection.add_index "engines", "car_id" + connection.add_foreign_key :engines, :cars, name: "fk_engines_cars" + + connection.rename_index("engines", "index_engines_on_car_id", "idx_renamed") + assert_equal ["idx_renamed"], connection.indexes("engines").map(&:name) + ensure + connection.remove_foreign_key :engines, name: "fk_engines_cars" + end + + def test_initializes_schema_migrations_for_encoding_utf8mb4 + smtn = ActiveRecord::Migrator.schema_migrations_table_name + connection.drop_table(smtn) if connection.table_exists?(smtn) + + config = connection.instance_variable_get(:@config) + original_encoding = config[:encoding] + + config[:encoding] = 'utf8mb4' + connection.initialize_schema_migrations_table + + assert connection.column_exists?(smtn, :version, :string, limit: Mysql2Adapter::MAX_INDEX_LENGTH_FOR_UTF8MB4) + ensure + config[:encoding] = original_encoding + end + + private + def connection + @connection ||= ActiveRecord::Base.connection + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/schema_test.rb b/activerecord/test/cases/adapters/mysql2/schema_test.rb new file mode 100644 index 0000000000..43c9116b5a --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/schema_test.rb @@ -0,0 +1,79 @@ +require "cases/helper" +require 'models/post' +require 'models/comment' + +module ActiveRecord + module ConnectionAdapters + class Mysql2SchemaTest < ActiveRecord::TestCase + fixtures :posts + + def setup + @connection = ActiveRecord::Base.connection + db = Post.connection_pool.spec.config[:database] + table = Post.table_name + @db_name = db + + @omgpost = Class.new(ActiveRecord::Base) do + self.table_name = "#{db}.#{table}" + def self.name; 'Post'; end + end + end + + def test_schema + assert @omgpost.first + end + + def test_primary_key + assert_equal 'id', @omgpost.primary_key + end + + def test_table_exists? + name = @omgpost.table_name + assert @connection.table_exists?(name), "#{name} table should exist" + end + + def test_table_exists_wrong_schema + assert(!@connection.table_exists?("#{@db_name}.zomg"), "table should not exist") + end + + def test_tables_quoting + @connection.tables(nil, "foo-bar", nil) + flunk + rescue => e + # assertion for *quoted* database properly + assert_match(/database 'foo-bar'/, e.inspect) + end + + def test_dump_indexes + index_a_name = 'index_key_tests_on_snack' + index_b_name = 'index_key_tests_on_pizza' + index_c_name = 'index_key_tests_on_awesome' + + table = 'key_tests' + + indexes = @connection.indexes(table).sort_by {|i| i.name} + assert_equal 3,indexes.size + + index_a = indexes.select{|i| i.name == index_a_name}[0] + index_b = indexes.select{|i| i.name == index_b_name}[0] + index_c = indexes.select{|i| i.name == index_c_name}[0] + assert_equal :btree, index_a.using + assert_nil index_a.type + assert_equal :btree, index_b.using + assert_nil index_b.type + + assert_nil index_c.using + assert_equal :fulltext, index_c.type + end + + def test_drop_temporary_table + @connection.transaction do + @connection.create_table(:temp_table, temporary: true) + # if it doesn't properly say DROP TEMPORARY TABLE, the transaction commit + # will complain that no transaction is active + @connection.drop_table(:temp_table, temporary: true) + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/sql_types_test.rb b/activerecord/test/cases/adapters/mysql2/sql_types_test.rb new file mode 100644 index 0000000000..1ddb1b91c9 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/sql_types_test.rb @@ -0,0 +1,14 @@ +require "cases/helper" + +class SqlTypesTest < ActiveRecord::TestCase + def test_binary_types + assert_equal 'varbinary(64)', type_to_sql(:binary, 64) + assert_equal 'varbinary(4095)', type_to_sql(:binary, 4095) + assert_equal 'blob(4096)', type_to_sql(:binary, 4096) + assert_equal 'blob', type_to_sql(:binary) + end + + def type_to_sql(*args) + ActiveRecord::Base.connection.type_to_sql(*args) + end +end diff --git a/activerecord/test/cases/adapters/postgresql/active_schema_test.rb b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb new file mode 100644 index 0000000000..3808db5141 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb @@ -0,0 +1,58 @@ +require 'cases/helper' + +class PostgresqlActiveSchemaTest < ActiveRecord::TestCase + def setup + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.class_eval do + def execute(sql, name = nil) sql end + end + end + + teardown do + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.class_eval do + remove_method :execute + end + end + + def test_create_database_with_encoding + assert_equal %(CREATE DATABASE "matt" ENCODING = 'utf8'), create_database(:matt) + assert_equal %(CREATE DATABASE "aimonetti" ENCODING = 'latin1'), create_database(:aimonetti, :encoding => :latin1) + assert_equal %(CREATE DATABASE "aimonetti" ENCODING = 'latin1'), create_database(:aimonetti, 'encoding' => :latin1) + end + + def test_create_database_with_collation_and_ctype + assert_equal %(CREATE DATABASE "aimonetti" ENCODING = 'UTF8' LC_COLLATE = 'ja_JP.UTF8' LC_CTYPE = 'ja_JP.UTF8'), create_database(:aimonetti, :encoding => :"UTF8", :collation => :"ja_JP.UTF8", :ctype => :"ja_JP.UTF8") + end + + def test_add_index + # add_index calls index_name_exists? which can't work since execute is stubbed + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.stubs(:index_name_exists?).returns(false) + + expected = %(CREATE UNIQUE INDEX "index_people_on_last_name" ON "people" ("last_name") WHERE state = 'active') + assert_equal expected, add_index(:people, :last_name, :unique => true, :where => "state = 'active'") + + expected = %(CREATE INDEX CONCURRENTLY "index_people_on_last_name" ON "people" ("last_name")) + assert_equal expected, add_index(:people, :last_name, algorithm: :concurrently) + + %w(gin gist hash btree).each do |type| + expected = %(CREATE INDEX "index_people_on_last_name" ON "people" USING #{type} ("last_name")) + assert_equal expected, add_index(:people, :last_name, using: type) + + expected = %(CREATE INDEX CONCURRENTLY "index_people_on_last_name" ON "people" USING #{type} ("last_name")) + assert_equal expected, add_index(:people, :last_name, using: type, algorithm: :concurrently) + end + + assert_raise ArgumentError do + add_index(:people, :last_name, algorithm: :copy) + end + expected = %(CREATE UNIQUE INDEX "index_people_on_last_name" ON "people" USING gist ("last_name")) + assert_equal expected, add_index(:people, :last_name, :unique => true, :using => :gist) + + expected = %(CREATE UNIQUE INDEX "index_people_on_last_name" ON "people" USING gist ("last_name") WHERE state = 'active') + assert_equal expected, add_index(:people, :last_name, :unique => true, :where => "state = 'active'", :using => :gist) + end + + private + def method_missing(method_symbol, *arguments) + ActiveRecord::Base.connection.send(method_symbol, *arguments) + end +end diff --git a/activerecord/test/cases/adapters/postgresql/array_test.rb b/activerecord/test/cases/adapters/postgresql/array_test.rb new file mode 100644 index 0000000000..8df1b7d18c --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/array_test.rb @@ -0,0 +1,275 @@ +# encoding: utf-8 +require "cases/helper" + +class PostgresqlArrayTest < ActiveRecord::TestCase + include InTimeZone + OID = ActiveRecord::ConnectionAdapters::PostgreSQL::OID + + class PgArray < ActiveRecord::Base + self.table_name = 'pg_arrays' + end + + def setup + @connection = ActiveRecord::Base.connection + + unless @connection.extension_enabled?('hstore') + @connection.enable_extension 'hstore' + @connection.commit_db_transaction + end + + @connection.reconnect! + + @connection.transaction do + @connection.create_table('pg_arrays') do |t| + t.string 'tags', array: true + t.integer 'ratings', array: true + t.datetime :datetimes, array: true + t.hstore :hstores, array: true + end + end + @column = PgArray.columns_hash['tags'] + end + + teardown do + @connection.execute 'drop table if exists pg_arrays' + end + + def test_column + assert_equal :string, @column.type + assert_equal "character varying", @column.sql_type + assert @column.array + assert_not @column.number? + assert_not @column.binary? + + ratings_column = PgArray.columns_hash['ratings'] + assert_equal :integer, ratings_column.type + assert ratings_column.array + assert_not ratings_column.number? + end + + def test_default + @connection.add_column 'pg_arrays', 'score', :integer, array: true, default: [4, 4, 2] + PgArray.reset_column_information + + assert_equal([4, 4, 2], PgArray.column_defaults['score']) + assert_equal([4, 4, 2], PgArray.new.score) + ensure + PgArray.reset_column_information + end + + def test_default_strings + @connection.add_column 'pg_arrays', 'names', :string, array: true, default: ["foo", "bar"] + PgArray.reset_column_information + + assert_equal(["foo", "bar"], PgArray.column_defaults['names']) + assert_equal(["foo", "bar"], PgArray.new.names) + ensure + PgArray.reset_column_information + end + + def test_change_column_with_array + @connection.add_column :pg_arrays, :snippets, :string, array: true, default: [] + @connection.change_column :pg_arrays, :snippets, :text, array: true, default: [] + + PgArray.reset_column_information + column = PgArray.columns_hash['snippets'] + + assert_equal :text, column.type + assert_equal [], PgArray.column_defaults['snippets'] + assert column.array + end + + def test_change_column_cant_make_non_array_column_to_array + @connection.add_column :pg_arrays, :a_string, :string + assert_raises ActiveRecord::StatementInvalid do + @connection.transaction do + @connection.change_column :pg_arrays, :a_string, :string, array: true + end + end + end + + def test_change_column_default_with_array + @connection.change_column_default :pg_arrays, :tags, [] + + PgArray.reset_column_information + assert_equal [], PgArray.column_defaults['tags'] + end + + def test_type_cast_array + assert_equal(['1', '2', '3'], @column.type_cast_from_database('{1,2,3}')) + assert_equal([], @column.type_cast_from_database('{}')) + assert_equal([nil], @column.type_cast_from_database('{NULL}')) + end + + def test_type_cast_integers + x = PgArray.new(ratings: ['1', '2']) + + assert_equal([1, 2], x.ratings) + + x.save! + x.reload + + assert_equal([1, 2], x.ratings) + end + + def test_select_with_strings + @connection.execute "insert into pg_arrays (tags) VALUES ('{1,2,3}')" + x = PgArray.first + assert_equal(['1','2','3'], x.tags) + end + + def test_rewrite_with_strings + @connection.execute "insert into pg_arrays (tags) VALUES ('{1,2,3}')" + x = PgArray.first + x.tags = ['1','2','3','4'] + x.save! + assert_equal ['1','2','3','4'], x.reload.tags + end + + def test_select_with_integers + @connection.execute "insert into pg_arrays (ratings) VALUES ('{1,2,3}')" + x = PgArray.first + assert_equal([1, 2, 3], x.ratings) + end + + def test_rewrite_with_integers + @connection.execute "insert into pg_arrays (ratings) VALUES ('{1,2,3}')" + x = PgArray.first + x.ratings = [2, '3', 4] + x.save! + assert_equal [2, 3, 4], x.reload.ratings + end + + def test_multi_dimensional_with_strings + assert_cycle(:tags, [[['1'], ['2']], [['2'], ['3']]]) + end + + def test_with_empty_strings + assert_cycle(:tags, [ '1', '2', '', '4', '', '5' ]) + end + + def test_with_multi_dimensional_empty_strings + assert_cycle(:tags, [[['1', '2'], ['', '4'], ['', '5']]]) + end + + def test_with_arbitrary_whitespace + assert_cycle(:tags, [[['1', '2'], [' ', '4'], [' ', '5']]]) + end + + def test_multi_dimensional_with_integers + assert_cycle(:ratings, [[[1], [7]], [[8], [10]]]) + end + + def test_strings_with_quotes + assert_cycle(:tags, ['this has','some "s that need to be escaped"']) + end + + def test_strings_with_commas + assert_cycle(:tags, ['this,has','many,values']) + end + + def test_strings_with_array_delimiters + assert_cycle(:tags, ['{','}']) + end + + def test_strings_with_null_strings + assert_cycle(:tags, ['NULL','NULL']) + end + + def test_contains_nils + assert_cycle(:tags, ['1',nil,nil]) + end + + def test_insert_fixture + tag_values = ["val1", "val2", "val3_with_'_multiple_quote_'_chars"] + @connection.insert_fixture({"tags" => tag_values}, "pg_arrays" ) + assert_equal(PgArray.last.tags, tag_values) + end + + def test_attribute_for_inspect_for_array_field + record = PgArray.new { |a| a.ratings = (1..11).to_a } + assert_equal("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...]", record.attribute_for_inspect(:ratings)) + end + + def test_escaping + unknown = 'foo\\",bar,baz,\\' + tags = ["hello_#{unknown}"] + ar = PgArray.create!(tags: tags) + ar.reload + assert_equal tags, ar.tags + end + + def test_string_quoting_rules_match_pg_behavior + tags = ["", "one{", "two}", %(three"), "four\\", "five ", "six\t", "seven\n", "eight,", "nine", "ten\r", "NULL"] + x = PgArray.create!(tags: tags) + x.reload + + assert_equal x.tags_before_type_cast, PgArray.columns_hash['tags'].type_cast_for_database(tags) + end + + def test_quoting_non_standard_delimiters + strings = ["hello,", "world;"] + comma_delim = OID::Array.new(ActiveRecord::Type::String.new, ',') + semicolon_delim = OID::Array.new(ActiveRecord::Type::String.new, ';') + + assert_equal %({"hello,",world;}), comma_delim.type_cast_for_database(strings) + assert_equal %({hello,;"world;"}), semicolon_delim.type_cast_for_database(strings) + end + + def test_mutate_array + x = PgArray.create!(tags: %w(one two)) + + x.tags << "three" + x.save! + x.reload + + assert_equal %w(one two three), x.tags + assert_not x.changed? + end + + def test_mutate_value_in_array + x = PgArray.create!(hstores: [{ a: 'a' }, { b: 'b' }]) + + x.hstores.first['a'] = 'c' + x.save! + x.reload + + assert_equal [{ 'a' => 'c' }, { 'b' => 'b' }], x.hstores + assert_not x.changed? + end + + def test_datetime_with_timezone_awareness + tz = "Pacific Time (US & Canada)" + + in_time_zone tz do + PgArray.reset_column_information + time_string = Time.current.to_s + time = Time.zone.parse(time_string) + + record = PgArray.new(datetimes: [time_string]) + assert_equal [time], record.datetimes + assert_equal ActiveSupport::TimeZone[tz], record.datetimes.first.time_zone + + record.save! + record.reload + + assert_equal [time], record.datetimes + assert_equal ActiveSupport::TimeZone[tz], record.datetimes.first.time_zone + end + end + + private + def assert_cycle field, array + # test creation + x = PgArray.create!(field => array) + x.reload + assert_equal(array, x.public_send(field)) + + # test updating + x = PgArray.create!(field => []) + x.public_send("#{field}=", array) + x.save! + x.reload + assert_equal(array, x.public_send(field)) + end +end diff --git a/activerecord/test/cases/adapters/postgresql/bit_string_test.rb b/activerecord/test/cases/adapters/postgresql/bit_string_test.rb new file mode 100644 index 0000000000..72222c01fd --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/bit_string_test.rb @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +require "cases/helper" +require 'support/connection_helper' +require 'support/schema_dumping_helper' + +class PostgresqlBitStringTest < ActiveRecord::TestCase + include ConnectionHelper + include SchemaDumpingHelper + + class PostgresqlBitString < ActiveRecord::Base; end + + def setup + @connection = ActiveRecord::Base.connection + @connection.create_table('postgresql_bit_strings', :force => true) do |t| + t.bit :a_bit, default: "00000011", limit: 8 + t.bit_varying :a_bit_varying, default: "0011", limit: 4 + end + end + + def teardown + return unless @connection + @connection.execute 'DROP TABLE IF EXISTS postgresql_bit_strings' + end + + def test_bit_string_column + column = PostgresqlBitString.columns_hash["a_bit"] + assert_equal :bit, column.type + assert_equal "bit(8)", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_bit_string_varying_column + column = PostgresqlBitString.columns_hash["a_bit_varying"] + assert_equal :bit_varying, column.type + assert_equal "bit varying(4)", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_default + assert_equal "00000011", PostgresqlBitString.column_defaults['a_bit'] + assert_equal "00000011", PostgresqlBitString.new.a_bit + + assert_equal "0011", PostgresqlBitString.column_defaults['a_bit_varying'] + assert_equal "0011", PostgresqlBitString.new.a_bit_varying + end + + def test_schema_dumping + output = dump_table_schema("postgresql_bit_strings") + assert_match %r{t\.bit\s+"a_bit",\s+limit: 8,\s+default: "00000011"$}, output + assert_match %r{t\.bit_varying\s+"a_bit_varying",\s+limit: 4,\s+default: "0011"$}, output + end + + def test_assigning_invalid_hex_string_raises_exception + assert_raises(ActiveRecord::StatementInvalid) { PostgresqlBitString.create! a_bit: "FF" } + assert_raises(ActiveRecord::StatementInvalid) { PostgresqlBitString.create! a_bit_varying: "FF" } + end + + def test_roundtrip + PostgresqlBitString.create! a_bit: "00001010", a_bit_varying: "0101" + record = PostgresqlBitString.first + assert_equal "00001010", record.a_bit + assert_equal "0101", record.a_bit_varying + + record.a_bit = "11111111" + record.a_bit_varying = "0xF" + record.save! + + assert record.reload + assert_equal "11111111", record.a_bit + assert_equal "1111", record.a_bit_varying + end +end diff --git a/activerecord/test/cases/adapters/postgresql/bytea_test.rb b/activerecord/test/cases/adapters/postgresql/bytea_test.rb new file mode 100644 index 0000000000..7872f91943 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/bytea_test.rb @@ -0,0 +1,118 @@ +# encoding: utf-8 +require "cases/helper" + +class PostgresqlByteaTest < ActiveRecord::TestCase + class ByteaDataType < ActiveRecord::Base + self.table_name = 'bytea_data_type' + end + + def setup + @connection = ActiveRecord::Base.connection + begin + @connection.transaction do + @connection.create_table('bytea_data_type') do |t| + t.binary 'payload' + t.binary 'serialized' + end + end + end + @column = ByteaDataType.columns_hash['payload'] + assert(@column.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQLColumn)) + end + + teardown do + @connection.execute 'drop table if exists bytea_data_type' + end + + def test_column + assert_equal :binary, @column.type + end + + def test_type_cast_binary_converts_the_encoding + assert @column + + data = "\u001F\x8B" + assert_equal('UTF-8', data.encoding.name) + assert_equal('ASCII-8BIT', @column.type_cast_from_database(data).encoding.name) + end + + def test_type_cast_binary_value + data = "\u001F\x8B".force_encoding("BINARY") + assert_equal(data, @column.type_cast_from_database(data)) + end + + def test_type_case_nil + assert_equal(nil, @column.type_cast_from_database(nil)) + end + + def test_read_value + data = "\u001F" + @connection.execute "insert into bytea_data_type (payload) VALUES ('#{data}')" + record = ByteaDataType.first + assert_equal(data, record.payload) + record.delete + end + + def test_read_nil_value + @connection.execute "insert into bytea_data_type (payload) VALUES (null)" + record = ByteaDataType.first + assert_equal(nil, record.payload) + record.delete + end + + def test_write_value + data = "\u001F" + record = ByteaDataType.create(payload: data) + assert_not record.new_record? + assert_equal(data, record.payload) + end + + def test_via_to_sql + data = "'\u001F\\" + ByteaDataType.create(payload: data) + sql = ByteaDataType.where(payload: data).select(:payload).to_sql + result = @connection.query(sql) + assert_equal([[data]], result) + end + + def test_via_to_sql_with_complicating_connection + Thread.new do + other_conn = ActiveRecord::Base.connection + other_conn.execute('SET standard_conforming_strings = off') + end.join + + test_via_to_sql + end + + def test_write_binary + data = File.read(File.join(File.dirname(__FILE__), '..', '..', '..', 'assets', 'example.log')) + assert(data.size > 1) + record = ByteaDataType.create(payload: data) + assert_not record.new_record? + assert_equal(data, record.payload) + assert_equal(data, ByteaDataType.where(id: record.id).first.payload) + end + + def test_write_nil + record = ByteaDataType.create(payload: nil) + assert_not record.new_record? + assert_equal(nil, record.payload) + assert_equal(nil, ByteaDataType.where(id: record.id).first.payload) + end + + class Serializer + def load(str); str; end + def dump(str); str; end + end + + def test_serialize + klass = Class.new(ByteaDataType) { + serialize :serialized, Serializer.new + } + obj = klass.new + obj.serialized = "hello world" + obj.save! + obj.reload + assert_equal "hello world", obj.serialized + end +end diff --git a/activerecord/test/cases/adapters/postgresql/citext_test.rb b/activerecord/test/cases/adapters/postgresql/citext_test.rb new file mode 100644 index 0000000000..2acb64f81c --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/citext_test.rb @@ -0,0 +1,76 @@ +# encoding: utf-8 +require 'cases/helper' + +if ActiveRecord::Base.connection.supports_extensions? + class PostgresqlCitextTest < ActiveRecord::TestCase + class Citext < ActiveRecord::Base + self.table_name = 'citexts' + end + + def setup + @connection = ActiveRecord::Base.connection + + unless @connection.extension_enabled?('citext') + @connection.enable_extension 'citext' + @connection.commit_db_transaction + end + + @connection.reconnect! + + @connection.create_table('citexts') do |t| + t.citext 'cival' + end + end + + teardown do + @connection.execute 'DROP TABLE IF EXISTS citexts;' + @connection.execute 'DROP EXTENSION IF EXISTS citext CASCADE;' + end + + def test_citext_enabled + assert @connection.extension_enabled?('citext') + end + + def test_column + column = Citext.columns_hash['cival'] + assert_equal :citext, column.type + assert_equal 'citext', column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_change_table_supports_json + @connection.transaction do + @connection.change_table('citexts') do |t| + t.citext 'username' + end + Citext.reset_column_information + column = Citext.columns_hash['username'] + assert_equal :citext, column.type + + raise ActiveRecord::Rollback # reset the schema change + end + ensure + Citext.reset_column_information + end + + def test_write + x = Citext.new(cival: 'Some CI Text') + x.save! + citext = Citext.first + assert_equal "Some CI Text", citext.cival + + citext.cival = "Some NEW CI Text" + citext.save! + + assert_equal "Some NEW CI Text", citext.reload.cival + end + + def test_select_case_insensitive + @connection.execute "insert into citexts (cival) values('Cased Text')" + x = Citext.where(cival: 'cased text').first + assert_equal 'Cased Text', x.cival + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/composite_test.rb b/activerecord/test/cases/adapters/postgresql/composite_test.rb new file mode 100644 index 0000000000..cfab5ca902 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/composite_test.rb @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +require "cases/helper" +require 'support/connection_helper' + +module PostgresqlCompositeBehavior + include ConnectionHelper + + class PostgresqlComposite < ActiveRecord::Base + self.table_name = "postgresql_composites" + end + + def setup + super + + @connection = ActiveRecord::Base.connection + @connection.transaction do + @connection.execute <<-SQL + CREATE TYPE full_address AS + ( + city VARCHAR(90), + street VARCHAR(90) + ); + SQL + @connection.create_table('postgresql_composites') do |t| + t.column :address, :full_address + end + end + end + + def teardown + super + + @connection.execute 'DROP TABLE IF EXISTS postgresql_composites' + @connection.execute 'DROP TYPE IF EXISTS full_address' + reset_connection + PostgresqlComposite.reset_column_information + end +end + +# Composites are mapped to `OID::Identity` by default. The user is informed by a warning like: +# "unknown OID 5653508: failed to recognize type of 'address'. It will be treated as String." +# To take full advantage of composite types, we suggest you register your own +OID::Type+. +# See PostgresqlCompositeWithCustomOIDTest +class PostgresqlCompositeTest < ActiveRecord::TestCase + include PostgresqlCompositeBehavior + + def test_column + ensure_warning_is_issued + + column = PostgresqlComposite.columns_hash["address"] + assert_nil column.type + assert_equal "full_address", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_composite_mapping + ensure_warning_is_issued + + @connection.execute "INSERT INTO postgresql_composites VALUES (1, ROW('Paris', 'Champs-Élysées'));" + composite = PostgresqlComposite.first + assert_equal "(Paris,Champs-Élysées)", composite.address + + composite.address = "(Paris,Rue Basse)" + composite.save! + + assert_equal '(Paris,"Rue Basse")', composite.reload.address + end + + private + def ensure_warning_is_issued + warning = capture(:stderr) do + PostgresqlComposite.columns_hash + end + assert_match(/unknown OID \d+: failed to recognize type of 'address'\. It will be treated as String\./, warning) + end +end + +class PostgresqlCompositeWithCustomOIDTest < ActiveRecord::TestCase + include PostgresqlCompositeBehavior + + class FullAddressType < ActiveRecord::Type::Value + def type; :full_address end + + def type_cast_from_database(value) + if value =~ /\("?([^",]*)"?,"?([^",]*)"?\)/ + FullAddress.new($1, $2) + end + end + + def type_cast_from_user(value) + value + end + + def type_cast_for_database(value) + return if value.nil? + "(#{value.city},#{value.street})" + end + end + + FullAddress = Struct.new(:city, :street) + + def setup + super + + @connection.type_map.register_type "full_address", FullAddressType.new + end + + def test_column + column = PostgresqlComposite.columns_hash["address"] + assert_equal :full_address, column.type + assert_equal "full_address", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_composite_mapping + @connection.execute "INSERT INTO postgresql_composites VALUES (1, ROW('Paris', 'Champs-Élysées'));" + composite = PostgresqlComposite.first + assert_equal "Paris", composite.address.city + assert_equal "Champs-Élysées", composite.address.street + + composite.address = FullAddress.new("Paris", "Rue Basse") + composite.save! + + assert_equal 'Paris', composite.reload.address.city + assert_equal 'Rue Basse', composite.reload.address.street + end +end diff --git a/activerecord/test/cases/adapters/postgresql/connection_test.rb b/activerecord/test/cases/adapters/postgresql/connection_test.rb new file mode 100644 index 0000000000..d26cda46fa --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/connection_test.rb @@ -0,0 +1,205 @@ +require "cases/helper" +require 'support/connection_helper' + +module ActiveRecord + class PostgresqlConnectionTest < ActiveRecord::TestCase + include ConnectionHelper + + class NonExistentTable < ActiveRecord::Base + end + + def setup + super + @subscriber = SQLSubscriber.new + @subscription = ActiveSupport::Notifications.subscribe('sql.active_record', @subscriber) + @connection = ActiveRecord::Base.connection + end + + def teardown + ActiveSupport::Notifications.unsubscribe(@subscription) + super + end + + def test_encoding + assert_not_nil @connection.encoding + end + + def test_collation + assert_not_nil @connection.collation + end + + def test_ctype + assert_not_nil @connection.ctype + end + + def test_default_client_min_messages + assert_equal "warning", @connection.client_min_messages + end + + # Ensure, we can set connection params using the example of Generic + # Query Optimizer (geqo). It is 'on' per default. + def test_connection_options + params = ActiveRecord::Base.connection_config.dup + params[:options] = "-c geqo=off" + NonExistentTable.establish_connection(params) + + # Verify the connection param has been applied. + expect = NonExistentTable.connection.query('show geqo').first.first + assert_equal 'off', expect + end + + def test_reset + @connection.query('ROLLBACK') + @connection.query('SET geqo TO off') + + # Verify the setting has been applied. + expect = @connection.query('show geqo').first.first + assert_equal 'off', expect + + @connection.reset! + + # Verify the setting has been cleared. + expect = @connection.query('show geqo').first.first + assert_equal 'on', expect + end + + def test_reset_with_transaction + @connection.query('ROLLBACK') + @connection.query('SET geqo TO off') + + # Verify the setting has been applied. + expect = @connection.query('show geqo').first.first + assert_equal 'off', expect + + @connection.query('BEGIN') + @connection.reset! + + # Verify the setting has been cleared. + expect = @connection.query('show geqo').first.first + assert_equal 'on', expect + end + + def test_tables_logs_name + @connection.tables('hello') + assert_equal 'SCHEMA', @subscriber.logged[0][1] + end + + def test_indexes_logs_name + @connection.indexes('items', 'hello') + assert_equal 'SCHEMA', @subscriber.logged[0][1] + end + + def test_table_exists_logs_name + @connection.table_exists?('items') + assert_equal 'SCHEMA', @subscriber.logged[0][1] + end + + def test_table_alias_length_logs_name + @connection.instance_variable_set("@table_alias_length", nil) + @connection.table_alias_length + assert_equal 'SCHEMA', @subscriber.logged[0][1] + end + + def test_current_database_logs_name + @connection.current_database + assert_equal 'SCHEMA', @subscriber.logged[0][1] + end + + def test_encoding_logs_name + @connection.encoding + assert_equal 'SCHEMA', @subscriber.logged[0][1] + end + + def test_schema_names_logs_name + @connection.schema_names + assert_equal 'SCHEMA', @subscriber.logged[0][1] + end + + def test_statement_key_is_logged + bindval = 1 + @connection.exec_query('SELECT $1::integer', 'SQL', [[nil, bindval]]) + name = @subscriber.payloads.last[:statement_name] + assert name + res = @connection.exec_query("EXPLAIN (FORMAT JSON) EXECUTE #{name}(#{bindval})") + plan = res.column_types['QUERY PLAN'].type_cast_from_database res.rows.first.first + assert_operator plan.length, :>, 0 + end + + # Must have PostgreSQL >= 9.2, or with_manual_interventions set to + # true for this test to run. + # + # When prompted, restart the PostgreSQL server with the + # "-m fast" option or kill the individual connection assuming + # you know the incantation to do that. + # To restart PostgreSQL 9.1 on OS X, installed via MacPorts, ... + # sudo su postgres -c "pg_ctl restart -D /opt/local/var/db/postgresql91/defaultdb/ -m fast" + def test_reconnection_after_actual_disconnection_with_verify + original_connection_pid = @connection.query('select pg_backend_pid()') + + # Sanity check. + assert @connection.active? + + if @connection.send(:postgresql_version) >= 90200 + secondary_connection = ActiveRecord::Base.connection_pool.checkout + secondary_connection.query("select pg_terminate_backend(#{original_connection_pid.first.first})") + ActiveRecord::Base.connection_pool.checkin(secondary_connection) + elsif ARTest.config['with_manual_interventions'] + puts 'Kill the connection now (e.g. by restarting the PostgreSQL ' + + 'server with the "-m fast" option) and then press enter.' + $stdin.gets + else + # We're not capable of terminating the backend ourselves, and + # we're not allowed to seek assistance; bail out without + # actually testing anything. + return + end + + @connection.verify! + + assert @connection.active? + + # If we get no exception here, then either we re-connected successfully, or + # we never actually got disconnected. + new_connection_pid = @connection.query('select pg_backend_pid()') + + assert_not_equal original_connection_pid, new_connection_pid, + "umm -- looks like you didn't break the connection, because we're still " + + "successfully querying with the same connection pid." + + # Repair all fixture connections so other tests won't break. + @fixture_connections.each do |c| + c.verify! + end + end + + def test_set_session_variable_true + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.deep_merge({:variables => {:debug_print_plan => true}})) + set_true = ActiveRecord::Base.connection.exec_query "SHOW DEBUG_PRINT_PLAN" + assert_equal set_true.rows, [["on"]] + end + end + + def test_set_session_variable_false + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.deep_merge({:variables => {:debug_print_plan => false}})) + set_false = ActiveRecord::Base.connection.exec_query "SHOW DEBUG_PRINT_PLAN" + assert_equal set_false.rows, [["off"]] + end + end + + def test_set_session_variable_nil + run_without_connection do |orig_connection| + # This should be a no-op that does not raise an error + ActiveRecord::Base.establish_connection(orig_connection.deep_merge({:variables => {:debug_print_plan => nil}})) + end + end + + def test_set_session_variable_default + run_without_connection do |orig_connection| + # This should execute a query that does not raise an error + ActiveRecord::Base.establish_connection(orig_connection.deep_merge({:variables => {:debug_print_plan => :default}})) + end + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/datatype_test.rb b/activerecord/test/cases/adapters/postgresql/datatype_test.rb new file mode 100644 index 0000000000..a0a34e4b87 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/datatype_test.rb @@ -0,0 +1,119 @@ +require "cases/helper" +require 'support/ddl_helper' + + +class PostgresqlNumber < ActiveRecord::Base +end + +class PostgresqlTime < ActiveRecord::Base +end + +class PostgresqlOid < ActiveRecord::Base +end + +class PostgresqlLtree < ActiveRecord::Base +end + +class PostgresqlDataTypeTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + def setup + @connection = ActiveRecord::Base.connection + + @connection.execute("INSERT INTO postgresql_numbers (id, single, double) VALUES (1, 123.456, 123456.789)") + @connection.execute("INSERT INTO postgresql_numbers (id, single, double) VALUES (2, '-Infinity', 'Infinity')") + @connection.execute("INSERT INTO postgresql_numbers (id, single, double) VALUES (3, 123.456, 'NaN')") + @first_number = PostgresqlNumber.find(1) + @second_number = PostgresqlNumber.find(2) + @third_number = PostgresqlNumber.find(3) + + @connection.execute("INSERT INTO postgresql_times (id, time_interval, scaled_time_interval) VALUES (1, '1 year 2 days ago', '3 weeks ago')") + @first_time = PostgresqlTime.find(1) + + @connection.execute("INSERT INTO postgresql_oids (id, obj_id) VALUES (1, 1234)") + @first_oid = PostgresqlOid.find(1) + end + + teardown do + [PostgresqlNumber, PostgresqlTime, PostgresqlOid].each(&:delete_all) + end + + def test_data_type_of_number_types + assert_equal :float, @first_number.column_for_attribute(:single).type + assert_equal :float, @first_number.column_for_attribute(:double).type + end + + def test_data_type_of_time_types + assert_equal :string, @first_time.column_for_attribute(:time_interval).type + assert_equal :string, @first_time.column_for_attribute(:scaled_time_interval).type + end + + def test_data_type_of_oid_types + assert_equal :integer, @first_oid.column_for_attribute(:obj_id).type + end + + def test_number_values + assert_equal 123.456, @first_number.single + assert_equal 123456.789, @first_number.double + assert_equal(-::Float::INFINITY, @second_number.single) + assert_equal ::Float::INFINITY, @second_number.double + assert_same ::Float::NAN, @third_number.double + end + + def test_time_values + assert_equal '-1 years -2 days', @first_time.time_interval + assert_equal '-21 days', @first_time.scaled_time_interval + end + + def test_oid_values + assert_equal 1234, @first_oid.obj_id + end + + def test_update_number + new_single = 789.012 + new_double = 789012.345 + @first_number.single = new_single + @first_number.double = new_double + assert @first_number.save + assert @first_number.reload + assert_equal new_single, @first_number.single + assert_equal new_double, @first_number.double + end + + def test_update_time + @first_time.time_interval = '2 years 3 minutes' + assert @first_time.save + assert @first_time.reload + assert_equal '2 years 00:03:00', @first_time.time_interval + end + + def test_update_oid + new_value = 567890 + @first_oid.obj_id = new_value + assert @first_oid.save + assert @first_oid.reload + assert_equal new_value, @first_oid.obj_id + end +end + +class PostgresqlInternalDataTypeTest < ActiveRecord::TestCase + include DdlHelper + + setup do + @connection = ActiveRecord::Base.connection + end + + def test_name_column_type + with_example_table @connection, 'ex', 'data name' do + column = @connection.columns('ex').find { |col| col.name == 'data' } + assert_equal :string, column.type + end + end + + def test_char_column_type + with_example_table @connection, 'ex', 'data "char"' do + column = @connection.columns('ex').find { |col| col.name == 'data' } + assert_equal :string, column.type + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/domain_test.rb b/activerecord/test/cases/adapters/postgresql/domain_test.rb new file mode 100644 index 0000000000..1500adb42d --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/domain_test.rb @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +require "cases/helper" +require 'support/connection_helper' + +class PostgresqlDomainTest < ActiveRecord::TestCase + include ConnectionHelper + + class PostgresqlDomain < ActiveRecord::Base + self.table_name = "postgresql_domains" + end + + def setup + @connection = ActiveRecord::Base.connection + @connection.transaction do + @connection.execute "CREATE DOMAIN custom_money as numeric(8,2)" + @connection.create_table('postgresql_domains') do |t| + t.column :price, :custom_money + end + end + end + + teardown do + @connection.execute 'DROP TABLE IF EXISTS postgresql_domains' + @connection.execute 'DROP DOMAIN IF EXISTS custom_money' + reset_connection + end + + def test_column + column = PostgresqlDomain.columns_hash["price"] + assert_equal :decimal, column.type + assert_equal "custom_money", column.sql_type + assert column.number? + assert_not column.binary? + assert_not column.array + end + + def test_domain_acts_like_basetype + PostgresqlDomain.create price: "" + record = PostgresqlDomain.first + assert_nil record.price + + record.price = "34.15" + record.save! + + assert_equal BigDecimal.new("34.15"), record.reload.price + end +end diff --git a/activerecord/test/cases/adapters/postgresql/enum_test.rb b/activerecord/test/cases/adapters/postgresql/enum_test.rb new file mode 100644 index 0000000000..d99c4a292e --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/enum_test.rb @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +require "cases/helper" +require 'support/connection_helper' +require 'active_record/base' +require 'active_record/connection_adapters/postgresql_adapter' + +class PostgresqlEnumTest < ActiveRecord::TestCase + include ConnectionHelper + + class PostgresqlEnum < ActiveRecord::Base + self.table_name = "postgresql_enums" + end + + def setup + @connection = ActiveRecord::Base.connection + @connection.transaction do + @connection.execute <<-SQL + CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy'); + SQL + @connection.create_table('postgresql_enums') do |t| + t.column :current_mood, :mood + end + end + end + + teardown do + @connection.execute 'DROP TABLE IF EXISTS postgresql_enums' + @connection.execute 'DROP TYPE IF EXISTS mood' + reset_connection + end + + def test_column + column = PostgresqlEnum.columns_hash["current_mood"] + assert_equal :enum, column.type + assert_equal "mood", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_enum_defaults + @connection.add_column 'postgresql_enums', 'good_mood', :mood, default: 'happy' + PostgresqlEnum.reset_column_information + + assert_equal "happy", PostgresqlEnum.column_defaults['good_mood'] + assert_equal "happy", PostgresqlEnum.new.good_mood + ensure + PostgresqlEnum.reset_column_information + end + + def test_enum_mapping + @connection.execute "INSERT INTO postgresql_enums VALUES (1, 'sad');" + enum = PostgresqlEnum.first + assert_equal "sad", enum.current_mood + + enum.current_mood = "happy" + enum.save! + + assert_equal "happy", enum.reload.current_mood + end + + def test_invalid_enum_update + @connection.execute "INSERT INTO postgresql_enums VALUES (1, 'sad');" + enum = PostgresqlEnum.first + enum.current_mood = "angry" + + assert_raise ActiveRecord::StatementInvalid do + enum.save + end + end + + def test_no_oid_warning + @connection.execute "INSERT INTO postgresql_enums VALUES (1, 'sad');" + stderr_output = capture(:stderr) { PostgresqlEnum.first } + + assert stderr_output.blank? + end + + def test_enum_type_cast + enum = PostgresqlEnum.new + enum.current_mood = :happy + + assert_equal "happy", enum.current_mood + end +end diff --git a/activerecord/test/cases/adapters/postgresql/explain_test.rb b/activerecord/test/cases/adapters/postgresql/explain_test.rb new file mode 100644 index 0000000000..416f84cb38 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/explain_test.rb @@ -0,0 +1,28 @@ +require "cases/helper" +require 'models/developer' + +module ActiveRecord + module ConnectionAdapters + class PostgreSQLAdapter + class ExplainTest < ActiveRecord::TestCase + fixtures :developers + + def test_explain_for_one_query + explain = Developer.where(:id => 1).explain + assert_match %(EXPLAIN for: SELECT "developers".* FROM "developers" WHERE "developers"."id" = $1), explain + assert_match %(QUERY PLAN), explain + assert_match %(Index Scan using developers_pkey on developers), explain + end + + def test_explain_with_eager_loading + explain = Developer.where(:id => 1).includes(:audit_logs).explain + assert_match %(QUERY PLAN), explain + assert_match %(EXPLAIN for: SELECT "developers".* FROM "developers" WHERE "developers"."id" = $1), explain + assert_match %(Index Scan using developers_pkey on developers), explain + assert_match %(EXPLAIN for: SELECT "audit_logs".* FROM "audit_logs" WHERE "audit_logs"."developer_id" IN (1)), explain + assert_match %(Seq Scan on audit_logs), explain + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb new file mode 100644 index 0000000000..7b99fcdda0 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb @@ -0,0 +1,63 @@ +require "cases/helper" + +class PostgresqlExtensionMigrationTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + class EnableHstore < ActiveRecord::Migration + def change + enable_extension "hstore" + end + end + + class DisableHstore < ActiveRecord::Migration + def change + disable_extension "hstore" + end + end + + def setup + super + + @connection = ActiveRecord::Base.connection + + unless @connection.supports_extensions? + return skip("no extension support") + end + + @old_schema_migration_tabel_name = ActiveRecord::SchemaMigration.table_name + @old_tabel_name_prefix = ActiveRecord::Base.table_name_prefix + @old_tabel_name_suffix = ActiveRecord::Base.table_name_suffix + + ActiveRecord::Base.table_name_prefix = "p_" + ActiveRecord::Base.table_name_suffix = "_s" + ActiveRecord::SchemaMigration.delete_all rescue nil + ActiveRecord::SchemaMigration.table_name = "p_schema_migrations_s" + ActiveRecord::Migration.verbose = false + end + + def teardown + ActiveRecord::Base.table_name_prefix = @old_tabel_name_prefix + ActiveRecord::Base.table_name_suffix = @old_tabel_name_suffix + ActiveRecord::SchemaMigration.delete_all rescue nil + ActiveRecord::Migration.verbose = true + ActiveRecord::SchemaMigration.table_name = @old_schema_migration_tabel_name + + super + end + + def test_enable_extension_migration_ignores_prefix_and_suffix + @connection.disable_extension("hstore") + + migrations = [EnableHstore.new(nil, 1)] + ActiveRecord::Migrator.new(:up, migrations).migrate + assert @connection.extension_enabled?("hstore"), "extension hstore should be enabled" + end + + def test_disable_extension_migration_ignores_prefix_and_suffix + @connection.enable_extension("hstore") + + migrations = [DisableHstore.new(nil, 1)] + ActiveRecord::Migrator.new(:up, migrations).migrate + assert_not @connection.extension_enabled?("hstore"), "extension hstore should not be enabled" + end +end diff --git a/activerecord/test/cases/adapters/postgresql/full_text_test.rb b/activerecord/test/cases/adapters/postgresql/full_text_test.rb new file mode 100644 index 0000000000..9dadb177ca --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/full_text_test.rb @@ -0,0 +1,26 @@ +# encoding: utf-8 +require "cases/helper" + +class PostgresqlFullTextTest < ActiveRecord::TestCase + class PostgresqlTsvector < ActiveRecord::Base; end + + def test_tsvector_column + column = PostgresqlTsvector.columns_hash["text_vector"] + assert_equal :tsvector, column.type + assert_equal "tsvector", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_update_tsvector + PostgresqlTsvector.create text_vector: "'text' 'vector'" + tsvector = PostgresqlTsvector.first + assert_equal "'text' 'vector'", tsvector.text_vector + + tsvector.text_vector = "'new' 'text' 'vector'" + tsvector.save! + assert tsvector.reload + assert_equal "'new' 'text' 'vector'", tsvector.text_vector + end +end diff --git a/activerecord/test/cases/adapters/postgresql/geometric_test.rb b/activerecord/test/cases/adapters/postgresql/geometric_test.rb new file mode 100644 index 0000000000..6c0adbbeaa --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/geometric_test.rb @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +require "cases/helper" +require 'support/connection_helper' +require 'support/schema_dumping_helper' + +class PostgresqlPointTest < ActiveRecord::TestCase + include ConnectionHelper + include SchemaDumpingHelper + + class PostgresqlPoint < ActiveRecord::Base; end + + def setup + @connection = ActiveRecord::Base.connection + @connection.transaction do + @connection.create_table('postgresql_points') do |t| + t.point :x + t.point :y, default: [12.2, 13.3] + t.point :z, default: "(14.4,15.5)" + end + end + end + + teardown do + @connection.execute 'DROP TABLE IF EXISTS postgresql_points' + end + + def test_column + column = PostgresqlPoint.columns_hash["x"] + assert_equal :point, column.type + assert_equal "point", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_default + assert_equal [12.2, 13.3], PostgresqlPoint.column_defaults['y'] + assert_equal [12.2, 13.3], PostgresqlPoint.new.y + + assert_equal [14.4, 15.5], PostgresqlPoint.column_defaults['z'] + assert_equal [14.4, 15.5], PostgresqlPoint.new.z + end + + def test_schema_dumping + output = dump_table_schema("postgresql_points") + assert_match %r{t\.point\s+"x"$}, output + assert_match %r{t\.point\s+"y",\s+default: \[12\.2, 13\.3\]$}, output + assert_match %r{t\.point\s+"z",\s+default: \[14\.4, 15\.5\]$}, output + end + + def test_roundtrip + PostgresqlPoint.create! x: [10, 25.2] + record = PostgresqlPoint.first + assert_equal [10, 25.2], record.x + + record.x = [1.1, 2.2] + record.save! + assert record.reload + assert_equal [1.1, 2.2], record.x + end + + def test_mutation + p = PostgresqlPoint.create! x: [10, 20] + + p.x[1] = 25 + p.save! + p.reload + + assert_equal [10.0, 25.0], p.x + assert_not p.changed? + end +end diff --git a/activerecord/test/cases/adapters/postgresql/hstore_test.rb b/activerecord/test/cases/adapters/postgresql/hstore_test.rb new file mode 100644 index 0000000000..1296eb72c0 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/hstore_test.rb @@ -0,0 +1,349 @@ +# encoding: utf-8 + +require "cases/helper" +require 'active_record/base' +require 'active_record/connection_adapters/postgresql_adapter' + +class PostgresqlHstoreTest < ActiveRecord::TestCase + class Hstore < ActiveRecord::Base + self.table_name = 'hstores' + + store_accessor :settings, :language, :timezone + end + + def setup + @connection = ActiveRecord::Base.connection + + unless @connection.extension_enabled?('hstore') + @connection.enable_extension 'hstore' + @connection.commit_db_transaction + end + + @connection.reconnect! + + @connection.transaction do + @connection.create_table('hstores') do |t| + t.hstore 'tags', :default => '' + t.hstore 'payload', array: true + t.hstore 'settings' + end + end + @column = Hstore.columns_hash['tags'] + end + + teardown do + @connection.execute 'drop table if exists hstores' + end + + if ActiveRecord::Base.connection.supports_extensions? + def test_hstore_included_in_extensions + assert @connection.respond_to?(:extensions), "connection should have a list of extensions" + assert @connection.extensions.include?('hstore'), "extension list should include hstore" + end + + def test_disable_enable_hstore + assert @connection.extension_enabled?('hstore') + @connection.disable_extension 'hstore' + assert_not @connection.extension_enabled?('hstore') + @connection.enable_extension 'hstore' + assert @connection.extension_enabled?('hstore') + ensure + # Restore column(s) dropped by `drop extension hstore cascade;` + load_schema + end + + def test_column + assert_equal :hstore, @column.type + assert_equal "hstore", @column.sql_type + assert_not @column.number? + assert_not @column.binary? + assert_not @column.array + end + + def test_default + @connection.add_column 'hstores', 'permissions', :hstore, default: '"users"=>"read", "articles"=>"write"' + Hstore.reset_column_information + + assert_equal({"users"=>"read", "articles"=>"write"}, Hstore.column_defaults['permissions']) + assert_equal({"users"=>"read", "articles"=>"write"}, Hstore.new.permissions) + ensure + Hstore.reset_column_information + end + + def test_change_table_supports_hstore + @connection.transaction do + @connection.change_table('hstores') do |t| + t.hstore 'users', default: '' + end + Hstore.reset_column_information + column = Hstore.columns_hash['users'] + assert_equal :hstore, column.type + + raise ActiveRecord::Rollback # reset the schema change + end + ensure + Hstore.reset_column_information + end + + def test_hstore_migration + hstore_migration = Class.new(ActiveRecord::Migration) do + def change + change_table("hstores") do |t| + t.hstore :keys + end + end + end + + hstore_migration.new.suppress_messages do + hstore_migration.migrate(:up) + assert_includes @connection.columns(:hstores).map(&:name), "keys" + hstore_migration.migrate(:down) + assert_not_includes @connection.columns(:hstores).map(&:name), "keys" + end + end + + def test_cast_value_on_write + x = Hstore.new tags: {"bool" => true, "number" => 5} + assert_equal({"bool" => true, "number" => 5}, x.tags_before_type_cast) + assert_equal({"bool" => "true", "number" => "5"}, x.tags) + x.save + assert_equal({"bool" => "true", "number" => "5"}, x.reload.tags) + end + + def test_type_cast_hstore + assert_equal({'1' => '2'}, @column.type_cast_from_database("\"1\"=>\"2\"")) + assert_equal({}, @column.type_cast_from_database("")) + assert_equal({'key'=>nil}, @column.type_cast_from_database('key => NULL')) + assert_equal({'c'=>'}','"a"'=>'b "a b'}, @column.type_cast_from_database(%q(c=>"}", "\"a\""=>"b \"a b"))) + end + + def test_with_store_accessors + x = Hstore.new(language: "fr", timezone: "GMT") + assert_equal "fr", x.language + assert_equal "GMT", x.timezone + + x.save! + x = Hstore.first + assert_equal "fr", x.language + assert_equal "GMT", x.timezone + + x.language = "de" + x.save! + + x = Hstore.first + assert_equal "de", x.language + assert_equal "GMT", x.timezone + end + + def test_duplication_with_store_accessors + x = Hstore.new(language: "fr", timezone: "GMT") + assert_equal "fr", x.language + assert_equal "GMT", x.timezone + + y = x.dup + assert_equal "fr", y.language + assert_equal "GMT", y.timezone + end + + def test_yaml_round_trip_with_store_accessors + x = Hstore.new(language: "fr", timezone: "GMT") + assert_equal "fr", x.language + assert_equal "GMT", x.timezone + + y = YAML.load(YAML.dump(x)) + assert_equal "fr", y.language + assert_equal "GMT", y.timezone + end + + def test_changes_in_place + hstore = Hstore.create!(settings: { 'one' => 'two' }) + hstore.settings['three'] = 'four' + hstore.save! + hstore.reload + + assert_equal 'four', hstore.settings['three'] + assert_not hstore.changed? + end + + def test_gen1 + assert_equal(%q(" "=>""), @column.cast_type.type_cast_for_database({' '=>''})) + end + + def test_gen2 + assert_equal(%q(","=>""), @column.cast_type.type_cast_for_database({','=>''})) + end + + def test_gen3 + assert_equal(%q("="=>""), @column.cast_type.type_cast_for_database({'='=>''})) + end + + def test_gen4 + assert_equal(%q(">"=>""), @column.cast_type.type_cast_for_database({'>'=>''})) + end + + def test_parse1 + assert_equal({'a'=>nil,'b'=>nil,'c'=>'NuLl','null'=>'c'}, @column.type_cast_from_database('a=>null,b=>NuLl,c=>"NuLl",null=>c')) + end + + def test_parse2 + assert_equal({" " => " "}, @column.type_cast_from_database("\\ =>\\ ")) + end + + def test_parse3 + assert_equal({"=" => ">"}, @column.type_cast_from_database("==>>")) + end + + def test_parse4 + assert_equal({"=a"=>"q=w"}, @column.type_cast_from_database('\=a=>q=w')) + end + + def test_parse5 + assert_equal({"=a"=>"q=w"}, @column.type_cast_from_database('"=a"=>q\=w')) + end + + def test_parse6 + assert_equal({"\"a"=>"q>w"}, @column.type_cast_from_database('"\"a"=>q>w')) + end + + def test_parse7 + assert_equal({"\"a"=>"q\"w"}, @column.type_cast_from_database('\"a=>q"w')) + end + + def test_rewrite + @connection.execute "insert into hstores (tags) VALUES ('1=>2')" + x = Hstore.first + x.tags = { '"a\'' => 'b' } + assert x.save! + end + + def test_select + @connection.execute "insert into hstores (tags) VALUES ('1=>2')" + x = Hstore.first + assert_equal({'1' => '2'}, x.tags) + end + + def test_array_cycle + assert_array_cycle([{"AA" => "BB", "CC" => "DD"}, {"AA" => nil}]) + end + + def test_array_strings_with_quotes + assert_array_cycle([{'this has' => 'some "s that need to be escaped"'}]) + end + + def test_array_strings_with_commas + assert_array_cycle([{'this,has' => 'many,values'}]) + end + + def test_array_strings_with_array_delimiters + assert_array_cycle(['{' => '}']) + end + + def test_array_strings_with_null_strings + assert_array_cycle([{'NULL' => 'NULL'}]) + end + + def test_contains_nils + assert_array_cycle([{'NULL' => nil}]) + end + + def test_select_multikey + @connection.execute "insert into hstores (tags) VALUES ('1=>2,2=>3')" + x = Hstore.first + assert_equal({'1' => '2', '2' => '3'}, x.tags) + end + + def test_create + assert_cycle('a' => 'b', '1' => '2') + end + + def test_nil + assert_cycle('a' => nil) + end + + def test_quotes + assert_cycle('a' => 'b"ar', '1"foo' => '2') + end + + def test_whitespace + assert_cycle('a b' => 'b ar', '1"foo' => '2') + end + + def test_backslash + assert_cycle('a\\b' => 'b\\ar', '1"foo' => '2') + end + + def test_comma + assert_cycle('a, b' => 'bar', '1"foo' => '2') + end + + def test_arrow + assert_cycle('a=>b' => 'bar', '1"foo' => '2') + end + + def test_quoting_special_characters + assert_cycle('ca' => 'cà', 'ac' => 'àc') + end + + def test_multiline + assert_cycle("a\nb" => "c\nd") + end + + class TagCollection + def initialize(hash); @hash = hash end + def to_hash; @hash end + def self.load(hash); new(hash) end + def self.dump(object); object.to_hash end + end + + class HstoreWithSerialize < Hstore + serialize :tags, TagCollection + end + + def test_hstore_with_serialized_attributes + HstoreWithSerialize.create! tags: TagCollection.new({"one" => "two"}) + record = HstoreWithSerialize.first + assert_instance_of TagCollection, record.tags + assert_equal({"one" => "two"}, record.tags.to_hash) + record.tags = TagCollection.new("three" => "four") + record.save! + assert_equal({"three" => "four"}, HstoreWithSerialize.first.tags.to_hash) + end + + def test_clone_hstore_with_serialized_attributes + HstoreWithSerialize.create! tags: TagCollection.new({"one" => "two"}) + record = HstoreWithSerialize.first + dupe = record.dup + assert_equal({"one" => "two"}, dupe.tags.to_hash) + end + end + + private + + def assert_array_cycle(array) + # test creation + x = Hstore.create!(payload: array) + x.reload + assert_equal(array, x.payload) + + # test updating + x = Hstore.create!(payload: []) + x.payload = array + x.save! + x.reload + assert_equal(array, x.payload) + end + + def assert_cycle(hash) + # test creation + x = Hstore.create!(:tags => hash) + x.reload + assert_equal(hash, x.tags) + + # test updating + x = Hstore.create!(:tags => {}) + x.tags = hash + x.save! + x.reload + assert_equal(hash, x.tags) + end +end diff --git a/activerecord/test/cases/adapters/postgresql/infinity_test.rb b/activerecord/test/cases/adapters/postgresql/infinity_test.rb new file mode 100644 index 0000000000..22e8873333 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/infinity_test.rb @@ -0,0 +1,44 @@ +require "cases/helper" + +class PostgresqlInfinityTest < ActiveRecord::TestCase + class PostgresqlInfinity < ActiveRecord::Base + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table(:postgresql_infinities) do |t| + t.float :float + t.datetime :datetime + end + end + + teardown do + @connection.execute("DROP TABLE IF EXISTS postgresql_infinities") + end + + test "type casting infinity on a float column" do + record = PostgresqlInfinity.create!(float: Float::INFINITY) + record.reload + assert_equal Float::INFINITY, record.float + end + + test "update_all with infinity on a float column" do + record = PostgresqlInfinity.create! + PostgresqlInfinity.update_all(float: Float::INFINITY) + record.reload + assert_equal Float::INFINITY, record.float + end + + test "type casting infinity on a datetime column" do + record = PostgresqlInfinity.create!(datetime: Float::INFINITY) + record.reload + assert_equal Float::INFINITY, record.datetime + end + + test "update_all with infinity on a datetime column" do + record = PostgresqlInfinity.create! + PostgresqlInfinity.update_all(datetime: Float::INFINITY) + record.reload + assert_equal Float::INFINITY, record.datetime + end +end diff --git a/activerecord/test/cases/adapters/postgresql/json_test.rb b/activerecord/test/cases/adapters/postgresql/json_test.rb new file mode 100644 index 0000000000..86ba849445 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/json_test.rb @@ -0,0 +1,193 @@ +# encoding: utf-8 + +require "cases/helper" +require 'active_record/base' +require 'active_record/connection_adapters/postgresql_adapter' + +module PostgresqlJSONSharedTestCases + class JsonDataType < ActiveRecord::Base + self.table_name = 'json_data_type' + + store_accessor :settings, :resolution + end + + def setup + @connection = ActiveRecord::Base.connection + begin + @connection.transaction do + @connection.create_table('json_data_type') do |t| + t.public_send column_type, 'payload', default: {} # t.json 'payload', default: {} + t.public_send column_type, 'settings' # t.json 'settings' + end + end + rescue ActiveRecord::StatementInvalid + skip "do not test on PG without json" + end + @column = JsonDataType.columns_hash['payload'] + end + + def teardown + @connection.execute 'drop table if exists json_data_type' + end + + def test_column + column = JsonDataType.columns_hash["payload"] + assert_equal column_type, column.type + assert_equal column_type.to_s, column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_default + @connection.add_column 'json_data_type', 'permissions', column_type, default: '{"users": "read", "posts": ["read", "write"]}' + JsonDataType.reset_column_information + + assert_equal({"users"=>"read", "posts"=>["read", "write"]}, JsonDataType.column_defaults['permissions']) + assert_equal({"users"=>"read", "posts"=>["read", "write"]}, JsonDataType.new.permissions) + ensure + JsonDataType.reset_column_information + end + + def test_change_table_supports_json + @connection.transaction do + @connection.change_table('json_data_type') do |t| + t.public_send column_type, 'users', default: '{}' # t.json 'users', default: '{}' + end + JsonDataType.reset_column_information + column = JsonDataType.columns_hash['users'] + assert_equal column_type, column.type + + raise ActiveRecord::Rollback # reset the schema change + end + ensure + JsonDataType.reset_column_information + end + + def test_cast_value_on_write + x = JsonDataType.new payload: {"string" => "foo", :symbol => :bar} + assert_equal({"string" => "foo", :symbol => :bar}, x.payload_before_type_cast) + assert_equal({"string" => "foo", "symbol" => "bar"}, x.payload) + x.save + assert_equal({"string" => "foo", "symbol" => "bar"}, x.reload.payload) + end + + def test_type_cast_json + column = JsonDataType.columns_hash["payload"] + + data = "{\"a_key\":\"a_value\"}" + hash = column.type_cast_from_database(data) + assert_equal({'a_key' => 'a_value'}, hash) + assert_equal({'a_key' => 'a_value'}, column.type_cast_from_database(data)) + + assert_equal({}, column.type_cast_from_database("{}")) + assert_equal({'key'=>nil}, column.type_cast_from_database('{"key": null}')) + assert_equal({'c'=>'}','"a"'=>'b "a b'}, column.type_cast_from_database(%q({"c":"}", "\"a\"":"b \"a b"}))) + end + + def test_rewrite + @connection.execute "insert into json_data_type (payload) VALUES ('{\"k\":\"v\"}')" + x = JsonDataType.first + x.payload = { '"a\'' => 'b' } + assert x.save! + end + + def test_select + @connection.execute "insert into json_data_type (payload) VALUES ('{\"k\":\"v\"}')" + x = JsonDataType.first + assert_equal({'k' => 'v'}, x.payload) + end + + def test_select_multikey + @connection.execute %q|insert into json_data_type (payload) VALUES ('{"k1":"v1", "k2":"v2", "k3":[1,2,3]}')| + x = JsonDataType.first + assert_equal({'k1' => 'v1', 'k2' => 'v2', 'k3' => [1,2,3]}, x.payload) + end + + def test_null_json + @connection.execute %q|insert into json_data_type (payload) VALUES(null)| + x = JsonDataType.first + assert_equal(nil, x.payload) + end + + def test_select_array_json_value + @connection.execute %q|insert into json_data_type (payload) VALUES ('["v0",{"k1":"v1"}]')| + x = JsonDataType.first + assert_equal(['v0', {'k1' => 'v1'}], x.payload) + end + + def test_rewrite_array_json_value + @connection.execute %q|insert into json_data_type (payload) VALUES ('["v0",{"k1":"v1"}]')| + x = JsonDataType.first + x.payload = ['v1', {'k2' => 'v2'}, 'v3'] + assert x.save! + end + + def test_with_store_accessors + x = JsonDataType.new(resolution: "320×480") + assert_equal "320×480", x.resolution + + x.save! + x = JsonDataType.first + assert_equal "320×480", x.resolution + + x.resolution = "640×1136" + x.save! + + x = JsonDataType.first + assert_equal "640×1136", x.resolution + end + + def test_duplication_with_store_accessors + x = JsonDataType.new(resolution: "320×480") + assert_equal "320×480", x.resolution + + y = x.dup + assert_equal "320×480", y.resolution + end + + def test_yaml_round_trip_with_store_accessors + x = JsonDataType.new(resolution: "320×480") + assert_equal "320×480", x.resolution + + y = YAML.load(YAML.dump(x)) + assert_equal "320×480", y.resolution + end + + def test_changes_in_place + json = JsonDataType.new + assert_not json.changed? + + json.payload = { 'one' => 'two' } + assert json.changed? + assert json.payload_changed? + + json.save! + assert_not json.changed? + + json.payload['three'] = 'four' + assert json.payload_changed? + + json.save! + json.reload + + assert_equal({ 'one' => 'two', 'three' => 'four' }, json.payload) + assert_not json.changed? + end +end + +class PostgresqlJSONTest < ActiveRecord::TestCase + include PostgresqlJSONSharedTestCases + + def column_type + :json + end +end + +class PostgresqlJSONBTest < ActiveRecord::TestCase + include PostgresqlJSONSharedTestCases + + def column_type + :jsonb + end +end diff --git a/activerecord/test/cases/adapters/postgresql/ltree_test.rb b/activerecord/test/cases/adapters/postgresql/ltree_test.rb new file mode 100644 index 0000000000..889e369bd6 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/ltree_test.rb @@ -0,0 +1,48 @@ +# encoding: utf-8 +require "cases/helper" + +class PostgresqlLtreeTest < ActiveRecord::TestCase + class Ltree < ActiveRecord::Base + self.table_name = 'ltrees' + end + + def setup + @connection = ActiveRecord::Base.connection + + unless @connection.extension_enabled?('ltree') + @connection.enable_extension 'ltree' + end + + @connection.transaction do + @connection.create_table('ltrees') do |t| + t.ltree 'path' + end + end + rescue ActiveRecord::StatementInvalid + skip "do not test on PG without ltree" + end + + teardown do + @connection.execute 'drop table if exists ltrees' + end + + def test_column + column = Ltree.columns_hash['path'] + assert_equal :ltree, column.type + assert_equal "ltree", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_write + ltree = Ltree.new(path: '1.2.3.4') + assert ltree.save! + end + + def test_select + @connection.execute "insert into ltrees (path) VALUES ('1.2.3')" + ltree = Ltree.first + assert_equal '1.2.3', ltree.path + end +end diff --git a/activerecord/test/cases/adapters/postgresql/money_test.rb b/activerecord/test/cases/adapters/postgresql/money_test.rb new file mode 100644 index 0000000000..87183174f2 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/money_test.rb @@ -0,0 +1,96 @@ +# encoding: utf-8 +require "cases/helper" +require 'support/schema_dumping_helper' + +class PostgresqlMoneyTest < ActiveRecord::TestCase + include SchemaDumpingHelper + + class PostgresqlMoney < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + @connection.execute("set lc_monetary = 'C'") + @connection.create_table('postgresql_moneys') do |t| + t.column "wealth", "money" + t.column "depth", "money", default: "150.55" + end + end + + teardown do + @connection.execute 'DROP TABLE IF EXISTS postgresql_moneys' + end + + def test_column + column = PostgresqlMoney.columns_hash["wealth"] + assert_equal :money, column.type + assert_equal "money", column.sql_type + assert_equal 2, column.scale + assert column.number? + assert_not column.binary? + assert_not column.array + end + + def test_default + assert_equal BigDecimal.new("150.55"), PostgresqlMoney.column_defaults['depth'] + assert_equal BigDecimal.new("150.55"), PostgresqlMoney.new.depth + end + + def test_money_values + @connection.execute("INSERT INTO postgresql_moneys (id, wealth) VALUES (1, '567.89'::money)") + @connection.execute("INSERT INTO postgresql_moneys (id, wealth) VALUES (2, '-567.89'::money)") + + first_money = PostgresqlMoney.find(1) + second_money = PostgresqlMoney.find(2) + assert_equal 567.89, first_money.wealth + assert_equal(-567.89, second_money.wealth) + end + + def test_money_type_cast + column = PostgresqlMoney.columns_hash['wealth'] + assert_equal(12345678.12, column.type_cast_from_user("$12,345,678.12")) + assert_equal(12345678.12, column.type_cast_from_user("$12.345.678,12")) + assert_equal(-1.15, column.type_cast_from_user("-$1.15")) + assert_equal(-2.25, column.type_cast_from_user("($2.25)")) + end + + def test_schema_dumping + output = dump_table_schema("postgresql_moneys") + assert_match %r{t\.money\s+"wealth",\s+scale: 2$}, output + assert_match %r{t\.money\s+"depth",\s+scale: 2,\s+default: 150.55$}, output + end + + def test_create_and_update_money + money = PostgresqlMoney.create(wealth: "987.65") + assert_equal 987.65, money.wealth + + new_value = BigDecimal.new('123.45') + money.wealth = new_value + money.save! + money.reload + assert_equal new_value, money.wealth + end + + def test_update_all_with_money_string + money = PostgresqlMoney.create! + PostgresqlMoney.update_all(wealth: "987.65") + money.reload + + assert_equal 987.65, money.wealth + end + + def test_update_all_with_money_big_decimal + money = PostgresqlMoney.create! + PostgresqlMoney.update_all(wealth: '123.45'.to_d) + money.reload + + assert_equal 123.45, money.wealth + end + + def test_update_all_with_money_numeric + money = PostgresqlMoney.create! + PostgresqlMoney.update_all(wealth: 123.45) + money.reload + + assert_equal 123.45, money.wealth + end +end diff --git a/activerecord/test/cases/adapters/postgresql/network_test.rb b/activerecord/test/cases/adapters/postgresql/network_test.rb new file mode 100644 index 0000000000..4f4c1103fa --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/network_test.rb @@ -0,0 +1,71 @@ +# encoding: utf-8 +require "cases/helper" + +class PostgresqlNetworkTest < ActiveRecord::TestCase + class PostgresqlNetworkAddress < ActiveRecord::Base + end + + def test_cidr_column + column = PostgresqlNetworkAddress.columns_hash["cidr_address"] + assert_equal :cidr, column.type + assert_equal "cidr", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_inet_column + column = PostgresqlNetworkAddress.columns_hash["inet_address"] + assert_equal :inet, column.type + assert_equal "inet", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_macaddr_column + column = PostgresqlNetworkAddress.columns_hash["mac_address"] + assert_equal :macaddr, column.type + assert_equal "macaddr", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_network_types + PostgresqlNetworkAddress.create(cidr_address: '192.168.0.0/24', + inet_address: '172.16.1.254/32', + mac_address: '01:23:45:67:89:0a') + + address = PostgresqlNetworkAddress.first + assert_equal IPAddr.new('192.168.0.0/24'), address.cidr_address + assert_equal IPAddr.new('172.16.1.254'), address.inet_address + assert_equal '01:23:45:67:89:0a', address.mac_address + + address.cidr_address = '10.1.2.3/32' + address.inet_address = '10.0.0.0/8' + address.mac_address = 'bc:de:f0:12:34:56' + + address.save! + assert address.reload + assert_equal IPAddr.new('10.1.2.3/32'), address.cidr_address + assert_equal IPAddr.new('10.0.0.0/8'), address.inet_address + assert_equal 'bc:de:f0:12:34:56', address.mac_address + end + + def test_invalid_network_address + invalid_address = PostgresqlNetworkAddress.new(cidr_address: 'invalid addr', + inet_address: 'invalid addr') + assert_nil invalid_address.cidr_address + assert_nil invalid_address.inet_address + assert_equal 'invalid addr', invalid_address.cidr_address_before_type_cast + assert_equal 'invalid addr', invalid_address.inet_address_before_type_cast + assert invalid_address.save + + invalid_address.reload + assert_nil invalid_address.cidr_address + assert_nil invalid_address.inet_address + assert_nil invalid_address.cidr_address_before_type_cast + assert_nil invalid_address.inet_address_before_type_cast + end +end diff --git a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb new file mode 100644 index 0000000000..cfff1f980b --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb @@ -0,0 +1,451 @@ +# encoding: utf-8 +require "cases/helper" +require 'support/ddl_helper' +require 'support/connection_helper' + +module ActiveRecord + module ConnectionAdapters + class PostgreSQLAdapterTest < ActiveRecord::TestCase + include DdlHelper + include ConnectionHelper + + def setup + @connection = ActiveRecord::Base.connection + end + + def test_bad_connection + assert_raise ActiveRecord::NoDatabaseError do + configuration = ActiveRecord::Base.configurations['arunit'].merge(database: 'should_not_exist-cinco-dog-db') + connection = ActiveRecord::Base.postgresql_connection(configuration) + connection.exec_query('SELECT 1') + end + end + + def test_valid_column + with_example_table do + column = @connection.columns('ex').find { |col| col.name == 'id' } + assert @connection.valid_type?(column.type) + end + end + + def test_invalid_column + assert_not @connection.valid_type?(:foobar) + end + + def test_primary_key + with_example_table do + assert_equal 'id', @connection.primary_key('ex') + end + end + + def test_primary_key_works_tables_containing_capital_letters + assert_equal 'id', @connection.primary_key('CamelCase') + end + + def test_non_standard_primary_key + with_example_table 'data character varying(255) primary key' do + assert_equal 'data', @connection.primary_key('ex') + end + end + + def test_primary_key_returns_nil_for_no_pk + with_example_table 'id integer' do + assert_nil @connection.primary_key('ex') + end + end + + def test_primary_key_raises_error_if_table_not_found + assert_raises(ActiveRecord::StatementInvalid) do + @connection.primary_key('unobtainium') + end + end + + def test_insert_sql_with_proprietary_returning_clause + with_example_table do + id = @connection.insert_sql("insert into ex (number) values(5150)", nil, "number") + assert_equal "5150", id + end + end + + def test_insert_sql_with_quoted_schema_and_table_name + with_example_table do + id = @connection.insert_sql('insert into "public"."ex" (number) values(5150)') + expect = @connection.query('select max(id) from ex').first.first + assert_equal expect, id + end + end + + def test_insert_sql_with_no_space_after_table_name + with_example_table do + id = @connection.insert_sql("insert into ex(number) values(5150)") + expect = @connection.query('select max(id) from ex').first.first + assert_equal expect, id + end + end + + def test_multiline_insert_sql + with_example_table do + id = @connection.insert_sql(<<-SQL) + insert into ex( + number) + values( + 5152 + ) + SQL + expect = @connection.query('select max(id) from ex').first.first + assert_equal expect, id + end + end + + def test_insert_sql_with_returning_disabled + connection = connection_without_insert_returning + id = connection.insert_sql("insert into postgresql_partitioned_table_parent (number) VALUES (1)") + expect = connection.query('select max(id) from postgresql_partitioned_table_parent').first.first + assert_equal expect, id + end + + def test_exec_insert_with_returning_disabled + connection = connection_without_insert_returning + result = connection.exec_insert("insert into postgresql_partitioned_table_parent (number) VALUES (1)", nil, [], 'id', 'postgresql_partitioned_table_parent_id_seq') + expect = connection.query('select max(id) from postgresql_partitioned_table_parent').first.first + assert_equal expect, result.rows.first.first + end + + def test_exec_insert_with_returning_disabled_and_no_sequence_name_given + connection = connection_without_insert_returning + result = connection.exec_insert("insert into postgresql_partitioned_table_parent (number) VALUES (1)", nil, [], 'id') + expect = connection.query('select max(id) from postgresql_partitioned_table_parent').first.first + assert_equal expect, result.rows.first.first + end + + def test_sql_for_insert_with_returning_disabled + connection = connection_without_insert_returning + result = connection.sql_for_insert('sql', nil, nil, nil, 'binds') + assert_equal ['sql', 'binds'], result + end + + def test_serial_sequence + assert_equal 'public.accounts_id_seq', + @connection.serial_sequence('accounts', 'id') + + assert_raises(ActiveRecord::StatementInvalid) do + @connection.serial_sequence('zomg', 'id') + end + end + + def test_default_sequence_name + assert_equal PostgreSQL::Name.new('public', 'accounts_id_seq'), + @connection.default_sequence_name('accounts', 'id') + + assert_equal PostgreSQL::Name.new('public', 'accounts_id_seq'), + @connection.default_sequence_name('accounts') + end + + def test_default_sequence_name_bad_table + assert_equal PostgreSQL::Name.new(nil, 'zomg_id_seq'), + @connection.default_sequence_name('zomg', 'id') + + assert_equal PostgreSQL::Name.new(nil, 'zomg_id_seq'), + @connection.default_sequence_name('zomg') + end + + def test_pk_and_sequence_for + with_example_table do + pk, seq = @connection.pk_and_sequence_for('ex') + assert_equal 'id', pk + assert_equal @connection.default_sequence_name('ex', 'id'), seq + end + end + + def test_pk_and_sequence_for_with_non_standard_primary_key + with_example_table 'code serial primary key' do + pk, seq = @connection.pk_and_sequence_for('ex') + assert_equal 'code', pk + assert_equal @connection.default_sequence_name('ex', 'code'), seq + end + end + + def test_pk_and_sequence_for_returns_nil_if_no_seq + with_example_table 'id integer primary key' do + assert_nil @connection.pk_and_sequence_for('ex') + end + end + + def test_pk_and_sequence_for_returns_nil_if_no_pk + with_example_table 'id integer' do + assert_nil @connection.pk_and_sequence_for('ex') + end + end + + def test_pk_and_sequence_for_returns_nil_if_table_not_found + assert_nil @connection.pk_and_sequence_for('unobtainium') + end + + def test_pk_and_sequence_for_with_collision_pg_class_oid + @connection.exec_query('create table ex(id serial primary key)') + @connection.exec_query('create table ex2(id serial primary key)') + + correct_depend_record = [ + "'pg_class'::regclass", + "'ex_id_seq'::regclass", + '0', + "'pg_class'::regclass", + "'ex'::regclass", + '1', + "'a'" + ] + + collision_depend_record = [ + "'pg_attrdef'::regclass", + "'ex2_id_seq'::regclass", + '0', + "'pg_class'::regclass", + "'ex'::regclass", + '1', + "'a'" + ] + + @connection.exec_query( + "DELETE FROM pg_depend WHERE objid = 'ex_id_seq'::regclass AND refobjid = 'ex'::regclass AND deptype = 'a'" + ) + @connection.exec_query( + "INSERT INTO pg_depend VALUES(#{collision_depend_record.join(',')})" + ) + @connection.exec_query( + "INSERT INTO pg_depend VALUES(#{correct_depend_record.join(',')})" + ) + + seq = @connection.pk_and_sequence_for('ex').last + assert_equal PostgreSQL::Name.new("public", "ex_id_seq"), seq + + @connection.exec_query( + "DELETE FROM pg_depend WHERE objid = 'ex2_id_seq'::regclass AND refobjid = 'ex'::regclass AND deptype = 'a'" + ) + ensure + @connection.exec_query('DROP TABLE IF EXISTS ex') + @connection.exec_query('DROP TABLE IF EXISTS ex2') + end + + def test_exec_insert_number + with_example_table do + insert(@connection, 'number' => 10) + + result = @connection.exec_query('SELECT number FROM ex WHERE number = 10') + + assert_equal 1, result.rows.length + assert_equal "10", result.rows.last.last + end + end + + def test_exec_insert_string + with_example_table do + str = 'いただきます!' + insert(@connection, 'number' => 10, 'data' => str) + + result = @connection.exec_query('SELECT number, data FROM ex WHERE number = 10') + + value = result.rows.last.last + + assert_equal str, value + end + end + + def test_table_alias_length + assert_nothing_raised do + @connection.table_alias_length + end + end + + def test_exec_no_binds + with_example_table do + result = @connection.exec_query('SELECT id, data FROM ex') + assert_equal 0, result.rows.length + assert_equal 2, result.columns.length + assert_equal %w{ id data }, result.columns + + string = @connection.quote('foo') + @connection.exec_query("INSERT INTO ex (id, data) VALUES (1, #{string})") + result = @connection.exec_query('SELECT id, data FROM ex') + assert_equal 1, result.rows.length + assert_equal 2, result.columns.length + + assert_equal [['1', 'foo']], result.rows + end + end + + def test_exec_with_binds + with_example_table do + string = @connection.quote('foo') + @connection.exec_query("INSERT INTO ex (id, data) VALUES (1, #{string})") + result = @connection.exec_query( + 'SELECT id, data FROM ex WHERE id = $1', nil, [[nil, 1]]) + + assert_equal 1, result.rows.length + assert_equal 2, result.columns.length + + assert_equal [['1', 'foo']], result.rows + end + end + + def test_exec_typecasts_bind_vals + with_example_table do + string = @connection.quote('foo') + @connection.exec_query("INSERT INTO ex (id, data) VALUES (1, #{string})") + + column = @connection.columns('ex').find { |col| col.name == 'id' } + result = @connection.exec_query( + 'SELECT id, data FROM ex WHERE id = $1', nil, [[column, '1-fuu']]) + + assert_equal 1, result.rows.length + assert_equal 2, result.columns.length + + assert_equal [['1', 'foo']], result.rows + end + end + + def test_substitute_at + bind = @connection.substitute_at(nil, 0) + assert_equal Arel.sql('$1'), bind + + bind = @connection.substitute_at(nil, 1) + assert_equal Arel.sql('$2'), bind + end + + def test_partial_index + with_example_table do + @connection.add_index 'ex', %w{ id number }, :name => 'partial', :where => "number > 100" + index = @connection.indexes('ex').find { |idx| idx.name == 'partial' } + assert_equal "(number > 100)", index.where + end + end + + def test_columns_for_distinct_zero_orders + assert_equal "posts.id", + @connection.columns_for_distinct("posts.id", []) + end + + def test_columns_for_distinct_one_order + assert_equal "posts.id, posts.created_at AS alias_0", + @connection.columns_for_distinct("posts.id", ["posts.created_at desc"]) + end + + def test_columns_for_distinct_few_orders + assert_equal "posts.id, posts.created_at AS alias_0, posts.position AS alias_1", + @connection.columns_for_distinct("posts.id", ["posts.created_at desc", "posts.position asc"]) + end + + def test_columns_for_distinct_blank_not_nil_orders + assert_equal "posts.id, posts.created_at AS alias_0", + @connection.columns_for_distinct("posts.id", ["posts.created_at desc", "", " "]) + end + + def test_columns_for_distinct_with_arel_order + order = Object.new + def order.to_sql + "posts.created_at desc" + end + assert_equal "posts.id, posts.created_at AS alias_0", + @connection.columns_for_distinct("posts.id", [order]) + end + + def test_columns_for_distinct_with_nulls + assert_equal "posts.title, posts.updater_id AS alias_0", @connection.columns_for_distinct("posts.title", ["posts.updater_id desc nulls first"]) + assert_equal "posts.title, posts.updater_id AS alias_0", @connection.columns_for_distinct("posts.title", ["posts.updater_id desc nulls last"]) + end + + def test_columns_for_distinct_without_order_specifiers + assert_equal "posts.title, posts.updater_id AS alias_0", + @connection.columns_for_distinct("posts.title", ["posts.updater_id"]) + + assert_equal "posts.title, posts.updater_id AS alias_0", + @connection.columns_for_distinct("posts.title", ["posts.updater_id nulls last"]) + + assert_equal "posts.title, posts.updater_id AS alias_0", + @connection.columns_for_distinct("posts.title", ["posts.updater_id nulls first"]) + end + + def test_raise_error_when_cannot_translate_exception + assert_raise TypeError do + @connection.send(:log, nil) { @connection.execute(nil) } + end + end + + def test_reload_type_map_for_newly_defined_types + @connection.execute "CREATE TYPE feeling AS ENUM ('good', 'bad')" + result = @connection.select_all "SELECT 'good'::feeling" + assert_instance_of(PostgreSQLAdapter::OID::Enum, + result.column_types["feeling"]) + ensure + @connection.execute "DROP TYPE IF EXISTS feeling" + reset_connection + end + + def test_only_reload_type_map_once_for_every_unknown_type + silence_warnings do + assert_queries 2, ignore_none: true do + @connection.select_all "SELECT NULL::anyelement" + end + assert_queries 1, ignore_none: true do + @connection.select_all "SELECT NULL::anyelement" + end + assert_queries 2, ignore_none: true do + @connection.select_all "SELECT NULL::anyarray" + end + end + ensure + reset_connection + end + + def test_only_warn_on_first_encounter_of_unknown_oid + warning = capture(:stderr) { + @connection.select_all "SELECT NULL::anyelement" + @connection.select_all "SELECT NULL::anyelement" + @connection.select_all "SELECT NULL::anyelement" + } + assert_match(/\Aunknown OID \d+: failed to recognize type of 'anyelement'. It will be treated as String.\n\z/, warning) + ensure + reset_connection + end + + def test_unparsed_defaults_are_at_least_set_when_saving + with_example_table "id SERIAL PRIMARY KEY, number INTEGER NOT NULL DEFAULT (4 + 4) * 2 / 4" do + number_klass = Class.new(ActiveRecord::Base) do + self.table_name = 'ex' + end + column = number_klass.columns_hash["number"] + assert_nil column.default + assert_nil column.default_function + + first_number = number_klass.new + assert_nil first_number.number + + first_number.save! + assert_equal 4, first_number.reload.number + end + end + + private + def insert(ctx, data) + binds = data.map { |name, value| + [ctx.columns('ex').find { |x| x.name == name }, value] + } + columns = binds.map(&:first).map(&:name) + + bind_subs = columns.length.times.map { |x| "$#{x + 1}" } + + sql = "INSERT INTO ex (#{columns.join(", ")}) + VALUES (#{bind_subs.join(', ')})" + + ctx.exec_insert(sql, 'SQL', binds) + end + + def with_example_table(definition = 'id serial primary key, number integer, data character varying(255)', &block) + super(@connection, 'ex', definition, &block) + end + + def connection_without_insert_returning + ActiveRecord::Base.postgresql_connection(ActiveRecord::Base.configurations['arunit'].merge(:insert_returning => false)) + end + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/quoting_test.rb b/activerecord/test/cases/adapters/postgresql/quoting_test.rb new file mode 100644 index 0000000000..11d5173d37 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/quoting_test.rb @@ -0,0 +1,74 @@ +require "cases/helper" +require 'ipaddr' + +module ActiveRecord + module ConnectionAdapters + class PostgreSQLAdapter + class QuotingTest < ActiveRecord::TestCase + def setup + @conn = ActiveRecord::Base.connection + end + + def test_type_cast_true + c = PostgreSQLColumn.new(nil, 1, Type::Boolean.new, 'boolean') + assert_equal 't', @conn.type_cast(true, nil) + assert_equal 't', @conn.type_cast(true, c) + end + + def test_type_cast_false + c = PostgreSQLColumn.new(nil, 1, Type::Boolean.new, 'boolean') + assert_equal 'f', @conn.type_cast(false, nil) + assert_equal 'f', @conn.type_cast(false, c) + end + + def test_type_cast_cidr + ip = IPAddr.new('255.0.0.0/8') + c = PostgreSQLColumn.new(nil, ip, OID::Cidr.new, 'cidr') + assert_equal ip, @conn.type_cast(ip, c) + end + + def test_type_cast_inet + ip = IPAddr.new('255.1.0.0/8') + c = PostgreSQLColumn.new(nil, ip, OID::Cidr.new, 'inet') + assert_equal ip, @conn.type_cast(ip, c) + end + + def test_quote_float_nan + nan = 0.0/0 + c = PostgreSQLColumn.new(nil, 1, OID::Float.new, 'float') + assert_equal "'NaN'", @conn.quote(nan, c) + end + + def test_quote_float_infinity + infinity = 1.0/0 + c = PostgreSQLColumn.new(nil, 1, OID::Float.new, 'float') + assert_equal "'Infinity'", @conn.quote(infinity, c) + end + + def test_quote_cast_numeric + fixnum = 666 + c = PostgreSQLColumn.new(nil, nil, Type::String.new, 'varchar') + assert_equal "'666'", @conn.quote(fixnum, c) + c = PostgreSQLColumn.new(nil, nil, Type::Text.new, 'text') + assert_equal "'666'", @conn.quote(fixnum, c) + end + + def test_quote_time_usec + assert_equal "'1970-01-01 00:00:00.000000'", @conn.quote(Time.at(0)) + assert_equal "'1970-01-01 00:00:00.000000'", @conn.quote(Time.at(0).to_datetime) + end + + def test_quote_range + range = "1,2]'; SELECT * FROM users; --".."a" + c = PostgreSQLColumn.new(nil, nil, OID::Range.new(Type::Integer.new, :int8range)) + assert_equal "'[1,0]'", @conn.quote(range, c) + end + + def test_quote_bit_string + c = PostgreSQLColumn.new(nil, 1, OID::Bit.new) + assert_equal nil, @conn.quote("'); SELECT * FROM users; /*\n01\n*/--", c) + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/range_test.rb b/activerecord/test/cases/adapters/postgresql/range_test.rb new file mode 100644 index 0000000000..d812cd01c4 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/range_test.rb @@ -0,0 +1,323 @@ +require "cases/helper" +require 'support/connection_helper' + +if ActiveRecord::Base.connection.supports_ranges? + class PostgresqlRange < ActiveRecord::Base + self.table_name = "postgresql_ranges" + end + + class PostgresqlRangeTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + include ConnectionHelper + + def setup + @connection = PostgresqlRange.connection + begin + @connection.transaction do + @connection.execute <<_SQL + CREATE TYPE floatrange AS RANGE ( + subtype = float8, + subtype_diff = float8mi + ); +_SQL + + @connection.create_table('postgresql_ranges') do |t| + t.daterange :date_range + t.numrange :num_range + t.tsrange :ts_range + t.tstzrange :tstz_range + t.int4range :int4_range + t.int8range :int8_range + end + + @connection.add_column 'postgresql_ranges', 'float_range', 'floatrange' + end + PostgresqlRange.reset_column_information + rescue ActiveRecord::StatementInvalid + skip "do not test on PG without range" + end + + insert_range(id: 101, + date_range: "[''2012-01-02'', ''2012-01-04'']", + num_range: "[0.1, 0.2]", + ts_range: "[''2010-01-01 14:30'', ''2011-01-01 14:30'']", + tstz_range: "[''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'']", + int4_range: "[1, 10]", + int8_range: "[10, 100]", + float_range: "[0.5, 0.7]") + + insert_range(id: 102, + date_range: "[''2012-01-02'', ''2012-01-04'')", + num_range: "[0.1, 0.2)", + ts_range: "[''2010-01-01 14:30'', ''2011-01-01 14:30'')", + tstz_range: "[''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'')", + int4_range: "[1, 10)", + int8_range: "[10, 100)", + float_range: "[0.5, 0.7)") + + insert_range(id: 103, + date_range: "[''2012-01-02'',]", + num_range: "[0.1,]", + ts_range: "[''2010-01-01 14:30'',]", + tstz_range: "[''2010-01-01 14:30:00+05'',]", + int4_range: "[1,]", + int8_range: "[10,]", + float_range: "[0.5,]") + + insert_range(id: 104, + date_range: "[,]", + num_range: "[,]", + ts_range: "[,]", + tstz_range: "[,]", + int4_range: "[,]", + int8_range: "[,]", + float_range: "[,]") + + insert_range(id: 105, + date_range: "[''2012-01-02'', ''2012-01-02'')", + num_range: "[0.1, 0.1)", + ts_range: "[''2010-01-01 14:30'', ''2010-01-01 14:30'')", + tstz_range: "[''2010-01-01 14:30:00+05'', ''2010-01-01 06:30:00-03'')", + int4_range: "[1, 1)", + int8_range: "[10, 10)", + float_range: "[0.5, 0.5)") + + @new_range = PostgresqlRange.new + @first_range = PostgresqlRange.find(101) + @second_range = PostgresqlRange.find(102) + @third_range = PostgresqlRange.find(103) + @fourth_range = PostgresqlRange.find(104) + @empty_range = PostgresqlRange.find(105) + end + + teardown do + @connection.execute 'DROP TABLE IF EXISTS postgresql_ranges' + @connection.execute 'DROP TYPE IF EXISTS floatrange' + reset_connection + end + + def test_data_type_of_range_types + assert_equal :daterange, @first_range.column_for_attribute(:date_range).type + assert_equal :numrange, @first_range.column_for_attribute(:num_range).type + assert_equal :tsrange, @first_range.column_for_attribute(:ts_range).type + assert_equal :tstzrange, @first_range.column_for_attribute(:tstz_range).type + assert_equal :int4range, @first_range.column_for_attribute(:int4_range).type + assert_equal :int8range, @first_range.column_for_attribute(:int8_range).type + end + + def test_int4range_values + assert_equal 1...11, @first_range.int4_range + assert_equal 1...10, @second_range.int4_range + assert_equal 1...Float::INFINITY, @third_range.int4_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.int4_range) + assert_nil @empty_range.int4_range + end + + def test_int8range_values + assert_equal 10...101, @first_range.int8_range + assert_equal 10...100, @second_range.int8_range + assert_equal 10...Float::INFINITY, @third_range.int8_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.int8_range) + assert_nil @empty_range.int8_range + end + + def test_daterange_values + assert_equal Date.new(2012, 1, 2)...Date.new(2012, 1, 5), @first_range.date_range + assert_equal Date.new(2012, 1, 2)...Date.new(2012, 1, 4), @second_range.date_range + assert_equal Date.new(2012, 1, 2)...Float::INFINITY, @third_range.date_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.date_range) + assert_nil @empty_range.date_range + end + + def test_numrange_values + assert_equal BigDecimal.new('0.1')..BigDecimal.new('0.2'), @first_range.num_range + assert_equal BigDecimal.new('0.1')...BigDecimal.new('0.2'), @second_range.num_range + assert_equal BigDecimal.new('0.1')...BigDecimal.new('Infinity'), @third_range.num_range + assert_equal BigDecimal.new('-Infinity')...BigDecimal.new('Infinity'), @fourth_range.num_range + assert_nil @empty_range.num_range + end + + def test_tsrange_values + tz = ::ActiveRecord::Base.default_timezone + assert_equal Time.send(tz, 2010, 1, 1, 14, 30, 0)..Time.send(tz, 2011, 1, 1, 14, 30, 0), @first_range.ts_range + assert_equal Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 1, 1, 14, 30, 0), @second_range.ts_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.ts_range) + assert_nil @empty_range.ts_range + end + + def test_tstzrange_values + assert_equal Time.parse('2010-01-01 09:30:00 UTC')..Time.parse('2011-01-01 17:30:00 UTC'), @first_range.tstz_range + assert_equal Time.parse('2010-01-01 09:30:00 UTC')...Time.parse('2011-01-01 17:30:00 UTC'), @second_range.tstz_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.tstz_range) + assert_nil @empty_range.tstz_range + end + + def test_custom_range_values + assert_equal 0.5..0.7, @first_range.float_range + assert_equal 0.5...0.7, @second_range.float_range + assert_equal 0.5...Float::INFINITY, @third_range.float_range + assert_equal(-Float::INFINITY...Float::INFINITY, @fourth_range.float_range) + assert_nil @empty_range.float_range + end + + def test_create_tstzrange + tstzrange = Time.parse('2010-01-01 14:30:00 +0100')...Time.parse('2011-02-02 14:30:00 CDT') + round_trip(@new_range, :tstz_range, tstzrange) + assert_equal @new_range.tstz_range, tstzrange + assert_equal @new_range.tstz_range, Time.parse('2010-01-01 13:30:00 UTC')...Time.parse('2011-02-02 19:30:00 UTC') + end + + def test_update_tstzrange + assert_equal_round_trip(@first_range, :tstz_range, + Time.parse('2010-01-01 14:30:00 CDT')...Time.parse('2011-02-02 14:30:00 CET')) + assert_nil_round_trip(@first_range, :tstz_range, + Time.parse('2010-01-01 14:30:00 +0100')...Time.parse('2010-01-01 13:30:00 +0000')) + end + + def test_create_tsrange + tz = ::ActiveRecord::Base.default_timezone + assert_equal_round_trip(@new_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 2, 2, 14, 30, 0)) + end + + def test_update_tsrange + tz = ::ActiveRecord::Base.default_timezone + assert_equal_round_trip(@first_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2011, 2, 2, 14, 30, 0)) + assert_nil_round_trip(@first_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0)...Time.send(tz, 2010, 1, 1, 14, 30, 0)) + end + + def test_create_numrange + assert_equal_round_trip(@new_range, :num_range, + BigDecimal.new('0.5')...BigDecimal.new('1')) + end + + def test_update_numrange + assert_equal_round_trip(@first_range, :num_range, + BigDecimal.new('0.5')...BigDecimal.new('1')) + assert_nil_round_trip(@first_range, :num_range, + BigDecimal.new('0.5')...BigDecimal.new('0.5')) + end + + def test_create_daterange + assert_equal_round_trip(@new_range, :date_range, + Range.new(Date.new(2012, 1, 1), Date.new(2013, 1, 1), true)) + end + + def test_update_daterange + assert_equal_round_trip(@first_range, :date_range, + Date.new(2012, 2, 3)...Date.new(2012, 2, 10)) + assert_nil_round_trip(@first_range, :date_range, + Date.new(2012, 2, 3)...Date.new(2012, 2, 3)) + end + + def test_create_int4range + assert_equal_round_trip(@new_range, :int4_range, Range.new(3, 50, true)) + end + + def test_update_int4range + assert_equal_round_trip(@first_range, :int4_range, 6...10) + assert_nil_round_trip(@first_range, :int4_range, 3...3) + end + + def test_create_int8range + assert_equal_round_trip(@new_range, :int8_range, Range.new(30, 50, true)) + end + + def test_update_int8range + assert_equal_round_trip(@first_range, :int8_range, 60000...10000000) + assert_nil_round_trip(@first_range, :int8_range, 39999...39999) + end + + def test_exclude_beginning_for_subtypes_with_succ_method_is_deprecated + tz = ::ActiveRecord::Base.default_timezone + + silence_warnings { + assert_deprecated { + range = PostgresqlRange.create!(date_range: "(''2012-01-02'', ''2012-01-04'']") + assert_equal Date.new(2012, 1, 3)..Date.new(2012, 1, 4), range.date_range + } + assert_deprecated { + range = PostgresqlRange.create!(ts_range: "(''2010-01-01 14:30'', ''2011-01-01 14:30'']") + assert_equal Time.send(tz, 2010, 1, 1, 14, 30, 1)..Time.send(tz, 2011, 1, 1, 14, 30, 0), range.ts_range + } + assert_deprecated { + range = PostgresqlRange.create!(tstz_range: "(''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'']") + assert_equal Time.parse('2010-01-01 09:30:01 UTC')..Time.parse('2011-01-01 17:30:00 UTC'), range.tstz_range + } + assert_deprecated { + range = PostgresqlRange.create!(int4_range: "(1, 10]") + assert_equal 2..10, range.int4_range + } + assert_deprecated { + range = PostgresqlRange.create!(int8_range: "(10, 100]") + assert_equal 11..100, range.int8_range + } + } + end + + def test_exclude_beginning_for_subtypes_without_succ_method_is_not_supported + assert_raises(ArgumentError) { PostgresqlRange.create!(num_range: "(0.1, 0.2]") } + assert_raises(ArgumentError) { PostgresqlRange.create!(float_range: "(0.5, 0.7]") } + end + + def test_update_all_with_ranges + PostgresqlRange.create! + + PostgresqlRange.update_all(int8_range: 1..100) + + assert_equal 1...101, PostgresqlRange.first.int8_range + end + + def test_ranges_correctly_escape_input + range = "-1,2]'; DROP TABLE postgresql_ranges; --".."a" + PostgresqlRange.update_all(int8_range: range) + + assert_nothing_raised do + PostgresqlRange.first + end + end + + private + def assert_equal_round_trip(range, attribute, value) + round_trip(range, attribute, value) + assert_equal value, range.public_send(attribute) + end + + def assert_nil_round_trip(range, attribute, value) + round_trip(range, attribute, value) + assert_nil range.public_send(attribute) + end + + def round_trip(range, attribute, value) + range.public_send "#{attribute}=", value + assert range.save + assert range.reload + end + + def insert_range(values) + @connection.execute <<-SQL + INSERT INTO postgresql_ranges ( + id, + date_range, + num_range, + ts_range, + tstz_range, + int4_range, + int8_range, + float_range + ) VALUES ( + #{values[:id]}, + '#{values[:date_range]}', + '#{values[:num_range]}', + '#{values[:ts_range]}', + '#{values[:tstz_range]}', + '#{values[:int4_range]}', + '#{values[:int8_range]}', + '#{values[:float_range]}' + ) + SQL + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb b/activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb new file mode 100644 index 0000000000..99c26c4bf7 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb @@ -0,0 +1,114 @@ +require "cases/helper" + +class SchemaThing < ActiveRecord::Base +end + +class SchemaAuthorizationTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + TABLE_NAME = 'schema_things' + COLUMNS = [ + 'id serial primary key', + 'name character varying(50)' + ] + USERS = ['rails_pg_schema_user1', 'rails_pg_schema_user2'] + + def setup + @connection = ActiveRecord::Base.connection + @connection.execute "SET search_path TO '$user',public" + set_session_auth + USERS.each do |u| + @connection.execute "CREATE USER #{u}" rescue nil + @connection.execute "CREATE SCHEMA AUTHORIZATION #{u}" rescue nil + set_session_auth u + @connection.execute "CREATE TABLE #{TABLE_NAME} (#{COLUMNS.join(',')})" + @connection.execute "INSERT INTO #{TABLE_NAME} (name) VALUES ('#{u}')" + set_session_auth + end + end + + teardown do + set_session_auth + @connection.execute "RESET search_path" + USERS.each do |u| + @connection.execute "DROP SCHEMA #{u} CASCADE" + @connection.execute "DROP USER #{u}" + end + end + + def test_schema_invisible + assert_raise(ActiveRecord::StatementInvalid) do + set_session_auth + @connection.execute "SELECT * FROM #{TABLE_NAME}" + end + end + + def test_session_auth= + assert_raise(ActiveRecord::StatementInvalid) do + @connection.session_auth = 'DEFAULT' + @connection.execute "SELECT * FROM #{TABLE_NAME}" + end + end + + def test_setting_auth_clears_stmt_cache + assert_nothing_raised do + set_session_auth + USERS.each do |u| + set_session_auth u + assert_equal u, @connection.exec_query("SELECT name FROM #{TABLE_NAME} WHERE id = $1", 'SQL', [[nil, 1]]).first['name'] + set_session_auth + end + end + end + + def test_auth_with_bind + assert_nothing_raised do + set_session_auth + USERS.each do |u| + @connection.clear_cache! + set_session_auth u + assert_equal u, @connection.exec_query("SELECT name FROM #{TABLE_NAME} WHERE id = $1", 'SQL', [[nil, 1]]).first['name'] + set_session_auth + end + end + end + + def test_schema_uniqueness + assert_nothing_raised do + set_session_auth + USERS.each do |u| + set_session_auth u + assert_equal u, @connection.select_value("SELECT name FROM #{TABLE_NAME} WHERE id = 1") + set_session_auth + end + end + end + + def test_sequence_schema_caching + assert_nothing_raised do + USERS.each do |u| + set_session_auth u + st = SchemaThing.new :name => 'TEST1' + st.save! + st = SchemaThing.new :id => 5, :name => 'TEST2' + st.save! + set_session_auth + end + end + end + + def test_tables_in_current_schemas + assert !@connection.tables.include?(TABLE_NAME) + USERS.each do |u| + set_session_auth u + assert @connection.tables.include?(TABLE_NAME) + set_session_auth + end + end + + private + def set_session_auth auth = nil + @connection.session_auth = auth || 'default' + end + +end diff --git a/activerecord/test/cases/adapters/postgresql/schema_test.rb b/activerecord/test/cases/adapters/postgresql/schema_test.rb new file mode 100644 index 0000000000..9e5fd17dc4 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/schema_test.rb @@ -0,0 +1,428 @@ +require "cases/helper" + +class SchemaTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + SCHEMA_NAME = 'test_schema' + SCHEMA2_NAME = 'test_schema2' + TABLE_NAME = 'things' + CAPITALIZED_TABLE_NAME = 'Things' + INDEX_A_NAME = 'a_index_things_on_name' + INDEX_B_NAME = 'b_index_things_on_different_columns_in_each_schema' + INDEX_C_NAME = 'c_index_full_text_search' + INDEX_D_NAME = 'd_index_things_on_description_desc' + INDEX_E_NAME = 'e_index_things_on_name_vector' + INDEX_A_COLUMN = 'name' + INDEX_B_COLUMN_S1 = 'email' + INDEX_B_COLUMN_S2 = 'moment' + INDEX_C_COLUMN = %q{(to_tsvector('english', coalesce(things.name, '')))} + INDEX_D_COLUMN = 'description' + INDEX_E_COLUMN = 'name_vector' + COLUMNS = [ + 'id integer', + 'name character varying(50)', + 'email character varying(50)', + 'description character varying(100)', + 'name_vector tsvector', + 'moment timestamp without time zone default now()' + ] + PK_TABLE_NAME = 'table_with_pk' + UNMATCHED_SEQUENCE_NAME = 'unmatched_primary_key_default_value_seq' + UNMATCHED_PK_TABLE_NAME = 'table_with_unmatched_sequence_for_pk' + + class Thing1 < ActiveRecord::Base + self.table_name = "test_schema.things" + end + + class Thing2 < ActiveRecord::Base + self.table_name = "test_schema2.things" + end + + class Thing3 < ActiveRecord::Base + self.table_name = 'test_schema."things.table"' + end + + class Thing4 < ActiveRecord::Base + self.table_name = 'test_schema."Things"' + end + + class Thing5 < ActiveRecord::Base + self.table_name = 'things' + end + + class Song < ActiveRecord::Base + self.table_name = "music.songs" + has_and_belongs_to_many :albums + end + + class Album < ActiveRecord::Base + self.table_name = "music.albums" + has_and_belongs_to_many :songs + end + + def setup + @connection = ActiveRecord::Base.connection + @connection.execute "CREATE SCHEMA #{SCHEMA_NAME} CREATE TABLE #{TABLE_NAME} (#{COLUMNS.join(',')})" + @connection.execute "CREATE TABLE #{SCHEMA_NAME}.\"#{TABLE_NAME}.table\" (#{COLUMNS.join(',')})" + @connection.execute "CREATE TABLE #{SCHEMA_NAME}.\"#{CAPITALIZED_TABLE_NAME}\" (#{COLUMNS.join(',')})" + @connection.execute "CREATE SCHEMA #{SCHEMA2_NAME} CREATE TABLE #{TABLE_NAME} (#{COLUMNS.join(',')})" + @connection.execute "CREATE INDEX #{INDEX_A_NAME} ON #{SCHEMA_NAME}.#{TABLE_NAME} USING btree (#{INDEX_A_COLUMN});" + @connection.execute "CREATE INDEX #{INDEX_A_NAME} ON #{SCHEMA2_NAME}.#{TABLE_NAME} USING btree (#{INDEX_A_COLUMN});" + @connection.execute "CREATE INDEX #{INDEX_B_NAME} ON #{SCHEMA_NAME}.#{TABLE_NAME} USING btree (#{INDEX_B_COLUMN_S1});" + @connection.execute "CREATE INDEX #{INDEX_B_NAME} ON #{SCHEMA2_NAME}.#{TABLE_NAME} USING btree (#{INDEX_B_COLUMN_S2});" + @connection.execute "CREATE INDEX #{INDEX_C_NAME} ON #{SCHEMA_NAME}.#{TABLE_NAME} USING gin (#{INDEX_C_COLUMN});" + @connection.execute "CREATE INDEX #{INDEX_C_NAME} ON #{SCHEMA2_NAME}.#{TABLE_NAME} USING gin (#{INDEX_C_COLUMN});" + @connection.execute "CREATE INDEX #{INDEX_D_NAME} ON #{SCHEMA_NAME}.#{TABLE_NAME} USING btree (#{INDEX_D_COLUMN} DESC);" + @connection.execute "CREATE INDEX #{INDEX_D_NAME} ON #{SCHEMA2_NAME}.#{TABLE_NAME} USING btree (#{INDEX_D_COLUMN} DESC);" + @connection.execute "CREATE INDEX #{INDEX_E_NAME} ON #{SCHEMA_NAME}.#{TABLE_NAME} USING gin (#{INDEX_E_COLUMN});" + @connection.execute "CREATE INDEX #{INDEX_E_NAME} ON #{SCHEMA2_NAME}.#{TABLE_NAME} USING gin (#{INDEX_E_COLUMN});" + @connection.execute "CREATE TABLE #{SCHEMA_NAME}.#{PK_TABLE_NAME} (id serial primary key)" + @connection.execute "CREATE SEQUENCE #{SCHEMA_NAME}.#{UNMATCHED_SEQUENCE_NAME}" + @connection.execute "CREATE TABLE #{SCHEMA_NAME}.#{UNMATCHED_PK_TABLE_NAME} (id integer NOT NULL DEFAULT nextval('#{SCHEMA_NAME}.#{UNMATCHED_SEQUENCE_NAME}'::regclass), CONSTRAINT unmatched_pkey PRIMARY KEY (id))" + end + + teardown do + @connection.execute "DROP SCHEMA #{SCHEMA2_NAME} CASCADE" + @connection.execute "DROP SCHEMA #{SCHEMA_NAME} CASCADE" + end + + def test_schema_names + assert_equal ["public", "schema_1", "test_schema", "test_schema2"], @connection.schema_names + end + + def test_create_schema + begin + @connection.create_schema "test_schema3" + assert @connection.schema_names.include? "test_schema3" + ensure + @connection.drop_schema "test_schema3" + end + end + + def test_raise_create_schema_with_existing_schema + begin + @connection.create_schema "test_schema3" + assert_raises(ActiveRecord::StatementInvalid) do + @connection.create_schema "test_schema3" + end + ensure + @connection.drop_schema "test_schema3" + end + end + + def test_drop_schema + begin + @connection.create_schema "test_schema3" + ensure + @connection.drop_schema "test_schema3" + end + assert !@connection.schema_names.include?("test_schema3") + end + + def test_habtm_table_name_with_schema + ActiveRecord::Base.connection.execute <<-SQL + DROP SCHEMA IF EXISTS music CASCADE; + CREATE SCHEMA music; + CREATE TABLE music.albums (id serial primary key); + CREATE TABLE music.songs (id serial primary key); + CREATE TABLE music.albums_songs (album_id integer, song_id integer); + SQL + + song = Song.create + Album.create + assert_equal song, Song.includes(:albums).references(:albums).first + ensure + ActiveRecord::Base.connection.execute "DROP SCHEMA music CASCADE;" + end + + def test_raise_drop_schema_with_nonexisting_schema + assert_raises(ActiveRecord::StatementInvalid) do + @connection.drop_schema "test_schema3" + end + end + + def test_raise_wraped_exception_on_bad_prepare + assert_raises(ActiveRecord::StatementInvalid) do + @connection.exec_query "select * from developers where id = ?", 'sql', [[nil, 1]] + end + end + + def test_schema_change_with_prepared_stmt + altered = false + @connection.exec_query "select * from developers where id = $1", 'sql', [[nil, 1]] + @connection.exec_query "alter table developers add column zomg int", 'sql', [] + altered = true + @connection.exec_query "select * from developers where id = $1", 'sql', [[nil, 1]] + ensure + # We are not using DROP COLUMN IF EXISTS because that syntax is only + # supported by pg 9.X + @connection.exec_query("alter table developers drop column zomg", 'sql', []) if altered + end + + def test_table_exists? + [Thing1, Thing2, Thing3, Thing4].each do |klass| + name = klass.table_name + assert @connection.table_exists?(name), "'#{name}' table should exist" + end + end + + def test_table_exists_when_on_schema_search_path + with_schema_search_path(SCHEMA_NAME) do + assert(@connection.table_exists?(TABLE_NAME), "table should exist and be found") + end + end + + def test_table_exists_when_not_on_schema_search_path + with_schema_search_path('PUBLIC') do + assert(!@connection.table_exists?(TABLE_NAME), "table exists but should not be found") + end + end + + def test_table_exists_wrong_schema + assert(!@connection.table_exists?("foo.things"), "table should not exist") + end + + def test_table_exists_quoted_names + [ %("#{SCHEMA_NAME}"."#{TABLE_NAME}"), %(#{SCHEMA_NAME}."#{TABLE_NAME}"), %(#{SCHEMA_NAME}."#{TABLE_NAME}")].each do |given| + assert(@connection.table_exists?(given), "table should exist when specified as #{given}") + end + with_schema_search_path(SCHEMA_NAME) do + given = %("#{TABLE_NAME}") + assert(@connection.table_exists?(given), "table should exist when specified as #{given}") + end + end + + def test_table_exists_quoted_table + with_schema_search_path(SCHEMA_NAME) do + assert(@connection.table_exists?('"things.table"'), "table should exist") + end + end + + def test_with_schema_prefixed_table_name + assert_nothing_raised do + assert_equal COLUMNS, columns("#{SCHEMA_NAME}.#{TABLE_NAME}") + end + end + + def test_with_schema_prefixed_capitalized_table_name + assert_nothing_raised do + assert_equal COLUMNS, columns("#{SCHEMA_NAME}.#{CAPITALIZED_TABLE_NAME}") + end + end + + def test_with_schema_search_path + assert_nothing_raised do + with_schema_search_path(SCHEMA_NAME) do + assert_equal COLUMNS, columns(TABLE_NAME) + end + end + end + + def test_proper_encoding_of_table_name + assert_equal '"table_name"', @connection.quote_table_name('table_name') + assert_equal '"table.name"', @connection.quote_table_name('"table.name"') + assert_equal '"schema_name"."table_name"', @connection.quote_table_name('schema_name.table_name') + assert_equal '"schema_name"."table.name"', @connection.quote_table_name('schema_name."table.name"') + assert_equal '"schema.name"."table_name"', @connection.quote_table_name('"schema.name".table_name') + assert_equal '"schema.name"."table.name"', @connection.quote_table_name('"schema.name"."table.name"') + end + + def test_classes_with_qualified_schema_name + assert_equal 0, Thing1.count + assert_equal 0, Thing2.count + assert_equal 0, Thing3.count + assert_equal 0, Thing4.count + + Thing1.create(:id => 1, :name => "thing1", :email => "thing1@localhost", :moment => Time.now) + assert_equal 1, Thing1.count + assert_equal 0, Thing2.count + assert_equal 0, Thing3.count + assert_equal 0, Thing4.count + + Thing2.create(:id => 1, :name => "thing1", :email => "thing1@localhost", :moment => Time.now) + assert_equal 1, Thing1.count + assert_equal 1, Thing2.count + assert_equal 0, Thing3.count + assert_equal 0, Thing4.count + + Thing3.create(:id => 1, :name => "thing1", :email => "thing1@localhost", :moment => Time.now) + assert_equal 1, Thing1.count + assert_equal 1, Thing2.count + assert_equal 1, Thing3.count + assert_equal 0, Thing4.count + + Thing4.create(:id => 1, :name => "thing1", :email => "thing1@localhost", :moment => Time.now) + assert_equal 1, Thing1.count + assert_equal 1, Thing2.count + assert_equal 1, Thing3.count + assert_equal 1, Thing4.count + end + + def test_raise_on_unquoted_schema_name + assert_raises(ActiveRecord::StatementInvalid) do + with_schema_search_path '$user,public' + end + end + + def test_without_schema_search_path + assert_raises(ActiveRecord::StatementInvalid) { columns(TABLE_NAME) } + end + + def test_ignore_nil_schema_search_path + assert_nothing_raised { with_schema_search_path nil } + end + + def test_index_name_exists + with_schema_search_path(SCHEMA_NAME) do + assert @connection.index_name_exists?(TABLE_NAME, INDEX_A_NAME, true) + assert @connection.index_name_exists?(TABLE_NAME, INDEX_B_NAME, true) + assert @connection.index_name_exists?(TABLE_NAME, INDEX_C_NAME, true) + assert @connection.index_name_exists?(TABLE_NAME, INDEX_D_NAME, true) + assert @connection.index_name_exists?(TABLE_NAME, INDEX_E_NAME, true) + assert @connection.index_name_exists?(TABLE_NAME, INDEX_E_NAME, true) + assert_not @connection.index_name_exists?(TABLE_NAME, 'missing_index', true) + end + end + + def test_dump_indexes_for_schema_one + do_dump_index_tests_for_schema(SCHEMA_NAME, INDEX_A_COLUMN, INDEX_B_COLUMN_S1, INDEX_D_COLUMN, INDEX_E_COLUMN) + end + + def test_dump_indexes_for_schema_two + do_dump_index_tests_for_schema(SCHEMA2_NAME, INDEX_A_COLUMN, INDEX_B_COLUMN_S2, INDEX_D_COLUMN, INDEX_E_COLUMN) + end + + def test_dump_indexes_for_schema_multiple_schemas_in_search_path + do_dump_index_tests_for_schema("public, #{SCHEMA_NAME}", INDEX_A_COLUMN, INDEX_B_COLUMN_S1, INDEX_D_COLUMN, INDEX_E_COLUMN) + end + + def test_with_uppercase_index_name + @connection.execute "CREATE INDEX \"things_Index\" ON #{SCHEMA_NAME}.things (name)" + assert_nothing_raised { @connection.remove_index! "things", "#{SCHEMA_NAME}.things_Index"} + @connection.execute "CREATE INDEX \"things_Index\" ON #{SCHEMA_NAME}.things (name)" + + with_schema_search_path SCHEMA_NAME do + assert_nothing_raised { @connection.remove_index! "things", "things_Index"} + end + end + + def test_primary_key_with_schema_specified + [ + %("#{SCHEMA_NAME}"."#{PK_TABLE_NAME}"), + %(#{SCHEMA_NAME}."#{PK_TABLE_NAME}"), + %(#{SCHEMA_NAME}.#{PK_TABLE_NAME}) + ].each do |given| + assert_equal 'id', @connection.primary_key(given), "primary key should be found when table referenced as #{given}" + end + end + + def test_primary_key_assuming_schema_search_path + with_schema_search_path(SCHEMA_NAME) do + assert_equal 'id', @connection.primary_key(PK_TABLE_NAME), "primary key should be found" + end + end + + def test_primary_key_raises_error_if_table_not_found_on_schema_search_path + with_schema_search_path(SCHEMA2_NAME) do + assert_raises(ActiveRecord::StatementInvalid) do + @connection.primary_key(PK_TABLE_NAME) + end + end + end + + def test_pk_and_sequence_for_with_schema_specified + pg_name = ActiveRecord::ConnectionAdapters::PostgreSQL::Name + [ + %("#{SCHEMA_NAME}"."#{PK_TABLE_NAME}"), + %("#{SCHEMA_NAME}"."#{UNMATCHED_PK_TABLE_NAME}") + ].each do |given| + pk, seq = @connection.pk_and_sequence_for(given) + assert_equal 'id', pk, "primary key should be found when table referenced as #{given}" + assert_equal pg_name.new(SCHEMA_NAME, "#{PK_TABLE_NAME}_id_seq"), seq, "sequence name should be found when table referenced as #{given}" if given == %("#{SCHEMA_NAME}"."#{PK_TABLE_NAME}") + assert_equal pg_name.new(SCHEMA_NAME, UNMATCHED_SEQUENCE_NAME), seq, "sequence name should be found when table referenced as #{given}" if given == %("#{SCHEMA_NAME}"."#{UNMATCHED_PK_TABLE_NAME}") + end + end + + def test_current_schema + { + %('$user',public) => 'public', + SCHEMA_NAME => SCHEMA_NAME, + %(#{SCHEMA2_NAME},#{SCHEMA_NAME},public) => SCHEMA2_NAME, + %(public,#{SCHEMA2_NAME},#{SCHEMA_NAME}) => 'public' + }.each do |given,expect| + with_schema_search_path(given) { assert_equal expect, @connection.current_schema } + end + end + + def test_prepared_statements_with_multiple_schemas + [SCHEMA_NAME, SCHEMA2_NAME].each do |schema_name| + with_schema_search_path schema_name do + Thing5.create(:id => 1, :name => "thing inside #{SCHEMA_NAME}", :email => "thing1@localhost", :moment => Time.now) + end + end + + [SCHEMA_NAME, SCHEMA2_NAME].each do |schema_name| + with_schema_search_path schema_name do + assert_equal 1, Thing5.count + end + end + end + + def test_schema_exists? + { + 'public' => true, + SCHEMA_NAME => true, + SCHEMA2_NAME => true, + 'darkside' => false + }.each do |given,expect| + assert_equal expect, @connection.schema_exists?(given) + end + end + + def test_reset_pk_sequence + sequence_name = "#{SCHEMA_NAME}.#{UNMATCHED_SEQUENCE_NAME}" + @connection.execute "SELECT setval('#{sequence_name}', 123)" + assert_equal "124", @connection.select_value("SELECT nextval('#{sequence_name}')") + @connection.reset_pk_sequence!("#{SCHEMA_NAME}.#{UNMATCHED_PK_TABLE_NAME}") + assert_equal "1", @connection.select_value("SELECT nextval('#{sequence_name}')") + end + + private + def columns(table_name) + @connection.send(:column_definitions, table_name).map do |name, type, default| + "#{name} #{type}" + (default ? " default #{default}" : '') + end + end + + def with_schema_search_path(schema_search_path) + @connection.schema_search_path = schema_search_path + yield if block_given? + ensure + @connection.schema_search_path = "'$user', public" + end + + def do_dump_index_tests_for_schema(this_schema_name, first_index_column_name, second_index_column_name, third_index_column_name, fourth_index_column_name) + with_schema_search_path(this_schema_name) do + indexes = @connection.indexes(TABLE_NAME).sort_by {|i| i.name} + assert_equal 4,indexes.size + + do_dump_index_assertions_for_one_index(indexes[0], INDEX_A_NAME, first_index_column_name) + do_dump_index_assertions_for_one_index(indexes[1], INDEX_B_NAME, second_index_column_name) + do_dump_index_assertions_for_one_index(indexes[2], INDEX_D_NAME, third_index_column_name) + do_dump_index_assertions_for_one_index(indexes[3], INDEX_E_NAME, fourth_index_column_name) + + indexes.select{|i| i.name != INDEX_E_NAME}.each do |index| + assert_equal :btree, index.using + end + assert_equal :gin, indexes.select{|i| i.name == INDEX_E_NAME}[0].using + assert_equal :desc, indexes.select{|i| i.name == INDEX_D_NAME}[0].orders[INDEX_D_COLUMN] + end + end + + def do_dump_index_assertions_for_one_index(this_index, this_index_name, this_index_column) + assert_equal TABLE_NAME, this_index.table + assert_equal 1, this_index.columns.size + assert_equal this_index_column, this_index.columns[0] + assert_equal this_index_name, this_index.name + end +end diff --git a/activerecord/test/cases/adapters/postgresql/sql_types_test.rb b/activerecord/test/cases/adapters/postgresql/sql_types_test.rb new file mode 100644 index 0000000000..d7d40f6385 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/sql_types_test.rb @@ -0,0 +1,18 @@ +require "cases/helper" + +class SqlTypesTest < ActiveRecord::TestCase + def test_binary_types + assert_equal 'bytea', type_to_sql(:binary, 100_000) + assert_raise ActiveRecord::ActiveRecordError do + type_to_sql :binary, 4294967295 + end + assert_equal 'text', type_to_sql(:text, 100_000) + assert_raise ActiveRecord::ActiveRecordError do + type_to_sql :text, 4294967295 + end + end + + def type_to_sql(*args) + ActiveRecord::Base.connection.type_to_sql(*args) + end +end diff --git a/activerecord/test/cases/adapters/postgresql/statement_pool_test.rb b/activerecord/test/cases/adapters/postgresql/statement_pool_test.rb new file mode 100644 index 0000000000..1497b0abc7 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/statement_pool_test.rb @@ -0,0 +1,41 @@ +require 'cases/helper' + +module ActiveRecord + module ConnectionAdapters + class PostgreSQLAdapter < AbstractAdapter + class InactivePGconn + def query(*args) + raise PGError + end + + def status + PGconn::CONNECTION_BAD + end + end + + class StatementPoolTest < ActiveRecord::TestCase + if Process.respond_to?(:fork) + def test_cache_is_per_pid + cache = StatementPool.new nil, 10 + cache['foo'] = 'bar' + assert_equal 'bar', cache['foo'] + + pid = fork { + lookup = cache['foo']; + exit!(!lookup) + } + + Process.waitpid pid + assert $?.success?, 'process should exit successfully' + end + end + + def test_dealloc_does_not_raise_on_inactive_connection + cache = StatementPool.new InactivePGconn.new, 10 + cache['foo'] = 'bar' + assert_nothing_raised { cache.clear } + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/timestamp_test.rb b/activerecord/test/cases/adapters/postgresql/timestamp_test.rb new file mode 100644 index 0000000000..3614b29190 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/timestamp_test.rb @@ -0,0 +1,154 @@ +require 'cases/helper' +require 'models/developer' +require 'models/topic' + +class PostgresqlTimestampTest < ActiveRecord::TestCase + class PostgresqlTimestampWithZone < ActiveRecord::Base; end + + self.use_transactional_fixtures = false + + setup do + @connection = ActiveRecord::Base.connection + @connection.execute("INSERT INTO postgresql_timestamp_with_zones (id, time) VALUES (1, '2010-01-01 10:00:00-1')") + end + + teardown do + PostgresqlTimestampWithZone.delete_all + end + + def test_timestamp_with_zone_values_with_rails_time_zone_support + with_timezone_config default: :utc, aware_attributes: true do + @connection.reconnect! + + timestamp = PostgresqlTimestampWithZone.find(1) + assert_equal Time.utc(2010,1,1, 11,0,0), timestamp.time + assert_instance_of Time, timestamp.time + end + ensure + @connection.reconnect! + end + + def test_timestamp_with_zone_values_without_rails_time_zone_support + with_timezone_config default: :local, aware_attributes: false do + @connection.reconnect! + # make sure to use a non-UTC time zone + @connection.execute("SET time zone 'America/Jamaica'", 'SCHEMA') + + timestamp = PostgresqlTimestampWithZone.find(1) + assert_equal Time.utc(2010,1,1, 11,0,0), timestamp.time + assert_instance_of Time, timestamp.time + end + ensure + @connection.reconnect! + end +end + +class TimestampTest < ActiveRecord::TestCase + fixtures :topics + + def test_group_by_date + keys = Topic.group("date_trunc('month', created_at)").count.keys + assert_operator keys.length, :>, 0 + keys.each { |k| assert_kind_of Time, k } + end + + def test_load_infinity_and_beyond + d = Developer.find_by_sql("select 'infinity'::timestamp as updated_at") + assert d.first.updated_at.infinite?, 'timestamp should be infinite' + + d = Developer.find_by_sql("select '-infinity'::timestamp as updated_at") + time = d.first.updated_at + assert time.infinite?, 'timestamp should be infinite' + assert_operator time, :<, 0 + end + + def test_save_infinity_and_beyond + d = Developer.create!(:name => 'aaron', :updated_at => 1.0 / 0.0) + assert_equal(1.0 / 0.0, d.updated_at) + + d = Developer.create!(:name => 'aaron', :updated_at => -1.0 / 0.0) + assert_equal(-1.0 / 0.0, d.updated_at) + end + + def test_default_datetime_precision + ActiveRecord::Base.connection.create_table(:foos) + ActiveRecord::Base.connection.add_column :foos, :created_at, :datetime + ActiveRecord::Base.connection.add_column :foos, :updated_at, :datetime + assert_nil activerecord_column_option('foos', 'created_at', 'precision') + end + + def test_timestamp_data_type_with_precision + ActiveRecord::Base.connection.create_table(:foos) + ActiveRecord::Base.connection.add_column :foos, :created_at, :datetime, :precision => 0 + ActiveRecord::Base.connection.add_column :foos, :updated_at, :datetime, :precision => 5 + assert_equal 0, activerecord_column_option('foos', 'created_at', 'precision') + assert_equal 5, activerecord_column_option('foos', 'updated_at', 'precision') + end + + def test_timestamps_helper_with_custom_precision + ActiveRecord::Base.connection.create_table(:foos) do |t| + t.timestamps :precision => 4 + end + assert_equal 4, activerecord_column_option('foos', 'created_at', 'precision') + assert_equal 4, activerecord_column_option('foos', 'updated_at', 'precision') + end + + def test_passing_precision_to_timestamp_does_not_set_limit + ActiveRecord::Base.connection.create_table(:foos) do |t| + t.timestamps :precision => 4 + end + assert_nil activerecord_column_option("foos", "created_at", "limit") + assert_nil activerecord_column_option("foos", "updated_at", "limit") + end + + def test_invalid_timestamp_precision_raises_error + assert_raises ActiveRecord::ActiveRecordError do + ActiveRecord::Base.connection.create_table(:foos) do |t| + t.timestamps :precision => 7 + end + end + end + + def test_postgres_agrees_with_activerecord_about_precision + ActiveRecord::Base.connection.create_table(:foos) do |t| + t.timestamps :precision => 4 + end + assert_equal '4', pg_datetime_precision('foos', 'created_at') + assert_equal '4', pg_datetime_precision('foos', 'updated_at') + end + + def test_bc_timestamp + date = Date.new(0) - 1.week + Developer.create!(:name => "aaron", :updated_at => date) + assert_equal date, Developer.find_by_name("aaron").updated_at + end + + def test_bc_timestamp_leap_year + date = Time.utc(-4, 2, 29) + Developer.create!(:name => "taihou", :updated_at => date) + assert_equal date, Developer.find_by_name("taihou").updated_at + end + + def test_bc_timestamp_year_zero + date = Time.utc(0, 4, 7) + Developer.create!(:name => "yahagi", :updated_at => date) + assert_equal date, Developer.find_by_name("yahagi").updated_at + end + + private + + def pg_datetime_precision(table_name, column_name) + results = ActiveRecord::Base.connection.execute("SELECT column_name, datetime_precision FROM information_schema.columns WHERE table_name ='#{table_name}'") + result = results.find do |result_hash| + result_hash["column_name"] == column_name + end + result && result["datetime_precision"] + end + + def activerecord_column_option(tablename, column_name, option) + result = ActiveRecord::Base.connection.columns(tablename).find do |column| + column.name == column_name + end + result && result.send(option) + end +end diff --git a/activerecord/test/cases/adapters/postgresql/type_lookup_test.rb b/activerecord/test/cases/adapters/postgresql/type_lookup_test.rb new file mode 100644 index 0000000000..23817198b1 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/type_lookup_test.rb @@ -0,0 +1,15 @@ +require 'cases/helper' + +class PostgresqlTypeLookupTest < ActiveRecord::TestCase + setup do + @connection = ActiveRecord::Base.connection + end + + test "array delimiters are looked up correctly" do + box_array = @connection.type_map.lookup(1020) + int_array = @connection.type_map.lookup(1007) + + assert_equal ';', box_array.delimiter + assert_equal ',', int_array.delimiter + end +end diff --git a/activerecord/test/cases/adapters/postgresql/utils_test.rb b/activerecord/test/cases/adapters/postgresql/utils_test.rb new file mode 100644 index 0000000000..3fdb6888d9 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/utils_test.rb @@ -0,0 +1,61 @@ +require 'cases/helper' + +class PostgreSQLUtilsTest < ActiveSupport::TestCase + Name = ActiveRecord::ConnectionAdapters::PostgreSQL::Name + include ActiveRecord::ConnectionAdapters::PostgreSQL::Utils + + def test_extract_schema_qualified_name + { + %(table_name) => [nil,'table_name'], + %("table.name") => [nil,'table.name'], + %(schema.table_name) => %w{schema table_name}, + %("schema".table_name) => %w{schema table_name}, + %(schema."table_name") => %w{schema table_name}, + %("schema"."table_name") => %w{schema table_name}, + %("even spaces".table) => ['even spaces','table'], + %(schema."table.name") => ['schema', 'table.name'] + }.each do |given, expect| + assert_equal Name.new(*expect), extract_schema_qualified_name(given) + end + end +end + +class PostgreSQLNameTest < ActiveSupport::TestCase + Name = ActiveRecord::ConnectionAdapters::PostgreSQL::Name + + test "represents itself as schema.name" do + obj = Name.new("public", "articles") + assert_equal "public.articles", obj.to_s + end + + test "without schema, represents itself as name only" do + obj = Name.new(nil, "articles") + assert_equal "articles", obj.to_s + end + + test "quoted returns a string representation usable in a query" do + assert_equal %("articles"), Name.new(nil, "articles").quoted + assert_equal %("public"."articles"), Name.new("public", "articles").quoted + end + + test "prevents double quoting" do + name = Name.new('"quoted_schema"', '"quoted_table"') + assert_equal "quoted_schema.quoted_table", name.to_s + assert_equal %("quoted_schema"."quoted_table"), name.quoted + end + + test "equality based on state" do + assert_equal Name.new("access", "users"), Name.new("access", "users") + assert_equal Name.new(nil, "users"), Name.new(nil, "users") + assert_not_equal Name.new(nil, "users"), Name.new("access", "users") + assert_not_equal Name.new("access", "users"), Name.new("public", "users") + assert_not_equal Name.new("public", "users"), Name.new("public", "articles") + end + + test "can be used as hash key" do + hash = {Name.new("schema", "article_seq") => "success"} + assert_equal "success", hash[Name.new("schema", "article_seq")] + assert_equal nil, hash[Name.new("schema", "articles")] + assert_equal nil, hash[Name.new("public", "article_seq")] + end +end diff --git a/activerecord/test/cases/adapters/postgresql/uuid_test.rb b/activerecord/test/cases/adapters/postgresql/uuid_test.rb new file mode 100644 index 0000000000..66006d718f --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/uuid_test.rb @@ -0,0 +1,256 @@ +# encoding: utf-8 + +require "cases/helper" +require 'active_record/base' +require 'active_record/connection_adapters/postgresql_adapter' + +module PostgresqlUUIDHelper + def connection + @connection ||= ActiveRecord::Base.connection + end + + def drop_table(name) + connection.execute "drop table if exists #{name}" + end +end + +class PostgresqlUUIDTest < ActiveRecord::TestCase + include PostgresqlUUIDHelper + + class UUIDType < ActiveRecord::Base + self.table_name = "uuid_data_type" + end + + setup do + connection.create_table "uuid_data_type" do |t| + t.uuid 'guid' + end + end + + teardown do + drop_table "uuid_data_type" + end + + def test_change_column_default + @connection.add_column :uuid_data_type, :thingy, :uuid, null: false, default: "uuid_generate_v1()" + UUIDType.reset_column_information + column = UUIDType.columns_hash['thingy'] + assert_equal "uuid_generate_v1()", column.default_function + + @connection.change_column :uuid_data_type, :thingy, :uuid, null: false, default: "uuid_generate_v4()" + + UUIDType.reset_column_information + column = UUIDType.columns_hash['thingy'] + assert_equal "uuid_generate_v4()", column.default_function + ensure + UUIDType.reset_column_information + end + + def test_data_type_of_uuid_types + column = UUIDType.columns_hash["guid"] + assert_equal :uuid, column.type + assert_equal "uuid", column.sql_type + assert_not column.number? + assert_not column.binary? + assert_not column.array + end + + def test_treat_blank_uuid_as_nil + UUIDType.create! guid: '' + assert_equal(nil, UUIDType.last.guid) + end + + def test_treat_invalid_uuid_as_nil + uuid = UUIDType.create! guid: 'foobar' + assert_equal(nil, uuid.guid) + end + + def test_invalid_uuid_dont_modify_before_type_cast + uuid = UUIDType.new guid: 'foobar' + assert_equal 'foobar', uuid.guid_before_type_cast + end + + def test_rfc_4122_regex + # Valid uuids + ['A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11', + '{a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11}', + 'a0eebc999c0b4ef8bb6d6bb9bd380a11', + 'a0ee-bc99-9c0b-4ef8-bb6d-6bb9-bd38-0a11', + '{a0eebc99-9c0b4ef8-bb6d6bb9-bd380a11}'].each do |valid_uuid| + uuid = UUIDType.new guid: valid_uuid + assert_not_nil uuid.guid + end + + # Invalid uuids + [['A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11'], + Hash.new, + 0, + 0.0, + true, + 'Z0000C99-9C0B-4EF8-BB6D-6BB9BD380A11', + '{a0eebc99-9c0b-4ef8-fb6d-6bb9bd380a11}', + 'a0eebc999r0b4ef8ab6d6bb9bd380a11', + 'a0ee-bc99------4ef8-bb6d-6bb9-bd38-0a11', + '{a0eebc99-bb6d6bb9-bd380a11}'].each do |invalid_uuid| + uuid = UUIDType.new guid: invalid_uuid + assert_nil uuid.guid + end + end + + def test_uuid_formats + ["A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11", + "{a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11}", + "a0eebc999c0b4ef8bb6d6bb9bd380a11", + "a0ee-bc99-9c0b-4ef8-bb6d-6bb9-bd38-0a11", + "{a0eebc99-9c0b4ef8-bb6d6bb9-bd380a11}"].each do |valid_uuid| + UUIDType.create(guid: valid_uuid) + uuid = UUIDType.last + assert_equal "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", uuid.guid + end + end +end + +class PostgresqlUUIDGenerationTest < ActiveRecord::TestCase + include PostgresqlUUIDHelper + + class UUID < ActiveRecord::Base + self.table_name = 'pg_uuids' + end + + setup do + enable_uuid_ossp!(connection) + + connection.create_table('pg_uuids', id: :uuid, default: 'uuid_generate_v1()') do |t| + t.string 'name' + t.uuid 'other_uuid', default: 'uuid_generate_v4()' + end + + # Create custom PostgreSQL function to generate UUIDs + # to test dumping tables which columns have defaults with custom functions + connection.execute <<-SQL + CREATE OR REPLACE FUNCTION my_uuid_generator() RETURNS uuid + AS $$ SELECT * FROM uuid_generate_v4() $$ + LANGUAGE SQL VOLATILE; + SQL + + # Create such a table with custom function as default value generator + connection.create_table('pg_uuids_2', id: :uuid, default: 'my_uuid_generator()') do |t| + t.string 'name' + t.uuid 'other_uuid_2', default: 'my_uuid_generator()' + end + end + + teardown do + drop_table "pg_uuids" + drop_table 'pg_uuids_2' + connection.execute 'DROP FUNCTION IF EXISTS my_uuid_generator();' + end + + if ActiveRecord::Base.connection.supports_extensions? + def test_id_is_uuid + assert_equal :uuid, UUID.columns_hash['id'].type + assert UUID.primary_key + end + + def test_id_has_a_default + u = UUID.create + assert_not_nil u.id + end + + def test_auto_create_uuid + u = UUID.create + u.reload + assert_not_nil u.other_uuid + end + + def test_pk_and_sequence_for_uuid_primary_key + pk, seq = connection.pk_and_sequence_for('pg_uuids') + assert_equal 'id', pk + assert_equal nil, seq + end + + def test_schema_dumper_for_uuid_primary_key + schema = StringIO.new + ActiveRecord::SchemaDumper.dump(connection, schema) + assert_match(/\bcreate_table "pg_uuids", id: :uuid, default: "uuid_generate_v1\(\)"/, schema.string) + assert_match(/t\.uuid "other_uuid", default: "uuid_generate_v4\(\)"/, schema.string) + end + + def test_schema_dumper_for_uuid_primary_key_with_custom_default + schema = StringIO.new + ActiveRecord::SchemaDumper.dump(connection, schema) + assert_match(/\bcreate_table "pg_uuids_2", id: :uuid, default: "my_uuid_generator\(\)"/, schema.string) + assert_match(/t\.uuid "other_uuid_2", default: "my_uuid_generator\(\)"/, schema.string) + end + end +end + +class PostgresqlUUIDTestNilDefault < ActiveRecord::TestCase + include PostgresqlUUIDHelper + + setup do + enable_uuid_ossp!(connection) + + connection.create_table('pg_uuids', id: false) do |t| + t.primary_key :id, :uuid, default: nil + t.string 'name' + end + end + + teardown do + drop_table "pg_uuids" + end + + if ActiveRecord::Base.connection.supports_extensions? + def test_id_allows_default_override_via_nil + col_desc = connection.execute("SELECT pg_get_expr(d.adbin, d.adrelid) as default + FROM pg_attribute a + LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum + WHERE a.attname='id' AND a.attrelid = 'pg_uuids'::regclass").first + assert_nil col_desc["default"] + end + end +end + +class PostgresqlUUIDTestInverseOf < ActiveRecord::TestCase + include PostgresqlUUIDHelper + + class UuidPost < ActiveRecord::Base + self.table_name = 'pg_uuid_posts' + has_many :uuid_comments, inverse_of: :uuid_post + end + + class UuidComment < ActiveRecord::Base + self.table_name = 'pg_uuid_comments' + belongs_to :uuid_post + end + + setup do + enable_uuid_ossp!(connection) + + connection.transaction do + connection.create_table('pg_uuid_posts', id: :uuid) do |t| + t.string 'title' + end + connection.create_table('pg_uuid_comments', id: :uuid) do |t| + t.references :uuid_post, type: :uuid + t.string 'content' + end + end + end + + teardown do + connection.transaction do + drop_table "pg_uuid_comments" + drop_table "pg_uuid_posts" + end + end + + if ActiveRecord::Base.connection.supports_extensions? + def test_collection_association_with_uuid + post = UuidPost.create! + comment = post.uuid_comments.create! + assert post.uuid_comments.find(comment.id) + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/view_test.rb b/activerecord/test/cases/adapters/postgresql/view_test.rb new file mode 100644 index 0000000000..47b7d38eda --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/view_test.rb @@ -0,0 +1,67 @@ +require "cases/helper" + +module ViewTestConcern + extend ActiveSupport::Concern + + included do + self.use_transactional_fixtures = false + mattr_accessor :view_type + end + + SCHEMA_NAME = 'test_schema' + TABLE_NAME = 'things' + COLUMNS = [ + 'id integer', + 'name character varying(50)', + 'email character varying(50)', + 'moment timestamp without time zone' + ] + + class ThingView < ActiveRecord::Base + end + + def setup + super + ThingView.table_name = "#{SCHEMA_NAME}.#{view_type}_things" + + @connection = ActiveRecord::Base.connection + @connection.execute "CREATE SCHEMA #{SCHEMA_NAME} CREATE TABLE #{TABLE_NAME} (#{COLUMNS.join(',')})" + @connection.execute "CREATE #{view_type.humanize} #{ThingView.table_name} AS SELECT * FROM #{SCHEMA_NAME}.#{TABLE_NAME}" + end + + def teardown + super + @connection.execute "DROP SCHEMA #{SCHEMA_NAME} CASCADE" + end + + def test_table_exists + name = ThingView.table_name + assert @connection.table_exists?(name), "'#{name}' table should exist" + end + + def test_column_definitions + assert_nothing_raised do + assert_equal COLUMNS, columns(ThingView.table_name) + end + end + + private + def columns(table_name) + @connection.send(:column_definitions, table_name).map do |name, type, default| + "#{name} #{type}" + (default ? " default #{default}" : '') + end + end + +end + +class ViewTest < ActiveRecord::TestCase + include ViewTestConcern + self.view_type = 'view' +end + +if ActiveRecord::Base.connection.supports_materialized_views? + class MaterializedViewTest < ActiveRecord::TestCase + include ViewTestConcern + self.view_type = 'materialized_view' + end +end diff --git a/activerecord/test/cases/adapters/postgresql/xml_test.rb b/activerecord/test/cases/adapters/postgresql/xml_test.rb new file mode 100644 index 0000000000..4165dd5ac9 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/xml_test.rb @@ -0,0 +1,48 @@ +# encoding: utf-8 +require 'cases/helper' + +class PostgresqlXMLTest < ActiveRecord::TestCase + class XmlDataType < ActiveRecord::Base + self.table_name = 'xml_data_type' + end + + def setup + @connection = ActiveRecord::Base.connection + begin + @connection.transaction do + @connection.create_table('xml_data_type') do |t| + t.xml 'payload' + end + end + rescue ActiveRecord::StatementInvalid + skip "do not test on PG without xml" + end + @column = XmlDataType.columns_hash['payload'] + end + + teardown do + @connection.execute 'drop table if exists xml_data_type' + end + + def test_column + assert_equal :xml, @column.type + end + + def test_null_xml + @connection.execute %q|insert into xml_data_type (payload) VALUES(null)| + assert_nil XmlDataType.first.payload + end + + def test_round_trip + data = XmlDataType.new(payload: "<foo>bar</foo>") + assert_equal "<foo>bar</foo>", data.payload + data.save! + assert_equal "<foo>bar</foo>", data.reload.payload + end + + def test_update_all + data = XmlDataType.create! + XmlDataType.update_all(payload: "<bar>baz</bar>") + assert_equal "<bar>baz</bar>", data.reload.payload + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/copy_table_test.rb b/activerecord/test/cases/adapters/sqlite3/copy_table_test.rb new file mode 100644 index 0000000000..13b754d226 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/copy_table_test.rb @@ -0,0 +1,98 @@ +require "cases/helper" + +class CopyTableTest < ActiveRecord::TestCase + fixtures :customers + + def setup + @connection = ActiveRecord::Base.connection + class << @connection + public :copy_table, :table_structure, :indexes + end + end + + def test_copy_table(from = 'customers', to = 'customers2', options = {}) + assert_nothing_raised {copy_table(from, to, options)} + assert_equal row_count(from), row_count(to) + + if block_given? + yield from, to, options + else + assert_equal column_names(from), column_names(to) + end + + @connection.drop_table(to) rescue nil + end + + def test_copy_table_renaming_column + test_copy_table('customers', 'customers2', + :rename => {'name' => 'person_name'}) do |from, to, options| + expected = column_values(from, 'name') + assert_equal expected, column_values(to, 'person_name') + assert expected.any?, "No values in table: #{expected.inspect}" + end + end + + def test_copy_table_allows_to_pass_options_to_create_table + @connection.create_table('blocker_table') + test_copy_table('customers', 'blocker_table', force: true) + end + + def test_copy_table_with_index + test_copy_table('comments', 'comments_with_index') do + @connection.add_index('comments_with_index', ['post_id', 'type']) + test_copy_table('comments_with_index', 'comments_with_index2') do + assert_equal table_indexes_without_name('comments_with_index'), + table_indexes_without_name('comments_with_index2') + end + end + end + + def test_copy_table_without_primary_key + test_copy_table('developers_projects', 'programmers_projects') do + assert_nil @connection.primary_key('programmers_projects') + end + end + + def test_copy_table_with_id_col_that_is_not_primary_key + test_copy_table('goofy_string_id', 'goofy_string_id2') do + original_id = @connection.columns('goofy_string_id').detect{|col| col.name == 'id' } + copied_id = @connection.columns('goofy_string_id2').detect{|col| col.name == 'id' } + assert_equal original_id.type, copied_id.type + assert_equal original_id.sql_type, copied_id.sql_type + assert_equal original_id.limit, copied_id.limit + end + end + + def test_copy_table_with_unconventional_primary_key + test_copy_table('owners', 'owners_unconventional') do + original_pk = @connection.primary_key('owners') + copied_pk = @connection.primary_key('owners_unconventional') + assert_equal original_pk, copied_pk + end + end + + def test_copy_table_with_binary_column + test_copy_table 'binaries', 'binaries2' + end + +protected + def copy_table(from, to, options = {}) + @connection.copy_table(from, to, {:temporary => true}.merge(options)) + end + + def column_names(table) + @connection.table_structure(table).map {|column| column['name']} + end + + def column_values(table, column) + @connection.select_all("SELECT #{column} FROM #{table} ORDER BY id").map {|row| row[column]} + end + + def table_indexes_without_name(table) + @connection.indexes(table).delete(:name) + end + + def row_count(table) + @connection.select_one("SELECT COUNT(*) AS count FROM #{table}")['count'] + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/explain_test.rb b/activerecord/test/cases/adapters/sqlite3/explain_test.rb new file mode 100644 index 0000000000..f1d6119d2e --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/explain_test.rb @@ -0,0 +1,26 @@ +require "cases/helper" +require 'models/developer' + +module ActiveRecord + module ConnectionAdapters + class SQLite3Adapter + class ExplainTest < ActiveRecord::TestCase + fixtures :developers + + def test_explain_for_one_query + explain = Developer.where(:id => 1).explain + assert_match %(EXPLAIN for: SELECT "developers".* FROM "developers" WHERE "developers"."id" = ?), explain + assert_match(/(SEARCH )?TABLE developers USING (INTEGER )?PRIMARY KEY/, explain) + end + + def test_explain_with_eager_loading + explain = Developer.where(:id => 1).includes(:audit_logs).explain + assert_match %(EXPLAIN for: SELECT "developers".* FROM "developers" WHERE "developers"."id" = ?), explain + assert_match(/(SEARCH )?TABLE developers USING (INTEGER )?PRIMARY KEY/, explain) + assert_match %(EXPLAIN for: SELECT "audit_logs".* FROM "audit_logs" WHERE "audit_logs"."developer_id" IN (1)), explain + assert_match(/(SCAN )?TABLE audit_logs/, explain) + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/quoting_test.rb b/activerecord/test/cases/adapters/sqlite3/quoting_test.rb new file mode 100644 index 0000000000..ac8332e2fa --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/quoting_test.rb @@ -0,0 +1,116 @@ +require "cases/helper" +require 'bigdecimal' +require 'yaml' +require 'securerandom' + +module ActiveRecord + module ConnectionAdapters + class SQLite3Adapter + class QuotingTest < ActiveRecord::TestCase + def setup + @conn = Base.sqlite3_connection :database => ':memory:', + :adapter => 'sqlite3', + :timeout => 100 + end + + def test_type_cast_binary_encoding_without_logger + @conn.extend(Module.new { def logger; end }) + column = Column.new(nil, nil, Type::String.new) + binary = SecureRandom.hex + expected = binary.dup.encode!(Encoding::UTF_8) + assert_equal expected, @conn.type_cast(binary, column) + end + + def test_type_cast_symbol + assert_equal 'foo', @conn.type_cast(:foo, nil) + end + + def test_type_cast_date + date = Date.today + expected = @conn.quoted_date(date) + assert_equal expected, @conn.type_cast(date, nil) + end + + def test_type_cast_time + time = Time.now + expected = @conn.quoted_date(time) + assert_equal expected, @conn.type_cast(time, nil) + end + + def test_type_cast_numeric + assert_equal 10, @conn.type_cast(10, nil) + assert_equal 2.2, @conn.type_cast(2.2, nil) + end + + def test_type_cast_nil + assert_equal nil, @conn.type_cast(nil, nil) + end + + def test_type_cast_true + c = Column.new(nil, 1, Type::Integer.new) + assert_equal 't', @conn.type_cast(true, nil) + assert_equal 1, @conn.type_cast(true, c) + end + + def test_type_cast_false + c = Column.new(nil, 1, Type::Integer.new) + assert_equal 'f', @conn.type_cast(false, nil) + assert_equal 0, @conn.type_cast(false, c) + end + + def test_type_cast_string + assert_equal '10', @conn.type_cast('10', nil) + + c = Column.new(nil, 1, Type::Integer.new) + assert_equal 10, @conn.type_cast('10', c) + + c = Column.new(nil, 1, Type::Float.new) + assert_equal 10.1, @conn.type_cast('10.1', c) + + c = Column.new(nil, 1, Type::Binary.new) + assert_equal '10.1', @conn.type_cast('10.1', c) + + c = Column.new(nil, 1, Type::Date.new) + assert_equal '10.1', @conn.type_cast('10.1', c) + end + + def test_type_cast_bigdecimal + bd = BigDecimal.new '10.0' + assert_equal bd.to_f, @conn.type_cast(bd, nil) + end + + def test_type_cast_unknown_should_raise_error + obj = Class.new.new + assert_raise(TypeError) { @conn.type_cast(obj, nil) } + end + + def test_type_cast_object_which_responds_to_quoted_id + quoted_id_obj = Class.new { + def quoted_id + "'zomg'" + end + + def id + 10 + end + }.new + assert_equal 10, @conn.type_cast(quoted_id_obj, nil) + + quoted_id_obj = Class.new { + def quoted_id + "'zomg'" + end + }.new + assert_raise(TypeError) { @conn.type_cast(quoted_id_obj, nil) } + end + + def test_quoting_binary_strings + value = "hello".encode('ascii-8bit') + column = Column.new(nil, 1, SQLite3String.new) + + assert_equal "'hello'", @conn.quote(value, column) + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb new file mode 100644 index 0000000000..b2bf9480dd --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb @@ -0,0 +1,455 @@ +# encoding: utf-8 +require "cases/helper" +require 'models/owner' +require 'tempfile' +require 'support/ddl_helper' + +module ActiveRecord + module ConnectionAdapters + class SQLite3AdapterTest < ActiveRecord::TestCase + include DdlHelper + + self.use_transactional_fixtures = false + + class DualEncoding < ActiveRecord::Base + end + + def setup + @conn = Base.sqlite3_connection database: ':memory:', + adapter: 'sqlite3', + timeout: 100 + end + + def test_bad_connection + assert_raise ActiveRecord::NoDatabaseError do + connection = ActiveRecord::Base.sqlite3_connection(adapter: "sqlite3", database: "/tmp/should/_not/_exist/-cinco-dog.db") + connection.exec_query('drop table if exists ex') + end + end + + unless in_memory_db? + def test_connect_with_url + original_connection = ActiveRecord::Base.remove_connection + tf = Tempfile.open 'whatever' + url = "sqlite3:#{tf.path}" + ActiveRecord::Base.establish_connection(url) + assert ActiveRecord::Base.connection + ensure + tf.close + tf.unlink + ActiveRecord::Base.establish_connection(original_connection) + end + + def test_connect_memory_with_url + original_connection = ActiveRecord::Base.remove_connection + url = "sqlite3::memory:" + ActiveRecord::Base.establish_connection(url) + assert ActiveRecord::Base.connection + ensure + ActiveRecord::Base.establish_connection(original_connection) + end + end + + def test_valid_column + with_example_table do + column = @conn.columns('ex').find { |col| col.name == 'id' } + assert @conn.valid_type?(column.type) + end + end + + # sqlite3 databases should be able to support any type and not just the + # ones mentioned in the native_database_types. + # + # Therefore test_invalid column should always return true even if the + # type is not valid. + def test_invalid_column + assert @conn.valid_type?(:foobar) + end + + def test_column_types + owner = Owner.create!(name: "hello".encode('ascii-8bit')) + owner.reload + select = Owner.columns.map { |c| "typeof(#{c.name})" }.join ', ' + result = Owner.connection.exec_query <<-esql + SELECT #{select} + FROM #{Owner.table_name} + WHERE #{Owner.primary_key} = #{owner.id} + esql + + assert(!result.rows.first.include?("blob"), "should not store blobs") + ensure + owner.delete + end + + def test_exec_insert + with_example_table do + column = @conn.columns('ex').find { |col| col.name == 'number' } + vals = [[column, 10]] + @conn.exec_insert('insert into ex (number) VALUES (?)', 'SQL', vals) + + result = @conn.exec_query( + 'select number from ex where number = ?', 'SQL', vals) + + assert_equal 1, result.rows.length + assert_equal 10, result.rows.first.first + end + end + + def test_primary_key_returns_nil_for_no_pk + with_example_table 'id int, data string' do + assert_nil @conn.primary_key('ex') + end + end + + def test_connection_no_db + assert_raises(ArgumentError) do + Base.sqlite3_connection {} + end + end + + def test_bad_timeout + assert_raises(TypeError) do + Base.sqlite3_connection database: ':memory:', + adapter: 'sqlite3', + timeout: 'usa' + end + end + + # connection is OK with a nil timeout + def test_nil_timeout + conn = Base.sqlite3_connection database: ':memory:', + adapter: 'sqlite3', + timeout: nil + assert conn, 'made a connection' + end + + def test_connect + assert @conn, 'should have connection' + end + + # sqlite3 defaults to UTF-8 encoding + def test_encoding + assert_equal 'UTF-8', @conn.encoding + end + + def test_bind_value_substitute + bind_param = @conn.substitute_at('foo', 0) + assert_equal Arel.sql('?'), bind_param + end + + def test_exec_no_binds + with_example_table 'id int, data string' do + result = @conn.exec_query('SELECT id, data FROM ex') + assert_equal 0, result.rows.length + assert_equal 2, result.columns.length + assert_equal %w{ id data }, result.columns + + @conn.exec_query('INSERT INTO ex (id, data) VALUES (1, "foo")') + result = @conn.exec_query('SELECT id, data FROM ex') + assert_equal 1, result.rows.length + assert_equal 2, result.columns.length + + assert_equal [[1, 'foo']], result.rows + end + end + + def test_exec_query_with_binds + with_example_table 'id int, data string' do + @conn.exec_query('INSERT INTO ex (id, data) VALUES (1, "foo")') + result = @conn.exec_query( + 'SELECT id, data FROM ex WHERE id = ?', nil, [[nil, 1]]) + + assert_equal 1, result.rows.length + assert_equal 2, result.columns.length + + assert_equal [[1, 'foo']], result.rows + end + end + + def test_exec_query_typecasts_bind_vals + with_example_table 'id int, data string' do + @conn.exec_query('INSERT INTO ex (id, data) VALUES (1, "foo")') + column = @conn.columns('ex').find { |col| col.name == 'id' } + + result = @conn.exec_query( + 'SELECT id, data FROM ex WHERE id = ?', nil, [[column, '1-fuu']]) + + assert_equal 1, result.rows.length + assert_equal 2, result.columns.length + + assert_equal [[1, 'foo']], result.rows + end + end + + def test_quote_binary_column_escapes_it + DualEncoding.connection.execute(<<-eosql) + CREATE TABLE IF NOT EXISTS dual_encodings ( + id integer PRIMARY KEY AUTOINCREMENT, + name varchar(255), + data binary + ) + eosql + str = "\x80".force_encoding("ASCII-8BIT") + binary = DualEncoding.new name: 'いただきます!', data: str + binary.save! + assert_equal str, binary.data + ensure + DualEncoding.connection.execute('DROP TABLE IF EXISTS dual_encodings') + end + + def test_type_cast_should_not_mutate_encoding + name = 'hello'.force_encoding(Encoding::ASCII_8BIT) + Owner.create(name: name) + assert_equal Encoding::ASCII_8BIT, name.encoding + ensure + Owner.delete_all + end + + def test_execute + with_example_table do + @conn.execute "INSERT INTO ex (number) VALUES (10)" + records = @conn.execute "SELECT * FROM ex" + assert_equal 1, records.length + + record = records.first + assert_equal 10, record['number'] + assert_equal 1, record['id'] + end + end + + def test_quote_string + assert_equal "''", @conn.quote_string("'") + end + + def test_insert_sql + with_example_table do + 2.times do |i| + rv = @conn.insert_sql "INSERT INTO ex (number) VALUES (#{i})" + assert_equal(i + 1, rv) + end + + records = @conn.execute "SELECT * FROM ex" + assert_equal 2, records.length + end + end + + def test_insert_sql_logged + with_example_table do + sql = "INSERT INTO ex (number) VALUES (10)" + name = "foo" + assert_logged [[sql, name, []]] do + @conn.insert_sql sql, name + end + end + end + + def test_insert_id_value_returned + with_example_table do + sql = "INSERT INTO ex (number) VALUES (10)" + idval = 'vuvuzela' + id = @conn.insert_sql sql, nil, nil, idval + assert_equal idval, id + end + end + + def test_select_rows + with_example_table do + 2.times do |i| + @conn.create "INSERT INTO ex (number) VALUES (#{i})" + end + rows = @conn.select_rows 'select number, id from ex' + assert_equal [[0, 1], [1, 2]], rows + end + end + + def test_select_rows_logged + with_example_table do + sql = "select * from ex" + name = "foo" + assert_logged [[sql, name, []]] do + @conn.select_rows sql, name + end + end + end + + def test_transaction + with_example_table do + count_sql = 'select count(*) from ex' + + @conn.begin_db_transaction + @conn.create "INSERT INTO ex (number) VALUES (10)" + + assert_equal 1, @conn.select_rows(count_sql).first.first + @conn.rollback_db_transaction + assert_equal 0, @conn.select_rows(count_sql).first.first + end + end + + def test_tables + with_example_table do + assert_equal %w{ ex }, @conn.tables + with_example_table 'id integer PRIMARY KEY AUTOINCREMENT, number integer', 'people' do + assert_equal %w{ ex people }.sort, @conn.tables.sort + end + end + end + + def test_tables_logs_name + sql = <<-SQL + SELECT name FROM sqlite_master + WHERE type = 'table' AND NOT name = 'sqlite_sequence' + SQL + assert_logged [[sql.squish, 'SCHEMA', []]] do + @conn.tables('hello') + end + end + + def test_indexes_logs_name + with_example_table do + assert_logged [["PRAGMA index_list(\"ex\")", 'SCHEMA', []]] do + @conn.indexes('ex', 'hello') + end + end + end + + def test_table_exists_logs_name + with_example_table do + sql = <<-SQL + SELECT name FROM sqlite_master + WHERE type = 'table' + AND NOT name = 'sqlite_sequence' AND name = \"ex\" + SQL + assert_logged [[sql.squish, 'SCHEMA', []]] do + assert @conn.table_exists?('ex') + end + end + end + + def test_columns + with_example_table do + columns = @conn.columns('ex').sort_by { |x| x.name } + assert_equal 2, columns.length + assert_equal %w{ id number }.sort, columns.map { |x| x.name } + assert_equal [nil, nil], columns.map { |x| x.default } + assert_equal [true, true], columns.map { |x| x.null } + end + end + + def test_columns_with_default + with_example_table 'id integer PRIMARY KEY AUTOINCREMENT, number integer default 10' do + column = @conn.columns('ex').find { |x| + x.name == 'number' + } + assert_equal '10', column.default + end + end + + def test_columns_with_not_null + with_example_table 'id integer PRIMARY KEY AUTOINCREMENT, number integer not null' do + column = @conn.columns('ex').find { |x| x.name == 'number' } + assert_not column.null, "column should not be null" + end + end + + def test_indexes_logs + with_example_table do + assert_logged [["PRAGMA index_list(\"ex\")", "SCHEMA", []]] do + @conn.indexes('ex') + end + end + end + + def test_no_indexes + assert_equal [], @conn.indexes('items') + end + + def test_index + with_example_table do + @conn.add_index 'ex', 'id', unique: true, name: 'fun' + index = @conn.indexes('ex').find { |idx| idx.name == 'fun' } + + assert_equal 'ex', index.table + assert index.unique, 'index is unique' + assert_equal ['id'], index.columns + end + end + + def test_non_unique_index + with_example_table do + @conn.add_index 'ex', 'id', name: 'fun' + index = @conn.indexes('ex').find { |idx| idx.name == 'fun' } + assert_not index.unique, 'index is not unique' + end + end + + def test_compound_index + with_example_table do + @conn.add_index 'ex', %w{ id number }, name: 'fun' + index = @conn.indexes('ex').find { |idx| idx.name == 'fun' } + assert_equal %w{ id number }.sort, index.columns.sort + end + end + + def test_primary_key + with_example_table do + assert_equal 'id', @conn.primary_key('ex') + with_example_table 'internet integer PRIMARY KEY AUTOINCREMENT, number integer not null', 'foos' do + assert_equal 'internet', @conn.primary_key('foos') + end + end + end + + def test_no_primary_key + with_example_table 'number integer not null' do + assert_nil @conn.primary_key('ex') + end + end + + def test_supports_extensions + assert_not @conn.supports_extensions?, 'does not support extensions' + end + + def test_respond_to_enable_extension + assert @conn.respond_to?(:enable_extension) + end + + def test_respond_to_disable_extension + assert @conn.respond_to?(:disable_extension) + end + + def test_statement_closed + db = SQLite3::Database.new(ActiveRecord::Base. + configurations['arunit']['database']) + statement = SQLite3::Statement.new(db, + 'CREATE TABLE statement_test (number integer not null)') + statement.stubs(:step).raises(SQLite3::BusyException, 'busy') + statement.stubs(:columns).once.returns([]) + statement.expects(:close).once + SQLite3::Statement.stubs(:new).returns(statement) + + assert_raises ActiveRecord::StatementInvalid do + @conn.exec_query 'select * from statement_test' + end + end + + private + + def assert_logged logs + subscriber = SQLSubscriber.new + subscription = ActiveSupport::Notifications.subscribe('sql.active_record', subscriber) + yield + assert_equal logs, subscriber.logged + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + + def with_example_table(definition = nil, table_name = 'ex', &block) + definition ||= <<-SQL + id integer PRIMARY KEY AUTOINCREMENT, + number integer + SQL + super(@conn, table_name, definition, &block) + end + end + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb new file mode 100644 index 0000000000..f545fc2011 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb @@ -0,0 +1,21 @@ +# encoding: utf-8 +require "cases/helper" +require 'models/owner' + +module ActiveRecord + module ConnectionAdapters + class SQLite3CreateFolder < ActiveRecord::TestCase + def test_sqlite_creates_directory + Dir.mktmpdir do |dir| + dir = Pathname.new(dir) + @conn = Base.sqlite3_connection :database => dir.join("db/foo.sqlite3"), + :adapter => 'sqlite3', + :timeout => 100 + + assert Dir.exist? dir.join('db') + assert File.exist? dir.join('db/foo.sqlite3') + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb b/activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb new file mode 100644 index 0000000000..fd0044ac05 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb @@ -0,0 +1,25 @@ +require 'cases/helper' + +module ActiveRecord::ConnectionAdapters + class SQLite3Adapter + class StatementPoolTest < ActiveRecord::TestCase + if Process.respond_to?(:fork) + def test_cache_is_per_pid + + cache = StatementPool.new nil, 10 + cache['foo'] = 'bar' + assert_equal 'bar', cache['foo'] + + pid = fork { + lookup = cache['foo']; + exit!(!lookup) + } + + Process.waitpid pid + assert $?.success?, 'process should exit successfully' + end + end + end + end +end + diff --git a/activerecord/test/cases/aggregations_test.rb b/activerecord/test/cases/aggregations_test.rb new file mode 100644 index 0000000000..5536702f58 --- /dev/null +++ b/activerecord/test/cases/aggregations_test.rb @@ -0,0 +1,158 @@ +require "cases/helper" +require 'models/customer' + +class AggregationsTest < ActiveRecord::TestCase + fixtures :customers + + def test_find_single_value_object + assert_equal 50, customers(:david).balance.amount + assert_kind_of Money, customers(:david).balance + assert_equal 300, customers(:david).balance.exchange_to("DKK").amount + end + + def test_find_multiple_value_object + assert_equal customers(:david).address_street, customers(:david).address.street + assert( + customers(:david).address.close_to?(Address.new("Different Street", customers(:david).address_city, customers(:david).address_country)) + ) + end + + def test_change_single_value_object + customers(:david).balance = Money.new(100) + customers(:david).save + assert_equal 100, customers(:david).reload.balance.amount + end + + def test_immutable_value_objects + customers(:david).balance = Money.new(100) + assert_raise(RuntimeError) { customers(:david).balance.instance_eval { @amount = 20 } } + end + + def test_inferred_mapping + assert_equal "35.544623640962634", customers(:david).gps_location.latitude + assert_equal "-105.9309951055148", customers(:david).gps_location.longitude + + customers(:david).gps_location = GpsLocation.new("39x-110") + + assert_equal "39", customers(:david).gps_location.latitude + assert_equal "-110", customers(:david).gps_location.longitude + + customers(:david).save + + customers(:david).reload + + assert_equal "39", customers(:david).gps_location.latitude + assert_equal "-110", customers(:david).gps_location.longitude + end + + def test_reloaded_instance_refreshes_aggregations + assert_equal "35.544623640962634", customers(:david).gps_location.latitude + assert_equal "-105.9309951055148", customers(:david).gps_location.longitude + + Customer.update_all("gps_location = '24x113'") + customers(:david).reload + assert_equal '24x113', customers(:david)['gps_location'] + + assert_equal GpsLocation.new('24x113'), customers(:david).gps_location + end + + def test_gps_equality + assert_equal GpsLocation.new('39x110'), GpsLocation.new('39x110') + end + + def test_gps_inequality + assert_not_equal GpsLocation.new('39x110'), GpsLocation.new('39x111') + end + + def test_allow_nil_gps_is_nil + assert_nil customers(:zaphod).gps_location + end + + def test_allow_nil_gps_set_to_nil + customers(:david).gps_location = nil + customers(:david).save + customers(:david).reload + assert_nil customers(:david).gps_location + end + + def test_allow_nil_set_address_attributes_to_nil + customers(:zaphod).address = nil + assert_nil customers(:zaphod).attributes[:address_street] + assert_nil customers(:zaphod).attributes[:address_city] + assert_nil customers(:zaphod).attributes[:address_country] + end + + def test_allow_nil_address_set_to_nil + customers(:zaphod).address = nil + customers(:zaphod).save + customers(:zaphod).reload + assert_nil customers(:zaphod).address + end + + def test_nil_raises_error_when_allow_nil_is_false + assert_raise(NoMethodError) { customers(:david).balance = nil } + end + + def test_allow_nil_address_loaded_when_only_some_attributes_are_nil + customers(:zaphod).address_street = nil + customers(:zaphod).save + customers(:zaphod).reload + assert_kind_of Address, customers(:zaphod).address + assert_nil customers(:zaphod).address.street + end + + def test_nil_assignment_results_in_nil + customers(:david).gps_location = GpsLocation.new('39x111') + assert_not_nil customers(:david).gps_location + customers(:david).gps_location = nil + assert_nil customers(:david).gps_location + end + + def test_nil_return_from_converter_is_respected_when_allow_nil_is_true + customers(:david).non_blank_gps_location = "" + customers(:david).save + customers(:david).reload + assert_nil customers(:david).non_blank_gps_location + ensure + Customer.gps_conversion_was_run = nil + end + + def test_nil_return_from_converter_results_in_failure_when_allow_nil_is_false + assert_raises(NoMethodError) do + customers(:barney).gps_location = "" + end + end + + def test_do_not_run_the_converter_when_nil_was_set + customers(:david).non_blank_gps_location = nil + assert_nil Customer.gps_conversion_was_run + end + + def test_custom_constructor + assert_equal 'Barney GUMBLE', customers(:barney).fullname.to_s + assert_kind_of Fullname, customers(:barney).fullname + end + + def test_custom_converter + customers(:barney).fullname = 'Barnoit Gumbleau' + assert_equal 'Barnoit GUMBLEAU', customers(:barney).fullname.to_s + assert_kind_of Fullname, customers(:barney).fullname + end +end + +class OverridingAggregationsTest < ActiveRecord::TestCase + class DifferentName; end + + class Person < ActiveRecord::Base + composed_of :composed_of, :mapping => %w(person_first_name first_name) + end + + class DifferentPerson < Person + composed_of :composed_of, :class_name => 'DifferentName', :mapping => %w(different_person_first_name first_name) + end + + def test_composed_of_aggregation_redefinition_reflections_should_differ_and_not_inherited + assert_not_equal Person.reflect_on_aggregation(:composed_of), + DifferentPerson.reflect_on_aggregation(:composed_of) + end +end diff --git a/activerecord/test/cases/ar_schema_test.rb b/activerecord/test/cases/ar_schema_test.rb new file mode 100644 index 0000000000..8700b20dee --- /dev/null +++ b/activerecord/test/cases/ar_schema_test.rb @@ -0,0 +1,92 @@ +require "cases/helper" + +if ActiveRecord::Base.connection.supports_migrations? + + class ActiveRecordSchemaTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + def setup + @connection = ActiveRecord::Base.connection + ActiveRecord::SchemaMigration.drop_table + end + + teardown do + @connection.drop_table :fruits rescue nil + @connection.drop_table :nep_fruits rescue nil + @connection.drop_table :nep_schema_migrations rescue nil + ActiveRecord::SchemaMigration.delete_all rescue nil + end + + def test_has_no_primary_key + old_primary_key_prefix_type = ActiveRecord::Base.primary_key_prefix_type + ActiveRecord::Base.primary_key_prefix_type = :table_name_with_underscore + assert_nil ActiveRecord::SchemaMigration.primary_key + + ActiveRecord::SchemaMigration.create_table + assert_difference "ActiveRecord::SchemaMigration.count", 1 do + ActiveRecord::SchemaMigration.create version: 12 + end + ensure + ActiveRecord::SchemaMigration.drop_table + ActiveRecord::Base.primary_key_prefix_type = old_primary_key_prefix_type + end + + def test_schema_define + ActiveRecord::Schema.define(:version => 7) do + create_table :fruits do |t| + t.column :color, :string + t.column :fruit_size, :string # NOTE: "size" is reserved in Oracle + t.column :texture, :string + t.column :flavor, :string + end + end + + assert_nothing_raised { @connection.select_all "SELECT * FROM fruits" } + assert_nothing_raised { @connection.select_all "SELECT * FROM schema_migrations" } + assert_equal 7, ActiveRecord::Migrator::current_version + end + + def test_schema_define_w_table_name_prefix + table_name = ActiveRecord::SchemaMigration.table_name + old_table_name_prefix = ActiveRecord::Base.table_name_prefix + ActiveRecord::Base.table_name_prefix = "nep_" + ActiveRecord::SchemaMigration.table_name = "nep_#{table_name}" + ActiveRecord::Schema.define(:version => 7) do + create_table :fruits do |t| + t.column :color, :string + t.column :fruit_size, :string # NOTE: "size" is reserved in Oracle + t.column :texture, :string + t.column :flavor, :string + end + end + assert_equal 7, ActiveRecord::Migrator::current_version + ensure + ActiveRecord::Base.table_name_prefix = old_table_name_prefix + ActiveRecord::SchemaMigration.table_name = table_name + end + + def test_schema_raises_an_error_for_invalid_column_type + assert_raise NoMethodError do + ActiveRecord::Schema.define(:version => 8) do + create_table :vegetables do |t| + t.unknown :color + end + end + end + end + + def test_schema_subclass + Class.new(ActiveRecord::Schema).define(:version => 9) do + create_table :fruits + end + assert_nothing_raised { @connection.select_all "SELECT * FROM fruits" } + end + + def test_normalize_version + assert_equal "118", ActiveRecord::SchemaMigration.normalize_migration_number("0000118") + assert_equal "002", ActiveRecord::SchemaMigration.normalize_migration_number("2") + assert_equal "017", ActiveRecord::SchemaMigration.normalize_migration_number("0017") + assert_equal "20131219224947", ActiveRecord::SchemaMigration.normalize_migration_number("20131219224947") + end + end +end diff --git a/activerecord/test/cases/associations/association_scope_test.rb b/activerecord/test/cases/associations/association_scope_test.rb new file mode 100644 index 0000000000..3e0032ec73 --- /dev/null +++ b/activerecord/test/cases/associations/association_scope_test.rb @@ -0,0 +1,21 @@ +require 'cases/helper' +require 'models/post' +require 'models/author' + +module ActiveRecord + module Associations + class AssociationScopeTest < ActiveRecord::TestCase + test 'does not duplicate conditions' do + scope = AssociationScope.scope(Author.new.association(:welcome_posts), + Author.connection) + wheres = scope.where_values.map(&:right) + binds = scope.bind_values.map(&:last) + wheres = scope.where_values.map(&:right).reject { |node| + Arel::Nodes::BindParam === node + } + assert_equal wheres.uniq, wheres + assert_equal binds.uniq, binds + end + 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 new file mode 100644 index 0000000000..25555bd75c --- /dev/null +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb @@ -0,0 +1,948 @@ +require 'cases/helper' +require 'models/developer' +require 'models/project' +require 'models/company' +require 'models/topic' +require 'models/reply' +require 'models/computer' +require 'models/post' +require 'models/author' +require 'models/tag' +require 'models/tagging' +require 'models/comment' +require 'models/sponsor' +require 'models/member' +require 'models/essay' +require 'models/toy' +require 'models/invoice' +require 'models/line_item' +require 'models/column' +require 'models/record' + +class BelongsToAssociationsTest < ActiveRecord::TestCase + fixtures :accounts, :companies, :developers, :projects, :topics, + :developers_projects, :computers, :authors, :author_addresses, + :posts, :tags, :taggings, :comments, :sponsors, :members + + def test_belongs_to + firm = Client.find(3).firm + assert_not_nil firm + assert_equal companies(:first_firm).name, firm.name + end + + def test_belongs_to_does_not_use_order_by + ActiveRecord::SQLCounter.clear_log + Client.find(3).firm + ensure + assert ActiveRecord::SQLCounter.log_all.all? { |sql| /order by/i !~ sql }, 'ORDER BY was used in the query' + end + + def test_belongs_to_with_primary_key + client = Client.create(:name => "Primary key client", :firm_name => companies(:first_firm).name) + assert_equal companies(:first_firm).name, client.firm_with_primary_key.name + end + + def test_belongs_to_with_primary_key_joins_on_correct_column + sql = Client.joins(:firm_with_primary_key).to_sql + if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + assert_no_match(/`firm_with_primary_keys_companies`\.`id`/, sql) + assert_match(/`firm_with_primary_keys_companies`\.`name`/, sql) + elsif current_adapter?(:OracleAdapter) + # on Oracle aliases are truncated to 30 characters and are quoted in uppercase + assert_no_match(/"firm_with_primary_keys_compani"\."id"/i, sql) + assert_match(/"firm_with_primary_keys_compani"\."name"/i, sql) + else + assert_no_match(/"firm_with_primary_keys_companies"\."id"/, sql) + assert_match(/"firm_with_primary_keys_companies"\."name"/, sql) + end + end + + def test_proxy_assignment + account = Account.find(1) + assert_nothing_raised { account.firm = account.firm } + end + + def test_type_mismatch + assert_raise(ActiveRecord::AssociationTypeMismatch) { Account.find(1).firm = 1 } + assert_raise(ActiveRecord::AssociationTypeMismatch) { Account.find(1).firm = Project.find(1) } + end + + def test_natural_assignment + apple = Firm.create("name" => "Apple") + citibank = Account.create("credit_limit" => 10) + citibank.firm = apple + assert_equal apple.id, citibank.firm_id + end + + def test_id_assignment + apple = Firm.create("name" => "Apple") + citibank = Account.create("credit_limit" => 10) + citibank.firm_id = apple + assert_nil citibank.firm_id + end + + def test_natural_assignment_with_primary_key + apple = Firm.create("name" => "Apple") + citibank = Client.create("name" => "Primary key client") + citibank.firm_with_primary_key = apple + assert_equal apple.name, citibank.firm_name + end + + def test_eager_loading_with_primary_key + Firm.create("name" => "Apple") + Client.create("name" => "Citibank", :firm_name => "Apple") + citibank_result = Client.all.merge!(:where => {:name => "Citibank"}, :includes => :firm_with_primary_key).first + assert citibank_result.association_cache.key?(:firm_with_primary_key) + end + + def test_eager_loading_with_primary_key_as_symbol + Firm.create("name" => "Apple") + Client.create("name" => "Citibank", :firm_name => "Apple") + citibank_result = Client.all.merge!(:where => {:name => "Citibank"}, :includes => :firm_with_primary_key_symbols).first + assert citibank_result.association_cache.key?(:firm_with_primary_key_symbols) + end + + def test_creating_the_belonging_object + citibank = Account.create("credit_limit" => 10) + apple = citibank.create_firm("name" => "Apple") + assert_equal apple, citibank.firm + citibank.save + citibank.reload + assert_equal apple, citibank.firm + end + + def test_creating_the_belonging_object_with_primary_key + client = Client.create(:name => "Primary key client") + apple = client.create_firm_with_primary_key("name" => "Apple") + assert_equal apple, client.firm_with_primary_key + client.save + client.reload + assert_equal apple, client.firm_with_primary_key + end + + def test_building_the_belonging_object + citibank = Account.create("credit_limit" => 10) + apple = citibank.build_firm("name" => "Apple") + citibank.save + assert_equal apple.id, citibank.firm_id + end + + def test_building_the_belonging_object_with_implicit_sti_base_class + account = Account.new + company = account.build_firm + assert_kind_of Company, company, "Expected #{company.class} to be a Company" + end + + def test_building_the_belonging_object_with_explicit_sti_base_class + account = Account.new + company = account.build_firm(:type => "Company") + assert_kind_of Company, company, "Expected #{company.class} to be a Company" + end + + def test_building_the_belonging_object_with_sti_subclass + account = Account.new + company = account.build_firm(:type => "Firm") + assert_kind_of Firm, company, "Expected #{company.class} to be a Firm" + end + + def test_building_the_belonging_object_with_an_invalid_type + account = Account.new + assert_raise(ActiveRecord::SubclassNotFound) { account.build_firm(:type => "InvalidType") } + end + + def test_building_the_belonging_object_with_an_unrelated_type + account = Account.new + assert_raise(ActiveRecord::SubclassNotFound) { account.build_firm(:type => "Account") } + end + + def test_building_the_belonging_object_with_primary_key + client = Client.create(:name => "Primary key client") + apple = client.build_firm_with_primary_key("name" => "Apple") + client.save + assert_equal apple.name, client.firm_name + end + + def test_create! + client = Client.create!(:name => "Jimmy") + account = client.create_account!(:credit_limit => 10) + assert_equal account, client.account + assert account.persisted? + client.save + client.reload + assert_equal account, client.account + end + + def test_failing_create! + client = Client.create!(:name => "Jimmy") + assert_raise(ActiveRecord::RecordInvalid) { client.create_account! } + assert_not_nil client.account + assert client.account.new_record? + end + + def test_natural_assignment_to_nil + client = Client.find(3) + client.firm = nil + client.save + assert_nil client.firm(true) + assert_nil client.client_of + end + + def test_natural_assignment_to_nil_with_primary_key + client = Client.create(:name => "Primary key client", :firm_name => companies(:first_firm).name) + client.firm_with_primary_key = nil + client.save + assert_nil client.firm_with_primary_key(true) + assert_nil client.client_of + end + + def test_with_different_class_name + assert_equal Company.find(1).name, Company.find(3).firm_with_other_name.name + assert_not_nil Company.find(3).firm_with_other_name, "Microsoft should have a firm" + end + + def test_with_condition + assert_equal Company.find(1).name, Company.find(3).firm_with_condition.name + assert_not_nil Company.find(3).firm_with_condition, "Microsoft should have a firm" + end + + def test_polymorphic_association_class + sponsor = Sponsor.new + assert_nil sponsor.association(:sponsorable).send(:klass) + + sponsor.sponsorable_type = '' # the column doesn't have to be declared NOT NULL + assert_nil sponsor.association(:sponsorable).send(:klass) + + sponsor.sponsorable = Member.new :name => "Bert" + assert_equal Member, sponsor.association(:sponsorable).send(:klass) + assert_equal "members", sponsor.association(:sponsorable).aliased_table_name + end + + def test_with_polymorphic_and_condition + sponsor = Sponsor.create + member = Member.create :name => "Bert" + sponsor.sponsorable = member + + assert_equal member, sponsor.sponsorable + assert_nil sponsor.sponsorable_with_conditions + end + + def test_with_select + assert_equal 1, Company.find(2).firm_with_select.attributes.size + assert_equal 1, Company.all.merge!(:includes => :firm_with_select ).find(2).firm_with_select.attributes.size + end + + def test_belongs_to_counter + debate = Topic.create("title" => "debate") + assert_equal 0, debate.read_attribute("replies_count"), "No replies yet" + + trash = debate.replies.create("title" => "blah!", "content" => "world around!") + assert_equal 1, Topic.find(debate.id).read_attribute("replies_count"), "First reply created" + + trash.destroy + assert_equal 0, Topic.find(debate.id).read_attribute("replies_count"), "First reply deleted" + end + + def test_belongs_to_counter_with_assigning_nil + post = Post.find(1) + comment = Comment.find(1) + + assert_equal post.id, comment.post_id + assert_equal 2, Post.find(post.id).comments.size + + comment.post = nil + + assert_equal 1, Post.find(post.id).comments.size + end + + def test_belongs_to_with_primary_key_counter + debate = Topic.create("title" => "debate") + debate2 = Topic.create("title" => "debate2") + reply = Reply.create("title" => "blah!", "content" => "world around!", "parent_title" => "debate") + + assert_equal 1, debate.reload.replies_count + assert_equal 0, debate2.reload.replies_count + + reply.topic_with_primary_key = debate2 + + assert_equal 0, debate.reload.replies_count + assert_equal 1, debate2.reload.replies_count + + reply.topic_with_primary_key = nil + + assert_equal 0, debate.reload.replies_count + assert_equal 0, debate2.reload.replies_count + end + + def test_belongs_to_counter_with_reassigning + topic1 = Topic.create("title" => "t1") + topic2 = Topic.create("title" => "t2") + reply1 = Reply.new("title" => "r1", "content" => "r1") + reply1.topic = topic1 + + assert reply1.save + assert_equal 1, Topic.find(topic1.id).replies.size + assert_equal 0, Topic.find(topic2.id).replies.size + + reply1.topic = Topic.find(topic2.id) + + assert_no_queries do + reply1.topic = topic2 + end + + assert reply1.save + assert_equal 0, Topic.find(topic1.id).replies.size + assert_equal 1, Topic.find(topic2.id).replies.size + + reply1.topic = nil + + assert_equal 0, Topic.find(topic1.id).replies.size + assert_equal 0, Topic.find(topic2.id).replies.size + + reply1.topic = topic1 + + assert_equal 1, Topic.find(topic1.id).replies.size + assert_equal 0, Topic.find(topic2.id).replies.size + + reply1.destroy + + assert_equal 0, Topic.find(topic1.id).replies.size + assert_equal 0, Topic.find(topic2.id).replies.size + end + + def test_belongs_to_reassign_with_namespaced_models_and_counters + topic1 = Web::Topic.create("title" => "t1") + topic2 = Web::Topic.create("title" => "t2") + reply1 = Web::Reply.new("title" => "r1", "content" => "r1") + reply1.topic = topic1 + + assert reply1.save + assert_equal 1, Web::Topic.find(topic1.id).replies.size + assert_equal 0, Web::Topic.find(topic2.id).replies.size + + reply1.topic = Web::Topic.find(topic2.id) + + assert reply1.save + assert_equal 0, Web::Topic.find(topic1.id).replies.size + assert_equal 1, Web::Topic.find(topic2.id).replies.size + end + + def test_belongs_to_counter_after_save + topic = Topic.create!(:title => "monday night") + topic.replies.create!(:title => "re: monday night", :content => "football") + assert_equal 1, Topic.find(topic.id)[:replies_count] + + topic.save! + assert_equal 1, Topic.find(topic.id)[:replies_count] + end + + def test_belongs_to_with_touch_option_on_touch + line_item = LineItem.create! + Invoice.create!(line_items: [line_item]) + + assert_queries(1) { line_item.touch } + end + + def test_belongs_to_with_touch_option_on_touch_without_updated_at_attributes + assert_not LineItem.column_names.include?("updated_at") + + line_item = LineItem.create! + invoice = Invoice.create!(line_items: [line_item]) + initial = invoice.updated_at + line_item.touch + + assert_not_equal initial, invoice.reload.updated_at + end + + def test_belongs_to_with_touch_option_on_touch_and_removed_parent + line_item = LineItem.create! + Invoice.create!(line_items: [line_item]) + + line_item.invoice = nil + + assert_queries(2) { line_item.touch } + end + + def test_belongs_to_with_touch_option_on_update + line_item = LineItem.create! + Invoice.create!(line_items: [line_item]) + + assert_queries(2) { line_item.update amount: 10 } + end + + def test_belongs_to_with_touch_option_on_empty_update + line_item = LineItem.create! + Invoice.create!(line_items: [line_item]) + + assert_queries(0) { line_item.save } + end + + def test_belongs_to_with_touch_option_on_destroy + line_item = LineItem.create! + Invoice.create!(line_items: [line_item]) + + assert_queries(2) { line_item.destroy } + end + + def test_belongs_to_with_touch_option_on_destroy_with_destroyed_parent + line_item = LineItem.create! + invoice = Invoice.create!(line_items: [line_item]) + invoice.destroy + + assert_queries(1) { line_item.destroy } + end + + def test_belongs_to_with_touch_option_on_touch_and_reassigned_parent + line_item = LineItem.create! + Invoice.create!(line_items: [line_item]) + + line_item.invoice = Invoice.create! + + assert_queries(3) { line_item.touch } + end + + def test_belongs_to_counter_after_update + topic = Topic.create!(title: "37s") + topic.replies.create!(title: "re: 37s", content: "rails") + assert_equal 1, Topic.find(topic.id)[:replies_count] + + topic.update(title: "37signals") + assert_equal 1, Topic.find(topic.id)[:replies_count] + end + + def test_belongs_to_counter_when_update_columns + topic = Topic.create!(:title => "37s") + topic.replies.create!(:title => "re: 37s", :content => "rails") + assert_equal 1, Topic.find(topic.id)[:replies_count] + + topic.update_columns(content: "rails is wonderful") + assert_equal 1, Topic.find(topic.id)[:replies_count] + end + + def test_assignment_before_child_saved + final_cut = Client.new("name" => "Final Cut") + firm = Firm.find(1) + final_cut.firm = firm + assert !final_cut.persisted? + assert final_cut.save + assert final_cut.persisted? + assert firm.persisted? + assert_equal firm, final_cut.firm + assert_equal firm, final_cut.firm(true) + end + + def test_assignment_before_child_saved_with_primary_key + final_cut = Client.new("name" => "Final Cut") + firm = Firm.find(1) + final_cut.firm_with_primary_key = firm + assert !final_cut.persisted? + assert final_cut.save + assert final_cut.persisted? + assert firm.persisted? + assert_equal firm, final_cut.firm_with_primary_key + assert_equal firm, final_cut.firm_with_primary_key(true) + end + + def test_new_record_with_foreign_key_but_no_object + client = Client.new("firm_id" => 1) + # sometimes tests on Oracle fail if ORDER BY is not provided therefore add always :order with :first + assert_equal Firm.all.merge!(:order => "id").first, client.firm_with_basic_id + end + + def test_setting_foreign_key_after_nil_target_loaded + client = Client.new + client.firm_with_basic_id + client.firm_id = 1 + + assert_equal companies(:first_firm), client.firm_with_basic_id + end + + def test_polymorphic_setting_foreign_key_after_nil_target_loaded + sponsor = Sponsor.new + sponsor.sponsorable + sponsor.sponsorable_id = 1 + sponsor.sponsorable_type = "Member" + + assert_equal members(:groucho), sponsor.sponsorable + end + + def test_dont_find_target_when_foreign_key_is_null + tagging = taggings(:thinking_general) + assert_queries(0) { tagging.super_tag } + end + + def test_field_name_same_as_foreign_key + computer = Computer.find(1) + assert_not_nil computer.developer, ":foreign key == attribute didn't lock up" # ' + end + + def test_counter_cache + topic = Topic.create :title => "Zoom-zoom-zoom" + assert_equal 0, topic[:replies_count] + + reply = Reply.create(:title => "re: zoom", :content => "speedy quick!") + reply.topic = topic + + assert_equal 1, topic.reload[:replies_count] + assert_equal 1, topic.replies.size + + topic[:replies_count] = 15 + assert_equal 15, topic.replies.size + end + + def test_counter_cache_double_destroy + topic = Topic.create :title => "Zoom-zoom-zoom" + + 5.times do + topic.replies.create(:title => "re: zoom", :content => "speedy quick!") + end + + assert_equal 5, topic.reload[:replies_count] + assert_equal 5, topic.replies.size + + reply = topic.replies.first + + reply.destroy + assert_equal 4, topic.reload[:replies_count] + + reply.destroy + assert_equal 4, topic.reload[:replies_count] + assert_equal 4, topic.replies.size + end + + def test_concurrent_counter_cache_double_destroy + topic = Topic.create :title => "Zoom-zoom-zoom" + + 5.times do + topic.replies.create(:title => "re: zoom", :content => "speedy quick!") + end + + assert_equal 5, topic.reload[:replies_count] + assert_equal 5, topic.replies.size + + reply = topic.replies.first + reply_clone = Reply.find(reply.id) + + reply.destroy + assert_equal 4, topic.reload[:replies_count] + + reply_clone.destroy + assert_equal 4, topic.reload[:replies_count] + assert_equal 4, topic.replies.size + end + + def test_custom_counter_cache + reply = Reply.create(:title => "re: zoom", :content => "speedy quick!") + assert_equal 0, reply[:replies_count] + + silly = SillyReply.create(:title => "gaga", :content => "boo-boo") + silly.reply = reply + + assert_equal 1, reply.reload[:replies_count] + assert_equal 1, reply.replies.size + + reply[:replies_count] = 17 + assert_equal 17, reply.replies.size + end + + def test_association_assignment_sticks + post = Post.first + + author1, author2 = Author.all.merge!(:limit => 2).to_a + assert_not_nil author1 + assert_not_nil author2 + + # make sure the association is loaded + post.author + + # set the association by id, directly + post.author_id = author2.id + + # save and reload + post.save! + post.reload + + # the author id of the post should be the id we set + assert_equal post.author_id, author2.id + end + + def test_cant_save_readonly_association + assert_raise(ActiveRecord::ReadOnlyRecord) { companies(:first_client).readonly_firm.save! } + assert companies(:first_client).readonly_firm.readonly? + end + + def test_test_polymorphic_assignment_foreign_key_type_string + comment = Comment.first + comment.author = Author.first + comment.resource = Member.first + comment.save + + assert_equal Comment.all.to_a, + Comment.includes(:author).to_a + + assert_equal Comment.all.to_a, + Comment.includes(:resource).to_a + end + + def test_polymorphic_assignment_foreign_type_field_updating + # should update when assigning a saved record + sponsor = Sponsor.new + member = Member.create + sponsor.sponsorable = member + assert_equal "Member", sponsor.sponsorable_type + + # should update when assigning a new record + sponsor = Sponsor.new + member = Member.new + sponsor.sponsorable = member + assert_equal "Member", sponsor.sponsorable_type + end + + def test_polymorphic_assignment_with_primary_key_foreign_type_field_updating + # should update when assigning a saved record + essay = Essay.new + writer = Author.create(:name => "David") + essay.writer = writer + assert_equal "Author", essay.writer_type + + # should update when assigning a new record + essay = Essay.new + writer = Author.new + essay.writer = writer + assert_equal "Author", essay.writer_type + end + + def test_polymorphic_assignment_updates_foreign_id_field_for_new_and_saved_records + sponsor = Sponsor.new + saved_member = Member.create + new_member = Member.new + + sponsor.sponsorable = saved_member + assert_equal saved_member.id, sponsor.sponsorable_id + + sponsor.sponsorable = new_member + assert_nil sponsor.sponsorable_id + end + + def test_assignment_updates_foreign_id_field_for_new_and_saved_records + client = Client.new + saved_firm = Firm.create :name => "Saved" + new_firm = Firm.new + + client.firm = saved_firm + assert_equal saved_firm.id, client.client_of + + client.firm = new_firm + assert_nil client.client_of + end + + def test_polymorphic_assignment_with_primary_key_updates_foreign_id_field_for_new_and_saved_records + essay = Essay.new + saved_writer = Author.create(:name => "David") + new_writer = Author.new + + essay.writer = saved_writer + assert_equal saved_writer.name, essay.writer_id + + essay.writer = new_writer + assert_nil essay.writer_id + end + + def test_polymorphic_assignment_with_nil + essay = Essay.new + assert_nil essay.writer_id + assert_nil essay.writer_type + + essay.writer_id = 1 + essay.writer_type = 'Author' + + essay.writer = nil + assert_nil essay.writer_id + assert_nil essay.writer_type + end + + def test_belongs_to_proxy_should_not_respond_to_private_methods + assert_raise(NoMethodError) { companies(:first_firm).private_method } + assert_raise(NoMethodError) { companies(:second_client).firm.private_method } + end + + def test_belongs_to_proxy_should_respond_to_private_methods_via_send + companies(:first_firm).send(:private_method) + companies(:second_client).firm.send(:private_method) + end + + def test_save_of_record_with_loaded_belongs_to + @account = companies(:first_firm).account + + assert_nothing_raised do + Account.find(@account.id).save! + Account.all.merge!(:includes => :firm).find(@account.id).save! + end + + @account.firm.delete + + assert_nothing_raised do + Account.find(@account.id).save! + Account.all.merge!(:includes => :firm).find(@account.id).save! + end + end + + def test_dependent_delete_and_destroy_with_belongs_to + AuthorAddress.destroyed_author_address_ids.clear + + author_address = author_addresses(:david_address) + author_address_extra = author_addresses(:david_address_extra) + assert_equal [], AuthorAddress.destroyed_author_address_ids + + assert_difference "AuthorAddress.count", -2 do + authors(:david).destroy + end + + assert_equal [], AuthorAddress.where(id: [author_address.id, author_address_extra.id]) + assert_equal [author_address.id], AuthorAddress.destroyed_author_address_ids + end + + def test_belongs_to_invalid_dependent_option_raises_exception + error = assert_raise ArgumentError do + Class.new(Author).belongs_to :special_author_address, :dependent => :nullify + end + assert_equal error.message, 'The :dependent option must be one of [:destroy, :delete], but is :nullify' + end + + def test_attributes_are_being_set_when_initialized_from_belongs_to_association_with_where_clause + new_firm = accounts(:signals37).build_firm(:name => 'Apple') + assert_equal new_firm.name, "Apple" + end + + def test_attributes_are_set_without_error_when_initialized_from_belongs_to_association_with_array_in_where_clause + new_account = Account.where(:credit_limit => [ 50, 60 ]).new + assert_nil new_account.credit_limit + end + + def test_reassigning_the_parent_id_updates_the_object + client = companies(:second_client) + + client.firm + client.firm_with_condition + firm_proxy = client.send(:association_instance_get, :firm) + firm_with_condition_proxy = client.send(:association_instance_get, :firm_with_condition) + + assert !firm_proxy.stale_target? + assert !firm_with_condition_proxy.stale_target? + assert_equal companies(:first_firm), client.firm + assert_equal companies(:first_firm), client.firm_with_condition + + client.client_of = companies(:another_firm).id + + assert firm_proxy.stale_target? + assert firm_with_condition_proxy.stale_target? + assert_equal companies(:another_firm), client.firm + assert_equal companies(:another_firm), client.firm_with_condition + end + + def test_polymorphic_reassignment_of_associated_id_updates_the_object + sponsor = sponsors(:moustache_club_sponsor_for_groucho) + + sponsor.sponsorable + proxy = sponsor.send(:association_instance_get, :sponsorable) + + assert !proxy.stale_target? + assert_equal members(:groucho), sponsor.sponsorable + + sponsor.sponsorable_id = members(:some_other_guy).id + + assert proxy.stale_target? + assert_equal members(:some_other_guy), sponsor.sponsorable + end + + def test_polymorphic_reassignment_of_associated_type_updates_the_object + sponsor = sponsors(:moustache_club_sponsor_for_groucho) + + sponsor.sponsorable + proxy = sponsor.send(:association_instance_get, :sponsorable) + + assert !proxy.stale_target? + assert_equal members(:groucho), sponsor.sponsorable + + sponsor.sponsorable_type = 'Firm' + + assert proxy.stale_target? + assert_equal companies(:first_firm), sponsor.sponsorable + end + + def test_reloading_association_with_key_change + client = companies(:second_client) + firm = client.association(:firm) + + client.firm = companies(:another_firm) + firm.reload + assert_equal companies(:another_firm), firm.target + + client.client_of = companies(:first_firm).id + firm.reload + assert_equal companies(:first_firm), firm.target + end + + def test_polymorphic_counter_cache + tagging = taggings(:welcome_general) + post = posts(:welcome) + comment = comments(:greetings) + + assert_difference lambda { post.reload.tags_count }, -1 do + assert_difference 'comment.reload.tags_count', +1 do + tagging.taggable = comment + end + end + end + + def test_polymorphic_with_custom_foreign_type + sponsor = sponsors(:moustache_club_sponsor_for_groucho) + groucho = members(:groucho) + other = members(:some_other_guy) + + assert_equal groucho, sponsor.sponsorable + assert_equal groucho, sponsor.thing + + sponsor.thing = other + + assert_equal other, sponsor.sponsorable + assert_equal other, sponsor.thing + + sponsor.sponsorable = groucho + + assert_equal groucho, sponsor.sponsorable + assert_equal groucho, sponsor.thing + end + + def test_build_with_conditions + client = companies(:second_client) + firm = client.build_bob_firm + + assert_equal "Bob", firm.name + end + + def test_create_with_conditions + client = companies(:second_client) + firm = client.create_bob_firm + + assert_equal "Bob", firm.name + end + + def test_create_bang_with_conditions + client = companies(:second_client) + firm = client.create_bob_firm! + + assert_equal "Bob", firm.name + end + + def test_build_with_block + client = Client.create(:name => 'Client Company') + + firm = client.build_firm{ |f| f.name = 'Agency Company' } + assert_equal 'Agency Company', firm.name + end + + def test_create_with_block + client = Client.create(:name => 'Client Company') + + firm = client.create_firm{ |f| f.name = 'Agency Company' } + assert_equal 'Agency Company', firm.name + end + + def test_create_bang_with_block + client = Client.create(:name => 'Client Company') + + firm = client.create_firm!{ |f| f.name = 'Agency Company' } + assert_equal 'Agency Company', firm.name + end + + def test_should_set_foreign_key_on_create_association + client = Client.create! :name => "fuu" + + firm = client.create_firm :name => "baa" + assert_equal firm.id, client.client_of + end + + def test_should_set_foreign_key_on_create_association! + client = Client.create! :name => "fuu" + + firm = client.create_firm! :name => "baa" + assert_equal firm.id, client.client_of + end + + def test_self_referential_belongs_to_with_counter_cache_assigning_nil + comment = Comment.create! :post => posts(:thinking), :body => "fuu" + comment.parent = nil + comment.save! + + assert_equal nil, comment.reload.parent + assert_equal 0, comments(:greetings).reload.children_count + end + + def test_belongs_to_with_id_assigning + post = posts(:welcome) + comment = Comment.create! body: "foo", post: post + parent = comments(:greetings) + assert_equal 0, parent.reload.children_count + comment.parent_id = parent.id + + comment.save! + assert_equal 1, parent.reload.children_count + end + + def test_polymorphic_with_custom_primary_key + toy = Toy.create! + sponsor = Sponsor.create!(:sponsorable => toy) + + assert_equal toy, sponsor.reload.sponsorable + end + + test "stale tracking doesn't care about the type" do + apple = Firm.create("name" => "Apple") + citibank = Account.create("credit_limit" => 10) + + citibank.firm_id = apple.id + citibank.firm # load it + + citibank.firm_id = apple.id.to_s + + assert !citibank.association(:firm).stale_target? + end + + def test_reflect_the_most_recent_change + author1, author2 = Author.limit(2) + post = Post.new(:title => "foo", :body=> "bar") + + post.author = author1 + post.author_id = author2.id + + assert post.save + assert_equal post.author_id, author2.id + end + + test 'dangerous association name raises ArgumentError' do + [:errors, 'errors', :save, 'save'].each do |name| + assert_raises(ArgumentError, "Association #{name} should not be allowed") do + Class.new(ActiveRecord::Base) do + belongs_to name + end + end + end + end + + test 'belongs_to works with model called Record' do + record = Record.create! + Column.create! record: record + assert_equal 1, Column.count + end +end + +class BelongsToWithForeignKeyTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses + + def test_destroy_linked_models + address = AuthorAddress.create! + author = Author.create! name: "Author", author_address_id: address.id + + author.destroy! + end +end diff --git a/activerecord/test/cases/associations/callbacks_test.rb b/activerecord/test/cases/associations/callbacks_test.rb new file mode 100644 index 0000000000..5b7e462f64 --- /dev/null +++ b/activerecord/test/cases/associations/callbacks_test.rb @@ -0,0 +1,189 @@ +require "cases/helper" +require 'models/post' +require 'models/author' +require 'models/project' +require 'models/developer' +require 'models/company' + +class AssociationCallbacksTest < ActiveRecord::TestCase + fixtures :posts, :authors, :projects, :developers + + def setup + @david = authors(:david) + @thinking = posts(:thinking) + @authorless = posts(:authorless) + assert @david.post_log.empty? + end + + def test_adding_macro_callbacks + @david.posts_with_callbacks << @thinking + assert_equal ["before_adding#{@thinking.id}", "after_adding#{@thinking.id}"], @david.post_log + @david.posts_with_callbacks << @thinking + assert_equal ["before_adding#{@thinking.id}", "after_adding#{@thinking.id}", "before_adding#{@thinking.id}", + "after_adding#{@thinking.id}"], @david.post_log + end + + def test_adding_with_proc_callbacks + @david.posts_with_proc_callbacks << @thinking + assert_equal ["before_adding#{@thinking.id}", "after_adding#{@thinking.id}"], @david.post_log + @david.posts_with_proc_callbacks << @thinking + assert_equal ["before_adding#{@thinking.id}", "after_adding#{@thinking.id}", "before_adding#{@thinking.id}", + "after_adding#{@thinking.id}"], @david.post_log + end + + def test_removing_with_macro_callbacks + first_post, second_post = @david.posts_with_callbacks[0, 2] + @david.posts_with_callbacks.delete(first_post) + assert_equal ["before_removing#{first_post.id}", "after_removing#{first_post.id}"], @david.post_log + @david.posts_with_callbacks.delete(second_post) + assert_equal ["before_removing#{first_post.id}", "after_removing#{first_post.id}", "before_removing#{second_post.id}", + "after_removing#{second_post.id}"], @david.post_log + end + + def test_removing_with_proc_callbacks + first_post, second_post = @david.posts_with_callbacks[0, 2] + @david.posts_with_proc_callbacks.delete(first_post) + assert_equal ["before_removing#{first_post.id}", "after_removing#{first_post.id}"], @david.post_log + @david.posts_with_proc_callbacks.delete(second_post) + assert_equal ["before_removing#{first_post.id}", "after_removing#{first_post.id}", "before_removing#{second_post.id}", + "after_removing#{second_post.id}"], @david.post_log + end + + def test_multiple_callbacks + @david.posts_with_multiple_callbacks << @thinking + assert_equal ["before_adding#{@thinking.id}", "before_adding_proc#{@thinking.id}", "after_adding#{@thinking.id}", + "after_adding_proc#{@thinking.id}"], @david.post_log + @david.posts_with_multiple_callbacks << @thinking + assert_equal ["before_adding#{@thinking.id}", "before_adding_proc#{@thinking.id}", "after_adding#{@thinking.id}", + "after_adding_proc#{@thinking.id}", "before_adding#{@thinking.id}", "before_adding_proc#{@thinking.id}", + "after_adding#{@thinking.id}", "after_adding_proc#{@thinking.id}"], @david.post_log + end + + def test_has_many_callbacks_with_create + morten = Author.create :name => "Morten" + post = morten.posts_with_proc_callbacks.create! :title => "Hello", :body => "How are you doing?" + assert_equal ["before_adding<new>", "after_adding#{post.id}"], morten.post_log + end + + def test_has_many_callbacks_with_create! + morten = Author.create! :name => "Morten" + post = morten.posts_with_proc_callbacks.create :title => "Hello", :body => "How are you doing?" + assert_equal ["before_adding<new>", "after_adding#{post.id}"], morten.post_log + end + + def test_has_many_callbacks_for_save_on_parent + jack = Author.new :name => "Jack" + jack.posts_with_callbacks.build :title => "Call me back!", :body => "Before you wake up and after you sleep" + + callback_log = ["before_adding<new>", "after_adding#{jack.posts_with_callbacks.first.id}"] + assert_equal callback_log, jack.post_log + assert jack.save + assert_equal 1, jack.posts_with_callbacks.count + assert_equal callback_log, jack.post_log + end + + def test_has_many_callbacks_for_destroy_on_parent + firm = Firm.create! :name => "Firm" + client = firm.clients.create! :name => "Client" + firm.destroy + + assert_equal ["before_remove#{client.id}", "after_remove#{client.id}"], firm.log + end + + def test_has_and_belongs_to_many_add_callback + david = developers(:david) + ar = projects(:active_record) + assert ar.developers_log.empty? + ar.developers_with_callbacks << david + assert_equal ["before_adding#{david.id}", "after_adding#{david.id}"], ar.developers_log + ar.developers_with_callbacks << david + assert_equal ["before_adding#{david.id}", "after_adding#{david.id}", "before_adding#{david.id}", + "after_adding#{david.id}"], ar.developers_log + end + + def test_has_and_belongs_to_many_before_add_called_before_save + dev = nil + new_dev = nil + klass = Class.new(Project) do + def self.name; Project.name; end + has_and_belongs_to_many :developers_with_callbacks, + :class_name => "Developer", + :before_add => lambda { |o,r| + dev = r + new_dev = r.new_record? + } + end + rec = klass.create! + alice = Developer.new(:name => 'alice') + rec.developers_with_callbacks << alice + assert_equal alice, dev + assert_not_nil new_dev + assert new_dev, "record should not have been saved" + assert_not alice.new_record? + end + + def test_has_and_belongs_to_many_after_add_called_after_save + ar = projects(:active_record) + assert ar.developers_log.empty? + alice = Developer.new(:name => 'alice') + ar.developers_with_callbacks << alice + assert_equal"after_adding#{alice.id}", ar.developers_log.last + + bob = ar.developers_with_callbacks.create(:name => 'bob') + assert_equal "after_adding#{bob.id}", ar.developers_log.last + + ar.developers_with_callbacks.build(:name => 'charlie') + assert_equal "after_adding<new>", ar.developers_log.last + end + + + def test_has_and_belongs_to_many_remove_callback + david = developers(:david) + jamis = developers(:jamis) + activerecord = projects(:active_record) + assert activerecord.developers_log.empty? + activerecord.developers_with_callbacks.delete(david) + assert_equal ["before_removing#{david.id}", "after_removing#{david.id}"], activerecord.developers_log + + activerecord.developers_with_callbacks.delete(jamis) + assert_equal ["before_removing#{david.id}", "after_removing#{david.id}", "before_removing#{jamis.id}", + "after_removing#{jamis.id}"], activerecord.developers_log + end + + def test_has_and_belongs_to_many_does_not_fire_callbacks_on_clear + activerecord = projects(:active_record) + assert activerecord.developers_log.empty? + if activerecord.developers_with_callbacks.size == 0 + activerecord.developers << developers(:david) + activerecord.developers << developers(:jamis) + activerecord.reload + assert activerecord.developers_with_callbacks.size == 2 + end + activerecord.developers_with_callbacks.flat_map {|d| ["before_removing#{d.id}","after_removing#{d.id}"]}.sort + assert activerecord.developers_with_callbacks.clear + assert_predicate activerecord.developers_log, :empty? + end + + def test_has_many_and_belongs_to_many_callbacks_for_save_on_parent + project = Project.new :name => "Callbacks" + project.developers_with_callbacks.build :name => "Jack", :salary => 95000 + + callback_log = ["before_adding<new>", "after_adding<new>"] + assert_equal callback_log, project.developers_log + assert project.save + assert_equal 1, project.developers_with_callbacks.size + assert_equal callback_log, project.developers_log + end + + def test_dont_add_if_before_callback_raises_exception + assert !@david.unchangable_posts.include?(@authorless) + begin + @david.unchangable_posts << @authorless + rescue Exception + end + assert @david.post_log.empty? + assert !@david.unchangable_posts.include?(@authorless) + @david.reload + assert !@david.unchangable_posts.include?(@authorless) + end +end diff --git a/activerecord/test/cases/associations/cascaded_eager_loading_test.rb b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb new file mode 100644 index 0000000000..51d8e0523e --- /dev/null +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -0,0 +1,188 @@ +require "cases/helper" +require 'models/post' +require 'models/comment' +require 'models/author' +require 'models/categorization' +require 'models/category' +require 'models/company' +require 'models/topic' +require 'models/reply' +require 'models/person' +require 'models/vertex' +require 'models/edge' + +class CascadedEagerLoadingTest < ActiveRecord::TestCase + fixtures :authors, :mixins, :companies, :posts, :topics, :accounts, :comments, + :categorizations, :people, :categories, :edges, :vertices + + def test_eager_association_loading_with_cascaded_two_levels + authors = Author.all.merge!(:includes=>{:posts=>:comments}, :order=>"authors.id").to_a + assert_equal 3, authors.size + assert_equal 5, authors[0].posts.size + assert_equal 3, authors[1].posts.size + assert_equal 10, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} + end + + def test_eager_association_loading_with_cascaded_two_levels_and_one_level + authors = Author.all.merge!(:includes=>[{:posts=>:comments}, :categorizations], :order=>"authors.id").to_a + assert_equal 3, authors.size + assert_equal 5, authors[0].posts.size + assert_equal 3, authors[1].posts.size + assert_equal 10, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} + assert_equal 1, authors[0].categorizations.size + assert_equal 2, authors[1].categorizations.size + end + + def test_eager_association_loading_with_hmt_does_not_table_name_collide_when_joining_associations + assert_nothing_raised do + Author.joins(:posts).eager_load(:comments).where(:posts => {:tags_count => 1}).to_a + end + authors = Author.joins(:posts).eager_load(:comments).where(:posts => {:tags_count => 1}).to_a + assert_equal 1, assert_no_queries { authors.size } + assert_equal 10, assert_no_queries { authors[0].comments.size } + end + + def test_eager_association_loading_grafts_stashed_associations_to_correct_parent + assert_nothing_raised do + Person.eager_load(:primary_contact => :primary_contact).where('primary_contacts_people_2.first_name = ?', 'Susan').order('people.id').to_a + end + assert_equal people(:michael), Person.eager_load(:primary_contact => :primary_contact).where('primary_contacts_people_2.first_name = ?', 'Susan').order('people.id').first + end + + def test_cascaded_eager_association_loading_with_join_for_count + categories = Category.joins(:categorizations).includes([{:posts=>:comments}, :authors]) + + assert_equal 4, categories.count + assert_equal 4, categories.to_a.count + assert_equal 3, categories.distinct.count + assert_equal 3, categories.to_a.uniq.size # Must uniq since instantiating with inner joins will get dupes + end + + def test_cascaded_eager_association_loading_with_duplicated_includes + categories = Category.includes(:categorizations).includes(:categorizations => :author).where("categorizations.id is not null").references(:categorizations) + assert_nothing_raised do + assert_equal 3, categories.count + assert_equal 3, categories.to_a.size + end + end + + def test_cascaded_eager_association_loading_with_twice_includes_edge_cases + categories = Category.includes(:categorizations => :author).includes(:categorizations => :post).where("posts.id is not null").references(:posts) + assert_nothing_raised do + assert_equal 3, categories.count + assert_equal 3, categories.to_a.size + end + end + + def test_eager_association_loading_with_join_for_count + authors = Author.joins(:special_posts).includes([:posts, :categorizations]) + + assert_nothing_raised { authors.count } + assert_queries(3) { authors.to_a } + end + + def test_eager_association_loading_with_cascaded_two_levels_with_two_has_many_associations + authors = Author.all.merge!(:includes=>{:posts=>[:comments, :categorizations]}, :order=>"authors.id").to_a + assert_equal 3, authors.size + assert_equal 5, authors[0].posts.size + assert_equal 3, authors[1].posts.size + assert_equal 10, authors[0].posts.collect{|post| post.comments.size }.inject(0){|sum,i| sum+i} + end + + def test_eager_association_loading_with_cascaded_two_levels_and_self_table_reference + authors = Author.all.merge!(:includes=>{:posts=>[:comments, :author]}, :order=>"authors.id").to_a + assert_equal 3, authors.size + assert_equal 5, authors[0].posts.size + assert_equal authors(:david).name, authors[0].name + assert_equal [authors(:david).name], authors[0].posts.collect{|post| post.author.name}.uniq + end + + def test_eager_association_loading_with_cascaded_two_levels_with_condition + authors = Author.all.merge!(:includes=>{:posts=>:comments}, :where=>"authors.id=1", :order=>"authors.id").to_a + assert_equal 1, authors.size + assert_equal 5, authors[0].posts.size + end + + def test_eager_association_loading_with_cascaded_three_levels_by_ping_pong + firms = Firm.all.merge!(:includes=>{:account=>{:firm=>:account}}, :order=>"companies.id").to_a + assert_equal 2, firms.size + assert_equal firms.first.account, firms.first.account.firm.account + assert_equal companies(:first_firm).account, assert_no_queries { firms.first.account.firm.account } + assert_equal companies(:first_firm).account.firm.account, assert_no_queries { firms.first.account.firm.account } + end + + def test_eager_association_loading_with_has_many_sti + topics = Topic.all.merge!(:includes => :replies, :order => 'topics.id').to_a + first, second, = topics(:first).replies.size, topics(:second).replies.size + assert_no_queries do + assert_equal first, topics[0].replies.size + assert_equal second, topics[1].replies.size + 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.all.merge!(:includes => :replies, :order => ['topics.id', 'replies_topics.id']).to_a + 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.all.merge!(:includes => :topic, :order => 'topics.id').to_a + assert replies.include?(topics(:second)) + assert !replies.include?(topics(:first)) + assert_equal topics(:first), assert_no_queries { replies.first.topic } + end + + def test_eager_association_loading_with_multiple_stis_and_order + author = Author.all.merge!(:includes => { :posts => [ :special_comments , :very_special_comment ] }, :order => ['authors.name', 'comments.body', 'very_special_comments_posts.body'], :where => 'posts.id = 4').first + assert_equal authors(:david), author + assert_no_queries do + author.posts.first.special_comments + author.posts.first.very_special_comment + end + end + + def test_eager_association_loading_of_stis_with_multiple_references + authors = Author.all.merge!(:includes => { :posts => { :special_comments => { :post => [ :special_comments, :very_special_comment ] } } }, :order => 'comments.body, very_special_comments_posts.body', :where => 'posts.id = 4').to_a + assert_equal [authors(:david)], authors + assert_no_queries do + authors.first.posts.first.special_comments.first.post.special_comments + authors.first.posts.first.special_comments.first.post.very_special_comment + end + end + + def test_eager_association_loading_where_first_level_returns_nil + authors = Author.all.merge!(:includes => {:post_about_thinking => :comments}, :order => 'authors.id DESC').to_a + assert_equal [authors(:bob), authors(:mary), authors(:david)], authors + assert_no_queries do + authors[2].post_about_thinking.comments.first + end + end + + def test_eager_association_loading_with_recursive_cascading_four_levels_has_many_through + source = Vertex.all.merge!(:includes=>{:sinks=>{:sinks=>{:sinks=>:sinks}}}, :order => 'vertices.id').first + assert_equal vertices(:vertex_4), assert_no_queries { source.sinks.first.sinks.first.sinks.first } + end + + def test_eager_association_loading_with_recursive_cascading_four_levels_has_and_belongs_to_many + sink = Vertex.all.merge!(:includes=>{:sources=>{:sources=>{:sources=>:sources}}}, :order => 'vertices.id DESC').first + assert_equal vertices(:vertex_1), assert_no_queries { sink.sources.first.sources.first.sources.first.sources.first } + end + + def test_eager_association_loading_with_cascaded_interdependent_one_level_and_two_levels + authors_relation = Author.all.merge!(includes: [:comments, { posts: :categorizations }], order: "authors.id") + authors = authors_relation.to_a + assert_equal 3, authors.size + assert_equal 10, authors[0].comments.size + assert_equal 1, authors[1].comments.size + assert_equal 5, authors[0].posts.size + assert_equal 3, authors[1].posts.size + assert_equal 3, authors[0].posts.collect { |post| post.categorizations.size }.inject(0) { |sum, i| sum+i } + end +end diff --git a/activerecord/test/cases/associations/deprecated_counter_cache_on_has_many_through_test.rb b/activerecord/test/cases/associations/deprecated_counter_cache_on_has_many_through_test.rb new file mode 100644 index 0000000000..48f7ddbe83 --- /dev/null +++ b/activerecord/test/cases/associations/deprecated_counter_cache_on_has_many_through_test.rb @@ -0,0 +1,26 @@ +require "cases/helper" + +class DeprecatedCounterCacheOnHasManyThroughTest < ActiveRecord::TestCase + class Post < ActiveRecord::Base + has_many :taggings, as: :taggable + has_many :tags, through: :taggings + end + + class Tagging < ActiveRecord::Base + belongs_to :taggable, polymorphic: true + belongs_to :tag + end + + class Tag < ActiveRecord::Base + end + + test "counter caches are updated in the database if the belongs_to association doesn't specify a counter cache" do + post = Post.create!(title: 'Hello', body: 'World!') + assert_deprecated { post.tags << Tag.create!(name: 'whatever') } + + assert_equal 1, post.tags.size + assert_equal 1, post.tags_count + assert_equal 1, post.reload.tags.size + assert_equal 1, post.reload.tags_count + end +end 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..75a6295350 --- /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 + self.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.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.includes(:tagging).find_by_title('Great stuff') + assert_nil post.tagging + + ActiveRecord::Base.store_full_sti_class = true + post = Namespaced::Post.includes(:tagging).find_by_title('Great stuff') + assert_instance_of Tagging, post.tagging + 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 new file mode 100644 index 0000000000..0ff87d53ea --- /dev/null +++ b/activerecord/test/cases/associations/eager_load_nested_include_test.rb @@ -0,0 +1,128 @@ +require 'cases/helper' +require 'models/post' +require 'models/tag' +require 'models/author' +require 'models/comment' +require 'models/category' +require 'models/categorization' +require 'models/tagging' + +module Remembered + extend ActiveSupport::Concern + + included do + after_create :remember + protected + def remember; self.class.remembered << self; end + end + + module ClassMethods + def remembered; @@remembered ||= []; end + def sample; @@remembered.sample; end + end +end + +class ShapeExpression < ActiveRecord::Base + belongs_to :shape, :polymorphic => true + belongs_to :paint, :polymorphic => true +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 + + + +class EagerLoadPolyAssocsTest < ActiveRecord::TestCase + NUM_SIMPLE_OBJS = 50 + NUM_SHAPE_EXPRESSIONS = 100 + + def setup + generate_test_object_graphs + end + + teardown do + [Circle, Square, Triangle, PaintColor, PaintTexture, + ShapeExpression, NonPolyOne, NonPolyTwo].each do |c| + c.delete_all + end + 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 + PaintColor.create!(:non_poly_one_id => NonPolyOne.sample.id) + PaintTexture.create!(:non_poly_two_id => NonPolyTwo.sample.id) + end + 1.upto(NUM_SHAPE_EXPRESSIONS) do + shape_type = [Circle, Square, Triangle].sample + paint_type = [PaintColor, PaintTexture].sample + ShapeExpression.create!(:shape_type => shape_type.to_s, :shape_id => shape_type.sample.id, + :paint_type => paint_type.to_s, :paint_id => paint_type.sample.id) + end + end + + def test_include_query + res = ShapeExpression.all.merge!(:includes => [ :shape, { :paint => :non_poly } ]).to_a + assert_equal NUM_SHAPE_EXPRESSIONS, res.size + assert_queries(0) do + res.each do |se| + assert_not_nil se.paint.non_poly, "this is the association that was loading incorrectly before the change" + assert_not_nil se.shape, "just making sure other associations still work" + end + end + end +end + +class EagerLoadNestedIncludeWithMissingDataTest < ActiveRecord::TestCase + def setup + @davey_mcdave = Author.create(:name => 'Davey McDave') + @first_post = @davey_mcdave.posts.create(:title => 'Davey Speaks', :body => 'Expressive wordage') + @first_comment = @first_post.comments.create(:body => 'Inflamatory doublespeak') + @first_categorization = @davey_mcdave.categorizations.create(:category => Category.first, :post => @first_post) + end + + teardown do + @davey_mcdave.destroy + @first_post.destroy + @first_comment.destroy + @first_categorization.destroy + end + + def test_missing_data_in_a_nested_include_should_not_cause_errors_when_constructing_objects + assert_nothing_raised do + # @davey_mcdave doesn't have any author_favorites + includes = {:posts => :comments, :categorizations => :category, :author_favorites => :favorite_author } + Author.all.merge!(:includes => includes, :where => {:authors => {:name => @davey_mcdave.name}}, :order => 'categories.name').to_a + end + end +end diff --git a/activerecord/test/cases/associations/eager_singularization_test.rb b/activerecord/test/cases/associations/eager_singularization_test.rb new file mode 100644 index 0000000000..a61a070331 --- /dev/null +++ b/activerecord/test/cases/associations/eager_singularization_test.rb @@ -0,0 +1,148 @@ +require "cases/helper" + + +if ActiveRecord::Base.connection.supports_migrations? +class EagerSingularizationTest < ActiveRecord::TestCase + class Virus < ActiveRecord::Base + belongs_to :octopus + end + + class Octopus < ActiveRecord::Base + has_one :virus + end + + class Pass < ActiveRecord::Base + belongs_to :bus + end + + class Bus < ActiveRecord::Base + has_many :passes + end + + class Mess < ActiveRecord::Base + has_and_belongs_to_many :crises + end + + class Crisis < ActiveRecord::Base + has_and_belongs_to_many :messes + has_many :analyses, :dependent => :destroy + has_many :successes, :through => :analyses + has_many :dresses, :dependent => :destroy + has_many :compresses, :through => :dresses + end + + class Analysis < ActiveRecord::Base + belongs_to :crisis + belongs_to :success + end + + class Success < ActiveRecord::Base + has_many :analyses, :dependent => :destroy + has_many :crises, :through => :analyses + end + + class Dress < ActiveRecord::Base + belongs_to :crisis + has_many :compresses + end + + class Compress < ActiveRecord::Base + belongs_to :dress + end + + def setup + connection.create_table :viri do |t| + t.column :octopus_id, :integer + t.column :species, :string + end + connection.create_table :octopi do |t| + t.column :species, :string + end + connection.create_table :passes do |t| + t.column :bus_id, :integer + t.column :rides, :integer + end + connection.create_table :buses do |t| + t.column :name, :string + end + connection.create_table :crises_messes, :id => false do |t| + t.column :crisis_id, :integer + t.column :mess_id, :integer + end + connection.create_table :messes do |t| + t.column :name, :string + end + connection.create_table :crises do |t| + t.column :name, :string + end + connection.create_table :successes do |t| + t.column :name, :string + end + connection.create_table :analyses do |t| + t.column :crisis_id, :integer + t.column :success_id, :integer + end + connection.create_table :dresses do |t| + t.column :crisis_id, :integer + end + connection.create_table :compresses do |t| + t.column :dress_id, :integer + end + end + + teardown do + connection.drop_table :viri + connection.drop_table :octopi + connection.drop_table :passes + connection.drop_table :buses + connection.drop_table :crises_messes + connection.drop_table :messes + connection.drop_table :crises + connection.drop_table :successes + connection.drop_table :analyses + connection.drop_table :dresses + connection.drop_table :compresses + end + + def connection + ActiveRecord::Base.connection + end + + def test_eager_no_extra_singularization_belongs_to + assert_nothing_raised do + Virus.all.merge!(:includes => :octopus).to_a + end + end + + def test_eager_no_extra_singularization_has_one + assert_nothing_raised do + Octopus.all.merge!(:includes => :virus).to_a + end + end + + def test_eager_no_extra_singularization_has_many + assert_nothing_raised do + Bus.all.merge!(:includes => :passes).to_a + end + end + + def test_eager_no_extra_singularization_has_and_belongs_to_many + assert_nothing_raised do + Crisis.all.merge!(:includes => :messes).to_a + Mess.all.merge!(:includes => :crises).to_a + end + end + + def test_eager_no_extra_singularization_has_many_through_belongs_to + assert_nothing_raised do + Crisis.all.merge!(:includes => :successes).to_a + end + end + + def test_eager_no_extra_singularization_has_many_through_has_many + assert_nothing_raised do + Crisis.all.merge!(:includes => :compresses).to_a + end + end +end +end diff --git a/activerecord/test/cases/associations/eager_test.rb b/activerecord/test/cases/associations/eager_test.rb new file mode 100644 index 0000000000..21912fdf0f --- /dev/null +++ b/activerecord/test/cases/associations/eager_test.rb @@ -0,0 +1,1293 @@ +require "cases/helper" +require 'models/post' +require 'models/tagging' +require 'models/tag' +require 'models/comment' +require 'models/author' +require 'models/essay' +require 'models/category' +require 'models/company' +require 'models/person' +require 'models/reader' +require 'models/owner' +require 'models/pet' +require 'models/reference' +require 'models/job' +require 'models/subscriber' +require 'models/subscription' +require 'models/book' +require 'models/developer' +require 'models/project' +require 'models/member' +require 'models/membership' +require 'models/club' +require 'models/categorization' +require 'models/sponsor' + +class EagerAssociationTest < ActiveRecord::TestCase + fixtures :posts, :comments, :authors, :essays, :author_addresses, :categories, :categories_posts, + :companies, :accounts, :tags, :taggings, :people, :readers, :categorizations, + :owners, :pets, :author_favorites, :jobs, :references, :subscribers, :subscriptions, :books, + :developers, :projects, :developers_projects, :members, :memberships, :clubs, :sponsors + + def test_eager_with_has_one_through_join_model_with_conditions_on_the_through + member = Member.all.merge!(:includes => :favourite_club).find(members(:some_other_guy).id) + assert_nil member.favourite_club + end + + def test_loading_with_one_association + posts = Post.all.merge!(:includes => :comments).to_a + post = posts.find { |p| p.id == 1 } + assert_equal 2, post.comments.size + assert post.comments.include?(comments(:greetings)) + + post = Post.all.merge!(:includes => :comments, :where => "posts.title = 'Welcome to the weblog'").first + assert_equal 2, post.comments.size + assert post.comments.include?(comments(:greetings)) + + posts = Post.all.merge!(:includes => :last_comment).to_a + post = posts.find { |p| p.id == 1 } + assert_equal Post.find(1).last_comment, post.last_comment + end + + def test_loading_with_one_association_with_non_preload + posts = Post.all.merge!(:includes => :last_comment, :order => 'comments.id DESC').to_a + 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.references(:comments).merge( + :includes => :comments, + :where => "comments.body like 'Normal%' OR comments.#{QUOTED_TYPE} = 'SpecialComment'" + ).to_a + assert_nil posts.detect { |p| p.author_id != authors(:david).id }, + "expected to find only david's posts" + end + + def test_with_ordering + list = Post.all.merge!(:includes => :comments, :order => "posts.id DESC").to_a + [:other_by_mary, :other_by_bob, :misc_by_mary, :misc_by_bob, :eager_other, + :sti_habtm, :sti_post_and_comments, :sti_comments, :authorless, :thinking, :welcome + ].each_with_index do |post, index| + assert_equal posts(post), list[index] + end + end + + def test_has_many_through_with_order + authors = Author.includes(:favorite_authors).to_a + assert_no_queries { authors.map(&:favorite_authors) } + end + + def test_with_two_tables_in_from_without_getting_double_quoted + posts = Post.select("posts.*").from("authors, posts").eager_load(:comments).where("posts.author_id = authors.id").order("posts.id").to_a + assert_equal 2, posts.first.comments.size + end + + def test_loading_with_multiple_associations + posts = Post.all.merge!(:includes => [ :comments, :author, :categories ], :order => "posts.id").to_a + assert_equal 2, posts.first.comments.size + assert_equal 2, posts.first.categories.size + assert posts.first.comments.include?(comments(:greetings)) + end + + def test_duplicate_middle_objects + comments = Comment.all.merge!(:where => 'post_id = 1', :includes => [:post => :author]).to_a + assert_no_queries do + comments.each {|comment| comment.post.author.name} + end + end + + def test_preloading_has_many_in_multiple_queries_with_more_ids_than_database_can_handle + Comment.connection.expects(:in_clause_length).at_least_once.returns(5) + posts = Post.all.merge!(:includes=>:comments).to_a + assert_equal 11, posts.size + end + + def test_preloading_has_many_in_one_queries_when_database_has_no_limit_on_ids_it_can_handle + Comment.connection.expects(:in_clause_length).at_least_once.returns(nil) + posts = Post.all.merge!(:includes=>:comments).to_a + assert_equal 11, posts.size + end + + def test_preloading_habtm_in_multiple_queries_with_more_ids_than_database_can_handle + Comment.connection.expects(:in_clause_length).at_least_once.returns(5) + posts = Post.all.merge!(:includes=>:categories).to_a + assert_equal 11, posts.size + end + + def test_preloading_habtm_in_one_queries_when_database_has_no_limit_on_ids_it_can_handle + Comment.connection.expects(:in_clause_length).at_least_once.returns(nil) + posts = Post.all.merge!(:includes=>:categories).to_a + assert_equal 11, posts.size + end + + def test_load_associated_records_in_one_query_when_adapter_has_no_limit + Comment.connection.expects(:in_clause_length).at_least_once.returns(nil) + + post = posts(:welcome) + assert_queries(2) do + Post.includes(:comments).where(:id => post.id).to_a + end + end + + def test_load_associated_records_in_several_queries_when_many_ids_passed + Comment.connection.expects(:in_clause_length).at_least_once.returns(1) + + post1, post2 = posts(:welcome), posts(:thinking) + assert_queries(3) do + Post.includes(:comments).where(:id => [post1.id, post2.id]).to_a + end + end + + def test_load_associated_records_in_one_query_when_a_few_ids_passed + Comment.connection.expects(:in_clause_length).at_least_once.returns(3) + + post = posts(:welcome) + assert_queries(2) do + Post.includes(:comments).where(:id => post.id).to_a + end + end + + def test_including_duplicate_objects_from_belongs_to + popular_post = Post.create!(:title => 'foo', :body => "I like cars!") + comment = popular_post.comments.create!(:body => "lol") + popular_post.readers.create!(:person => people(:michael)) + popular_post.readers.create!(:person => people(:david)) + + readers = Reader.all.merge!(:where => ["post_id = ?", popular_post.id], + :includes => {:post => :comments}).to_a + readers.each do |reader| + assert_equal [comment], reader.post.comments + end + end + + def test_including_duplicate_objects_from_has_many + car_post = Post.create!(:title => 'foo', :body => "I like cars!") + car_post.categories << categories(:general) + car_post.categories << categories(:technology) + + comment = car_post.comments.create!(:body => "hmm") + categories = Category.all.merge!(:where => { 'posts.id' => car_post.id }, + :includes => {:posts => :comments}).to_a + categories.each do |category| + assert_equal [comment], category.posts[0].comments + end + end + + def test_associations_loaded_for_all_records + post = Post.create!(:title => 'foo', :body => "I like cars!") + SpecialComment.create!(:body => 'Come on!', :post => post) + first_category = Category.create! :name => 'First!', :posts => [post] + second_category = Category.create! :name => 'Second!', :posts => [post] + + categories = Category.where(:id => [first_category.id, second_category.id]).includes(:posts => :special_comments) + assert_equal categories.map { |category| category.posts.first.special_comments.loaded? }, [true, true] + end + + def test_finding_with_includes_on_has_many_association_with_same_include_includes_only_once + author_id = authors(:david).id + author = assert_queries(3) { Author.all.merge!(:includes => {:posts_with_comments => :comments}).find(author_id) } # find the author, then find the posts, then find the comments + author.posts_with_comments.each do |post_with_comments| + assert_equal post_with_comments.comments.length, post_with_comments.comments.count + assert_nil post_with_comments.comments.to_a.uniq! + end + end + + def test_finding_with_includes_on_has_one_association_with_same_include_includes_only_once + author = authors(:david) + post = author.post_about_thinking_with_last_comment + last_comment = post.last_comment + author = assert_queries(3) { Author.all.merge!(:includes => {:post_about_thinking_with_last_comment => :last_comment}).find(author.id)} # find the author, then find the posts, then find the comments + assert_no_queries do + assert_equal post, author.post_about_thinking_with_last_comment + assert_equal last_comment, author.post_about_thinking_with_last_comment.last_comment + end + end + + def test_finding_with_includes_on_belongs_to_association_with_same_include_includes_only_once + post = posts(:welcome) + author = post.author + author_address = author.author_address + post = assert_queries(3) { Post.all.merge!(:includes => {:author_with_address => :author_address}).find(post.id) } # find the post, then find the author, then find the address + assert_no_queries do + assert_equal author, post.author_with_address + assert_equal author_address, post.author_with_address.author_address + end + end + + def test_finding_with_includes_on_null_belongs_to_association_with_same_include_includes_only_once + post = posts(:welcome) + post.update!(author: nil) + post = assert_queries(1) { Post.all.merge!(includes: {author_with_address: :author_address}).find(post.id) } + # find the post, then find the author which is null so no query for the author or address + assert_no_queries do + assert_equal nil, post.author_with_address + end + end + + def test_finding_with_includes_on_null_belongs_to_polymorphic_association + sponsor = sponsors(:moustache_club_sponsor_for_groucho) + sponsor.update!(sponsorable: nil) + sponsor = assert_queries(1) { Sponsor.all.merge!(:includes => :sponsorable).find(sponsor.id) } + assert_no_queries do + assert_equal nil, sponsor.sponsorable + end + end + + def test_finding_with_includes_on_empty_polymorphic_type_column + sponsor = sponsors(:moustache_club_sponsor_for_groucho) + sponsor.update!(sponsorable_type: '', sponsorable_id: nil) # sponsorable_type column might be declared NOT NULL + sponsor = assert_queries(1) do + assert_nothing_raised { Sponsor.all.merge!(:includes => :sponsorable).find(sponsor.id) } + end + assert_no_queries do + assert_equal nil, sponsor.sponsorable + end + end + + def test_loading_from_an_association + posts = authors(:david).posts.merge(:includes => :comments, :order => "posts.id").to_a + assert_equal 2, posts.first.comments.size + end + + def test_loading_from_an_association_that_has_a_hash_of_conditions + assert_nothing_raised do + Author.all.merge!(:includes => :hello_posts_with_hash_conditions).to_a + end + assert !Author.all.merge!(:includes => :hello_posts_with_hash_conditions).find(authors(:david).id).hello_posts.empty? + end + + def test_loading_with_no_associations + assert_nil Post.all.merge!(:includes => :author).find(posts(:authorless).id).author + end + + # Regression test for 21c75e5 + def test_nested_loading_does_not_raise_exception_when_association_does_not_exist + assert_nothing_raised do + Post.all.merge!(:includes => {:author => :author_addresss}).find(posts(:authorless).id) + end + end + + def test_nested_loading_through_has_one_association + aa = AuthorAddress.all.merge!(:includes => {:author => :posts}).find(author_addresses(:david_address).id) + assert_equal aa.author.posts.count, aa.author.posts.length + end + + def test_nested_loading_through_has_one_association_with_order + aa = AuthorAddress.all.merge!(:includes => {:author => :posts}, :order => 'author_addresses.id').find(author_addresses(:david_address).id) + assert_equal aa.author.posts.count, aa.author.posts.length + end + + def test_nested_loading_through_has_one_association_with_order_on_association + aa = AuthorAddress.all.merge!(:includes => {:author => :posts}, :order => 'authors.id').find(author_addresses(:david_address).id) + assert_equal aa.author.posts.count, aa.author.posts.length + end + + def test_nested_loading_through_has_one_association_with_order_on_nested_association + aa = AuthorAddress.all.merge!(:includes => {:author => :posts}, :order => 'posts.id').find(author_addresses(:david_address).id) + assert_equal aa.author.posts.count, aa.author.posts.length + end + + def test_nested_loading_through_has_one_association_with_conditions + aa = AuthorAddress.references(:author_addresses).merge( + :includes => {:author => :posts}, + :where => "author_addresses.id > 0" + ).find author_addresses(:david_address).id + assert_equal aa.author.posts.count, aa.author.posts.length + end + + def test_nested_loading_through_has_one_association_with_conditions_on_association + aa = AuthorAddress.references(:authors).merge( + :includes => {:author => :posts}, + :where => "authors.id > 0" + ).find author_addresses(:david_address).id + assert_equal aa.author.posts.count, aa.author.posts.length + end + + def test_nested_loading_through_has_one_association_with_conditions_on_nested_association + aa = AuthorAddress.references(:posts).merge( + :includes => {:author => :posts}, + :where => "posts.id > 0" + ).find author_addresses(:david_address).id + assert_equal aa.author.posts.count, aa.author.posts.length + end + + def test_eager_association_loading_with_belongs_to_and_foreign_keys + pets = Pet.all.merge!(:includes => :owner).to_a + assert_equal 4, pets.length + end + + def test_eager_association_loading_with_belongs_to + comments = Comment.all.merge!(:includes => :post).to_a + assert_equal 11, comments.length + titles = comments.map { |c| c.post.title } + assert titles.include?(posts(:welcome).title) + assert titles.include?(posts(:sti_post_and_comments).title) + end + + def test_eager_association_loading_with_belongs_to_and_limit + comments = Comment.all.merge!(:includes => :post, :limit => 5, :order => 'comments.id').to_a + assert_equal 5, comments.length + assert_equal [1,2,3,5,6], comments.collect { |c| c.id } + end + + def test_eager_association_loading_with_belongs_to_and_limit_and_conditions + comments = Comment.all.merge!(:includes => :post, :where => 'post_id = 4', :limit => 3, :order => 'comments.id').to_a + assert_equal 3, comments.length + assert_equal [5,6,7], comments.collect { |c| c.id } + end + + def test_eager_association_loading_with_belongs_to_and_limit_and_offset + comments = Comment.all.merge!(:includes => :post, :limit => 3, :offset => 2, :order => 'comments.id').to_a + assert_equal 3, comments.length + assert_equal [3,5,6], comments.collect { |c| c.id } + end + + def test_eager_association_loading_with_belongs_to_and_limit_and_offset_and_conditions + comments = Comment.all.merge!(:includes => :post, :where => 'post_id = 4', :limit => 3, :offset => 1, :order => 'comments.id').to_a + assert_equal 3, comments.length + assert_equal [6,7,8], comments.collect { |c| c.id } + end + + def test_eager_association_loading_with_belongs_to_and_limit_and_offset_and_conditions_array + comments = Comment.all.merge!(:includes => :post, :where => ['post_id = ?',4], :limit => 3, :offset => 1, :order => 'comments.id').to_a + assert_equal 3, comments.length + assert_equal [6,7,8], comments.collect { |c| c.id } + end + + def test_eager_association_loading_with_belongs_to_and_conditions_string_with_unquoted_table_name + assert_nothing_raised do + Comment.includes(:post).references(:posts).where('posts.id = ?', 4) + end + end + + def test_eager_association_loading_with_belongs_to_and_conditions_hash + comments = [] + assert_nothing_raised do + comments = Comment.all.merge!(:includes => :post, :where => {:posts => {:id => 4}}, :limit => 3, :order => 'comments.id').to_a + end + assert_equal 3, comments.length + assert_equal [5,6,7], comments.collect { |c| c.id } + assert_no_queries do + comments.first.post + end + end + + def test_eager_association_loading_with_belongs_to_and_conditions_string_with_quoted_table_name + quoted_posts_id= Comment.connection.quote_table_name('posts') + '.' + Comment.connection.quote_column_name('id') + assert_nothing_raised do + Comment.includes(:post).references(:posts).where("#{quoted_posts_id} = ?", 4) + end + end + + def test_eager_association_loading_with_belongs_to_and_order_string_with_unquoted_table_name + assert_nothing_raised do + Comment.all.merge!(:includes => :post, :order => 'posts.id').to_a + end + end + + def test_eager_association_loading_with_belongs_to_and_order_string_with_quoted_table_name + quoted_posts_id= Comment.connection.quote_table_name('posts') + '.' + Comment.connection.quote_column_name('id') + assert_nothing_raised do + Comment.includes(:post).references(:posts).order(quoted_posts_id) + end + end + + def test_eager_association_loading_with_belongs_to_and_limit_and_multiple_associations + posts = Post.all.merge!(:includes => [:author, :very_special_comment], :limit => 1, :order => 'posts.id').to_a + assert_equal 1, posts.length + assert_equal [1], posts.collect { |p| p.id } + end + + def test_eager_association_loading_with_belongs_to_and_limit_and_offset_and_multiple_associations + posts = Post.all.merge!(:includes => [:author, :very_special_comment], :limit => 1, :offset => 1, :order => 'posts.id').to_a + assert_equal 1, posts.length + assert_equal [2], posts.collect { |p| p.id } + end + + def test_eager_association_loading_with_belongs_to_inferred_foreign_key_from_association_name + author_favorite = AuthorFavorite.all.merge!(:includes => :favorite_author).first + assert_equal authors(:mary), assert_no_queries { author_favorite.favorite_author } + end + + def test_eager_load_belongs_to_quotes_table_and_column_names + job = Job.includes(:ideal_reference).find jobs(:unicyclist).id + references(:michael_unicyclist) + assert_no_queries{ assert_equal references(:michael_unicyclist), job.ideal_reference} + end + + def test_eager_load_has_one_quotes_table_and_column_names + michael = Person.all.merge!(:includes => :favourite_reference).find(people(:michael).id) + references(:michael_unicyclist) + assert_no_queries{ assert_equal references(:michael_unicyclist), michael.favourite_reference} + end + + def test_eager_load_has_many_quotes_table_and_column_names + michael = Person.all.merge!(:includes => :references).find(people(:michael).id) + references(:michael_magician,:michael_unicyclist) + assert_no_queries{ assert_equal references(:michael_magician,:michael_unicyclist), michael.references.sort_by(&:id) } + end + + def test_eager_load_has_many_through_quotes_table_and_column_names + michael = Person.all.merge!(:includes => :jobs).find(people(:michael).id) + jobs(:magician, :unicyclist) + assert_no_queries{ assert_equal jobs(:unicyclist, :magician), michael.jobs.sort_by(&:id) } + end + + def test_eager_load_has_many_with_string_keys + subscriptions = subscriptions(:webster_awdr, :webster_rfr) + subscriber =Subscriber.all.merge!(:includes => :subscriptions).find(subscribers(:second).id) + assert_equal subscriptions, subscriber.subscriptions.sort_by(&:id) + end + + def test_string_id_column_joins + s = Subscriber.create! do |c| + c.id = "PL" + end + + b = Book.create! + + Subscription.create!(:subscriber_id => "PL", :book_id => b.id) + s.reload + s.book_ids = s.book_ids + end + + def test_eager_load_has_many_through_with_string_keys + books = books(:awdr, :rfr) + subscriber = Subscriber.all.merge!(:includes => :books).find(subscribers(:second).id) + assert_equal books, subscriber.books.sort_by(&:id) + end + + def test_eager_load_belongs_to_with_string_keys + subscriber = subscribers(:second) + subscription = Subscription.all.merge!(:includes => :subscriber).find(subscriptions(:webster_awdr).id) + assert_equal subscriber, subscription.subscriber + end + + def test_eager_association_loading_with_explicit_join + posts = Post.all.merge!(:includes => :comments, :joins => "INNER JOIN authors ON posts.author_id = authors.id AND authors.name = 'Mary'", :limit => 1, :order => 'author_id').to_a + assert_equal 1, posts.length + end + + def test_eager_with_has_many_through + posts_with_comments = people(:michael).posts.merge(:includes => :comments, :order => 'posts.id').to_a + posts_with_author = people(:michael).posts.merge(:includes => :author, :order => 'posts.id').to_a + posts_with_comments_and_author = people(:michael).posts.merge(:includes => [ :comments, :author ], :order => 'posts.id').to_a + 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 } + end + + def test_eager_with_has_many_through_a_belongs_to_association + author = authors(:mary) + Post.create!(:author => author, :title => "TITLE", :body => "BODY") + author.author_favorites.create(:favorite_author_id => 1) + author.author_favorites.create(:favorite_author_id => 2) + posts_with_author_favorites = author.posts.merge(:includes => :author_favorites).to_a + assert_no_queries { posts_with_author_favorites.first.author_favorites.first.author_id } + end + + def test_eager_with_has_many_through_an_sti_join_model + author = Author.all.merge!(:includes => :special_post_comments, :order => 'authors.id').first + assert_equal [comments(:does_it_hurt)], assert_no_queries { author.special_post_comments } + end + + def test_eager_with_has_many_through_an_sti_join_model_with_conditions_on_both + author = Author.all.merge!(:includes => :special_nonexistant_post_comments, :order => 'authors.id').first + assert_equal [], author.special_nonexistant_post_comments + end + + def test_eager_with_has_many_through_join_model_with_conditions + assert_equal Author.all.merge!(:includes => :hello_post_comments, + :order => 'authors.id').first.hello_post_comments.sort_by(&:id), + Author.all.merge!(:order => 'authors.id').first.hello_post_comments.sort_by(&:id) + end + + def test_eager_with_has_many_through_join_model_with_conditions_on_top_level + assert_equal comments(:more_greetings), Author.all.merge!(:includes => :comments_with_order_and_conditions).find(authors(:david).id).comments_with_order_and_conditions.first + end + + def test_eager_with_has_many_through_join_model_with_include + author_comments = Author.all.merge!(:includes => :comments_with_include).find(authors(:david).id).comments_with_include.to_a + assert_no_queries do + author_comments.first.post.title + end + end + + def test_eager_with_has_many_through_with_conditions_join_model_with_include + post_tags = Post.find(posts(:welcome).id).misc_tags + eager_post_tags = Post.all.merge!(:includes => :misc_tags).find(1).misc_tags + assert_equal post_tags, eager_post_tags + end + + def test_eager_with_has_many_through_join_model_ignores_default_includes + assert_nothing_raised do + authors(:david).comments_on_posts_with_default_include.to_a + end + end + + def test_eager_with_has_many_and_limit + posts = Post.all.merge!(:order => 'posts.id asc', :includes => [ :author, :comments ], :limit => 2).to_a + assert_equal 2, posts.size + assert_equal 3, posts.inject(0) { |sum, post| sum + post.comments.size } + end + + def test_eager_with_has_many_and_limit_and_conditions + posts = Post.all.merge!(:includes => [ :author, :comments ], :limit => 2, :where => "posts.body = 'hello'", :order => "posts.id").to_a + assert_equal 2, posts.size + assert_equal [4,5], posts.collect { |p| p.id } + end + + def test_eager_with_has_many_and_limit_and_conditions_array + posts = Post.all.merge!(:includes => [ :author, :comments ], :limit => 2, :where => [ "posts.body = ?", 'hello' ], :order => "posts.id").to_a + assert_equal 2, posts.size + assert_equal [4,5], posts.collect { |p| p.id } + end + + def test_eager_with_has_many_and_limit_and_conditions_array_on_the_eagers + posts = Post.includes(:author, :comments).limit(2).references(:author).where("authors.name = ?", 'David') + assert_equal 2, posts.size + + count = Post.includes(:author, :comments).limit(2).references(:author).where("authors.name = ?", 'David').count + assert_equal posts.size, count + end + + def test_eager_with_has_many_and_limit_and_high_offset + posts = Post.all.merge!(:includes => [ :author, :comments ], :limit => 2, :offset => 10, :where => { 'authors.name' => 'David' }).to_a + assert_equal 0, posts.size + end + + def test_eager_with_has_many_and_limit_and_high_offset_and_multiple_array_conditions + assert_queries(1) do + posts = Post.references(:authors, :comments). + merge(:includes => [ :author, :comments ], :limit => 2, :offset => 10, + :where => [ "authors.name = ? and comments.body = ?", 'David', 'go crazy' ]).to_a + assert_equal 0, posts.size + end + end + + def test_eager_with_has_many_and_limit_and_high_offset_and_multiple_hash_conditions + assert_queries(1) do + posts = Post.all.merge!(:includes => [ :author, :comments ], :limit => 2, :offset => 10, + :where => { 'authors.name' => 'David', 'comments.body' => 'go crazy' }).to_a + assert_equal 0, posts.size + end + end + + def test_count_eager_with_has_many_and_limit_and_high_offset + posts = Post.all.merge!(:includes => [ :author, :comments ], :limit => 2, :offset => 10, :where => { 'authors.name' => 'David' }).count(:all) + assert_equal 0, posts + end + + def test_eager_with_has_many_and_limit_with_no_results + posts = Post.all.merge!(:includes => [ :author, :comments ], :limit => 2, :where => "posts.title = 'magic forest'").to_a + assert_equal 0, posts.size + end + + def test_eager_count_performed_on_a_has_many_association_with_multi_table_conditional + author = authors(:david) + author_posts_without_comments = author.posts.select { |post| post.comments.blank? } + assert_equal author_posts_without_comments.size, author.posts.includes(:comments).where('comments.id is null').references(:comments).count + end + + def test_eager_count_performed_on_a_has_many_through_association_with_multi_table_conditional + person = people(:michael) + person_posts_without_comments = person.posts.select { |post| post.comments.blank? } + assert_equal person_posts_without_comments.size, person.posts_with_no_comments.count + end + + def test_eager_with_has_and_belongs_to_many_and_limit + posts = Post.all.merge!(:includes => :categories, :order => "posts.id", :limit => 3).to_a + assert_equal 3, posts.size + assert_equal 2, posts[0].categories.size + assert_equal 1, posts[1].categories.size + assert_equal 0, posts[2].categories.size + assert posts[0].categories.include?(categories(:technology)) + assert posts[1].categories.include?(categories(:general)) + end + + # Since the preloader for habtm gets raw row hashes from the database and then + # instantiates them, this test ensures that it only instantiates one actual + # object per record from the database. + def test_has_and_belongs_to_many_should_not_instantiate_same_records_multiple_times + welcome = posts(:welcome) + categories = Category.includes(:posts) + + general = categories.find { |c| c == categories(:general) } + technology = categories.find { |c| c == categories(:technology) } + + post1 = general.posts.to_a.find { |p| p == welcome } + post2 = technology.posts.to_a.find { |p| p == welcome } + + assert_equal post1.object_id, post2.object_id + end + + def test_eager_with_has_many_and_limit_and_conditions_on_the_eagers + posts = + authors(:david).posts + .includes(:comments) + .where("comments.body like 'Normal%' OR comments.#{QUOTED_TYPE}= 'SpecialComment'") + .references(:comments) + .limit(2) + .to_a + assert_equal 2, posts.size + + count = + Post.includes(:comments, :author) + .where("authors.name = 'David' AND (comments.body like 'Normal%' OR comments.#{QUOTED_TYPE}= 'SpecialComment')") + .references(:authors, :comments) + .limit(2) + .count + assert_equal count, posts.size + end + + def test_eager_with_has_many_and_limit_and_scoped_conditions_on_the_eagers + posts = nil + Post.includes(:comments) + .where("comments.body like 'Normal%' OR comments.#{QUOTED_TYPE}= 'SpecialComment'") + .references(:comments) + .scoping do + + posts = authors(:david).posts.limit(2).to_a + assert_equal 2, posts.size + end + + Post.includes(:comments, :author) + .where("authors.name = 'David' AND (comments.body like 'Normal%' OR comments.#{QUOTED_TYPE}= 'SpecialComment')") + .references(:authors, :comments) + .scoping do + + count = Post.limit(2).count + assert_equal count, posts.size + end + end + + def test_eager_association_loading_with_habtm + posts = Post.all.merge!(:includes => :categories, :order => "posts.id").to_a + assert_equal 2, posts[0].categories.size + assert_equal 1, posts[1].categories.size + assert_equal 0, posts[2].categories.size + assert posts[0].categories.include?(categories(:technology)) + assert posts[1].categories.include?(categories(:general)) + end + + def test_eager_with_inheritance + SpecialPost.all.merge!(:includes => [ :comments ]).to_a + end + + def test_eager_has_one_with_association_inheritance + post = Post.all.merge!(:includes => [ :very_special_comment ]).find(4) + assert_equal "VerySpecialComment", post.very_special_comment.class.to_s + end + + def test_eager_has_many_with_association_inheritance + post = Post.all.merge!(:includes => [ :special_comments ]).find(4) + post.special_comments.each do |special_comment| + assert special_comment.is_a?(SpecialComment) + end + end + + def test_eager_habtm_with_association_inheritance + post = Post.all.merge!(:includes => [ :special_categories ]).find(6) + assert_equal 1, post.special_categories.size + post.special_categories.each do |special_category| + assert_equal "SpecialCategory", special_category.class.to_s + end + end + + def test_eager_with_has_one_dependent_does_not_destroy_dependent + assert_not_nil companies(:first_firm).account + f = Firm.all.merge!(:includes => :account, + :where => ["companies.name = ?", "37signals"]).first + assert_not_nil f.account + assert_equal companies(:first_firm, :reload).account, f.account + end + + def test_eager_with_multi_table_conditional_properly_counts_the_records_when_using_size + author = authors(:david) + posts_with_no_comments = author.posts.select { |post| post.comments.blank? } + assert_equal posts_with_no_comments.size, author.posts_with_no_comments.size + assert_equal posts_with_no_comments, author.posts_with_no_comments + end + + def test_eager_with_invalid_association_reference + assert_raise(ActiveRecord::AssociationNotFoundError, "Association was not found; perhaps you misspelled it? You specified :include => :monkeys") { + Post.all.merge!(:includes=> :monkeys ).find(6) + } + assert_raise(ActiveRecord::AssociationNotFoundError, "Association was not found; perhaps you misspelled it? You specified :include => :monkeys") { + Post.all.merge!(:includes=>[ :monkeys ]).find(6) + } + assert_raise(ActiveRecord::AssociationNotFoundError, "Association was not found; perhaps you misspelled it? You specified :include => :monkeys") { + Post.all.merge!(:includes=>[ 'monkeys' ]).find(6) + } + assert_raise(ActiveRecord::AssociationNotFoundError, "Association was not found; perhaps you misspelled it? You specified :include => :monkeys, :elephants") { + Post.all.merge!(:includes=>[ :monkeys, :elephants ]).find(6) + } + end + + def test_eager_with_default_scope + developer = EagerDeveloperWithDefaultScope.where(:name => 'David').first + projects = Project.order(:id).to_a + assert_no_queries do + assert_equal(projects, developer.projects) + end + end + + def test_eager_with_default_scope_as_class_method + developer = EagerDeveloperWithClassMethodDefaultScope.where(:name => 'David').first + projects = Project.order(:id).to_a + assert_no_queries do + assert_equal(projects, developer.projects) + end + end + + def test_eager_with_default_scope_as_lambda + developer = EagerDeveloperWithLambdaDefaultScope.where(:name => 'David').first + projects = Project.order(:id).to_a + assert_no_queries do + assert_equal(projects, developer.projects) + end + end + + def test_eager_with_default_scope_as_block + # warm up the habtm cache + EagerDeveloperWithBlockDefaultScope.where(:name => 'David').first.projects + developer = EagerDeveloperWithBlockDefaultScope.where(:name => 'David').first + projects = Project.order(:id).to_a + assert_no_queries do + assert_equal(projects, developer.projects) + end + end + + def test_eager_with_default_scope_as_callable + developer = EagerDeveloperWithCallableDefaultScope.where(:name => 'David').first + projects = Project.order(:id).to_a + assert_no_queries do + assert_equal(projects, developer.projects) + end + end + + def find_all_ordered(className, include=nil) + className.all.merge!(:order=>"#{className.table_name}.#{className.primary_key}", :includes=>include).to_a + end + + def test_limited_eager_with_order + assert_equal( + posts(:thinking, :sti_comments), + Post.all.merge!( + :includes => [:author, :comments], :where => { 'authors.name' => 'David' }, + :order => 'UPPER(posts.title)', :limit => 2, :offset => 1 + ).to_a + ) + assert_equal( + posts(:sti_post_and_comments, :sti_comments), + Post.all.merge!( + :includes => [:author, :comments], :where => { 'authors.name' => 'David' }, + :order => 'UPPER(posts.title) DESC', :limit => 2, :offset => 1 + ).to_a + ) + end + + def test_limited_eager_with_multiple_order_columns + assert_equal( + posts(:thinking, :sti_comments), + Post.all.merge!( + :includes => [:author, :comments], :where => { 'authors.name' => 'David' }, + :order => ['UPPER(posts.title)', 'posts.id'], :limit => 2, :offset => 1 + ).to_a + ) + assert_equal( + posts(:sti_post_and_comments, :sti_comments), + Post.all.merge!( + :includes => [:author, :comments], :where => { 'authors.name' => 'David' }, + :order => ['UPPER(posts.title) DESC', 'posts.id'], :limit => 2, :offset => 1 + ).to_a + ) + end + + def test_limited_eager_with_numeric_in_association + assert_equal( + people(:david, :susan), + Person.references(:number1_fans_people).merge( + :includes => [:readers, :primary_contact, :number1_fan], + :where => "number1_fans_people.first_name like 'M%'", + :order => 'people.id', :limit => 2, :offset => 0 + ).to_a + ) + end + + def test_preload_with_interpolation + assert_deprecated do + post = Post.includes(:comments_with_interpolated_conditions).find(posts(:welcome).id) + assert_equal [comments(:greetings)], post.comments_with_interpolated_conditions + end + + assert_deprecated do + post = Post.joins(:comments_with_interpolated_conditions).find(posts(:welcome).id) + assert_equal [comments(:greetings)], post.comments_with_interpolated_conditions + end + end + + def test_polymorphic_type_condition + post = Post.all.merge!(:includes => :taggings).find(posts(:thinking).id) + assert post.taggings.include?(taggings(:thinking_general)) + post = SpecialPost.all.merge!(:includes => :taggings).find(posts(:thinking).id) + assert post.taggings.include?(taggings(:thinking_general)) + end + + def test_eager_with_multiple_associations_with_same_table_has_many_and_habtm + # Eager includes of has many and habtm associations aren't necessarily sorted in the same way + def assert_equal_after_sort(item1, item2, item3 = nil) + assert_equal(item1.sort{|a,b| a.id <=> b.id}, item2.sort{|a,b| a.id <=> b.id}) + assert_equal(item3.sort{|a,b| a.id <=> b.id}, item2.sort{|a,b| a.id <=> b.id}) if item3 + end + # Test regular association, association with conditions, association with + # STI, and association with conditions assured not to be true + post_types = [:posts, :other_posts, :special_posts] + # test both has_many and has_and_belongs_to_many + [Author, Category].each do |className| + d1 = find_all_ordered(className) + # test including all post types at once + d2 = find_all_ordered(className, post_types) + d1.each_index do |i| + assert_equal(d1[i], d2[i]) + assert_equal_after_sort(d1[i].posts, d2[i].posts) + post_types[1..-1].each do |post_type| + # test including post_types together + d3 = find_all_ordered(className, [:posts, post_type]) + assert_equal(d1[i], d3[i]) + assert_equal_after_sort(d1[i].posts, d3[i].posts) + assert_equal_after_sort(d1[i].send(post_type), d2[i].send(post_type), d3[i].send(post_type)) + end + end + end + end + + def test_eager_with_multiple_associations_with_same_table_has_one + d1 = find_all_ordered(Firm) + d2 = find_all_ordered(Firm, :account) + d1.each_index do |i| + assert_equal(d1[i], d2[i]) + assert_equal(d1[i].account, d2[i].account) + end + end + + def test_eager_with_multiple_associations_with_same_table_belongs_to + firm_types = [:firm, :firm_with_basic_id, :firm_with_other_name, :firm_with_condition] + d1 = find_all_ordered(Client) + d2 = find_all_ordered(Client, firm_types) + d1.each_index do |i| + assert_equal(d1[i], d2[i]) + firm_types.each { |type| assert_equal(d1[i].send(type), d2[i].send(type)) } + end + end + def test_eager_with_valid_association_as_string_not_symbol + assert_nothing_raised { Post.all.merge!(:includes => 'comments').to_a } + 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.all.merge!(:where => "123.456 = 123.456", :includes => :post).to_a + end + end + + def test_preconfigured_includes_with_belongs_to + author = posts(:welcome).author_with_posts + assert_no_queries {assert_equal 5, author.posts.size} + end + + def test_preconfigured_includes_with_has_one + comment = posts(:sti_comments).very_special_comment_with_post + assert_no_queries {assert_equal posts(:sti_comments), comment.post} + end + + def test_preconfigured_includes_with_has_many + posts = authors(:david).posts_with_comments + one = posts.detect { |p| p.id == 1 } + assert_no_queries do + assert_equal 5, posts.size + assert_equal 2, one.comments.size + end + end + + def test_preconfigured_includes_with_habtm + posts = authors(:david).posts_with_categories + one = posts.detect { |p| p.id == 1 } + assert_no_queries do + assert_equal 5, posts.size + assert_equal 2, one.categories.size + end + end + + def test_preconfigured_includes_with_has_many_and_habtm + posts = authors(:david).posts_with_comments_and_categories + one = posts.detect { |p| p.id == 1 } + assert_no_queries do + assert_equal 5, posts.size + assert_equal 2, one.comments.size + assert_equal 2, one.categories.size + end + end + + def test_count_with_include + assert_equal 3, authors(:david).posts_with_comments.where("length(comments.body) > 15").references(:comments).count + end + + def test_load_with_sti_sharing_association + assert_queries(2) do #should not do 1 query per subclass + Comment.includes(:post).to_a + end + end + + def test_conditions_on_join_table_with_include_and_limit + assert_equal 3, Developer.all.merge!(:includes => 'projects', :where => { 'developers_projects.access_level' => 1 }, :limit => 5).to_a.size + end + + def test_dont_create_temporary_active_record_instances + Developer.instance_count = 0 + developers = Developer.all.merge!(:includes => 'projects', :where => { 'developers_projects.access_level' => 1 }, :limit => 5).to_a + assert_equal developers.count, Developer.instance_count + end + + def test_order_on_join_table_with_include_and_limit + assert_equal 5, Developer.all.merge!(:includes => 'projects', :order => 'developers_projects.joined_on DESC', :limit => 5).to_a.size + end + + def test_eager_loading_with_order_on_joined_table_preloads + posts = assert_queries(2) do + Post.all.merge!(:joins => :comments, :includes => :author, :order => 'comments.id DESC').to_a + end + assert_equal posts(:eager_other), posts[1] + assert_equal authors(:mary), assert_no_queries { posts[1].author} + end + + def test_eager_loading_with_conditions_on_joined_table_preloads + posts = assert_queries(2) do + Post.all.merge!(:select => 'distinct posts.*', :includes => :author, :joins => [:comments], :where => "comments.body like 'Thank you%'", :order => 'posts.id').to_a + end + assert_equal [posts(:welcome)], posts + assert_equal authors(:david), assert_no_queries { posts[0].author} + + posts = assert_queries(2) do + Post.all.merge!(:select => 'distinct posts.*', :includes => :author, :joins => [:comments], :where => "comments.body like 'Thank you%'", :order => 'posts.id').to_a + end + assert_equal [posts(:welcome)], posts + assert_equal authors(:david), assert_no_queries { posts[0].author} + + posts = assert_queries(2) do + Post.all.merge!(:includes => :author, :joins => {:taggings => :tag}, :where => "tags.name = 'General'", :order => 'posts.id').to_a + end + assert_equal posts(:welcome, :thinking), posts + + posts = assert_queries(2) do + Post.all.merge!(:includes => :author, :joins => {:taggings => {:tag => :taggings}}, :where => "taggings_tags.super_tag_id=2", :order => 'posts.id').to_a + end + assert_equal posts(:welcome, :thinking), posts + end + + def test_preload_has_many_with_association_condition_and_default_scope + post = Post.create!(:title => 'Beaches', :body => "I like beaches!") + Reader.create! :person => people(:david), :post => post + LazyReader.create! :person => people(:susan), :post => post + + assert_equal 1, post.lazy_readers.to_a.size + assert_equal 2, post.lazy_readers_skimmers_or_not.to_a.size + + post_with_readers = Post.includes(:lazy_readers_skimmers_or_not).find(post.id) + assert_equal 2, post_with_readers.lazy_readers_skimmers_or_not.to_a.size + end + + def test_eager_loading_with_conditions_on_string_joined_table_preloads + posts = assert_queries(2) do + Post.all.merge!(:select => 'distinct posts.*', :includes => :author, :joins => "INNER JOIN comments on comments.post_id = posts.id", :where => "comments.body like 'Thank you%'", :order => 'posts.id').to_a + end + assert_equal [posts(:welcome)], posts + assert_equal authors(:david), assert_no_queries { posts[0].author} + + posts = assert_queries(2) do + Post.all.merge!(:select => 'distinct posts.*', :includes => :author, :joins => ["INNER JOIN comments on comments.post_id = posts.id"], :where => "comments.body like 'Thank you%'", :order => 'posts.id').to_a + end + assert_equal [posts(:welcome)], posts + assert_equal authors(:david), assert_no_queries { posts[0].author} + end + + def test_eager_loading_with_select_on_joined_table_preloads + posts = assert_queries(2) do + Post.all.merge!(:select => 'posts.*, authors.name as author_name', :includes => :comments, :joins => :author, :order => 'posts.id').to_a + end + assert_equal 'David', posts[0].author_name + assert_equal posts(:welcome).comments, assert_no_queries { posts[0].comments} + end + + def test_eager_loading_with_conditions_on_join_model_preloads + authors = assert_queries(2) do + Author.all.merge!(:includes => :author_address, :joins => :comments, :where => "posts.title like 'Welcome%'").to_a + end + assert_equal authors(:david), authors[0] + assert_equal author_addresses(:david_address), authors[0].author_address + end + + def test_preload_belongs_to_uses_exclusive_scope + people = Person.males.merge(:includes => :primary_contact).to_a + assert_not_equal people.length, 0 + people.each do |person| + assert_no_queries {assert_not_nil person.primary_contact} + assert_equal Person.find(person.id).primary_contact, person.primary_contact + end + end + + def test_preload_has_many_uses_exclusive_scope + people = Person.males.includes(:agents).to_a + people.each do |person| + assert_equal Person.find(person.id).agents, person.agents + end + end + + def test_preload_has_many_using_primary_key + expected = Firm.first.clients_using_primary_key.to_a + firm = Firm.includes(:clients_using_primary_key).first + assert_no_queries do + assert_equal expected, firm.clients_using_primary_key + end + end + + def test_include_has_many_using_primary_key + expected = Firm.find(1).clients_using_primary_key.sort_by(&:name) + # Oracle adapter truncates alias to 30 characters + if current_adapter?(:OracleAdapter) + firm = Firm.all.merge!(:includes => :clients_using_primary_key, :order => 'clients_using_primary_keys_companies'[0,30]+'.name').find(1) + else + firm = Firm.all.merge!(:includes => :clients_using_primary_key, :order => 'clients_using_primary_keys_companies.name').find(1) + end + assert_no_queries do + assert_equal expected, firm.clients_using_primary_key + end + end + + def test_preload_has_one_using_primary_key + expected = accounts(:signals37) + firm = Firm.all.merge!(:includes => :account_using_primary_key, :order => 'companies.id').first + assert_no_queries do + assert_equal expected, firm.account_using_primary_key + end + end + + def test_include_has_one_using_primary_key + expected = accounts(:signals37) + firm = Firm.all.merge!(:includes => :account_using_primary_key, :order => 'accounts.id').to_a.detect {|f| f.id == 1} + assert_no_queries do + assert_equal expected, firm.account_using_primary_key + end + end + + def test_preloading_empty_belongs_to + c = Client.create!(:name => 'Foo', :client_of => Company.maximum(:id) + 1) + + client = assert_queries(2) { Client.preload(:firm).find(c.id) } + assert_no_queries { assert_nil client.firm } + end + + def test_preloading_empty_belongs_to_polymorphic + t = Tagging.create!(:taggable_type => 'Post', :taggable_id => Post.maximum(:id) + 1, :tag => tags(:general)) + + tagging = assert_queries(2) { Tagging.preload(:taggable).find(t.id) } + assert_no_queries { assert_nil tagging.taggable } + end + + def test_preloading_through_empty_belongs_to + c = Client.create!(:name => 'Foo', :client_of => Company.maximum(:id) + 1) + + client = assert_queries(2) { Client.preload(:accounts).find(c.id) } + assert_no_queries { assert client.accounts.empty? } + end + + def test_preloading_has_many_through_with_uniq + mary = Author.includes(:unique_categorized_posts).where(:id => authors(:mary).id).first + assert_equal 1, mary.unique_categorized_posts.length + assert_equal 1, mary.unique_categorized_post_ids.length + end + + def test_preloading_polymorphic_with_custom_foreign_type + sponsor = sponsors(:moustache_club_sponsor_for_groucho) + groucho = members(:groucho) + + sponsor = assert_queries(2) { + Sponsor.includes(:thing).where(:id => sponsor.id).first + } + assert_no_queries { assert_equal groucho, sponsor.thing } + end + + def test_joins_with_includes_should_preload_via_joins + post = assert_queries(1) { Post.includes(:comments).joins(:comments).order('posts.id desc').to_a.first } + + assert_queries(0) do + assert_not_equal 0, post.comments.to_a.count + end + end + + def test_join_eager_with_empty_order_should_generate_valid_sql + assert_nothing_raised(ActiveRecord::StatementInvalid) do + Post.includes(:comments).order("").where(:comments => {:body => "Thank you for the welcome"}).first + end + end + + def test_join_eager_with_nil_order_should_generate_valid_sql + assert_nothing_raised(ActiveRecord::StatementInvalid) do + Post.includes(:comments).order(nil).where(:comments => {:body => "Thank you for the welcome"}).first + end + end + + def test_deep_including_through_habtm + # warm up habtm cache + posts = Post.all.merge!(:includes => {:categories => :categorizations}, :order => "posts.id").to_a + posts[0].categories[0].categorizations.length + + posts = Post.all.merge!(:includes => {:categories => :categorizations}, :order => "posts.id").to_a + assert_no_queries { assert_equal 2, posts[0].categories[0].categorizations.length } + assert_no_queries { assert_equal 1, posts[0].categories[1].categorizations.length } + assert_no_queries { assert_equal 2, posts[1].categories[0].categorizations.length } + end + + test "scoping with a circular preload" do + assert_equal Comment.find(1), Comment.preload(:post => :comments).scoping { Comment.find(1) } + end + + test "circular preload does not modify unscoped" do + expected = FirstPost.unscoped.find(2) + FirstPost.preload(:comments => :first_post).find(1) + assert_equal expected, FirstPost.unscoped.find(2) + end + + test "preload ignores the scoping" do + assert_equal( + Comment.find(1).post, + Post.where('1 = 0').scoping { Comment.preload(:post).find(1).post } + ) + end + + test "deep preload" do + post = Post.preload(author: :posts, comments: :post).first + + assert_predicate post.author.association(:posts), :loaded? + assert_predicate post.comments.first.association(:post), :loaded? + end + + test "preloading does not cache has many association subset when preloaded with a through association" do + author = Author.includes(:comments_with_order_and_conditions, :posts).first + assert_no_queries { assert_equal 2, author.comments_with_order_and_conditions.size } + assert_no_queries { assert_equal 5, author.posts.size, "should not cache a subset of the association" } + end + + test "preloading a through association twice does not reset it" do + members = Member.includes(current_membership: :club).includes(:club).to_a + assert_no_queries { + assert_equal 3, members.map(&:current_membership).map(&:club).size + } + end + + test "works in combination with order(:symbol) and reorder(:symbol)" do + author = Author.includes(:posts).references(:posts).order(:name).find_by('posts.title IS NOT NULL') + assert_equal authors(:bob), author + + author = Author.includes(:posts).references(:posts).reorder(:name).find_by('posts.title IS NOT NULL') + assert_equal authors(:bob), author + end + + test "preloading with a polymorphic association and using the existential predicate but also using a select" do + assert_equal authors(:david), authors(:david).essays.includes(:writer).first.writer + + assert_nothing_raised do + authors(:david).essays.includes(:writer).select(:name).any? + end + end + + test "preloading the same association twice works" do + Member.create! + members = Member.preload(:current_membership).includes(current_membership: :club).all.to_a + assert_no_queries { + members_with_membership = members.select(&:current_membership) + assert_equal 3, members_with_membership.map(&:current_membership).map(&:club).size + } + end + + test "preloading with a polymorphic association and using the existential predicate" do + assert_equal authors(:david), authors(:david).essays.includes(:writer).first.writer + + assert_nothing_raised do + authors(:david).essays.includes(:writer).any? + end + end + + test "preloading associations with string joins and order references" do + author = assert_queries(2) { + Author.includes(:posts).joins("LEFT JOIN posts ON posts.author_id = authors.id").order("posts.title DESC").first + } + assert_no_queries { + assert_equal 5, author.posts.size + } + end + + test "including associations with where.not adds implicit references" do + author = assert_queries(2) { + Author.includes(:posts).where.not(posts: { title: 'Welcome to the weblog'} ).last + } + + assert_no_queries { + assert_equal 2, author.posts.size + } + end + + test "including association based on sql condition and no database column" do + assert_equal pets(:parrot), Owner.including_last_pet.first.last_pet + end + + test "include instance dependent associations is deprecated" do + message = "association scope 'posts_with_signature' is" + assert_deprecated message do + begin + Author.includes(:posts_with_signature).to_a + rescue NoMethodError + # it's expected that preloading of this association fails + end + end + + assert_deprecated message do + Author.preload(:posts_with_signature).to_a rescue NoMethodError + end + + assert_deprecated message do + Author.eager_load(:posts_with_signature).to_a + end + end + + test "preloading readonly association" do + # has-one + firm = Firm.where(id: "1").preload(:readonly_account).first! + assert firm.readonly_account.readonly? + + # has_and_belongs_to_many + project = Project.where(id: "2").preload(:readonly_developers).first! + assert project.readonly_developers.first.readonly? + + # has-many :through + david = Author.where(id: "1").preload(:readonly_comments).first! + assert david.readonly_comments.first.readonly? + end + + test "eager-loading readonly association" do + skip "eager_load does not yet preserve readonly associations" + # has-one + firm = Firm.where(id: "1").eager_load(:readonly_account).first! + assert firm.readonly_account.readonly? + + # has_and_belongs_to_many + project = Project.where(id: "2").eager_load(:readonly_developers).first! + assert project.readonly_developers.first.readonly? + + # has-many :through + david = Author.where(id: "1").eager_load(:readonly_comments).first! + assert david.readonly_comments.first.readonly? + end +end diff --git a/activerecord/test/cases/associations/extension_test.rb b/activerecord/test/cases/associations/extension_test.rb new file mode 100644 index 0000000000..4c1fdfdd9a --- /dev/null +++ b/activerecord/test/cases/associations/extension_test.rb @@ -0,0 +1,81 @@ +require "cases/helper" +require 'models/post' +require 'models/comment' +require 'models/project' +require 'models/developer' +require 'models/company_in_module' + +class AssociationsExtensionsTest < ActiveRecord::TestCase + fixtures :projects, :developers, :developers_projects, :comments, :posts + + def test_extension_on_has_many + assert_equal comments(:more_greetings), posts(:welcome).comments.find_most_recent + end + + def test_extension_on_habtm + assert_equal projects(:action_controller), developers(:david).projects.find_most_recent + end + + def test_named_extension_on_habtm + assert_equal projects(:action_controller), developers(:david).projects_extended_by_name.find_most_recent + end + + def test_named_two_extensions_on_habtm + assert_equal projects(:action_controller), developers(:david).projects_extended_by_name_twice.find_most_recent + assert_equal projects(:active_record), developers(:david).projects_extended_by_name_twice.find_least_recent + end + + def test_named_extension_and_block_on_habtm + assert_equal projects(:action_controller), developers(:david).projects_extended_by_name_and_block.find_most_recent + assert_equal projects(:active_record), developers(:david).projects_extended_by_name_and_block.find_least_recent + end + + def test_extension_with_scopes + assert_equal comments(:greetings), posts(:welcome).comments.offset(1).find_most_recent + assert_equal comments(:greetings), posts(:welcome).comments.not_again.find_most_recent + end + + def test_marshalling_extensions + david = developers(:david) + assert_equal projects(:action_controller), david.projects.find_most_recent + + marshalled = Marshal.dump(david) + + # Marshaling an association shouldn't make it unusable by wiping its reflection. + assert_not_nil david.association(:projects).reflection + + david_too = Marshal.load(marshalled) + assert_equal projects(:action_controller), david_too.projects.find_most_recent + end + + def test_marshalling_named_extensions + david = developers(:david) + assert_equal projects(:action_controller), david.projects_extended_by_name.find_most_recent + + marshalled = Marshal.dump(david) + david = Marshal.load(marshalled) + + assert_equal projects(:action_controller), david.projects_extended_by_name.find_most_recent + end + + def test_extension_name + extend!(Developer) + extend!(MyApplication::Business::Developer) + + assert Object.const_get 'DeveloperAssociationNameAssociationExtension' + assert MyApplication::Business.const_get 'DeveloperAssociationNameAssociationExtension' + end + + def test_proxy_association_after_scoped + post = posts(:welcome) + assert_equal post.association(:comments), post.comments.the_association + assert_equal post.association(:comments), post.comments.where('1=1').the_association + end + + private + + def extend!(model) + builder = ActiveRecord::Associations::Builder::HasMany.new(model, :association_name, nil, {}) { } + builder.define_extensions(model) + end +end 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 new file mode 100644 index 0000000000..cc58a4a1a2 --- /dev/null +++ b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb @@ -0,0 +1,886 @@ +require "cases/helper" +require 'models/developer' +require 'models/project' +require 'models/company' +require 'models/customer' +require 'models/order' +require 'models/categorization' +require 'models/category' +require 'models/post' +require 'models/author' +require 'models/tag' +require 'models/tagging' +require 'models/parrot' +require 'models/person' +require 'models/pirate' +require 'models/treasure' +require 'models/price_estimate' +require 'models/club' +require 'models/member' +require 'models/membership' +require 'models/sponsor' +require 'models/country' +require 'models/treaty' +require 'models/vertex' +require 'models/publisher' +require 'models/publisher/article' +require 'models/publisher/magazine' +require 'active_support/core_ext/string/conversions' + +class ProjectWithAfterCreateHook < ActiveRecord::Base + self.table_name = 'projects' + has_and_belongs_to_many :developers, + :class_name => "DeveloperForProjectWithAfterCreateHook", + :join_table => "developers_projects", + :foreign_key => "project_id", + :association_foreign_key => "developer_id" + + after_create :add_david + + def add_david + david = DeveloperForProjectWithAfterCreateHook.find_by_name('David') + david.projects << self + end +end + +class DeveloperForProjectWithAfterCreateHook < ActiveRecord::Base + self.table_name = 'developers' + has_and_belongs_to_many :projects, + :class_name => "ProjectWithAfterCreateHook", + :join_table => "developers_projects", + :association_foreign_key => "project_id", + :foreign_key => "developer_id" +end + +class ProjectWithSymbolsForKeys < ActiveRecord::Base + self.table_name = 'projects' + has_and_belongs_to_many :developers, + :class_name => "DeveloperWithSymbolsForKeys", + :join_table => :developers_projects, + :foreign_key => :project_id, + :association_foreign_key => "developer_id" +end + +class DeveloperWithSymbolsForKeys < ActiveRecord::Base + self.table_name = 'developers' + has_and_belongs_to_many :projects, + :class_name => "ProjectWithSymbolsForKeys", + :join_table => :developers_projects, + :association_foreign_key => :project_id, + :foreign_key => "developer_id" +end + +class SubDeveloper < Developer + self.table_name = 'developers' + has_and_belongs_to_many :special_projects, + :join_table => 'developers_projects', + :foreign_key => "project_id", + :association_foreign_key => "developer_id" +end + +class HasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase + fixtures :accounts, :companies, :categories, :posts, :categories_posts, :developers, :projects, :developers_projects, + :parrots, :pirates, :parrots_pirates, :treasures, :price_estimates, :tags, :taggings + + def setup_data_for_habtm_case + ActiveRecord::Base.connection.execute('delete from countries_treaties') + + country = Country.new(:name => 'India') + country.country_id = 'c1' + country.save! + + treaty = Treaty.new(:name => 'peace') + treaty.treaty_id = 't1' + country.treaties << treaty + end + + def test_marshal_dump + post = posts :welcome + preloaded = Post.includes(:categories).find post.id + assert_equal preloaded, Marshal.load(Marshal.dump(preloaded)) + end + + def test_should_property_quote_string_primary_keys + setup_data_for_habtm_case + + con = ActiveRecord::Base.connection + sql = 'select * from countries_treaties' + record = con.select_rows(sql).last + assert_equal 'c1', record[0] + assert_equal 't1', record[1] + end + + def test_proper_usage_of_primary_keys_and_join_table + setup_data_for_habtm_case + + assert_equal 'country_id', Country.primary_key + assert_equal 'treaty_id', Treaty.primary_key + + country = Country.first + assert_equal 1, country.treaties.count + end + + def test_has_and_belongs_to_many + david = Developer.find(1) + + assert !david.projects.empty? + assert_equal 2, david.projects.size + + active_record = Project.find(1) + assert !active_record.developers.empty? + assert_equal 3, active_record.developers.size + assert active_record.developers.include?(david) + end + + def test_adding_single + jamis = Developer.find(2) + jamis.projects.reload # causing the collection to load + action_controller = Project.find(2) + assert_equal 1, jamis.projects.size + assert_equal 1, action_controller.developers.size + + jamis.projects << action_controller + + assert_equal 2, jamis.projects.size + assert_equal 2, jamis.projects(true).size + assert_equal 2, action_controller.developers(true).size + end + + def test_adding_type_mismatch + jamis = Developer.find(2) + assert_raise(ActiveRecord::AssociationTypeMismatch) { jamis.projects << nil } + assert_raise(ActiveRecord::AssociationTypeMismatch) { jamis.projects << 1 } + end + + def test_adding_from_the_project + jamis = Developer.find(2) + action_controller = Project.find(2) + action_controller.developers.reload + assert_equal 1, jamis.projects.size + assert_equal 1, action_controller.developers.size + + action_controller.developers << jamis + + assert_equal 2, jamis.projects(true).size + assert_equal 2, action_controller.developers.size + assert_equal 2, action_controller.developers(true).size + end + + def test_adding_from_the_project_fixed_timestamp + jamis = Developer.find(2) + action_controller = Project.find(2) + action_controller.developers.reload + assert_equal 1, jamis.projects.size + assert_equal 1, action_controller.developers.size + updated_at = jamis.updated_at + + action_controller.developers << jamis + + assert_equal updated_at, jamis.updated_at + assert_equal 2, jamis.projects(true).size + assert_equal 2, action_controller.developers.size + assert_equal 2, action_controller.developers(true).size + end + + def test_adding_multiple + aredridel = Developer.new("name" => "Aredridel") + aredridel.save + aredridel.projects.reload + aredridel.projects.push(Project.find(1), Project.find(2)) + assert_equal 2, aredridel.projects.size + assert_equal 2, aredridel.projects(true).size + end + + def test_adding_a_collection + aredridel = Developer.new("name" => "Aredridel") + aredridel.save + aredridel.projects.reload + aredridel.projects.concat([Project.find(1), Project.find(2)]) + assert_equal 2, aredridel.projects.size + assert_equal 2, aredridel.projects(true).size + end + + def test_habtm_adding_before_save + no_of_devels = Developer.count + no_of_projects = Project.count + aredridel = Developer.new("name" => "Aredridel") + aredridel.projects.concat([Project.find(1), p = Project.new("name" => "Projekt")]) + assert !aredridel.persisted? + assert !p.persisted? + assert aredridel.save + assert aredridel.persisted? + assert_equal no_of_devels+1, Developer.count + assert_equal no_of_projects+1, Project.count + assert_equal 2, aredridel.projects.size + assert_equal 2, aredridel.projects(true).size + end + + def test_habtm_saving_multiple_relationships + new_project = Project.new("name" => "Grimetime") + amount_of_developers = 4 + developers = (0...amount_of_developers).collect {|i| Developer.create(:name => "JME #{i}") }.reverse + + new_project.developer_ids = [developers[0].id, developers[1].id] + new_project.developers_with_callback_ids = [developers[2].id, developers[3].id] + assert new_project.save + + new_project.reload + assert_equal amount_of_developers, new_project.developers.size + assert_equal developers, new_project.developers + end + + def test_habtm_unique_order_preserved + assert_equal developers(:poor_jamis, :jamis, :david), projects(:active_record).non_unique_developers + assert_equal developers(:poor_jamis, :jamis, :david), projects(:active_record).developers + end + + def test_habtm_collection_size_from_build + devel = Developer.create("name" => "Fred Wu") + devel.projects << Project.create("name" => "Grimetime") + devel.projects.build + + assert_equal 2, devel.projects.size + end + + def test_habtm_collection_size_from_params + devel = Developer.new({ + projects_attributes: { + '0' => {} + } + }) + + assert_equal 1, devel.projects.size + end + + def test_build + devel = Developer.find(1) + proj = assert_no_queries { devel.projects.build("name" => "Projekt") } + assert !devel.projects.loaded? + + assert_equal devel.projects.last, proj + assert devel.projects.loaded? + + assert !proj.persisted? + devel.save + assert proj.persisted? + assert_equal devel.projects.last, proj + assert_equal Developer.find(1).projects.sort_by(&:id).last, proj # prove join table is updated + end + + def test_new_aliased_to_build + devel = Developer.find(1) + proj = assert_no_queries { devel.projects.new("name" => "Projekt") } + assert !devel.projects.loaded? + + assert_equal devel.projects.last, proj + assert devel.projects.loaded? + + assert !proj.persisted? + devel.save + assert proj.persisted? + assert_equal devel.projects.last, proj + assert_equal Developer.find(1).projects.sort_by(&:id).last, proj # prove join table is updated + end + + def test_build_by_new_record + devel = Developer.new(:name => "Marcel", :salary => 75000) + devel.projects.build(:name => "Make bed") + proj2 = devel.projects.build(:name => "Lie in it") + assert_equal devel.projects.last, proj2 + assert !proj2.persisted? + devel.save + assert devel.persisted? + assert proj2.persisted? + assert_equal devel.projects.last, proj2 + assert_equal Developer.find_by_name("Marcel").projects.last, proj2 # prove join table is updated + end + + def test_create + devel = Developer.find(1) + proj = devel.projects.create("name" => "Projekt") + assert !devel.projects.loaded? + + assert_equal devel.projects.last, proj + assert !devel.projects.loaded? + + assert proj.persisted? + assert_equal Developer.find(1).projects.sort_by(&:id).last, proj # prove join table is updated + end + + def test_create_by_new_record + devel = Developer.new(:name => "Marcel", :salary => 75000) + devel.projects.build(:name => "Make bed") + proj2 = devel.projects.build(:name => "Lie in it") + assert_equal devel.projects.last, proj2 + assert !proj2.persisted? + devel.save + assert devel.persisted? + assert proj2.persisted? + assert_equal devel.projects.last, proj2 + assert_equal Developer.find_by_name("Marcel").projects.last, proj2 # prove join table is updated + end + + def test_creation_respects_hash_condition + # in Oracle '' is saved as null therefore need to save ' ' in not null column + post = categories(:general).post_with_conditions.build(:body => ' ') + + assert post.save + assert_equal 'Yet Another Testing Title', post.title + + # in Oracle '' is saved as null therefore need to save ' ' in not null column + another_post = categories(:general).post_with_conditions.create(:body => ' ') + + assert another_post.persisted? + assert_equal 'Yet Another Testing Title', another_post.title + end + + def test_uniq_after_the_fact + dev = developers(:jamis) + dev.projects << projects(:active_record) + dev.projects << projects(:active_record) + + assert_equal 3, dev.projects.size + assert_equal 1, dev.projects.distinct.size + end + + def test_uniq_before_the_fact + projects(:active_record).developers << developers(:jamis) + projects(:active_record).developers << developers(:david) + 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_uniq_when_association_already_loaded + project = projects(:active_record) + project.developers << [ developers(:jamis), developers(:david), developers(:jamis), developers(:david) ] + assert_equal 3, Project.includes(:developers).find(project.id).developers.size + end + + def test_deleting + david = Developer.find(1) + active_record = Project.find(1) + david.projects.reload + assert_equal 2, david.projects.size + assert_equal 3, active_record.developers.size + + david.projects.delete(active_record) + + assert_equal 1, david.projects.size + assert_equal 1, david.projects(true).size + assert_equal 2, active_record.developers(true).size + end + + def test_deleting_array + david = Developer.find(1) + david.projects.reload + david.projects.delete(Project.all.to_a) + assert_equal 0, david.projects.size + assert_equal 0, david.projects(true).size + end + + def test_deleting_all + david = Developer.find(1) + david.projects.reload + david.projects.clear + assert_equal 0, david.projects.size + assert_equal 0, david.projects(true).size + end + + def test_removing_associations_on_destroy + david = DeveloperWithBeforeDestroyRaise.find(1) + assert !david.projects.empty? + david.destroy + assert david.projects.empty? + assert DeveloperWithBeforeDestroyRaise.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = 1").empty? + end + + def test_destroying + david = Developer.find(1) + project = Project.find(1) + david.projects.reload + assert_equal 2, david.projects.size + assert_equal 3, project.developers.size + + assert_no_difference "Project.count" do + david.projects.destroy(project) + end + + join_records = Developer.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = #{david.id} AND project_id = #{project.id}") + assert join_records.empty? + + assert_equal 1, david.reload.projects.size + assert_equal 1, david.projects(true).size + end + + def test_destroying_many + david = Developer.find(1) + david.projects.reload + projects = Project.all.to_a + + assert_no_difference "Project.count" do + david.projects.destroy(*projects) + end + + join_records = Developer.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = #{david.id}") + assert join_records.empty? + + assert_equal 0, david.reload.projects.size + assert_equal 0, david.projects(true).size + end + + def test_destroy_all + david = Developer.find(1) + david.projects.reload + assert !david.projects.empty? + + assert_no_difference "Project.count" do + david.projects.destroy_all + end + + join_records = Developer.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = #{david.id}") + assert join_records.empty? + + assert david.projects.empty? + assert david.projects(true).empty? + end + + def test_destroy_associations_destroys_multiple_associations + george = parrots(:george) + assert !george.pirates.empty? + assert !george.treasures.empty? + + assert_no_difference "Pirate.count" do + assert_no_difference "Treasure.count" do + george.destroy_associations + end + end + + join_records = Parrot.connection.select_all("SELECT * FROM parrots_pirates WHERE parrot_id = #{george.id}") + assert join_records.empty? + assert george.pirates(true).empty? + + join_records = Parrot.connection.select_all("SELECT * FROM parrots_treasures WHERE parrot_id = #{george.id}") + assert join_records.empty? + assert george.treasures(true).empty? + end + + def test_associations_with_conditions + assert_equal 3, projects(:active_record).developers.size + assert_equal 1, projects(:active_record).developers_named_david.size + assert_equal 1, projects(:active_record).developers_named_david_with_hash_conditions.size + + assert_equal developers(:david), projects(:active_record).developers_named_david.find(developers(:david).id) + assert_equal developers(:david), projects(:active_record).developers_named_david_with_hash_conditions.find(developers(:david).id) + assert_equal developers(:david), projects(:active_record).salaried_developers.find(developers(:david).id) + + projects(:active_record).developers_named_david.clear + assert_equal 2, projects(:active_record, :reload).developers.size + end + + def test_find_in_association + # Using sql + assert_equal developers(:david), projects(:active_record).developers.find(developers(:david).id), "SQL find" + + # Using ruby + active_record = projects(:active_record) + active_record.developers.reload + assert_equal developers(:david), active_record.developers.find(developers(:david).id), "Ruby find" + end + + def test_include_uses_array_include_after_loaded + project = projects(:active_record) + project.developers.load_target + + developer = project.developers.first + + assert_no_queries do + assert project.developers.loaded? + assert project.developers.include?(developer) + end + end + + def test_include_checks_if_record_exists_if_target_not_loaded + project = projects(:active_record) + developer = project.developers.first + + project.reload + assert ! project.developers.loaded? + assert_queries(1) do + assert project.developers.include?(developer) + end + assert ! project.developers.loaded? + end + + def test_include_returns_false_for_non_matching_record_to_verify_scoping + project = projects(:active_record) + developer = Developer.create :name => "Bryan", :salary => 50_000 + + assert ! project.developers.loaded? + assert ! project.developers.include?(developer) + end + + def test_find_with_merged_options + assert_equal 1, projects(:active_record).limited_developers.size + assert_equal 1, projects(:active_record).limited_developers.to_a.size + assert_equal 3, projects(:active_record).limited_developers.limit(nil).to_a.size + end + + def test_dynamic_find_should_respect_association_order + # Developers are ordered 'name DESC, id DESC' + high_id_jamis = projects(:active_record).developers.create(:name => 'Jamis') + + assert_equal high_id_jamis, projects(:active_record).developers.merge(:where => "name = 'Jamis'").first + assert_equal high_id_jamis, projects(:active_record).developers.find_by_name('Jamis') + end + + def test_find_should_append_to_association_order + ordered_developers = projects(:active_record).developers.order('projects.id') + assert_equal ['developers.name desc, developers.id desc', 'projects.id'], ordered_developers.order_values + end + + def test_dynamic_find_all_should_respect_readonly_access + projects(:active_record).readonly_developers.each { |d| assert_raise(ActiveRecord::ReadOnlyRecord) { d.save! } if d.valid?} + projects(:active_record).readonly_developers.each { |d| d.readonly? } + end + + def test_new_with_values_in_collection + jamis = DeveloperForProjectWithAfterCreateHook.find_by_name('Jamis') + david = DeveloperForProjectWithAfterCreateHook.find_by_name('David') + project = ProjectWithAfterCreateHook.new(:name => "Cooking with Bertie") + project.developers << jamis + project.save! + project.reload + + assert project.developers.include?(jamis) + assert project.developers.include?(david) + end + + def test_find_in_association_with_options + developers = projects(:active_record).developers.to_a + assert_equal 3, developers.size + + assert_equal developers(:poor_jamis), projects(:active_record).developers.where("salary < 10000").first + end + + def test_replace_with_less + david = developers(:david) + david.projects = [projects(:action_controller)] + assert david.save + assert_equal 1, david.projects.length + end + + def test_replace_with_new + david = developers(:david) + david.projects = [projects(:action_controller), Project.new("name" => "ActionWebSearch")] + david.save + assert_equal 2, david.projects.length + assert !david.projects.include?(projects(:active_record)) + end + + def test_replace_on_new_object + new_developer = Developer.new("name" => "Matz") + new_developer.projects = [projects(:action_controller), Project.new("name" => "ActionWebSearch")] + new_developer.save + assert_equal 2, new_developer.projects.length + end + + def test_consider_type + developer = Developer.first + special_project = SpecialProject.create("name" => "Special Project") + + other_project = developer.projects.first + developer.special_projects << special_project + developer.reload + + assert developer.projects.include?(special_project) + assert developer.special_projects.include?(special_project) + assert !developer.special_projects.include?(other_project) + end + + def test_symbol_join_table + developer = Developer.first + sp = developer.sym_special_projects.create("name" => "omg") + developer.reload + assert_includes developer.sym_special_projects, sp + end + + def test_update_attributes_after_push_without_duplicate_join_table_rows + developer = Developer.new("name" => "Kano") + project = SpecialProject.create("name" => "Special Project") + assert developer.save + developer.projects << project + developer.update_columns("name" => "Bruza") + assert_equal 1, Developer.connection.select_value(<<-end_sql).to_i + SELECT count(*) FROM developers_projects + WHERE project_id = #{project.id} + AND developer_id = #{developer.id} + end_sql + end + + def test_updating_attributes_on_non_rich_associations + welcome = categories(:technology).posts.first + welcome.title = "Something else" + assert welcome.save! + end + + def test_habtm_respects_select + categories(:technology).select_testing_posts(true).each do |o| + assert_respond_to o, :correctness_marker + end + assert_respond_to categories(:technology).select_testing_posts.first, :correctness_marker + end + + def test_habtm_selects_all_columns_by_default + assert_equal Project.column_names.sort, developers(:david).projects.first.attributes.keys.sort + end + + def test_habtm_respects_select_query_method + assert_equal ['id'], developers(:david).projects.select(:id).first.attributes.keys + end + + def test_join_table_alias + # FIXME: `references` has no impact on the aliases generated for the join + # query. The fact that we pass `:developers_projects_join` to `references` + # and that the SQL string contains `developers_projects_join` is merely a + # coincidence. + assert_equal( + 3, + Developer.references(:developers_projects_join).merge( + :includes => {:projects => :developers}, + :where => 'projects_developers_projects_join.joined_on IS NOT NULL' + ).to_a.size + ) + end + + def test_join_with_group + # FIXME: `references` has no impact on the aliases generated for the join + # query. The fact that we pass `:developers_projects_join` to `references` + # and that the SQL string contains `developers_projects_join` is merely a + # coincidence. + group = Developer.columns.inject([]) do |g, c| + g << "developers.#{c.name}" + g << "developers_projects_2.#{c.name}" + end + Project.columns.each { |c| group << "projects.#{c.name}" } + + assert_equal( + 3, + Developer.references(:developers_projects_join).merge( + :includes => {:projects => :developers}, :where => 'projects_developers_projects_join.joined_on IS NOT NULL', + :group => group.join(",") + ).to_a.size + ) + end + + def test_find_grouped + all_posts_from_category1 = Post.all.merge!(:where => "category_id = 1", :joins => :categories).to_a + grouped_posts_of_category1 = Post.all.merge!(:where => "category_id = 1", :group => "author_id", :select => 'count(posts.id) as posts_count', :joins => :categories).to_a + assert_equal 5, all_posts_from_category1.size + assert_equal 2, grouped_posts_of_category1.size + end + + def test_find_scoped_grouped + assert_equal 5, categories(:general).posts_grouped_by_title.to_a.size + assert_equal 1, categories(:technology).posts_grouped_by_title.to_a.size + end + + def test_find_scoped_grouped_having + assert_equal 2, projects(:active_record).well_payed_salary_groups.to_a.size + assert projects(:active_record).well_payed_salary_groups.all? { |g| g.salary > 10000 } + end + + def test_get_ids + assert_equal projects(:active_record, :action_controller).map(&:id).sort, developers(:david).project_ids.sort + assert_equal [projects(:active_record).id], developers(:jamis).project_ids + end + + def test_get_ids_for_loaded_associations + developer = developers(:david) + developer.projects(true) + assert_queries(0) do + developer.project_ids + developer.project_ids + end + end + + def test_get_ids_for_unloaded_associations_does_not_load_them + developer = developers(:david) + assert !developer.projects.loaded? + assert_equal projects(:active_record, :action_controller).map(&:id).sort, developer.project_ids.sort + assert !developer.projects.loaded? + end + + def test_assign_ids + developer = Developer.new("name" => "Joe") + developer.project_ids = projects(:active_record, :action_controller).map(&:id) + developer.save + developer.reload + assert_equal 2, developer.projects.length + assert_equal [projects(:active_record), projects(:action_controller)].map(&:id).sort, developer.project_ids.sort + end + + def test_assign_ids_ignoring_blanks + developer = Developer.new("name" => "Joe") + developer.project_ids = [projects(:active_record).id, nil, projects(:action_controller).id, ''] + developer.save + developer.reload + assert_equal 2, developer.projects.length + assert_equal [projects(:active_record), projects(:action_controller)].map(&:id).sort, developer.project_ids.sort + end + + def test_scoped_find_on_through_association_doesnt_return_read_only_records + tag = Post.find(1).tags.find_by_name("General") + + assert_nothing_raised do + tag.save! + end + end + + def test_has_many_through_polymorphic_has_manys_works + assert_equal [10, 20].to_set, pirates(:redbeard).treasure_estimates.map(&:price).to_set + end + + def test_symbols_as_keys + developer = DeveloperWithSymbolsForKeys.new(:name => 'David') + project = ProjectWithSymbolsForKeys.new(:name => 'Rails Testing') + project.developers << developer + project.save! + + assert_equal 1, project.developers.size + assert_equal 1, developer.projects.size + assert_equal developer, project.developers.first + assert_equal project, developer.projects.first + end + + def test_dynamic_find_should_respect_association_include + # SQL error in sort clause if :include is not included + # due to Unknown column 'authors.id' + assert Category.find(1).posts_with_authors_sorted_by_author_id.find_by_title('Welcome to the weblog') + end + + def test_count + david = Developer.find(1) + assert_equal 2, david.projects.count + end + + def test_association_proxy_transaction_method_starts_transaction_in_association_class + Post.expects(:transaction) + Category.first.posts.transaction do + # nothing + end + end + + def test_caching_of_columns + david = Developer.find(1) + # clear cache possibly created by other tests + david.projects.reset_column_information + + assert_queries(:any) { david.projects.columns } + assert_no_queries { david.projects.columns } + + ## and again to verify that reset_column_information clears the cache correctly + david.projects.reset_column_information + + assert_queries(:any) { david.projects.columns } + assert_no_queries { david.projects.columns } + end + + def test_attributes_are_being_set_when_initialized_from_habm_association_with_where_clause + new_developer = projects(:action_controller).developers.where(:name => "Marcelo").build + assert_equal new_developer.name, "Marcelo" + end + + def test_attributes_are_being_set_when_initialized_from_habm_association_with_multiple_where_clauses + new_developer = projects(:action_controller).developers.where(:name => "Marcelo").where(:salary => 90_000).build + assert_equal new_developer.name, "Marcelo" + assert_equal new_developer.salary, 90_000 + end + + def test_include_method_in_has_and_belongs_to_many_association_should_return_true_for_instance_added_with_build + project = Project.new + developer = project.developers.build + assert project.developers.include?(developer) + end + + def test_destruction_does_not_error_without_primary_key + redbeard = pirates(:redbeard) + george = parrots(:george) + redbeard.parrots << george + assert_equal 2, george.pirates.count + Pirate.includes(:parrots).where(parrot: redbeard.parrot).find(redbeard.id).destroy + assert_equal 1, george.pirates.count + assert_equal [], Pirate.where(id: redbeard.id) + end + + def test_has_and_belongs_to_many_associations_on_new_records_use_null_relations + projects = Developer.new.projects + assert_no_queries do + assert_equal [], projects + assert_equal [], projects.where(title: 'omg') + assert_equal [], projects.pluck(:title) + assert_equal 0, projects.count + end + end + + def test_association_with_validate_false_does_not_run_associated_validation_callbacks_on_create + rich_person = RichPerson.new + + treasure = Treasure.new + treasure.rich_people << rich_person + treasure.valid? + + assert_equal 1, treasure.rich_people.size + assert_nil rich_person.first_name, 'should not run associated person validation on create when validate: false' + end + + def test_association_with_validate_false_does_not_run_associated_validation_callbacks_on_update + rich_person = RichPerson.create! + person_first_name = rich_person.first_name + assert_not_nil person_first_name + + treasure = Treasure.new + treasure.rich_people << rich_person + treasure.valid? + + assert_equal 1, treasure.rich_people.size + assert_equal person_first_name, rich_person.first_name, 'should not run associated person validation on update when validate: false' + end + + def test_custom_join_table + assert_equal 'edges', Vertex.reflect_on_association(:sources).join_table + end + + def test_has_and_belongs_to_many_in_a_namespaced_model_pointing_to_a_namespaced_model + magazine = Publisher::Magazine.create + article = Publisher::Article.create + magazine.articles << article + magazine.save + + assert_includes magazine.articles, article + end + + def test_has_and_belongs_to_many_in_a_namespaced_model_pointing_to_a_non_namespaced_model + article = Publisher::Article.create + tag = Tag.create + article.tags << tag + article.save + + assert_includes article.tags, tag + end + + def test_redefine_habtm + child = SubDeveloper.new("name" => "Aredridel") + child.special_projects << SpecialProject.new("name" => "Special Project") + assert child.save, 'child object should be saved' + end +end diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb new file mode 100644 index 0000000000..fe961e871c --- /dev/null +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -0,0 +1,1944 @@ +require "cases/helper" +require 'models/developer' +require 'models/project' +require 'models/company' +require 'models/contract' +require 'models/topic' +require 'models/reply' +require 'models/category' +require 'models/post' +require 'models/author' +require 'models/essay' +require 'models/comment' +require 'models/person' +require 'models/reader' +require 'models/tagging' +require 'models/tag' +require 'models/invoice' +require 'models/line_item' +require 'models/car' +require 'models/bulb' +require 'models/engine' +require 'models/categorization' +require 'models/minivan' +require 'models/speedometer' +require 'models/reference' +require 'models/job' +require 'models/college' +require 'models/student' +require 'models/pirate' +require 'models/ship' + +class HasManyAssociationsTestForReorderWithJoinDependency < ActiveRecord::TestCase + fixtures :authors, :posts, :comments + + def test_should_generate_valid_sql + author = authors(:david) + # this can fail on adapters which require ORDER BY expressions to be included in the SELECT expression + # if the reorder clauses are not correctly handled + assert author.posts_with_comments_sorted_by_comment_id.where('comments.id > 0').reorder('posts.comments_count DESC', 'posts.tags_count DESC').last + end +end + + +class HasManyAssociationsTest < ActiveRecord::TestCase + fixtures :accounts, :categories, :companies, :developers, :projects, + :developers_projects, :topics, :authors, :comments, + :people, :posts, :readers, :taggings, :cars, :essays, + :categorizations, :jobs, :tags + + def setup + Client.destroyed_client_ids.clear + end + + def test_sti_subselect_count + tag = Tag.first + len = Post.tagged_with(tag.id).limit(10).size + assert_operator len, :>, 0 + end + + def test_anonymous_has_many + developer = Class.new(ActiveRecord::Base) { + self.table_name = 'developers' + dev = self + + developer_project = Class.new(ActiveRecord::Base) { + self.table_name = 'developers_projects' + belongs_to :developer, :class => dev + } + has_many :developer_projects, :class => developer_project, :foreign_key => 'developer_id' + } + dev = developer.first + named = Developer.find(dev.id) + assert_operator dev.developer_projects.count, :>, 0 + assert_equal named.projects.map(&:id).sort, + dev.developer_projects.map(&:project_id).sort + end + + def test_has_many_build_with_options + college = College.create(name: 'UFMT') + Student.create(active: true, college_id: college.id, name: 'Sarah') + + assert_equal college.students, Student.where(active: true, college_id: college.id) + end + + def test_create_from_association_should_respect_default_scope + car = Car.create(:name => 'honda') + assert_equal 'honda', car.name + + bulb = Bulb.create + assert_equal 'defaulty', bulb.name + + bulb = car.bulbs.build + assert_equal 'defaulty', bulb.name + + bulb = car.bulbs.create + assert_equal 'defaulty', bulb.name + + bulb = car.bulbs.create(:name => 'exotic') + assert_equal 'exotic', bulb.name + end + + def test_build_from_association_should_respect_scope + author = Author.new + + post = author.thinking_posts.build + assert_equal 'So I was thinking', post.title + end + + def test_create_from_association_with_nil_values_should_work + car = Car.create(:name => 'honda') + + bulb = car.bulbs.new(nil) + assert_equal 'defaulty', bulb.name + + bulb = car.bulbs.build(nil) + assert_equal 'defaulty', bulb.name + + bulb = car.bulbs.create(nil) + assert_equal 'defaulty', bulb.name + end + + def test_do_not_call_callbacks_for_delete_all + car = Car.create(:name => 'honda') + car.funky_bulbs.create! + assert_nothing_raised { car.reload.funky_bulbs.delete_all } + assert_equal 0, Bulb.count, "bulbs should have been deleted using :delete_all strategy" + end + + def test_delete_all_on_association_is_the_same_as_not_loaded + author = authors :david + author.thinking_posts.create!(:body => "test") + author.reload + expected_sql = capture_sql { author.thinking_posts.delete_all } + + author.thinking_posts.create!(:body => "test") + author.reload + author.thinking_posts.inspect + loaded_sql = capture_sql { author.thinking_posts.delete_all } + assert_equal(expected_sql, loaded_sql) + end + + def test_delete_all_on_association_with_nil_dependency_is_the_same_as_not_loaded + author = authors :david + author.posts.create!(:title => "test", :body => "body") + author.reload + expected_sql = capture_sql { author.posts.delete_all } + + author.posts.create!(:title => "test", :body => "body") + author.reload + author.posts.to_a + loaded_sql = capture_sql { author.posts.delete_all } + assert_equal(expected_sql, loaded_sql) + end + + def test_building_the_associated_object_with_implicit_sti_base_class + firm = DependentFirm.new + company = firm.companies.build + assert_kind_of Company, company, "Expected #{company.class} to be a Company" + end + + def test_building_the_associated_object_with_explicit_sti_base_class + firm = DependentFirm.new + company = firm.companies.build(:type => "Company") + assert_kind_of Company, company, "Expected #{company.class} to be a Company" + end + + def test_building_the_associated_object_with_sti_subclass + firm = DependentFirm.new + company = firm.companies.build(:type => "Client") + assert_kind_of Client, company, "Expected #{company.class} to be a Client" + end + + def test_building_the_associated_object_with_an_invalid_type + firm = DependentFirm.new + assert_raise(ActiveRecord::SubclassNotFound) { firm.companies.build(:type => "Invalid") } + end + + def test_building_the_associated_object_with_an_unrelated_type + firm = DependentFirm.new + assert_raise(ActiveRecord::SubclassNotFound) { firm.companies.build(:type => "Account") } + end + + test "building the association with an array" do + speedometer = Speedometer.new(speedometer_id: "a") + data = [{name: "first"}, {name: "second"}] + speedometer.minivans.build(data) + + assert_equal 2, speedometer.minivans.size + assert speedometer.save + assert_equal ["first", "second"], speedometer.reload.minivans.map(&:name) + end + + def test_association_keys_bypass_attribute_protection + car = Car.create(:name => 'honda') + + bulb = car.bulbs.new + assert_equal car.id, bulb.car_id + + bulb = car.bulbs.new :car_id => car.id + 1 + assert_equal car.id, bulb.car_id + + bulb = car.bulbs.build + assert_equal car.id, bulb.car_id + + bulb = car.bulbs.build :car_id => car.id + 1 + assert_equal car.id, bulb.car_id + + bulb = car.bulbs.create + assert_equal car.id, bulb.car_id + + bulb = car.bulbs.create :car_id => car.id + 1 + assert_equal car.id, bulb.car_id + end + + def test_association_protect_foreign_key + invoice = Invoice.create + + line_item = invoice.line_items.new + assert_equal invoice.id, line_item.invoice_id + + line_item = invoice.line_items.new :invoice_id => invoice.id + 1 + assert_equal invoice.id, line_item.invoice_id + + line_item = invoice.line_items.build + assert_equal invoice.id, line_item.invoice_id + + line_item = invoice.line_items.build :invoice_id => invoice.id + 1 + assert_equal invoice.id, line_item.invoice_id + + line_item = invoice.line_items.create + assert_equal invoice.id, line_item.invoice_id + + line_item = invoice.line_items.create :invoice_id => invoice.id + 1 + assert_equal invoice.id, line_item.invoice_id + end + + # When creating objects on the association, we must not do it within a scope (even though it + # would be convenient), because this would cause that scope to be applied to any callbacks etc. + def test_build_and_create_should_not_happen_within_scope + car = cars(:honda) + scoped_count = car.foo_bulbs.where_values.count + + bulb = car.foo_bulbs.build + assert_not_equal scoped_count, bulb.scope_after_initialize.where_values.count + + bulb = car.foo_bulbs.create + assert_not_equal scoped_count, bulb.scope_after_initialize.where_values.count + + bulb = car.foo_bulbs.create! + assert_not_equal scoped_count, bulb.scope_after_initialize.where_values.count + end + + def test_no_sql_should_be_fired_if_association_already_loaded + Car.create(:name => 'honda') + bulbs = Car.first.bulbs + bulbs.to_a # to load all instances of bulbs + + assert_no_queries do + bulbs.first() + bulbs.first({}) + end + + assert_no_queries do + bulbs.second() + bulbs.second({}) + end + + assert_no_queries do + bulbs.third() + bulbs.third({}) + end + + assert_no_queries do + bulbs.fourth() + bulbs.fourth({}) + end + + assert_no_queries do + bulbs.fifth() + bulbs.fifth({}) + end + + assert_no_queries do + bulbs.forty_two() + bulbs.forty_two({}) + end + + assert_no_queries do + bulbs.last() + bulbs.last({}) + end + end + + def test_create_resets_cached_counters + person = Person.create!(:first_name => 'tenderlove') + post = Post.first + + assert_equal [], person.readers + assert_nil person.readers.find_by_post_id(post.id) + + person.readers.create(:post_id => post.id) + + assert_equal 1, person.readers.count + assert_equal 1, person.readers.length + assert_equal post, person.readers.first.post + assert_equal person, person.readers.first.person + end + + def force_signal37_to_load_all_clients_of_firm + companies(:first_firm).clients_of_firm.each {|f| } + end + + # sometimes tests on Oracle fail if ORDER BY is not provided therefore add always :order with :first + def test_counting_with_counter_sql + assert_equal 3, Firm.all.merge!(:order => "id").first.clients.count + end + + def test_counting + assert_equal 3, Firm.all.merge!(:order => "id").first.plain_clients.count + end + + def test_counting_with_single_hash + assert_equal 1, Firm.all.merge!(:order => "id").first.plain_clients.where(:name => "Microsoft").count + end + + def test_counting_with_column_name_and_hash + assert_equal 3, Firm.all.merge!(:order => "id").first.plain_clients.count(:name) + end + + def test_counting_with_association_limit + firm = companies(:first_firm) + assert_equal firm.limited_clients.length, firm.limited_clients.size + assert_equal firm.limited_clients.length, firm.limited_clients.count + end + + def test_finding + assert_equal 3, Firm.all.merge!(:order => "id").first.clients.length + end + + def test_finding_array_compatibility + assert_equal 3, Firm.order(:id).find{|f| f.id > 0}.clients.length + end + + def test_find_many_with_merged_options + assert_equal 1, companies(:first_firm).limited_clients.size + assert_equal 1, companies(:first_firm).limited_clients.to_a.size + assert_equal 3, companies(:first_firm).limited_clients.limit(nil).to_a.size + end + + def test_find_should_append_to_association_order + ordered_clients = companies(:first_firm).clients_sorted_desc.order('companies.id') + assert_equal ['id DESC', 'companies.id'], ordered_clients.order_values + end + + def test_dynamic_find_should_respect_association_order + assert_equal companies(:another_first_firm_client), companies(:first_firm).clients_sorted_desc.where("type = 'Client'").first + assert_equal companies(:another_first_firm_client), companies(:first_firm).clients_sorted_desc.find_by_type('Client') + end + + def test_cant_save_has_many_readonly_association + authors(:david).readonly_comments.each { |c| assert_raise(ActiveRecord::ReadOnlyRecord) { c.save! } } + authors(:david).readonly_comments.each { |c| assert c.readonly? } + end + + def test_finding_default_orders + assert_equal "Summit", Firm.all.merge!(:order => "id").first.clients.first.name + end + + def test_finding_with_different_class_name_and_order + assert_equal "Apex", Firm.all.merge!(:order => "id").first.clients_sorted_desc.first.name + end + + def test_finding_with_foreign_key + assert_equal "Microsoft", Firm.all.merge!(:order => "id").first.clients_of_firm.first.name + end + + def test_finding_with_condition + assert_equal "Microsoft", Firm.all.merge!(:order => "id").first.clients_like_ms.first.name + end + + def test_finding_with_condition_hash + assert_equal "Microsoft", Firm.all.merge!(:order => "id").first.clients_like_ms_with_hash_conditions.first.name + end + + def test_finding_using_primary_key + assert_equal "Summit", Firm.all.merge!(:order => "id").first.clients_using_primary_key.first.name + end + + def test_belongs_to_sanity + c = Client.new + assert_nil c.firm, "belongs_to failed sanity check on new object" + end + + def test_find_ids + firm = Firm.all.merge!(:order => "id").first + + assert_raise(ActiveRecord::RecordNotFound) { firm.clients.find } + + client = firm.clients.find(2) + assert_kind_of Client, client + + client_ary = firm.clients.find([2]) + assert_kind_of Array, client_ary + assert_equal client, client_ary.first + + client_ary = firm.clients.find(2, 3) + assert_kind_of Array, client_ary + assert_equal 2, client_ary.size + assert_equal client, client_ary.first + + assert_raise(ActiveRecord::RecordNotFound) { firm.clients.find(2, 99) } + end + + def test_find_ids_and_inverse_of + force_signal37_to_load_all_clients_of_firm + + firm = companies(:first_firm) + client = firm.clients_of_firm.find(3) + assert_kind_of Client, client + + client_ary = firm.clients_of_firm.find([3]) + assert_kind_of Array, client_ary + assert_equal client, client_ary.first + end + + def test_find_all + firm = Firm.all.merge!(:order => "id").first + assert_equal 3, firm.clients.where("#{QUOTED_TYPE} = 'Client'").to_a.length + assert_equal 1, firm.clients.where("name = 'Summit'").to_a.length + end + + def test_find_each + firm = companies(:first_firm) + + assert ! firm.clients.loaded? + + assert_queries(4) do + firm.clients.find_each(:batch_size => 1) {|c| assert_equal firm.id, c.firm_id } + end + + assert ! firm.clients.loaded? + end + + def test_find_each_with_conditions + firm = companies(:first_firm) + + assert_queries(2) do + firm.clients.where(name: 'Microsoft').find_each(batch_size: 1) do |c| + assert_equal firm.id, c.firm_id + assert_equal "Microsoft", c.name + end + end + + assert ! firm.clients.loaded? + end + + def test_find_in_batches + firm = companies(:first_firm) + + assert ! firm.clients.loaded? + + assert_queries(2) do + firm.clients.find_in_batches(:batch_size => 2) do |clients| + clients.each {|c| assert_equal firm.id, c.firm_id } + end + end + + assert ! firm.clients.loaded? + end + + def test_find_all_sanitized + # sometimes tests on Oracle fail if ORDER BY is not provided therefore add always :order with :first + firm = Firm.all.merge!(:order => "id").first + summit = firm.clients.where("name = 'Summit'").to_a + assert_equal summit, firm.clients.where("name = ?", "Summit").to_a + assert_equal summit, firm.clients.where("name = :name", { :name => "Summit" }).to_a + end + + def test_find_first + firm = Firm.all.merge!(:order => "id").first + client2 = Client.find(2) + assert_equal firm.clients.first, firm.clients.order("id").first + assert_equal client2, firm.clients.where("#{QUOTED_TYPE} = 'Client'").order("id").first + end + + def test_find_first_sanitized + firm = Firm.all.merge!(:order => "id").first + client2 = Client.find(2) + assert_equal client2, firm.clients.merge!(:where => ["#{QUOTED_TYPE} = ?", 'Client'], :order => "id").first + assert_equal client2, firm.clients.merge!(:where => ["#{QUOTED_TYPE} = :type", { :type => 'Client' }], :order => "id").first + end + + def test_find_all_with_include_and_conditions + assert_nothing_raised do + Developer.all.merge!(:joins => :audit_logs, :where => {'audit_logs.message' => nil, :name => 'Smith'}).to_a + end + end + + def test_find_in_collection + assert_equal Client.find(2).name, companies(:first_firm).clients.find(2).name + assert_raise(ActiveRecord::RecordNotFound) { companies(:first_firm).clients.find(6) } + end + + def test_find_grouped + all_clients_of_firm1 = Client.all.merge!(:where => "firm_id = 1").to_a + grouped_clients_of_firm1 = Client.all.merge!(:where => "firm_id = 1", :group => "firm_id", :select => 'firm_id, count(id) as clients_count').to_a + assert_equal 3, all_clients_of_firm1.size + assert_equal 1, grouped_clients_of_firm1.size + end + + def test_find_scoped_grouped + assert_equal 1, companies(:first_firm).clients_grouped_by_firm_id.size + assert_equal 1, companies(:first_firm).clients_grouped_by_firm_id.length + assert_equal 3, companies(:first_firm).clients_grouped_by_name.size + assert_equal 3, companies(:first_firm).clients_grouped_by_name.length + end + + def test_find_scoped_grouped_having + assert_equal 1, authors(:david).popular_grouped_posts.length + assert_equal 0, authors(:mary).popular_grouped_posts.length + end + + def test_default_select + assert_equal Comment.column_names.sort, posts(:welcome).comments.first.attributes.keys.sort + end + + def test_select_query_method + assert_equal ['id', 'body'], posts(:welcome).comments.select(:id, :body).first.attributes.keys + end + + def test_select_with_block + assert_equal [1], posts(:welcome).comments.select { |c| c.id == 1 }.map(&:id) + end + + def test_select_without_foreign_key + assert_equal companies(:first_firm).accounts.first.credit_limit, companies(:first_firm).accounts.select(:credit_limit).first.credit_limit + end + + def test_adding + force_signal37_to_load_all_clients_of_firm + natural = Client.new("name" => "Natural Company") + companies(:first_firm).clients_of_firm << natural + assert_equal 3, companies(:first_firm).clients_of_firm.size # checking via the collection + assert_equal 3, companies(:first_firm).clients_of_firm(true).size # checking using the db + assert_equal natural, companies(:first_firm).clients_of_firm.last + end + + def test_adding_using_create + first_firm = companies(:first_firm) + assert_equal 3, first_firm.plain_clients.size + first_firm.plain_clients.create(:name => "Natural Company") + assert_equal 4, first_firm.plain_clients.length + assert_equal 4, first_firm.plain_clients.size + end + + def test_create_with_bang_on_has_many_when_parent_is_new_raises + assert_raise(ActiveRecord::RecordNotSaved) do + firm = Firm.new + firm.plain_clients.create! :name=>"Whoever" + end + end + + def test_regular_create_on_has_many_when_parent_is_new_raises + assert_raise(ActiveRecord::RecordNotSaved) do + firm = Firm.new + firm.plain_clients.create :name=>"Whoever" + end + end + + def test_create_with_bang_on_has_many_raises_when_record_not_saved + assert_raise(ActiveRecord::RecordInvalid) do + firm = Firm.all.merge!(:order => "id").first + firm.plain_clients.create! + end + end + + def test_create_with_bang_on_habtm_when_parent_is_new_raises + assert_raise(ActiveRecord::RecordNotSaved) do + Developer.new("name" => "Aredridel").projects.create! + end + end + + def test_adding_a_mismatch_class + assert_raise(ActiveRecord::AssociationTypeMismatch) { companies(:first_firm).clients_of_firm << nil } + assert_raise(ActiveRecord::AssociationTypeMismatch) { companies(:first_firm).clients_of_firm << 1 } + assert_raise(ActiveRecord::AssociationTypeMismatch) { companies(:first_firm).clients_of_firm << Topic.find(1) } + end + + def test_adding_a_collection + force_signal37_to_load_all_clients_of_firm + companies(:first_firm).clients_of_firm.concat([Client.new("name" => "Natural Company"), Client.new("name" => "Apple")]) + assert_equal 4, companies(:first_firm).clients_of_firm.size + assert_equal 4, companies(:first_firm).clients_of_firm(true).size + end + + def test_transactions_when_adding_to_persisted + good = Client.new(:name => "Good") + bad = Client.new(:name => "Bad", :raise_on_save => true) + + begin + companies(:first_firm).clients_of_firm.concat(good, bad) + rescue Client::RaisedOnSave + end + + assert !companies(:first_firm).clients_of_firm(true).include?(good) + end + + def test_transactions_when_adding_to_new_record + assert_no_queries do + firm = Firm.new + firm.clients_of_firm.concat(Client.new("name" => "Natural Company")) + end + end + + def test_inverse_on_before_validate + firm = companies(:first_firm) + assert_queries(1) do + firm.clients_of_firm << Client.new("name" => "Natural Company") + end + end + + def test_new_aliased_to_build + company = companies(:first_firm) + new_client = assert_no_queries { company.clients_of_firm.new("name" => "Another Client") } + assert !company.clients_of_firm.loaded? + + assert_equal "Another Client", new_client.name + assert !new_client.persisted? + assert_equal new_client, company.clients_of_firm.last + end + + def test_build + company = companies(:first_firm) + new_client = assert_no_queries { company.clients_of_firm.build("name" => "Another Client") } + assert !company.clients_of_firm.loaded? + + assert_equal "Another Client", new_client.name + assert !new_client.persisted? + assert_equal new_client, company.clients_of_firm.last + end + + def test_collection_size_after_building + company = companies(:first_firm) # company already has one client + company.clients_of_firm.build("name" => "Another Client") + company.clients_of_firm.build("name" => "Yet Another Client") + assert_equal 4, company.clients_of_firm.size + end + + def test_collection_not_empty_after_building + company = companies(:first_firm) + assert_predicate company.contracts, :empty? + company.contracts.build + assert_not_predicate company.contracts, :empty? + 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"}]) } + assert_equal 2, new_clients.size + end + + def test_build_followed_by_save_does_not_load_target + companies(:first_firm).clients_of_firm.build("name" => "Another Client") + assert companies(:first_firm).save + assert !companies(:first_firm).clients_of_firm.loaded? + end + + def test_build_without_loading_association + first_topic = topics(:first) + Reply.column_names + + assert_equal 1, first_topic.replies.length + + assert_no_queries do + first_topic.replies.build(:title => "Not saved", :content => "Superstars") + assert_equal 2, first_topic.replies.size + end + + assert_equal 2, first_topic.replies.to_ary.size + end + + def test_build_via_block + company = companies(:first_firm) + new_client = assert_no_queries { company.clients_of_firm.build {|client| client.name = "Another Client" } } + assert !company.clients_of_firm.loaded? + + assert_equal "Another Client", new_client.name + assert !new_client.persisted? + assert_equal new_client, company.clients_of_firm.last + end + + def test_build_many_via_block + company = companies(:first_firm) + new_clients = assert_no_queries do + company.clients_of_firm.build([{"name" => "Another Client"}, {"name" => "Another Client II"}]) do |client| + client.name = "changed" + end + end + + assert_equal 2, new_clients.size + assert_equal "changed", new_clients.first.name + assert_equal "changed", new_clients.last.name + end + + def test_create_without_loading_association + first_firm = companies(:first_firm) + Firm.column_names + Client.column_names + + assert_equal 2, first_firm.clients_of_firm.size + first_firm.clients_of_firm.reset + + assert_queries(1) do + first_firm.clients_of_firm.create(:name => "Superstars") + end + + assert_equal 3, first_firm.clients_of_firm.size + end + + def test_create + force_signal37_to_load_all_clients_of_firm + new_client = companies(:first_firm).clients_of_firm.create("name" => "Another Client") + assert new_client.persisted? + assert_equal new_client, companies(:first_firm).clients_of_firm.last + assert_equal new_client, companies(:first_firm).clients_of_firm(true).last + end + + def test_create_many + companies(:first_firm).clients_of_firm.create([{"name" => "Another Client"}, {"name" => "Another Client II"}]) + assert_equal 4, companies(:first_firm).clients_of_firm(true).size + end + + def test_create_followed_by_save_does_not_load_target + companies(:first_firm).clients_of_firm.create("name" => "Another Client") + assert companies(:first_firm).save + assert !companies(:first_firm).clients_of_firm.loaded? + end + + def test_deleting + force_signal37_to_load_all_clients_of_firm + companies(:first_firm).clients_of_firm.delete(companies(:first_firm).clients_of_firm.first) + assert_equal 1, companies(:first_firm).clients_of_firm.size + assert_equal 1, companies(:first_firm).clients_of_firm(true).size + end + + def test_deleting_before_save + new_firm = Firm.new("name" => "A New Firm, Inc.") + new_client = new_firm.clients_of_firm.build("name" => "Another Client") + assert_equal 1, new_firm.clients_of_firm.size + new_firm.clients_of_firm.delete(new_client) + assert_equal 0, new_firm.clients_of_firm.size + end + + def test_deleting_updates_counter_cache + topic = Topic.order("id ASC").first + assert_equal topic.replies.to_a.size, topic.replies_count + + topic.replies.delete(topic.replies.first) + topic.reload + assert_equal topic.replies.to_a.size, topic.replies_count + end + + def test_counter_cache_updates_in_memory_after_concat + topic = Topic.create title: "Zoom-zoom-zoom" + + topic.replies << Reply.create(title: "re: zoom", content: "speedy quick!") + assert_equal 1, topic.replies_count + assert_equal 1, topic.replies.size + assert_equal 1, topic.reload.replies.size + end + + def test_counter_cache_updates_in_memory_after_create + topic = Topic.create title: "Zoom-zoom-zoom" + + topic.replies.create!(title: "re: zoom", content: "speedy quick!") + assert_equal 1, topic.replies_count + assert_equal 1, topic.replies.size + assert_equal 1, topic.reload.replies.size + end + + def test_counter_cache_updates_in_memory_after_create_with_array + topic = Topic.create title: "Zoom-zoom-zoom" + + topic.replies.create!([ + { title: "re: zoom", content: "speedy quick!" }, + { title: "re: zoom 2", content: "OMG lol!" }, + ]) + assert_equal 2, topic.replies_count + assert_equal 2, topic.replies.size + assert_equal 2, topic.reload.replies.size + end + + def test_pushing_association_updates_counter_cache + topic = Topic.order("id ASC").first + reply = Reply.create! + + assert_difference "topic.reload.replies_count", 1 do + topic.replies << reply + end + end + + def test_deleting_updates_counter_cache_without_dependent_option + post = posts(:welcome) + + assert_difference "post.reload.tags_count", -1 do + post.taggings.delete(post.taggings.first) + end + end + + def test_deleting_updates_counter_cache_with_dependent_delete_all + post = posts(:welcome) + post.update_columns(taggings_with_delete_all_count: post.tags_count) + + assert_difference "post.reload.taggings_with_delete_all_count", -1 do + post.taggings_with_delete_all.delete(post.taggings_with_delete_all.first) + end + end + + def test_deleting_updates_counter_cache_with_dependent_destroy + post = posts(:welcome) + post.update_columns(taggings_with_destroy_count: post.tags_count) + + assert_difference "post.reload.taggings_with_destroy_count", -1 do + post.taggings_with_destroy.delete(post.taggings_with_destroy.first) + end + end + + def test_calling_empty_with_counter_cache + post = posts(:welcome) + assert_queries(0) do + assert_not post.comments.empty? + end + end + + def test_custom_named_counter_cache + topic = topics(:first) + + assert_difference "topic.reload.replies_count", -1 do + topic.approved_replies.clear + end + end + + def test_calling_update_attributes_on_id_changes_the_counter_cache + topic = Topic.order("id ASC").first + original_count = topic.replies.to_a.size + assert_equal original_count, topic.replies_count + + first_reply = topic.replies.first + first_reply.update_attributes(:parent_id => nil) + assert_equal original_count - 1, topic.reload.replies_count + + first_reply.update_attributes(:parent_id => topic.id) + assert_equal original_count, topic.reload.replies_count + end + + def test_calling_update_attributes_changing_ids_doesnt_change_counter_cache + topic1 = Topic.find(1) + topic2 = Topic.find(3) + original_count1 = topic1.replies.to_a.size + original_count2 = topic2.replies.to_a.size + + reply1 = topic1.replies.first + reply2 = topic2.replies.first + + reply1.update_attributes(:parent_id => topic2.id) + assert_equal original_count1 - 1, topic1.reload.replies_count + assert_equal original_count2 + 1, topic2.reload.replies_count + + reply2.update_attributes(:parent_id => topic1.id) + assert_equal original_count1, topic1.reload.replies_count + assert_equal original_count2, topic2.reload.replies_count + end + + def test_deleting_a_collection + force_signal37_to_load_all_clients_of_firm + companies(:first_firm).clients_of_firm.create("name" => "Another Client") + assert_equal 3, companies(:first_firm).clients_of_firm.size + companies(:first_firm).clients_of_firm.delete([companies(:first_firm).clients_of_firm[0], companies(:first_firm).clients_of_firm[1], companies(:first_firm).clients_of_firm[2]]) + assert_equal 0, companies(:first_firm).clients_of_firm.size + assert_equal 0, companies(:first_firm).clients_of_firm(true).size + end + + def test_delete_all + force_signal37_to_load_all_clients_of_firm + companies(:first_firm).dependent_clients_of_firm.create("name" => "Another Client") + clients = companies(:first_firm).dependent_clients_of_firm.to_a + assert_equal 3, clients.count + + assert_difference "Client.count", -(clients.count) do + companies(:first_firm).dependent_clients_of_firm.delete_all + end + end + + def test_delete_all_with_not_yet_loaded_association_collection + force_signal37_to_load_all_clients_of_firm + companies(:first_firm).clients_of_firm.create("name" => "Another Client") + assert_equal 3, companies(:first_firm).clients_of_firm.size + companies(:first_firm).clients_of_firm.reset + companies(:first_firm).clients_of_firm.delete_all + assert_equal 0, companies(:first_firm).clients_of_firm.size + assert_equal 0, companies(:first_firm).clients_of_firm(true).size + end + + def test_transaction_when_deleting_persisted + good = Client.new(:name => "Good") + bad = Client.new(:name => "Bad", :raise_on_destroy => true) + + companies(:first_firm).clients_of_firm = [good, bad] + + begin + companies(:first_firm).clients_of_firm.destroy(good, bad) + rescue Client::RaisedOnDestroy + end + + assert_equal [good, bad], companies(:first_firm).clients_of_firm(true) + end + + def test_transaction_when_deleting_new_record + assert_no_queries do + firm = Firm.new + client = Client.new("name" => "New Client") + firm.clients_of_firm << client + firm.clients_of_firm.destroy(client) + end + end + + def test_clearing_an_association_collection + firm = companies(:first_firm) + client_id = firm.clients_of_firm.first.id + assert_equal 2, firm.clients_of_firm.size + + firm.clients_of_firm.clear + + assert_equal 0, firm.clients_of_firm.size + assert_equal 0, firm.clients_of_firm(true).size + assert_equal [], Client.destroyed_client_ids[firm.id] + + # Should not be destroyed since the association is not dependent. + assert_nothing_raised do + assert_nil Client.find(client_id).firm + end + end + + def test_clearing_updates_counter_cache + topic = Topic.first + + assert_difference 'topic.reload.replies_count', -1 do + topic.replies.clear + end + end + + def test_clearing_updates_counter_cache_when_inverse_counter_cache_is_a_symbol_with_dependent_destroy + car = Car.first + car.engines.create! + + assert_difference 'car.reload.engines_count', -1 do + car.engines.clear + end + end + + def test_clearing_a_dependent_association_collection + firm = companies(:first_firm) + client_id = firm.dependent_clients_of_firm.first.id + assert_equal 2, firm.dependent_clients_of_firm.size + assert_equal 1, Client.find_by_id(client_id).client_of + + # :delete_all is called on each client since the dependent options is :destroy + firm.dependent_clients_of_firm.clear + + assert_equal 0, firm.dependent_clients_of_firm.size + assert_equal 0, firm.dependent_clients_of_firm(true).size + assert_equal [], Client.destroyed_client_ids[firm.id] + + # Should be destroyed since the association is dependent. + assert_nil Client.find_by_id(client_id) + end + + def test_delete_all_with_option_delete_all + firm = companies(:first_firm) + client_id = firm.dependent_clients_of_firm.first.id + firm.dependent_clients_of_firm.delete_all(:delete_all) + assert_nil Client.find_by_id(client_id) + end + + def test_delete_all_accepts_limited_parameters + firm = companies(:first_firm) + assert_raise(ArgumentError) do + firm.dependent_clients_of_firm.delete_all(:destroy) + end + end + + def test_clearing_an_exclusively_dependent_association_collection + firm = companies(:first_firm) + client_id = firm.exclusively_dependent_clients_of_firm.first.id + assert_equal 2, firm.exclusively_dependent_clients_of_firm.size + + assert_equal [], Client.destroyed_client_ids[firm.id] + + # :exclusively_dependent means each client is deleted directly from + # the database without looping through them calling destroy. + firm.exclusively_dependent_clients_of_firm.clear + + assert_equal 0, firm.exclusively_dependent_clients_of_firm.size + assert_equal 0, firm.exclusively_dependent_clients_of_firm(true).size + # no destroy-filters should have been called + assert_equal [], Client.destroyed_client_ids[firm.id] + + # Should be destroyed since the association is exclusively dependent. + assert_nil Client.find_by_id(client_id) + end + + def test_dependent_association_respects_optional_conditions_on_delete + firm = companies(:odegy) + Client.create(:client_of => firm.id, :name => "BigShot Inc.") + Client.create(:client_of => firm.id, :name => "SmallTime Inc.") + # only one of two clients is included in the association due to the :conditions key + assert_equal 2, Client.where(client_of: firm.id).size + assert_equal 1, firm.dependent_conditional_clients_of_firm.size + firm.destroy + # only the correctly associated client should have been deleted + assert_equal 1, Client.where(client_of: firm.id).size + end + + def test_dependent_association_respects_optional_sanitized_conditions_on_delete + firm = companies(:odegy) + Client.create(:client_of => firm.id, :name => "BigShot Inc.") + Client.create(:client_of => firm.id, :name => "SmallTime Inc.") + # only one of two clients is included in the association due to the :conditions key + assert_equal 2, Client.where(client_of: firm.id).size + assert_equal 1, firm.dependent_sanitized_conditional_clients_of_firm.size + firm.destroy + # only the correctly associated client should have been deleted + assert_equal 1, Client.where(client_of: firm.id).size + end + + def test_dependent_association_respects_optional_hash_conditions_on_delete + firm = companies(:odegy) + Client.create(:client_of => firm.id, :name => "BigShot Inc.") + Client.create(:client_of => firm.id, :name => "SmallTime Inc.") + # only one of two clients is included in the association due to the :conditions key + assert_equal 2, Client.where(client_of: firm.id).size + assert_equal 1, firm.dependent_sanitized_conditional_clients_of_firm.size + firm.destroy + # only the correctly associated client should have been deleted + assert_equal 1, Client.where(client_of: firm.id).size + end + + def test_delete_all_association_with_primary_key_deletes_correct_records + firm = Firm.first + # break the vanilla firm_id foreign key + assert_equal 3, firm.clients.count + firm.clients.first.update_columns(firm_id: nil) + assert_equal 2, firm.clients(true).count + assert_equal 2, firm.clients_using_primary_key_with_delete_all.count + old_record = firm.clients_using_primary_key_with_delete_all.first + firm = Firm.first + firm.destroy + assert_nil Client.find_by_id(old_record.id) + end + + def test_creation_respects_hash_condition + ms_client = companies(:first_firm).clients_like_ms_with_hash_conditions.build + + assert ms_client.save + assert_equal 'Microsoft', ms_client.name + + another_ms_client = companies(:first_firm).clients_like_ms_with_hash_conditions.create + + assert another_ms_client.persisted? + assert_equal 'Microsoft', another_ms_client.name + end + + def test_clearing_without_initial_access + firm = companies(:first_firm) + + firm.clients_of_firm.clear + + assert_equal 0, firm.clients_of_firm.size + assert_equal 0, firm.clients_of_firm(true).size + end + + def test_deleting_a_item_which_is_not_in_the_collection + force_signal37_to_load_all_clients_of_firm + summit = Client.find_by_name('Summit') + companies(:first_firm).clients_of_firm.delete(summit) + assert_equal 2, companies(:first_firm).clients_of_firm.size + assert_equal 2, companies(:first_firm).clients_of_firm(true).size + assert_equal 2, summit.client_of + end + + def test_deleting_by_fixnum_id + david = Developer.find(1) + + assert_difference 'david.projects.count', -1 do + assert_equal 1, david.projects.delete(1).size + end + + assert_equal 1, david.projects.size + end + + def test_deleting_by_string_id + david = Developer.find(1) + + assert_difference 'david.projects.count', -1 do + assert_equal 1, david.projects.delete('1').size + end + + assert_equal 1, david.projects.size + end + + def test_deleting_self_type_mismatch + david = Developer.find(1) + david.projects.reload + assert_raise(ActiveRecord::AssociationTypeMismatch) { david.projects.delete(Project.find(1).developers) } + end + + def test_destroying + force_signal37_to_load_all_clients_of_firm + + assert_difference "Client.count", -1 do + companies(:first_firm).clients_of_firm.destroy(companies(:first_firm).clients_of_firm.first) + end + + assert_equal 1, companies(:first_firm).reload.clients_of_firm.size + assert_equal 1, companies(:first_firm).clients_of_firm(true).size + end + + def test_destroying_by_fixnum_id + force_signal37_to_load_all_clients_of_firm + + assert_difference "Client.count", -1 do + companies(:first_firm).clients_of_firm.destroy(companies(:first_firm).clients_of_firm.first.id) + end + + assert_equal 1, companies(:first_firm).reload.clients_of_firm.size + assert_equal 1, companies(:first_firm).clients_of_firm(true).size + end + + def test_destroying_by_string_id + force_signal37_to_load_all_clients_of_firm + + assert_difference "Client.count", -1 do + companies(:first_firm).clients_of_firm.destroy(companies(:first_firm).clients_of_firm.first.id.to_s) + end + + assert_equal 1, companies(:first_firm).reload.clients_of_firm.size + assert_equal 1, companies(:first_firm).clients_of_firm(true).size + end + + def test_destroying_a_collection + force_signal37_to_load_all_clients_of_firm + companies(:first_firm).clients_of_firm.create("name" => "Another Client") + assert_equal 3, companies(:first_firm).clients_of_firm.size + + assert_difference "Client.count", -2 do + companies(:first_firm).clients_of_firm.destroy([companies(:first_firm).clients_of_firm[0], companies(:first_firm).clients_of_firm[1]]) + end + + assert_equal 1, companies(:first_firm).reload.clients_of_firm.size + assert_equal 1, companies(:first_firm).clients_of_firm(true).size + end + + def test_destroy_all + force_signal37_to_load_all_clients_of_firm + clients = companies(:first_firm).clients_of_firm.to_a + assert !clients.empty?, "37signals has clients after load" + destroyed = companies(:first_firm).clients_of_firm.destroy_all + assert_equal clients.sort_by(&:id), destroyed.sort_by(&:id) + assert destroyed.all? { |client| client.frozen? }, "destroyed clients should be frozen" + assert companies(:first_firm).clients_of_firm.empty?, "37signals has no clients after destroy all" + assert companies(:first_firm).clients_of_firm(true).empty?, "37signals has no clients after destroy all and refresh" + end + + def test_dependence + firm = companies(:first_firm) + assert_equal 3, firm.clients.size + firm.destroy + assert Client.all.merge!(:where => "firm_id=#{firm.id}").to_a.empty? + end + + def test_dependence_for_associations_with_hash_condition + david = authors(:david) + assert_difference('Post.count', -1) { assert david.destroy } + end + + def test_destroy_dependent_when_deleted_from_association + # sometimes tests on Oracle fail if ORDER BY is not provided therefore add always :order with :first + firm = Firm.all.merge!(:order => "id").first + assert_equal 3, firm.clients.size + + client = firm.clients.first + firm.clients.delete(client) + + assert_raise(ActiveRecord::RecordNotFound) { Client.find(client.id) } + assert_raise(ActiveRecord::RecordNotFound) { firm.clients.find(client.id) } + assert_equal 2, firm.clients.size + end + + def test_three_levels_of_dependence + topic = Topic.create "title" => "neat and simple" + reply = topic.replies.create "title" => "neat and simple", "content" => "still digging it" + reply.replies.create "title" => "neat and simple", "content" => "ain't complaining" + + assert_nothing_raised { topic.destroy } + end + + uses_transaction :test_dependence_with_transaction_support_on_failure + def test_dependence_with_transaction_support_on_failure + firm = companies(:first_firm) + clients = firm.clients + assert_equal 3, clients.length + clients.last.instance_eval { def overwrite_to_raise() raise "Trigger rollback" end } + + firm.destroy rescue "do nothing" + + assert_equal 3, Client.all.merge!(:where => "firm_id=#{firm.id}").to_a.size + end + + def test_dependence_on_account + num_accounts = Account.count + companies(:first_firm).destroy + assert_equal num_accounts - 1, Account.count + end + + def test_depends_and_nullify + num_accounts = Account.count + + core = companies(:rails_core) + assert_equal accounts(:rails_core_account), core.account + assert_equal companies(:leetsoft, :jadedpixel), core.companies + core.destroy + assert_nil accounts(:rails_core_account).reload.firm_id + assert_nil companies(:leetsoft).reload.client_of + assert_nil companies(:jadedpixel).reload.client_of + + assert_equal num_accounts, Account.count + end + + def test_restrict_with_exception + firm = RestrictedWithExceptionFirm.create!(:name => 'restrict') + firm.companies.create(:name => 'child') + + assert !firm.companies.empty? + assert_raise(ActiveRecord::DeleteRestrictionError) { firm.destroy } + assert RestrictedWithExceptionFirm.exists?(:name => 'restrict') + assert firm.companies.exists?(:name => 'child') + end + + def test_restrict_with_error + firm = RestrictedWithErrorFirm.create!(:name => 'restrict') + firm.companies.create(:name => 'child') + + assert !firm.companies.empty? + + firm.destroy + + assert !firm.errors.empty? + + assert_equal "Cannot delete record because dependent companies exist", firm.errors[:base].first + assert RestrictedWithErrorFirm.exists?(:name => 'restrict') + assert firm.companies.exists?(:name => 'child') + end + + def test_included_in_collection + assert_equal true, companies(:first_firm).clients.include?(Client.find(2)) + end + + def test_included_in_collection_for_new_records + client = Client.create(:name => 'Persisted') + assert_nil client.client_of + assert_equal false, Firm.new.clients_of_firm.include?(client), + 'includes a client that does not belong to any firm' + end + + def test_adding_array_and_collection + assert_nothing_raised { Firm.first.clients + Firm.all.last.clients } + end + + def test_replace_with_less + firm = Firm.all.merge!(:order => "id").first + firm.clients = [companies(:first_client)] + assert firm.save, "Could not save firm" + firm.reload + assert_equal 1, firm.clients.length + end + + def test_replace_with_less_and_dependent_nullify + num_companies = Company.count + companies(:rails_core).companies = [] + assert_equal num_companies, Company.count + end + + def test_replace_with_new + firm = Firm.all.merge!(:order => "id").first + firm.clients = [companies(:second_client), Client.new("name" => "New Client")] + firm.save + firm.reload + assert_equal 2, firm.clients.length + assert_equal false, firm.clients.include?(:first_client) + end + + def test_replace_failure + firm = companies(:first_firm) + account = Account.new + orig_accounts = firm.accounts.to_a + + assert !account.valid? + assert !orig_accounts.empty? + assert_raise ActiveRecord::RecordNotSaved do + firm.accounts = [account] + end + assert_equal orig_accounts, firm.accounts + end + + def test_replace_with_same_content + firm = Firm.first + firm.clients = [] + firm.save + + assert_queries(0, ignore_none: true) do + firm.clients = [] + end + end + + def test_transactions_when_replacing_on_persisted + good = Client.new(:name => "Good") + bad = Client.new(:name => "Bad", :raise_on_save => true) + + companies(:first_firm).clients_of_firm = [good] + + begin + companies(:first_firm).clients_of_firm = [bad] + rescue Client::RaisedOnSave + end + + assert_equal [good], companies(:first_firm).clients_of_firm(true) + end + + def test_transactions_when_replacing_on_new_record + assert_no_queries do + firm = Firm.new + firm.clients_of_firm = [Client.new("name" => "New Client")] + end + end + + def test_get_ids + assert_equal [companies(:first_client).id, companies(:second_client).id, companies(:another_first_firm_client).id], companies(:first_firm).client_ids + end + + def test_get_ids_for_loaded_associations + company = companies(:first_firm) + company.clients(true) + assert_queries(0) do + company.client_ids + company.client_ids + end + end + + def test_get_ids_for_unloaded_associations_does_not_load_them + company = companies(:first_firm) + assert !company.clients.loaded? + assert_equal [companies(:first_client).id, companies(:second_client).id, companies(:another_first_firm_client).id], company.client_ids + assert !company.clients.loaded? + end + + def test_get_ids_ignores_include_option + assert_equal [readers(:michael_welcome).id], posts(:welcome).readers_with_person_ids + end + + def test_get_ids_for_ordered_association + assert_equal [companies(:another_first_firm_client).id, companies(:second_client).id, companies(:first_client).id], companies(:first_firm).clients_ordered_by_name_ids + end + + def test_get_ids_for_association_on_new_record_does_not_try_to_find_records + Company.columns # Load schema information so we don't query below + Contract.columns # if running just this test. + + company = Company.new + assert_queries(0) do + company.contract_ids + end + + assert_equal [], company.contract_ids + end + + def test_set_ids_for_association_on_new_record_applies_association_correctly + contract_a = Contract.create! + contract_b = Contract.create! + Contract.create! # another contract + company = Company.new(:name => "Some Company") + + company.contract_ids = [contract_a.id, contract_b.id] + assert_equal [contract_a.id, contract_b.id], company.contract_ids + assert_equal [contract_a, contract_b], company.contracts + + company.save! + assert_equal company, contract_a.reload.company + assert_equal company, contract_b.reload.company + end + + def test_assign_ids_ignoring_blanks + firm = Firm.create!(:name => 'Apple') + firm.client_ids = [companies(:first_client).id, nil, companies(:second_client).id, ''] + firm.save! + + assert_equal 2, firm.clients(true).size + assert_equal true, firm.clients.include?(companies(:second_client)) + end + + def test_get_ids_for_through + assert_equal [comments(:eager_other_comment1).id], authors(:mary).comment_ids + end + + def test_modifying_a_through_a_has_many_should_raise + [ + lambda { authors(:mary).comment_ids = [comments(:greetings).id, comments(:more_greetings).id] }, + lambda { authors(:mary).comments = [comments(:greetings), comments(:more_greetings)] }, + lambda { authors(:mary).comments << Comment.create!(:body => "Yay", :post_id => 424242) }, + lambda { authors(:mary).comments.delete(authors(:mary).comments.first) }, + ].each {|block| assert_raise(ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection, &block) } + end + + def test_dynamic_find_should_respect_association_order_for_through + assert_equal Comment.find(10), authors(:david).comments_desc.where("comments.type = 'SpecialComment'").first + assert_equal Comment.find(10), authors(:david).comments_desc.find_by_type('SpecialComment') + end + + def test_has_many_through_respects_hash_conditions + assert_equal authors(:david).hello_posts, authors(:david).hello_posts_with_hash_conditions + assert_equal authors(:david).hello_post_comments, authors(:david).hello_post_comments_with_hash_conditions + end + + def test_include_uses_array_include_after_loaded + firm = companies(:first_firm) + firm.clients.load_target + + client = firm.clients.first + + assert_no_queries do + assert firm.clients.loaded? + assert_equal true, firm.clients.include?(client) + end + end + + def test_include_checks_if_record_exists_if_target_not_loaded + firm = companies(:first_firm) + client = firm.clients.first + + firm.reload + assert ! firm.clients.loaded? + assert_queries(1) do + assert_equal true, firm.clients.include?(client) + end + assert ! firm.clients.loaded? + end + + def test_include_returns_false_for_non_matching_record_to_verify_scoping + firm = companies(:first_firm) + client = Client.create!(:name => 'Not Associated') + + assert ! firm.clients.loaded? + assert_equal false, firm.clients.include?(client) + end + + def test_calling_first_nth_or_last_on_association_should_not_load_association + firm = companies(:first_firm) + firm.clients.first + firm.clients.second + firm.clients.last + assert !firm.clients.loaded? + end + + def test_calling_first_or_last_on_loaded_association_should_not_fetch_with_query + firm = companies(:first_firm) + firm.clients.load_target + assert firm.clients.loaded? + + assert_no_queries do + firm.clients.first + assert_equal 2, firm.clients.first(2).size + firm.clients.last + assert_equal 2, firm.clients.last(2).size + end + end + + def test_calling_first_or_last_on_existing_record_with_build_should_load_association + firm = companies(:first_firm) + firm.clients.build(:name => 'Foo') + assert !firm.clients.loaded? + + assert_queries 1 do + firm.clients.first + firm.clients.second + firm.clients.last + end + + assert firm.clients.loaded? + end + + def test_calling_first_nth_or_last_on_existing_record_with_create_should_not_load_association + firm = companies(:first_firm) + firm.clients.create(:name => 'Foo') + assert !firm.clients.loaded? + + assert_queries 3 do + firm.clients.first + firm.clients.second + firm.clients.last + end + + assert !firm.clients.loaded? + end + + def test_calling_first_nth_or_last_on_new_record_should_not_run_queries + firm = Firm.new + + assert_no_queries do + firm.clients.first + firm.clients.second + firm.clients.last + end + end + + def test_custom_primary_key_on_new_record_should_fetch_with_query + author = Author.new(:name => "David") + assert !author.essays.loaded? + + assert_queries 1 do + assert_equal 1, author.essays.size + end + + assert_equal author.essays, Essay.where(writer_id: "David") + end + + def test_has_many_custom_primary_key + david = authors(:david) + assert_equal david.essays, Essay.where(writer_id: "David") + end + + def test_has_many_assignment_with_custom_primary_key + david = people(:david) + + assert_equal ["A Modest Proposal"], david.essays.map(&:name) + david.essays = [Essay.create!(name: "Remote Work" )] + assert_equal ["Remote Work"], david.essays.map(&:name) + end + + def test_blank_custom_primary_key_on_new_record_should_not_run_queries + author = Author.new + assert !author.essays.loaded? + + assert_queries 0 do + assert_equal 0, author.essays.size + end + end + + def test_calling_first_or_last_with_integer_on_association_should_not_load_association + firm = companies(:first_firm) + firm.clients.create(:name => 'Foo') + assert !firm.clients.loaded? + + assert_queries 2 do + firm.clients.first(2) + firm.clients.last(2) + end + + assert !firm.clients.loaded? + end + + def test_calling_many_should_count_instead_of_loading_association + firm = companies(:first_firm) + assert_queries(1) do + firm.clients.many? # use count query + end + assert !firm.clients.loaded? + end + + def test_calling_many_on_loaded_association_should_not_use_query + firm = companies(:first_firm) + firm.clients.collect # force load + assert_no_queries { assert firm.clients.many? } + end + + def test_calling_many_should_defer_to_collection_if_using_a_block + firm = companies(:first_firm) + assert_queries(1) do + firm.clients.expects(:size).never + firm.clients.many? { true } + end + assert firm.clients.loaded? + end + + def test_calling_many_should_return_false_if_none_or_one + firm = companies(:another_firm) + assert !firm.clients_like_ms.many? + assert_equal 0, firm.clients_like_ms.size + + firm = companies(:first_firm) + assert !firm.limited_clients.many? + assert_equal 1, firm.limited_clients.size + end + + def test_calling_many_should_return_true_if_more_than_one + firm = companies(:first_firm) + assert firm.clients.many? + assert_equal 3, firm.clients.size + 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.all.merge!( + :select => "#{Namespaced::Firm.table_name}.id, COUNT(#{Namespaced::Client.table_name}.id) AS num_clients", + :joins => :clients, + :group => "#{Namespaced::Firm.table_name}.id" + ).find firm.id + assert_equal 1, stats.num_clients.to_i + ensure + ActiveRecord::Base.store_full_sti_class = old + end + + def test_association_proxy_transaction_method_starts_transaction_in_association_class + Comment.expects(:transaction) + Post.first.comments.transaction do + # nothing + end + end + + def test_sending_new_to_association_proxy_should_have_same_effect_as_calling_new + client_association = companies(:first_firm).clients + assert_equal client_association.new.attributes, client_association.send(:new).attributes + end + + def test_respond_to_private_class_methods + client_association = companies(:first_firm).clients + assert !client_association.respond_to?(:private_method) + assert client_association.respond_to?(:private_method, true) + end + + def test_creating_using_primary_key + firm = Firm.all.merge!(:order => "id").first + client = firm.clients_using_primary_key.create!(:name => 'test') + assert_equal firm.name, client.firm_name + end + + def test_defining_has_many_association_with_delete_all_dependency_lazily_evaluates_target_class + ActiveRecord::Reflection::AssociationReflection.any_instance.expects(:class_name).never + class_eval(<<-EOF, __FILE__, __LINE__ + 1) + class DeleteAllModel < ActiveRecord::Base + has_many :nonentities, :dependent => :delete_all + end + EOF + end + + def test_defining_has_many_association_with_nullify_dependency_lazily_evaluates_target_class + ActiveRecord::Reflection::AssociationReflection.any_instance.expects(:class_name).never + class_eval(<<-EOF, __FILE__, __LINE__ + 1) + class NullifyModel < ActiveRecord::Base + has_many :nonentities, :dependent => :nullify + end + EOF + end + + def test_attributes_are_being_set_when_initialized_from_has_many_association_with_where_clause + new_comment = posts(:welcome).comments.where(:body => "Some content").build + assert_equal new_comment.body, "Some content" + end + + def test_attributes_are_being_set_when_initialized_from_has_many_association_with_multiple_where_clauses + new_comment = posts(:welcome).comments.where(:body => "Some content").where(:type => 'SpecialComment').build + assert_equal new_comment.body, "Some content" + assert_equal new_comment.type, "SpecialComment" + assert_equal new_comment.post_id, posts(:welcome).id + end + + def test_include_method_in_has_many_association_should_return_true_for_instance_added_with_build + post = Post.new + comment = post.comments.build + assert_equal true, post.comments.include?(comment) + end + + def test_load_target_respects_protected_attributes + topic = Topic.create! + reply = topic.replies.create(:title => "reply 1") + reply.approved = false + reply.save! + + # Save with a different object instance, so the instance that's still held + # in topic.relies doesn't know about the changed attribute. + reply2 = Reply.find(reply.id) + reply2.approved = true + reply2.save! + + # Force loading the collection from the db. This will merge the existing + # object (reply) with what gets loaded from the db (which includes the + # changed approved attribute). approved is a protected attribute, so if mass + # assignment is used, it won't get updated and will still be false. + first = topic.replies.to_a.first + assert_equal reply.id, first.id + assert_equal true, first.approved? + end + + def test_to_a_should_dup_target + ary = topics(:first).replies.to_a + target = topics(:first).replies.target + + assert_not_equal target.object_id, ary.object_id + end + + def test_merging_with_custom_attribute_writer + bulb = Bulb.new(:color => "red") + assert_equal "RED!", bulb.color + + car = Car.create! + car.bulbs << bulb + + assert_equal "RED!", car.bulbs.to_a.first.color + end + + def test_abstract_class_with_polymorphic_has_many + post = SubStiPost.create! :title => "fooo", :body => "baa" + tagging = Tagging.create! :taggable => post + assert_equal [tagging], post.taggings + end + + def test_build_with_polymorphic_has_many_does_not_allow_to_override_type_and_id + welcome = posts(:welcome) + tagging = welcome.taggings.build(:taggable_id => 99, :taggable_type => 'ShouldNotChange') + + assert_equal welcome.id, tagging.taggable_id + assert_equal 'Post', tagging.taggable_type + end + + def test_dont_call_save_callbacks_twice_on_has_many + firm = companies(:first_firm) + contract = firm.contracts.create! + + assert_equal 1, contract.hi_count + assert_equal 1, contract.bye_count + end + + def test_association_attributes_are_available_to_after_initialize + car = Car.create(:name => 'honda') + bulb = car.bulbs.build + + assert_equal car.id, bulb.attributes_after_initialize['car_id'] + end + + def test_attributes_are_set_when_initialized_from_has_many_null_relationship + car = Car.new name: 'honda' + bulb = car.bulbs.where(name: 'headlight').first_or_initialize + assert_equal 'headlight', bulb.name + end + + def test_attributes_are_set_when_initialized_from_polymorphic_has_many_null_relationship + post = Post.new title: 'title', body: 'bar' + tag = Tag.create!(name: 'foo') + + tagging = post.taggings.where(tag: tag).first_or_initialize + + assert_equal tag.id, tagging.tag_id + assert_equal 'Post', tagging.taggable_type + end + + def test_replace + car = Car.create(:name => 'honda') + bulb1 = car.bulbs.create + bulb2 = Bulb.create + + assert_equal [bulb1], car.bulbs + car.bulbs.replace([bulb2]) + assert_equal [bulb2], car.bulbs + assert_equal [bulb2], car.reload.bulbs + end + + def test_replace_returns_target + car = Car.create(:name => 'honda') + bulb1 = car.bulbs.create + bulb2 = car.bulbs.create + bulb3 = Bulb.create + + assert_equal [bulb1, bulb2], car.bulbs + result = car.bulbs.replace([bulb3, bulb1]) + assert_equal [bulb1, bulb3], car.bulbs + assert_equal [bulb1, bulb3], result + end + + def test_collection_association_with_private_kernel_method + firm = companies(:first_firm) + assert_equal [accounts(:signals37)], firm.accounts.open + end + + test "first_or_initialize adds the record to the association" do + firm = Firm.create! name: 'omg' + client = firm.clients_of_firm.first_or_initialize + assert_equal [client], firm.clients_of_firm + end + + test "first_or_create adds the record to the association" do + firm = Firm.create! name: 'omg' + firm.clients_of_firm.load_target + client = firm.clients_of_firm.first_or_create name: 'lol' + assert_equal [client], firm.clients_of_firm + assert_equal [client], firm.reload.clients_of_firm + end + + test "delete_all, when not loaded, doesn't load the records" do + post = posts(:welcome) + + assert post.taggings_with_delete_all.count > 0 + assert !post.taggings_with_delete_all.loaded? + + # 2 queries: one DELETE and another to update the counter cache + assert_queries(2) do + post.taggings_with_delete_all.delete_all + end + end + + test "has many associations on new records use null relations" do + post = Post.new + + assert_no_queries do + assert_equal [], post.comments + assert_equal [], post.comments.where(body: 'omg') + assert_equal [], post.comments.pluck(:body) + assert_equal 0, post.comments.sum(:id) + assert_equal 0, post.comments.count + end + end + + test "collection proxy respects default scope" do + author = authors(:mary) + assert !author.first_posts.exists? + end + + test "association with extend option" do + post = posts(:welcome) + assert_equal "lifo", post.comments_with_extend.author + assert_equal "hello", post.comments_with_extend.greeting + end + + test "association with extend option with multiple extensions" do + post = posts(:welcome) + assert_equal "lifo", post.comments_with_extend_2.author + assert_equal "hello", post.comments_with_extend_2.greeting + end + + test "delete record with complex joins" do + david = authors(:david) + + post = david.posts.first + post.type = 'PostWithSpecialCategorization' + post.save + + categorization = post.categorizations.first + categorization.special = true + categorization.save + + assert_not_equal [], david.posts_with_special_categorizations + david.posts_with_special_categorizations = [] + assert_equal [], david.posts_with_special_categorizations + end + + test "does not duplicate associations when used with natural primary keys" do + speedometer = Speedometer.create!(id: '4') + speedometer.minivans.create!(minivan_id: 'a-van-red' ,name: 'a van', color: 'red') + + assert_equal 1, speedometer.minivans.to_a.size, "Only one association should be present:\n#{speedometer.minivans.to_a}" + assert_equal 1, speedometer.reload.minivans.to_a.size + end + + test "can unscope the default scope of the associated model" do + car = Car.create! + bulb1 = Bulb.create! name: "defaulty", car: car + bulb2 = Bulb.create! name: "other", car: car + + assert_equal [bulb1], car.bulbs + assert_equal [bulb1, bulb2], car.all_bulbs.sort_by(&:id) + end + + test "raises RecordNotDestroyed when replaced child can't be destroyed" do + car = Car.create! + original_child = FailedBulb.create!(car: car) + + assert_raise(ActiveRecord::RecordNotDestroyed) do + car.failed_bulbs = [FailedBulb.create!] + end + + assert_equal [original_child], car.reload.failed_bulbs + end + + test 'updates counter cache when default scope is given' do + topic = DefaultRejectedTopic.create approved: true + + assert_difference "topic.reload.replies_count", 1 do + topic.approved_replies.create! + end + end + + test 'dangerous association name raises ArgumentError' do + [:errors, 'errors', :save, 'save'].each do |name| + assert_raises(ArgumentError, "Association #{name} should not be allowed") do + Class.new(ActiveRecord::Base) do + has_many name + end + end + end + end + + test 'passes custom context validation to validate children' do + pirate = FamousPirate.new + pirate.famous_ships << ship = FamousShip.new + + assert pirate.valid? + assert_not pirate.valid?(:conference) + assert_equal "can't be blank", ship.errors[:name].first + end + + test 'association with instance dependent scope' do + bob = authors(:bob) + Post.create!(title: "signed post by bob", body: "stuff", author: authors(:bob)) + Post.create!(title: "anonymous post", body: "more stuff", author: authors(:bob)) + assert_equal ["misc post by bob", "other post by bob", + "signed post by bob"], bob.posts_with_signature.map(&:title).sort + + assert_equal [], authors(:david).posts_with_signature.map(&:title) + 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 new file mode 100644 index 0000000000..a85e020f0c --- /dev/null +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -0,0 +1,1169 @@ +require "cases/helper" +require 'models/post' +require 'models/person' +require 'models/reference' +require 'models/job' +require 'models/reader' +require 'models/comment' +require 'models/rating' +require 'models/tag' +require 'models/tagging' +require 'models/author' +require 'models/owner' +require 'models/pet' +require 'models/toy' +require 'models/contract' +require 'models/company' +require 'models/developer' +require 'models/subscriber' +require 'models/book' +require 'models/subscription' +require 'models/essay' +require 'models/category' +require 'models/categorization' +require 'models/member' +require 'models/membership' +require 'models/club' + +class HasManyThroughAssociationsTest < ActiveRecord::TestCase + fixtures :posts, :readers, :people, :comments, :authors, :categories, :taggings, :tags, + :owners, :pets, :toys, :jobs, :references, :companies, :members, :author_addresses, + :subscribers, :books, :subscriptions, :developers, :categorizations, :essays, + :categories_posts, :clubs, :memberships + + # Dummies to force column loads so query counts are clean. + def setup + Person.create :first_name => 'gummy' + Reader.create :person_id => 0, :post_id => 0 + end + + def test_preload_sti_rhs_class + developers = Developer.includes(:firms).all.to_a + assert_no_queries do + developers.each { |d| d.firms } + end + end + + def test_preload_sti_middle_relation + club = Club.create!(name: 'Aaron cool banana club') + member1 = Member.create!(name: 'Aaron') + member2 = Member.create!(name: 'Cat') + + SuperMembership.create! club: club, member: member1 + CurrentMembership.create! club: club, member: member2 + + club1 = Club.includes(:members).find_by_id club.id + assert_equal [member1, member2].sort_by(&:id), + club1.members.sort_by(&:id) + end + + def make_model(name) + Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } } + end + + def test_ordered_habtm + person_prime = Class.new(ActiveRecord::Base) do + def self.name; 'Person'; end + + has_many :readers + has_many :posts, -> { order('posts.id DESC') }, :through => :readers + end + posts = person_prime.includes(:posts).first.posts + + assert_operator posts.length, :>, 1 + posts.each_cons(2) do |left,right| + assert_operator left.id, :>, right.id + end + end + + def test_singleton_has_many_through + book = make_model "Book" + subscription = make_model "Subscription" + subscriber = make_model "Subscriber" + + subscriber.primary_key = 'nick' + subscription.belongs_to :book, class: book + subscription.belongs_to :subscriber, class: subscriber + + book.has_many :subscriptions, class: subscription + book.has_many :subscribers, through: :subscriptions, class: subscriber + + anonbook = book.first + namebook = Book.find anonbook.id + + assert_operator anonbook.subscribers.count, :>, 0 + anonbook.subscribers.each do |s| + assert_instance_of subscriber, s + end + assert_equal namebook.subscribers.map(&:id).sort, + anonbook.subscribers.map(&:id).sort + end + + def test_no_pk_join_table_append + lesson, _, student = make_no_pk_hm_t + + sicp = lesson.new(:name => "SICP") + ben = student.new(:name => "Ben Bitdiddle") + sicp.students << ben + assert sicp.save! + end + + def test_no_pk_join_table_delete + lesson, lesson_student, student = make_no_pk_hm_t + + sicp = lesson.new(:name => "SICP") + ben = student.new(:name => "Ben Bitdiddle") + louis = student.new(:name => "Louis Reasoner") + sicp.students << ben + sicp.students << louis + assert sicp.save! + + sicp.students.reload + assert_operator lesson_student.count, :>=, 2 + assert_no_difference('student.count') do + assert_difference('lesson_student.count', -2) do + sicp.students.destroy(*student.all.to_a) + end + end + end + + def test_no_pk_join_model_callbacks + lesson, lesson_student, student = make_no_pk_hm_t + + after_destroy_called = false + lesson_student.after_destroy do + after_destroy_called = true + end + + sicp = lesson.new(:name => "SICP") + ben = student.new(:name => "Ben Bitdiddle") + sicp.students << ben + assert sicp.save! + + sicp.students.reload + sicp.students.destroy(*student.all.to_a) + assert after_destroy_called, "after destroy should be called" + end + + def make_no_pk_hm_t + lesson = make_model 'Lesson' + student = make_model 'Student' + + lesson_student = make_model 'LessonStudent' + lesson_student.table_name = 'lessons_students' + + lesson_student.belongs_to :lesson, :class => lesson + lesson_student.belongs_to :student, :class => student + lesson.has_many :lesson_students, :class => lesson_student + lesson.has_many :students, :through => :lesson_students, :class => student + [lesson, lesson_student, student] + end + + def test_pk_is_not_required_for_join + post = Post.includes(:scategories).first + post2 = Post.includes(:categories).first + + assert_operator post.categories.length, :>, 0 + assert_equal post2.categories, post.categories + end + + def test_include? + person = Person.new + post = Post.new + person.posts << post + assert person.posts.include?(post) + end + + def test_associate_existing + post = posts(:thinking) + person = people(:david) + + assert_queries(1) do + post.people << person + end + + assert_queries(1) do + assert post.people.include?(person) + end + + assert post.reload.people(true).include?(person) + end + + def test_delete_all_for_with_dependent_option_destroy + person = people(:david) + assert_equal 1, person.jobs_with_dependent_destroy.count + + assert_no_difference 'Job.count' do + assert_difference 'Reference.count', -1 do + person.reload.jobs_with_dependent_destroy.delete_all + end + end + end + + def test_delete_all_for_with_dependent_option_nullify + person = people(:david) + assert_equal 1, person.jobs_with_dependent_nullify.count + + assert_no_difference 'Job.count' do + assert_no_difference 'Reference.count' do + person.reload.jobs_with_dependent_nullify.delete_all + end + end + end + + def test_delete_all_for_with_dependent_option_delete_all + person = people(:david) + assert_equal 1, person.jobs_with_dependent_delete_all.count + + assert_no_difference 'Job.count' do + assert_difference 'Reference.count', -1 do + person.reload.jobs_with_dependent_delete_all.delete_all + end + end + end + + def test_concat + person = people(:david) + post = posts(:thinking) + post.people.concat [person] + assert_equal 1, post.people.size + assert_equal 1, post.people(true).size + end + + def test_associate_existing_record_twice_should_add_to_target_twice + post = posts(:thinking) + person = people(:david) + + assert_difference 'post.people.to_a.count', 2 do + post.people << person + post.people << person + end + end + + def test_associate_existing_record_twice_should_add_records_twice + post = posts(:thinking) + person = people(:david) + + assert_difference 'post.people.count', 2 do + post.people << person + post.people << person + end + end + + def test_add_two_instance_and_then_deleting + post = posts(:thinking) + person = people(:david) + + post.people << person + post.people << person + + counts = ['post.people.count', 'post.people.to_a.count', 'post.readers.count', 'post.readers.to_a.count'] + assert_difference counts, -2 do + post.people.delete(person) + end + + assert !post.people.reload.include?(person) + end + + def test_associating_new + assert_queries(1) { posts(:thinking) } + new_person = nil # so block binding catches it + + assert_queries(0) do + new_person = Person.new :first_name => 'bob' + end + + # Associating new records always saves them + # Thus, 1 query for the new person record, 1 query for the new join table record + assert_queries(2) do + posts(:thinking).people << new_person + end + + assert_queries(1) do + assert posts(:thinking).people.include?(new_person) + end + + assert posts(:thinking).reload.people(true).include?(new_person) + end + + def test_associate_new_by_building + assert_queries(1) { posts(:thinking) } + + assert_queries(0) do + posts(:thinking).people.build(:first_name => "Bob") + posts(:thinking).people.new(:first_name => "Ted") + end + + # Should only need to load the association once + assert_queries(1) do + assert posts(:thinking).people.collect(&:first_name).include?("Bob") + assert posts(:thinking).people.collect(&:first_name).include?("Ted") + end + + # 2 queries for each new record (1 to save the record itself, 1 for the join model) + # * 2 new records = 4 + # + 1 query to save the actual post = 5 + assert_queries(5) do + posts(:thinking).body += '-changed' + posts(:thinking).save + end + + assert posts(:thinking).reload.people(true).collect(&:first_name).include?("Bob") + assert posts(:thinking).reload.people(true).collect(&:first_name).include?("Ted") + end + + def test_build_then_save_with_has_many_inverse + post = posts(:thinking) + person = post.people.build(:first_name => "Bob") + person.save + post.reload + + assert post.people.include?(person) + end + + def test_build_then_save_with_has_one_inverse + post = posts(:thinking) + person = post.single_people.build(:first_name => "Bob") + person.save + post.reload + + assert post.single_people.include?(person) + end + + def test_both_parent_ids_set_when_saving_new + post = Post.new(title: 'Hello', body: 'world') + person = Person.new(first_name: 'Sean') + + post.people = [person] + post.save + + assert post.id + assert person.id + assert_equal post.id, post.readers.first.post_id + assert_equal person.id, post.readers.first.person_id + end + + def test_delete_association + assert_queries(2){posts(:welcome);people(:michael); } + + assert_queries(1) do + posts(:welcome).people.delete(people(:michael)) + end + + assert_queries(1) do + assert posts(:welcome).people.empty? + end + + assert posts(:welcome).reload.people(true).empty? + end + + def test_destroy_association + assert_no_difference "Person.count" do + assert_difference "Reader.count", -1 do + posts(:welcome).people.destroy(people(:michael)) + end + end + + assert posts(:welcome).reload.people.empty? + assert posts(:welcome).people(true).empty? + end + + def test_destroy_all + assert_no_difference "Person.count" do + assert_difference "Reader.count", -1 do + posts(:welcome).people.destroy_all + end + end + + assert posts(:welcome).reload.people.empty? + assert posts(:welcome).people(true).empty? + end + + def test_should_raise_exception_for_destroying_mismatching_records + assert_no_difference ["Person.count", "Reader.count"] do + assert_raise(ActiveRecord::AssociationTypeMismatch) { posts(:welcome).people.destroy(posts(:thinking)) } + end + end + + def test_delete_through_belongs_to_with_dependent_nullify + Reference.make_comments = true + + person = people(:michael) + job = jobs(:magician) + reference = Reference.where(:job_id => job.id, :person_id => person.id).first + + assert_no_difference ['Job.count', 'Reference.count'] do + assert_difference 'person.jobs.count', -1 do + person.jobs_with_dependent_nullify.delete(job) + end + end + + assert_equal nil, reference.reload.job_id + ensure + Reference.make_comments = false + end + + def test_delete_through_belongs_to_with_dependent_delete_all + Reference.make_comments = true + + person = people(:michael) + job = jobs(:magician) + + # Make sure we're not deleting everything + assert person.jobs.count >= 2 + + assert_no_difference 'Job.count' do + assert_difference ['person.jobs.count', 'Reference.count'], -1 do + person.jobs_with_dependent_delete_all.delete(job) + end + end + + # Check that the destroy callback on Reference did not run + assert_equal nil, person.reload.comments + ensure + Reference.make_comments = false + end + + def test_delete_through_belongs_to_with_dependent_destroy + Reference.make_comments = true + + person = people(:michael) + job = jobs(:magician) + + # Make sure we're not deleting everything + assert person.jobs.count >= 2 + + assert_no_difference 'Job.count' do + assert_difference ['person.jobs.count', 'Reference.count'], -1 do + person.jobs_with_dependent_destroy.delete(job) + end + end + + # Check that the destroy callback on Reference ran + assert_equal "Reference destroyed", person.reload.comments + ensure + Reference.make_comments = false + end + + def test_belongs_to_with_dependent_destroy + person = PersonWithDependentDestroyJobs.find(1) + + # Create a reference which is not linked to a job. This should not be destroyed. + person.references.create! + + assert_no_difference 'Job.count' do + assert_difference 'Reference.count', -person.jobs.count do + person.destroy + end + end + end + + def test_belongs_to_with_dependent_delete_all + person = PersonWithDependentDeleteAllJobs.find(1) + + # Create a reference which is not linked to a job. This should not be destroyed. + person.references.create! + + assert_no_difference 'Job.count' do + assert_difference 'Reference.count', -person.jobs.count do + person.destroy + end + end + end + + def test_belongs_to_with_dependent_nullify + person = PersonWithDependentNullifyJobs.find(1) + + references = person.references.to_a + + assert_no_difference ['Reference.count', 'Job.count'] do + person.destroy + end + + references.each do |reference| + assert_equal nil, reference.reload.job_id + end + end + + def test_update_counter_caches_on_delete + post = posts(:welcome) + tag = post.tags.create!(:name => 'doomed') + + assert_difference ['post.reload.tags_count'], -1 do + posts(:welcome).tags.delete(tag) + end + end + + def test_update_counter_caches_on_delete_with_dependent_destroy + post = posts(:welcome) + tag = post.tags.create!(:name => 'doomed') + post.update_columns(tags_with_destroy_count: post.tags.count) + + assert_difference ['post.reload.tags_with_destroy_count'], -1 do + posts(:welcome).tags_with_destroy.delete(tag) + end + end + + def test_update_counter_caches_on_delete_with_dependent_nullify + post = posts(:welcome) + tag = post.tags.create!(:name => 'doomed') + post.update_columns(tags_with_nullify_count: post.tags.count) + + assert_no_difference 'post.reload.tags_count' do + assert_difference 'post.reload.tags_with_nullify_count', -1 do + posts(:welcome).tags_with_nullify.delete(tag) + end + end + end + + def test_update_counter_caches_on_replace_association + post = posts(:welcome) + tag = post.tags.create!(:name => 'doomed') + tag.tagged_posts << posts(:thinking) + + tag.tagged_posts = [] + post.reload + + assert_equal(post.taggings.count, post.tags_count) + end + + def test_update_counter_caches_on_destroy + post = posts(:welcome) + tag = post.tags.create!(name: 'doomed') + + assert_difference 'post.reload.tags_count', -1 do + tag.tagged_posts.destroy(post) + end + end + + def test_replace_association + assert_queries(4){posts(:welcome);people(:david);people(:michael); posts(:welcome).people(true)} + + # 1 query to delete the existing reader (michael) + # 1 query to associate the new reader (david) + assert_queries(2) do + posts(:welcome).people = [people(:david)] + end + + assert_queries(0){ + assert posts(:welcome).people.include?(people(:david)) + assert !posts(:welcome).people.include?(people(:michael)) + } + + assert posts(:welcome).reload.people(true).include?(people(:david)) + assert !posts(:welcome).reload.people(true).include?(people(:michael)) + end + + def test_replace_order_is_preserved + posts(:welcome).people.clear + posts(:welcome).people = [people(:david), people(:michael)] + assert_equal [people(:david).id, people(:michael).id], posts(:welcome).readers.order('id').map(&:person_id) + + # Test the inverse order in case the first success was a coincidence + posts(:welcome).people.clear + posts(:welcome).people = [people(:michael), people(:david)] + assert_equal [people(:michael).id, people(:david).id], posts(:welcome).readers.order('id').map(&:person_id) + end + + def test_replace_by_id_order_is_preserved + posts(:welcome).people.clear + posts(:welcome).person_ids = [people(:david).id, people(:michael).id] + assert_equal [people(:david).id, people(:michael).id], posts(:welcome).readers.order('id').map(&:person_id) + + # Test the inverse order in case the first success was a coincidence + posts(:welcome).people.clear + posts(:welcome).person_ids = [people(:michael).id, people(:david).id] + assert_equal [people(:michael).id, people(:david).id], posts(:welcome).readers.order('id').map(&:person_id) + end + + def test_associate_with_create + assert_queries(1) { posts(:thinking) } + + # 1 query for the new record, 1 for the join table record + # No need to update the actual collection yet! + assert_queries(2) do + posts(:thinking).people.create(:first_name=>"Jeb") + end + + # *Now* we actually need the collection so it's loaded + assert_queries(1) do + assert posts(:thinking).people.collect(&:first_name).include?("Jeb") + end + + assert posts(:thinking).reload.people(true).collect(&:first_name).include?("Jeb") + end + + def test_associate_with_create_and_no_options + peeps = posts(:thinking).people.count + posts(:thinking).people.create(:first_name => 'foo') + assert_equal peeps + 1, posts(:thinking).people.count + end + + def test_associate_with_create_with_through_having_conditions + impatient_people = posts(:thinking).impatient_people.count + posts(:thinking).impatient_people.create!(:first_name => 'foo') + assert_equal impatient_people + 1, posts(:thinking).impatient_people.count + end + + def test_associate_with_create_exclamation_and_no_options + peeps = posts(:thinking).people.count + posts(:thinking).people.create!(:first_name => 'foo') + assert_equal peeps + 1, posts(:thinking).people.count + end + + def test_create_on_new_record + p = Post.new + + assert_raises(ActiveRecord::RecordNotSaved) { p.people.create(:first_name => "mew") } + assert_raises(ActiveRecord::RecordNotSaved) { p.people.create!(:first_name => "snow") } + end + + def test_associate_with_create_and_invalid_options + firm = companies(:first_firm) + assert_no_difference('firm.developers.count') { assert_nothing_raised { firm.developers.create(:name => '0') } } + end + + def test_associate_with_create_and_valid_options + firm = companies(:first_firm) + assert_difference('firm.developers.count', 1) { firm.developers.create(:name => 'developer') } + end + + def test_associate_with_create_bang_and_invalid_options + firm = companies(:first_firm) + assert_no_difference('firm.developers.count') { assert_raises(ActiveRecord::RecordInvalid) { firm.developers.create!(:name => '0') } } + end + + def test_associate_with_create_bang_and_valid_options + firm = companies(:first_firm) + assert_difference('firm.developers.count', 1) { firm.developers.create!(:name => 'developer') } + end + + def test_push_with_invalid_record + firm = companies(:first_firm) + assert_raises(ActiveRecord::RecordInvalid) { firm.developers << Developer.new(:name => '0') } + end + + def test_push_with_invalid_join_record + repair_validations(Contract) do + Contract.validate {|r| r.errors[:base] << 'Invalid Contract' } + + firm = companies(:first_firm) + lifo = Developer.new(:name => 'lifo') + assert_raises(ActiveRecord::RecordInvalid) { firm.developers << lifo } + + lifo = Developer.create!(:name => 'lifo') + assert_raises(ActiveRecord::RecordInvalid) { firm.developers << lifo } + end + end + + def test_clear_associations + assert_queries(2) { posts(:welcome);posts(:welcome).people(true) } + + assert_queries(1) do + posts(:welcome).people.clear + end + + assert_queries(0) do + assert posts(:welcome).people.empty? + end + + assert posts(:welcome).reload.people(true).empty? + end + + def test_association_callback_ordering + Post.reset_log + log = Post.log + post = posts(:thinking) + + post.people_with_callbacks << people(:michael) + assert_equal [ + [:added, :before, "Michael"], + [:added, :after, "Michael"] + ], log.last(2) + + post.people_with_callbacks.push(people(:david), Person.create!(:first_name => "Bob"), Person.new(:first_name => "Lary")) + assert_equal [ + [:added, :before, "David"], + [:added, :after, "David"], + [:added, :before, "Bob"], + [:added, :after, "Bob"], + [:added, :before, "Lary"], + [:added, :after, "Lary"] + ],log.last(6) + + post.people_with_callbacks.build(:first_name => "Ted") + assert_equal [ + [:added, :before, "Ted"], + [:added, :after, "Ted"] + ], log.last(2) + + post.people_with_callbacks.create(:first_name => "Sam") + assert_equal [ + [:added, :before, "Sam"], + [:added, :after, "Sam"] + ], log.last(2) + + post.people_with_callbacks = [people(:michael),people(:david), Person.new(:first_name => "Julian"), Person.create!(:first_name => "Roger")] + assert_equal((%w(Ted Bob Sam Lary) * 2).sort, log[-12..-5].collect(&:last).sort) + assert_equal [ + [:added, :before, "Julian"], + [:added, :after, "Julian"], + [:added, :before, "Roger"], + [:added, :after, "Roger"] + ], log.last(4) + end + + def test_dynamic_find_should_respect_association_include + # SQL error in sort clause if :include is not included + # due to Unknown column 'comments.id' + assert Person.find(1).posts_with_comments_sorted_by_comment_id.find_by_title('Welcome to the weblog') + end + + def test_count_with_include_should_alias_join_table + assert_equal 2, people(:michael).posts.includes(:readers).count + end + + def test_inner_join_with_quoted_table_name + assert_equal 2, people(:michael).jobs.size + end + + def test_get_ids + assert_equal [posts(:welcome).id, posts(:authorless).id].sort, people(:michael).post_ids.sort + end + + def test_get_ids_for_has_many_through_with_conditions_should_not_preload + Tagging.create!(:taggable_type => 'Post', :taggable_id => posts(:welcome).id, :tag => tags(:misc)) + ActiveRecord::Associations::Preloader.expects(:new).never + posts(:welcome).misc_tag_ids + end + + def test_get_ids_for_loaded_associations + person = people(:michael) + person.posts(true) + assert_queries(0) do + person.post_ids + person.post_ids + end + end + + def test_get_ids_for_unloaded_associations_does_not_load_them + person = people(:michael) + assert !person.posts.loaded? + assert_equal [posts(:welcome).id, posts(:authorless).id].sort, person.post_ids.sort + assert !person.posts.loaded? + end + + def test_association_proxy_transaction_method_starts_transaction_in_association_class + Tag.expects(:transaction) + Post.first.tags.transaction do + # nothing + end + end + + def test_has_many_association_through_a_belongs_to_association_where_the_association_doesnt_exist + post = Post.create!(:title => "TITLE", :body => "BODY") + assert_equal [], post.author_favorites + end + + def test_has_many_association_through_a_belongs_to_association + author = authors(:mary) + post = Post.create!(:author => author, :title => "TITLE", :body => "BODY") + author.author_favorites.create(:favorite_author_id => 1) + author.author_favorites.create(:favorite_author_id => 2) + author.author_favorites.create(:favorite_author_id => 3) + assert_equal post.author.author_favorites, post.author_favorites + end + + def test_merge_join_association_with_has_many_through_association_proxy + author = authors(:mary) + assert_nothing_raised { author.comments.ratings.to_sql } + end + + def test_has_many_association_through_a_has_many_association_with_nonstandard_primary_keys + assert_equal 2, owners(:blackbeard).toys.count + end + + def test_find_on_has_many_association_collection_with_include_and_conditions + post_with_no_comments = people(:michael).posts_with_no_comments.first + assert_equal post_with_no_comments, posts(:authorless) + end + + def test_has_many_through_has_one_reflection + assert_equal [comments(:eager_sti_on_associations_vs_comment)], authors(:david).very_special_comments + end + + def test_modifying_has_many_through_has_one_reflection_should_raise + [ + lambda { authors(:david).very_special_comments = [VerySpecialComment.create!(:body => "Gorp!", :post_id => 1011), VerySpecialComment.create!(:body => "Eep!", :post_id => 1012)] }, + lambda { authors(:david).very_special_comments << VerySpecialComment.create!(:body => "Hoohah!", :post_id => 1013) }, + lambda { authors(:david).very_special_comments.delete(authors(:david).very_special_comments.first) }, + ].each {|block| assert_raise(ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection, &block) } + end + + def test_has_many_association_through_a_has_many_association_to_self + sarah = Person.create!(:first_name => 'Sarah', :primary_contact_id => people(:susan).id, :gender => 'F', :number1_fan_id => 1) + john = Person.create!(:first_name => 'John', :primary_contact_id => sarah.id, :gender => 'M', :number1_fan_id => 1) + assert_equal sarah.agents, [john] + assert_equal people(:susan).agents.flat_map(&:agents), people(:susan).agents_of_agents + end + + def test_associate_existing_with_nonstandard_primary_key_on_belongs_to + Categorization.create(:author => authors(:mary), :named_category_name => categories(:general).name) + assert_equal categories(:general), authors(:mary).named_categories.first + end + + def test_collection_build_with_nonstandard_primary_key_on_belongs_to + author = authors(:mary) + category = author.named_categories.build(:name => "Primary") + author.save + assert Categorization.exists?(:author_id => author.id, :named_category_name => category.name) + assert author.named_categories(true).include?(category) + end + + def test_collection_create_with_nonstandard_primary_key_on_belongs_to + author = authors(:mary) + category = author.named_categories.create(:name => "Primary") + assert Categorization.exists?(:author_id => author.id, :named_category_name => category.name) + assert author.named_categories(true).include?(category) + end + + def test_collection_exists + author = authors(:mary) + category = Category.create!(author_ids: [author.id], name: "Primary") + assert category.authors.exists?(id: author.id) + assert category.reload.authors.exists?(id: author.id) + end + + def test_collection_delete_with_nonstandard_primary_key_on_belongs_to + author = authors(:mary) + category = author.named_categories.create(:name => "Primary") + author.named_categories.delete(category) + assert !Categorization.exists?(:author_id => author.id, :named_category_name => category.name) + assert author.named_categories(true).empty? + end + + def test_collection_singular_ids_getter_with_string_primary_keys + book = books(:awdr) + assert_equal 2, book.subscriber_ids.size + assert_equal [subscribers(:first).nick, subscribers(:second).nick].sort, book.subscriber_ids.sort + end + + def test_collection_singular_ids_setter + company = companies(:rails_core) + dev = Developer.first + + company.developer_ids = [dev.id] + assert_equal [dev], company.developers + end + + def test_collection_singular_ids_setter_with_string_primary_keys + assert_nothing_raised do + book = books(:awdr) + book.subscriber_ids = [subscribers(:second).nick] + assert_equal [subscribers(:second)], book.subscribers(true) + + book.subscriber_ids = [] + assert_equal [], book.subscribers(true) + end + + end + + def test_collection_singular_ids_setter_raises_exception_when_invalid_ids_set + company = companies(:rails_core) + ids = [Developer.first.id, -9999] + assert_raises(ActiveRecord::RecordNotFound) {company.developer_ids= ids} + end + + def test_build_a_model_from_hm_through_association_with_where_clause + assert_nothing_raised { books(:awdr).subscribers.where(:nick => "marklazz").build } + end + + def test_attributes_are_being_set_when_initialized_from_hm_through_association_with_where_clause + new_subscriber = books(:awdr).subscribers.where(:nick => "marklazz").build + assert_equal new_subscriber.nick, "marklazz" + end + + def test_attributes_are_being_set_when_initialized_from_hm_through_association_with_multiple_where_clauses + new_subscriber = books(:awdr).subscribers.where(:nick => "marklazz").where(:name => 'Marcelo Giorgi').build + assert_equal new_subscriber.nick, "marklazz" + assert_equal new_subscriber.name, "Marcelo Giorgi" + end + + def test_include_method_in_association_through_should_return_true_for_instance_added_with_build + person = Person.new + reference = person.references.build + job = reference.build_job + assert person.jobs.include?(job) + end + + def test_include_method_in_association_through_should_return_true_for_instance_added_with_nested_builds + author = Author.new + post = author.posts.build + comment = post.comments.build + assert author.comments.include?(comment) + end + + def test_through_association_readonly_should_be_false + assert !people(:michael).posts.first.readonly? + assert !people(:michael).posts.to_a.first.readonly? + end + + def test_can_update_through_association + assert_nothing_raised do + people(:michael).posts.first.update!(title: "Can write") + end + end + + def test_has_many_through_polymorphic_with_primary_key_option + assert_equal [categories(:general)], authors(:david).essay_categories + + authors = Author.joins(:essay_categories).where('categories.id' => categories(:general).id) + assert_equal authors(:david), authors.first + + assert_equal [owners(:blackbeard)], authors(:david).essay_owners + + authors = Author.joins(:essay_owners).where("owners.name = 'blackbeard'") + assert_equal authors(:david), authors.first + end + + def test_has_many_through_with_primary_key_option + assert_equal [categories(:general)], authors(:david).essay_categories_2 + + authors = Author.joins(:essay_categories_2).where('categories.id' => categories(:general).id) + assert_equal authors(:david), authors.first + end + + def test_size_of_through_association_should_increase_correctly_when_has_many_association_is_added + post = posts(:thinking) + readers = post.readers.size + post.people << people(:michael) + assert_equal readers + 1, post.readers.size + end + + def test_has_many_through_with_default_scope_on_join_model + assert_equal posts(:welcome).comments.order('id').to_a, authors(:david).comments_on_first_posts + end + + def test_create_has_many_through_with_default_scope_on_join_model + category = authors(:david).special_categories.create(:name => "Foo") + assert_equal 1, category.categorizations.where(:special => true).count + end + + def test_joining_has_many_through_with_uniq + mary = Author.joins(:unique_categorized_posts).where(:id => authors(:mary).id).first + assert_equal 1, mary.unique_categorized_posts.length + assert_equal 1, mary.unique_categorized_post_ids.length + end + + def test_joining_has_many_through_belongs_to + posts = Post.joins(:author_categorizations).order('posts.id'). + where('categorizations.id' => categorizations(:mary_thinking_sti).id) + + assert_equal [posts(:eager_other), posts(:misc_by_mary), posts(:other_by_mary)], posts + end + + def test_select_chosen_fields_only + author = authors(:david) + assert_equal ['body', 'id'].sort, author.comments.select('comments.body').first.attributes.keys.sort + end + + def test_get_has_many_through_belongs_to_ids_with_conditions + assert_equal [categories(:general).id], authors(:mary).categories_like_general_ids + end + + def test_get_collection_singular_ids_on_has_many_through_with_conditions_and_include + person = Person.first + assert_equal person.posts_with_no_comment_ids, person.posts_with_no_comments.map(&:id) + end + + def test_count_has_many_through_with_named_scope + assert_equal 2, authors(:mary).categories.count + assert_equal 1, authors(:mary).categories.general.count + end + + def test_has_many_through_belongs_to_should_update_when_the_through_foreign_key_changes + post = posts(:eager_other) + + post.author_categorizations + proxy = post.send(:association_instance_get, :author_categorizations) + + assert !proxy.stale_target? + assert_equal authors(:mary).categorizations.sort_by(&:id), post.author_categorizations.sort_by(&:id) + + post.author_id = authors(:david).id + + assert proxy.stale_target? + assert_equal authors(:david).categorizations.sort_by(&:id), post.author_categorizations.sort_by(&:id) + end + + def test_create_with_conditions_hash_on_through_association + member = members(:groucho) + club = member.clubs.create! + + assert_equal true, club.reload.membership.favourite + end + + def test_deleting_from_has_many_through_a_belongs_to_should_not_try_to_update_counter + post = posts(:welcome) + address = author_addresses(:david_address) + + assert post.author_addresses.include?(address) + post.author_addresses.delete(address) + assert post[:author_count].nil? + end + + def test_primary_key_option_on_source + post = posts(:welcome) + category = categories(:general) + Categorization.create!(:post_id => post.id, :named_category_name => category.name) + + assert_equal [category], post.named_categories + assert_equal [category.name], post.named_category_ids # checks when target loaded + assert_equal [category.name], post.reload.named_category_ids # checks when target no loaded + end + + def test_create_should_not_raise_exception_when_join_record_has_errors + repair_validations(Categorization) do + Categorization.validate { |r| r.errors[:base] << 'Invalid Categorization' } + Category.create(:name => 'Fishing', :authors => [Author.first]) + end + end + + def test_save_should_not_raise_exception_when_join_record_has_errors + repair_validations(Categorization) do + Categorization.validate { |r| r.errors[:base] << 'Invalid Categorization' } + c = Category.create(:name => 'Fishing', :authors => [Author.first]) + c.save + end + end + + def test_assign_array_to_new_record_builds_join_records + c = Category.new(:name => 'Fishing', :authors => [Author.first]) + assert_equal 1, c.categorizations.size + end + + def test_create_bang_should_raise_exception_when_join_record_has_errors + repair_validations(Categorization) do + Categorization.validate { |r| r.errors[:base] << 'Invalid Categorization' } + assert_raises(ActiveRecord::RecordInvalid) do + Category.create!(:name => 'Fishing', :authors => [Author.first]) + end + end + end + + def test_save_bang_should_raise_exception_when_join_record_has_errors + repair_validations(Categorization) do + Categorization.validate { |r| r.errors[:base] << 'Invalid Categorization' } + c = Category.new(:name => 'Fishing', :authors => [Author.first]) + assert_raises(ActiveRecord::RecordInvalid) do + c.save! + end + end + end + + def test_create_bang_returns_falsy_when_join_record_has_errors + repair_validations(Categorization) do + Categorization.validate { |r| r.errors[:base] << 'Invalid Categorization' } + c = Category.new(:name => 'Fishing', :authors => [Author.first]) + assert !c.save + end + end + + def test_preloading_empty_through_association_via_joins + person = Person.create!(:first_name => "Gaga") + person = Person.where(:id => person.id).where('readers.id = 1 or 1=1').references(:readers).includes(:posts).to_a.first + + assert person.posts.loaded?, 'person.posts should be loaded' + assert_equal [], person.posts + end + + def test_explicitly_joining_join_table + assert_equal owners(:blackbeard).toys, owners(:blackbeard).toys.with_pet + end + + def test_has_many_through_with_polymorphic_source + post = tags(:general).tagged_posts.create! :title => "foo", :body => "bar" + assert_equal [tags(:general)], post.reload.tags + end + + def test_has_many_through_obeys_order_on_through_association + owner = owners(:blackbeard) + assert owner.toys.to_sql.include?("pets.name desc") + assert_equal ["parrot", "bulbul"], owner.toys.map { |r| r.pet.name } + end + + def test_has_many_through_associations_on_new_records_use_null_relations + person = Person.new + + assert_no_queries do + assert_equal [], person.posts + assert_equal [], person.posts.where(body: 'omg') + assert_equal [], person.posts.pluck(:body) + assert_equal 0, person.posts.sum(:tags_count) + assert_equal 0, person.posts.count + end + end + + def test_has_many_through_with_default_scope_on_the_target + person = people(:michael) + assert_equal [posts(:thinking)], person.first_posts + + readers(:michael_authorless).update(first_post_id: 1) + assert_equal [posts(:thinking)], person.reload.first_posts + end + + def test_has_many_through_with_includes_in_through_association_scope + assert_not_empty posts(:welcome).author_address_extra_with_address + end + + def test_insert_records_via_has_many_through_association_with_scope + club = Club.create! + member = Member.create! + Membership.create!(club: club, member: member) + + club.favourites << member + assert_equal [member], club.favourites + + club.reload + assert_equal [member], club.favourites + end + + def test_has_many_through_unscope_default_scope + post = Post.create!(:title => 'Beaches', :body => "I like beaches!") + Reader.create! :person => people(:david), :post => post + LazyReader.create! :person => people(:susan), :post => post + + assert_equal 2, post.people.to_a.size + assert_equal 1, post.lazy_people.to_a.size + + assert_equal 2, post.lazy_readers_unscope_skimmers.to_a.size + assert_equal 2, post.lazy_people_unscope_skimmers.to_a.size + end + + def test_has_many_through_add_with_sti_middle_relation + club = SuperClub.create!(name: 'Fight Club') + member = Member.create!(name: 'Tyler Durden') + + club.members << member + assert_equal 1, SuperMembership.where(member_id: member.id, club_id: club.id).count + end + + class ClubWithCallbacks < ActiveRecord::Base + self.table_name = 'clubs' + after_create :add_a_member + + has_many :memberships, inverse_of: :club, foreign_key: :club_id + has_many :members, through: :memberships + + def add_a_member + members << Member.last + end + end + + def test_has_many_with_callback_before_association + Member.create! + club = ClubWithCallbacks.create! + + assert_equal 1, club.reload.memberships.count + end +end diff --git a/activerecord/test/cases/associations/has_one_associations_test.rb b/activerecord/test/cases/associations/has_one_associations_test.rb new file mode 100644 index 0000000000..a4650ccdf2 --- /dev/null +++ b/activerecord/test/cases/associations/has_one_associations_test.rb @@ -0,0 +1,577 @@ +require "cases/helper" +require 'models/developer' +require 'models/project' +require 'models/company' +require 'models/ship' +require 'models/pirate' +require 'models/car' +require 'models/bulb' +require 'models/author' +require 'models/post' + +class HasOneAssociationsTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + fixtures :accounts, :companies, :developers, :projects, :developers_projects, :ships, :pirates + + def setup + Account.destroyed_account_ids.clear + end + + def test_has_one + assert_equal companies(:first_firm).account, Account.find(1) + assert_equal Account.find(1).credit_limit, companies(:first_firm).account.credit_limit + end + + def test_has_one_does_not_use_order_by + ActiveRecord::SQLCounter.clear_log + companies(:first_firm).account + ensure + assert ActiveRecord::SQLCounter.log_all.all? { |sql| /order by/i !~ sql }, 'ORDER BY was used in the query' + end + + def test_has_one_cache_nils + firm = companies(:another_firm) + assert_queries(1) { assert_nil firm.account } + assert_queries(0) { assert_nil firm.account } + + firms = Firm.all.merge!(:includes => :account).to_a + assert_queries(0) { firms.each(&:account) } + end + + def test_with_select + assert_equal Firm.find(1).account_with_select.attributes.size, 2 + assert_equal Firm.all.merge!(:includes => :account_with_select).find(1).account_with_select.attributes.size, 2 + end + + def test_finding_using_primary_key + firm = companies(:first_firm) + assert_equal Account.find_by_firm_id(firm.id), firm.account + firm.firm_id = companies(:rails_core).id + assert_equal accounts(:rails_core_account), firm.account_using_primary_key + end + + def test_update_with_foreign_and_primary_keys + firm = companies(:first_firm) + account = firm.account_using_foreign_and_primary_keys + assert_equal Account.find_by_firm_name(firm.name), account + firm.save + firm.reload + assert_equal account, firm.account_using_foreign_and_primary_keys + end + + def test_can_marshal_has_one_association_with_nil_target + firm = Firm.new + assert_nothing_raised do + assert_equal firm.attributes, Marshal.load(Marshal.dump(firm)).attributes + end + + firm.account + assert_nothing_raised do + assert_equal firm.attributes, Marshal.load(Marshal.dump(firm)).attributes + end + end + + def test_proxy_assignment + company = companies(:first_firm) + assert_nothing_raised { company.account = company.account } + end + + def test_type_mismatch + assert_raise(ActiveRecord::AssociationTypeMismatch) { companies(:first_firm).account = 1 } + assert_raise(ActiveRecord::AssociationTypeMismatch) { companies(:first_firm).account = Project.find(1) } + end + + def test_natural_assignment + apple = Firm.create("name" => "Apple") + citibank = Account.create("credit_limit" => 10) + apple.account = citibank + assert_equal apple.id, citibank.firm_id + end + + def test_natural_assignment_to_nil + old_account_id = companies(:first_firm).account.id + companies(:first_firm).account = nil + companies(:first_firm).save + assert_nil companies(:first_firm).account + # account is dependent, therefore is destroyed when reference to owner is lost + assert_raise(ActiveRecord::RecordNotFound) { Account.find(old_account_id) } + end + + def test_nullification_on_association_change + firm = companies(:rails_core) + old_account_id = firm.account.id + firm.account = Account.new(:credit_limit => 5) + # account is dependent with nullify, therefore its firm_id should be nil + assert_nil Account.find(old_account_id).firm_id + end + + def test_natural_assignment_to_nil_after_destroy + firm = companies(:rails_core) + old_account_id = firm.account.id + firm.account.destroy + firm.account = nil + assert_nil companies(:rails_core).account + assert_raise(ActiveRecord::RecordNotFound) { Account.find(old_account_id) } + end + + def test_association_change_calls_delete + companies(:first_firm).deletable_account = Account.new(:credit_limit => 5) + assert_equal [], Account.destroyed_account_ids[companies(:first_firm).id] + end + + def test_association_change_calls_destroy + companies(:first_firm).account = Account.new(:credit_limit => 5) + assert_equal [companies(:first_firm).id], Account.destroyed_account_ids[companies(:first_firm).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_dependence + num_accounts = Account.count + + firm = Firm.find(1) + assert_not_nil firm.account + account_id = firm.account.id + assert_equal [], Account.destroyed_account_ids[firm.id] + + firm.destroy + assert_equal num_accounts - 1, Account.count + assert_equal [account_id], Account.destroyed_account_ids[firm.id] + end + + def test_exclusive_dependence + num_accounts = Account.count + + firm = ExclusivelyDependentFirm.find(9) + assert_not_nil firm.account + assert_equal [], Account.destroyed_account_ids[firm.id] + + firm.destroy + assert_equal num_accounts - 1, Account.count + assert_equal [], Account.destroyed_account_ids[firm.id] + end + + def test_dependence_with_nil_associate + firm = DependentFirm.new(:name => 'nullify') + firm.save! + assert_nothing_raised { firm.destroy } + end + + def test_restrict_with_exception + firm = RestrictedWithExceptionFirm.create!(:name => 'restrict') + firm.create_account(:credit_limit => 10) + + assert_not_nil firm.account + + assert_raise(ActiveRecord::DeleteRestrictionError) { firm.destroy } + assert RestrictedWithExceptionFirm.exists?(:name => 'restrict') + assert firm.account.present? + end + + def test_restrict_with_error + firm = RestrictedWithErrorFirm.create!(:name => 'restrict') + firm.create_account(:credit_limit => 10) + + assert_not_nil firm.account + + firm.destroy + + assert !firm.errors.empty? + assert_equal "Cannot delete record because a dependent account exists", firm.errors[:base].first + assert RestrictedWithErrorFirm.exists?(:name => 'restrict') + assert firm.account.present? + end + + def test_successful_build_association + firm = Firm.new("name" => "GlobalMegaCorp") + firm.save + + account = firm.build_account("credit_limit" => 1000) + assert account.save + assert_equal account, firm.account + end + + def test_build_association_dont_create_transaction + assert_no_queries { + Firm.new.build_account + } + end + + def test_building_the_associated_object_with_implicit_sti_base_class + firm = DependentFirm.new + company = firm.build_company + assert_kind_of Company, company, "Expected #{company.class} to be a Company" + end + + def test_building_the_associated_object_with_explicit_sti_base_class + firm = DependentFirm.new + company = firm.build_company(:type => "Company") + assert_kind_of Company, company, "Expected #{company.class} to be a Company" + end + + def test_building_the_associated_object_with_sti_subclass + firm = DependentFirm.new + company = firm.build_company(:type => "Client") + assert_kind_of Client, company, "Expected #{company.class} to be a Client" + end + + def test_building_the_associated_object_with_an_invalid_type + firm = DependentFirm.new + assert_raise(ActiveRecord::SubclassNotFound) { firm.build_company(:type => "Invalid") } + end + + def test_building_the_associated_object_with_an_unrelated_type + firm = DependentFirm.new + assert_raise(ActiveRecord::SubclassNotFound) { firm.build_company(:type => "Account") } + end + + def test_build_and_create_should_not_happen_within_scope + pirate = pirates(:blackbeard) + scoped_count = pirate.association(:foo_bulb).scope.where_values.count + + bulb = pirate.build_foo_bulb + assert_not_equal scoped_count, bulb.scope_after_initialize.where_values.count + + bulb = pirate.create_foo_bulb + assert_not_equal scoped_count, bulb.scope_after_initialize.where_values.count + + bulb = pirate.create_foo_bulb! + assert_not_equal scoped_count, bulb.scope_after_initialize.where_values.count + end + + def test_create_association + firm = Firm.create(:name => "GlobalMegaCorp") + account = firm.create_account(:credit_limit => 1000) + assert_equal account, firm.reload.account + end + + def test_create_association_with_bang + firm = Firm.create(:name => "GlobalMegaCorp") + account = firm.create_account!(:credit_limit => 1000) + assert_equal account, firm.reload.account + end + + def test_create_association_with_bang_failing + firm = Firm.create(:name => "GlobalMegaCorp") + assert_raise ActiveRecord::RecordInvalid do + firm.create_account! + end + account = firm.account + assert_not_nil account + account.credit_limit = 5 + account.save + assert_equal account, firm.reload.account + end + + def test_build + firm = Firm.new("name" => "GlobalMegaCorp") + firm.save + + firm.account = account = Account.new("credit_limit" => 1000) + assert_equal account, firm.account + assert account.save + assert_equal account, firm.account + end + + def test_create + firm = Firm.new("name" => "GlobalMegaCorp") + firm.save + firm.account = account = Account.create("credit_limit" => 1000) + assert_equal account, firm.account + end + + def test_create_before_save + firm = Firm.new("name" => "GlobalMegaCorp") + firm.account = account = Account.create("credit_limit" => 1000) + assert_equal account, firm.account + end + + def test_dependence_with_missing_association + Account.destroy_all + firm = Firm.find(1) + assert_nil firm.account + firm.destroy + end + + def test_dependence_with_missing_association_and_nullify + Account.destroy_all + firm = DependentFirm.first + assert_nil firm.account + firm.destroy + end + + def test_finding_with_interpolated_condition + firm = Firm.first + superior = firm.clients.create(:name => 'SuperiorCo') + superior.rating = 10 + superior.save + assert_equal 10, firm.clients_with_interpolated_conditions.first.rating + end + + def test_assignment_before_child_saved + firm = Firm.find(1) + firm.account = a = Account.new("credit_limit" => 1000) + assert a.persisted? + assert_equal a, firm.account + assert_equal a, firm.account + assert_equal a, firm.account(true) + end + + def test_save_still_works_after_accessing_nil_has_one + jp = Company.new :name => 'Jaded Pixel' + jp.dummy_account.nil? + + assert_nothing_raised do + jp.save! + end + end + + def test_cant_save_readonly_association + assert_raise(ActiveRecord::ReadOnlyRecord) { companies(:first_firm).readonly_account.save! } + assert companies(:first_firm).readonly_account.readonly? + end + + def test_has_one_proxy_should_not_respond_to_private_methods + assert_raise(NoMethodError) { accounts(:signals37).private_method } + assert_raise(NoMethodError) { companies(:first_firm).account.private_method } + end + + def test_has_one_proxy_should_respond_to_private_methods_via_send + accounts(:signals37).send(:private_method) + companies(:first_firm).account.send(:private_method) + end + + def test_save_of_record_with_loaded_has_one + @firm = companies(:first_firm) + assert_not_nil @firm.account + + assert_nothing_raised do + Firm.find(@firm.id).save! + Firm.all.merge!(:includes => :account).find(@firm.id).save! + end + + @firm.account.destroy + + assert_nothing_raised do + Firm.find(@firm.id).save! + Firm.all.merge!(:includes => :account).find(@firm.id).save! + end + end + + def test_build_respects_hash_condition + account = companies(:first_firm).build_account_limit_500_with_hash_conditions + assert account.save + assert_equal 500, account.credit_limit + end + + def test_create_respects_hash_condition + account = companies(:first_firm).create_account_limit_500_with_hash_conditions + assert account.persisted? + assert_equal 500, account.credit_limit + end + + def test_attributes_are_being_set_when_initialized_from_has_one_association_with_where_clause + new_account = companies(:first_firm).build_account(:firm_name => 'Account') + assert_equal new_account.firm_name, "Account" + end + + def test_creation_failure_without_dependent_option + pirate = pirates(:blackbeard) + orig_ship = pirate.ship + + assert_equal ships(:black_pearl), orig_ship + new_ship = pirate.create_ship + assert_not_equal ships(:black_pearl), new_ship + assert_equal new_ship, pirate.ship + assert new_ship.new_record? + assert_nil orig_ship.pirate_id + assert !orig_ship.changed? # check it was saved + end + + def test_creation_failure_with_dependent_option + pirate = pirates(:blackbeard).becomes(DestructivePirate) + orig_ship = pirate.dependent_ship + + new_ship = pirate.create_dependent_ship + assert new_ship.new_record? + assert orig_ship.destroyed? + end + + def test_creation_failure_due_to_new_record_should_raise_error + pirate = pirates(:redbeard) + new_ship = Ship.new + + assert_raise(ActiveRecord::RecordNotSaved) do + pirate.ship = new_ship + end + assert_nil pirate.ship + assert_nil new_ship.pirate_id + end + + def test_replacement_failure_due_to_existing_record_should_raise_error + pirate = pirates(:blackbeard) + pirate.ship.name = nil + + assert !pirate.ship.valid? + assert_raise(ActiveRecord::RecordNotSaved) do + pirate.ship = ships(:interceptor) + end + assert_equal ships(:black_pearl), pirate.ship + assert_equal pirate.id, pirate.ship.pirate_id + end + + def test_replacement_failure_due_to_new_record_should_raise_error + pirate = pirates(:blackbeard) + new_ship = Ship.new + + assert_raise(ActiveRecord::RecordNotSaved) do + pirate.ship = new_ship + end + assert_equal ships(:black_pearl), pirate.ship + assert_equal pirate.id, pirate.ship.pirate_id + assert_equal pirate.id, ships(:black_pearl).reload.pirate_id + assert_nil new_ship.pirate_id + end + + def test_association_keys_bypass_attribute_protection + car = Car.create(:name => 'honda') + + bulb = car.build_bulb + assert_equal car.id, bulb.car_id + + bulb = car.build_bulb :car_id => car.id + 1 + assert_equal car.id, bulb.car_id + + bulb = car.create_bulb + assert_equal car.id, bulb.car_id + + bulb = car.create_bulb :car_id => car.id + 1 + assert_equal car.id, bulb.car_id + end + + def test_association_protect_foreign_key + pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?") + + ship = pirate.build_ship + assert_equal pirate.id, ship.pirate_id + + ship = pirate.build_ship :pirate_id => pirate.id + 1 + assert_equal pirate.id, ship.pirate_id + + ship = pirate.create_ship + assert_equal pirate.id, ship.pirate_id + + ship = pirate.create_ship :pirate_id => pirate.id + 1 + assert_equal pirate.id, ship.pirate_id + end + + def test_build_with_block + car = Car.create(:name => 'honda') + + bulb = car.build_bulb{ |b| b.color = 'Red' } + assert_equal 'RED!', bulb.color + end + + def test_create_with_block + car = Car.create(:name => 'honda') + + bulb = car.create_bulb{ |b| b.color = 'Red' } + assert_equal 'RED!', bulb.color + end + + def test_create_bang_with_block + car = Car.create(:name => 'honda') + + bulb = car.create_bulb!{ |b| b.color = 'Red' } + assert_equal 'RED!', bulb.color + end + + def test_association_attributes_are_available_to_after_initialize + car = Car.create(:name => 'honda') + bulb = car.create_bulb + + assert_equal car.id, bulb.attributes_after_initialize['car_id'] + end + + def test_has_one_transaction + company = companies(:first_firm) + account = Account.find(1) + + company.account # force loading + assert_no_queries { company.account = account } + + company.account = nil + assert_no_queries { company.account = nil } + account = Account.find(2) + assert_queries { company.account = account } + + assert_no_queries { Firm.new.account = account } + end + + def test_has_one_assignment_dont_trigger_save_on_change_of_same_object + pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?") + ship = pirate.build_ship(name: 'old name') + ship.save! + + ship.name = 'new name' + assert ship.changed? + assert_queries(1) do + # One query for updating name, not triggering query for updating pirate_id + pirate.ship = ship + end + + assert_equal 'new name', pirate.ship.reload.name + end + + def test_has_one_assignment_triggers_save_on_change_on_replacing_object + pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?") + ship = pirate.build_ship(name: 'old name') + ship.save! + + new_ship = Ship.create(name: 'new name') + assert_queries(2) do + # One query for updating name and second query for updating pirate_id + pirate.ship = new_ship + end + + assert_equal 'new name', pirate.ship.reload.name + end + + def test_has_one_autosave_with_primary_key_manually_set + post = Post.create(id: 1234, title: "Some title", body: 'Some content') + author = Author.new(id: 33, name: 'Hank Moody') + + author.post = post + author.save + author.reload + + assert_not_nil author.post + assert_equal author.post, post + end + + def test_has_one_relationship_cannot_have_a_counter_cache + assert_raise(ArgumentError) do + Class.new(ActiveRecord::Base) do + has_one :thing, counter_cache: true + end + end + end + + test 'dangerous association name raises ArgumentError' do + [:errors, 'errors', :save, 'save'].each do |name| + assert_raises(ArgumentError, "Association #{name} should not be allowed") do + Class.new(ActiveRecord::Base) do + has_one name + end + end + end + end +end diff --git a/activerecord/test/cases/associations/has_one_through_associations_test.rb b/activerecord/test/cases/associations/has_one_through_associations_test.rb new file mode 100644 index 0000000000..089cb0a3a2 --- /dev/null +++ b/activerecord/test/cases/associations/has_one_through_associations_test.rb @@ -0,0 +1,340 @@ +require "cases/helper" +require 'models/club' +require 'models/member_type' +require 'models/member' +require 'models/membership' +require 'models/sponsor' +require 'models/organization' +require 'models/member_detail' +require 'models/minivan' +require 'models/dashboard' +require 'models/speedometer' +require 'models/category' +require 'models/author' +require 'models/essay' +require 'models/owner' +require 'models/post' +require 'models/comment' + +class HasOneThroughAssociationsTest < ActiveRecord::TestCase + fixtures :member_types, :members, :clubs, :memberships, :sponsors, :organizations, :minivans, + :dashboards, :speedometers, :authors, :posts, :comments, :categories, :essays, :owners + + def setup + @member = members(:groucho) + end + + def test_has_one_through_with_has_one + assert_equal clubs(:boring_club), @member.club + end + + def test_creating_association_creates_through_record + new_member = Member.create(:name => "Chris") + new_member.club = Club.create(:name => "LRUG") + assert_not_nil new_member.current_membership + assert_not_nil new_member.club + end + + def test_creating_association_builds_through_record_for_new + new_member = Member.new(:name => "Jane") + new_member.club = clubs(:moustache_club) + assert new_member.current_membership + assert_equal clubs(:moustache_club), new_member.current_membership.club + assert_equal clubs(:moustache_club), new_member.club + assert new_member.save + assert_equal clubs(:moustache_club), new_member.club + end + + def test_creating_association_sets_both_parent_ids_for_new + member = Member.new(name: 'Sean Griffin') + club = Club.new(name: 'Da Club') + + member.club = club + + member.save! + + assert member.id + assert club.id + assert_equal member.id, member.current_membership.member_id + assert_equal club.id, member.current_membership.club_id + end + + def test_replace_target_record + new_club = Club.create(:name => "Marx Bros") + @member.club = new_club + @member.reload + assert_equal new_club, @member.club + end + + def test_replacing_target_record_deletes_old_association + assert_no_difference "Membership.count" do + new_club = Club.create(:name => "Bananarama") + @member.club = new_club + @member.reload + end + end + + def test_set_record_to_nil_should_delete_association + @member.club = nil + @member.reload + assert_equal nil, @member.current_membership + assert_nil @member.club + end + + def test_has_one_through_polymorphic + assert_equal clubs(:moustache_club), @member.sponsor_club + end + + def test_has_one_through_eager_loading + members = assert_queries(3) do #base table, through table, clubs table + Member.all.merge!(:includes => :club, :where => ["name = ?", "Groucho Marx"]).to_a + 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 = assert_queries(3) do #base table, through table, clubs table + Member.all.merge!(:includes => :sponsor_club, :where => ["name = ?", "Groucho Marx"]).to_a + end + assert_equal 1, members.size + assert_not_nil assert_no_queries {members[0].sponsor_club} + end + + def test_has_one_through_with_conditions_eager_loading + # conditions on the through table + assert_equal clubs(:moustache_club), Member.all.merge!(:includes => :favourite_club).find(@member.id).favourite_club + memberships(:membership_of_favourite_club).update_columns(favourite: false) + assert_equal nil, Member.all.merge!(:includes => :favourite_club).find(@member.id).reload.favourite_club + + # conditions on the source table + assert_equal clubs(:moustache_club), Member.all.merge!(:includes => :hairy_club).find(@member.id).hairy_club + clubs(:moustache_club).update_columns(name: "Association of Clean-Shaven Persons") + assert_equal nil, Member.all.merge!(:includes => :hairy_club).find(@member.id).reload.hairy_club + end + + def test_has_one_through_polymorphic_with_source_type + assert_equal members(:groucho), clubs(:moustache_club).sponsored_member + end + + def test_eager_has_one_through_polymorphic_with_source_type + clubs = Club.all.merge!(:includes => :sponsored_member, :where => ["name = ?","Moustache and Eyebrow Fancier Club"]).to_a + # Only the eyebrow fanciers club has a sponsored_member + assert_not_nil assert_no_queries {clubs[0].sponsored_member} + end + + def test_has_one_through_nonpreload_eagerloading + members = assert_queries(1) do + Member.all.merge!(:includes => :club, :where => ["members.name = ?", "Groucho Marx"], :order => 'clubs.name').to_a #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.all.merge!(:includes => :sponsor_club, :where => ["members.name = ?", "Groucho Marx"], :order => 'clubs.name').to_a #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.all.merge!(:includes => :sponsor_club, :where => ["members.name = ?", "Groucho Marx"], :order => 'clubs.name DESC').to_a #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 + + def test_uninitialized_has_one_through_should_return_nil_for_unsaved_record + assert_nil Member.new.club + end + + def test_assigning_association_correctly_assigns_target + new_member = Member.create(:name => "Chris") + new_member.club = new_club = Club.create(:name => "LRUG") + assert_equal new_club, new_member.association(:club).target + end + + def test_has_one_through_proxy_should_not_respond_to_private_methods + assert_raise(NoMethodError) { clubs(:moustache_club).private_method } + assert_raise(NoMethodError) { @member.club.private_method } + end + + def test_has_one_through_proxy_should_respond_to_private_methods_via_send + clubs(:moustache_club).send(:private_method) + @member.club.send(:private_method) + end + + def test_assigning_to_has_one_through_preserves_decorated_join_record + @organization = organizations(:nsa) + assert_difference 'MemberDetail.count', 1 do + @member_detail = MemberDetail.new(:extra_data => 'Extra') + @member.member_detail = @member_detail + @member.organization = @organization + end + assert_equal @organization, @member.organization + assert @organization.members.include?(@member) + assert_equal 'Extra', @member.member_detail.extra_data + end + + def test_reassigning_has_one_through + @organization = organizations(:nsa) + @new_organization = organizations(:discordians) + + assert_difference 'MemberDetail.count', 1 do + @member_detail = MemberDetail.new(:extra_data => 'Extra') + @member.member_detail = @member_detail + @member.organization = @organization + end + assert_equal @organization, @member.organization + assert_equal 'Extra', @member.member_detail.extra_data + assert @organization.members.include?(@member) + assert !@new_organization.members.include?(@member) + + assert_no_difference 'MemberDetail.count' do + @member.organization = @new_organization + end + assert_equal @new_organization, @member.organization + assert_equal 'Extra', @member.member_detail.extra_data + assert !@organization.members.include?(@member) + assert @new_organization.members.include?(@member) + end + + def test_preloading_has_one_through_on_belongs_to + MemberDetail.delete_all + assert_not_nil @member.member_type + @organization = organizations(:nsa) + @member_detail = MemberDetail.new + @member.member_detail = @member_detail + @member.organization = @organization + @member_details = assert_queries(3) do + MemberDetail.all.merge!(:includes => :member_type).to_a + end + @new_detail = @member_details[0] + assert @new_detail.send(:association, :member_type).loaded? + assert_no_queries { @new_detail.member_type } + end + + def test_save_of_record_with_loaded_has_one_through + @club = @member.club + assert_not_nil @club.sponsored_member + + assert_nothing_raised do + Club.find(@club.id).save! + Club.all.merge!(:includes => :sponsored_member).find(@club.id).save! + end + + @club.sponsor.destroy + + assert_nothing_raised do + Club.find(@club.id).save! + Club.all.merge!(:includes => :sponsored_member).find(@club.id).save! + end + end + + def test_through_belongs_to_after_destroy + @member_detail = MemberDetail.new(:extra_data => 'Extra') + @member.member_detail = @member_detail + @member.save! + + assert_not_nil @member_detail.member_type + @member_detail.destroy + assert_queries(1) do + assert_not_nil @member_detail.member_type(true) + end + + @member_detail.member.destroy + assert_queries(1) do + assert_nil @member_detail.member_type(true) + end + end + + def test_value_is_properly_quoted + minivan = Minivan.find('m1') + assert_nothing_raised do + minivan.dashboard + end + end + + def test_has_one_through_polymorphic_with_primary_key_option + assert_equal categories(:general), authors(:david).essay_category + + authors = Author.joins(:essay_category).where('categories.id' => categories(:general).id) + assert_equal authors(:david), authors.first + + assert_equal owners(:blackbeard), authors(:david).essay_owner + + authors = Author.joins(:essay_owner).where("owners.name = 'blackbeard'") + assert_equal authors(:david), authors.first + end + + def test_has_one_through_with_primary_key_option + assert_equal categories(:general), authors(:david).essay_category_2 + + authors = Author.joins(:essay_category_2).where('categories.id' => categories(:general).id) + assert_equal authors(:david), authors.first + end + + def test_has_one_through_with_default_scope_on_join_model + assert_equal posts(:welcome).comments.order('id').first, authors(:david).comment_on_first_post + end + + def test_has_one_through_many_raises_exception + assert_raise(ActiveRecord::HasOneThroughCantAssociateThroughCollection) do + members(:groucho).club_through_many + end + end + + def test_has_one_through_belongs_to_should_update_when_the_through_foreign_key_changes + minivan = minivans(:cool_first) + + minivan.dashboard + proxy = minivan.send(:association_instance_get, :dashboard) + + assert !proxy.stale_target? + assert_equal dashboards(:cool_first), minivan.dashboard + + minivan.speedometer_id = speedometers(:second).id + + assert proxy.stale_target? + assert_equal dashboards(:second), minivan.dashboard + end + + def test_has_one_through_belongs_to_setting_belongs_to_foreign_key_after_nil_target_loaded + minivan = Minivan.new + + minivan.dashboard + proxy = minivan.send(:association_instance_get, :dashboard) + + minivan.speedometer_id = speedometers(:second).id + + assert proxy.stale_target? + assert_equal dashboards(:second), minivan.dashboard + end + + def test_assigning_has_one_through_belongs_to_with_new_record_owner + minivan = Minivan.new + dashboard = dashboards(:cool_first) + + minivan.dashboard = dashboard + + assert_equal dashboard, minivan.dashboard + assert_equal dashboard, minivan.speedometer.dashboard + end + + def test_has_one_through_with_custom_select_on_join_model_default_scope + assert_equal clubs(:boring_club), members(:groucho).selected_club + end + + def test_has_one_through_relationship_cannot_have_a_counter_cache + assert_raise(ArgumentError) do + Class.new(ActiveRecord::Base) do + has_one :thing, through: :other_thing, counter_cache: true + end + end + end +end diff --git a/activerecord/test/cases/associations/inner_join_association_test.rb b/activerecord/test/cases/associations/inner_join_association_test.rb new file mode 100644 index 0000000000..07cf65a760 --- /dev/null +++ b/activerecord/test/cases/associations/inner_join_association_test.rb @@ -0,0 +1,139 @@ +require "cases/helper" +require 'models/post' +require 'models/comment' +require 'models/author' +require 'models/essay' +require 'models/category' +require 'models/categorization' +require 'models/person' +require 'models/tagging' +require 'models/tag' + +class InnerJoinAssociationTest < ActiveRecord::TestCase + fixtures :authors, :essays, :posts, :comments, :categories, :categories_posts, :categorizations, + :taggings, :tags + + def test_construct_finder_sql_applies_aliases_tables_on_association_conditions + result = Author.joins(:thinking_posts, :welcome_posts).to_a + assert_equal authors(:david), result.first + end + + def test_construct_finder_sql_does_not_table_name_collide_on_duplicate_associations + assert_nothing_raised do + sql = Person.joins(:agents => {:agents => :agents}).joins(:agents => {:agents => {:primary_contact => :agents}}).to_sql + assert_match(/agents_people_4/i, sql) + end + end + + def test_construct_finder_sql_ignores_empty_joins_hash + sql = Author.joins({}).to_sql + assert_no_match(/JOIN/i, sql) + end + + def test_construct_finder_sql_ignores_empty_joins_array + sql = Author.joins([]).to_sql + assert_no_match(/JOIN/i, sql) + end + + def test_join_conditions_added_to_join_clause + sql = Author.joins(:essays).to_sql + assert_match(/writer_type.*?=.*?Author/i, sql) + assert_no_match(/WHERE/i, sql) + end + + def test_join_association_conditions_support_string_and_arel_expressions + assert_equal 0, Author.joins(:welcome_posts_with_one_comment).count + assert_equal 1, Author.joins(:welcome_posts_with_comments).count + end + + def test_join_conditions_allow_nil_associations + authors = Author.includes(:essays).where(:essays => {:id => nil}) + assert_equal 2, authors.count + end + + def test_find_with_implicit_inner_joins_without_select_does_not_imply_readonly + authors = Author.joins(:posts) + assert_not authors.empty?, "expected authors to be non-empty" + assert authors.none? {|a| a.readonly? }, "expected no authors to be readonly" + end + + def test_find_with_implicit_inner_joins_honors_readonly_with_select + authors = Author.joins(:posts).select('authors.*').to_a + assert !authors.empty?, "expected authors to be non-empty" + assert authors.all? {|a| !a.readonly? }, "expected no authors to be readonly" + end + + def test_find_with_implicit_inner_joins_honors_readonly_false + authors = Author.joins(:posts).readonly(false).to_a + assert !authors.empty?, "expected authors to be non-empty" + assert authors.all? {|a| !a.readonly? }, "expected no authors to be readonly" + end + + def test_find_with_implicit_inner_joins_does_not_set_associations + authors = Author.joins(:posts).select('authors.*').to_a + assert !authors.empty?, "expected authors to be non-empty" + assert authors.all? { |a| !a.instance_variable_defined?(:@posts) }, "expected no authors to have the @posts association loaded" + end + + def test_count_honors_implicit_inner_joins + real_count = Author.all.to_a.sum{|a| a.posts.count } + assert_equal real_count, Author.joins(:posts).count, "plain inner join count should match the number of referenced posts records" + end + + def test_calculate_honors_implicit_inner_joins + real_count = Author.all.to_a.sum{|a| a.posts.count } + assert_equal real_count, Author.joins(:posts).calculate(:count, 'authors.id'), "plain inner join count should match the number of referenced posts records" + end + + def test_calculate_honors_implicit_inner_joins_and_distinct_and_conditions + real_count = Author.all.to_a.select {|a| a.posts.any? {|p| p.title =~ /^Welcome/} }.length + authors_with_welcoming_post_titles = Author.all.merge!(joins: :posts, where: "posts.title like 'Welcome%'").distinct.calculate(:count, 'authors.id') + assert_equal real_count, authors_with_welcoming_post_titles, "inner join and conditions should have only returned authors posting titles starting with 'Welcome'" + end + + def test_find_with_sti_join + scope = Post.joins(:special_comments).where(:id => posts(:sti_comments).id) + + # The join should match SpecialComment and its subclasses only + assert scope.where("comments.type" => "Comment").empty? + assert !scope.where("comments.type" => "SpecialComment").empty? + assert !scope.where("comments.type" => "SubSpecialComment").empty? + end + + def test_find_with_conditions_on_reflection + assert !posts(:welcome).comments.empty? + assert Post.joins(:nonexistant_comments).where(:id => posts(:welcome).id).empty? # [sic!] + end + + def test_find_with_conditions_on_through_reflection + assert !posts(:welcome).tags.empty? + assert Post.joins(:misc_tags).where(:id => posts(:welcome).id).empty? + end + + test "the default scope of the target is applied when joining associations" do + author = Author.create! name: "Jon" + author.categorizations.create! + author.categorizations.create! special: true + + assert_equal [author], Author.where(id: author).joins(:special_categorizations) + end + + test "the default scope of the target is correctly aliased when joining associations" do + author = Author.create! name: "Jon" + author.categories.create! name: 'Not Special' + author.special_categories.create! name: 'Special' + + categories = author.categories.includes(:special_categorizations).references(:special_categorizations).to_a + assert_equal 2, categories.size + end + + test "the correct records are loaded when including an aliased association" do + author = Author.create! name: "Jon" + author.categories.create! name: 'Not Special' + author.special_categories.create! name: 'Special' + + categories = author.categories.eager_load(:special_categorizations).order(:name).to_a + assert_equal 0, categories.first.special_categorizations.size + assert_equal 1, categories.second.special_categorizations.size + end +end diff --git a/activerecord/test/cases/associations/inverse_associations_test.rb b/activerecord/test/cases/associations/inverse_associations_test.rb new file mode 100644 index 0000000000..60df4e14dd --- /dev/null +++ b/activerecord/test/cases/associations/inverse_associations_test.rb @@ -0,0 +1,681 @@ +require "cases/helper" +require 'models/man' +require 'models/face' +require 'models/interest' +require 'models/zine' +require 'models/club' +require 'models/sponsor' +require 'models/rating' +require 'models/comment' +require 'models/car' +require 'models/bulb' +require 'models/mixed_case_monkey' + +class AutomaticInverseFindingTests < ActiveRecord::TestCase + fixtures :ratings, :comments, :cars + + def test_has_one_and_belongs_to_should_find_inverse_automatically_on_multiple_word_name + monkey_reflection = MixedCaseMonkey.reflect_on_association(:man) + man_reflection = Man.reflect_on_association(:mixed_case_monkey) + + assert_respond_to monkey_reflection, :has_inverse? + assert monkey_reflection.has_inverse?, "The monkey reflection should have an inverse" + assert_equal man_reflection, monkey_reflection.inverse_of, "The monkey reflection's inverse should be the man reflection" + + assert_respond_to man_reflection, :has_inverse? + assert man_reflection.has_inverse?, "The man reflection should have an inverse" + assert_equal monkey_reflection, man_reflection.inverse_of, "The man reflection's inverse should be the monkey reflection" + end + + def test_has_one_and_belongs_to_should_find_inverse_automatically + car_reflection = Car.reflect_on_association(:bulb) + bulb_reflection = Bulb.reflect_on_association(:car) + + assert_respond_to car_reflection, :has_inverse? + assert car_reflection.has_inverse?, "The Car reflection should have an inverse" + assert_equal bulb_reflection, car_reflection.inverse_of, "The Car reflection's inverse should be the Bulb reflection" + + assert_respond_to bulb_reflection, :has_inverse? + assert bulb_reflection.has_inverse?, "The Bulb reflection should have an inverse" + assert_equal car_reflection, bulb_reflection.inverse_of, "The Bulb reflection's inverse should be the Car reflection" + end + + def test_has_many_and_belongs_to_should_find_inverse_automatically + comment_reflection = Comment.reflect_on_association(:ratings) + rating_reflection = Rating.reflect_on_association(:comment) + + assert_respond_to comment_reflection, :has_inverse? + assert comment_reflection.has_inverse?, "The Comment reflection should have an inverse" + assert_equal rating_reflection, comment_reflection.inverse_of, "The Comment reflection's inverse should be the Rating reflection" + end + + def test_has_one_and_belongs_to_automatic_inverse_shares_objects + car = Car.first + bulb = Bulb.create!(car: car) + + assert_equal car.bulb, bulb, "The Car's bulb should be the original bulb" + + car.bulb.color = "Blue" + assert_equal car.bulb.color, bulb.color, "Changing the bulb's color on the car association should change the bulb's color" + + bulb.color = "Red" + assert_equal bulb.color, car.bulb.color, "Changing the bulb's color should change the bulb's color on the car association" + end + + def test_has_many_and_belongs_to_automatic_inverse_shares_objects_on_rating + comment = Comment.first + rating = Rating.create!(comment: comment) + + assert_equal rating.comment, comment, "The Rating's comment should be the original Comment" + + rating.comment.body = "Brogramming is the act of programming, like a bro." + assert_equal rating.comment.body, comment.body, "Changing the Comment's body on the association should change the original Comment's body" + + comment.body = "Broseiden is the king of the sea of bros." + assert_equal comment.body, rating.comment.body, "Changing the original Comment's body should change the Comment's body on the association" + end + + def test_has_many_and_belongs_to_automatic_inverse_shares_objects_on_comment + rating = Rating.create! + comment = Comment.first + rating.comment = comment + + assert_equal rating.comment, comment, "The Rating's comment should be the original Comment" + + rating.comment.body = "Brogramming is the act of programming, like a bro." + assert_equal rating.comment.body, comment.body, "Changing the Comment's body on the association should change the original Comment's body" + + comment.body = "Broseiden is the king of the sea of bros." + assert_equal comment.body, rating.comment.body, "Changing the original Comment's body should change the Comment's body on the association" + end + + def test_polymorphic_and_has_many_through_relationships_should_not_have_inverses + sponsor_reflection = Sponsor.reflect_on_association(:sponsorable) + + assert_respond_to sponsor_reflection, :has_inverse? + assert !sponsor_reflection.has_inverse?, "A polymorphic association should not find an inverse automatically" + + club_reflection = Club.reflect_on_association(:members) + + assert_respond_to club_reflection, :has_inverse? + assert !club_reflection.has_inverse?, "A has_many_through association should not find an inverse automatically" + end + + def test_polymorphic_relationships_should_still_not_have_inverses_when_non_polymorphic_relationship_has_the_same_name + man_reflection = Man.reflect_on_association(:polymorphic_face_without_inverse) + face_reflection = Face.reflect_on_association(:man) + + assert_respond_to face_reflection, :has_inverse? + assert face_reflection.has_inverse?, "For this test, the non-polymorphic association must have an inverse" + + assert_respond_to man_reflection, :has_inverse? + assert !man_reflection.has_inverse?, "The target of a polymorphic association should not find an inverse automatically" + end +end + +class InverseAssociationTests < ActiveRecord::TestCase + def test_should_allow_for_inverse_of_options_in_associations + assert_nothing_raised(ArgumentError, 'ActiveRecord should allow the inverse_of options on has_many') do + Class.new(ActiveRecord::Base).has_many(:wheels, :inverse_of => :car) + end + + assert_nothing_raised(ArgumentError, 'ActiveRecord should allow the inverse_of options on has_one') do + Class.new(ActiveRecord::Base).has_one(:engine, :inverse_of => :car) + end + + assert_nothing_raised(ArgumentError, 'ActiveRecord should allow the inverse_of options on belongs_to') do + Class.new(ActiveRecord::Base).belongs_to(:car, :inverse_of => :driver) + end + end + + def test_should_be_able_to_ask_a_reflection_if_it_has_an_inverse + has_one_with_inverse_ref = Man.reflect_on_association(:face) + assert_respond_to has_one_with_inverse_ref, :has_inverse? + assert has_one_with_inverse_ref.has_inverse? + + has_many_with_inverse_ref = Man.reflect_on_association(:interests) + assert_respond_to has_many_with_inverse_ref, :has_inverse? + assert has_many_with_inverse_ref.has_inverse? + + belongs_to_with_inverse_ref = Face.reflect_on_association(:man) + assert_respond_to belongs_to_with_inverse_ref, :has_inverse? + assert belongs_to_with_inverse_ref.has_inverse? + + has_one_without_inverse_ref = Club.reflect_on_association(:sponsor) + assert_respond_to has_one_without_inverse_ref, :has_inverse? + assert !has_one_without_inverse_ref.has_inverse? + + has_many_without_inverse_ref = Club.reflect_on_association(:memberships) + assert_respond_to has_many_without_inverse_ref, :has_inverse? + assert !has_many_without_inverse_ref.has_inverse? + + belongs_to_without_inverse_ref = Sponsor.reflect_on_association(:sponsor_club) + assert_respond_to belongs_to_without_inverse_ref, :has_inverse? + assert !belongs_to_without_inverse_ref.has_inverse? + end + + def test_should_be_able_to_ask_a_reflection_what_it_is_the_inverse_of + has_one_ref = Man.reflect_on_association(:face) + assert_respond_to has_one_ref, :inverse_of + + has_many_ref = Man.reflect_on_association(:interests) + assert_respond_to has_many_ref, :inverse_of + + belongs_to_ref = Face.reflect_on_association(:man) + assert_respond_to belongs_to_ref, :inverse_of + end + + def test_inverse_of_method_should_supply_the_actual_reflection_instance_it_is_the_inverse_of + has_one_ref = Man.reflect_on_association(:face) + assert_equal Face.reflect_on_association(:man), has_one_ref.inverse_of + + has_many_ref = Man.reflect_on_association(:interests) + assert_equal Interest.reflect_on_association(:man), has_many_ref.inverse_of + + belongs_to_ref = Face.reflect_on_association(:man) + assert_equal Man.reflect_on_association(:face), belongs_to_ref.inverse_of + end + + def test_associations_with_no_inverse_of_should_return_nil + has_one_ref = Club.reflect_on_association(:sponsor) + assert_nil has_one_ref.inverse_of + + has_many_ref = Club.reflect_on_association(:memberships) + assert_nil has_many_ref.inverse_of + + belongs_to_ref = Sponsor.reflect_on_association(:sponsor_club) + assert_nil belongs_to_ref.inverse_of + end +end + +class InverseHasOneTests < ActiveRecord::TestCase + fixtures :men, :faces + + def test_parent_instance_should_be_shared_with_child_on_find + m = men(:gordon) + f = m.face + assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance" + f.man.name = 'Mungo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to child-owned instance" + end + + + def test_parent_instance_should_be_shared_with_eager_loaded_child_on_find + m = Man.all.merge!(:where => {:name => 'Gordon'}, :includes => :face).first + f = m.face + assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance" + f.man.name = 'Mungo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to child-owned instance" + + m = Man.all.merge!(:where => {:name => 'Gordon'}, :includes => :face, :order => 'faces.id').first + f = m.face + assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance" + f.man.name = 'Mungo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to child-owned instance" + end + + def test_parent_instance_should_be_shared_with_newly_built_child + m = Man.first + f = m.build_face(:description => 'haunted') + assert_not_nil f.man + assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance" + f.man.name = 'Mungo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to just-built-child-owned instance" + end + + def test_parent_instance_should_be_shared_with_newly_created_child + m = Man.first + f = m.create_face(:description => 'haunted') + assert_not_nil f.man + assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance" + f.man.name = 'Mungo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to newly-created-child-owned instance" + end + + def test_parent_instance_should_be_shared_with_newly_created_child_via_bang_method + m = Man.first + f = m.create_face!(:description => 'haunted') + assert_not_nil f.man + assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance" + f.man.name = 'Mungo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to newly-created-child-owned instance" + end + + def test_parent_instance_should_be_shared_with_replaced_via_accessor_child + m = Man.first + f = Face.new(:description => 'haunted') + m.face = f + assert_not_nil f.man + assert_equal m.name, f.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to parent instance" + f.man.name = 'Mungo' + assert_equal m.name, f.man.name, "Name of man should be the same after changes to replaced-child-owned instance" + end + + def test_trying_to_use_inverses_that_dont_exist_should_raise_an_error + assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Man.first.dirty_face } + end +end + +class InverseHasManyTests < ActiveRecord::TestCase + fixtures :men, :interests + + def test_parent_instance_should_be_shared_with_every_child_on_find + m = men(:gordon) + is = m.interests + is.each do |i| + assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance" + i.man.name = 'Mungo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to child-owned instance" + end + end + + def test_parent_instance_should_be_shared_with_eager_loaded_children + m = Man.all.merge!(:where => {:name => 'Gordon'}, :includes => :interests).first + is = m.interests + is.each do |i| + assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance" + i.man.name = 'Mungo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to child-owned instance" + end + + m = Man.all.merge!(:where => {:name => 'Gordon'}, :includes => :interests, :order => 'interests.id').first + is = m.interests + is.each do |i| + assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance" + i.man.name = 'Mungo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to child-owned instance" + end + end + + def test_parent_instance_should_be_shared_with_newly_block_style_built_child + m = Man.first + i = m.interests.build {|ii| ii.topic = 'Industrial Revolution Re-enactment'} + assert_not_nil i.topic, "Child attributes supplied to build via blocks should be populated" + assert_not_nil i.man + assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance" + i.man.name = 'Mungo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to just-built-child-owned instance" + end + + def test_parent_instance_should_be_shared_with_newly_created_via_bang_method_child + m = Man.first + i = m.interests.create!(:topic => 'Industrial Revolution Re-enactment') + assert_not_nil i.man + assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance" + i.man.name = 'Mungo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to newly-created-child-owned instance" + end + + def test_parent_instance_should_be_shared_with_newly_block_style_created_child + m = Man.first + i = m.interests.create {|ii| ii.topic = 'Industrial Revolution Re-enactment'} + assert_not_nil i.topic, "Child attributes supplied to create via blocks should be populated" + assert_not_nil i.man + assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance" + i.man.name = 'Mungo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to newly-created-child-owned instance" + end + + def test_parent_instance_should_be_shared_within_create_block_of_new_child + man = Man.first + interest = man.interests.create do |i| + assert i.man.equal?(man), "Man of child should be the same instance as a parent" + end + assert interest.man.equal?(man), "Man of the child should still be the same instance as a parent" + end + + def test_parent_instance_should_be_shared_within_build_block_of_new_child + man = Man.first + interest = man.interests.build do |i| + assert i.man.equal?(man), "Man of child should be the same instance as a parent" + end + assert interest.man.equal?(man), "Man of the child should still be the same instance as a parent" + end + + def test_parent_instance_should_be_shared_with_poked_in_child + m = men(:gordon) + i = Interest.create(:topic => 'Industrial Revolution Re-enactment') + m.interests << i + assert_not_nil i.man + assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance" + i.man.name = 'Mungo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to newly-created-child-owned instance" + end + + def test_parent_instance_should_be_shared_with_replaced_via_accessor_children + m = Man.first + i = Interest.new(:topic => 'Industrial Revolution Re-enactment') + m.interests = [i] + assert_not_nil i.man + assert_equal m.name, i.man.name, "Name of man should be the same before changes to parent instance" + m.name = 'Bongo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to parent instance" + i.man.name = 'Mungo' + assert_equal m.name, i.man.name, "Name of man should be the same after changes to replaced-child-owned instance" + end + + def test_parent_instance_should_be_shared_with_first_and_last_child + man = Man.first + + assert man.interests.first.man.equal? man + assert man.interests.last.man.equal? man + end + + def test_parent_instance_should_be_shared_with_first_n_and_last_n_children + man = Man.first + + interests = man.interests.first(2) + assert interests[0].man.equal? man + assert interests[1].man.equal? man + + interests = man.interests.last(2) + assert interests[0].man.equal? man + assert interests[1].man.equal? man + end + + def test_parent_instance_should_find_child_instance_using_child_instance_id + man = Man.create! + interest = Interest.create! + man.interests = [interest] + + assert interest.equal?(man.interests.first), "The inverse association should use the interest already created and held in memory" + assert interest.equal?(man.interests.find(interest.id)), "The inverse association should use the interest already created and held in memory" + assert man.equal?(man.interests.first.man), "Two inversion should lead back to the same object that was originally held" + assert man.equal?(man.interests.find(interest.id).man), "Two inversions should lead back to the same object that was originally held" + end + + def test_parent_instance_should_find_child_instance_using_child_instance_id_when_created + man = Man.create! + interest = Interest.create!(man: man) + + assert man.equal?(man.interests.first.man), "Two inverses should lead back to the same object that was originally held" + assert man.equal?(man.interests.find(interest.id).man), "Two inversions should lead back to the same object that was originally held" + + assert_equal man.name, man.interests.find(interest.id).man.name, "The name of the man should match before the name is changed" + man.name = "Ben Bitdiddle" + assert_equal man.name, man.interests.find(interest.id).man.name, "The name of the man should match after the parent name is changed" + man.interests.find(interest.id).man.name = "Alyssa P. Hacker" + assert_equal man.name, man.interests.find(interest.id).man.name, "The name of the man should match after the child name is changed" + end + + def test_find_on_child_instance_with_id_should_not_load_all_child_records + man = Man.create! + interest = Interest.create!(man: man) + + man.interests.find(interest.id) + assert_not man.interests.loaded? + end + + def test_raise_record_not_found_error_when_invalid_ids_are_passed + # delete all interest records to ensure that hard coded invalid_id(s) + # are indeed invalid. + Interest.delete_all + + man = Man.create! + + invalid_id = 245324523 + assert_raise(ActiveRecord::RecordNotFound) { man.interests.find(invalid_id) } + + invalid_ids = [8432342, 2390102913, 2453245234523452] + assert_raise(ActiveRecord::RecordNotFound) { man.interests.find(invalid_ids) } + end + + def test_raise_record_not_found_error_when_no_ids_are_passed + man = Man.create! + + assert_raise(ActiveRecord::RecordNotFound) { man.interests.find() } + end + + def test_trying_to_use_inverses_that_dont_exist_should_raise_an_error + assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Man.first.secret_interests } + end + + def test_child_instance_should_point_to_parent_without_saving + man = Man.new + i = Interest.create(:topic => 'Industrial Revolution Re-enactment') + + man.interests << i + assert_not_nil i.man + + i.man.name = "Charles" + assert_equal i.man.name, man.name + + assert !man.persisted? + end +end + +class InverseBelongsToTests < ActiveRecord::TestCase + fixtures :men, :faces, :interests + + def test_child_instance_should_be_shared_with_parent_on_find + f = faces(:trusting) + m = f.man + assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance" + f.description = 'gormless' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance" + m.face.description = 'pleasing' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to parent-owned instance" + end + + def test_eager_loaded_child_instance_should_be_shared_with_parent_on_find + f = Face.all.merge!(:includes => :man, :where => {:description => 'trusting'}).first + m = f.man + assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance" + f.description = 'gormless' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance" + m.face.description = 'pleasing' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to parent-owned instance" + + f = Face.all.merge!(:includes => :man, :order => 'men.id', :where => {:description => 'trusting'}).first + m = f.man + assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance" + f.description = 'gormless' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance" + m.face.description = 'pleasing' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to parent-owned instance" + end + + def test_child_instance_should_be_shared_with_newly_built_parent + f = faces(:trusting) + m = f.build_man(:name => 'Charles') + assert_not_nil m.face + assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance" + f.description = 'gormless' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance" + m.face.description = 'pleasing' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to just-built-parent-owned instance" + end + + def test_child_instance_should_be_shared_with_newly_created_parent + f = faces(:trusting) + m = f.create_man(:name => 'Charles') + assert_not_nil m.face + assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance" + f.description = 'gormless' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance" + m.face.description = 'pleasing' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to newly-created-parent-owned instance" + end + + def test_should_not_try_to_set_inverse_instances_when_the_inverse_is_a_has_many + i = interests(:trainspotting) + m = i.man + assert_not_nil m.interests + iz = m.interests.detect { |_iz| _iz.id == i.id} + assert_not_nil iz + assert_equal i.topic, iz.topic, "Interest topics should be the same before changes to child" + i.topic = 'Eating cheese with a spoon' + assert_not_equal i.topic, iz.topic, "Interest topics should not be the same after changes to child" + iz.topic = 'Cow tipping' + assert_not_equal i.topic, iz.topic, "Interest topics should not be the same after changes to parent-owned instance" + end + + def test_child_instance_should_be_shared_with_replaced_via_accessor_parent + f = Face.first + m = Man.new(:name => 'Charles') + f.man = m + assert_not_nil m.face + assert_equal f.description, m.face.description, "Description of face should be the same before changes to child instance" + f.description = 'gormless' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to child instance" + m.face.description = 'pleasing' + assert_equal f.description, m.face.description, "Description of face should be the same after changes to replaced-parent-owned instance" + end + + def test_trying_to_use_inverses_that_dont_exist_should_raise_an_error + assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.horrible_man } + end +end + +class InversePolymorphicBelongsToTests < ActiveRecord::TestCase + fixtures :men, :faces, :interests + + def test_child_instance_should_be_shared_with_parent_on_find + f = Face.all.merge!(:where => {:description => 'confused'}).first + m = f.polymorphic_man + assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same before changes to child instance" + f.description = 'gormless' + assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to child instance" + m.polymorphic_face.description = 'pleasing' + assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to parent-owned instance" + end + + def test_eager_loaded_child_instance_should_be_shared_with_parent_on_find + f = Face.all.merge!(:where => {:description => 'confused'}, :includes => :man).first + m = f.polymorphic_man + assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same before changes to child instance" + f.description = 'gormless' + assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to child instance" + m.polymorphic_face.description = 'pleasing' + assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to parent-owned instance" + + f = Face.all.merge!(:where => {:description => 'confused'}, :includes => :man, :order => 'men.id').first + m = f.polymorphic_man + assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same before changes to child instance" + f.description = 'gormless' + assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to child instance" + m.polymorphic_face.description = 'pleasing' + assert_equal f.description, m.polymorphic_face.description, "Description of face should be the same after changes to parent-owned instance" + end + + def test_child_instance_should_be_shared_with_replaced_via_accessor_parent + face = faces(:confused) + new_man = Man.new + + assert_not_nil face.polymorphic_man + face.polymorphic_man = new_man + + assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same before changes to parent instance" + face.description = 'Bongo' + assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same after changes to parent instance" + new_man.polymorphic_face.description = 'Mungo' + assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same after changes to replaced-parent-owned instance" + end + + def test_child_instance_should_be_shared_with_replaced_via_method_parent + face = faces(:confused) + new_man = Man.new + + assert_not_nil face.polymorphic_man + face.polymorphic_man = new_man + + assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same before changes to parent instance" + face.description = 'Bongo' + assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same after changes to parent instance" + new_man.polymorphic_face.description = 'Mungo' + assert_equal face.description, new_man.polymorphic_face.description, "Description of face should be the same after changes to replaced-parent-owned instance" + end + + def test_inversed_instance_should_not_be_reloaded_after_stale_state_changed + new_man = Man.new + face = Face.new + new_man.face = face + + old_inversed_man = face.man + new_man.save! + new_inversed_man = face.man + + assert_equal old_inversed_man.object_id, new_inversed_man.object_id + end + + def test_should_not_try_to_set_inverse_instances_when_the_inverse_is_a_has_many + i = interests(:llama_wrangling) + m = i.polymorphic_man + assert_not_nil m.polymorphic_interests + iz = m.polymorphic_interests.detect { |_iz| _iz.id == i.id} + assert_not_nil iz + assert_equal i.topic, iz.topic, "Interest topics should be the same before changes to child" + i.topic = 'Eating cheese with a spoon' + assert_not_equal i.topic, iz.topic, "Interest topics should not be the same after changes to child" + iz.topic = 'Cow tipping' + assert_not_equal i.topic, iz.topic, "Interest topics should not be the same after changes to parent-owned instance" + end + + def test_trying_to_access_inverses_that_dont_exist_shouldnt_raise_an_error + # Ideally this would, if only for symmetry's sake with other association types + assert_nothing_raised(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.horrible_polymorphic_man } + end + + def test_trying_to_set_polymorphic_inverses_that_dont_exist_at_all_should_raise_an_error + # fails because no class has the correct inverse_of for horrible_polymorphic_man + assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.horrible_polymorphic_man = Man.first } + end + + def test_trying_to_set_polymorphic_inverses_that_dont_exist_on_the_instance_being_set_should_raise_an_error + # passes because Man does have the correct inverse_of + assert_nothing_raised(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.polymorphic_man = Man.first } + # fails because Interest does have the correct inverse_of + assert_raise(ActiveRecord::InverseOfAssociationNotFoundError) { Face.first.polymorphic_man = Interest.first } + end +end + +# NOTE - these tests might not be meaningful, ripped as they were from the parental_control plugin +# which would guess the inverse rather than look for an explicit configuration option. +class InverseMultipleHasManyInversesForSameModel < ActiveRecord::TestCase + fixtures :men, :interests, :zines + + def test_that_we_can_load_associations_that_have_the_same_reciprocal_name_from_different_models + assert_nothing_raised(ActiveRecord::AssociationTypeMismatch) do + i = Interest.first + i.zine + i.man + end + end + + def test_that_we_can_create_associations_that_have_the_same_reciprocal_name_from_different_models + assert_nothing_raised(ActiveRecord::AssociationTypeMismatch) do + i = Interest.first + i.build_zine(:title => 'Get Some in Winter! 2008') + i.build_man(:name => 'Gordon') + i.save! + end + end +end diff --git a/activerecord/test/cases/associations/join_model_test.rb b/activerecord/test/cases/associations/join_model_test.rb new file mode 100644 index 0000000000..cace7ba142 --- /dev/null +++ b/activerecord/test/cases/associations/join_model_test.rb @@ -0,0 +1,751 @@ +require "cases/helper" +require 'models/tag' +require 'models/tagging' +require 'models/post' +require 'models/rating' +require 'models/item' +require 'models/comment' +require 'models/author' +require 'models/category' +require 'models/categorization' +require 'models/vertex' +require 'models/edge' +require 'models/book' +require 'models/citation' +require 'models/aircraft' +require 'models/engine' +require 'models/car' + +class AssociationsJoinModelTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + fixtures :posts, :authors, :categories, :categorizations, :comments, :tags, :taggings, :author_favorites, :vertices, :items, :books, + # Reload edges table from fixtures as otherwise repeated test was failing + :edges + + def test_has_many + assert authors(:david).categories.include?(categories(:general)) + end + + def test_has_many_inherited + assert authors(:mary).categories.include?(categories(:sti_test)) + end + + def test_inherited_has_many + assert categories(:sti_test).authors.include?(authors(:mary)) + end + + def test_has_many_uniq_through_join_model + assert_equal 2, authors(:mary).categorized_posts.size + assert_equal 1, authors(:mary).unique_categorized_posts.size + end + + def test_has_many_uniq_through_count + author = authors(:mary) + assert !authors(:mary).unique_categorized_posts.loaded? + assert_queries(1) { assert_equal 1, author.unique_categorized_posts.count } + assert_queries(1) { assert_equal 1, author.unique_categorized_posts.count(:title) } + assert_queries(1) { assert_equal 0, author.unique_categorized_posts.where(title: nil).count(:title) } + assert !authors(:mary).unique_categorized_posts.loaded? + end + + def test_has_many_uniq_through_find + assert_equal 1, authors(:mary).unique_categorized_posts.to_a.size + end + + def test_polymorphic_has_many_going_through_join_model + assert_equal tags(:general), tag = posts(:welcome).tags.first + assert_no_queries do + tag.tagging + end + end + + def test_count_polymorphic_has_many + assert_equal 1, posts(:welcome).taggings.count + assert_equal 1, posts(:welcome).tags.count + end + + def test_polymorphic_has_many_going_through_join_model_with_find + assert_equal tags(:general), tag = posts(:welcome).tags.first + assert_no_queries do + tag.tagging + end + end + + def test_polymorphic_has_many_going_through_join_model_with_include_on_source_reflection + assert_equal tags(:general), tag = posts(:welcome).funky_tags.first + assert_no_queries do + tag.tagging + end + end + + def test_polymorphic_has_many_going_through_join_model_with_include_on_source_reflection_with_find + assert_equal tags(:general), tag = posts(:welcome).funky_tags.first + assert_no_queries do + tag.tagging + end + end + + def test_polymorphic_has_many_going_through_join_model_with_custom_select_and_joins + assert_equal tags(:general), tag = posts(:welcome).tags.add_joins_and_select.first + assert_nothing_raised(NoMethodError) { tag.author_id } + end + + def test_polymorphic_has_many_going_through_join_model_with_custom_foreign_key + assert_equal tags(:misc), taggings(:welcome_general).super_tag + assert_equal tags(:misc), posts(:welcome).super_tags.first + end + + def test_polymorphic_has_many_create_model_with_inheritance_and_custom_base_class + post = SubStiPost.create :title => 'SubStiPost', :body => 'SubStiPost body' + assert_instance_of SubStiPost, post + + tagging = tags(:misc).taggings.create(:taggable => post) + assert_equal "SubStiPost", tagging.taggable_type + end + + def test_polymorphic_has_many_going_through_join_model_with_inheritance + assert_equal tags(:general), posts(:thinking).tags.first + end + + def test_polymorphic_has_many_going_through_join_model_with_inheritance_with_custom_class_name + assert_equal tags(:general), posts(:thinking).funky_tags.first + end + + def test_polymorphic_has_many_create_model_with_inheritance + post = posts(:thinking) + assert_instance_of SpecialPost, post + + tagging = tags(:misc).taggings.create(:taggable => post) + assert_equal "Post", tagging.taggable_type + end + + def test_polymorphic_has_one_create_model_with_inheritance + tagging = tags(:misc).create_tagging(:taggable => posts(:thinking)) + assert_equal "Post", tagging.taggable_type + end + + def test_set_polymorphic_has_many + tagging = tags(:misc).taggings.create + posts(:thinking).taggings << tagging + assert_equal "Post", tagging.taggable_type + end + + def test_set_polymorphic_has_one + tagging = tags(:misc).taggings.create + posts(:thinking).tagging = tagging + + assert_equal "Post", tagging.taggable_type + assert_equal posts(:thinking).id, tagging.taggable_id + assert_equal posts(:thinking), tagging.taggable + end + + def test_set_polymorphic_has_one_on_new_record + tagging = tags(:misc).taggings.create + post = Post.new :title => "foo", :body => "bar" + post.tagging = tagging + post.save! + + assert_equal "Post", tagging.taggable_type + assert_equal post.id, tagging.taggable_id + assert_equal post, tagging.taggable + end + + def test_create_polymorphic_has_many_with_scope + old_count = posts(:welcome).taggings.count + tagging = posts(:welcome).taggings.create(:tag => tags(:misc)) + assert_equal "Post", tagging.taggable_type + assert_equal old_count+1, posts(:welcome).taggings.count + end + + def test_create_bang_polymorphic_with_has_many_scope + old_count = posts(:welcome).taggings.count + tagging = posts(:welcome).taggings.create!(:tag => tags(:misc)) + assert_equal "Post", tagging.taggable_type + assert_equal old_count+1, posts(:welcome).taggings.count + end + + def test_create_polymorphic_has_one_with_scope + old_count = Tagging.count + tagging = posts(:welcome).create_tagging(:tag => tags(:misc)) + assert_equal "Post", tagging.taggable_type + assert_equal old_count+1, Tagging.count + end + + def test_delete_polymorphic_has_many_with_delete_all + assert_equal 1, posts(:welcome).taggings.count + posts(:welcome).taggings.first.update_columns taggable_type: 'PostWithHasManyDeleteAll' + post = find_post_with_dependency(1, :has_many, :taggings, :delete_all) + + old_count = Tagging.count + post.destroy + assert_equal old_count-1, Tagging.count + assert_equal 0, posts(:welcome).taggings.count + end + + def test_delete_polymorphic_has_many_with_destroy + assert_equal 1, posts(:welcome).taggings.count + posts(:welcome).taggings.first.update_columns taggable_type: 'PostWithHasManyDestroy' + post = find_post_with_dependency(1, :has_many, :taggings, :destroy) + + old_count = Tagging.count + post.destroy + assert_equal old_count-1, Tagging.count + assert_equal 0, posts(:welcome).taggings.count + end + + def test_delete_polymorphic_has_many_with_nullify + assert_equal 1, posts(:welcome).taggings.count + posts(:welcome).taggings.first.update_columns taggable_type: 'PostWithHasManyNullify' + post = find_post_with_dependency(1, :has_many, :taggings, :nullify) + + old_count = Tagging.count + post.destroy + assert_equal old_count, Tagging.count + assert_equal 0, posts(:welcome).taggings.count + end + + def test_delete_polymorphic_has_one_with_destroy + assert posts(:welcome).tagging + posts(:welcome).tagging.update_columns taggable_type: 'PostWithHasOneDestroy' + post = find_post_with_dependency(1, :has_one, :tagging, :destroy) + + old_count = Tagging.count + post.destroy + assert_equal old_count-1, Tagging.count + assert_nil posts(:welcome).tagging(true) + end + + def test_delete_polymorphic_has_one_with_nullify + assert posts(:welcome).tagging + posts(:welcome).tagging.update_columns taggable_type: 'PostWithHasOneNullify' + post = find_post_with_dependency(1, :has_one, :tagging, :nullify) + + old_count = Tagging.count + post.destroy + assert_equal old_count, Tagging.count + assert_nil posts(:welcome).tagging(true) + end + + def test_has_many_with_piggyback + assert_equal "2", categories(:sti_test).authors_with_select.first.post_id.to_s + end + + def test_create_through_has_many_with_piggyback + category = categories(:sti_test) + ernie = category.authors_with_select.create(:name => 'Ernie') + assert_nothing_raised do + assert_equal ernie, category.authors_with_select.detect {|a| a.name == 'Ernie'} + end + end + + def test_include_has_many_through + posts = Post.all.merge!(:order => 'posts.id').to_a + posts_with_authors = Post.all.merge!(:includes => :authors, :order => 'posts.id').to_a + assert_equal posts.length, posts_with_authors.length + posts.length.times do |i| + assert_equal posts[i].authors.length, assert_no_queries { posts_with_authors[i].authors.length } + end + end + + def test_include_polymorphic_has_one + post = Post.includes(:tagging).find posts(:welcome).id + tagging = taggings(:welcome_general) + assert_no_queries do + assert_equal tagging, post.tagging + end + end + + def test_include_polymorphic_has_one_defined_in_abstract_parent + item = Item.includes(:tagging).find items(:dvd).id + tagging = taggings(:godfather) + assert_no_queries do + assert_equal tagging, item.tagging + end + end + + def test_include_polymorphic_has_many_through + posts = Post.all.merge!(:order => 'posts.id').to_a + posts_with_tags = Post.all.merge!(:includes => :tags, :order => 'posts.id').to_a + assert_equal posts.length, posts_with_tags.length + posts.length.times do |i| + assert_equal posts[i].tags.length, assert_no_queries { posts_with_tags[i].tags.length } + end + end + + def test_include_polymorphic_has_many + posts = Post.all.merge!(:order => 'posts.id').to_a + posts_with_taggings = Post.all.merge!(:includes => :taggings, :order => 'posts.id').to_a + assert_equal posts.length, posts_with_taggings.length + posts.length.times do |i| + assert_equal posts[i].taggings.length, assert_no_queries { posts_with_taggings[i].taggings.length } + end + end + + def test_has_many_find_all + assert_equal [categories(:general)], authors(:david).categories.to_a + end + + def test_has_many_find_first + assert_equal categories(:general), authors(:david).categories.first + end + + def test_has_many_with_hash_conditions + assert_equal categories(:general), authors(:david).categories_like_general.first + end + + def test_has_many_find_conditions + assert_equal categories(:general), authors(:david).categories.where("categories.name = 'General'").first + assert_nil authors(:david).categories.where("categories.name = 'Technology'").first + end + + def test_has_many_array_methods_called_by_method_missing + assert authors(:david).categories.any? { |category| category.name == 'General' } + assert_nothing_raised { authors(:david).categories.sort } + end + + def test_has_many_going_through_join_model_with_custom_foreign_key + assert_equal [authors(:bob)], posts(:thinking).authors + assert_equal [authors(:mary)], posts(:authorless).authors + end + + def test_has_many_going_through_join_model_with_custom_primary_key + assert_equal [authors(:david)], posts(:thinking).authors_using_author_id + end + + def test_has_many_going_through_polymorphic_join_model_with_custom_primary_key + assert_equal [tags(:general)], posts(:eager_other).tags_using_author_id + end + + def test_has_many_through_with_custom_primary_key_on_belongs_to_source + assert_equal [authors(:david), authors(:david)], posts(:thinking).author_using_custom_pk + end + + def test_has_many_through_with_custom_primary_key_on_has_many_source + assert_equal [authors(:david), authors(:bob)], posts(:thinking).authors_using_custom_pk.order('authors.id') + end + + def test_belongs_to_polymorphic_with_counter_cache + assert_equal 1, posts(:welcome)[:tags_count] + tagging = posts(:welcome).taggings.create(:tag => tags(:general)) + assert_equal 2, posts(:welcome, :reload)[:tags_count] + tagging.destroy + assert_equal 1, posts(:welcome, :reload)[:tags_count] + end + + def test_unavailable_through_reflection + assert_raise(ActiveRecord::HasManyThroughAssociationNotFoundError) { authors(:david).nothings } + end + + def test_has_many_through_join_model_with_conditions + assert_equal [], posts(:welcome).invalid_taggings + assert_equal [], posts(:welcome).invalid_tags + end + + def test_has_many_polymorphic + assert_raise ActiveRecord::HasManyThroughAssociationPolymorphicSourceError do + tags(:general).taggables + end + + assert_raise ActiveRecord::HasManyThroughAssociationPolymorphicThroughError do + taggings(:welcome_general).things + end + + assert_raise ActiveRecord::EagerLoadPolymorphicError do + tags(:general).taggings.includes(:taggable).where('bogus_table.column = 1').references(:bogus_table).to_a + end + end + + def test_has_many_polymorphic_with_source_type + # added sort by ID as otherwise Oracle select sometimes returned rows in different order + assert_equal posts(:welcome, :thinking).sort_by(&:id), tags(:general).tagged_posts.sort_by(&:id) + end + + def test_eager_has_many_polymorphic_with_source_type + tag_with_include = Tag.all.merge!(:includes => :tagged_posts).find(tags(:general).id) + desired = posts(:welcome, :thinking) + assert_no_queries do + # added sort by ID as otherwise test using JRuby was failing as array elements were in different order + assert_equal desired.sort_by(&:id), tag_with_include.tagged_posts.sort_by(&:id) + end + assert_equal 5, tag_with_include.taggings.length + end + + def test_has_many_through_has_many_find_all + assert_equal comments(:greetings), authors(:david).comments.order('comments.id').to_a.first + end + + def test_has_many_through_has_many_find_all_with_custom_class + assert_equal comments(:greetings), authors(:david).funky_comments.order('comments.id').to_a.first + end + + def test_has_many_through_has_many_find_first + assert_equal comments(:greetings), authors(:david).comments.order('comments.id').first + end + + def test_has_many_through_has_many_find_conditions + options = { :where => "comments.#{QUOTED_TYPE}='SpecialComment'", :order => 'comments.id' } + assert_equal comments(:does_it_hurt), authors(:david).comments.merge(options).first + end + + def test_has_many_through_has_many_find_by_id + assert_equal comments(:more_greetings), authors(:david).comments.find(2) + end + + def test_has_many_through_polymorphic_has_one + assert_equal Tagging.find(1,2).sort_by { |t| t.id }, authors(:david).taggings_2 + end + + def test_has_many_through_polymorphic_has_many + assert_equal taggings(:welcome_general, :thinking_general), authors(:david).taggings.distinct.sort_by { |t| t.id } + end + + def test_include_has_many_through_polymorphic_has_many + author = Author.includes(:taggings).find authors(:david).id + expected_taggings = taggings(:welcome_general, :thinking_general) + assert_no_queries do + assert_equal expected_taggings, author.taggings.distinct.sort_by { |t| t.id } + end + end + + def test_eager_load_has_many_through_has_many + author = Author.all.merge!(:where => ['name = ?', 'David'], :includes => :comments, :order => 'comments.id').first + SpecialComment.new; VerySpecialComment.new + assert_no_queries do + assert_equal [1,2,3,5,6,7,8,9,10,12], author.comments.collect(&:id) + end + end + + def test_eager_load_has_many_through_has_many_with_conditions + post = Post.all.merge!(:includes => :invalid_tags).first + assert_no_queries do + post.invalid_tags + end + end + + def test_eager_belongs_to_and_has_one_not_singularized + assert_nothing_raised do + Author.all.merge!(:includes => :author_address).first + AuthorAddress.all.merge!(:includes => :author).first + end + end + + def test_self_referential_has_many_through + assert_equal [authors(:mary)], authors(:david).favorite_authors + assert_equal [], authors(:mary).favorite_authors + end + + def test_add_to_self_referential_has_many_through + new_author = Author.create(:name => "Bob") + authors(:david).author_favorites.create :favorite_author => new_author + assert_equal new_author, authors(:david).reload.favorite_authors.first + end + + def test_has_many_through_uses_conditions_specified_on_the_has_many_association + author = Author.first + assert author.comments.present? + assert author.nonexistant_comments.blank? + end + + def test_has_many_through_uses_correct_attributes + assert_nil posts(:thinking).tags.find_by_name("General").attributes["tag_id"] + end + + def test_associating_unsaved_records_with_has_many_through + saved_post = posts(:thinking) + new_tag = Tag.new(:name => "new") + + saved_post.tags << new_tag + assert new_tag.persisted? #consistent with habtm! + assert saved_post.persisted? + assert saved_post.tags.include?(new_tag) + + assert new_tag.persisted? + assert saved_post.reload.tags(true).include?(new_tag) + + + new_post = Post.new(:title => "Association replacement works!", :body => "You best believe it.") + saved_tag = tags(:general) + + new_post.tags << saved_tag + assert !new_post.persisted? + assert saved_tag.persisted? + assert new_post.tags.include?(saved_tag) + + new_post.save! + assert new_post.persisted? + assert new_post.reload.tags(true).include?(saved_tag) + + assert !posts(:thinking).tags.build.persisted? + assert !posts(:thinking).tags.new.persisted? + end + + def test_create_associate_when_adding_to_has_many_through + count = posts(:thinking).tags.count + push = Tag.create!(:name => 'pushme') + post_thinking = posts(:thinking) + assert_nothing_raised { post_thinking.tags << push } + assert_nil( wrong = post_thinking.tags.detect { |t| t.class != Tag }, + message = "Expected a Tag in tags collection, got #{wrong.class}.") + assert_nil( wrong = post_thinking.taggings.detect { |t| t.class != Tagging }, + message = "Expected a Tagging in taggings collection, got #{wrong.class}.") + assert_equal(count + 1, post_thinking.reload.tags.size) + assert_equal(count + 1, post_thinking.tags(true).size) + + assert_kind_of Tag, post_thinking.tags.create!(:name => 'foo') + assert_nil( wrong = post_thinking.tags.detect { |t| t.class != Tag }, + message = "Expected a Tag in tags collection, got #{wrong.class}.") + assert_nil( wrong = post_thinking.taggings.detect { |t| t.class != Tagging }, + message = "Expected a Tagging in taggings collection, got #{wrong.class}.") + assert_equal(count + 2, post_thinking.reload.tags.size) + assert_equal(count + 2, post_thinking.tags(true).size) + + assert_nothing_raised { post_thinking.tags.concat(Tag.create!(:name => 'abc'), Tag.create!(:name => 'def')) } + assert_nil( wrong = post_thinking.tags.detect { |t| t.class != Tag }, + message = "Expected a Tag in tags collection, got #{wrong.class}.") + assert_nil( wrong = post_thinking.taggings.detect { |t| t.class != Tagging }, + message = "Expected a Tagging in taggings collection, got #{wrong.class}.") + assert_equal(count + 4, post_thinking.reload.tags.size) + assert_equal(count + 4, post_thinking.tags(true).size) + + # Raises if the wrong reflection name is used to set the Edge belongs_to + assert_nothing_raised { vertices(:vertex_1).sinks << vertices(:vertex_5) } + end + + def test_add_to_join_table_with_no_id + assert_nothing_raised { vertices(:vertex_1).sinks << vertices(:vertex_5) } + end + + def test_has_many_through_collection_size_doesnt_load_target_if_not_loaded + author = authors(:david) + assert_equal 10, author.comments.size + assert !author.comments.loaded? + end + + def test_has_many_through_collection_size_uses_counter_cache_if_it_exists + c = categories(:general) + c.categorizations_count = 100 + assert_equal 100, c.categorizations.size + assert !c.categorizations.loaded? + end + + def test_adding_junk_to_has_many_through_should_raise_type_mismatch + assert_raise(ActiveRecord::AssociationTypeMismatch) { posts(:thinking).tags << "Uhh what now?" } + end + + def test_adding_to_has_many_through_should_return_self + tags = posts(:thinking).tags + assert_equal tags, posts(:thinking).tags.push(tags(:general)) + end + + def test_delete_associate_when_deleting_from_has_many_through_with_nonstandard_id + count = books(:awdr).references.count + references_before = books(:awdr).references + book = Book.create!(:name => 'Getting Real') + book_awdr = books(:awdr) + book_awdr.references << book + assert_equal(count + 1, book_awdr.references(true).size) + + assert_nothing_raised { book_awdr.references.delete(book) } + assert_equal(count, book_awdr.references.size) + assert_equal(count, book_awdr.references(true).size) + assert_equal(references_before.sort, book_awdr.references.sort) + end + + def test_delete_associate_when_deleting_from_has_many_through + count = posts(:thinking).tags.count + tags_before = posts(:thinking).tags.sort + tag = Tag.create!(:name => 'doomed') + post_thinking = posts(:thinking) + post_thinking.tags << tag + assert_equal(count + 1, post_thinking.taggings(true).size) + assert_equal(count + 1, post_thinking.reload.tags(true).size) + assert_not_equal(tags_before, post_thinking.tags.sort) + + assert_nothing_raised { post_thinking.tags.delete(tag) } + assert_equal(count, post_thinking.tags.size) + assert_equal(count, post_thinking.tags(true).size) + assert_equal(count, post_thinking.taggings(true).size) + assert_equal(tags_before, post_thinking.tags.sort) + end + + def test_delete_associate_when_deleting_from_has_many_through_with_multiple_tags + count = posts(:thinking).tags.count + tags_before = posts(:thinking).tags.sort + doomed = Tag.create!(:name => 'doomed') + doomed2 = Tag.create!(:name => 'doomed2') + quaked = Tag.create!(:name => 'quaked') + post_thinking = posts(:thinking) + post_thinking.tags << doomed << doomed2 + assert_equal(count + 2, post_thinking.reload.tags(true).size) + + assert_nothing_raised { post_thinking.tags.delete(doomed, doomed2, quaked) } + assert_equal(count, post_thinking.tags.size) + assert_equal(count, post_thinking.tags(true).size) + assert_equal(tags_before, post_thinking.tags.sort) + end + + def test_deleting_junk_from_has_many_through_should_raise_type_mismatch + assert_raise(ActiveRecord::AssociationTypeMismatch) { posts(:thinking).tags.delete(Object.new) } + end + + def test_deleting_by_fixnum_id_from_has_many_through + post = posts(:thinking) + + assert_difference 'post.tags.count', -1 do + assert_equal 1, post.tags.delete(1).size + end + + assert_equal 0, post.tags.size + end + + def test_deleting_by_string_id_from_has_many_through + post = posts(:thinking) + + assert_difference 'post.tags.count', -1 do + assert_equal 1, post.tags.delete('1').size + end + + assert_equal 0, post.tags.size + end + + def test_has_many_through_sum_uses_calculations + assert_nothing_raised { authors(:david).comments.sum(:post_id) } + end + + def test_calculations_on_has_many_through_should_disambiguate_fields + assert_nothing_raised { authors(:david).categories.maximum(:id) } + end + + def test_calculations_on_has_many_through_should_not_disambiguate_fields_unless_necessary + assert_nothing_raised { authors(:david).categories.maximum("categories.id") } + end + + def test_has_many_through_has_many_with_sti + assert_equal [comments(:does_it_hurt)], authors(:david).special_post_comments + end + + def test_uniq_has_many_through_should_retain_order + comment_ids = authors(:david).comments.map(&:id) + assert_equal comment_ids.sort, authors(:david).ordered_uniq_comments.map(&:id) + assert_equal comment_ids.sort.reverse, authors(:david).ordered_uniq_comments_desc.map(&:id) + end + + def test_polymorphic_has_many + expected = taggings(:welcome_general) + p = Post.all.merge!(:includes => :taggings).find(posts(:welcome).id) + assert_no_queries {assert p.taggings.include?(expected)} + assert posts(:welcome).taggings.include?(taggings(:welcome_general)) + end + + def test_polymorphic_has_one + expected = posts(:welcome) + + tagging = Tagging.all.merge!(:includes => :taggable).find(taggings(:welcome_general).id) + assert_no_queries { assert_equal expected, tagging.taggable} + end + + def test_polymorphic_belongs_to + p = Post.all.merge!(:includes => {:taggings => :taggable}).find(posts(:welcome).id) + assert_no_queries {assert_equal posts(:welcome), p.taggings.first.taggable} + end + + def test_preload_polymorphic_has_many_through + posts = Post.all.merge!(:order => 'posts.id').to_a + posts_with_tags = Post.all.merge!(:includes => :tags, :order => 'posts.id').to_a + assert_equal posts.length, posts_with_tags.length + posts.length.times do |i| + assert_equal posts[i].tags.length, assert_no_queries { posts_with_tags[i].tags.length } + end + end + + def test_preload_polymorph_many_types + taggings = Tagging.all.merge!(:includes => :taggable, :where => ['taggable_type != ?', 'FakeModel']).to_a + assert_no_queries do + taggings.first.taggable.id + taggings[1].taggable.id + end + + taggables = taggings.map(&:taggable) + assert taggables.include?(items(:dvd)) + assert taggables.include?(posts(:welcome)) + end + + def test_preload_nil_polymorphic_belongs_to + assert_nothing_raised do + Tagging.all.merge!(:includes => :taggable, :where => ['taggable_type IS NULL']).to_a + end + end + + def test_preload_polymorphic_has_many + posts = Post.all.merge!(:order => 'posts.id').to_a + posts_with_taggings = Post.all.merge!(:includes => :taggings, :order => 'posts.id').to_a + assert_equal posts.length, posts_with_taggings.length + posts.length.times do |i| + assert_equal posts[i].taggings.length, assert_no_queries { posts_with_taggings[i].taggings.length } + end + end + + def test_belongs_to_shared_parent + comments = Comment.all.merge!(:includes => :post, :where => 'post_id = 1').to_a + assert_no_queries do + assert_equal comments.first.post, comments[1].post + end + end + + def test_has_many_through_include_uses_array_include_after_loaded + david = authors(:david) + david.categories.load_target + + category = david.categories.first + + assert_no_queries do + assert david.categories.loaded? + assert david.categories.include?(category) + end + end + + def test_has_many_through_include_checks_if_record_exists_if_target_not_loaded + david = authors(:david) + category = david.categories.first + + david.reload + assert ! david.categories.loaded? + assert_queries(1) do + assert david.categories.include?(category) + end + assert ! david.categories.loaded? + end + + def test_has_many_through_include_returns_false_for_non_matching_record_to_verify_scoping + david = authors(:david) + category = Category.create!(:name => 'Not Associated') + + assert ! david.categories.loaded? + 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 + + def test_has_many_with_pluralize_table_names_false + aircraft = Aircraft.create!(:name => "Airbus 380") + engine = Engine.create!(:car_id => aircraft.id) + assert_equal aircraft.engines, [engine] + end + + private + # create dynamic Post models to allow different dependency options + def find_post_with_dependency(post_id, association, association_name, dependency) + class_name = "PostWith#{association.to_s.classify}#{dependency.to_s.classify}" + Post.find(post_id).update_columns type: class_name + klass = Object.const_set(class_name, Class.new(ActiveRecord::Base)) + klass.table_name = 'posts' + klass.send(association, association_name, :as => :taggable, :dependent => dependency) + klass.find(post_id) + end +end diff --git a/activerecord/test/cases/associations/nested_through_associations_test.rb b/activerecord/test/cases/associations/nested_through_associations_test.rb new file mode 100644 index 0000000000..31b68c940e --- /dev/null +++ b/activerecord/test/cases/associations/nested_through_associations_test.rb @@ -0,0 +1,579 @@ +require "cases/helper" +require 'models/author' +require 'models/post' +require 'models/person' +require 'models/reference' +require 'models/job' +require 'models/reader' +require 'models/comment' +require 'models/tag' +require 'models/tagging' +require 'models/subscriber' +require 'models/book' +require 'models/subscription' +require 'models/rating' +require 'models/member' +require 'models/member_detail' +require 'models/member_type' +require 'models/sponsor' +require 'models/club' +require 'models/organization' +require 'models/category' +require 'models/categorization' +require 'models/membership' +require 'models/essay' + +class NestedThroughAssociationsTest < ActiveRecord::TestCase + fixtures :authors, :books, :posts, :subscriptions, :subscribers, :tags, :taggings, + :people, :readers, :references, :jobs, :ratings, :comments, :members, :member_details, + :member_types, :sponsors, :clubs, :organizations, :categories, :categories_posts, + :categorizations, :memberships, :essays + + # Through associations can either use the has_many or has_one macros. + # + # has_many + # - Source reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many + # - Through reflection can be has_many, has_one, belongs_to or has_and_belongs_to_many + # + # has_one + # - Source reflection can be has_one or belongs_to + # - Through reflection can be has_one or belongs_to + # + # Additionally, the source reflection and/or through reflection may be subject to + # polymorphism and/or STI. + # + # When testing these, we need to make sure it works via loading the association directly, or + # joining the association, or including the association. We also need to ensure that associations + # are readonly where relevant. + + # has_many through + # Source: has_many through + # Through: has_many + def test_has_many_through_has_many_with_has_many_through_source_reflection + general = tags(:general) + assert_equal [general, general], authors(:david).tags + end + + def test_has_many_through_has_many_with_has_many_through_source_reflection_preload + authors = assert_queries(5) { Author.includes(:tags).to_a } + general = tags(:general) + + assert_no_queries do + assert_equal [general, general], authors.first.tags + end + end + + def test_has_many_through_has_many_with_has_many_through_source_reflection_preload_via_joins + assert_includes_and_joins_equal( + Author.where('tags.id' => tags(:general).id), + [authors(:david)], :tags + ) + + # This ensures that the polymorphism of taggings is being observed correctly + authors = Author.joins(:tags).where('taggings.taggable_type' => 'FakeModel') + assert authors.empty? + end + + # has_many through + # Source: has_many + # Through: has_many through + def test_has_many_through_has_many_through_with_has_many_source_reflection + luke, david = subscribers(:first), subscribers(:second) + assert_equal [luke, david, david], authors(:david).subscribers.order('subscribers.nick') + end + + def test_has_many_through_has_many_through_with_has_many_source_reflection_preload + luke, david = subscribers(:first), subscribers(:second) + authors = assert_queries(4) { Author.includes(:subscribers).to_a } + assert_no_queries do + assert_equal [luke, david, david], authors.first.subscribers.sort_by(&:nick) + end + end + + def test_has_many_through_has_many_through_with_has_many_source_reflection_preload_via_joins + # All authors with subscribers where one of the subscribers' nick is 'alterself' + assert_includes_and_joins_equal( + Author.where('subscribers.nick' => 'alterself'), + [authors(:david)], :subscribers + ) + end + + # has_many through + # Source: has_one through + # Through: has_one + def test_has_many_through_has_one_with_has_one_through_source_reflection + assert_equal [member_types(:founding)], members(:groucho).nested_member_types + end + + def test_has_many_through_has_one_with_has_one_through_source_reflection_preload + members = assert_queries(4) { Member.includes(:nested_member_types).to_a } + founding = member_types(:founding) + assert_no_queries do + assert_equal [founding], members.first.nested_member_types + end + end + + def test_has_many_through_has_one_with_has_one_through_source_reflection_preload_via_joins + assert_includes_and_joins_equal( + Member.where('member_types.id' => member_types(:founding).id), + [members(:groucho)], :nested_member_types + ) + end + + # has_many through + # Source: has_one + # Through: has_one through + def test_has_many_through_has_one_through_with_has_one_source_reflection + assert_equal [sponsors(:moustache_club_sponsor_for_groucho)], members(:groucho).nested_sponsors + end + + def test_has_many_through_has_one_through_with_has_one_source_reflection_preload + members = assert_queries(4) { Member.includes(:nested_sponsors).to_a } + mustache = sponsors(:moustache_club_sponsor_for_groucho) + assert_no_queries(ignore_none: false) do + assert_equal [mustache], members.first.nested_sponsors + end + end + + def test_has_many_through_has_one_through_with_has_one_source_reflection_preload_via_joins + assert_includes_and_joins_equal( + Member.where('sponsors.id' => sponsors(:moustache_club_sponsor_for_groucho).id), + [members(:groucho)], :nested_sponsors + ) + end + + # has_many through + # Source: has_many through + # Through: has_one + def test_has_many_through_has_one_with_has_many_through_source_reflection + groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) + + assert_equal [groucho_details, other_details], + members(:groucho).organization_member_details.order('member_details.id') + end + + def test_has_many_through_has_one_with_has_many_through_source_reflection_preload + ActiveRecord::Base.connection.table_alias_length # preheat cache + members = assert_queries(4) { Member.includes(:organization_member_details).to_a.sort_by(&:id) } + groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) + + assert_no_queries do + assert_equal [groucho_details, other_details], members.first.organization_member_details.sort_by(&:id) + end + end + + def test_has_many_through_has_one_with_has_many_through_source_reflection_preload_via_joins + assert_includes_and_joins_equal( + Member.where('member_details.id' => member_details(:groucho).id).order('member_details.id'), + [members(:groucho), members(:some_other_guy)], :organization_member_details + ) + + members = Member.joins(:organization_member_details). + where('member_details.id' => 9) + assert members.empty? + end + + # has_many through + # Source: has_many + # Through: has_one through + def test_has_many_through_has_one_through_with_has_many_source_reflection + groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) + + assert_equal [groucho_details, other_details], + members(:groucho).organization_member_details_2.order('member_details.id') + end + + def test_has_many_through_has_one_through_with_has_many_source_reflection_preload + members = assert_queries(4) { Member.includes(:organization_member_details_2).to_a.sort_by(&:id) } + groucho_details, other_details = member_details(:groucho), member_details(:some_other_guy) + + # postgresql test if randomly executed then executes "SHOW max_identifier_length". Hence + # the need to ignore certain predefined sqls that deal with system calls. + assert_no_queries(ignore_none: false) do + assert_equal [groucho_details, other_details], members.first.organization_member_details_2.sort_by(&:id) + end + end + + def test_has_many_through_has_one_through_with_has_many_source_reflection_preload_via_joins + assert_includes_and_joins_equal( + Member.where('member_details.id' => member_details(:groucho).id).order('member_details.id'), + [members(:groucho), members(:some_other_guy)], :organization_member_details_2 + ) + + members = Member.joins(:organization_member_details_2). + where('member_details.id' => 9) + assert members.empty? + end + + # has_many through + # Source: has_and_belongs_to_many + # Through: has_many + def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection + general, cooking = categories(:general), categories(:cooking) + + assert_equal [general, cooking], authors(:bob).post_categories.order('categories.id') + end + + def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection_preload + authors = assert_queries(4) { Author.includes(:post_categories).to_a.sort_by(&:id) } + general, cooking = categories(:general), categories(:cooking) + + assert_no_queries do + assert_equal [general, cooking], authors[2].post_categories.sort_by(&:id) + end + end + + def test_has_many_through_has_many_with_has_and_belongs_to_many_source_reflection_preload_via_joins + # preload table schemas + Author.joins(:post_categories).first + + assert_includes_and_joins_equal( + Author.where('categories.id' => categories(:cooking).id), + [authors(:bob)], :post_categories + ) + end + + # has_many through + # Source: has_many + # Through: has_and_belongs_to_many + def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection + greetings, more = comments(:greetings), comments(:more_greetings) + + assert_equal [greetings, more], categories(:technology).post_comments.order('comments.id') + end + + def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection_preload + Category.includes(:post_comments).to_a # preheat cache + categories = assert_queries(4) { Category.includes(:post_comments).to_a.sort_by(&:id) } + greetings, more = comments(:greetings), comments(:more_greetings) + + assert_no_queries do + assert_equal [greetings, more], categories[1].post_comments.sort_by(&:id) + end + end + + def test_has_many_through_has_and_belongs_to_many_with_has_many_source_reflection_preload_via_joins + # preload table schemas + Category.joins(:post_comments).first + + assert_includes_and_joins_equal( + Category.where('comments.id' => comments(:more_greetings).id).order('categories.id'), + [categories(:general), categories(:technology)], :post_comments + ) + end + + # has_many through + # Source: has_many through a habtm + # Through: has_many through + def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection + greetings, more = comments(:greetings), comments(:more_greetings) + + assert_equal [greetings, more], authors(:bob).category_post_comments.order('comments.id') + end + + def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection_preload + authors = assert_queries(6) { Author.includes(:category_post_comments).to_a.sort_by(&:id) } + greetings, more = comments(:greetings), comments(:more_greetings) + + assert_no_queries do + assert_equal [greetings, more], authors[2].category_post_comments.sort_by(&:id) + end + end + + def test_has_many_through_has_many_with_has_many_through_habtm_source_reflection_preload_via_joins + # preload table schemas + Author.joins(:category_post_comments).first + + assert_includes_and_joins_equal( + Author.where('comments.id' => comments(:does_it_hurt).id).order('authors.id'), + [authors(:david), authors(:mary)], :category_post_comments + ) + end + + # has_many through + # Source: belongs_to + # Through: has_many through + def test_has_many_through_has_many_through_with_belongs_to_source_reflection + assert_equal [tags(:general), tags(:general)], authors(:david).tagging_tags + end + + def test_has_many_through_has_many_through_with_belongs_to_source_reflection_preload + authors = assert_queries(5) { Author.includes(:tagging_tags).to_a } + general = tags(:general) + + assert_no_queries do + assert_equal [general, general], authors.first.tagging_tags + end + end + + def test_has_many_through_has_many_through_with_belongs_to_source_reflection_preload_via_joins + assert_includes_and_joins_equal( + Author.where('tags.id' => tags(:general).id), + [authors(:david)], :tagging_tags + ) + end + + # has_many through + # Source: has_many through + # Through: belongs_to + def test_has_many_through_belongs_to_with_has_many_through_source_reflection + welcome_general, thinking_general = taggings(:welcome_general), taggings(:thinking_general) + + assert_equal [welcome_general, thinking_general], + categorizations(:david_welcome_general).post_taggings.order('taggings.id') + end + + def test_has_many_through_belongs_to_with_has_many_through_source_reflection_preload + categorizations = assert_queries(4) { Categorization.includes(:post_taggings).to_a.sort_by(&:id) } + welcome_general, thinking_general = taggings(:welcome_general), taggings(:thinking_general) + + assert_no_queries do + assert_equal [welcome_general, thinking_general], categorizations.first.post_taggings.sort_by(&:id) + end + end + + def test_has_many_through_belongs_to_with_has_many_through_source_reflection_preload_via_joins + assert_includes_and_joins_equal( + Categorization.where('taggings.id' => taggings(:welcome_general).id).order('taggings.id'), + [categorizations(:david_welcome_general)], :post_taggings + ) + end + + # has_one through + # Source: has_one through + # Through: has_one + def test_has_one_through_has_one_with_has_one_through_source_reflection + assert_equal member_types(:founding), members(:groucho).nested_member_type + end + + def test_has_one_through_has_one_with_has_one_through_source_reflection_preload + members = assert_queries(4) { Member.includes(:nested_member_type).to_a.sort_by(&:id) } + founding = member_types(:founding) + + assert_no_queries do + assert_equal founding, members.first.nested_member_type + end + end + + def test_has_one_through_has_one_with_has_one_through_source_reflection_preload_via_joins + assert_includes_and_joins_equal( + Member.where('member_types.id' => member_types(:founding).id), + [members(:groucho)], :nested_member_type + ) + end + + # has_one through + # Source: belongs_to + # Through: has_one through + def test_has_one_through_has_one_through_with_belongs_to_source_reflection + assert_equal categories(:general), members(:groucho).club_category + end + + def test_joins_and_includes_from_through_models_not_included_in_association + prev_default_scope = Club.default_scopes + + [:includes, :preload, :joins, :eager_load].each do |q| + Club.default_scopes = [proc { Club.send(q, :category) }] + assert_equal categories(:general), members(:groucho).reload.club_category + end + ensure + Club.default_scopes = prev_default_scope + end + + def test_has_one_through_has_one_through_with_belongs_to_source_reflection_preload + members = assert_queries(4) { Member.includes(:club_category).to_a.sort_by(&:id) } + general = categories(:general) + + assert_no_queries do + assert_equal general, members.first.club_category + end + end + + def test_has_one_through_has_one_through_with_belongs_to_source_reflection_preload_via_joins + assert_includes_and_joins_equal( + Member.where('categories.id' => categories(:technology).id), + [members(:blarpy_winkup)], :club_category + ) + end + + def test_distinct_has_many_through_a_has_many_through_association_on_source_reflection + author = authors(:david) + assert_equal [tags(:general)], author.distinct_tags + end + + def test_distinct_has_many_through_a_has_many_through_association_on_through_reflection + author = authors(:david) + assert_equal [subscribers(:first), subscribers(:second)], + author.distinct_subscribers.order('subscribers.nick') + end + + def test_nested_has_many_through_with_a_table_referenced_multiple_times + author = authors(:bob) + assert_equal [posts(:misc_by_bob), posts(:misc_by_mary), posts(:other_by_bob), posts(:other_by_mary)], + author.similar_posts.sort_by(&:id) + + # Mary and Bob both have posts in misc, but they are the only ones. + authors = Author.joins(:similar_posts).where('posts.id' => posts(:misc_by_bob).id) + assert_equal [authors(:mary), authors(:bob)], authors.distinct.sort_by(&:id) + + # Check the polymorphism of taggings is being observed correctly (in both joins) + authors = Author.joins(:similar_posts).where('taggings.taggable_type' => 'FakeModel') + assert authors.empty? + authors = Author.joins(:similar_posts).where('taggings_authors_join.taggable_type' => 'FakeModel') + assert authors.empty? + end + + def test_has_many_through_with_foreign_key_option_on_through_reflection + assert_equal [posts(:welcome), posts(:authorless)], people(:david).agents_posts.order('posts.id') + assert_equal [authors(:david)], references(:david_unicyclist).agents_posts_authors + + references = Reference.joins(:agents_posts_authors).where('authors.id' => authors(:david).id) + assert_equal [references(:david_unicyclist)], references + end + + def test_has_many_through_with_foreign_key_option_on_source_reflection + assert_equal [people(:michael), people(:susan)], jobs(:unicyclist).agents.order('people.id') + + jobs = Job.joins(:agents) + assert_equal [jobs(:unicyclist), jobs(:unicyclist)], jobs + end + + def test_has_many_through_with_sti_on_through_reflection + ratings = posts(:sti_comments).special_comments_ratings.sort_by(&:id) + assert_equal [ratings(:special_comment_rating), ratings(:sub_special_comment_rating)], ratings + + # Ensure STI is respected in the join + scope = Post.joins(:special_comments_ratings).where(:id => posts(:sti_comments).id) + assert scope.where("comments.type" => "Comment").empty? + assert !scope.where("comments.type" => "SpecialComment").empty? + assert !scope.where("comments.type" => "SubSpecialComment").empty? + end + + def test_has_many_through_with_sti_on_nested_through_reflection + taggings = posts(:sti_comments).special_comments_ratings_taggings + assert_equal [taggings(:special_comment_rating)], taggings + + scope = Post.joins(:special_comments_ratings_taggings).where(:id => posts(:sti_comments).id) + assert scope.where("comments.type" => "Comment").empty? + assert !scope.where("comments.type" => "SpecialComment").empty? + end + + def test_nested_has_many_through_writers_should_raise_error + david = authors(:david) + subscriber = subscribers(:first) + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers = [subscriber] + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscriber_ids = [subscriber.id] + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers << subscriber + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.delete(subscriber) + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.clear + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.build + end + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + david.subscribers.create + end + end + + def test_nested_has_one_through_writers_should_raise_error + groucho = members(:groucho) + founding = member_types(:founding) + + assert_raises(ActiveRecord::HasManyThroughNestedAssociationsAreReadonly) do + groucho.nested_member_type = founding + end + end + + def test_nested_has_many_through_with_conditions_on_through_associations + assert_equal [tags(:blue)], authors(:bob).misc_post_first_blue_tags + end + + def test_nested_has_many_through_with_conditions_on_through_associations_preload + assert Author.where('tags.id' => 100).joins(:misc_post_first_blue_tags).empty? + + authors = assert_queries(3) { Author.includes(:misc_post_first_blue_tags).to_a.sort_by(&:id) } + blue = tags(:blue) + + assert_no_queries do + assert_equal [blue], authors[2].misc_post_first_blue_tags + end + end + + def test_nested_has_many_through_with_conditions_on_through_associations_preload_via_joins + # Pointless condition to force single-query loading + assert_includes_and_joins_equal( + Author.where('tags.id = tags.id').references(:tags), + [authors(:bob)], :misc_post_first_blue_tags + ) + end + + def test_nested_has_many_through_with_conditions_on_source_associations + assert_equal [tags(:blue)], authors(:bob).misc_post_first_blue_tags_2 + end + + def test_nested_has_many_through_with_conditions_on_source_associations_preload + authors = assert_queries(4) { Author.includes(:misc_post_first_blue_tags_2).to_a.sort_by(&:id) } + blue = tags(:blue) + + assert_no_queries do + assert_equal [blue], authors[2].misc_post_first_blue_tags_2 + end + end + + def test_nested_has_many_through_with_conditions_on_source_associations_preload_via_joins + # Pointless condition to force single-query loading + assert_includes_and_joins_equal( + Author.where('tags.id = tags.id').references(:tags), + [authors(:bob)], :misc_post_first_blue_tags_2 + ) + end + + def test_nested_has_many_through_with_foreign_key_option_on_the_source_reflection_through_reflection + assert_equal [categories(:general)], organizations(:nsa).author_essay_categories + + organizations = Organization.joins(:author_essay_categories). + where('categories.id' => categories(:general).id) + assert_equal [organizations(:nsa)], organizations + + assert_equal categories(:general), organizations(:nsa).author_owned_essay_category + + organizations = Organization.joins(:author_owned_essay_category). + where('categories.id' => categories(:general).id) + assert_equal [organizations(:nsa)], organizations + end + + def test_nested_has_many_through_should_not_be_autosaved + c = Categorization.new + c.author = authors(:david) + c.post_taggings.to_a + assert !c.post_taggings.empty? + c.save + assert !c.post_taggings.empty? + end + + private + + def assert_includes_and_joins_equal(query, expected, association) + actual = assert_queries(1) { query.joins(association).to_a.uniq } + assert_equal expected, actual + + actual = assert_queries(1) { query.includes(association).to_a.uniq } + assert_equal expected, actual + end +end diff --git a/activerecord/test/cases/associations/required_test.rb b/activerecord/test/cases/associations/required_test.rb new file mode 100644 index 0000000000..a6934a056e --- /dev/null +++ b/activerecord/test/cases/associations/required_test.rb @@ -0,0 +1,82 @@ +require "cases/helper" + +class RequiredAssociationsTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + class Parent < ActiveRecord::Base + end + + class Child < ActiveRecord::Base + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table :parents, force: true + @connection.create_table :children, force: true do |t| + t.belongs_to :parent + end + end + + teardown do + @connection.execute("DROP TABLE IF EXISTS parents") + @connection.execute("DROP TABLE IF EXISTS children") + end + + test "belongs_to associations are not required by default" do + model = subclass_of(Child) do + belongs_to :parent, inverse_of: false, + class_name: "RequiredAssociationsTest::Parent" + end + + assert model.new.save + assert model.new(parent: Parent.new).save + end + + test "required belongs_to associations have presence validated" do + model = subclass_of(Child) do + belongs_to :parent, required: true, inverse_of: false, + class_name: "RequiredAssociationsTest::Parent" + end + + record = model.new + assert_not record.save + assert_equal ["Parent can't be blank"], record.errors.full_messages + + record.parent = Parent.new + assert record.save + end + + test "has_one associations are not required by default" do + model = subclass_of(Parent) do + has_one :child, inverse_of: false, + class_name: "RequiredAssociationsTest::Child" + end + + assert model.new.save + assert model.new(child: Child.new).save + end + + test "required has_one associations have presence validated" do + model = subclass_of(Parent) do + has_one :child, required: true, inverse_of: false, + class_name: "RequiredAssociationsTest::Child" + end + + record = model.new + assert_not record.save + assert_equal ["Child can't be blank"], record.errors.full_messages + + record.child = Child.new + assert record.save + end + + private + + def subclass_of(klass, &block) + subclass = Class.new(klass, &block) + def subclass.name + superclass.name + end + subclass + end +end diff --git a/activerecord/test/cases/associations_test.rb b/activerecord/test/cases/associations_test.rb new file mode 100644 index 0000000000..f663b5490c --- /dev/null +++ b/activerecord/test/cases/associations_test.rb @@ -0,0 +1,353 @@ +require "cases/helper" +require 'models/computer' +require 'models/developer' +require 'models/project' +require 'models/company' +require 'models/categorization' +require 'models/category' +require 'models/post' +require 'models/author' +require 'models/comment' +require 'models/tag' +require 'models/tagging' +require 'models/person' +require 'models/reader' +require 'models/parrot' +require 'models/ship_part' +require 'models/ship' +require 'models/liquid' +require 'models/molecule' +require 'models/electron' +require 'models/man' +require 'models/interest' + +class AssociationsTest < ActiveRecord::TestCase + fixtures :accounts, :companies, :developers, :projects, :developers_projects, + :computers, :people, :readers + + def test_eager_loading_should_not_change_count_of_children + liquid = Liquid.create(:name => 'salty') + molecule = liquid.molecules.create(:name => 'molecule_1') + molecule.electrons.create(:name => 'electron_1') + molecule.electrons.create(:name => 'electron_2') + + liquids = Liquid.includes(:molecules => :electrons).references(:molecules).where('molecules.id is not null') + assert_equal 1, liquids[0].molecules.length + end + + def test_clear_association_cache_stored + firm = Firm.find(1) + assert_kind_of Firm, firm + + firm.clear_association_cache + assert_equal Firm.find(1).clients.collect{ |x| x.name }.sort, firm.clients.collect{ |x| x.name }.sort + end + + def test_clear_association_cache_new_record + firm = Firm.new + client_stored = Client.find(3) + client_new = Client.new + client_new.name = "The Joneses" + clients = [ client_stored, client_new ] + + firm.clients << clients + assert_equal clients.map(&:name).to_set, firm.clients.map(&:name).to_set + + firm.clear_association_cache + assert_equal clients.map(&:name).to_set, firm.clients.map(&:name).to_set + end + + def test_loading_the_association_target_should_keep_child_records_marked_for_destruction + ship = Ship.create!(:name => "The good ship Dollypop") + part = ship.parts.create!(:name => "Mast") + part.mark_for_destruction + ship.parts.send(:load_target) + assert ship.parts[0].marked_for_destruction? + end + + def test_loading_the_association_target_should_load_most_recent_attributes_for_child_records_marked_for_destruction + ship = Ship.create!(:name => "The good ship Dollypop") + part = ship.parts.create!(:name => "Mast") + part.mark_for_destruction + ShipPart.find(part.id).update_columns(name: 'Deck') + ship.parts.send(:load_target) + assert_equal 'Deck', ship.parts[0].name + end + + + def test_include_with_order_works + assert_nothing_raised {Account.all.merge!(:order => 'id', :includes => :firm).first} + assert_nothing_raised {Account.all.merge!(:order => :id, :includes => :firm).first} + end + + def test_bad_collection_keys + assert_raise(ArgumentError, 'ActiveRecord should have barked on bad collection keys') do + Class.new(ActiveRecord::Base).has_many(:wheels, :name => 'wheels') + end + end + + def test_should_construct_new_finder_sql_after_create + person = Person.new :first_name => 'clark' + assert_equal [], person.readers.to_a + person.save! + reader = Reader.create! :person => person, :post => Post.new(:title => "foo", :body => "bar") + assert person.readers.find(reader.id) + end + + def test_force_reload + firm = Firm.new("name" => "A New Firm, Inc") + firm.save + firm.clients.each {} # forcing to load all clients + assert firm.clients.empty?, "New firm shouldn't have client objects" + assert_equal 0, firm.clients.size, "New firm should have 0 clients" + + client = Client.new("name" => "TheClient.com", "firm_id" => firm.id) + client.save + + assert firm.clients.empty?, "New firm should have cached no client objects" + assert_equal 0, firm.clients.size, "New firm should have cached 0 clients count" + + assert !firm.clients(true).empty?, "New firm should have reloaded client objects" + assert_equal 1, firm.clients(true).size, "New firm should have reloaded clients count" + end + + def test_using_limitable_reflections_helper + using_limitable_reflections = lambda { |reflections| Tagging.all.send :using_limitable_reflections?, reflections } + belongs_to_reflections = [Tagging.reflect_on_association(:tag), Tagging.reflect_on_association(:super_tag)] + has_many_reflections = [Tag.reflect_on_association(:taggings), Developer.reflect_on_association(:projects)] + mixed_reflections = (belongs_to_reflections + has_many_reflections).uniq + assert using_limitable_reflections.call(belongs_to_reflections), "Belong to associations are limitable" + assert !using_limitable_reflections.call(has_many_reflections), "All has many style associations are not limitable" + assert !using_limitable_reflections.call(mixed_reflections), "No collection associations (has many style) should pass" + end + + def test_force_reload_is_uncached + firm = Firm.create!("name" => "A New Firm, Inc") + Client.create!("name" => "TheClient.com", :firm => firm) + ActiveRecord::Base.cache do + firm.clients.each {} + assert_queries(0) { assert_not_nil firm.clients.each {} } + assert_queries(1) { assert_not_nil firm.clients(true).each {} } + end + end + + def test_association_with_references + firm = companies(:first_firm) + assert_equal ['foo'], firm.association_with_references.references_values + end + +end + +class AssociationProxyTest < ActiveRecord::TestCase + fixtures :authors, :posts, :categorizations, :categories, :developers, :projects, :developers_projects + + def test_push_does_not_load_target + david = authors(:david) + + david.posts << (post = Post.new(:title => "New on Edge", :body => "More cool stuff!")) + assert !david.posts.loaded? + assert david.posts.include?(post) + end + + def test_push_has_many_through_does_not_load_target + david = authors(:david) + + david.categories << categories(:technology) + assert !david.categories.loaded? + assert david.categories.include?(categories(:technology)) + end + + def test_push_followed_by_save_does_not_load_target + david = authors(:david) + + david.posts << (post = Post.new(:title => "New on Edge", :body => "More cool stuff!")) + assert !david.posts.loaded? + david.save + assert !david.posts.loaded? + assert david.posts.include?(post) + end + + def test_push_does_not_lose_additions_to_new_record + josh = Author.new(:name => "Josh") + josh.posts << Post.new(:title => "New on Edge", :body => "More cool stuff!") + assert josh.posts.loaded? + assert_equal 1, josh.posts.size + end + + def test_append_behaves_like_push + josh = Author.new(:name => "Josh") + josh.posts.append Post.new(:title => "New on Edge", :body => "More cool stuff!") + assert josh.posts.loaded? + assert_equal 1, josh.posts.size + end + + def test_prepend_is_not_defined + josh = Author.new(:name => "Josh") + assert_raises(NoMethodError) { josh.posts.prepend Post.new } + end + + def test_save_on_parent_does_not_load_target + david = developers(:david) + + assert !david.projects.loaded? + david.update_columns(created_at: Time.now) + assert !david.projects.loaded? + end + + def test_inspect_does_not_reload_a_not_yet_loaded_target + andreas = Developer.new :name => 'Andreas', :log => 'new developer added' + assert !andreas.audit_logs.loaded? + assert_match(/message: "new developer added"/, andreas.audit_logs.inspect) + end + + def test_save_on_parent_saves_children + developer = Developer.create :name => "Bryan", :salary => 50_000 + assert_equal 1, developer.reload.audit_logs.size + end + + def test_create_via_association_with_block + post = authors(:david).posts.create(:title => "New on Edge") {|p| p.body = "More cool stuff!"} + assert_equal post.title, "New on Edge" + assert_equal post.body, "More cool stuff!" + end + + def test_create_with_bang_via_association_with_block + post = authors(:david).posts.create!(:title => "New on Edge") {|p| p.body = "More cool stuff!"} + assert_equal post.title, "New on Edge" + assert_equal post.body, "More cool stuff!" + end + + def test_reload_returns_association + david = developers(:david) + assert_nothing_raised do + assert_equal david.projects, david.projects.reload.reload + end + end + + def test_proxy_association_accessor + david = developers(:david) + assert_equal david.association(:projects), david.projects.proxy_association + end + + def test_scoped_allows_conditions + assert developers(:david).projects.merge!(where: 'foo').where_values.include?('foo') + end + + test "getting a scope from an association" do + david = developers(:david) + + assert david.projects.scope.is_a?(ActiveRecord::Relation) + assert_equal david.projects, david.projects.scope + end + + test "proxy object is cached" do + david = developers(:david) + assert david.projects.equal?(david.projects) + end + + test "inverses get set of subsets of the association" do + man = Man.create + man.interests.create + + man = Man.find(man.id) + + assert_queries(1) do + assert_equal man, man.interests.where("1=1").first.man + end + end + + def test_reset_unloads_target + david = authors(:david) + david.posts.reload + + assert david.posts.loaded? + david.posts.reset + assert !david.posts.loaded? + end +end + +class OverridingAssociationsTest < ActiveRecord::TestCase + class DifferentPerson < ActiveRecord::Base; end + + class PeopleList < ActiveRecord::Base + has_and_belongs_to_many :has_and_belongs_to_many, :before_add => :enlist + has_many :has_many, :before_add => :enlist + belongs_to :belongs_to + has_one :has_one + end + + class DifferentPeopleList < PeopleList + # Different association with the same name, callbacks should be omitted here. + has_and_belongs_to_many :has_and_belongs_to_many, :class_name => 'DifferentPerson' + has_many :has_many, :class_name => 'DifferentPerson' + belongs_to :belongs_to, :class_name => 'DifferentPerson' + has_one :has_one, :class_name => 'DifferentPerson' + end + + def test_habtm_association_redefinition_callbacks_should_differ_and_not_inherited + # redeclared association on AR descendant should not inherit callbacks from superclass + callbacks = PeopleList.before_add_for_has_and_belongs_to_many + assert_equal(1, callbacks.length) + callbacks = DifferentPeopleList.before_add_for_has_and_belongs_to_many + assert_equal([], callbacks) + end + + def test_has_many_association_redefinition_callbacks_should_differ_and_not_inherited + # redeclared association on AR descendant should not inherit callbacks from superclass + callbacks = PeopleList.before_add_for_has_many + assert_equal(1, callbacks.length) + callbacks = DifferentPeopleList.before_add_for_has_many + assert_equal([], callbacks) + end + + def test_habtm_association_redefinition_reflections_should_differ_and_not_inherited + assert_not_equal( + PeopleList.reflect_on_association(:has_and_belongs_to_many), + DifferentPeopleList.reflect_on_association(:has_and_belongs_to_many) + ) + end + + def test_has_many_association_redefinition_reflections_should_differ_and_not_inherited + assert_not_equal( + PeopleList.reflect_on_association(:has_many), + DifferentPeopleList.reflect_on_association(:has_many) + ) + end + + def test_belongs_to_association_redefinition_reflections_should_differ_and_not_inherited + assert_not_equal( + PeopleList.reflect_on_association(:belongs_to), + DifferentPeopleList.reflect_on_association(:belongs_to) + ) + end + + def test_has_one_association_redefinition_reflections_should_differ_and_not_inherited + assert_not_equal( + PeopleList.reflect_on_association(:has_one), + DifferentPeopleList.reflect_on_association(:has_one) + ) + end + + def test_requires_symbol_argument + assert_raises ArgumentError do + Class.new(Post) do + belongs_to "author" + end + end + end +end + +class GeneratedMethodsTest < ActiveRecord::TestCase + fixtures :developers, :computers, :posts, :comments + def test_association_methods_override_attribute_methods_of_same_name + assert_equal(developers(:david), computers(:workstation).developer) + # this next line will fail if the attribute methods module is generated lazily + # after the association methods module is generated + assert_equal(developers(:david), computers(:workstation).developer) + assert_equal(developers(:david).id, computers(:workstation)[:developer]) + end + + def test_model_method_overrides_association_method + assert_equal(comments(:greetings).body, posts(:welcome).first_comment) + end +end diff --git a/activerecord/test/cases/attribute_decorators_test.rb b/activerecord/test/cases/attribute_decorators_test.rb new file mode 100644 index 0000000000..cbc2c4e5d7 --- /dev/null +++ b/activerecord/test/cases/attribute_decorators_test.rb @@ -0,0 +1,124 @@ +require 'cases/helper' + +module ActiveRecord + class AttributeDecoratorsTest < ActiveRecord::TestCase + class Model < ActiveRecord::Base + self.table_name = 'attribute_decorators_model' + end + + class StringDecorator < SimpleDelegator + def initialize(delegate, decoration = "decorated!") + @decoration = decoration + super(delegate) + end + + def type_cast_from_user(value) + "#{super} #{@decoration}" + end + + alias type_cast_from_database type_cast_from_user + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table :attribute_decorators_model, force: true do |t| + t.string :a_string + end + end + + teardown do + return unless @connection + @connection.execute 'DROP TABLE IF EXISTS attribute_decorators_model' + Model.attribute_type_decorations.clear + Model.reset_column_information + end + + test "attributes can be decorated" do + model = Model.new(a_string: 'Hello') + assert_equal 'Hello', model.a_string + + Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) } + + model = Model.new(a_string: 'Hello') + assert_equal 'Hello decorated!', model.a_string + end + + test "decoration does not eagerly load existing columns" do + assert_no_queries do + Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) } + end + end + + test "undecorated columns are not touched" do + Model.attribute :another_string, Type::String.new, default: 'something or other' + Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) } + + assert_equal 'something or other', Model.new.another_string + end + + test "decorators can be chained" do + Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) } + Model.decorate_attribute_type(:a_string, :other) { |t| StringDecorator.new(t) } + + model = Model.new(a_string: 'Hello!') + + assert_equal 'Hello! decorated! decorated!', model.a_string + end + + test "decoration of the same type multiple times is idempotent" do + Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) } + Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) } + + model = Model.new(a_string: 'Hello') + assert_equal 'Hello decorated!', model.a_string + end + + test "decorations occur in order of declaration" do + Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) } + Model.decorate_attribute_type(:a_string, :other) do |type| + StringDecorator.new(type, 'decorated again!') + end + + model = Model.new(a_string: 'Hello!') + + assert_equal 'Hello! decorated! decorated again!', model.a_string + end + + test "decorating attributes does not modify parent classes" do + Model.attribute :another_string, Type::String.new, default: 'whatever' + Model.decorate_attribute_type(:a_string, :test) { |t| StringDecorator.new(t) } + child_class = Class.new(Model) + child_class.decorate_attribute_type(:another_string, :test) { |t| StringDecorator.new(t) } + child_class.decorate_attribute_type(:a_string, :other) { |t| StringDecorator.new(t) } + + model = Model.new(a_string: 'Hello!') + child = child_class.new(a_string: 'Hello!') + + assert_equal 'Hello! decorated!', model.a_string + assert_equal 'whatever', model.another_string + assert_equal 'Hello! decorated! decorated!', child.a_string + assert_equal 'whatever decorated!', child.another_string + end + + class Multiplier < SimpleDelegator + def type_cast_from_user(value) + return if value.nil? + value * 2 + end + alias type_cast_from_database type_cast_from_user + end + + test "decorating with a proc" do + Model.attribute :an_int, Type::Integer.new + type_is_integer = proc { |_, type| type.type == :integer } + Model.decorate_matching_attribute_types type_is_integer, :multiplier do |type| + Multiplier.new(type) + end + + model = Model.new(a_string: 'whatever', an_int: 1) + + assert_equal 'whatever', model.a_string + assert_equal 2, model.an_int + end + end +end diff --git a/activerecord/test/cases/attribute_methods/read_test.rb b/activerecord/test/cases/attribute_methods/read_test.rb new file mode 100644 index 0000000000..4741ee8799 --- /dev/null +++ b/activerecord/test/cases/attribute_methods/read_test.rb @@ -0,0 +1,59 @@ +require "cases/helper" +require 'thread' + +module ActiveRecord + module AttributeMethods + class ReadTest < ActiveRecord::TestCase + class FakeColumn < Struct.new(:name) + def type; :integer; end + end + + def setup + @klass = Class.new do + def self.superclass; Base; end + def self.base_class; self; end + def self.decorate_matching_attribute_types(*); end + + include ActiveRecord::AttributeMethods + + def self.column_names + %w{ one two three } + end + + def self.primary_key + end + + def self.columns + column_names.map { FakeColumn.new(name) } + end + + def self.columns_hash + Hash[column_names.map { |name| + [name, FakeColumn.new(name)] + }] + end + end + end + + def test_define_attribute_methods + instance = @klass.new + + @klass.column_names.each do |name| + assert !instance.methods.map(&:to_s).include?(name) + end + + @klass.define_attribute_methods + + @klass.column_names.each do |name| + assert instance.methods.map(&:to_s).include?(name), "#{name} is not defined" + end + end + + def test_attribute_methods_generated? + assert_not @klass.method_defined?(:one) + @klass.define_attribute_methods + assert @klass.method_defined?(:one) + end + end + end +end diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb new file mode 100644 index 0000000000..ab67cf4085 --- /dev/null +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -0,0 +1,906 @@ +require "cases/helper" +require 'models/minimalistic' +require 'models/developer' +require 'models/auto_id' +require 'models/boolean' +require 'models/computer' +require 'models/topic' +require 'models/company' +require 'models/category' +require 'models/reply' +require 'models/contact' +require 'models/keyboard' + +class AttributeMethodsTest < ActiveRecord::TestCase + include InTimeZone + + fixtures :topics, :developers, :companies, :computers + + def setup + @old_matchers = ActiveRecord::Base.send(:attribute_method_matchers).dup + @target = Class.new(ActiveRecord::Base) + @target.table_name = 'topics' + end + + teardown do + ActiveRecord::Base.send(:attribute_method_matchers).clear + ActiveRecord::Base.send(:attribute_method_matchers).concat(@old_matchers) + end + + def test_attribute_for_inspect + t = topics(:first) + t.title = "The First Topic Now Has A Title With\nNewlines And More Than 50 Characters" + + assert_equal %("#{t.written_on.to_s(:db)}"), t.attribute_for_inspect(:written_on) + assert_equal '"The First Topic Now Has A Title With\nNewlines And ..."', t.attribute_for_inspect(:title) + end + + def test_attribute_present + t = Topic.new + t.title = "hello there!" + t.written_on = Time.now + t.author_name = "" + assert t.attribute_present?("title") + assert t.attribute_present?("written_on") + assert !t.attribute_present?("content") + assert !t.attribute_present?("author_name") + end + + def test_attribute_present_with_booleans + b1 = Boolean.new + b1.value = false + assert b1.attribute_present?(:value) + + b2 = Boolean.new + b2.value = true + assert b2.attribute_present?(:value) + + b3 = Boolean.new + assert !b3.attribute_present?(:value) + + b4 = Boolean.new + b4.value = false + b4.save! + assert Boolean.find(b4.id).attribute_present?(:value) + end + + def test_caching_nil_primary_key + klass = Class.new(Minimalistic) + klass.expects(:reset_primary_key).returns(nil).once + 2.times { klass.primary_key } + end + + def test_attribute_keys_on_new_instance + t = Topic.new + assert_equal nil, t.title, "The topics table has a title column, so it should be nil" + assert_raise(NoMethodError) { t.title2 } + end + + def test_boolean_attributes + assert !Topic.find(1).approved? + assert Topic.find(2).approved? + end + + def test_set_attributes + topic = Topic.find(1) + topic.attributes = { "title" => "Budget", "author_name" => "Jason" } + topic.save + assert_equal("Budget", topic.title) + assert_equal("Jason", topic.author_name) + assert_equal(topics(:first).author_email_address, Topic.find(1).author_email_address) + end + + def test_set_attributes_without_hash + topic = Topic.new + assert_raise(ArgumentError) { topic.attributes = '' } + end + + def test_integers_as_nil + test = AutoId.create('value' => '') + assert_nil AutoId.find(test.id).value + end + + def test_set_attributes_with_block + topic = Topic.new do |t| + t.title = "Budget" + t.author_name = "Jason" + end + + assert_equal("Budget", topic.title) + assert_equal("Jason", topic.author_name) + end + + def test_respond_to? + topic = Topic.find(1) + assert_respond_to topic, "title" + assert_respond_to topic, "title?" + assert_respond_to topic, "title=" + assert_respond_to topic, :title + assert_respond_to topic, :title? + assert_respond_to topic, :title= + assert_respond_to topic, "author_name" + assert_respond_to topic, "attribute_names" + assert !topic.respond_to?("nothingness") + assert !topic.respond_to?(:nothingness) + end + + def test_respond_to_with_custom_primary_key + keyboard = Keyboard.create + assert_not_nil keyboard.key_number + assert_equal keyboard.key_number, keyboard.id + assert keyboard.respond_to?('key_number') + assert keyboard.respond_to?('id') + end + + def test_id_before_type_cast_with_custom_primary_key + keyboard = Keyboard.create + keyboard.key_number = '10' + assert_equal '10', keyboard.id_before_type_cast + assert_equal nil, keyboard.read_attribute_before_type_cast('id') + assert_equal '10', keyboard.read_attribute_before_type_cast('key_number') + assert_equal '10', keyboard.read_attribute_before_type_cast(:key_number) + end + + # Syck calls respond_to? before actually calling initialize + def test_respond_to_with_allocated_object + klass = Class.new(ActiveRecord::Base) do + self.table_name = 'topics' + end + + topic = klass.allocate + assert !topic.respond_to?("nothingness") + assert !topic.respond_to?(:nothingness) + assert_respond_to topic, "title" + assert_respond_to topic, :title + end + + # IRB inspects the return value of "MyModel.allocate". + def test_allocated_object_can_be_inspected + topic = Topic.allocate + assert_equal "#<Topic not initialized>", topic.inspect + end + + def test_array_content + topic = Topic.new + topic.content = %w( one two three ) + topic.save + + assert_equal(%w( one two three ), Topic.find(topic.id).content) + end + + def test_read_attributes_before_type_cast + category = Category.new({:name=>"Test category", :type => nil}) + category_attrs = {"name"=>"Test category", "id" => nil, "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 = Boolean.create({ "value" => false }) + if RUBY_PLATFORM =~ /java/ + # JRuby will return the value before typecast as string + assert_equal "0", bool.reload.attributes_before_type_cast["value"] + else + assert_equal 0, bool.reload.attributes_before_type_cast["value"] + end + end + end + + def test_read_attributes_before_type_cast_on_datetime + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + + record.written_on = "345643456" + assert_equal "345643456", record.written_on_before_type_cast + assert_equal nil, record.written_on + + record.written_on = "2009-10-11 12:13:14" + assert_equal "2009-10-11 12:13:14", record.written_on_before_type_cast + assert_equal Time.zone.parse("2009-10-11 12:13:14"), record.written_on + assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone + end + end + + def test_read_attributes_after_type_cast_on_datetime + tz = "Pacific Time (US & Canada)" + + in_time_zone tz do + record = @target.new + + date_string = "2011-03-24" + time = Time.zone.parse date_string + + record.written_on = date_string + assert_equal date_string, record.written_on_before_type_cast + assert_equal time, record.written_on + assert_equal ActiveSupport::TimeZone[tz], record.written_on.time_zone + + record.save + record.reload + + assert_equal time, record.written_on + end + end + + def test_hash_content + topic = Topic.new + topic.content = { "one" => 1, "two" => 2 } + topic.save + + assert_equal 2, Topic.find(topic.id).content["two"] + + topic.content_will_change! + topic.content["three"] = 3 + topic.save + + assert_equal 3, Topic.find(topic.id).content["three"] + end + + def test_update_array_content + topic = Topic.new + topic.content = %w( one two three ) + + topic.content.push "four" + assert_equal(%w( one two three four ), topic.content) + + topic.save + + topic = Topic.find(topic.id) + topic.content << "five" + assert_equal(%w( one two three four five ), topic.content) + end + + def test_case_sensitive_attributes_hash + # DB2 is not case-sensitive + return true if current_adapter?(:DB2Adapter) + + assert_equal @loaded_fixtures['computers']['workstation'].to_hash, Computer.first.attributes + end + + def test_attributes_without_primary_key + klass = Class.new(ActiveRecord::Base) do + self.table_name = 'developers_projects' + end + + assert_equal klass.column_names, klass.new.attributes.keys + assert_not klass.new.attributes.key?('id') + end + + def test_hashes_not_mangled + new_topic = { :title => "New Topic" } + new_topic_values = { :title => "AnotherTopic" } + + topic = Topic.new(new_topic) + assert_equal new_topic[:title], topic.title + + topic.attributes= new_topic_values + assert_equal new_topic_values[:title], topic.title + end + + def test_create_through_factory + topic = Topic.create("title" => "New Topic") + topicReloaded = Topic.find(topic.id) + assert_equal(topic, topicReloaded) + end + + def test_write_attribute + topic = Topic.new + topic.send(:write_attribute, :title, "Still another topic") + assert_equal "Still another topic", topic.title + + topic[:title] = "Still another topic: part 2" + assert_equal "Still another topic: part 2", topic.title + + topic.send(:write_attribute, "title", "Still another topic: part 3") + assert_equal "Still another topic: part 3", topic.title + + topic["title"] = "Still another topic: part 4" + assert_equal "Still another topic: part 4", topic.title + end + + def test_read_attribute + topic = Topic.new + topic.title = "Don't change the topic" + assert_equal "Don't change the topic", topic.read_attribute("title") + assert_equal "Don't change the topic", topic["title"] + + assert_equal "Don't change the topic", topic.read_attribute(:title) + assert_equal "Don't change the topic", topic[:title] + end + + def test_read_attribute_raises_missing_attribute_error_when_not_exists + computer = Computer.select('id').first + assert_raises(ActiveModel::MissingAttributeError) { computer[:developer] } + assert_raises(ActiveModel::MissingAttributeError) { computer[:extendedWarranty] } + assert_raises(ActiveModel::MissingAttributeError) { computer[:no_column_exists] = 'Hello!' } + assert_nothing_raised { computer[:developer] = 'Hello!' } + end + + def test_read_attribute_when_false + topic = topics(:first) + topic.approved = false + assert !topic.approved?, "approved should be false" + topic.approved = "false" + assert !topic.approved?, "approved should be false" + end + + def test_read_attribute_when_true + topic = topics(:first) + topic.approved = true + assert topic.approved?, "approved should be true" + topic.approved = "true" + assert topic.approved?, "approved should be true" + end + + def test_read_write_boolean_attribute + topic = Topic.new + topic.approved = "false" + assert !topic.approved?, "approved should be false" + + topic.approved = "false" + assert !topic.approved?, "approved should be false" + + topic.approved = "true" + assert topic.approved?, "approved should be true" + + topic.approved = "true" + assert topic.approved?, "approved should be true" + end + + def test_overridden_write_attribute + topic = Topic.new + def topic.write_attribute(attr_name, value) + super(attr_name, value.downcase) + end + + topic.send(:write_attribute, :title, "Yet another topic") + assert_equal "yet another topic", topic.title + + topic[:title] = "Yet another topic: part 2" + assert_equal "yet another topic: part 2", topic.title + + topic.send(:write_attribute, "title", "Yet another topic: part 3") + assert_equal "yet another topic: part 3", topic.title + + topic["title"] = "Yet another topic: part 4" + assert_equal "yet another topic: part 4", topic.title + end + + def test_overridden_read_attribute + topic = Topic.new + topic.title = "Stop changing the topic" + def topic.read_attribute(attr_name) + super(attr_name).upcase + end + + assert_equal "STOP CHANGING THE TOPIC", topic.read_attribute("title") + assert_equal "STOP CHANGING THE TOPIC", topic["title"] + + assert_equal "STOP CHANGING THE TOPIC", topic.read_attribute(:title) + assert_equal "STOP CHANGING THE TOPIC", topic[:title] + end + + def test_read_overridden_attribute + topic = Topic.new(:title => 'a') + def topic.title() 'b' end + assert_equal 'a', topic[:title] + end + + def test_query_attribute_string + [nil, "", " "].each do |value| + assert_equal false, Topic.new(:author_name => value).author_name? + end + + assert_equal true, Topic.new(:author_name => "Name").author_name? + end + + def test_query_attribute_number + [nil, 0, "0"].each do |value| + assert_equal false, Developer.new(:salary => value).salary? + end + + assert_equal true, Developer.new(:salary => 1).salary? + assert_equal true, Developer.new(:salary => "1").salary? + end + + def test_query_attribute_boolean + [nil, "", false, "false", "f", 0].each do |value| + assert_equal false, Topic.new(:approved => value).approved? + end + + [true, "true", "1", 1].each do |value| + assert_equal true, Topic.new(:approved => value).approved? + end + end + + def test_query_attribute_with_custom_fields + object = Company.find_by_sql(<<-SQL).first + SELECT c1.*, c2.type as string_value, c2.rating as int_value + FROM companies c1, companies c2 + WHERE c1.firm_id = c2.id + AND c1.id = 2 + SQL + + assert_equal "Firm", object.string_value + assert object.string_value? + + object.string_value = " " + assert !object.string_value? + + assert_equal 1, object.int_value.to_i + assert object.int_value? + + object.int_value = "0" + assert !object.int_value? + end + + def test_non_attribute_access_and_assignment + topic = Topic.new + assert !topic.respond_to?("mumbo") + assert_raise(NoMethodError) { topic.mumbo } + assert_raise(NoMethodError) { topic.mumbo = 5 } + end + + def test_undeclared_attribute_method_does_not_affect_respond_to_and_method_missing + topic = @target.new(:title => 'Budget') + assert topic.respond_to?('title') + assert_equal 'Budget', topic.title + assert !topic.respond_to?('title_hello_world') + assert_raise(NoMethodError) { topic.title_hello_world } + end + + def test_declared_prefixed_attribute_method_affects_respond_to_and_method_missing + topic = @target.new(:title => 'Budget') + %w(default_ title_).each do |prefix| + @target.class_eval "def #{prefix}attribute(*args) args end" + @target.attribute_method_prefix prefix + + meth = "#{prefix}title" + assert topic.respond_to?(meth) + assert_equal ['title'], topic.send(meth) + assert_equal ['title', 'a'], topic.send(meth, 'a') + assert_equal ['title', 1, 2, 3], topic.send(meth, 1, 2, 3) + end + end + + def test_declared_suffixed_attribute_method_affects_respond_to_and_method_missing + %w(_default _title_default _it! _candidate= able?).each do |suffix| + @target.class_eval "def attribute#{suffix}(*args) args end" + @target.attribute_method_suffix suffix + topic = @target.new(:title => 'Budget') + + meth = "title#{suffix}" + assert topic.respond_to?(meth) + assert_equal ['title'], topic.send(meth) + assert_equal ['title', 'a'], topic.send(meth, 'a') + assert_equal ['title', 1, 2, 3], topic.send(meth, 1, 2, 3) + end + end + + def test_declared_affixed_attribute_method_affects_respond_to_and_method_missing + [['mark_', '_for_update'], ['reset_', '!'], ['default_', '_value?']].each do |prefix, suffix| + @target.class_eval "def #{prefix}attribute#{suffix}(*args) args end" + @target.attribute_method_affix({ :prefix => prefix, :suffix => suffix }) + topic = @target.new(:title => 'Budget') + + meth = "#{prefix}title#{suffix}" + assert topic.respond_to?(meth) + assert_equal ['title'], topic.send(meth) + assert_equal ['title', 'a'], topic.send(meth, 'a') + assert_equal ['title', 1, 2, 3], topic.send(meth, 1, 2, 3) + end + end + + def test_should_unserialize_attributes_for_frozen_records + myobj = {:value1 => :value2} + topic = Topic.create("content" => myobj) + topic.freeze + assert_equal myobj, topic.content + end + + def test_typecast_attribute_from_select_to_false + Topic.create(:title => 'Budget') + # Oracle does not support boolean expressions in SELECT + if current_adapter?(:OracleAdapter) + topic = Topic.all.merge!(:select => "topics.*, 0 as is_test").first + else + topic = Topic.all.merge!(:select => "topics.*, 1=2 as is_test").first + end + assert !topic.is_test? + end + + def test_typecast_attribute_from_select_to_true + Topic.create(:title => 'Budget') + # Oracle does not support boolean expressions in SELECT + if current_adapter?(:OracleAdapter) + topic = Topic.all.merge!(:select => "topics.*, 1 as is_test").first + else + topic = Topic.all.merge!(:select => "topics.*, 2=2 as is_test").first + end + assert topic.is_test? + end + + def test_raises_dangerous_attribute_error_when_defining_activerecord_method_in_model + %w(save create_or_update).each do |method| + klass = Class.new ActiveRecord::Base + klass.class_eval "def #{method}() 'defined #{method}' end" + assert_raise ActiveRecord::DangerousAttributeError do + klass.instance_method_already_implemented?(method) + end + end + end + + def test_deprecated_cache_attributes + assert_deprecated do + Topic.cache_attributes :replies_count + end + + assert_deprecated do + Topic.cached_attributes + end + + assert_deprecated do + Topic.cache_attribute? :replies_count + end + end + + def test_converted_values_are_returned_after_assignment + developer = Developer.new(name: 1337, salary: "50000") + + assert_equal "50000", developer.salary_before_type_cast + assert_equal 1337, developer.name_before_type_cast + + assert_equal 50000, developer.salary + assert_equal "1337", developer.name + + developer.save! + + assert_equal "50000", developer.salary_before_type_cast + assert_equal 1337, developer.name_before_type_cast + + assert_equal 50000, developer.salary + assert_equal "1337", developer.name + end + + def test_write_nil_to_time_attributes + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + record.written_on = nil + assert_nil record.written_on + end + end + + def test_write_time_to_date_attributes + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + record.last_read = Time.utc(2010, 1, 1, 10) + assert_equal Date.civil(2010, 1, 1), record.last_read + end + end + + def test_time_attributes_are_retrieved_in_current_time_zone + in_time_zone "Pacific Time (US & Canada)" do + utc_time = Time.utc(2008, 1, 1) + record = @target.new + record[:written_on] = utc_time + assert_equal utc_time, record.written_on # record.written on is equal to (i.e., simultaneous with) utc_time + assert_kind_of ActiveSupport::TimeWithZone, record.written_on # but is a TimeWithZone + assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone # and is in the current Time.zone + assert_equal Time.utc(2007, 12, 31, 16), record.written_on.time # and represents time values adjusted accordingly + end + end + + def test_setting_time_zone_aware_attribute_to_utc + in_time_zone "Pacific Time (US & Canada)" do + utc_time = Time.utc(2008, 1, 1) + record = @target.new + record.written_on = utc_time + assert_equal utc_time, record.written_on + assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone + assert_equal Time.utc(2007, 12, 31, 16), record.written_on.time + end + end + + def test_setting_time_zone_aware_attribute_in_other_time_zone + utc_time = Time.utc(2008, 1, 1) + cst_time = utc_time.in_time_zone("Central Time (US & Canada)") + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + record.written_on = cst_time + assert_equal utc_time, record.written_on + assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone + assert_equal Time.utc(2007, 12, 31, 16), record.written_on.time + end + end + + def test_setting_time_zone_aware_read_attribute + utc_time = Time.utc(2008, 1, 1) + cst_time = utc_time.in_time_zone("Central Time (US & Canada)") + in_time_zone "Pacific Time (US & Canada)" do + record = @target.create(:written_on => cst_time).reload + assert_equal utc_time, record[:written_on] + assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record[:written_on].time_zone + assert_equal Time.utc(2007, 12, 31, 16), record[:written_on].time + end + end + + def test_setting_time_zone_aware_attribute_with_string + utc_time = Time.utc(2008, 1, 1) + (-11..13).each do |timezone_offset| + time_string = utc_time.in_time_zone(timezone_offset).to_s + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + record.written_on = time_string + assert_equal Time.zone.parse(time_string), record.written_on + assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone + assert_equal Time.utc(2007, 12, 31, 16), record.written_on.time + end + end + end + + def test_time_zone_aware_attribute_saved + in_time_zone 1 do + record = @target.create(:written_on => '2012-02-20 10:00') + + record.written_on = '2012-02-20 09:00' + record.save + assert_equal Time.zone.local(2012, 02, 20, 9), record.reload.written_on + end + end + + def test_setting_time_zone_aware_attribute_to_blank_string_returns_nil + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + record.written_on = ' ' + assert_nil record.written_on + assert_nil record[:written_on] + end + end + + def test_setting_time_zone_aware_attribute_interprets_time_zone_unaware_string_in_time_zone + time_string = 'Tue Jan 01 00:00:00 2008' + (-11..13).each do |timezone_offset| + in_time_zone timezone_offset do + record = @target.new + record.written_on = time_string + assert_equal Time.zone.parse(time_string), record.written_on + assert_equal ActiveSupport::TimeZone[timezone_offset], record.written_on.time_zone + assert_equal Time.utc(2008, 1, 1), record.written_on.time + end + end + end + + def test_setting_time_zone_aware_attribute_in_current_time_zone + utc_time = Time.utc(2008, 1, 1) + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + record.written_on = utc_time.in_time_zone + assert_equal utc_time, record.written_on + assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.written_on.time_zone + assert_equal Time.utc(2007, 12, 31, 16), record.written_on.time + end + end + + def test_setting_time_zone_conversion_for_attributes_should_write_value_on_class_variable + Topic.skip_time_zone_conversion_for_attributes = [:field_a] + Minimalistic.skip_time_zone_conversion_for_attributes = [:field_b] + + assert_equal [:field_a], Topic.skip_time_zone_conversion_for_attributes + assert_equal [:field_b], Minimalistic.skip_time_zone_conversion_for_attributes + end + + def test_read_attributes_respect_access_control + privatize("title") + + topic = @target.new(:title => "The pros and cons of programming naked.") + assert !topic.respond_to?(:title) + exception = assert_raise(NoMethodError) { topic.title } + assert exception.message.include?("private method") + assert_equal "I'm private", topic.send(:title) + end + + def test_write_attributes_respect_access_control + privatize("title=(value)") + + topic = @target.new + assert !topic.respond_to?(:title=) + exception = assert_raise(NoMethodError) { topic.title = "Pants"} + assert exception.message.include?("private method") + topic.send(:title=, "Very large pants") + end + + def test_question_attributes_respect_access_control + privatize("title?") + + topic = @target.new(:title => "Isaac Newton's pants") + assert !topic.respond_to?(:title?) + exception = assert_raise(NoMethodError) { topic.title? } + assert exception.message.include?("private method") + assert topic.send(:title?) + end + + def test_bulk_update_respects_access_control + privatize("title=(value)") + + assert_raise(ActiveRecord::UnknownAttributeError) { @target.new(:title => "Rants about pants") } + assert_raise(ActiveRecord::UnknownAttributeError) { @target.new.attributes = { :title => "Ants in pants" } } + end + + def test_bulk_update_raise_unknown_attribute_errro + error = assert_raises(ActiveRecord::UnknownAttributeError) { + @target.new(:hello => "world") + } + assert @target, error.record + assert "hello", error.attribute + assert "unknown attribute: hello", error.message + end + + def test_methods_override_in_multi_level_subclass + klass = Class.new(Developer) do + def name + "dev:#{read_attribute(:name)}" + end + end + + 2.times { klass = Class.new klass } + dev = klass.new(name: 'arthurnn') + dev.save! + assert_equal 'dev:arthurnn', dev.reload.name + end + + def test_global_methods_are_overwritten + klass = Class.new(ActiveRecord::Base) do + self.table_name = 'computers' + end + + assert !klass.instance_method_already_implemented?(:system) + computer = klass.new + assert_nil computer.system + end + + def test_global_methods_are_overwritte_when_subclassing + klass = Class.new(ActiveRecord::Base) { self.abstract_class = true } + + subklass = Class.new(klass) do + self.table_name = 'computers' + end + + assert !klass.instance_method_already_implemented?(:system) + assert !subklass.instance_method_already_implemented?(:system) + computer = subklass.new + assert_nil computer.system + end + + def test_instance_method_should_be_defined_on_the_base_class + subklass = Class.new(Topic) + + Topic.define_attribute_methods + + instance = subklass.new + instance.id = 5 + assert_equal 5, instance.id + assert subklass.method_defined?(:id), "subklass is missing id method" + + Topic.undefine_attribute_methods + + assert_equal 5, instance.id + assert subklass.method_defined?(:id), "subklass is missing id method" + end + + def test_read_attribute_with_nil_should_not_asplode + assert_equal nil, Topic.new.read_attribute(nil) + end + + # If B < A, and A defines an accessor for 'foo', we don't want to override + # that by defining a 'foo' method in the generated methods module for B. + # (That module will be inserted between the two, e.g. [B, <GeneratedAttributes>, A].) + def test_inherited_custom_accessors + klass = new_topic_like_ar_class do + self.abstract_class = true + def title; "omg"; end + def title=(val); self.author_name = val; end + end + subklass = Class.new(klass) + [klass, subklass].each(&:define_attribute_methods) + + topic = subklass.find(1) + assert_equal "omg", topic.title + + topic.title = "lol" + assert_equal "lol", topic.author_name + end + + def test_on_the_fly_super_invokable_generated_attribute_methods_via_method_missing + klass = new_topic_like_ar_class do + def title + super + '!' + end + end + + real_topic = topics(:first) + assert_equal real_topic.title + '!', klass.find(real_topic.id).title + end + + def test_on_the_fly_super_invokable_generated_predicate_attribute_methods_via_method_missing + klass = new_topic_like_ar_class do + def title? + !super + end + end + + real_topic = topics(:first) + assert_equal !real_topic.title?, klass.find(real_topic.id).title? + end + + def test_calling_super_when_parent_does_not_define_method_raises_error + klass = new_topic_like_ar_class do + def some_method_that_is_not_on_super + super + end + end + + assert_raise(NoMethodError) do + klass.new.some_method_that_is_not_on_super + end + end + + def test_attribute_method? + assert @target.attribute_method?(:title) + assert @target.attribute_method?(:title=) + assert_not @target.attribute_method?(:wibble) + end + + def test_attribute_method_returns_false_if_table_does_not_exist + @target.table_name = 'wibble' + assert_not @target.attribute_method?(:title) + end + + def test_attribute_names_on_new_record + model = @target.new + + assert_equal @target.column_names, model.attribute_names + end + + def test_attribute_names_on_queried_record + model = @target.last! + + assert_equal @target.column_names, model.attribute_names + end + + def test_attribute_names_with_custom_select + model = @target.select('id').last! + + assert_equal ['id'], model.attribute_names + # Sanity check, make sure other columns exist + assert_not_equal ['id'], @target.column_names + end + + private + + def new_topic_like_ar_class(&block) + klass = Class.new(ActiveRecord::Base) do + self.table_name = 'topics' + class_eval(&block) + end + + assert_empty klass.generated_attribute_methods.instance_methods(false) + klass + end + + def cached_columns + Topic.columns.map(&:name) + end + + def time_related_columns_on_topic + Topic.columns.select { |c| [:time, :date, :datetime, :timestamp].include?(c.type) } + end + + def privatize(method_signature) + @target.class_eval(<<-private_method, __FILE__, __LINE__ + 1) + private + def #{method_signature} + "I'm private" + end + private_method + end +end diff --git a/activerecord/test/cases/attribute_set_test.rb b/activerecord/test/cases/attribute_set_test.rb new file mode 100644 index 0000000000..dc20c3c676 --- /dev/null +++ b/activerecord/test/cases/attribute_set_test.rb @@ -0,0 +1,165 @@ +require 'cases/helper' + +module ActiveRecord + class AttributeSetTest < ActiveRecord::TestCase + test "building a new set from raw attributes" do + builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) + attributes = builder.build_from_database(foo: '1.1', bar: '2.2') + + assert_equal 1, attributes[:foo].value + assert_equal 2.2, attributes[:bar].value + assert_equal :foo, attributes[:foo].name + assert_equal :bar, attributes[:bar].name + end + + test "building with custom types" do + builder = AttributeSet::Builder.new(foo: Type::Float.new) + attributes = builder.build_from_database({ foo: '3.3', bar: '4.4' }, { bar: Type::Integer.new }) + + assert_equal 3.3, attributes[:foo].value + assert_equal 4, attributes[:bar].value + end + + test "[] returns a null object" do + builder = AttributeSet::Builder.new(foo: Type::Float.new) + attributes = builder.build_from_database(foo: '3.3') + + assert_equal '3.3', attributes[:foo].value_before_type_cast + assert_equal nil, attributes[:bar].value_before_type_cast + assert_equal :bar, attributes[:bar].name + end + + test "duping creates a new hash and dups each attribute" do + builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::String.new) + attributes = builder.build_from_database(foo: 1, bar: 'foo') + + # Ensure the type cast value is cached + attributes[:foo].value + attributes[:bar].value + + duped = attributes.dup + duped.write_from_database(:foo, 2) + duped[:bar].value << 'bar' + + assert_equal 1, attributes[:foo].value + assert_equal 2, duped[:foo].value + assert_equal 'foo', attributes[:bar].value + assert_equal 'foobar', duped[:bar].value + end + + test "freezing cloned set does not freeze original" do + attributes = AttributeSet.new({}) + clone = attributes.clone + + clone.freeze + + assert clone.frozen? + assert_not attributes.frozen? + end + + test "to_hash returns a hash of the type cast values" do + builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) + attributes = builder.build_from_database(foo: '1.1', bar: '2.2') + + assert_equal({ foo: 1, bar: 2.2 }, attributes.to_hash) + assert_equal({ foo: 1, bar: 2.2 }, attributes.to_h) + end + + test "values_before_type_cast" do + builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Integer.new) + attributes = builder.build_from_database(foo: '1.1', bar: '2.2') + + assert_equal({ foo: '1.1', bar: '2.2' }, attributes.values_before_type_cast) + end + + test "known columns are built with uninitialized attributes" do + attributes = attributes_with_uninitialized_key + assert attributes[:foo].initialized? + assert_not attributes[:bar].initialized? + end + + test "uninitialized attributes are not included in the attributes hash" do + attributes = attributes_with_uninitialized_key + assert_equal({ foo: 1 }, attributes.to_hash) + end + + test "uninitialized attributes are not included in keys" do + attributes = attributes_with_uninitialized_key + assert_equal [:foo], attributes.keys + end + + test "uninitialized attributes return false for key?" do + attributes = attributes_with_uninitialized_key + assert attributes.key?(:foo) + assert_not attributes.key?(:bar) + end + + test "unknown attributes return false for key?" do + attributes = attributes_with_uninitialized_key + assert_not attributes.key?(:wibble) + end + + test "fetch_value returns the value for the given initialized attribute" do + builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) + attributes = builder.build_from_database(foo: '1.1', bar: '2.2') + + assert_equal 1, attributes.fetch_value(:foo) + assert_equal 2.2, attributes.fetch_value(:bar) + end + + test "fetch_value returns nil for unknown attributes" do + attributes = attributes_with_uninitialized_key + assert_nil attributes.fetch_value(:wibble) + end + + test "fetch_value uses the given block for uninitialized attributes" do + attributes = attributes_with_uninitialized_key + value = attributes.fetch_value(:bar) { |n| n.to_s + '!' } + assert_equal 'bar!', value + end + + test "fetch_value returns nil for uninitialized attributes if no block is given" do + attributes = attributes_with_uninitialized_key + assert_nil attributes.fetch_value(:bar) + end + + class MyType + def type_cast_from_user(value) + return if value.nil? + value + " from user" + end + + def type_cast_from_database(value) + return if value.nil? + value + " from database" + end + end + + test "write_from_database sets the attribute with database typecasting" do + builder = AttributeSet::Builder.new(foo: MyType.new) + attributes = builder.build_from_database + + assert_nil attributes.fetch_value(:foo) + + attributes.write_from_database(:foo, "value") + + assert_equal "value from database", attributes.fetch_value(:foo) + end + + test "write_from_user sets the attribute with user typecasting" do + builder = AttributeSet::Builder.new(foo: MyType.new) + attributes = builder.build_from_database + + assert_nil attributes.fetch_value(:foo) + + attributes.write_from_user(:foo, "value") + + assert_equal "value from user", attributes.fetch_value(:foo) + end + + def attributes_with_uninitialized_key + builder = AttributeSet::Builder.new(foo: Type::Integer.new, bar: Type::Float.new) + builder.build_from_database(foo: '1.1') + end + end +end diff --git a/activerecord/test/cases/attribute_test.rb b/activerecord/test/cases/attribute_test.rb new file mode 100644 index 0000000000..91f6aee931 --- /dev/null +++ b/activerecord/test/cases/attribute_test.rb @@ -0,0 +1,142 @@ +require 'cases/helper' +require 'minitest/mock' + +module ActiveRecord + class AttributeTest < ActiveRecord::TestCase + setup do + @type = Minitest::Mock.new + end + + teardown do + assert @type.verify + end + + test "from_database + read type casts from database" do + @type.expect(:type_cast_from_database, 'type cast from database', ['a value']) + attribute = Attribute.from_database(nil, 'a value', @type) + + type_cast_value = attribute.value + + assert_equal 'type cast from database', type_cast_value + end + + test "from_user + read type casts from user" do + @type.expect(:type_cast_from_user, 'type cast from user', ['a value']) + attribute = Attribute.from_user(nil, 'a value', @type) + + type_cast_value = attribute.value + + assert_equal 'type cast from user', type_cast_value + end + + test "reading memoizes the value" do + @type.expect(:type_cast_from_database, 'from the database', ['whatever']) + attribute = Attribute.from_database(nil, 'whatever', @type) + + type_cast_value = attribute.value + second_read = attribute.value + + assert_equal 'from the database', type_cast_value + assert_same type_cast_value, second_read + end + + test "reading memoizes falsy values" do + @type.expect(:type_cast_from_database, false, ['whatever']) + attribute = Attribute.from_database(nil, 'whatever', @type) + + attribute.value + attribute.value + end + + test "read_before_typecast returns the given value" do + attribute = Attribute.from_database(nil, 'raw value', @type) + + raw_value = attribute.value_before_type_cast + + assert_equal 'raw value', raw_value + end + + test "from_database + read_for_database type casts to and from database" do + @type.expect(:type_cast_from_database, 'read from database', ['whatever']) + @type.expect(:type_cast_for_database, 'ready for database', ['read from database']) + attribute = Attribute.from_database(nil, 'whatever', @type) + + type_cast_for_database = attribute.value_for_database + + assert_equal 'ready for database', type_cast_for_database + end + + test "from_user + read_for_database type casts from the user to the database" do + @type.expect(:type_cast_from_user, 'read from user', ['whatever']) + @type.expect(:type_cast_for_database, 'ready for database', ['read from user']) + attribute = Attribute.from_user(nil, 'whatever', @type) + + type_cast_for_database = attribute.value_for_database + + assert_equal 'ready for database', type_cast_for_database + end + + test "duping dups the value" do + @type.expect(:type_cast_from_database, 'type cast', ['a value']) + attribute = Attribute.from_database(nil, 'a value', @type) + + value_from_orig = attribute.value + value_from_clone = attribute.dup.value + value_from_orig << ' foo' + + assert_equal 'type cast foo', value_from_orig + assert_equal 'type cast', value_from_clone + end + + test "duping does not dup the value if it is not dupable" do + @type.expect(:type_cast_from_database, false, ['a value']) + attribute = Attribute.from_database(nil, 'a value', @type) + + assert_same attribute.value, attribute.dup.value + end + + test "duping does not eagerly type cast if we have not yet type cast" do + attribute = Attribute.from_database(nil, 'a value', @type) + attribute.dup + end + + class MyType + def type_cast_from_user(value) + value + " from user" + end + + def type_cast_from_database(value) + value + " from database" + end + end + + test "with_value_from_user returns a new attribute with the value from the user" do + old = Attribute.from_database(nil, "old", MyType.new) + new = old.with_value_from_user("new") + + assert_equal "old from database", old.value + assert_equal "new from user", new.value + end + + test "with_value_from_database returns a new attribute with the value from the database" do + old = Attribute.from_user(nil, "old", MyType.new) + new = old.with_value_from_database("new") + + assert_equal "old from user", old.value + assert_equal "new from database", new.value + end + + test "uninitialized attributes yield their name if a block is given to value" do + block = proc { |name| name.to_s + "!" } + foo = Attribute.uninitialized(:foo, nil) + bar = Attribute.uninitialized(:bar, nil) + + assert_equal "foo!", foo.value(&block) + assert_equal "bar!", bar.value(&block) + end + + test "uninitialized attributes have no value" do + assert_nil Attribute.uninitialized(:foo, nil).value + end + end +end diff --git a/activerecord/test/cases/attributes_test.rb b/activerecord/test/cases/attributes_test.rb new file mode 100644 index 0000000000..79ef0502cb --- /dev/null +++ b/activerecord/test/cases/attributes_test.rb @@ -0,0 +1,111 @@ +require 'cases/helper' + +class OverloadedType < ActiveRecord::Base + attribute :overloaded_float, Type::Integer.new + attribute :overloaded_string_with_limit, Type::String.new(limit: 50) + attribute :non_existent_decimal, Type::Decimal.new + attribute :string_with_default, Type::String.new, default: 'the overloaded default' +end + +class ChildOfOverloadedType < OverloadedType +end + +class GrandchildOfOverloadedType < ChildOfOverloadedType + attribute :overloaded_float, Type::Float.new +end + +class UnoverloadedType < ActiveRecord::Base + self.table_name = 'overloaded_types' +end + +module ActiveRecord + class CustomPropertiesTest < ActiveRecord::TestCase + def test_overloading_types + data = OverloadedType.new + + data.overloaded_float = "1.1" + data.unoverloaded_float = "1.1" + + assert_equal 1, data.overloaded_float + assert_equal 1.1, data.unoverloaded_float + end + + def test_overloaded_properties_save + data = OverloadedType.new + + data.overloaded_float = "2.2" + data.save! + data.reload + + assert_equal 2, data.overloaded_float + assert_kind_of Fixnum, OverloadedType.last.overloaded_float + assert_equal 2.0, UnoverloadedType.last.overloaded_float + assert_kind_of Float, UnoverloadedType.last.overloaded_float + end + + def test_properties_assigned_in_constructor + data = OverloadedType.new(overloaded_float: '3.3') + + assert_equal 3, data.overloaded_float + end + + def test_overloaded_properties_with_limit + assert_equal 50, OverloadedType.columns_hash['overloaded_string_with_limit'].limit + assert_equal 255, UnoverloadedType.columns_hash['overloaded_string_with_limit'].limit + end + + def test_nonexistent_attribute + data = OverloadedType.new(non_existent_decimal: 1) + + assert_equal BigDecimal.new(1), data.non_existent_decimal + assert_raise ActiveRecord::UnknownAttributeError do + UnoverloadedType.new(non_existent_decimal: 1) + end + end + + def test_changing_defaults + data = OverloadedType.new + unoverloaded_data = UnoverloadedType.new + + assert_equal 'the overloaded default', data.string_with_default + assert_equal 'the original default', unoverloaded_data.string_with_default + end + + def test_children_inherit_custom_properties + data = ChildOfOverloadedType.new(overloaded_float: '4.4') + + assert_equal 4, data.overloaded_float + end + + def test_children_can_override_parents + data = GrandchildOfOverloadedType.new(overloaded_float: '4.4') + + assert_equal 4.4, data.overloaded_float + end + + def test_overloading_properties_does_not_change_column_order + column_names = OverloadedType.column_names + assert_equal %w(id overloaded_float unoverloaded_float overloaded_string_with_limit string_with_default non_existent_decimal), column_names + end + + def test_caches_are_cleared + klass = Class.new(OverloadedType) + + assert_equal 6, klass.columns.length + assert_not klass.columns_hash.key?('wibble') + assert_equal 6, klass.column_types.length + assert_equal 6, klass.column_defaults.length + assert_not klass.column_names.include?('wibble') + assert_equal 5, klass.content_columns.length + + klass.attribute :wibble, Type::Value.new + + assert_equal 7, klass.columns.length + assert klass.columns_hash.key?('wibble') + assert_equal 7, klass.column_types.length + assert_equal 7, klass.column_defaults.length + assert klass.column_names.include?('wibble') + assert_equal 6, klass.content_columns.length + end + end +end diff --git a/activerecord/test/cases/autosave_association_test.rb b/activerecord/test/cases/autosave_association_test.rb new file mode 100644 index 0000000000..09892d50ba --- /dev/null +++ b/activerecord/test/cases/autosave_association_test.rb @@ -0,0 +1,1539 @@ +require 'cases/helper' +require 'models/bird' +require 'models/company' +require 'models/customer' +require 'models/developer' +require 'models/invoice' +require 'models/line_item' +require 'models/order' +require 'models/parrot' +require 'models/person' +require 'models/pirate' +require 'models/post' +require 'models/reader' +require 'models/ship' +require 'models/ship_part' +require 'models/tag' +require 'models/tagging' +require 'models/treasure' +require 'models/eye' +require 'models/electron' +require 'models/molecule' + +class TestAutosaveAssociationsInGeneral < ActiveRecord::TestCase + def test_autosave_validation + person = Class.new(ActiveRecord::Base) { + self.table_name = 'people' + validate :should_be_cool, :on => :create + def self.name; 'Person'; end + + private + + def should_be_cool + unless self.first_name == 'cool' + errors.add :first_name, "not cool" + end + end + } + reference = Class.new(ActiveRecord::Base) { + self.table_name = "references" + def self.name; 'Reference'; end + belongs_to :person, autosave: true, class: person + } + + u = person.create!(first_name: 'cool') + u.update_attributes!(first_name: 'nah') # still valid because validation only applies on 'create' + assert reference.create!(person: u).persisted? + end + + def test_should_not_add_the_same_callbacks_multiple_times_for_has_one + assert_no_difference_when_adding_callbacks_twice_for Pirate, :ship + end + + def test_should_not_add_the_same_callbacks_multiple_times_for_belongs_to + assert_no_difference_when_adding_callbacks_twice_for Ship, :pirate + end + + def test_should_not_add_the_same_callbacks_multiple_times_for_has_many + assert_no_difference_when_adding_callbacks_twice_for Pirate, :birds + end + + def test_should_not_add_the_same_callbacks_multiple_times_for_has_and_belongs_to_many + assert_no_difference_when_adding_callbacks_twice_for Pirate, :parrots + end + + private + + def assert_no_difference_when_adding_callbacks_twice_for(model, association_name) + reflection = model.reflect_on_association(association_name) + assert_no_difference "callbacks_for_model(#{model.name}).length" do + model.send(:add_autosave_association_callbacks, reflection) + end + end + + def callbacks_for_model(model) + model.instance_variables.grep(/_callbacks$/).flat_map do |ivar| + model.instance_variable_get(ivar) + end + end +end + +class TestDefaultAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCase + fixtures :companies, :accounts + + def test_should_save_parent_but_not_invalid_child + firm = Firm.new(:name => 'GlobalMegaCorp') + assert firm.valid? + + firm.build_account_using_primary_key + assert !firm.build_account_using_primary_key.valid? + + assert firm.save + assert !firm.account_using_primary_key.persisted? + end + + def test_save_fails_for_invalid_has_one + firm = Firm.first + assert firm.valid? + + firm.build_account + + assert !firm.account.valid? + assert !firm.valid? + assert !firm.save + assert_equal ["is invalid"], firm.errors["account"] + end + + def test_save_succeeds_for_invalid_has_one_with_validate_false + firm = Firm.first + assert firm.valid? + + firm.build_unvalidated_account + + assert !firm.unvalidated_account.valid? + assert firm.valid? + assert firm.save + end + + def test_build_before_child_saved + firm = Firm.find(1) + + account = firm.build_account("credit_limit" => 1000) + assert_equal account, firm.account + assert !account.persisted? + assert firm.save + assert_equal account, firm.account + assert account.persisted? + end + + def test_build_before_either_saved + firm = Firm.new("name" => "GlobalMegaCorp") + + firm.account = account = Account.new("credit_limit" => 1000) + assert_equal account, firm.account + assert !account.persisted? + assert firm.save + assert_equal account, firm.account + assert account.persisted? + end + + def test_assignment_before_parent_saved + firm = Firm.new("name" => "GlobalMegaCorp") + firm.account = a = Account.find(1) + assert !firm.persisted? + assert_equal a, firm.account + assert firm.save + assert_equal a, firm.account + assert_equal a, firm.account(true) + end + + def test_assignment_before_either_saved + firm = Firm.new("name" => "GlobalMegaCorp") + firm.account = a = Account.new("credit_limit" => 1000) + assert !firm.persisted? + assert !a.persisted? + assert_equal a, firm.account + assert firm.save + assert firm.persisted? + assert a.persisted? + assert_equal a, firm.account + assert_equal a, firm.account(true) + end + + def test_not_resaved_when_unchanged + firm = Firm.all.merge!(:includes => :account).first + firm.name += '-changed' + assert_queries(1) { firm.save! } + + firm = Firm.first + firm.account = Account.first + assert_queries(Firm.partial_writes? ? 0 : 1) { firm.save! } + + firm = Firm.first.dup + firm.account = Account.first + assert_queries(2) { firm.save! } + + firm = Firm.first.dup + firm.account = Account.first.dup + assert_queries(2) { firm.save! } + end + + def test_callbacks_firing_order_on_create + eye = Eye.create(:iris_attributes => {:color => 'honey'}) + assert_equal [true, false], eye.after_create_callbacks_stack + end + + def test_callbacks_firing_order_on_update + eye = Eye.create(iris_attributes: {color: 'honey'}) + eye.update(iris_attributes: {color: 'green'}) + assert_equal [true, false], eye.after_update_callbacks_stack + end + + def test_callbacks_firing_order_on_save + eye = Eye.create(iris_attributes: {color: 'honey'}) + assert_equal [false, false], eye.after_save_callbacks_stack + + eye.update(iris_attributes: {color: 'blue'}) + assert_equal [false, false, false, false], eye.after_save_callbacks_stack + end +end + +class TestDefaultAutosaveAssociationOnABelongsToAssociation < ActiveRecord::TestCase + fixtures :companies, :posts, :tags, :taggings + + def test_should_save_parent_but_not_invalid_child + client = Client.new(:name => 'Joe (the Plumber)') + assert client.valid? + + client.build_firm + assert !client.firm.valid? + + assert client.save + assert !client.firm.persisted? + end + + def test_save_fails_for_invalid_belongs_to + # Oracle saves empty string as NULL therefore :message changed to one space + assert log = AuditLog.create(:developer_id => 0, :message => " ") + + log.developer = Developer.new + assert !log.developer.valid? + assert !log.valid? + assert !log.save + assert_equal ["is invalid"], log.errors["developer"] + end + + def test_save_succeeds_for_invalid_belongs_to_with_validate_false + # Oracle saves empty string as NULL therefore :message changed to one space + assert log = AuditLog.create(:developer_id => 0, :message=> " ") + + log.unvalidated_developer = Developer.new + assert !log.unvalidated_developer.valid? + assert log.valid? + assert log.save + end + + def test_assignment_before_parent_saved + client = Client.first + apple = Firm.new("name" => "Apple") + client.firm = apple + assert_equal apple, client.firm + assert !apple.persisted? + assert client.save + assert apple.save + assert apple.persisted? + assert_equal apple, client.firm + assert_equal apple, client.firm(true) + end + + def test_assignment_before_either_saved + final_cut = Client.new("name" => "Final Cut") + apple = Firm.new("name" => "Apple") + final_cut.firm = apple + assert !final_cut.persisted? + assert !apple.persisted? + assert final_cut.save + assert final_cut.persisted? + assert apple.persisted? + assert_equal apple, final_cut.firm + assert_equal apple, final_cut.firm(true) + end + + def test_store_two_association_with_one_save + num_orders = Order.count + num_customers = Customer.count + order = Order.new + + customer1 = order.billing = Customer.new + customer2 = order.shipping = Customer.new + assert order.save + assert_equal customer1, order.billing + assert_equal customer2, order.shipping + + order.reload + + assert_equal customer1, order.billing + assert_equal customer2, order.shipping + + assert_equal num_orders + 1, Order.count + assert_equal num_customers + 2, Customer.count + end + + def test_store_association_in_two_relations_with_one_save + num_orders = Order.count + num_customers = Customer.count + order = Order.new + + customer = order.billing = order.shipping = Customer.new + assert order.save + assert_equal customer, order.billing + assert_equal customer, order.shipping + + order.reload + + assert_equal customer, order.billing + assert_equal customer, order.shipping + + assert_equal num_orders + 1, Order.count + assert_equal num_customers + 1, Customer.count + end + + def test_store_association_in_two_relations_with_one_save_in_existing_object + num_orders = Order.count + num_customers = Customer.count + order = Order.create + + customer = order.billing = order.shipping = Customer.new + assert order.save + assert_equal customer, order.billing + assert_equal customer, order.shipping + + order.reload + + assert_equal customer, order.billing + assert_equal customer, order.shipping + + assert_equal num_orders + 1, Order.count + assert_equal num_customers + 1, Customer.count + end + + def test_store_association_in_two_relations_with_one_save_in_existing_object_with_values + num_orders = Order.count + num_customers = Customer.count + order = Order.create + + customer = order.billing = order.shipping = Customer.new + assert order.save + assert_equal customer, order.billing + assert_equal customer, order.shipping + + order.reload + + customer = order.billing = order.shipping = Customer.new + + assert order.save + order.reload + + assert_equal customer, order.billing + assert_equal customer, order.shipping + + assert_equal num_orders + 1, Order.count + assert_equal num_customers + 2, Customer.count + end + + def test_store_association_with_a_polymorphic_relationship + num_tagging = Tagging.count + tags(:misc).create_tagging(:taggable => posts(:thinking)) + assert_equal num_tagging + 1, Tagging.count + end + + def test_build_and_then_save_parent_should_not_reload_target + client = Client.first + apple = client.build_firm(:name => "Apple") + client.save! + assert_no_queries { assert_equal apple, client.firm } + end + + def test_validation_does_not_validate_stale_association_target + valid_developer = Developer.create!(:name => "Dude", :salary => 50_000) + invalid_developer = Developer.new() + + auditlog = AuditLog.new(:message => "foo") + auditlog.developer = invalid_developer + auditlog.developer_id = valid_developer.id + + assert auditlog.valid? + end +end + +class TestDefaultAutosaveAssociationOnAHasManyAssociationWithAcceptsNestedAttributes < ActiveRecord::TestCase + def test_invalid_adding_with_nested_attributes + molecule = Molecule.new + valid_electron = Electron.new(name: 'electron') + invalid_electron = Electron.new + + molecule.electrons = [valid_electron, invalid_electron] + molecule.save + + assert_not invalid_electron.valid? + assert valid_electron.valid? + assert_not molecule.persisted?, 'Molecule should not be persisted when its electrons are invalid' + end + + def test_valid_adding_with_nested_attributes + molecule = Molecule.new + valid_electron = Electron.new(name: 'electron') + + molecule.electrons = [valid_electron] + molecule.save + + assert valid_electron.valid? + assert molecule.persisted? + assert_equal 1, molecule.electrons.count + end +end + +class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCase + fixtures :companies, :people + + def test_invalid_adding + firm = Firm.find(1) + assert !(firm.clients_of_firm << c = Client.new) + assert !c.persisted? + assert !firm.valid? + assert !firm.save + assert !c.persisted? + end + + def test_invalid_adding_before_save + new_firm = Firm.new("name" => "A New Firm, Inc") + new_firm.clients_of_firm.concat([c = Client.new, Client.new("name" => "Apple")]) + assert !c.persisted? + assert !c.valid? + assert !new_firm.valid? + assert !new_firm.save + assert !c.persisted? + assert !new_firm.persisted? + end + + def test_invalid_adding_with_validate_false + firm = Firm.first + client = Client.new + firm.unvalidated_clients_of_firm << client + + assert firm.valid? + assert !client.valid? + assert firm.save + assert !client.persisted? + end + + def test_valid_adding_with_validate_false + no_of_clients = Client.count + + firm = Firm.first + client = Client.new("name" => "Apple") + + assert firm.valid? + assert client.valid? + assert !client.persisted? + + firm.unvalidated_clients_of_firm << client + + assert firm.save + assert client.persisted? + assert_equal no_of_clients + 1, Client.count + end + + def test_invalid_build + new_client = companies(:first_firm).clients_of_firm.build + assert !new_client.persisted? + assert !new_client.valid? + assert_equal new_client, companies(:first_firm).clients_of_firm.last + assert !companies(:first_firm).save + assert !new_client.persisted? + assert_equal 2, companies(:first_firm).clients_of_firm(true).size + end + + def test_adding_before_save + no_of_firms = Firm.count + no_of_clients = Client.count + + new_firm = Firm.new("name" => "A New Firm, Inc") + c = Client.new("name" => "Apple") + + new_firm.clients_of_firm.push Client.new("name" => "Natural Company") + assert_equal 1, new_firm.clients_of_firm.size + new_firm.clients_of_firm << c + assert_equal 2, new_firm.clients_of_firm.size + + assert_equal no_of_firms, Firm.count # Firm was not saved to database. + assert_equal no_of_clients, Client.count # Clients were not saved to database. + assert new_firm.save + assert new_firm.persisted? + assert c.persisted? + assert_equal new_firm, c.firm + assert_equal no_of_firms + 1, Firm.count # Firm was saved to database. + assert_equal no_of_clients + 2, Client.count # Clients were saved to database. + + assert_equal 2, new_firm.clients_of_firm.size + assert_equal 2, new_firm.clients_of_firm(true).size + end + + def test_assign_ids + firm = Firm.new("name" => "Apple") + firm.client_ids = [companies(:first_client).id, companies(:second_client).id] + firm.save + firm.reload + assert_equal 2, firm.clients.length + assert firm.clients.include?(companies(:second_client)) + end + + def test_assign_ids_for_through_a_belongs_to + post = Post.new(:title => "Assigning IDs works!", :body => "You heard it here first, folks!") + post.person_ids = [people(:david).id, people(:michael).id] + post.save + post.reload + assert_equal 2, post.people.length + assert post.people.include?(people(:david)) + end + + def test_build_before_save + company = companies(:first_firm) + new_client = assert_no_queries { company.clients_of_firm.build("name" => "Another Client") } + assert !company.clients_of_firm.loaded? + + company.name += '-changed' + assert_queries(2) { assert company.save } + assert new_client.persisted? + assert_equal 3, company.clients_of_firm(true).size + end + + def test_build_many_before_save + company = companies(:first_firm) + assert_no_queries { company.clients_of_firm.build([{"name" => "Another Client"}, {"name" => "Another Client II"}]) } + + company.name += '-changed' + assert_queries(3) { assert company.save } + assert_equal 4, company.clients_of_firm(true).size + end + + def test_build_via_block_before_save + company = companies(:first_firm) + new_client = assert_no_queries { company.clients_of_firm.build {|client| client.name = "Another Client" } } + assert !company.clients_of_firm.loaded? + + company.name += '-changed' + assert_queries(2) { assert company.save } + assert new_client.persisted? + assert_equal 3, company.clients_of_firm(true).size + end + + def test_build_many_via_block_before_save + company = companies(:first_firm) + assert_no_queries do + company.clients_of_firm.build([{"name" => "Another Client"}, {"name" => "Another Client II"}]) do |client| + client.name = "changed" + end + end + + company.name += '-changed' + assert_queries(3) { assert company.save } + assert_equal 4, company.clients_of_firm(true).size + end + + def test_replace_on_new_object + firm = Firm.new("name" => "New Firm") + firm.clients = [companies(:second_client), Client.new("name" => "New Client")] + assert firm.save + firm.reload + assert_equal 2, firm.clients.length + assert firm.clients.include?(Client.find_by_name("New Client")) + end +end + +class TestDefaultAutosaveAssociationOnNewRecord < ActiveRecord::TestCase + def test_autosave_new_record_on_belongs_to_can_be_disabled_per_relationship + new_account = Account.new("credit_limit" => 1000) + new_firm = Firm.new("name" => "some firm") + + assert !new_firm.persisted? + new_account.firm = new_firm + new_account.save! + + assert new_firm.persisted? + + new_account = Account.new("credit_limit" => 1000) + new_autosaved_firm = Firm.new("name" => "some firm") + + assert !new_autosaved_firm.persisted? + new_account.unautosaved_firm = new_autosaved_firm + new_account.save! + + assert !new_autosaved_firm.persisted? + end + + def test_autosave_new_record_on_has_one_can_be_disabled_per_relationship + firm = Firm.new("name" => "some firm") + account = Account.new("credit_limit" => 1000) + + assert !account.persisted? + firm.account = account + firm.save! + + assert account.persisted? + + firm = Firm.new("name" => "some firm") + account = Account.new("credit_limit" => 1000) + + firm.unautosaved_account = account + + assert !account.persisted? + firm.unautosaved_account = account + firm.save! + + assert !account.persisted? + end + + def test_autosave_new_record_on_has_many_can_be_disabled_per_relationship + firm = Firm.new("name" => "some firm") + account = Account.new("credit_limit" => 1000) + + assert !account.persisted? + firm.accounts << account + + firm.save! + assert account.persisted? + + firm = Firm.new("name" => "some firm") + account = Account.new("credit_limit" => 1000) + + assert !account.persisted? + firm.unautosaved_accounts << account + + firm.save! + assert !account.persisted? + end +end + +class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + setup do + @pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?") + @ship = @pirate.create_ship(:name => 'Nights Dirty Lightning') + end + + teardown do + # We are running without transactional fixtures and need to cleanup. + Bird.delete_all + Parrot.delete_all + @ship.delete + @pirate.delete + end + + # reload + def test_a_marked_for_destruction_record_should_not_be_be_marked_after_reload + @pirate.mark_for_destruction + @pirate.ship.mark_for_destruction + + assert !@pirate.reload.marked_for_destruction? + assert !@pirate.ship.reload.marked_for_destruction? + end + + # has_one + def test_should_destroy_a_child_association_as_part_of_the_save_transaction_if_it_was_marked_for_destroyal + assert !@pirate.ship.marked_for_destruction? + + @pirate.ship.mark_for_destruction + id = @pirate.ship.id + + assert @pirate.ship.marked_for_destruction? + assert Ship.find_by_id(id) + + @pirate.save + assert_nil @pirate.reload.ship + assert_nil Ship.find_by_id(id) + end + + def test_should_skip_validation_on_a_child_association_if_marked_for_destruction + @pirate.ship.name = '' + assert !@pirate.valid? + + @pirate.ship.mark_for_destruction + @pirate.ship.expects(:valid?).never + assert_difference('Ship.count', -1) { @pirate.save! } + end + + def test_a_child_marked_for_destruction_should_not_be_destroyed_twice + @pirate.ship.mark_for_destruction + assert @pirate.save + class << @pirate.ship + def destroy; raise "Should not be called" end + end + assert @pirate.save + end + + def test_should_rollback_destructions_if_an_exception_occurred_while_saving_a_child + # Stub the save method of the @pirate.ship instance to destroy and then raise an exception + class << @pirate.ship + def save(*args) + super + destroy + raise 'Oh noes!' + end + end + + @ship.pirate.catchphrase = "Changed Catchphrase" + + assert_raise(RuntimeError) { assert !@pirate.save } + assert_not_nil @pirate.reload.ship + end + + def test_should_save_changed_has_one_changed_object_if_child_is_saved + @pirate.ship.name = "NewName" + assert @pirate.save + assert_equal "NewName", @pirate.ship.reload.name + end + + def test_should_not_save_changed_has_one_unchanged_object_if_child_is_saved + @pirate.ship.expects(:save).never + assert @pirate.save + end + + # belongs_to + def test_should_destroy_a_parent_association_as_part_of_the_save_transaction_if_it_was_marked_for_destroyal + assert !@ship.pirate.marked_for_destruction? + + @ship.pirate.mark_for_destruction + id = @ship.pirate.id + + assert @ship.pirate.marked_for_destruction? + assert Pirate.find_by_id(id) + + @ship.save + assert_nil @ship.reload.pirate + assert_nil Pirate.find_by_id(id) + end + + def test_should_skip_validation_on_a_parent_association_if_marked_for_destruction + @ship.pirate.catchphrase = '' + assert !@ship.valid? + + @ship.pirate.mark_for_destruction + @ship.pirate.expects(:valid?).never + assert_difference('Pirate.count', -1) { @ship.save! } + end + + def test_a_parent_marked_for_destruction_should_not_be_destroyed_twice + @ship.pirate.mark_for_destruction + assert @ship.save + class << @ship.pirate + def destroy; raise "Should not be called" end + end + assert @ship.save + end + + def test_should_rollback_destructions_if_an_exception_occurred_while_saving_a_parent + # Stub the save method of the @ship.pirate instance to destroy and then raise an exception + class << @ship.pirate + def save(*args) + super + destroy + raise 'Oh noes!' + end + end + + @ship.pirate.catchphrase = "Changed Catchphrase" + + assert_raise(RuntimeError) { assert !@ship.save } + assert_not_nil @ship.reload.pirate + end + + def test_should_save_changed_child_objects_if_parent_is_saved + @pirate = @ship.create_pirate(:catchphrase => "Don' botharrr talkin' like one, savvy?") + @parrot = @pirate.parrots.create!(:name => 'Posideons Killer') + @parrot.name = "NewName" + @ship.save + + assert_equal 'NewName', @parrot.reload.name + end + + def test_should_destroy_has_many_as_part_of_the_save_transaction_if_they_were_marked_for_destruction + 2.times { |i| @pirate.birds.create!(:name => "birds_#{i}") } + + assert !@pirate.birds.any? { |child| child.marked_for_destruction? } + + @pirate.birds.each { |child| child.mark_for_destruction } + klass = @pirate.birds.first.class + ids = @pirate.birds.map(&:id) + + assert @pirate.birds.all? { |child| child.marked_for_destruction? } + ids.each { |id| assert klass.find_by_id(id) } + + @pirate.save + assert @pirate.reload.birds.empty? + ids.each { |id| assert_nil klass.find_by_id(id) } + end + + def test_should_not_resave_destroyed_association + @pirate.birds.create!(name: :parrot) + @pirate.birds.first.destroy + @pirate.save! + assert @pirate.reload.birds.empty? + end + + def test_should_skip_validation_on_has_many_if_marked_for_destruction + 2.times { |i| @pirate.birds.create!(:name => "birds_#{i}") } + + @pirate.birds.each { |bird| bird.name = '' } + assert !@pirate.valid? + + @pirate.birds.each do |bird| + bird.mark_for_destruction + bird.expects(:valid?).never + end + assert_difference("Bird.count", -2) { @pirate.save! } + end + + def test_should_skip_validation_on_has_many_if_destroyed + @pirate.birds.create!(:name => "birds_1") + + @pirate.birds.each { |bird| bird.name = '' } + assert !@pirate.valid? + + @pirate.birds.each { |bird| bird.destroy } + assert @pirate.valid? + end + + def test_a_child_marked_for_destruction_should_not_be_destroyed_twice_while_saving_has_many + @pirate.birds.create!(:name => "birds_1") + + @pirate.birds.each { |bird| bird.mark_for_destruction } + assert @pirate.save + + @pirate.birds.each { |bird| bird.expects(:destroy).never } + assert @pirate.save + end + + def test_should_rollback_destructions_if_an_exception_occurred_while_saving_has_many + 2.times { |i| @pirate.birds.create!(:name => "birds_#{i}") } + before = @pirate.birds.map { |c| c.mark_for_destruction ; c } + + # Stub the destroy method of the second child to raise an exception + class << before.last + def destroy(*args) + super + raise 'Oh noes!' + end + end + + assert_raise(RuntimeError) { assert !@pirate.save } + assert_equal before, @pirate.reload.birds + end + + def test_when_new_record_a_child_marked_for_destruction_should_not_affect_other_records_from_saving + @pirate = @ship.build_pirate(:catchphrase => "Arr' now I shall keep me eye on you matey!") # new record + + 3.times { |i| @pirate.birds.build(:name => "birds_#{i}") } + @pirate.birds[1].mark_for_destruction + @pirate.save! + + assert_equal 2, @pirate.birds.reload.length + end + + def test_should_save_new_record_that_has_same_value_as_existing_record_marked_for_destruction_on_field_that_has_unique_index + Bird.connection.add_index :birds, :name, unique: true + + 3.times { |i| @pirate.birds.create(name: "unique_birds_#{i}") } + + @pirate.birds[0].mark_for_destruction + @pirate.birds.build(name: @pirate.birds[0].name) + @pirate.save! + + assert_equal 3, @pirate.birds.reload.length + ensure + Bird.connection.remove_index :birds, column: :name + end + + # Add and remove callbacks tests for association collections. + %w{ method proc }.each do |callback_type| + define_method("test_should_run_add_callback_#{callback_type}s_for_has_many") do + association_name_with_callbacks = "birds_with_#{callback_type}_callbacks" + + pirate = Pirate.new(:catchphrase => "Arr") + pirate.send(association_name_with_callbacks).build(:name => "Crowe the One-Eyed") + + expected = [ + "before_adding_#{callback_type}_bird_<new>", + "after_adding_#{callback_type}_bird_<new>" + ] + + assert_equal expected, pirate.ship_log + end + + define_method("test_should_run_remove_callback_#{callback_type}s_for_has_many") do + association_name_with_callbacks = "birds_with_#{callback_type}_callbacks" + + @pirate.send(association_name_with_callbacks).create!(:name => "Crowe the One-Eyed") + @pirate.send(association_name_with_callbacks).each { |c| c.mark_for_destruction } + child_id = @pirate.send(association_name_with_callbacks).first.id + + @pirate.ship_log.clear + @pirate.save + + expected = [ + "before_removing_#{callback_type}_bird_#{child_id}", + "after_removing_#{callback_type}_bird_#{child_id}" + ] + + assert_equal expected, @pirate.ship_log + end + end + + def test_should_destroy_habtm_as_part_of_the_save_transaction_if_they_were_marked_for_destruction + 2.times { |i| @pirate.parrots.create!(:name => "parrots_#{i}") } + + assert !@pirate.parrots.any? { |parrot| parrot.marked_for_destruction? } + @pirate.parrots.each { |parrot| parrot.mark_for_destruction } + + assert_no_difference "Parrot.count" do + @pirate.save + end + + assert @pirate.reload.parrots.empty? + + join_records = Pirate.connection.select_all("SELECT * FROM parrots_pirates WHERE pirate_id = #{@pirate.id}") + assert join_records.empty? + end + + def test_should_skip_validation_on_habtm_if_marked_for_destruction + 2.times { |i| @pirate.parrots.create!(:name => "parrots_#{i}") } + + @pirate.parrots.each { |parrot| parrot.name = '' } + assert !@pirate.valid? + + @pirate.parrots.each do |parrot| + parrot.mark_for_destruction + parrot.expects(:valid?).never + end + + @pirate.save! + assert @pirate.reload.parrots.empty? + end + + def test_should_skip_validation_on_habtm_if_destroyed + @pirate.parrots.create!(:name => "parrots_1") + + @pirate.parrots.each { |parrot| parrot.name = '' } + assert !@pirate.valid? + + @pirate.parrots.each { |parrot| parrot.destroy } + assert @pirate.valid? + end + + def test_a_child_marked_for_destruction_should_not_be_destroyed_twice_while_saving_habtm + @pirate.parrots.create!(:name => "parrots_1") + + @pirate.parrots.each { |parrot| parrot.mark_for_destruction } + assert @pirate.save + + Pirate.transaction do + assert_queries(0) do + assert @pirate.save + end + end + end + + def test_should_rollback_destructions_if_an_exception_occurred_while_saving_habtm + 2.times { |i| @pirate.parrots.create!(:name => "parrots_#{i}") } + before = @pirate.parrots.map { |c| c.mark_for_destruction ; c } + + class << @pirate.association(:parrots) + def destroy(*args) + super + raise 'Oh noes!' + end + end + + assert_raise(RuntimeError) { assert !@pirate.save } + assert_equal before, @pirate.reload.parrots + end + + # Add and remove callbacks tests for association collections. + %w{ method proc }.each do |callback_type| + define_method("test_should_run_add_callback_#{callback_type}s_for_habtm") do + association_name_with_callbacks = "parrots_with_#{callback_type}_callbacks" + + pirate = Pirate.new(:catchphrase => "Arr") + pirate.send(association_name_with_callbacks).build(:name => "Crowe the One-Eyed") + + expected = [ + "before_adding_#{callback_type}_parrot_<new>", + "after_adding_#{callback_type}_parrot_<new>" + ] + + assert_equal expected, pirate.ship_log + end + + define_method("test_should_run_remove_callback_#{callback_type}s_for_habtm") do + association_name_with_callbacks = "parrots_with_#{callback_type}_callbacks" + + @pirate.send(association_name_with_callbacks).create!(:name => "Crowe the One-Eyed") + @pirate.send(association_name_with_callbacks).each { |c| c.mark_for_destruction } + child_id = @pirate.send(association_name_with_callbacks).first.id + + @pirate.ship_log.clear + @pirate.save + + expected = [ + "before_removing_#{callback_type}_parrot_#{child_id}", + "after_removing_#{callback_type}_parrot_#{child_id}" + ] + + assert_equal expected, @pirate.ship_log + end + end +end + +class TestAutosaveAssociationOnAHasOneAssociation < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + super + @pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?") + @ship = @pirate.create_ship(:name => 'Nights Dirty Lightning') + end + + def test_should_still_work_without_an_associated_model + @ship.destroy + @pirate.reload.catchphrase = "Arr" + @pirate.save + assert_equal 'Arr', @pirate.reload.catchphrase + end + + def test_should_automatically_save_the_associated_model + @pirate.ship.name = 'The Vile Insanity' + @pirate.save + assert_equal 'The Vile Insanity', @pirate.reload.ship.name + end + + def test_should_automatically_save_bang_the_associated_model + @pirate.ship.name = 'The Vile Insanity' + @pirate.save! + assert_equal 'The Vile Insanity', @pirate.reload.ship.name + end + + def test_should_automatically_validate_the_associated_model + @pirate.ship.name = '' + assert @pirate.invalid? + assert @pirate.errors[:"ship.name"].any? + end + + def test_should_merge_errors_on_the_associated_models_onto_the_parent_even_if_it_is_not_valid + @pirate.ship.name = nil + @pirate.catchphrase = nil + assert @pirate.invalid? + assert @pirate.errors[:"ship.name"].any? + assert @pirate.errors[:catchphrase].any? + end + + def test_should_not_ignore_different_error_messages_on_the_same_attribute + Ship.validates_format_of :name, :with => /\w/ + @pirate.ship.name = "" + @pirate.catchphrase = nil + assert @pirate.invalid? + assert_equal ["can't be blank", "is invalid"], @pirate.errors[:"ship.name"] + end + + def test_should_still_allow_to_bypass_validations_on_the_associated_model + @pirate.catchphrase = '' + @pirate.ship.name = '' + @pirate.save(:validate => false) + # Oracle saves empty string as NULL + if current_adapter?(:OracleAdapter) + assert_equal [nil, nil], [@pirate.reload.catchphrase, @pirate.ship.name] + else + assert_equal ['', ''], [@pirate.reload.catchphrase, @pirate.ship.name] + end + end + + def test_should_allow_to_bypass_validations_on_associated_models_at_any_depth + 2.times { |i| @pirate.ship.parts.create!(:name => "part #{i}") } + + @pirate.catchphrase = '' + @pirate.ship.name = '' + @pirate.ship.parts.each { |part| part.name = '' } + @pirate.save(:validate => false) + + values = [@pirate.reload.catchphrase, @pirate.ship.name, *@pirate.ship.parts.map(&:name)] + # Oracle saves empty string as NULL + if current_adapter?(:OracleAdapter) + assert_equal [nil, nil, nil, nil], values + else + assert_equal ['', '', '', ''], values + end + end + + def test_should_still_raise_an_ActiveRecordRecord_Invalid_exception_if_we_want_that + @pirate.ship.name = '' + assert_raise(ActiveRecord::RecordInvalid) do + @pirate.save! + end + end + + def test_should_not_save_and_return_false_if_a_callback_cancelled_saving + pirate = Pirate.new(:catchphrase => 'Arr') + ship = pirate.build_ship(:name => 'The Vile Insanity') + ship.cancel_save_from_callback = true + + assert_no_difference 'Pirate.count' do + assert_no_difference 'Ship.count' do + assert !pirate.save + end + end + end + + def test_should_rollback_any_changes_if_an_exception_occurred_while_saving + before = [@pirate.catchphrase, @pirate.ship.name] + + @pirate.catchphrase = 'Arr' + @pirate.ship.name = 'The Vile Insanity' + + # Stub the save method of the @pirate.ship instance to raise an exception + class << @pirate.ship + def save(*args) + super + raise 'Oh noes!' + end + end + + assert_raise(RuntimeError) { assert !@pirate.save } + assert_equal before, [@pirate.reload.catchphrase, @pirate.ship.name] + end + + def test_should_not_load_the_associated_model + assert_queries(1) { @pirate.catchphrase = 'Arr'; @pirate.save! } + end +end + +class TestAutosaveAssociationOnABelongsToAssociation < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + super + @ship = Ship.create(:name => 'Nights Dirty Lightning') + @pirate = @ship.create_pirate(:catchphrase => "Don' botharrr talkin' like one, savvy?") + end + + def test_should_still_work_without_an_associated_model + @pirate.destroy + @ship.reload.name = "The Vile Insanity" + @ship.save + assert_equal 'The Vile Insanity', @ship.reload.name + end + + def test_should_automatically_save_the_associated_model + @ship.pirate.catchphrase = 'Arr' + @ship.save + assert_equal 'Arr', @ship.reload.pirate.catchphrase + end + + def test_should_automatically_save_bang_the_associated_model + @ship.pirate.catchphrase = 'Arr' + @ship.save! + assert_equal 'Arr', @ship.reload.pirate.catchphrase + end + + def test_should_automatically_validate_the_associated_model + @ship.pirate.catchphrase = '' + assert @ship.invalid? + assert @ship.errors[:"pirate.catchphrase"].any? + end + + def test_should_merge_errors_on_the_associated_model_onto_the_parent_even_if_it_is_not_valid + @ship.name = nil + @ship.pirate.catchphrase = nil + assert @ship.invalid? + assert @ship.errors[:name].any? + assert @ship.errors[:"pirate.catchphrase"].any? + end + + def test_should_still_allow_to_bypass_validations_on_the_associated_model + @ship.pirate.catchphrase = '' + @ship.name = '' + @ship.save(:validate => false) + # Oracle saves empty string as NULL + if current_adapter?(:OracleAdapter) + assert_equal [nil, nil], [@ship.reload.name, @ship.pirate.catchphrase] + else + assert_equal ['', ''], [@ship.reload.name, @ship.pirate.catchphrase] + end + end + + def test_should_still_raise_an_ActiveRecordRecord_Invalid_exception_if_we_want_that + @ship.pirate.catchphrase = '' + assert_raise(ActiveRecord::RecordInvalid) do + @ship.save! + end + end + + def test_should_not_save_and_return_false_if_a_callback_cancelled_saving + ship = Ship.new(:name => 'The Vile Insanity') + pirate = ship.build_pirate(:catchphrase => 'Arr') + pirate.cancel_save_from_callback = true + + assert_no_difference 'Ship.count' do + assert_no_difference 'Pirate.count' do + assert !ship.save + end + end + end + + def test_should_rollback_any_changes_if_an_exception_occurred_while_saving + before = [@ship.pirate.catchphrase, @ship.name] + + @ship.pirate.catchphrase = 'Arr' + @ship.name = 'The Vile Insanity' + + # Stub the save method of the @ship.pirate instance to raise an exception + class << @ship.pirate + def save(*args) + super + raise 'Oh noes!' + end + end + + assert_raise(RuntimeError) { assert !@ship.save } + assert_equal before, [@ship.pirate.reload.catchphrase, @ship.reload.name] + end + + def test_should_not_load_the_associated_model + assert_queries(1) { @ship.name = 'The Vile Insanity'; @ship.save! } + end +end + +module AutosaveAssociationOnACollectionAssociationTests + def test_should_automatically_save_the_associated_models + new_names = ['Grace OMalley', 'Privateers Greed'] + @pirate.send(@association_name).each_with_index { |child, i| child.name = new_names[i] } + + @pirate.save + assert_equal new_names, @pirate.reload.send(@association_name).map(&:name) + end + + def test_should_automatically_save_bang_the_associated_models + new_names = ['Grace OMalley', 'Privateers Greed'] + @pirate.send(@association_name).each_with_index { |child, i| child.name = new_names[i] } + + @pirate.save! + assert_equal new_names, @pirate.reload.send(@association_name).map(&:name) + end + + def test_should_automatically_validate_the_associated_models + @pirate.send(@association_name).each { |child| child.name = '' } + + assert !@pirate.valid? + assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] + assert @pirate.errors[@association_name].empty? + end + + def test_should_not_use_default_invalid_error_on_associated_models + @pirate.send(@association_name).build(:name => '') + + assert !@pirate.valid? + assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] + assert @pirate.errors[@association_name].empty? + end + + def test_should_default_invalid_error_from_i18n + I18n.backend.store_translations(:en, activerecord: {errors: { models: + { @associated_model_name.to_s.to_sym => { blank: "cannot be blank" } } + }}) + + @pirate.send(@association_name).build(name: '') + + assert !@pirate.valid? + assert_equal ["cannot be blank"], @pirate.errors["#{@association_name}.name"] + assert_equal ["#{@association_name.to_s.humanize} name cannot be blank"], @pirate.errors.full_messages + assert @pirate.errors[@association_name].empty? + ensure + I18n.backend = I18n::Backend::Simple.new + end + + def test_should_merge_errors_on_the_associated_models_onto_the_parent_even_if_it_is_not_valid + @pirate.send(@association_name).each { |child| child.name = '' } + @pirate.catchphrase = nil + + assert !@pirate.valid? + assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] + assert @pirate.errors[:catchphrase].any? + end + + def test_should_allow_to_bypass_validations_on_the_associated_models_on_update + @pirate.catchphrase = '' + @pirate.send(@association_name).each { |child| child.name = '' } + + assert @pirate.save(:validate => false) + # Oracle saves empty string as NULL + if current_adapter?(:OracleAdapter) + assert_equal [nil, nil, nil], [ + @pirate.reload.catchphrase, + @pirate.send(@association_name).first.name, + @pirate.send(@association_name).last.name + ] + else + assert_equal ['', '', ''], [ + @pirate.reload.catchphrase, + @pirate.send(@association_name).first.name, + @pirate.send(@association_name).last.name + ] + end + end + + def test_should_validation_the_associated_models_on_create + assert_no_difference("#{ @association_name == :birds ? 'Bird' : 'Parrot' }.count") do + 2.times { @pirate.send(@association_name).build } + @pirate.save + end + end + + def test_should_allow_to_bypass_validations_on_the_associated_models_on_create + assert_difference("#{ @association_name == :birds ? 'Bird' : 'Parrot' }.count", 2) do + 2.times { @pirate.send(@association_name).build } + @pirate.save(:validate => false) + end + end + + def test_should_not_save_and_return_false_if_a_callback_cancelled_saving_in_either_create_or_update + @pirate.catchphrase = 'Changed' + @child_1.name = 'Changed' + @child_1.cancel_save_from_callback = true + + assert !@pirate.save + assert_equal "Don' botharrr talkin' like one, savvy?", @pirate.reload.catchphrase + assert_equal "Posideons Killer", @child_1.reload.name + + new_pirate = Pirate.new(:catchphrase => 'Arr') + new_child = new_pirate.send(@association_name).build(:name => 'Grace OMalley') + new_child.cancel_save_from_callback = true + + assert_no_difference 'Pirate.count' do + assert_no_difference "#{new_child.class.name}.count" do + assert !new_pirate.save + end + end + end + + def test_should_rollback_any_changes_if_an_exception_occurred_while_saving + before = [@pirate.catchphrase, *@pirate.send(@association_name).map(&:name)] + new_names = ['Grace OMalley', 'Privateers Greed'] + + @pirate.catchphrase = 'Arr' + @pirate.send(@association_name).each_with_index { |child, i| child.name = new_names[i] } + + # Stub the save method of the first child instance to raise an exception + class << @pirate.send(@association_name).first + def save(*args) + super + raise 'Oh noes!' + end + end + + assert_raise(RuntimeError) { assert !@pirate.save } + assert_equal before, [@pirate.reload.catchphrase, *@pirate.send(@association_name).map(&:name)] + end + + def test_should_still_raise_an_ActiveRecordRecord_Invalid_exception_if_we_want_that + @pirate.send(@association_name).each { |child| child.name = '' } + assert_raise(ActiveRecord::RecordInvalid) do + @pirate.save! + end + end + + def test_should_not_load_the_associated_models_if_they_were_not_loaded_yet + assert_queries(1) { @pirate.catchphrase = 'Arr'; @pirate.save! } + + @pirate.send(@association_name).load_target + + assert_queries(3) do + @pirate.catchphrase = 'Yarr' + new_names = ['Grace OMalley', 'Privateers Greed'] + @pirate.send(@association_name).each_with_index { |child, i| child.name = new_names[i] } + @pirate.save! + end + end +end + +class TestAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + super + @association_name = :birds + @associated_model_name = :bird + + @pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?") + @child_1 = @pirate.birds.create(:name => 'Posideons Killer') + @child_2 = @pirate.birds.create(:name => 'Killer bandita Dionne') + end + + include AutosaveAssociationOnACollectionAssociationTests +end + +class TestAutosaveAssociationOnAHasAndBelongsToManyAssociation < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + super + @association_name = :autosaved_parrots + @associated_model_name = :parrot + @habtm = true + + @pirate = Pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?") + @child_1 = @pirate.parrots.create(name: 'Posideons Killer') + @child_2 = @pirate.parrots.create(name: 'Killer bandita Dionne') + end + + include AutosaveAssociationOnACollectionAssociationTests +end + +class TestAutosaveAssociationOnAHasAndBelongsToManyAssociationWithAcceptsNestedAttributes < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + super + @association_name = :parrots + @associated_model_name = :parrot + @habtm = true + + @pirate = Pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?") + @child_1 = @pirate.parrots.create(name: 'Posideons Killer') + @child_2 = @pirate.parrots.create(name: 'Killer bandita Dionne') + end + + include AutosaveAssociationOnACollectionAssociationTests +end + +class TestAutosaveAssociationValidationsOnAHasManyAssociation < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + super + @pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?") + @pirate.birds.create(:name => 'cookoo') + end + + test "should automatically validate associations" do + assert @pirate.valid? + @pirate.birds.each { |bird| bird.name = '' } + + assert !@pirate.valid? + end +end + +class TestAutosaveAssociationValidationsOnAHasOneAssociation < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + super + @pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?") + @pirate.create_ship(:name => 'titanic') + super + end + + test "should automatically validate associations with :validate => true" do + assert @pirate.valid? + @pirate.ship.name = '' + assert !@pirate.valid? + end + + test "should not automatically add validate associations without :validate => true" do + assert @pirate.valid? + @pirate.non_validated_ship.name = '' + assert @pirate.valid? + end +end + +class TestAutosaveAssociationValidationsOnABelongsToAssociation < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + super + @pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?") + end + + test "should automatically validate associations with :validate => true" do + assert @pirate.valid? + @pirate.parrot = Parrot.new(:name => '') + assert !@pirate.valid? + end + + test "should not automatically validate associations without :validate => true" do + assert @pirate.valid? + @pirate.non_validated_parrot = Parrot.new(:name => '') + assert @pirate.valid? + end +end + +class TestAutosaveAssociationValidationsOnAHABTMAssociation < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + super + @pirate = Pirate.create(:catchphrase => "Don' botharrr talkin' like one, savvy?") + end + + test "should automatically validate associations with :validate => true" do + assert @pirate.valid? + @pirate.parrots = [ Parrot.new(:name => 'popuga') ] + @pirate.parrots.each { |parrot| parrot.name = '' } + assert !@pirate.valid? + end + + test "should not automatically validate associations without :validate => true" do + assert @pirate.valid? + @pirate.non_validated_parrots = [ Parrot.new(:name => 'popuga') ] + @pirate.non_validated_parrots.each { |parrot| parrot.name = '' } + assert @pirate.valid? + end +end + +class TestAutosaveAssociationValidationMethodsGeneration < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + super + @pirate = Pirate.new + end + + test "should generate validation methods for has_many associations" do + assert_respond_to @pirate, :validate_associated_records_for_birds + end + + test "should generate validation methods for has_one associations with :validate => true" do + assert_respond_to @pirate, :validate_associated_records_for_ship + end + + test "should not generate validation methods for has_one associations without :validate => true" do + assert !@pirate.respond_to?(:validate_associated_records_for_non_validated_ship) + end + + test "should generate validation methods for belongs_to associations with :validate => true" do + assert_respond_to @pirate, :validate_associated_records_for_parrot + end + + test "should not generate validation methods for belongs_to associations without :validate => true" do + assert !@pirate.respond_to?(:validate_associated_records_for_non_validated_parrot) + end + + test "should generate validation methods for HABTM associations with :validate => true" do + assert_respond_to @pirate, :validate_associated_records_for_parrots + end +end + +class TestAutosaveAssociationWithTouch < ActiveRecord::TestCase + def test_autosave_with_touch_should_not_raise_system_stack_error + invoice = Invoice.create + assert_nothing_raised { invoice.line_items.create(:amount => 10) } + end +end diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb new file mode 100644 index 0000000000..4c0b0c868a --- /dev/null +++ b/activerecord/test/cases/base_test.rb @@ -0,0 +1,1624 @@ +# encoding: utf-8 + +require "cases/helper" +require 'active_support/concurrency/latch' +require 'models/post' +require 'models/author' +require 'models/topic' +require 'models/reply' +require 'models/category' +require 'models/company' +require 'models/customer' +require 'models/developer' +require 'models/project' +require 'models/default' +require 'models/auto_id' +require 'models/boolean' +require 'models/column_name' +require 'models/subscriber' +require 'models/keyboard' +require 'models/comment' +require 'models/minimalistic' +require 'models/warehouse_thing' +require 'models/parrot' +require 'models/person' +require 'models/edge' +require 'models/joke' +require 'models/bird' +require 'models/car' +require 'models/bulb' +require 'rexml/document' + +class FirstAbstractClass < ActiveRecord::Base + self.abstract_class = true +end +class SecondAbstractClass < FirstAbstractClass + self.abstract_class = true +end +class Photo < SecondAbstractClass; end +class Category < ActiveRecord::Base; end +class Categorization < ActiveRecord::Base; end +class Smarts < ActiveRecord::Base; end +class CreditCard < ActiveRecord::Base + class PinNumber < ActiveRecord::Base + class CvvCode < ActiveRecord::Base; end + class SubCvvCode < CvvCode; end + end + class SubPinNumber < PinNumber; end + class Brand < Category; end +end +class MasterCreditCard < ActiveRecord::Base; end +class Post < ActiveRecord::Base; end +class Computer < ActiveRecord::Base; end +class NonExistentTable < ActiveRecord::Base; end +class TestOracleDefault < ActiveRecord::Base; end + +class ReadonlyTitlePost < Post + attr_readonly :title +end + +class Weird < ActiveRecord::Base; end + +class Boolean < ActiveRecord::Base + def has_fun + super + end +end + +class LintTest < ActiveRecord::TestCase + include ActiveModel::Lint::Tests + + class LintModel < ActiveRecord::Base; end + + def setup + @model = LintModel.new + end +end + +class BasicsTest < ActiveRecord::TestCase + fixtures :topics, :companies, :developers, :projects, :computers, :accounts, :minimalistics, 'warehouse-things', :authors, :categorizations, :categories, :posts + + def test_column_names_are_escaped + conn = ActiveRecord::Base.connection + classname = conn.class.name[/[^:]*$/] + badchar = { + 'SQLite3Adapter' => '"', + 'MysqlAdapter' => '`', + 'Mysql2Adapter' => '`', + 'PostgreSQLAdapter' => '"', + 'OracleAdapter' => '"', + }.fetch(classname) { + raise "need a bad char for #{classname}" + } + + quoted = conn.quote_column_name "foo#{badchar}bar" + if current_adapter?(:OracleAdapter) + # Oracle does not allow double quotes in table and column names at all + # therefore quoting removes them + assert_equal("#{badchar}foobar#{badchar}", quoted) + else + assert_equal("#{badchar}foo#{badchar * 2}bar#{badchar}", quoted) + end + end + + def test_columns_should_obey_set_primary_key + pk = Subscriber.columns_hash[Subscriber.primary_key] + assert_equal 'nick', pk.name, 'nick should be primary key' + end + + def test_primary_key_with_no_id + assert_nil Edge.primary_key + end + + unless current_adapter?(:PostgreSQLAdapter, :OracleAdapter, :SQLServerAdapter) + def test_limit_with_comma + assert Topic.limit("1,2").to_a + end + end + + def test_limit_without_comma + assert_equal 1, Topic.limit("1").to_a.length + assert_equal 1, Topic.limit(1).to_a.length + end + + def test_limit_should_take_value_from_latest_limit + assert_equal 1, Topic.limit(2).limit(1).to_a.length + end + + def test_invalid_limit + assert_raises(ArgumentError) do + Topic.limit("asdfadf").to_a + end + end + + def test_limit_should_sanitize_sql_injection_for_limit_without_commas + assert_raises(ArgumentError) do + Topic.limit("1 select * from schema").to_a + end + end + + def test_limit_should_sanitize_sql_injection_for_limit_with_commas + assert_raises(ArgumentError) do + Topic.limit("1, 7 procedure help()").to_a + end + end + + unless current_adapter?(:MysqlAdapter, :Mysql2Adapter) + def test_limit_should_allow_sql_literal + assert_equal 1, Topic.limit(Arel.sql('2-1')).to_a.length + end + end + + def test_select_symbol + topic_ids = Topic.select(:id).map(&:id).sort + assert_equal Topic.pluck(:id).sort, topic_ids + end + + def test_table_exists + assert !NonExistentTable.table_exists? + assert Topic.table_exists? + end + + def test_preserving_date_objects + # Oracle enhanced adapter allows to define Date attributes in model class (see topic.rb) + assert_kind_of( + Date, Topic.find(1).last_read, + "The last_read attribute should be of the Date class" + ) + end + + def test_previously_changed + topic = Topic.first + topic.title = '<3<3<3' + assert_equal({}, topic.previous_changes) + + topic.save! + expected = ["The First Topic", "<3<3<3"] + assert_equal(expected, topic.previous_changes['title']) + end + + def test_previously_changed_dup + topic = Topic.first + topic.title = '<3<3<3' + topic.save! + + t2 = topic.dup + + assert_equal(topic.previous_changes, t2.previous_changes) + + topic.title = "lolwut" + topic.save! + + assert_not_equal(topic.previous_changes, t2.previous_changes) + end + + def test_preserving_time_objects + assert_kind_of( + Time, Topic.find(1).bonus_time, + "The bonus_time attribute should be of the Time class" + ) + + assert_kind_of( + Time, Topic.find(1).written_on, + "The written_on attribute should be of the Time class" + ) + + # For adapters which support microsecond resolution. + if current_adapter?(:PostgreSQLAdapter, :SQLite3Adapter) || mysql_56? + assert_equal 11, Topic.find(1).written_on.sec + assert_equal 223300, Topic.find(1).written_on.usec + assert_equal 9900, Topic.find(2).written_on.usec + assert_equal 129346, Topic.find(3).written_on.usec + end + end + + def test_preserving_time_objects_with_local_time_conversion_to_default_timezone_utc + with_env_tz 'America/New_York' do + with_timezone_config default: :utc do + time = Time.local(2000) + topic = Topic.create('written_on' => time) + saved_time = Topic.find(topic.id).reload.written_on + assert_equal time, saved_time + assert_equal [0, 0, 0, 1, 1, 2000, 6, 1, false, "EST"], time.to_a + assert_equal [0, 0, 5, 1, 1, 2000, 6, 1, false, "UTC"], saved_time.to_a + end + end + end + + def test_preserving_time_objects_with_time_with_zone_conversion_to_default_timezone_utc + with_env_tz 'America/New_York' do + with_timezone_config default: :utc do + Time.use_zone 'Central Time (US & Canada)' do + time = Time.zone.local(2000) + topic = Topic.create('written_on' => time) + saved_time = Topic.find(topic.id).reload.written_on + assert_equal time, saved_time + assert_equal [0, 0, 0, 1, 1, 2000, 6, 1, false, "CST"], time.to_a + assert_equal [0, 0, 6, 1, 1, 2000, 6, 1, false, "UTC"], saved_time.to_a + end + end + end + end + + def test_preserving_time_objects_with_utc_time_conversion_to_default_timezone_local + with_env_tz 'America/New_York' do + with_timezone_config default: :local do + time = Time.utc(2000) + topic = Topic.create('written_on' => time) + saved_time = Topic.find(topic.id).reload.written_on + assert_equal time, saved_time + assert_equal [0, 0, 0, 1, 1, 2000, 6, 1, false, "UTC"], time.to_a + assert_equal [0, 0, 19, 31, 12, 1999, 5, 365, false, "EST"], saved_time.to_a + end + end + end + + def test_preserving_time_objects_with_time_with_zone_conversion_to_default_timezone_local + with_env_tz 'America/New_York' do + with_timezone_config default: :local do + Time.use_zone 'Central Time (US & Canada)' do + time = Time.zone.local(2000) + topic = Topic.create('written_on' => time) + saved_time = Topic.find(topic.id).reload.written_on + assert_equal time, saved_time + assert_equal [0, 0, 0, 1, 1, 2000, 6, 1, false, "CST"], time.to_a + assert_equal [0, 0, 1, 1, 1, 2000, 6, 1, false, "EST"], saved_time.to_a + end + end + end + end + + def test_custom_mutator + topic = Topic.find(1) + # This mutator is protected in the class definition + topic.send(:approved=, true) + assert topic.instance_variable_get("@custom_approved") + end + + def test_initialize_with_attributes + topic = Topic.new({ + "title" => "initialized from attributes", "written_on" => "2003-12-12 23:23" + }) + + assert_equal("initialized from attributes", topic.title) + end + + def test_initialize_with_invalid_attribute + Topic.new({ "title" => "test", + "last_read(1i)" => "2005", "last_read(2i)" => "2", "last_read(3i)" => "31"}) + rescue ActiveRecord::MultiparameterAssignmentErrors => ex + assert_equal(1, ex.errors.size) + assert_equal("last_read", ex.errors[0].attribute) + end + + def test_create_after_initialize_without_block + cb = CustomBulb.create(:name => 'Dude') + assert_equal('Dude', cb.name) + assert_equal(true, cb.frickinawesome) + end + + def test_create_after_initialize_with_block + cb = CustomBulb.create {|c| c.name = 'Dude' } + assert_equal('Dude', cb.name) + assert_equal(true, cb.frickinawesome) + end + + def test_create_after_initialize_with_array_param + cbs = CustomBulb.create([{ name: 'Dude' }, { name: 'Bob' }]) + assert_equal 'Dude', cbs[0].name + assert_equal 'Bob', cbs[1].name + assert cbs[0].frickinawesome + assert !cbs[1].frickinawesome + end + + def test_load + topics = Topic.all.merge!(:order => 'id').to_a + assert_equal(5, topics.size) + assert_equal(topics(:first).title, topics.first.title) + end + + def test_load_with_condition + topics = Topic.all.merge!(:where => "author_name = 'Mary'").to_a + + assert_equal(1, topics.size) + assert_equal(topics(:second).title, topics.first.title) + end + + GUESSED_CLASSES = [Category, Smarts, CreditCard, CreditCard::PinNumber, CreditCard::PinNumber::CvvCode, CreditCard::SubPinNumber, CreditCard::Brand, MasterCreditCard] + + def test_table_name_guesses + assert_equal "topics", Topic.table_name + + assert_equal "categories", Category.table_name + assert_equal "smarts", Smarts.table_name + assert_equal "credit_cards", CreditCard.table_name + assert_equal "credit_card_pin_numbers", CreditCard::PinNumber.table_name + assert_equal "credit_card_pin_number_cvv_codes", CreditCard::PinNumber::CvvCode.table_name + assert_equal "credit_card_pin_numbers", CreditCard::SubPinNumber.table_name + assert_equal "categories", CreditCard::Brand.table_name + assert_equal "master_credit_cards", MasterCreditCard.table_name + ensure + GUESSED_CLASSES.each(&:reset_table_name) + end + + def test_singular_table_name_guesses + ActiveRecord::Base.pluralize_table_names = false + GUESSED_CLASSES.each(&:reset_table_name) + + assert_equal "category", Category.table_name + assert_equal "smarts", Smarts.table_name + assert_equal "credit_card", CreditCard.table_name + assert_equal "credit_card_pin_number", CreditCard::PinNumber.table_name + assert_equal "credit_card_pin_number_cvv_code", CreditCard::PinNumber::CvvCode.table_name + assert_equal "credit_card_pin_number", CreditCard::SubPinNumber.table_name + assert_equal "category", CreditCard::Brand.table_name + assert_equal "master_credit_card", MasterCreditCard.table_name + ensure + ActiveRecord::Base.pluralize_table_names = true + GUESSED_CLASSES.each(&:reset_table_name) + end + + def test_table_name_guesses_with_prefixes_and_suffixes + ActiveRecord::Base.table_name_prefix = "test_" + Category.reset_table_name + assert_equal "test_categories", Category.table_name + ActiveRecord::Base.table_name_suffix = "_test" + Category.reset_table_name + assert_equal "test_categories_test", Category.table_name + ActiveRecord::Base.table_name_prefix = "" + Category.reset_table_name + assert_equal "categories_test", Category.table_name + ActiveRecord::Base.table_name_suffix = "" + Category.reset_table_name + assert_equal "categories", Category.table_name + ensure + ActiveRecord::Base.table_name_prefix = "" + ActiveRecord::Base.table_name_suffix = "" + GUESSED_CLASSES.each(&:reset_table_name) + end + + def test_singular_table_name_guesses_with_prefixes_and_suffixes + ActiveRecord::Base.pluralize_table_names = false + + ActiveRecord::Base.table_name_prefix = "test_" + Category.reset_table_name + assert_equal "test_category", Category.table_name + ActiveRecord::Base.table_name_suffix = "_test" + Category.reset_table_name + assert_equal "test_category_test", Category.table_name + ActiveRecord::Base.table_name_prefix = "" + Category.reset_table_name + assert_equal "category_test", Category.table_name + ActiveRecord::Base.table_name_suffix = "" + Category.reset_table_name + assert_equal "category", Category.table_name + ensure + ActiveRecord::Base.pluralize_table_names = true + ActiveRecord::Base.table_name_prefix = "" + ActiveRecord::Base.table_name_suffix = "" + GUESSED_CLASSES.each(&:reset_table_name) + end + + def test_table_name_guesses_with_inherited_prefixes_and_suffixes + GUESSED_CLASSES.each(&:reset_table_name) + + CreditCard.table_name_prefix = "test_" + CreditCard.reset_table_name + Category.reset_table_name + assert_equal "test_credit_cards", CreditCard.table_name + assert_equal "categories", Category.table_name + CreditCard.table_name_suffix = "_test" + CreditCard.reset_table_name + Category.reset_table_name + assert_equal "test_credit_cards_test", CreditCard.table_name + assert_equal "categories", Category.table_name + CreditCard.table_name_prefix = "" + CreditCard.reset_table_name + Category.reset_table_name + assert_equal "credit_cards_test", CreditCard.table_name + assert_equal "categories", Category.table_name + CreditCard.table_name_suffix = "" + CreditCard.reset_table_name + Category.reset_table_name + assert_equal "credit_cards", CreditCard.table_name + assert_equal "categories", Category.table_name + ensure + CreditCard.table_name_prefix = "" + CreditCard.table_name_suffix = "" + GUESSED_CLASSES.each(&:reset_table_name) + end + + def test_singular_table_name_guesses_for_individual_table + Post.pluralize_table_names = false + Post.reset_table_name + assert_equal "post", Post.table_name + assert_equal "categories", Category.table_name + ensure + Post.pluralize_table_names = true + Post.reset_table_name + end + + if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + def test_update_all_with_order_and_limit + assert_equal 1, Topic.limit(1).order('id DESC').update_all(:content => 'bulk updated!') + end + end + + def test_null_fields + assert_nil Topic.find(1).parent_id + assert_nil Topic.create("title" => "Hey you").parent_id + end + + def test_default_values + topic = Topic.new + assert topic.approved? + assert_nil topic.written_on + assert_nil topic.bonus_time + assert_nil topic.last_read + + topic.save + + topic = Topic.find(topic.id) + assert topic.approved? + assert_nil topic.last_read + + # Oracle has some funky default handling, so it requires a bit of + # extra testing. See ticket #2788. + if current_adapter?(:OracleAdapter) + test = TestOracleDefault.new + assert_equal "X", test.test_char + assert_equal "hello", test.test_string + assert_equal 3, test.test_int + end + end + + # Oracle does not have a TIME datatype. + unless current_adapter?(:OracleAdapter) + def test_utc_as_time_zone + with_timezone_config default: :utc do + attributes = { "bonus_time" => "5:42:00AM" } + topic = Topic.find(1) + topic.attributes = attributes + assert_equal Time.utc(2000, 1, 1, 5, 42, 0), topic.bonus_time + end + end + + def test_utc_as_time_zone_and_new + with_timezone_config default: :utc do + attributes = { "bonus_time(1i)"=>"2000", + "bonus_time(2i)"=>"1", + "bonus_time(3i)"=>"1", + "bonus_time(4i)"=>"10", + "bonus_time(5i)"=>"35", + "bonus_time(6i)"=>"50" } + topic = Topic.new(attributes) + assert_equal Time.utc(2000, 1, 1, 10, 35, 50), topic.bonus_time + end + end + end + + def test_default_values_on_empty_strings + topic = Topic.new + topic.approved = nil + topic.last_read = nil + + topic.save + + topic = Topic.find(topic.id) + assert_nil topic.last_read + + assert_nil topic.approved + end + + def test_equality + assert_equal Topic.find(1), Topic.find(2).topic + end + + def test_find_by_slug + assert_equal Topic.find('1-meowmeow'), Topic.find(1) + end + + def test_find_by_slug_with_array + assert_equal Topic.find(['1-meowmeow', '2-hello']), Topic.find([1, 2]) + end + + def test_equality_of_new_records + assert_not_equal Topic.new, Topic.new + assert_equal false, Topic.new == Topic.new + end + + def test_equality_of_destroyed_records + topic_1 = Topic.new(:title => 'test_1') + topic_1.save + topic_2 = Topic.find(topic_1.id) + topic_1.destroy + assert_equal topic_1, topic_2 + assert_equal topic_2, topic_1 + end + + def test_equality_with_blank_ids + one = Subscriber.new(:id => '') + two = Subscriber.new(:id => '') + assert_equal one, two + end + + def test_equality_of_relation_and_collection_proxy + car = Car.create! + car.bulbs.build + car.save + + assert car.bulbs == Bulb.where(car_id: car.id), 'CollectionProxy should be comparable with Relation' + assert Bulb.where(car_id: car.id) == car.bulbs, 'Relation should be comparable with CollectionProxy' + end + + def test_equality_of_relation_and_array + car = Car.create! + car.bulbs.build + car.save + + assert Bulb.where(car_id: car.id) == car.bulbs.to_a, 'Relation should be comparable with Array' + end + + def test_equality_of_relation_and_association_relation + car = Car.create! + car.bulbs.build + car.save + + assert_equal Bulb.where(car_id: car.id), car.bulbs.includes(:car), 'Relation should be comparable with AssociationRelation' + assert_equal car.bulbs.includes(:car), Bulb.where(car_id: car.id), 'AssociationRelation should be comparable with Relation' + end + + def test_equality_of_collection_proxy_and_association_relation + car = Car.create! + car.bulbs.build + car.save + + assert_equal car.bulbs, car.bulbs.includes(:car), 'CollectionProxy should be comparable with AssociationRelation' + assert_equal car.bulbs.includes(:car), car.bulbs, 'AssociationRelation should be comparable with CollectionProxy' + end + + def test_hashing + assert_equal [ Topic.find(1) ], [ Topic.find(2).topic ] & [ Topic.find(1) ] + end + + def test_successful_comparison_of_like_class_records + topic_1 = Topic.create! + topic_2 = Topic.create! + + assert_equal [topic_2, topic_1].sort, [topic_1, topic_2] + end + + def test_failed_comparison_of_unlike_class_records + assert_raises ArgumentError do + [ topics(:first), posts(:welcome) ].sort + end + end + + def test_create_without_prepared_statement + topic = Topic.connection.unprepared_statement do + Topic.create(:title => 'foo') + end + + assert_equal topic, Topic.find(topic.id) + end + + def test_destroy_without_prepared_statement + topic = Topic.create(title: 'foo') + Topic.connection.unprepared_statement do + Topic.find(topic.id).destroy + end + + assert_equal nil, Topic.find_by_id(topic.id) + end + + def test_comparison_with_different_objects + topic = Topic.create + category = Category.create(:name => "comparison") + assert_nil topic <=> category + end + + def test_comparison_with_different_objects_in_array + topic = Topic.create + assert_raises(ArgumentError) do + [1, topic].sort + end + end + + def test_readonly_attributes + assert_equal Set.new([ 'title' , 'comments_count' ]), ReadonlyTitlePost.readonly_attributes + + post = ReadonlyTitlePost.create(:title => "cannot change this", :body => "changeable") + post.reload + assert_equal "cannot change this", post.title + + post.update(title: "try to change", body: "changed") + post.reload + assert_equal "cannot change this", post.title + assert_equal "changed", post.body + end + + def test_unicode_column_name + Weird.reset_column_information + weird = Weird.create(:なまえ => 'たこ焼き仮面') + assert_equal 'たこ焼き仮面', weird.なまえ + end + + unless current_adapter?(:PostgreSQLAdapter) + def test_respect_internal_encoding + old_default_internal = Encoding.default_internal + silence_warnings { Encoding.default_internal = "EUC-JP" } + + Weird.reset_column_information + + assert_equal ["EUC-JP"], Weird.columns.map {|c| c.name.encoding.name }.uniq + ensure + silence_warnings { Encoding.default_internal = old_default_internal } + Weird.reset_column_information + end + end + + def test_non_valid_identifier_column_name + weird = Weird.create('a$b' => 'value') + weird.reload + assert_equal 'value', weird.send('a$b') + assert_equal 'value', weird.read_attribute('a$b') + + weird.update_columns('a$b' => 'value2') + weird.reload + assert_equal 'value2', weird.send('a$b') + assert_equal 'value2', weird.read_attribute('a$b') + end + + def test_group_weirds_by_from + Weird.create('a$b' => 'value', :from => 'aaron') + count = Weird.group(Weird.arel_table[:from]).count + assert_equal 1, count['aaron'] + end + + def test_attributes_on_dummy_time + # Oracle does not have a TIME datatype. + return true if current_adapter?(:OracleAdapter) + + with_timezone_config default: :local do + attributes = { + "bonus_time" => "5:42:00AM" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_equal Time.local(2000, 1, 1, 5, 42, 0), topic.bonus_time + end + end + + def test_attributes_on_dummy_time_with_invalid_time + # Oracle does not have a TIME datatype. + return true if current_adapter?(:OracleAdapter) + + attributes = { + "bonus_time" => "not a time" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_nil topic.bonus_time + end + + def test_boolean + b_nil = Boolean.create({ "value" => nil }) + nil_id = b_nil.id + b_false = Boolean.create({ "value" => false }) + false_id = b_false.id + b_true = Boolean.create({ "value" => true }) + true_id = b_true.id + + b_nil = Boolean.find(nil_id) + assert_nil b_nil.value + b_false = Boolean.find(false_id) + assert !b_false.value? + b_true = Boolean.find(true_id) + assert b_true.value? + end + + def test_boolean_without_questionmark + b_true = Boolean.create({ "value" => true }) + true_id = b_true.id + + subclass = Class.new(Boolean).find true_id + superclass = Boolean.find true_id + + assert_equal superclass.read_attribute(:has_fun), subclass.read_attribute(:has_fun) + end + + def test_boolean_cast_from_string + b_blank = Boolean.create({ "value" => "" }) + blank_id = b_blank.id + b_false = Boolean.create({ "value" => "0" }) + false_id = b_false.id + b_true = Boolean.create({ "value" => "1" }) + true_id = b_true.id + + b_blank = Boolean.find(blank_id) + assert_nil b_blank.value + b_false = Boolean.find(false_id) + assert !b_false.value? + b_true = Boolean.find(true_id) + assert b_true.value? + end + + def test_new_record_returns_boolean + assert_equal false, Topic.new.persisted? + assert_equal true, Topic.find(1).persisted? + end + + def test_dup + topic = Topic.find(1) + duped_topic = nil + assert_nothing_raised { duped_topic = topic.dup } + assert_equal topic.title, duped_topic.title + assert !duped_topic.persisted? + + # test if the attributes have been duped + topic.title = "a" + duped_topic.title = "b" + assert_equal "a", topic.title + assert_equal "b", duped_topic.title + + # test if the attribute values have been duped + duped_topic = topic.dup + duped_topic.title.replace "c" + assert_equal "a", topic.title + + # test if attributes set as part of after_initialize are duped correctly + assert_equal topic.author_email_address, duped_topic.author_email_address + + # test if saved clone object differs from original + duped_topic.save + assert duped_topic.persisted? + assert_not_equal duped_topic.id, topic.id + + duped_topic.reload + assert_equal("c", duped_topic.title) + end + + DeveloperSalary = Struct.new(:amount) + def test_dup_with_aggregate_of_same_name_as_attribute + developer_with_aggregate = Class.new(ActiveRecord::Base) do + self.table_name = 'developers' + composed_of :salary, :class_name => 'BasicsTest::DeveloperSalary', :mapping => [%w(salary amount)] + end + + dev = developer_with_aggregate.find(1) + assert_kind_of DeveloperSalary, dev.salary + + dup = nil + assert_nothing_raised { dup = dev.dup } + assert_kind_of DeveloperSalary, dup.salary + assert_equal dev.salary.amount, dup.salary.amount + assert !dup.persisted? + + # test if the attributes have been dupd + original_amount = dup.salary.amount + dev.salary.amount = 1 + assert_equal original_amount, dup.salary.amount + + assert dup.save + assert dup.persisted? + assert_not_equal dup.id, dev.id + end + + def test_dup_does_not_copy_associations + author = authors(:david) + assert_not_equal [], author.posts + author.send(:clear_association_cache) + + author_dup = author.dup + assert_equal [], author_dup.posts + end + + def test_clone_preserves_subtype + clone = nil + assert_nothing_raised { clone = Company.find(3).clone } + assert_kind_of Client, clone + end + + def test_clone_of_new_object_with_defaults + developer = Developer.new + assert !developer.name_changed? + assert !developer.salary_changed? + + cloned_developer = developer.clone + assert !cloned_developer.name_changed? + assert !cloned_developer.salary_changed? + end + + def test_clone_of_new_object_marks_attributes_as_dirty + developer = Developer.new :name => 'Bjorn', :salary => 100000 + assert developer.name_changed? + assert developer.salary_changed? + + cloned_developer = developer.clone + assert cloned_developer.name_changed? + assert cloned_developer.salary_changed? + end + + def test_clone_of_new_object_marks_as_dirty_only_changed_attributes + developer = Developer.new :name => 'Bjorn' + assert developer.name_changed? # obviously + assert !developer.salary_changed? # attribute has non-nil default value, so treated as not changed + + cloned_developer = developer.clone + assert cloned_developer.name_changed? + assert !cloned_developer.salary_changed? # ... and cloned instance should behave same + end + + def test_dup_of_saved_object_marks_attributes_as_dirty + developer = Developer.create! :name => 'Bjorn', :salary => 100000 + assert !developer.name_changed? + assert !developer.salary_changed? + + cloned_developer = developer.dup + assert cloned_developer.name_changed? # both attributes differ from defaults + assert cloned_developer.salary_changed? + end + + def test_dup_of_saved_object_marks_as_dirty_only_changed_attributes + developer = Developer.create! :name => 'Bjorn' + assert !developer.name_changed? # both attributes of saved object should be treated as not changed + assert !developer.salary_changed? + + cloned_developer = developer.dup + assert cloned_developer.name_changed? # ... but on cloned object should be + assert !cloned_developer.salary_changed? # ... BUT salary has non-nil default which should be treated as not changed on cloned instance + end + + def test_bignum + company = Company.find(1) + company.rating = 2147483647 + company.save + company = Company.find(1) + assert_equal 2147483647, company.rating + end + + # TODO: extend defaults tests to other databases! + if current_adapter?(:PostgreSQLAdapter) + def test_default + with_timezone_config default: :local do + default = Default.new + + # fixed dates / times + assert_equal Date.new(2004, 1, 1), default.fixed_date + assert_equal Time.local(2004, 1,1,0,0,0,0), default.fixed_time + + # char types + assert_equal 'Y', default.char1 + assert_equal 'a varchar field', default.char2 + assert_equal 'a text field', default.char3 + end + end + + class Geometric < ActiveRecord::Base; end + def test_geometric_content + + # accepted format notes: + # ()'s aren't required + # values can be a mix of float or integer + + g = Geometric.new( + :a_point => '(5.0, 6.1)', + #:a_line => '((2.0, 3), (5.5, 7.0))' # line type is currently unsupported in postgresql + :a_line_segment => '(2.0, 3), (5.5, 7.0)', + :a_box => '2.0, 3, 5.5, 7.0', + :a_path => '[(2.0, 3), (5.5, 7.0), (8.5, 11.0)]', # [ ] is an open path + :a_polygon => '((2.0, 3), (5.5, 7.0), (8.5, 11.0))', + :a_circle => '<(5.3, 10.4), 2>' + ) + + assert g.save + + # Reload and check that we have all the geometric attributes. + h = Geometric.find(g.id) + + assert_equal [5.0, 6.1], h.a_point + assert_equal '[(2,3),(5.5,7)]', h.a_line_segment + assert_equal '(5.5,7),(2,3)', h.a_box # reordered to store upper right corner then bottom left corner + assert_equal '[(2,3),(5.5,7),(8.5,11)]', h.a_path + assert_equal '((2,3),(5.5,7),(8.5,11))', h.a_polygon + assert_equal '<(5.3,10.4),2>', h.a_circle + + # use a geometric function to test for an open path + objs = Geometric.find_by_sql ["select isopen(a_path) from geometrics where id = ?", g.id] + + assert_equal true, objs[0].isopen + + # test alternate formats when defining the geometric types + + g = Geometric.new( + :a_point => '5.0, 6.1', + #:a_line => '((2.0, 3), (5.5, 7.0))' # line type is currently unsupported in postgresql + :a_line_segment => '((2.0, 3), (5.5, 7.0))', + :a_box => '(2.0, 3), (5.5, 7.0)', + :a_path => '((2.0, 3), (5.5, 7.0), (8.5, 11.0))', # ( ) is a closed path + :a_polygon => '2.0, 3, 5.5, 7.0, 8.5, 11.0', + :a_circle => '((5.3, 10.4), 2)' + ) + + assert g.save + + # Reload and check that we have all the geometric attributes. + h = Geometric.find(g.id) + + assert_equal [5.0, 6.1], h.a_point + assert_equal '[(2,3),(5.5,7)]', h.a_line_segment + assert_equal '(5.5,7),(2,3)', h.a_box # reordered to store upper right corner then bottom left corner + assert_equal '((2,3),(5.5,7),(8.5,11))', h.a_path + assert_equal '((2,3),(5.5,7),(8.5,11))', h.a_polygon + assert_equal '<(5.3,10.4),2>', h.a_circle + + # use a geometric function to test for an closed path + objs = Geometric.find_by_sql ["select isclosed(a_path) from geometrics where id = ?", g.id] + + assert_equal true, objs[0].isclosed + + # test native ruby formats when defining the geometric types + g = Geometric.new( + :a_point => [5.0, 6.1], + #:a_line => '((2.0, 3), (5.5, 7.0))' # line type is currently unsupported in postgresql + :a_line_segment => '((2.0, 3), (5.5, 7.0))', + :a_box => '(2.0, 3), (5.5, 7.0)', + :a_path => '((2.0, 3), (5.5, 7.0), (8.5, 11.0))', # ( ) is a closed path + :a_polygon => '2.0, 3, 5.5, 7.0, 8.5, 11.0', + :a_circle => '((5.3, 10.4), 2)' + ) + + assert g.save + + # Reload and check that we have all the geometric attributes. + h = Geometric.find(g.id) + + assert_equal [5.0, 6.1], h.a_point + assert_equal '[(2,3),(5.5,7)]', h.a_line_segment + assert_equal '(5.5,7),(2,3)', h.a_box # reordered to store upper right corner then bottom left corner + assert_equal '((2,3),(5.5,7),(8.5,11))', h.a_path + assert_equal '((2,3),(5.5,7),(8.5,11))', h.a_polygon + assert_equal '<(5.3,10.4),2>', h.a_circle + end + end + + class NumericData < ActiveRecord::Base + self.table_name = 'numeric_data' + + attribute :world_population, Type::Integer.new + attribute :my_house_population, Type::Integer.new + attribute :atoms_in_universe, Type::Integer.new + end + + def test_big_decimal_conditions + m = NumericData.new( + :bank_balance => 1586.43, + :big_bank_balance => BigDecimal("1000234000567.95"), + :world_population => 6000000000, + :my_house_population => 3 + ) + assert m.save + assert_equal 0, NumericData.where("bank_balance > ?", 2000.0).count + end + + def test_numeric_fields + m = NumericData.new( + :bank_balance => 1586.43, + :big_bank_balance => BigDecimal("1000234000567.95"), + :world_population => 6000000000, + :my_house_population => 3 + ) + assert m.save + + m1 = NumericData.find(m.id) + assert_not_nil m1 + + # As with migration_test.rb, we should make world_population >= 2**62 + # to cover 64-bit platforms and test it is a Bignum, but the main thing + # is that it's an Integer. + assert_kind_of Integer, m1.world_population + assert_equal 6000000000, m1.world_population + + assert_kind_of Fixnum, m1.my_house_population + assert_equal 3, m1.my_house_population + + assert_kind_of BigDecimal, m1.bank_balance + assert_equal BigDecimal("1586.43"), m1.bank_balance + + assert_kind_of BigDecimal, m1.big_bank_balance + assert_equal BigDecimal("1000234000567.95"), m1.big_bank_balance + end + + def test_auto_id + auto = AutoId.new + auto.save + assert(auto.id > 0) + end + + def test_sql_injection_via_find + assert_raise(ActiveRecord::RecordNotFound, ActiveRecord::StatementInvalid) do + Topic.find("123456 OR id > 0") + end + end + + def test_column_name_properly_quoted + col_record = ColumnName.new + col_record.references = 40 + assert col_record.save + col_record.references = 41 + assert col_record.save + assert_not_nil c2 = ColumnName.find(col_record.id) + assert_equal(41, c2.references) + end + + def test_quoting_arrays + replies = Reply.all.merge!(:where => [ "id IN (?)", topics(:first).replies.collect(&:id) ]).to_a + assert_equal topics(:first).replies.size, replies.size + + replies = Reply.all.merge!(:where => [ "id IN (?)", [] ]).to_a + assert_equal 0, replies.size + end + + def test_quote + author_name = "\\ \001 ' \n \\n \"" + topic = Topic.create('author_name' => author_name) + assert_equal author_name, Topic.find(topic.id).author_name + end + + def test_toggle_attribute + assert !topics(:first).approved? + topics(:first).toggle!(:approved) + assert topics(:first).approved? + topic = topics(:first) + topic.toggle(:approved) + assert !topic.approved? + topic.reload + assert topic.approved? + end + + def test_reload + t1 = Topic.find(1) + t2 = Topic.find(1) + t1.title = "something else" + t1.save + t2.reload + assert_equal t1.title, t2.title + end + + def test_reload_with_exclusive_scope + dev = DeveloperCalledDavid.first + dev.update!(name: "NotDavid" ) + assert_equal dev, dev.reload + end + + def test_switching_between_table_name + assert_difference("GoodJoke.count") do + Joke.table_name = "cold_jokes" + Joke.create + + Joke.table_name = "funny_jokes" + Joke.create + end + end + + def test_clear_cash_when_setting_table_name + Joke.table_name = "cold_jokes" + before_columns = Joke.columns + before_seq = Joke.sequence_name + + Joke.table_name = "funny_jokes" + after_columns = Joke.columns + after_seq = Joke.sequence_name + + assert_not_equal before_columns, after_columns + assert_not_equal before_seq, after_seq unless before_seq.nil? && after_seq.nil? + end + + def test_dont_clear_sequence_name_when_setting_explicitly + Joke.sequence_name = "black_jokes_seq" + Joke.table_name = "cold_jokes" + before_seq = Joke.sequence_name + + Joke.table_name = "funny_jokes" + after_seq = Joke.sequence_name + + assert_equal before_seq, after_seq unless before_seq.nil? && after_seq.nil? + ensure + Joke.reset_sequence_name + end + + def test_dont_clear_inheritance_column_when_setting_explicitly + Joke.inheritance_column = "my_type" + before_inherit = Joke.inheritance_column + + Joke.reset_column_information + after_inherit = Joke.inheritance_column + + assert_equal before_inherit, after_inherit unless before_inherit.blank? && after_inherit.blank? + end + + def test_set_table_name_symbol_converted_to_string + Joke.table_name = :cold_jokes + assert_equal 'cold_jokes', Joke.table_name + end + + def test_quoted_table_name_after_set_table_name + klass = Class.new(ActiveRecord::Base) + + klass.table_name = "foo" + assert_equal "foo", klass.table_name + assert_equal klass.connection.quote_table_name("foo"), klass.quoted_table_name + + klass.table_name = "bar" + assert_equal "bar", klass.table_name + assert_equal klass.connection.quote_table_name("bar"), klass.quoted_table_name + end + + def test_set_table_name_with_inheritance + k = Class.new( ActiveRecord::Base ) + def k.name; "Foo"; end + def k.table_name; super + "ks"; end + assert_equal "foosks", k.table_name + end + + def test_sequence_name_with_abstract_class + ak = Class.new(ActiveRecord::Base) + ak.abstract_class = true + k = Class.new(ak) + k.table_name = "projects" + orig_name = k.sequence_name + skip "sequences not supported by db" unless orig_name + assert_equal k.reset_sequence_name, orig_name + end + + def test_count_with_join + res = Post.count_by_sql "SELECT COUNT(*) FROM posts LEFT JOIN comments ON posts.id=comments.post_id WHERE posts.#{QUOTED_TYPE} = 'Post'" + + res2 = Post.where("posts.#{QUOTED_TYPE} = 'Post'").joins("LEFT JOIN comments ON posts.id=comments.post_id").count + assert_equal res, res2 + + res3 = nil + assert_nothing_raised do + res3 = Post.where("posts.#{QUOTED_TYPE} = 'Post'").joins("LEFT JOIN comments ON posts.id=comments.post_id").count + end + assert_equal res, res3 + + res4 = Post.count_by_sql "SELECT COUNT(p.id) FROM posts p, comments co WHERE p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id" + res5 = nil + assert_nothing_raised do + res5 = Post.where("p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id").joins("p, comments co").select("p.id").count + end + + assert_equal res4, res5 + + res6 = Post.count_by_sql "SELECT COUNT(DISTINCT p.id) FROM posts p, comments co WHERE p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id" + res7 = nil + assert_nothing_raised do + res7 = Post.where("p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id").joins("p, comments co").select("p.id").distinct.count + end + assert_equal res6, res7 + end + + def test_no_limit_offset + assert_nothing_raised do + Developer.all.merge!(:offset => 2).to_a + end + end + + def test_find_last + last = Developer.last + assert_equal last, Developer.all.merge!(:order => 'id desc').first + end + + def test_last + assert_equal Developer.all.merge!(:order => 'id desc').first, Developer.last + end + + def test_all + developers = Developer.all + assert_kind_of ActiveRecord::Relation, developers + assert_equal Developer.all, developers + end + + def test_all_with_conditions + assert_equal Developer.all.merge!(:order => 'id desc').to_a, Developer.order('id desc').to_a + end + + def test_find_ordered_last + last = Developer.all.merge!(:order => 'developers.salary ASC').last + assert_equal last, Developer.all.merge!(:order => 'developers.salary ASC').to_a.last + end + + def test_find_reverse_ordered_last + last = Developer.all.merge!(:order => 'developers.salary DESC').last + assert_equal last, Developer.all.merge!(:order => 'developers.salary DESC').to_a.last + end + + def test_find_multiple_ordered_last + last = Developer.all.merge!(:order => 'developers.name, developers.salary DESC').last + assert_equal last, Developer.all.merge!(:order => 'developers.name, developers.salary DESC').to_a.last + end + + def test_find_keeps_multiple_order_values + combined = Developer.all.merge!(:order => 'developers.name, developers.salary').to_a + assert_equal combined, Developer.all.merge!(:order => ['developers.name', 'developers.salary']).to_a + end + + def test_find_keeps_multiple_group_values + combined = Developer.all.merge!(:group => 'developers.name, developers.salary, developers.id, developers.created_at, developers.updated_at, developers.created_on, developers.updated_on').to_a + assert_equal combined, Developer.all.merge!(:group => ['developers.name', 'developers.salary', 'developers.id', 'developers.created_at', 'developers.updated_at', 'developers.created_on', 'developers.updated_on']).to_a + end + + def test_find_symbol_ordered_last + last = Developer.all.merge!(:order => :salary).last + assert_equal last, Developer.all.merge!(:order => :salary).to_a.last + end + + def test_abstract_class + assert !ActiveRecord::Base.abstract_class? + assert LoosePerson.abstract_class? + assert !LooseDescendant.abstract_class? + end + + def test_abstract_class_table_name + assert_nil AbstractCompany.table_name + end + + def test_descends_from_active_record + assert !ActiveRecord::Base.descends_from_active_record? + + # Abstract subclass of AR::Base. + assert LoosePerson.descends_from_active_record? + + # Concrete subclass of an abstract class. + assert LooseDescendant.descends_from_active_record? + + # Concrete subclass of AR::Base. + assert TightPerson.descends_from_active_record? + + # Concrete subclass of a concrete class but has no type column. + assert TightDescendant.descends_from_active_record? + + # Concrete subclass of AR::Base. + assert Post.descends_from_active_record? + + # Abstract subclass of a concrete class which has a type column. + # This is pathological, as you'll never have Sub < Abstract < Concrete. + assert !StiPost.descends_from_active_record? + + # Concrete subclasses an abstract class which has a type column. + assert !SubStiPost.descends_from_active_record? + end + + def test_find_on_abstract_base_class_doesnt_use_type_condition + old_class = LooseDescendant + Object.send :remove_const, :LooseDescendant + + descendant = old_class.create! :first_name => 'bob' + assert_not_nil LoosePerson.find(descendant.id), "Should have found instance of LooseDescendant when finding abstract LoosePerson: #{descendant.inspect}" + ensure + unless Object.const_defined?(:LooseDescendant) + Object.const_set :LooseDescendant, old_class + end + end + + def test_assert_queries + query = lambda { ActiveRecord::Base.connection.execute 'select count(*) from developers' } + assert_queries(2) { 2.times { query.call } } + assert_queries 1, &query + assert_no_queries { assert true } + end + + def test_benchmark_with_log_level + original_logger = ActiveRecord::Base.logger + log = StringIO.new + ActiveRecord::Base.logger = ActiveSupport::Logger.new(log) + ActiveRecord::Base.logger.level = Logger::WARN + ActiveRecord::Base.benchmark("Debug Topic Count", :level => :debug) { Topic.count } + ActiveRecord::Base.benchmark("Warn Topic Count", :level => :warn) { Topic.count } + ActiveRecord::Base.benchmark("Error Topic Count", :level => :error) { Topic.count } + assert_no_match(/Debug Topic Count/, log.string) + assert_match(/Warn Topic Count/, log.string) + assert_match(/Error Topic Count/, log.string) + ensure + ActiveRecord::Base.logger = original_logger + end + + def test_benchmark_with_use_silence + original_logger = ActiveRecord::Base.logger + log = StringIO.new + ActiveRecord::Base.logger = ActiveSupport::Logger.new(log) + ActiveRecord::Base.benchmark("Logging", :level => :debug, :silence => false) { ActiveRecord::Base.logger.debug "Quiet" } + assert_match(/Quiet/, log.string) + ensure + ActiveRecord::Base.logger = original_logger + end + + def test_compute_type_success + assert_equal Author, ActiveRecord::Base.send(:compute_type, 'Author') + end + + def test_compute_type_nonexistent_constant + e = assert_raises NameError do + ActiveRecord::Base.send :compute_type, 'NonexistentModel' + end + assert_equal 'uninitialized constant ActiveRecord::Base::NonexistentModel', e.message + assert_equal 'ActiveRecord::Base::NonexistentModel', e.name + end + + def test_compute_type_no_method_error + ActiveSupport::Dependencies.stubs(:safe_constantize).raises(NoMethodError) + assert_raises NoMethodError do + ActiveRecord::Base.send :compute_type, 'InvalidModel' + end + end + + def test_compute_type_on_undefined_method + error = nil + begin + Class.new(Author) do + alias_method :foo, :bar + end + rescue => e + error = e + end + + ActiveSupport::Dependencies.stubs(:safe_constantize).raises(e) + + exception = assert_raises NameError do + ActiveRecord::Base.send :compute_type, 'InvalidModel' + end + assert_equal error.message, exception.message + end + + def test_compute_type_argument_error + ActiveSupport::Dependencies.stubs(:safe_constantize).raises(ArgumentError) + assert_raises ArgumentError do + ActiveRecord::Base.send :compute_type, 'InvalidModel' + end + end + + def test_clear_cache! + # preheat cache + c1 = Post.connection.schema_cache.columns('posts') + ActiveRecord::Base.clear_cache! + c2 = Post.connection.schema_cache.columns('posts') + assert_not_equal c1, c2 + end + + def test_current_scope_is_reset + Object.const_set :UnloadablePost, Class.new(ActiveRecord::Base) + UnloadablePost.send(:current_scope=, UnloadablePost.all) + + UnloadablePost.unloadable + assert_not_nil ActiveRecord::Scoping::ScopeRegistry.value_for(:current_scope, "UnloadablePost") + ActiveSupport::Dependencies.remove_unloadable_constants! + assert_nil ActiveRecord::Scoping::ScopeRegistry.value_for(:current_scope, "UnloadablePost") + ensure + Object.class_eval{ remove_const :UnloadablePost } if defined?(UnloadablePost) + end + + def test_marshal_round_trip + expected = posts(:welcome) + marshalled = Marshal.dump(expected) + actual = Marshal.load(marshalled) + + assert_equal expected.attributes, actual.attributes + end + + def test_marshal_new_record_round_trip + marshalled = Marshal.dump(Post.new) + post = Marshal.load(marshalled) + + assert post.new_record?, "should be a new record" + end + + def test_marshalling_with_associations + post = Post.new + post.comments.build + + marshalled = Marshal.dump(post) + post = Marshal.load(marshalled) + + assert_equal 1, post.comments.length + end + + if Process.respond_to?(:fork) && !in_memory_db? + def test_marshal_between_processes + # Define a new model to ensure there are no caches + if self.class.const_defined?("Post", false) + flunk "there should be no post constant" + end + + self.class.const_set("Post", Class.new(ActiveRecord::Base) { + has_many :comments + }) + + rd, wr = IO.pipe + rd.binmode + wr.binmode + + ActiveRecord::Base.connection_handler.clear_all_connections! + + fork do + rd.close + post = Post.new + post.comments.build + wr.write Marshal.dump(post) + wr.close + end + + wr.close + assert Marshal.load rd.read + rd.close + end + end + + def test_marshalling_new_record_round_trip_with_associations + post = Post.new + post.comments.build + + post = Marshal.load(Marshal.dump(post)) + + assert post.new_record?, "should be a new record" + end + + def test_attribute_names + assert_equal ["id", "type", "firm_id", "firm_name", "name", "client_of", "rating", "account_id", "description"], + Company.attribute_names + end + + def test_attribute_names_on_table_not_exists + assert_equal [], NonExistentTable.attribute_names + end + + def test_attribute_names_on_abstract_class + assert_equal [], AbstractCompany.attribute_names + end + + def test_touch_should_raise_error_on_a_new_object + company = Company.new(:rating => 1, :name => "37signals", :firm_name => "37signals") + assert_raises(ActiveRecord::ActiveRecordError) do + company.touch :updated_at + end + end + + def test_uniq_delegates_to_scoped + scope = stub + Bird.stubs(:all).returns(mock(:uniq => scope)) + assert_equal scope, Bird.uniq + end + + def test_distinct_delegates_to_scoped + scope = stub + Bird.stubs(:all).returns(mock(:distinct => scope)) + assert_equal scope, Bird.distinct + end + + def test_table_name_with_2_abstract_subclasses + assert_equal "photos", Photo.table_name + end + + def test_column_types_typecast + topic = Topic.first + assert_not_equal 't.lo', topic.author_name + + attrs = topic.attributes.dup + attrs.delete 'id' + + typecast = Class.new(ActiveRecord::Type::Value) { + def type_cast value + "t.lo" + end + } + + types = { 'author_name' => typecast.new } + topic = Topic.instantiate(attrs, types) + + assert_equal 't.lo', topic.author_name + end + + def test_typecasting_aliases + assert_equal 10, Topic.select('10 as tenderlove').first.tenderlove + end + + def test_slice + company = Company.new(:rating => 1, :name => "37signals", :firm_name => "37signals") + hash = company.slice(:name, :rating, "arbitrary_method") + assert_equal hash[:name], company.name + assert_equal hash['name'], company.name + assert_equal hash[:rating], company.rating + assert_equal hash['arbitrary_method'], company.arbitrary_method + assert_equal hash[:arbitrary_method], company.arbitrary_method + assert_nil hash[:firm_name] + assert_nil hash['firm_name'] + end + + def test_default_values_are_deeply_dupped + company = Company.new + company.description << "foo" + assert_equal "", Company.new.description + end + + ["find_by", "find_by!"].each do |meth| + test "#{meth} delegates to scoped" do + record = stub + + scope = mock + scope.expects(meth).with(:foo, :bar).returns(record) + + klass = Class.new(ActiveRecord::Base) + klass.stubs(:all => scope) + + assert_equal record, klass.public_send(meth, :foo, :bar) + end + end + + test "scoped can take a values hash" do + klass = Class.new(ActiveRecord::Base) + assert_equal ['foo'], klass.all.merge!(select: 'foo').select_values + end + + test "connection_handler can be overridden" do + klass = Class.new(ActiveRecord::Base) + orig_handler = klass.connection_handler + new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new + thread_connection_handler = nil + + t = Thread.new do + klass.connection_handler = new_handler + thread_connection_handler = klass.connection_handler + end + t.join + + assert_equal klass.connection_handler, orig_handler + assert_equal thread_connection_handler, new_handler + end + + test "new threads get default the default connection handler" do + klass = Class.new(ActiveRecord::Base) + orig_handler = klass.connection_handler + handler = nil + + t = Thread.new do + handler = klass.connection_handler + end + t.join + + assert_equal handler, orig_handler + assert_equal klass.connection_handler, orig_handler + assert_equal klass.default_connection_handler, orig_handler + end + + test "changing a connection handler in a main thread does not poison the other threads" do + klass = Class.new(ActiveRecord::Base) + orig_handler = klass.connection_handler + new_handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new + after_handler = nil + latch1 = ActiveSupport::Concurrency::Latch.new + latch2 = ActiveSupport::Concurrency::Latch.new + + t = Thread.new do + klass.connection_handler = new_handler + latch1.release + latch2.await + after_handler = klass.connection_handler + end + + latch1.await + + klass.connection_handler = orig_handler + latch2.release + t.join + + assert_equal after_handler, new_handler + assert_equal orig_handler, klass.connection_handler + end + + # Note: This is a performance optimization for Array#uniq and Hash#[] with + # AR::Base objects. If the future has made this irrelevant, feel free to + # delete this. + test "records without an id have unique hashes" do + assert_not_equal Post.new.hash, Post.new.hash + end +end diff --git a/activerecord/test/cases/batches_test.rb b/activerecord/test/cases/batches_test.rb new file mode 100644 index 0000000000..c12fa03015 --- /dev/null +++ b/activerecord/test/cases/batches_test.rb @@ -0,0 +1,212 @@ +require 'cases/helper' +require 'models/post' +require 'models/subscriber' + +class EachTest < ActiveRecord::TestCase + fixtures :posts, :subscribers + + def setup + @posts = Post.order("id asc") + @total = Post.count + Post.count('id') # preheat arel's table cache + end + + def test_each_should_execute_one_query_per_batch + assert_queries(@total + 1) do + Post.find_each(:batch_size => 1) do |post| + assert_kind_of Post, post + end + end + end + + def test_each_should_not_return_query_chain_and_execute_only_one_query + assert_queries(1) do + result = Post.find_each(:batch_size => 100000){ } + assert_nil result + end + end + + def test_each_should_return_an_enumerator_if_no_block_is_present + assert_queries(1) do + Post.find_each(:batch_size => 100000).with_index do |post, index| + assert_kind_of Post, post + assert_kind_of Integer, index + end + end + end + + if Enumerator.method_defined? :size + def test_each_should_return_a_sized_enumerator + assert_equal 11, Post.find_each(:batch_size => 1).size + assert_equal 5, Post.find_each(:batch_size => 2, :start => 7).size + assert_equal 11, Post.find_each(:batch_size => 10_000).size + end + end + + def test_each_enumerator_should_execute_one_query_per_batch + assert_queries(@total + 1) do + Post.find_each(:batch_size => 1).with_index do |post, index| + assert_kind_of Post, post + assert_kind_of Integer, index + end + end + end + + def test_each_should_raise_if_select_is_set_without_id + assert_raise(RuntimeError) do + Post.select(:title).find_each(batch_size: 1) { |post| + flunk "should not call this block" + } + end + end + + def test_each_should_execute_if_id_is_in_select + assert_queries(6) do + Post.select("id, title, type").find_each(:batch_size => 2) do |post| + assert_kind_of Post, post + end + end + end + + def test_warn_if_limit_scope_is_set + ActiveRecord::Base.logger.expects(:warn) + Post.limit(1).find_each { |post| post } + end + + def test_warn_if_order_scope_is_set + ActiveRecord::Base.logger.expects(:warn) + Post.order("title").find_each { |post| post } + end + + def test_logger_not_required + previous_logger = ActiveRecord::Base.logger + ActiveRecord::Base.logger = nil + assert_nothing_raised do + Post.limit(1).find_each { |post| post } + end + ensure + ActiveRecord::Base.logger = previous_logger + end + + def test_find_in_batches_should_return_batches + assert_queries(@total + 1) do + Post.find_in_batches(:batch_size => 1) do |batch| + assert_kind_of Array, batch + assert_kind_of Post, batch.first + end + end + end + + def test_find_in_batches_should_start_from_the_start_option + assert_queries(@total) do + Post.find_in_batches(:batch_size => 1, :start => 2) do |batch| + assert_kind_of Array, batch + assert_kind_of Post, batch.first + end + end + end + + def test_find_in_batches_shouldnt_execute_query_unless_needed + assert_queries(2) do + Post.find_in_batches(:batch_size => @total) {|batch| assert_kind_of Array, batch } + end + + assert_queries(1) do + Post.find_in_batches(:batch_size => @total + 1) {|batch| assert_kind_of Array, batch } + end + end + + def test_find_in_batches_should_quote_batch_order + c = Post.connection + assert_sql(/ORDER BY #{c.quote_table_name('posts')}.#{c.quote_column_name('id')}/) do + Post.find_in_batches(:batch_size => 1) do |batch| + assert_kind_of Array, batch + assert_kind_of Post, batch.first + end + end + end + + def test_find_in_batches_should_not_use_records_after_yielding_them_in_case_original_array_is_modified + not_a_post = "not a post" + not_a_post.stubs(:id).raises(StandardError, "not_a_post had #id called on it") + + assert_nothing_raised do + Post.find_in_batches(:batch_size => 1) do |batch| + assert_kind_of Array, batch + assert_kind_of Post, batch.first + + batch.map! { not_a_post } + end + end + end + + def test_find_in_batches_should_ignore_the_order_default_scope + # First post is with title scope + first_post = PostWithDefaultScope.first + posts = [] + PostWithDefaultScope.find_in_batches do |batch| + posts.concat(batch) + end + # posts.first will be ordered using id only. Title order scope should not apply here + assert_not_equal first_post, posts.first + assert_equal posts(:welcome), posts.first + end + + def test_find_in_batches_should_not_ignore_the_default_scope_if_it_is_other_then_order + special_posts_ids = SpecialPostWithDefaultScope.all.map(&:id).sort + posts = [] + SpecialPostWithDefaultScope.find_in_batches do |batch| + posts.concat(batch) + end + assert_equal special_posts_ids, posts.map(&:id) + end + + def test_find_in_batches_should_not_modify_passed_options + assert_nothing_raised do + Post.find_in_batches({ batch_size: 42, start: 1 }.freeze){} + end + end + + def test_find_in_batches_should_use_any_column_as_primary_key + nick_order_subscribers = Subscriber.order('nick asc') + start_nick = nick_order_subscribers.second.nick + + subscribers = [] + Subscriber.find_in_batches(:batch_size => 1, :start => start_nick) do |batch| + subscribers.concat(batch) + end + + assert_equal nick_order_subscribers[1..-1].map(&:id), subscribers.map(&:id) + end + + def test_find_in_batches_should_use_any_column_as_primary_key_when_start_is_not_specified + assert_queries(Subscriber.count + 1) do + Subscriber.find_each(:batch_size => 1) do |subscriber| + assert_kind_of Subscriber, subscriber + end + end + end + + def test_find_in_batches_should_return_an_enumerator + enum = nil + assert_queries(0) do + enum = Post.find_in_batches(:batch_size => 1) + end + assert_queries(4) do + enum.first(4) do |batch| + assert_kind_of Array, batch + assert_kind_of Post, batch.first + end + end + end + + if Enumerator.method_defined? :size + def test_find_in_batches_should_return_a_sized_enumerator + assert_equal 11, Post.find_in_batches(:batch_size => 1).size + assert_equal 6, Post.find_in_batches(:batch_size => 2).size + assert_equal 4, Post.find_in_batches(:batch_size => 2, :start => 4).size + assert_equal 4, Post.find_in_batches(:batch_size => 3).size + assert_equal 1, Post.find_in_batches(:batch_size => 10_000).size + end + end +end diff --git a/activerecord/test/cases/binary_test.rb b/activerecord/test/cases/binary_test.rb new file mode 100644 index 0000000000..ccf2be369d --- /dev/null +++ b/activerecord/test/cases/binary_test.rb @@ -0,0 +1,49 @@ +# encoding: utf-8 +require "cases/helper" + +# Without using prepared statements, it makes no sense to test +# BLOB data with DB2, because the length of a statement +# is limited to 32KB. +unless current_adapter?(:DB2Adapter) + require 'models/binary' + + class BinaryTest < ActiveRecord::TestCase + FIXTURES = %w(flowers.jpg example.log test.txt) + + def test_mixed_encoding + str = "\x80" + str.force_encoding('ASCII-8BIT') + + binary = Binary.new :name => 'いただきます!', :data => str + binary.save! + binary.reload + assert_equal str, binary.data + + name = binary.name + + # MySQL adapter doesn't properly encode things, so we have to do it + if current_adapter?(:MysqlAdapter) + name.force_encoding(Encoding::UTF_8) + end + assert_equal 'いただきます!', name + end + + def test_load_save + Binary.delete_all + + FIXTURES.each do |filename| + data = File.read(ASSETS_ROOT + "/#{filename}") + data.force_encoding('ASCII-8BIT') + data.freeze + + bin = Binary.new(:data => data) + assert_equal data, bin.data, 'Newly assigned data differs from original' + + bin.save! + assert_equal data, bin.data, 'Data differs from original after save' + + assert_equal data, bin.reload.data, 'Reloaded data differs from original' + end + end + end +end diff --git a/activerecord/test/cases/bind_parameter_test.rb b/activerecord/test/cases/bind_parameter_test.rb new file mode 100644 index 0000000000..0bc7ee6d64 --- /dev/null +++ b/activerecord/test/cases/bind_parameter_test.rb @@ -0,0 +1,92 @@ +require 'cases/helper' +require 'models/topic' + +module ActiveRecord + class BindParameterTest < ActiveRecord::TestCase + fixtures :topics + + class LogListener + attr_accessor :calls + + def initialize + @calls = [] + end + + def call(*args) + calls << args + end + end + + def setup + super + @connection = ActiveRecord::Base.connection + @subscriber = LogListener.new + @pk = Topic.columns_hash[Topic.primary_key] + @subscription = ActiveSupport::Notifications.subscribe('sql.active_record', @subscriber) + end + + teardown do + ActiveSupport::Notifications.unsubscribe(@subscription) + end + + if ActiveRecord::Base.connection.supports_statement_cache? + def test_binds_are_logged + sub = @connection.substitute_at(@pk, 0) + binds = [[@pk, 1]] + sql = "select * from topics where id = #{sub}" + + @connection.exec_query(sql, 'SQL', binds) + + message = @subscriber.calls.find { |args| args[4][:sql] == sql } + assert_equal binds, message[4][:binds] + end + + def test_binds_are_logged_after_type_cast + sub = @connection.substitute_at(@pk, 0) + binds = [[@pk, "3"]] + sql = "select * from topics where id = #{sub}" + + @connection.exec_query(sql, 'SQL', binds) + + message = @subscriber.calls.find { |args| args[4][:sql] == sql } + assert_equal [[@pk, 3]], message[4][:binds] + end + + def test_find_one_uses_binds + Topic.find(1) + binds = [[@pk, 1]] + message = @subscriber.calls.find { |args| args[4][:binds] == binds } + assert message, 'expected a message with binds' + end + + def test_logs_bind_vars + payload = { + :name => 'SQL', + :sql => 'select * from topics where id = ?', + :binds => [[@pk, 10]] + } + event = ActiveSupport::Notifications::Event.new( + 'foo', + Time.now, + Time.now, + 123, + payload) + + logger = Class.new(ActiveRecord::LogSubscriber) { + attr_reader :debugs + def initialize + super + @debugs = [] + end + + def debug str + @debugs << str + end + }.new + + logger.sql event + assert_match([[@pk.name, 10]].inspect, logger.debugs.first) + end + end + end +end diff --git a/activerecord/test/cases/calculations_test.rb b/activerecord/test/cases/calculations_test.rb new file mode 100644 index 0000000000..319ea9260a --- /dev/null +++ b/activerecord/test/cases/calculations_test.rb @@ -0,0 +1,615 @@ +require "cases/helper" +require 'models/club' +require 'models/company' +require "models/contract" +require 'models/edge' +require 'models/organization' +require 'models/possession' +require 'models/topic' +require 'models/reply' +require 'models/minivan' +require 'models/speedometer' +require 'models/ship_part' + +Company.has_many :accounts + +class NumericData < ActiveRecord::Base + self.table_name = 'numeric_data' + + attribute :world_population, Type::Integer.new + attribute :my_house_population, Type::Integer.new + attribute :atoms_in_universe, Type::Integer.new +end + +class CalculationsTest < ActiveRecord::TestCase + fixtures :companies, :accounts, :topics + + def test_should_sum_field + assert_equal 318, Account.sum(:credit_limit) + end + + def test_should_average_field + value = Account.average(:credit_limit) + assert_equal 53.0, value + end + + def test_should_resolve_aliased_attributes + assert_equal 318, Account.sum(:available_credit) + end + + def test_should_return_decimal_average_of_integer_field + value = Account.average(:id) + assert_equal 3.5, value + end + + def test_should_return_integer_average_if_db_returns_such + ShipPart.delete_all + ShipPart.create!(:id => 3, :name => 'foo') + value = ShipPart.average(:id) + assert_equal 3, value + end + + def test_should_return_nil_as_average + assert_nil NumericData.average(:bank_balance) + end + + def test_should_get_maximum_of_field + assert_equal 60, Account.maximum(:credit_limit) + end + + def test_should_get_maximum_of_field_with_include + assert_equal 55, Account.where("companies.name != 'Summit'").references(:companies).includes(:firm).maximum(:credit_limit) + end + + def test_should_get_minimum_of_field + assert_equal 50, Account.minimum(:credit_limit) + end + + def test_should_group_by_field + c = Account.group(:firm_id).sum(:credit_limit) + [1,6,2].each do |firm_id| + assert c.keys.include?(firm_id), "Group #{c.inspect} does not contain firm_id #{firm_id}" + end + end + + def test_should_group_by_arel_attribute + c = Account.group(Account.arel_table[:firm_id]).sum(:credit_limit) + [1,6,2].each do |firm_id| + assert c.keys.include?(firm_id), "Group #{c.inspect} does not contain firm_id #{firm_id}" + end + end + + def test_should_group_by_multiple_fields + c = Account.group('firm_id', :credit_limit).count(:all) + [ [nil, 50], [1, 50], [6, 50], [6, 55], [9, 53], [2, 60] ].each { |firm_and_limit| assert c.keys.include?(firm_and_limit) } + end + + def test_should_group_by_multiple_fields_having_functions + c = Topic.group(:author_name, 'COALESCE(type, title)').count(:all) + assert_equal 1, c[["Carl", "The Third Topic of the day"]] + assert_equal 1, c[["Mary", "Reply"]] + assert_equal 1, c[["David", "The First Topic"]] + assert_equal 1, c[["Carl", "Reply"]] + end + + def test_should_group_by_summed_field + c = Account.group(:firm_id).sum(:credit_limit) + assert_equal 50, c[1] + assert_equal 105, c[6] + assert_equal 60, c[2] + end + + def test_should_order_by_grouped_field + c = Account.group(:firm_id).order("firm_id").sum(:credit_limit) + assert_equal [1, 2, 6, 9], c.keys.compact + end + + def test_should_order_by_calculation + c = Account.group(:firm_id).order("sum_credit_limit desc, firm_id").sum(:credit_limit) + assert_equal [105, 60, 53, 50, 50], c.keys.collect { |k| c[k] } + assert_equal [6, 2, 9, 1], c.keys.compact + end + + def test_should_limit_calculation + c = Account.where("firm_id IS NOT NULL").group(:firm_id).order("firm_id").limit(2).sum(:credit_limit) + assert_equal [1, 2], c.keys.compact + end + + def test_should_limit_calculation_with_offset + c = Account.where("firm_id IS NOT NULL").group(:firm_id).order("firm_id"). + limit(2).offset(1).sum(:credit_limit) + assert_equal [2, 6], c.keys.compact + end + + def test_limit_should_apply_before_count + accounts = Account.limit(3).where('firm_id IS NOT NULL') + + assert_equal 3, accounts.count(:firm_id) + assert_equal 3, accounts.select(:firm_id).count + end + + def test_count_should_shortcut_with_limit_zero + accounts = Account.limit(0) + + assert_no_queries { assert_equal 0, accounts.count } + end + + def test_limit_is_kept + return if current_adapter?(:OracleAdapter) + + queries = assert_sql { Account.limit(1).count } + assert_equal 1, queries.length + assert_match(/LIMIT/, queries.first) + end + + def test_offset_is_kept + return if current_adapter?(:OracleAdapter) + + queries = assert_sql { Account.offset(1).count } + assert_equal 1, queries.length + assert_match(/OFFSET/, queries.first) + end + + def test_limit_with_offset_is_kept + return if current_adapter?(:OracleAdapter) + + queries = assert_sql { Account.limit(1).offset(1).count } + assert_equal 1, queries.length + assert_match(/LIMIT/, queries.first) + assert_match(/OFFSET/, queries.first) + end + + def test_no_limit_no_offset + queries = assert_sql { Account.count } + assert_equal 1, queries.length + assert_no_match(/LIMIT/, queries.first) + assert_no_match(/OFFSET/, queries.first) + end + + def test_count_on_invalid_columns_raises + e = assert_raises(ActiveRecord::StatementInvalid) { + Account.select("credit_limit, firm_name").count + } + + assert_match %r{accounts}i, e.message + assert_match "credit_limit, firm_name", e.message + end + + def test_should_group_by_summed_field_having_condition + c = Account.group(:firm_id).having('sum(credit_limit) > 50').sum(:credit_limit) + assert_nil c[1] + assert_equal 105, c[6] + assert_equal 60, c[2] + end + + def test_should_group_by_summed_field_having_condition_from_select + c = Account.select("MIN(credit_limit) AS min_credit_limit").group(:firm_id).having("MIN(credit_limit) > 50").sum(:credit_limit) + assert_nil c[1] + assert_equal 60, c[2] + assert_equal 53, c[9] + end + + def test_should_group_by_summed_association + c = Account.group(:firm).sum(:credit_limit) + assert_equal 50, c[companies(:first_firm)] + assert_equal 105, c[companies(:rails_core)] + assert_equal 60, c[companies(:first_client)] + end + + def test_should_sum_field_with_conditions + assert_equal 105, Account.where('firm_id = 6').sum(:credit_limit) + end + + def test_should_return_zero_if_sum_conditions_return_nothing + assert_equal 0, Account.where('1 = 2').sum(:credit_limit) + assert_equal 0, companies(:rails_core).companies.where('1 = 2').sum(:id) + end + + def test_sum_should_return_valid_values_for_decimals + NumericData.create(:bank_balance => 19.83) + assert_equal 19.83, NumericData.sum(:bank_balance) + end + + def test_should_return_type_casted_values_with_group_and_expression + assert_equal 0.5, Account.group(:firm_name).sum('0.01 * credit_limit')['37signals'] + end + + def test_should_group_by_summed_field_with_conditions + c = Account.where('firm_id > 1').group(:firm_id).sum(:credit_limit) + assert_nil c[1] + assert_equal 105, c[6] + assert_equal 60, c[2] + end + + def test_should_group_by_summed_field_with_conditions_and_having + c = Account.where('firm_id > 1').group(:firm_id). + having('sum(credit_limit) > 60').sum(:credit_limit) + assert_nil c[1] + assert_equal 105, c[6] + assert_nil c[2] + end + + def test_should_group_by_fields_with_table_alias + c = Account.group('accounts.firm_id').sum(:credit_limit) + assert_equal 50, c[1] + assert_equal 105, c[6] + assert_equal 60, c[2] + end + + def test_should_calculate_with_invalid_field + assert_equal 6, Account.calculate(:count, '*') + assert_equal 6, Account.calculate(:count, :all) + end + + def test_should_calculate_grouped_with_invalid_field + c = Account.group('accounts.firm_id').count(:all) + assert_equal 1, c[1] + assert_equal 2, c[6] + assert_equal 1, c[2] + end + + def test_should_calculate_grouped_association_with_invalid_field + c = Account.group(:firm).count(:all) + assert_equal 1, c[companies(:first_firm)] + assert_equal 2, c[companies(:rails_core)] + assert_equal 1, c[companies(:first_client)] + end + + def test_should_group_by_association_with_non_numeric_foreign_key + Speedometer.create! id: 'ABC' + Minivan.create! id: 'OMG', speedometer_id: 'ABC' + + c = Minivan.group(:speedometer).count(:all) + first_key = c.keys.first + assert_equal Speedometer, first_key.class + assert_equal 1, c[first_key] + end + + def test_should_calculate_grouped_association_with_foreign_key_option + Account.belongs_to :another_firm, :class_name => 'Firm', :foreign_key => 'firm_id' + c = Account.group(:another_firm).count(:all) + assert_equal 1, c[companies(:first_firm)] + assert_equal 2, c[companies(:rails_core)] + assert_equal 1, c[companies(:first_client)] + end + + def test_should_calculate_grouped_by_function + c = Company.group("UPPER(#{QUOTED_TYPE})").count(:all) + assert_equal 2, c[nil] + assert_equal 1, c['DEPENDENTFIRM'] + assert_equal 5, c['CLIENT'] + assert_equal 2, c['FIRM'] + end + + def test_should_calculate_grouped_by_function_with_table_alias + c = Company.group("UPPER(companies.#{QUOTED_TYPE})").count(:all) + assert_equal 2, c[nil] + assert_equal 1, c['DEPENDENTFIRM'] + assert_equal 5, c['CLIENT'] + assert_equal 2, c['FIRM'] + end + + def test_should_not_overshadow_enumerable_sum + assert_equal 6, [1, 2, 3].sum(&:abs) + end + + def test_should_sum_scoped_field + assert_equal 15, companies(:rails_core).companies.sum(:id) + end + + def test_should_sum_scoped_field_with_from + assert_equal Club.count, Organization.clubs.count + end + + def test_should_sum_scoped_field_with_conditions + assert_equal 8, companies(:rails_core).companies.where('id > 7').sum(:id) + end + + def test_should_group_by_scoped_field + c = companies(:rails_core).companies.group(:name).sum(:id) + assert_equal 7, c['Leetsoft'] + assert_equal 8, c['Jadedpixel'] + end + + def test_should_group_by_summed_field_through_association_and_having + c = companies(:rails_core).companies.group(:name).having('sum(id) > 7').sum(:id) + assert_nil c['Leetsoft'] + assert_equal 8, c['Jadedpixel'] + end + + def test_should_count_selected_field_with_include + assert_equal 6, Account.includes(:firm).distinct.count + assert_equal 4, Account.includes(:firm).distinct.select(:credit_limit).count + end + + def test_should_not_perform_joined_include_by_default + assert_equal Account.count, Account.includes(:firm).count + queries = assert_sql { Account.includes(:firm).count } + assert_no_match(/join/i, queries.last) + end + + def test_should_perform_joined_include_when_referencing_included_tables + joined_count = Account.includes(:firm).where(:companies => {:name => '37signals'}).count + assert_equal 1, joined_count + end + + def test_should_count_scoped_select + Account.update_all("credit_limit = NULL") + assert_equal 0, Account.select("credit_limit").count + end + + def test_should_count_scoped_select_with_options + Account.update_all("credit_limit = NULL") + Account.last.update_columns('credit_limit' => 49) + Account.first.update_columns('credit_limit' => 51) + + assert_equal 1, Account.select("credit_limit").where('credit_limit >= 50').count + end + + def test_should_count_manual_select_with_include + assert_equal 6, Account.select("DISTINCT accounts.id").includes(:firm).count + end + + def test_count_with_column_parameter + assert_equal 5, Account.count(:firm_id) + end + + def test_count_with_distinct + assert_equal 4, Account.select(:credit_limit).distinct.count + assert_equal 4, Account.select(:credit_limit).uniq.count + end + + def test_count_with_aliased_attribute + assert_equal 6, Account.count(:available_credit) + end + + def test_count_with_column_and_options_parameter + assert_equal 2, Account.where("credit_limit = 50 AND firm_id IS NOT NULL").count(:firm_id) + end + + def test_should_count_field_in_joined_table + assert_equal 5, Account.joins(:firm).count('companies.id') + assert_equal 4, Account.joins(:firm).distinct.count('companies.id') + end + + def test_should_count_field_in_joined_table_with_group_by + c = Account.group('accounts.firm_id').joins(:firm).count('companies.id') + + [1,6,2,9].each { |firm_id| assert c.keys.include?(firm_id) } + end + + def test_count_with_no_parameters_isnt_deprecated + assert_not_deprecated { Account.count } + end + + def test_count_with_too_many_parameters_raises + assert_raise(ArgumentError) { Account.count(1, 2, 3) } + end + + def test_count_with_order + assert_equal 6, Account.order(:credit_limit).count + end + + def test_count_with_reverse_order + assert_equal 6, Account.order(:credit_limit).reverse_order.count + end + + def test_count_with_where_and_order + assert_equal 1, Account.where(firm_name: '37signals').count + assert_equal 1, Account.where(firm_name: '37signals').order(:firm_name).count + assert_equal 1, Account.where(firm_name: '37signals').order(:firm_name).reverse_order.count + end + + def test_should_sum_expression + # Oracle adapter returns floating point value 636.0 after SUM + if current_adapter?(:OracleAdapter) + assert_equal 636, Account.sum("2 * credit_limit") + else + assert_equal 636, Account.sum("2 * credit_limit").to_i + end + end + + def test_sum_expression_returns_zero_when_no_records_to_sum + assert_equal 0, Account.where('1 = 2').sum("2 * credit_limit") + end + + def test_count_with_from_option + assert_equal Company.count(:all), Company.from('companies').count(:all) + assert_equal Account.where("credit_limit = 50").count(:all), + Account.from('accounts').where("credit_limit = 50").count(:all) + assert_equal Company.where(:type => "Firm").count(:type), + Company.where(:type => "Firm").from('companies').count(:type) + end + + def test_sum_with_from_option + assert_equal Account.sum(:credit_limit), Account.from('accounts').sum(:credit_limit) + assert_equal Account.where("credit_limit > 50").sum(:credit_limit), + Account.where("credit_limit > 50").from('accounts').sum(:credit_limit) + end + + def test_average_with_from_option + assert_equal Account.average(:credit_limit), Account.from('accounts').average(:credit_limit) + assert_equal Account.where("credit_limit > 50").average(:credit_limit), + Account.where("credit_limit > 50").from('accounts').average(:credit_limit) + end + + def test_minimum_with_from_option + assert_equal Account.minimum(:credit_limit), Account.from('accounts').minimum(:credit_limit) + assert_equal Account.where("credit_limit > 50").minimum(:credit_limit), + Account.where("credit_limit > 50").from('accounts').minimum(:credit_limit) + end + + def test_maximum_with_from_option + assert_equal Account.maximum(:credit_limit), Account.from('accounts').maximum(:credit_limit) + assert_equal Account.where("credit_limit > 50").maximum(:credit_limit), + Account.where("credit_limit > 50").from('accounts').maximum(:credit_limit) + end + + def test_maximum_with_not_auto_table_name_prefix_if_column_included + Company.create!(:name => "test", :contracts => [Contract.new(:developer_id => 7)]) + + assert_equal 7, Company.includes(:contracts).maximum(:developer_id) + end + + def test_minimum_with_not_auto_table_name_prefix_if_column_included + Company.create!(:name => "test", :contracts => [Contract.new(:developer_id => 7)]) + + assert_equal 7, Company.includes(:contracts).minimum(:developer_id) + end + + def test_sum_with_not_auto_table_name_prefix_if_column_included + Company.create!(:name => "test", :contracts => [Contract.new(:developer_id => 7)]) + + assert_equal 7, Company.includes(:contracts).sum(:developer_id) + end + + + def test_from_option_with_specified_index + if Edge.connection.adapter_name == 'MySQL' or Edge.connection.adapter_name == 'Mysql2' + assert_equal Edge.count(:all), Edge.from('edges USE INDEX(unique_edge_index)').count(:all) + assert_equal Edge.where('sink_id < 5').count(:all), + Edge.from('edges USE INDEX(unique_edge_index)').where('sink_id < 5').count(:all) + end + end + + def test_from_option_with_table_different_than_class + assert_equal Account.count(:all), Company.from('accounts').count(:all) + end + + def test_distinct_is_honored_when_used_with_count_operation_after_group + # Count the number of authors for approved topics + approved_topics_count = Topic.group(:approved).count(:author_name)[true] + assert_equal approved_topics_count, 4 + # Count the number of distinct authors for approved Topics + distinct_authors_for_approved_count = Topic.group(:approved).distinct.count(:author_name)[true] + assert_equal distinct_authors_for_approved_count, 3 + end + + def test_pluck + assert_equal [1,2,3,4,5], Topic.order(:id).pluck(:id) + end + + def test_pluck_without_column_names + assert_equal [[1, "Firm", 1, nil, "37signals", nil, 1, nil, ""]], + Company.order(:id).limit(1).pluck + end + + def test_pluck_type_cast + topic = topics(:first) + relation = Topic.where(:id => topic.id) + assert_equal [ topic.approved ], relation.pluck(:approved) + assert_equal [ topic.last_read ], relation.pluck(:last_read) + assert_equal [ topic.written_on ], relation.pluck(:written_on) + end + + def test_pluck_and_uniq + assert_equal [50, 53, 55, 60], Account.order(:credit_limit).uniq.pluck(:credit_limit) + end + + def test_pluck_in_relation + company = Company.first + contract = company.contracts.create! + assert_equal [contract.id], company.contracts.pluck(:id) + end + + def test_pluck_on_aliased_attribute + assert_equal 'The First Topic', Topic.order(:id).pluck(:heading).first + end + + def test_pluck_with_serialization + t = Topic.create!(:content => { :foo => :bar }) + assert_equal [{:foo => :bar}], Topic.where(:id => t.id).pluck(:content) + end + + def test_pluck_with_qualified_column_name + assert_equal [1,2,3,4,5], Topic.order(:id).pluck("topics.id") + end + + def test_pluck_auto_table_name_prefix + c = Company.create!(:name => "test", :contracts => [Contract.new]) + assert_equal [c.id], Company.joins(:contracts).pluck(:id) + end + + def test_pluck_if_table_included + c = Company.create!(:name => "test", :contracts => [Contract.new(:developer_id => 7)]) + assert_equal [c.id], Company.includes(:contracts).where("contracts.id" => c.contracts.first).pluck(:id) + end + + def test_pluck_not_auto_table_name_prefix_if_column_joined + Company.create!(:name => "test", :contracts => [Contract.new(:developer_id => 7)]) + assert_equal [7], Company.joins(:contracts).pluck(:developer_id) + end + + def test_pluck_with_selection_clause + assert_equal [50, 53, 55, 60], Account.pluck('DISTINCT credit_limit').sort + assert_equal [50, 53, 55, 60], Account.pluck('DISTINCT accounts.credit_limit').sort + assert_equal [50, 53, 55, 60], Account.pluck('DISTINCT(credit_limit)').sort + + # MySQL returns "SUM(DISTINCT(credit_limit))" as the column name unless + # an alias is provided. Without the alias, the column cannot be found + # and properly typecast. + assert_equal [50 + 53 + 55 + 60], Account.pluck('SUM(DISTINCT(credit_limit)) as credit_limit') + end + + def test_plucks_with_ids + assert_equal Company.all.map(&:id).sort, Company.ids.sort + end + + def test_pluck_with_includes_limit_and_empty_result + assert_equal [], Topic.includes(:replies).limit(0).pluck(:id) + assert_equal [], Topic.includes(:replies).limit(1).where('0 = 1').pluck(:id) + end + + def test_pluck_not_auto_table_name_prefix_if_column_included + Company.create!(:name => "test", :contracts => [Contract.new(:developer_id => 7)]) + ids = Company.includes(:contracts).pluck(:developer_id) + assert_equal Company.count, ids.length + assert_equal [7], ids.compact + end + + def test_pluck_multiple_columns + assert_equal [ + [1, "The First Topic"], [2, "The Second Topic of the day"], + [3, "The Third Topic of the day"], [4, "The Fourth Topic of the day"], + [5, "The Fifth Topic of the day"] + ], Topic.order(:id).pluck(:id, :title) + assert_equal [ + [1, "The First Topic", "David"], [2, "The Second Topic of the day", "Mary"], + [3, "The Third Topic of the day", "Carl"], [4, "The Fourth Topic of the day", "Carl"], + [5, "The Fifth Topic of the day", "Jason"] + ], Topic.order(:id).pluck(:id, :title, :author_name) + end + + def test_pluck_with_multiple_columns_and_selection_clause + assert_equal [[1, 50], [2, 50], [3, 50], [4, 60], [5, 55], [6, 53]], + Account.pluck('id, credit_limit') + end + + def test_pluck_with_multiple_columns_and_includes + Company.create!(:name => "test", :contracts => [Contract.new(:developer_id => 7)]) + companies_and_developers = Company.order('companies.id').includes(:contracts).pluck(:name, :developer_id) + + assert_equal Company.count, companies_and_developers.length + assert_equal ["37signals", nil], companies_and_developers.first + assert_equal ["test", 7], companies_and_developers.last + end + + def test_pluck_with_reserved_words + Possession.create!(:where => "Over There") + + assert_equal ["Over There"], Possession.pluck(:where) + end + + def test_pluck_replaces_select_clause + taks_relation = Topic.select(:approved, :id).order(:id) + assert_equal [1,2,3,4,5], taks_relation.pluck(:id) + assert_equal [false, true, true, true, true], taks_relation.pluck(:approved) + end + + def test_pluck_columns_with_same_name + expected = [["The First Topic", "The Second Topic of the day"], ["The Third Topic of the day", "The Fourth Topic of the day"]] + actual = Topic.joins(:replies) + .pluck('topics.title', 'replies_topics.title') + assert_equal expected, actual + end +end diff --git a/activerecord/test/cases/callbacks_test.rb b/activerecord/test/cases/callbacks_test.rb new file mode 100644 index 0000000000..c8f56e3c73 --- /dev/null +++ b/activerecord/test/cases/callbacks_test.rb @@ -0,0 +1,535 @@ +require "cases/helper" + +class CallbackDeveloper < ActiveRecord::Base + self.table_name = 'developers' + + class << self + def callback_string(callback_method) + "history << [#{callback_method.to_sym.inspect}, :string]" + end + + def callback_proc(callback_method) + Proc.new { |model| model.history << [callback_method, :proc] } + end + + def define_callback_method(callback_method) + define_method(callback_method) do + self.history << [callback_method, :method] + end + send(callback_method, :"#{callback_method}") + end + + def callback_object(callback_method) + klass = Class.new + klass.send(:define_method, callback_method) do |model| + model.history << [callback_method, :object] + end + klass.new + end + end + + ActiveRecord::Callbacks::CALLBACKS.each do |callback_method| + next if callback_method.to_s =~ /^around_/ + define_callback_method(callback_method) + send(callback_method, callback_string(callback_method)) + send(callback_method, callback_proc(callback_method)) + send(callback_method, callback_object(callback_method)) + send(callback_method) { |model| model.history << [callback_method, :block] } + end + + def history + @history ||= [] + end +end + +class CallbackDeveloperWithFalseValidation < CallbackDeveloper + before_validation proc { |model| model.history << [:before_validation, :returning_false]; false } + before_validation proc { |model| model.history << [:before_validation, :should_never_get_here] } +end + +class ParentDeveloper < ActiveRecord::Base + self.table_name = 'developers' + attr_accessor :after_save_called + before_validation {|record| record.after_save_called = true} +end + +class ChildDeveloper < ParentDeveloper + +end + +class RecursiveCallbackDeveloper < ActiveRecord::Base + self.table_name = 'developers' + + before_save :on_before_save + after_save :on_after_save + + attr_reader :on_before_save_called, :on_after_save_called + + def on_before_save + @on_before_save_called ||= 0 + @on_before_save_called += 1 + save unless @on_before_save_called > 1 + end + + def on_after_save + @on_after_save_called ||= 0 + @on_after_save_called += 1 + save unless @on_after_save_called > 1 + end +end + +class ImmutableDeveloper < ActiveRecord::Base + self.table_name = 'developers' + + validates_inclusion_of :salary, :in => 50000..200000 + + before_save :cancel + before_destroy :cancel + + def cancelled? + @cancelled == true + end + + private + def cancel + @cancelled = true + false + end +end + +class ImmutableMethodDeveloper < ActiveRecord::Base + self.table_name = 'developers' + + validates_inclusion_of :salary, :in => 50000..200000 + + def cancelled? + @cancelled == true + end + + before_save do + @cancelled = true + false + end + + before_destroy do + @cancelled = true + false + end +end + +class OnCallbacksDeveloper < ActiveRecord::Base + self.table_name = 'developers' + + before_validation { history << :before_validation } + before_validation(:on => :create){ history << :before_validation_on_create } + before_validation(:on => :update){ history << :before_validation_on_update } + + validate do + history << :validate + end + + after_validation { history << :after_validation } + after_validation(:on => :create){ history << :after_validation_on_create } + after_validation(:on => :update){ history << :after_validation_on_update } + + def history + @history ||= [] + end +end + +class ContextualCallbacksDeveloper < ActiveRecord::Base + self.table_name = 'developers' + + before_validation { history << :before_validation } + before_validation :before_validation_on_create_and_update, :on => [ :create, :update ] + + validate do + history << :validate + end + + after_validation { history << :after_validation } + after_validation :after_validation_on_create_and_update, :on => [ :create, :update ] + + def before_validation_on_create_and_update + history << "before_validation_on_#{self.validation_context}".to_sym + end + + def after_validation_on_create_and_update + history << "after_validation_on_#{self.validation_context}".to_sym + end + + def history + @history ||= [] + end +end + +class CallbackCancellationDeveloper < ActiveRecord::Base + self.table_name = 'developers' + + attr_reader :after_save_called, :after_create_called, :after_update_called, :after_destroy_called + attr_accessor :cancel_before_save, :cancel_before_create, :cancel_before_update, :cancel_before_destroy + + before_save {defined?(@cancel_before_save) ? !@cancel_before_save : false} + before_create { !@cancel_before_create } + before_update { !@cancel_before_update } + before_destroy { !@cancel_before_destroy } + + after_save { @after_save_called = true } + after_update { @after_update_called = true } + after_create { @after_create_called = true } + after_destroy { @after_destroy_called = true } +end + +class CallbacksTest < ActiveRecord::TestCase + fixtures :developers + + def test_initialize + david = CallbackDeveloper.new + assert_equal [ + [ :after_initialize, :method ], + [ :after_initialize, :string ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + ], david.history + end + + def test_find + david = CallbackDeveloper.find(1) + assert_equal [ + [ :after_find, :method ], + [ :after_find, :string ], + [ :after_find, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :after_initialize, :string ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + ], david.history + end + + def test_new_valid? + david = CallbackDeveloper.new + david.valid? + assert_equal [ + [ :after_initialize, :method ], + [ :after_initialize, :string ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_validation, :method ], + [ :before_validation, :string ], + [ :before_validation, :proc ], + [ :before_validation, :object ], + [ :before_validation, :block ], + [ :after_validation, :method ], + [ :after_validation, :string ], + [ :after_validation, :proc ], + [ :after_validation, :object ], + [ :after_validation, :block ], + ], david.history + end + + def test_existing_valid? + david = CallbackDeveloper.find(1) + david.valid? + assert_equal [ + [ :after_find, :method ], + [ :after_find, :string ], + [ :after_find, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :after_initialize, :string ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_validation, :method ], + [ :before_validation, :string ], + [ :before_validation, :proc ], + [ :before_validation, :object ], + [ :before_validation, :block ], + [ :after_validation, :method ], + [ :after_validation, :string ], + [ :after_validation, :proc ], + [ :after_validation, :object ], + [ :after_validation, :block ], + ], david.history + end + + def test_create + david = CallbackDeveloper.create('name' => 'David', 'salary' => 1000000) + assert_equal [ + [ :after_initialize, :method ], + [ :after_initialize, :string ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_validation, :method ], + [ :before_validation, :string ], + [ :before_validation, :proc ], + [ :before_validation, :object ], + [ :before_validation, :block ], + [ :after_validation, :method ], + [ :after_validation, :string ], + [ :after_validation, :proc ], + [ :after_validation, :object ], + [ :after_validation, :block ], + [ :before_save, :method ], + [ :before_save, :string ], + [ :before_save, :proc ], + [ :before_save, :object ], + [ :before_save, :block ], + [ :before_create, :method ], + [ :before_create, :string ], + [ :before_create, :proc ], + [ :before_create, :object ], + [ :before_create, :block ], + [ :after_create, :method ], + [ :after_create, :string ], + [ :after_create, :proc ], + [ :after_create, :object ], + [ :after_create, :block ], + [ :after_save, :method ], + [ :after_save, :string ], + [ :after_save, :proc ], + [ :after_save, :object ], + [ :after_save, :block ] + ], david.history + end + + def test_validate_on_create + david = OnCallbacksDeveloper.create('name' => 'David', 'salary' => 1000000) + assert_equal [ + :before_validation, + :before_validation_on_create, + :validate, + :after_validation, + :after_validation_on_create + ], david.history + end + + def test_validate_on_contextual_create + david = ContextualCallbacksDeveloper.create('name' => 'David', 'salary' => 1000000) + assert_equal [ + :before_validation, + :before_validation_on_create, + :validate, + :after_validation, + :after_validation_on_create + ], david.history + end + + def test_update + david = CallbackDeveloper.find(1) + david.save + assert_equal [ + [ :after_find, :method ], + [ :after_find, :string ], + [ :after_find, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :after_initialize, :string ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_validation, :method ], + [ :before_validation, :string ], + [ :before_validation, :proc ], + [ :before_validation, :object ], + [ :before_validation, :block ], + [ :after_validation, :method ], + [ :after_validation, :string ], + [ :after_validation, :proc ], + [ :after_validation, :object ], + [ :after_validation, :block ], + [ :before_save, :method ], + [ :before_save, :string ], + [ :before_save, :proc ], + [ :before_save, :object ], + [ :before_save, :block ], + [ :before_update, :method ], + [ :before_update, :string ], + [ :before_update, :proc ], + [ :before_update, :object ], + [ :before_update, :block ], + [ :after_update, :method ], + [ :after_update, :string ], + [ :after_update, :proc ], + [ :after_update, :object ], + [ :after_update, :block ], + [ :after_save, :method ], + [ :after_save, :string ], + [ :after_save, :proc ], + [ :after_save, :object ], + [ :after_save, :block ] + ], david.history + end + + def test_validate_on_update + david = OnCallbacksDeveloper.find(1) + david.save + assert_equal [ + :before_validation, + :before_validation_on_update, + :validate, + :after_validation, + :after_validation_on_update + ], david.history + end + + def test_validate_on_contextual_update + david = ContextualCallbacksDeveloper.find(1) + david.save + assert_equal [ + :before_validation, + :before_validation_on_update, + :validate, + :after_validation, + :after_validation_on_update + ], david.history + end + + def test_destroy + david = CallbackDeveloper.find(1) + david.destroy + assert_equal [ + [ :after_find, :method ], + [ :after_find, :string ], + [ :after_find, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :after_initialize, :string ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_destroy, :method ], + [ :before_destroy, :string ], + [ :before_destroy, :proc ], + [ :before_destroy, :object ], + [ :before_destroy, :block ], + [ :after_destroy, :method ], + [ :after_destroy, :string ], + [ :after_destroy, :proc ], + [ :after_destroy, :object ], + [ :after_destroy, :block ] + ], david.history + end + + def test_delete + david = CallbackDeveloper.find(1) + CallbackDeveloper.delete(david.id) + assert_equal [ + [ :after_find, :method ], + [ :after_find, :string ], + [ :after_find, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :after_initialize, :string ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + ], david.history + end + + def test_before_save_returning_false + david = ImmutableDeveloper.find(1) + assert david.valid? + assert !david.save + assert_raise(ActiveRecord::RecordNotSaved) { david.save! } + + david = ImmutableDeveloper.find(1) + david.salary = 10_000_000 + assert !david.valid? + assert !david.save + assert_raise(ActiveRecord::RecordInvalid) { david.save! } + + someone = CallbackCancellationDeveloper.find(1) + someone.cancel_before_save = true + assert someone.valid? + assert !someone.save + assert_save_callbacks_not_called(someone) + end + + def test_before_create_returning_false + someone = CallbackCancellationDeveloper.new + someone.cancel_before_create = true + assert someone.valid? + assert !someone.save + assert_save_callbacks_not_called(someone) + end + + def test_before_update_returning_false + someone = CallbackCancellationDeveloper.find(1) + someone.cancel_before_update = true + assert someone.valid? + assert !someone.save + assert_save_callbacks_not_called(someone) + end + + def test_before_destroy_returning_false + david = ImmutableDeveloper.find(1) + assert !david.destroy + assert_raise(ActiveRecord::RecordNotDestroyed) { david.destroy! } + assert_not_nil ImmutableDeveloper.find_by_id(1) + + someone = CallbackCancellationDeveloper.find(1) + someone.cancel_before_destroy = true + assert !someone.destroy + assert_raise(ActiveRecord::RecordNotDestroyed) { someone.destroy! } + assert !someone.after_destroy_called + end + + def assert_save_callbacks_not_called(someone) + assert !someone.after_save_called + assert !someone.after_create_called + assert !someone.after_update_called + end + private :assert_save_callbacks_not_called + + def test_callback_returning_false + david = CallbackDeveloperWithFalseValidation.find(1) + david.save + assert_equal [ + [ :after_find, :method ], + [ :after_find, :string ], + [ :after_find, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :after_initialize, :string ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_validation, :method ], + [ :before_validation, :string ], + [ :before_validation, :proc ], + [ :before_validation, :object ], + [ :before_validation, :block ], + [ :before_validation, :returning_false ], + [ :after_rollback, :block ], + [ :after_rollback, :object ], + [ :after_rollback, :proc ], + [ :after_rollback, :string ], + [ :after_rollback, :method ], + ], david.history + end + + def test_inheritance_of_callbacks + parent = ParentDeveloper.new + assert !parent.after_save_called + parent.save + assert parent.after_save_called + + child = ChildDeveloper.new + assert !child.after_save_called + child.save + assert child.after_save_called + end + +end diff --git a/activerecord/test/cases/clone_test.rb b/activerecord/test/cases/clone_test.rb new file mode 100644 index 0000000000..5e43082c33 --- /dev/null +++ b/activerecord/test/cases/clone_test.rb @@ -0,0 +1,40 @@ +require "cases/helper" +require 'models/topic' + +module ActiveRecord + class CloneTest < ActiveRecord::TestCase + fixtures :topics + + def test_persisted + topic = Topic.first + cloned = topic.clone + assert topic.persisted?, 'topic persisted' + assert cloned.persisted?, 'topic persisted' + assert !cloned.new_record?, 'topic is not new' + end + + def test_stays_frozen + topic = Topic.first + topic.freeze + + cloned = topic.clone + assert cloned.persisted?, 'topic persisted' + assert !cloned.new_record?, 'topic is not new' + assert cloned.frozen?, 'topic should be frozen' + end + + def test_shallow + topic = Topic.first + cloned = topic.clone + topic.author_name = 'Aaron' + assert_equal 'Aaron', cloned.author_name + end + + def test_freezing_a_cloned_model_does_not_freeze_clone + cloned = Topic.new + clone = cloned.clone + cloned.freeze + assert_not clone.frozen? + end + end +end diff --git a/activerecord/test/cases/coders/yaml_column_test.rb b/activerecord/test/cases/coders/yaml_column_test.rb new file mode 100644 index 0000000000..b72c54f97b --- /dev/null +++ b/activerecord/test/cases/coders/yaml_column_test.rb @@ -0,0 +1,63 @@ + +require "cases/helper" + +module ActiveRecord + module Coders + class YAMLColumnTest < ActiveRecord::TestCase + def test_initialize_takes_class + coder = YAMLColumn.new(Object) + assert_equal Object, coder.object_class + end + + def test_type_mismatch_on_different_classes_on_dump + coder = YAMLColumn.new(Array) + assert_raises(SerializationTypeMismatch) do + coder.dump("a") + end + end + + def test_type_mismatch_on_different_classes + coder = YAMLColumn.new(Array) + assert_raises(SerializationTypeMismatch) do + coder.load "--- foo" + end + end + + def test_nil_is_ok + coder = YAMLColumn.new + assert_nil coder.load "--- " + end + + def test_returns_new_with_different_class + coder = YAMLColumn.new SerializationTypeMismatch + assert_equal SerializationTypeMismatch, coder.load("--- ").class + end + + def test_returns_string_unless_starts_with_dash + coder = YAMLColumn.new + assert_equal 'foo', coder.load("foo") + end + + def test_load_handles_other_classes + coder = YAMLColumn.new + assert_equal [], coder.load([]) + end + + def test_load_doesnt_swallow_yaml_exceptions + coder = YAMLColumn.new + bad_yaml = '--- {' + assert_raises(Psych::SyntaxError) do + coder.load(bad_yaml) + end + end + + def test_load_doesnt_handle_undefined_class_or_module + coder = YAMLColumn.new + missing_class_yaml = '--- !ruby/object:DoesNotExistAndShouldntEver {}\n' + assert_raises(ArgumentError) do + coder.load(missing_class_yaml) + end + end + end + end +end diff --git a/activerecord/test/cases/column_alias_test.rb b/activerecord/test/cases/column_alias_test.rb new file mode 100644 index 0000000000..40707d9cb2 --- /dev/null +++ b/activerecord/test/cases/column_alias_test.rb @@ -0,0 +1,17 @@ +require "cases/helper" +require 'models/topic' + +class TestColumnAlias < ActiveRecord::TestCase + fixtures :topics + + QUERY = if 'Oracle' == ActiveRecord::Base.connection.adapter_name + 'SELECT id AS pk FROM topics WHERE ROWNUM < 2' + else + 'SELECT id AS pk FROM topics' + end + + def test_column_alias + records = Topic.connection.select_all(QUERY) + assert_equal 'pk', records[0].keys[0] + end +end diff --git a/activerecord/test/cases/column_definition_test.rb b/activerecord/test/cases/column_definition_test.rb new file mode 100644 index 0000000000..bcfd66b4bf --- /dev/null +++ b/activerecord/test/cases/column_definition_test.rb @@ -0,0 +1,123 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class ColumnDefinitionTest < ActiveRecord::TestCase + def setup + @adapter = AbstractAdapter.new(nil) + def @adapter.native_database_types + {:string => "varchar"} + end + @viz = @adapter.schema_creation + end + + # Avoid column definitions in create table statements like: + # `title` varchar(255) DEFAULT NULL + def test_should_not_include_default_clause_when_default_is_null + column = Column.new("title", nil, Type::String.new(limit: 20)) + column_def = ColumnDefinition.new( + column.name, "string", + column.limit, column.precision, column.scale, column.default, column.null) + assert_equal "title varchar(20)", @viz.accept(column_def) + end + + def test_should_include_default_clause_when_default_is_present + column = Column.new("title", "Hello", Type::String.new(limit: 20)) + column_def = ColumnDefinition.new( + column.name, "string", + column.limit, column.precision, column.scale, column.default, column.null) + assert_equal %Q{title varchar(20) DEFAULT 'Hello'}, @viz.accept(column_def) + end + + def test_should_specify_not_null_if_null_option_is_false + column = Column.new("title", "Hello", Type::String.new(limit: 20), "varchar(20)", false) + column_def = ColumnDefinition.new( + column.name, "string", + column.limit, column.precision, column.scale, column.default, column.null) + assert_equal %Q{title varchar(20) DEFAULT 'Hello' NOT NULL}, @viz.accept(column_def) + end + + if current_adapter?(:MysqlAdapter) + def test_should_set_default_for_mysql_binary_data_types + binary_column = MysqlAdapter::Column.new("title", "a", Type::Binary.new, "binary(1)") + assert_equal "a", binary_column.default + + varbinary_column = MysqlAdapter::Column.new("title", "a", Type::Binary.new, "varbinary(1)") + assert_equal "a", varbinary_column.default + end + + def test_should_not_set_default_for_blob_and_text_data_types + assert_raise ArgumentError do + MysqlAdapter::Column.new("title", "a", Type::Binary.new, "blob") + end + + assert_raise ArgumentError do + MysqlAdapter::Column.new("title", "Hello", Type::Text.new) + end + + text_column = MysqlAdapter::Column.new("title", nil, Type::Text.new) + assert_equal nil, text_column.default + + not_null_text_column = MysqlAdapter::Column.new("title", nil, Type::Text.new, "text", false) + assert_equal "", not_null_text_column.default + end + + def test_has_default_should_return_false_for_blob_and_text_data_types + blob_column = MysqlAdapter::Column.new("title", nil, Type::Binary.new, "blob") + assert !blob_column.has_default? + + text_column = MysqlAdapter::Column.new("title", nil, Type::Text.new) + assert !text_column.has_default? + end + end + + if current_adapter?(:Mysql2Adapter) + def test_should_set_default_for_mysql_binary_data_types + binary_column = Mysql2Adapter::Column.new("title", "a", Type::Binary.new, "binary(1)") + assert_equal "a", binary_column.default + + varbinary_column = Mysql2Adapter::Column.new("title", "a", Type::Binary.new, "varbinary(1)") + assert_equal "a", varbinary_column.default + end + + def test_should_not_set_default_for_blob_and_text_data_types + assert_raise ArgumentError do + Mysql2Adapter::Column.new("title", "a", Type::Binary.new, "blob") + end + + assert_raise ArgumentError do + Mysql2Adapter::Column.new("title", "Hello", Type::Text.new) + end + + text_column = Mysql2Adapter::Column.new("title", nil, Type::Text.new) + assert_equal nil, text_column.default + + not_null_text_column = Mysql2Adapter::Column.new("title", nil, Type::Text.new, "text", false) + assert_equal "", not_null_text_column.default + end + + def test_has_default_should_return_false_for_blob_and_text_data_types + blob_column = Mysql2Adapter::Column.new("title", nil, Type::Binary.new, "blob") + assert !blob_column.has_default? + + text_column = Mysql2Adapter::Column.new("title", nil, Type::Text.new) + assert !text_column.has_default? + end + end + + if current_adapter?(:PostgreSQLAdapter) + def test_bigint_column_should_map_to_integer + oid = PostgreSQLAdapter::OID::Integer.new + bigint_column = PostgreSQLColumn.new('number', nil, oid, "bigint") + assert_equal :integer, bigint_column.type + end + + def test_smallint_column_should_map_to_integer + oid = PostgreSQLAdapter::OID::Integer.new + smallint_column = PostgreSQLColumn.new('number', nil, oid, "smallint") + assert_equal :integer, smallint_column.type + end + end + end + end +end diff --git a/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb b/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb new file mode 100644 index 0000000000..662e19f35e --- /dev/null +++ b/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb @@ -0,0 +1,54 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class AdapterLeasingTest < ActiveRecord::TestCase + class Pool < ConnectionPool + def insert_connection_for_test!(c) + synchronize do + @connections << c + @available.add c + end + end + end + + def setup + @adapter = AbstractAdapter.new nil, nil + end + + def test_in_use? + assert_not @adapter.in_use?, 'adapter is not in use' + assert @adapter.lease, 'lease adapter' + assert @adapter.in_use?, 'adapter is in use' + end + + def test_lease_twice + assert @adapter.lease, 'should lease adapter' + assert_not @adapter.lease, 'should not lease adapter' + end + + def test_expire_mutates_in_use + assert @adapter.lease, 'lease adapter' + assert @adapter.in_use?, 'adapter is in use' + @adapter.expire + assert_not @adapter.in_use?, 'adapter is in use' + end + + def test_close + pool = Pool.new(ConnectionSpecification.new({}, nil)) + pool.insert_connection_for_test! @adapter + @adapter.pool = pool + + # Make sure the pool marks the connection in use + assert_equal @adapter, pool.connection + assert @adapter.in_use? + + # Close should put the adapter back in the pool + @adapter.close + assert_not @adapter.in_use? + + assert_equal @adapter, pool.connection + end + end + end +end diff --git a/activerecord/test/cases/connection_adapters/connection_handler_test.rb b/activerecord/test/cases/connection_adapters/connection_handler_test.rb new file mode 100644 index 0000000000..3e33b30144 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/connection_handler_test.rb @@ -0,0 +1,53 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class ConnectionHandlerTest < ActiveRecord::TestCase + def setup + @klass = Class.new(Base) { def self.name; 'klass'; end } + @subklass = Class.new(@klass) { def self.name; 'subklass'; end } + + @handler = ConnectionHandler.new + @pool = @handler.establish_connection(@klass, Base.connection_pool.spec) + end + + def test_retrieve_connection + assert @handler.retrieve_connection(@klass) + end + + def test_active_connections? + assert !@handler.active_connections? + assert @handler.retrieve_connection(@klass) + assert @handler.active_connections? + @handler.clear_active_connections! + assert !@handler.active_connections? + end + + def test_retrieve_connection_pool_with_ar_base + assert_nil @handler.retrieve_connection_pool(ActiveRecord::Base) + end + + def test_retrieve_connection_pool + assert_not_nil @handler.retrieve_connection_pool(@klass) + end + + def test_retrieve_connection_pool_uses_superclass_when_no_subclass_connection + assert_not_nil @handler.retrieve_connection_pool(@subklass) + end + + def test_retrieve_connection_pool_uses_superclass_pool_after_subclass_establish_and_remove + sub_pool = @handler.establish_connection(@subklass, Base.connection_pool.spec) + assert_same sub_pool, @handler.retrieve_connection_pool(@subklass) + + @handler.remove_connection @subklass + assert_same @pool, @handler.retrieve_connection_pool(@subklass) + end + + def test_connection_pools + assert_deprecated do + assert_equal({ Base.connection_pool.spec => @pool }, @handler.connection_pools) + end + end + end + end +end diff --git a/activerecord/test/cases/connection_adapters/connection_specification_test.rb b/activerecord/test/cases/connection_adapters/connection_specification_test.rb new file mode 100644 index 0000000000..ea2196cda2 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/connection_specification_test.rb @@ -0,0 +1,12 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class ConnectionSpecificationTest < ActiveRecord::TestCase + def test_dup_deep_copy_config + spec = ConnectionSpecification.new({ :a => :b }, "bar") + assert_not_equal(spec.config.object_id, spec.dup.config.object_id) + end + end + end +end diff --git a/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb new file mode 100644 index 0000000000..e1b2804a18 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb @@ -0,0 +1,204 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class MergeAndResolveDefaultUrlConfigTest < ActiveRecord::TestCase + def setup + @previous_database_url = ENV.delete("DATABASE_URL") + end + + teardown do + ENV["DATABASE_URL"] = @previous_database_url + end + + def resolve_config(config) + ActiveRecord::ConnectionHandling::MergeAndResolveDefaultUrlConfig.new(config).resolve + end + + def resolve_spec(spec, config) + ConnectionSpecification::Resolver.new(resolve_config(config)).resolve(spec) + end + + def test_resolver_with_database_uri_and_current_env_symbol_key + ENV['DATABASE_URL'] = "postgres://localhost/foo" + config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } } + actual = resolve_spec(:default_env, config) + expected = { "adapter"=>"postgresql", "database"=>"foo", "host"=>"localhost" } + assert_equal expected, actual + end + + def test_resolver_with_database_uri_and_and_current_env_string_key + ENV['DATABASE_URL'] = "postgres://localhost/foo" + config = { "default_env" => { "adapter" => "not_postgres", "database" => "not_foo" } } + actual = assert_deprecated { resolve_spec("default_env", config) } + expected = { "adapter"=>"postgresql", "database"=>"foo", "host"=>"localhost" } + assert_equal expected, actual + end + + def test_resolver_with_database_uri_and_known_key + ENV['DATABASE_URL'] = "postgres://localhost/foo" + config = { "production" => { "adapter" => "not_postgres", "database" => "not_foo", "host" => "localhost" } } + actual = resolve_spec(:production, config) + expected = { "adapter"=>"not_postgres", "database"=>"not_foo", "host"=>"localhost" } + assert_equal expected, actual + end + + def test_resolver_with_database_uri_and_unknown_symbol_key + ENV['DATABASE_URL'] = "postgres://localhost/foo" + config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } } + assert_raises AdapterNotSpecified do + resolve_spec(:production, config) + end + end + + def test_resolver_with_database_uri_and_unknown_string_key + ENV['DATABASE_URL'] = "postgres://localhost/foo" + config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } } + assert_deprecated do + assert_raises AdapterNotSpecified do + resolve_spec("production", config) + end + end + end + + def test_resolver_with_database_uri_and_supplied_url + ENV['DATABASE_URL'] = "not-postgres://not-localhost/not_foo" + config = { "production" => { "adapter" => "also_not_postgres", "database" => "also_not_foo" } } + actual = resolve_spec("postgres://localhost/foo", config) + expected = { "adapter"=>"postgresql", "database"=>"foo", "host"=>"localhost" } + assert_equal expected, actual + end + + def test_jdbc_url + config = { "production" => { "url" => "jdbc:postgres://localhost/foo" } } + actual = resolve_config(config) + assert_equal config, actual + end + + def test_environment_does_not_exist_in_config_url_does_exist + ENV['DATABASE_URL'] = "postgres://localhost/foo" + config = { "not_default_env" => { "adapter" => "not_postgres", "database" => "not_foo" } } + actual = resolve_config(config) + expect_prod = { "adapter"=>"postgresql", "database"=>"foo", "host"=>"localhost" } + assert_equal expect_prod, actual["default_env"] + end + + def test_url_with_hyphenated_scheme + ENV['DATABASE_URL'] = "ibm-db://localhost/foo" + config = { "default_env" => { "adapter" => "not_postgres", "database" => "not_foo", "host" => "localhost" } } + actual = resolve_spec(:default_env, config) + expected = { "adapter"=>"ibm_db", "database"=>"foo", "host"=>"localhost" } + assert_equal expected, actual + end + + def test_string_connection + config = { "default_env" => "postgres://localhost/foo" } + actual = resolve_config(config) + expected = { "default_env" => + { "adapter" => "postgresql", + "database" => "foo", + "host" => "localhost" + } + } + assert_equal expected, actual + end + + def test_url_sub_key + config = { "default_env" => { "url" => "postgres://localhost/foo" } } + actual = resolve_config(config) + expected = { "default_env" => + { "adapter" => "postgresql", + "database" => "foo", + "host" => "localhost" + } + } + assert_equal expected, actual + end + + def test_hash + config = { "production" => { "adapter" => "postgres", "database" => "foo" } } + actual = resolve_config(config) + assert_equal config, actual + end + + def test_blank + config = {} + actual = resolve_config(config) + assert_equal config, actual + end + + def test_blank_with_database_url + ENV['DATABASE_URL'] = "postgres://localhost/foo" + + config = {} + actual = resolve_config(config) + expected = { "adapter" => "postgresql", + "database" => "foo", + "host" => "localhost" } + assert_equal expected, actual["default_env"] + assert_equal nil, actual["production"] + assert_equal nil, actual["development"] + assert_equal nil, actual["test"] + assert_equal nil, actual[:production] + assert_equal nil, actual[:development] + assert_equal nil, actual[:test] + end + + def test_database_url_with_ipv6_host_and_port + ENV['DATABASE_URL'] = "postgres://[::1]:5454/foo" + + config = {} + actual = resolve_config(config) + expected = { "adapter" => "postgresql", + "database" => "foo", + "host" => "::1", + "port" => 5454 } + assert_equal expected, actual["default_env"] + end + + def test_url_sub_key_with_database_url + ENV['DATABASE_URL'] = "NOT-POSTGRES://localhost/NOT_FOO" + + config = { "default_env" => { "url" => "postgres://localhost/foo" } } + actual = resolve_config(config) + expected = { "default_env" => + { "adapter" => "postgresql", + "database" => "foo", + "host" => "localhost" + } + } + assert_equal expected, actual + end + + def test_merge_no_conflicts_with_database_url + ENV['DATABASE_URL'] = "postgres://localhost/foo" + + config = {"default_env" => { "pool" => "5" } } + actual = resolve_config(config) + expected = { "default_env" => + { "adapter" => "postgresql", + "database" => "foo", + "host" => "localhost", + "pool" => "5" + } + } + assert_equal expected, actual + end + + def test_merge_conflicts_with_database_url + ENV['DATABASE_URL'] = "postgres://localhost/foo" + + config = {"default_env" => { "adapter" => "NOT-POSTGRES", "database" => "NOT-FOO", "pool" => "5" } } + actual = resolve_config(config) + expected = { "default_env" => + { "adapter" => "postgresql", + "database" => "foo", + "host" => "localhost", + "pool" => "5" + } + } + assert_equal expected, actual + end + end + end +end diff --git a/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb b/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb new file mode 100644 index 0000000000..d4d67487db --- /dev/null +++ b/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb @@ -0,0 +1,61 @@ +require "cases/helper" + +if current_adapter?(:MysqlAdapter, :Mysql2Adapter) +module ActiveRecord + module ConnectionAdapters + class MysqlTypeLookupTest < ActiveRecord::TestCase + setup do + @connection = ActiveRecord::Base.connection + end + + def test_boolean_types + emulate_booleans(true) do + assert_lookup_type :boolean, 'tinyint(1)' + assert_lookup_type :boolean, 'TINYINT(1)' + end + end + + def test_string_types + assert_lookup_type :string, "enum('one', 'two', 'three')" + assert_lookup_type :string, "ENUM('one', 'two', 'three')" + assert_lookup_type :string, "set('one', 'two', 'three')" + assert_lookup_type :string, "SET('one', 'two', 'three')" + end + + def test_binary_types + assert_lookup_type :binary, 'bit' + assert_lookup_type :binary, 'BIT' + end + + def test_integer_types + emulate_booleans(false) do + assert_lookup_type :integer, 'tinyint(1)' + assert_lookup_type :integer, 'TINYINT(1)' + assert_lookup_type :integer, 'year' + assert_lookup_type :integer, 'YEAR' + end + end + + private + + def assert_lookup_type(type, lookup) + cast_type = @connection.type_map.lookup(lookup) + assert_equal type, cast_type.type + end + + def emulate_booleans(value) + old_emulate_booleans = @connection.emulate_booleans + change_emulate_booleans(value) + yield + ensure + change_emulate_booleans(old_emulate_booleans) + end + + def change_emulate_booleans(value) + @connection.emulate_booleans = value + @connection.clear_cache! + end + end + end +end +end diff --git a/activerecord/test/cases/connection_adapters/quoting_test.rb b/activerecord/test/cases/connection_adapters/quoting_test.rb new file mode 100644 index 0000000000..59dcb96ebc --- /dev/null +++ b/activerecord/test/cases/connection_adapters/quoting_test.rb @@ -0,0 +1,13 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + module Quoting + class QuotingTest < ActiveRecord::TestCase + def test_quoting_classes + assert_equal "'Object'", AbstractAdapter.new(nil).quote(Object) + end + end + end + end +end diff --git a/activerecord/test/cases/connection_adapters/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb new file mode 100644 index 0000000000..c7531f5418 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/schema_cache_test.rb @@ -0,0 +1,56 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class SchemaCacheTest < ActiveRecord::TestCase + def setup + connection = ActiveRecord::Base.connection + @cache = SchemaCache.new connection + end + + def test_primary_key + assert_equal 'id', @cache.primary_keys('posts') + end + + def test_primary_key_for_non_existent_table + assert_nil @cache.primary_keys('omgponies') + end + + def test_caches_columns + columns = @cache.columns('posts') + assert_equal columns, @cache.columns('posts') + end + + def test_caches_columns_hash + columns_hash = @cache.columns_hash('posts') + assert_equal columns_hash, @cache.columns_hash('posts') + end + + def test_clearing + @cache.columns('posts') + @cache.columns_hash('posts') + @cache.tables('posts') + @cache.primary_keys('posts') + + @cache.clear! + + assert_equal 0, @cache.size + end + + def test_dump_and_load + @cache.columns('posts') + @cache.columns_hash('posts') + @cache.tables('posts') + @cache.primary_keys('posts') + + @cache = Marshal.load(Marshal.dump(@cache)) + + assert_equal 11, @cache.columns('posts').size + assert_equal 11, @cache.columns_hash('posts').size + assert @cache.tables('posts') + assert_equal 'id', @cache.primary_keys('posts') + end + + end + end +end diff --git a/activerecord/test/cases/connection_adapters/type_lookup_test.rb b/activerecord/test/cases/connection_adapters/type_lookup_test.rb new file mode 100644 index 0000000000..d5c1dc1e5d --- /dev/null +++ b/activerecord/test/cases/connection_adapters/type_lookup_test.rb @@ -0,0 +1,101 @@ +require "cases/helper" + +unless current_adapter?(:PostgreSQLAdapter) # PostgreSQL does not use type strigns for lookup +module ActiveRecord + module ConnectionAdapters + class TypeLookupTest < ActiveRecord::TestCase + setup do + @connection = ActiveRecord::Base.connection + end + + def test_boolean_types + assert_lookup_type :boolean, 'boolean' + assert_lookup_type :boolean, 'BOOLEAN' + end + + def test_string_types + assert_lookup_type :string, 'char' + assert_lookup_type :string, 'varchar' + assert_lookup_type :string, 'VARCHAR' + assert_lookup_type :string, 'varchar(255)' + assert_lookup_type :string, 'character varying' + end + + def test_binary_types + assert_lookup_type :binary, 'binary' + assert_lookup_type :binary, 'BINARY' + assert_lookup_type :binary, 'blob' + assert_lookup_type :binary, 'BLOB' + end + + def test_text_types + assert_lookup_type :text, 'text' + assert_lookup_type :text, 'TEXT' + assert_lookup_type :text, 'clob' + assert_lookup_type :text, 'CLOB' + end + + def test_date_types + assert_lookup_type :date, 'date' + assert_lookup_type :date, 'DATE' + end + + def test_time_types + assert_lookup_type :time, 'time' + assert_lookup_type :time, 'TIME' + end + + def test_datetime_types + assert_lookup_type :datetime, 'datetime' + assert_lookup_type :datetime, 'DATETIME' + assert_lookup_type :datetime, 'timestamp' + assert_lookup_type :datetime, 'TIMESTAMP' + end + + def test_decimal_types + assert_lookup_type :decimal, 'decimal' + assert_lookup_type :decimal, 'decimal(2,8)' + assert_lookup_type :decimal, 'DECIMAL' + assert_lookup_type :decimal, 'numeric' + assert_lookup_type :decimal, 'numeric(2,8)' + assert_lookup_type :decimal, 'NUMERIC' + assert_lookup_type :decimal, 'number' + assert_lookup_type :decimal, 'number(2,8)' + assert_lookup_type :decimal, 'NUMBER' + end + + def test_float_types + assert_lookup_type :float, 'float' + assert_lookup_type :float, 'FLOAT' + assert_lookup_type :float, 'double' + assert_lookup_type :float, 'DOUBLE' + end + + def test_integer_types + assert_lookup_type :integer, 'integer' + assert_lookup_type :integer, 'INTEGER' + assert_lookup_type :integer, 'tinyint' + assert_lookup_type :integer, 'smallint' + assert_lookup_type :integer, 'bigint' + end + + def test_decimal_without_scale + types = %w{decimal(2) decimal(2,0) numeric(2) numeric(2,0) number(2) number(2,0)} + types.each do |type| + cast_type = @connection.type_map.lookup(type) + + assert_equal :decimal, cast_type.type + assert_equal 2, cast_type.type_cast_from_user(2.1) + end + end + + private + + def assert_lookup_type(type, lookup) + cast_type = @connection.type_map.lookup(lookup) + assert_equal type, cast_type.type + end + end + end +end +end diff --git a/activerecord/test/cases/connection_management_test.rb b/activerecord/test/cases/connection_management_test.rb new file mode 100644 index 0000000000..77d9ae9b8e --- /dev/null +++ b/activerecord/test/cases/connection_management_test.rb @@ -0,0 +1,114 @@ +require "cases/helper" +require "rack" + +module ActiveRecord + module ConnectionAdapters + class ConnectionManagementTest < ActiveRecord::TestCase + class App + attr_reader :calls + def initialize + @calls = [] + end + + def call(env) + @calls << env + [200, {}, ['hi mom']] + end + end + + def setup + @env = {} + @app = App.new + @management = ConnectionManagement.new(@app) + + # make sure we have an active connection + assert ActiveRecord::Base.connection + assert ActiveRecord::Base.connection_handler.active_connections? + end + + if Process.respond_to?(:fork) + def test_connection_pool_per_pid + object_id = ActiveRecord::Base.connection.object_id + + rd, wr = IO.pipe + rd.binmode + wr.binmode + + pid = fork { + rd.close + wr.write Marshal.dump ActiveRecord::Base.connection.object_id + wr.close + exit! + } + + wr.close + + Process.waitpid pid + assert_not_equal object_id, Marshal.load(rd.read) + rd.close + end + end + + def test_app_delegation + manager = ConnectionManagement.new(@app) + + manager.call @env + assert_equal [@env], @app.calls + end + + def test_connections_are_active_after_call + @management.call(@env) + assert ActiveRecord::Base.connection_handler.active_connections? + end + + def test_body_responds_to_each + _, _, body = @management.call(@env) + bits = [] + body.each { |bit| bits << bit } + assert_equal ['hi mom'], bits + end + + def test_connections_are_cleared_after_body_close + _, _, body = @management.call(@env) + body.close + assert !ActiveRecord::Base.connection_handler.active_connections? + end + + def test_active_connections_are_not_cleared_on_body_close_during_test + @env['rack.test'] = true + _, _, body = @management.call(@env) + body.close + assert ActiveRecord::Base.connection_handler.active_connections? + end + + def test_connections_closed_if_exception + app = Class.new(App) { def call(env); raise NotImplementedError; end }.new + explosive = ConnectionManagement.new(app) + assert_raises(NotImplementedError) { explosive.call(@env) } + assert !ActiveRecord::Base.connection_handler.active_connections? + end + + def test_connections_not_closed_if_exception_and_test + @env['rack.test'] = true + app = Class.new(App) { def call(env); raise; end }.new + explosive = ConnectionManagement.new(app) + assert_raises(RuntimeError) { explosive.call(@env) } + assert ActiveRecord::Base.connection_handler.active_connections? + end + + test "doesn't clear active connections when running in a test case" do + @env['rack.test'] = true + @management.call(@env) + assert ActiveRecord::Base.connection_handler.active_connections? + end + + test "proxy is polite to it's body and responds to it" do + body = Class.new(String) { def to_path; "/path"; end }.new + app = lambda { |_| [200, {}, body] } + response_body = ConnectionManagement.new(app).call(@env)[2] + assert response_body.respond_to?(:to_path) + assert_equal response_body.to_path, "/path" + end + end + end +end diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb new file mode 100644 index 0000000000..8d15a76735 --- /dev/null +++ b/activerecord/test/cases/connection_pool_test.rb @@ -0,0 +1,346 @@ +require "cases/helper" +require 'active_support/concurrency/latch' + +module ActiveRecord + module ConnectionAdapters + class ConnectionPoolTest < ActiveRecord::TestCase + attr_reader :pool + + def setup + super + + # Keep a duplicate pool so we do not bother others + @pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec + + if in_memory_db? + # Separate connections to an in-memory database create an entirely new database, + # with an empty schema etc, so we just stub out this schema on the fly. + @pool.with_connection do |connection| + connection.create_table :posts do |t| + t.integer :cololumn + end + end + end + end + + teardown do + @pool.disconnect! + end + + def active_connections(pool) + pool.connections.find_all(&:in_use?) + end + + def test_checkout_after_close + connection = pool.connection + assert connection.in_use? + + connection.close + assert !connection.in_use? + + assert pool.connection.in_use? + end + + def test_released_connection_moves_between_threads + thread_conn = nil + + Thread.new { + pool.with_connection do |conn| + thread_conn = conn + end + }.join + + assert thread_conn + + Thread.new { + pool.with_connection do |conn| + assert_equal thread_conn, conn + end + }.join + end + + def test_with_connection + assert_equal 0, active_connections(pool).size + + main_thread = pool.connection + assert_equal 1, active_connections(pool).size + + Thread.new { + pool.with_connection do |conn| + assert conn + assert_equal 2, active_connections(pool).size + end + assert_equal 1, active_connections(pool).size + }.join + + main_thread.close + assert_equal 0, active_connections(pool).size + end + + def test_active_connection_in_use + assert !pool.active_connection? + main_thread = pool.connection + + assert pool.active_connection? + + main_thread.close + + assert !pool.active_connection? + end + + def test_full_pool_exception + @pool.size.times { @pool.checkout } + assert_raises(ConnectionTimeoutError) do + @pool.checkout + end + end + + def test_full_pool_blocks + cs = @pool.size.times.map { @pool.checkout } + t = Thread.new { @pool.checkout } + + # make sure our thread is in the timeout section + Thread.pass until t.status == "sleep" + + connection = cs.first + connection.close + assert_equal connection, t.join.value + end + + def test_removing_releases_latch + cs = @pool.size.times.map { @pool.checkout } + t = Thread.new { @pool.checkout } + + # make sure our thread is in the timeout section + Thread.pass until t.status == "sleep" + + connection = cs.first + @pool.remove connection + assert_respond_to t.join.value, :execute + connection.close + end + + def test_reap_and_active + @pool.checkout + @pool.checkout + @pool.checkout + + connections = @pool.connections.dup + + @pool.reap + + assert_equal connections.length, @pool.connections.length + end + + def test_reap_inactive + ready = ActiveSupport::Concurrency::Latch.new + @pool.checkout + child = Thread.new do + @pool.checkout + @pool.checkout + ready.release + Thread.stop + end + ready.await + + assert_equal 3, active_connections(@pool).size + + child.terminate + child.join + @pool.reap + + assert_equal 1, active_connections(@pool).size + ensure + @pool.connections.each(&:close) + end + + def test_remove_connection + conn = @pool.checkout + assert conn.in_use? + + length = @pool.connections.length + @pool.remove conn + assert conn.in_use? + assert_equal(length - 1, @pool.connections.length) + ensure + conn.close + end + + def test_remove_connection_for_thread + conn = @pool.connection + @pool.remove conn + assert_not_equal(conn, @pool.connection) + ensure + conn.close if conn + end + + def test_active_connection? + assert !@pool.active_connection? + assert @pool.connection + assert @pool.active_connection? + @pool.release_connection + assert !@pool.active_connection? + end + + def test_checkout_behaviour + pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec + connection = pool.connection + assert_not_nil connection + threads = [] + 4.times do |i| + threads << Thread.new(i) do + connection = pool.connection + assert_not_nil connection + connection.close + end + end + + threads.each(&:join) + + Thread.new do + assert pool.connection + pool.connection.close + end.join + end + + # The connection pool is "fair" if threads waiting for + # connections receive them the order in which they began + # waiting. This ensures that we don't timeout one HTTP request + # even while well under capacity in a multi-threaded environment + # such as a Java servlet container. + # + # We don't need strict fairness: if two connections become + # available at the same time, it's fine of two threads that were + # waiting acquire the connections out of order. + # + # Thus this test prepares waiting threads and then trickles in + # available connections slowly, ensuring the wakeup order is + # correct in this case. + def test_checkout_fairness + @pool.instance_variable_set(:@size, 10) + expected = (1..@pool.size).to_a.freeze + # check out all connections so our threads start out waiting + conns = expected.map { @pool.checkout } + mutex = Mutex.new + order = [] + errors = [] + + threads = expected.map do |i| + t = Thread.new { + begin + @pool.checkout # never checked back in + mutex.synchronize { order << i } + rescue => e + mutex.synchronize { errors << e } + end + } + Thread.pass until t.status == "sleep" + t + end + + # this should wake up the waiting threads one by one in order + conns.each { |conn| @pool.checkin(conn); sleep 0.1 } + + threads.each(&:join) + + raise errors.first if errors.any? + + assert_equal(expected, order) + end + + # As mentioned in #test_checkout_fairness, we don't care about + # strict fairness. This test creates two groups of threads: + # group1 whose members all start waiting before any thread in + # group2. Enough connections are checked in to wakeup all + # group1 threads, and the fact that only group1 and no group2 + # threads acquired a connection is enforced. + def test_checkout_fairness_by_group + @pool.instance_variable_set(:@size, 10) + # take all the connections + conns = (1..10).map { @pool.checkout } + mutex = Mutex.new + successes = [] # threads that successfully got a connection + errors = [] + + make_thread = proc do |i| + t = Thread.new { + begin + @pool.checkout # never checked back in + mutex.synchronize { successes << i } + rescue => e + mutex.synchronize { errors << e } + end + } + Thread.pass until t.status == "sleep" + t + end + + # all group1 threads start waiting before any in group2 + group1 = (1..5).map(&make_thread) + group2 = (6..10).map(&make_thread) + + # checkin n connections back to the pool + checkin = proc do |n| + n.times do + c = conns.pop + @pool.checkin(c) + end + end + + checkin.call(group1.size) # should wake up all group1 + + loop do + sleep 0.1 + break if mutex.synchronize { (successes.size + errors.size) == group1.size } + end + + winners = mutex.synchronize { successes.dup } + checkin.call(group2.size) # should wake up everyone remaining + + group1.each(&:join) + group2.each(&:join) + + assert_equal((1..group1.size).to_a, winners.sort) + + if errors.any? + raise errors.first + end + end + + def test_automatic_reconnect= + pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec + assert pool.automatic_reconnect + assert pool.connection + + pool.disconnect! + assert pool.connection + + pool.disconnect! + pool.automatic_reconnect = false + + assert_raises(ConnectionNotEstablished) do + pool.connection + end + + assert_raises(ConnectionNotEstablished) do + pool.with_connection + end + end + + def test_pool_sets_connection_visitor + assert @pool.connection.visitor.is_a?(Arel::Visitors::ToSql) + end + + # make sure exceptions are thrown when establish_connection + # is called with an anonymous class + def test_anonymous_class_exception + anonymous = Class.new(ActiveRecord::Base) + handler = ActiveRecord::Base.connection_handler + + assert_raises(RuntimeError) { + handler.establish_connection anonymous, nil + } + end + end + end +end diff --git a/activerecord/test/cases/connection_specification/resolver_test.rb b/activerecord/test/cases/connection_specification/resolver_test.rb new file mode 100644 index 0000000000..3c2f5d4219 --- /dev/null +++ b/activerecord/test/cases/connection_specification/resolver_test.rb @@ -0,0 +1,116 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class ConnectionSpecification + class ResolverTest < ActiveRecord::TestCase + def resolve(spec, config={}) + Resolver.new(config).resolve(spec) + end + + def spec(spec, config={}) + Resolver.new(config).spec(spec) + end + + def test_url_invalid_adapter + error = assert_raises(LoadError) do + spec 'ridiculous://foo?encoding=utf8' + end + + assert_match "Could not load 'active_record/connection_adapters/ridiculous_adapter'", error.message + end + + # The abstract adapter is used simply to bypass the bit of code that + # checks that the adapter file can be required in. + + def test_url_from_environment + spec = resolve :production, 'production' => 'abstract://foo?encoding=utf8' + assert_equal({ + "adapter" => "abstract", + "host" => "foo", + "encoding" => "utf8" }, spec) + end + + def test_url_sub_key + spec = resolve :production, 'production' => {"url" => 'abstract://foo?encoding=utf8'} + assert_equal({ + "adapter" => "abstract", + "host" => "foo", + "encoding" => "utf8" }, spec) + end + + def test_url_sub_key_merges_correctly + hash = {"url" => 'abstract://foo?encoding=utf8&', "adapter" => "sqlite3", "host" => "bar", "pool" => "3"} + spec = resolve :production, 'production' => hash + assert_equal({ + "adapter" => "abstract", + "host" => "foo", + "encoding" => "utf8", + "pool" => "3" }, spec) + end + + def test_url_host_no_db + spec = resolve 'abstract://foo?encoding=utf8' + assert_equal({ + "adapter" => "abstract", + "host" => "foo", + "encoding" => "utf8" }, spec) + end + + def test_url_host_db + spec = resolve 'abstract://foo/bar?encoding=utf8' + assert_equal({ + "adapter" => "abstract", + "database" => "bar", + "host" => "foo", + "encoding" => "utf8" }, spec) + end + + def test_url_port + spec = resolve 'abstract://foo:123?encoding=utf8' + assert_equal({ + "adapter" => "abstract", + "port" => 123, + "host" => "foo", + "encoding" => "utf8" }, spec) + end + + def test_encoded_password + password = 'am@z1ng_p@ssw0rd#!' + encoded_password = URI.encode_www_form_component(password) + spec = resolve "abstract://foo:#{encoded_password}@localhost/bar" + assert_equal password, spec["password"] + end + + def test_url_with_authority_for_sqlite3 + spec = resolve 'sqlite3:///foo_test' + assert_equal('/foo_test', spec["database"]) + end + + def test_url_absolute_path_for_sqlite3 + spec = resolve 'sqlite3:/foo_test' + assert_equal('/foo_test', spec["database"]) + end + + def test_url_relative_path_for_sqlite3 + spec = resolve 'sqlite3:foo_test' + assert_equal('foo_test', spec["database"]) + end + + def test_url_memory_db_for_sqlite3 + spec = resolve 'sqlite3::memory:' + assert_equal(':memory:', spec["database"]) + end + + def test_url_sub_key_for_sqlite3 + spec = resolve :production, 'production' => {"url" => 'sqlite3:foo?encoding=utf8'} + assert_equal({ + "adapter" => "sqlite3", + "database" => "foo", + "encoding" => "utf8" }, spec) + end + + end + end + end +end diff --git a/activerecord/test/cases/core_test.rb b/activerecord/test/cases/core_test.rb new file mode 100644 index 0000000000..715d92af99 --- /dev/null +++ b/activerecord/test/cases/core_test.rb @@ -0,0 +1,101 @@ +require 'cases/helper' +require 'models/person' +require 'models/topic' +require 'pp' +require 'active_support/core_ext/string/strip' + +class NonExistentTable < ActiveRecord::Base; end + +class CoreTest < ActiveRecord::TestCase + fixtures :topics + + def test_inspect_class + assert_equal 'ActiveRecord::Base', ActiveRecord::Base.inspect + assert_equal 'LoosePerson(abstract)', LoosePerson.inspect + assert_match(/^Topic\(id: integer, title: string/, Topic.inspect) + end + + def test_inspect_instance + topic = topics(:first) + assert_equal %(#<Topic id: 1, title: "The First Topic", author_name: "David", author_email_address: "david@loudthinking.com", written_on: "#{topic.written_on.to_s(:db)}", bonus_time: "#{topic.bonus_time.to_s(:db)}", last_read: "#{topic.last_read.to_s(:db)}", content: "Have a nice day", important: nil, approved: false, replies_count: 1, unique_replies_count: 0, parent_id: nil, parent_title: nil, type: nil, group: nil, created_at: "#{topic.created_at.to_s(:db)}", updated_at: "#{topic.updated_at.to_s(:db)}">), topic.inspect + end + + def test_inspect_new_instance + assert_match(/Topic id: nil/, Topic.new.inspect) + end + + def test_inspect_limited_select_instance + assert_equal %(#<Topic id: 1>), Topic.all.merge!(:select => 'id', :where => 'id = 1').first.inspect + assert_equal %(#<Topic id: 1, title: "The First Topic">), Topic.all.merge!(:select => 'id, title', :where => 'id = 1').first.inspect + end + + def test_inspect_class_without_table + assert_equal "NonExistentTable(Table doesn't exist)", NonExistentTable.inspect + end + + def test_pretty_print_new + topic = Topic.new + actual = '' + PP.pp(topic, StringIO.new(actual)) + expected = <<-PRETTY.strip_heredoc + #<Topic:0xXXXXXX + id: nil, + title: nil, + author_name: nil, + author_email_address: "test@test.com", + written_on: nil, + bonus_time: nil, + last_read: nil, + content: nil, + important: nil, + approved: true, + replies_count: 0, + unique_replies_count: 0, + parent_id: nil, + parent_title: nil, + type: nil, + group: nil, + created_at: nil, + updated_at: nil> + PRETTY + assert actual.start_with?(expected.split('XXXXXX').first) + assert actual.end_with?(expected.split('XXXXXX').last) + end + + def test_pretty_print_persisted + topic = topics(:first) + actual = '' + PP.pp(topic, StringIO.new(actual)) + expected = <<-PRETTY.strip_heredoc + #<Topic:0x\\w+ + id: 1, + title: "The First Topic", + author_name: "David", + author_email_address: "david@loudthinking.com", + written_on: 2003-07-16 14:28:11 UTC, + bonus_time: 2000-01-01 14:28:00 UTC, + last_read: Thu, 15 Apr 2004, + content: "Have a nice day", + important: nil, + approved: false, + replies_count: 1, + unique_replies_count: 0, + parent_id: nil, + parent_title: nil, + type: nil, + group: nil, + created_at: [^,]+, + updated_at: [^,>]+> + PRETTY + assert_match(/\A#{expected}\z/, actual) + end + + def test_pretty_print_uninitialized + topic = Topic.allocate + actual = '' + PP.pp(topic, StringIO.new(actual)) + expected = "#<Topic:XXXXXX not initialized>\n" + assert actual.start_with?(expected.split('XXXXXX').first) + assert actual.end_with?(expected.split('XXXXXX').last) + end +end diff --git a/activerecord/test/cases/counter_cache_test.rb b/activerecord/test/cases/counter_cache_test.rb new file mode 100644 index 0000000000..07a182070b --- /dev/null +++ b/activerecord/test/cases/counter_cache_test.rb @@ -0,0 +1,183 @@ +require 'cases/helper' +require 'models/topic' +require 'models/car' +require 'models/wheel' +require 'models/engine' +require 'models/reply' +require 'models/category' +require 'models/categorization' +require 'models/dog' +require 'models/dog_lover' +require 'models/person' +require 'models/friendship' +require 'models/subscriber' +require 'models/subscription' +require 'models/book' + +class CounterCacheTest < ActiveRecord::TestCase + fixtures :topics, :categories, :categorizations, :cars, :dogs, :dog_lovers, :people, :friendships, :subscribers, :subscriptions, :books + + class ::SpecialTopic < ::Topic + has_many :special_replies, :foreign_key => 'parent_id' + has_many :lightweight_special_replies, -> { select('topics.id, topics.title') }, :foreign_key => 'parent_id', :class_name => 'SpecialReply' + end + + class ::SpecialReply < ::Reply + belongs_to :special_topic, :foreign_key => 'parent_id', :counter_cache => 'replies_count' + end + + setup do + @topic = Topic.find(1) + end + + test "increment counter" do + assert_difference '@topic.reload.replies_count' do + Topic.increment_counter(:replies_count, @topic.id) + end + end + + test "decrement counter" do + assert_difference '@topic.reload.replies_count', -1 do + Topic.decrement_counter(:replies_count, @topic.id) + end + end + + test "reset counters" do + # throw the count off by 1 + Topic.increment_counter(:replies_count, @topic.id) + + # check that it gets reset + assert_difference '@topic.reload.replies_count', -1 do + Topic.reset_counters(@topic.id, :replies) + end + end + + test "reset counters by counter name" do + # throw the count off by 1 + Topic.increment_counter(:replies_count, @topic.id) + + # check that it gets reset + assert_difference '@topic.reload.replies_count', -1 do + Topic.reset_counters(@topic.id, :replies_count) + end + end + + test 'reset multiple counters' do + Topic.update_counters @topic.id, replies_count: 1, unique_replies_count: 1 + assert_difference ['@topic.reload.replies_count', '@topic.reload.unique_replies_count'], -1 do + Topic.reset_counters(@topic.id, :replies, :unique_replies) + end + end + + test "reset counters with string argument" do + Topic.increment_counter('replies_count', @topic.id) + + assert_difference '@topic.reload.replies_count', -1 do + Topic.reset_counters(@topic.id, 'replies') + end + end + + test "reset counters with modularized and camelized classnames" do + special = SpecialTopic.create!(:title => 'Special') + SpecialTopic.increment_counter(:replies_count, special.id) + + assert_difference 'special.reload.replies_count', -1 do + SpecialTopic.reset_counters(special.id, :special_replies) + end + end + + test "reset counter with belongs_to which has class_name" do + car = cars(:honda) + assert_nothing_raised do + Car.reset_counters(car.id, :engines) + end + assert_nothing_raised do + Car.reset_counters(car.id, :wheels) + end + end + + test "reset the right counter if two have the same class_name" do + david = dog_lovers(:david) + + DogLover.increment_counter(:bred_dogs_count, david.id) + DogLover.increment_counter(:trained_dogs_count, david.id) + + assert_difference 'david.reload.bred_dogs_count', -1 do + DogLover.reset_counters(david.id, :bred_dogs) + end + assert_difference 'david.reload.trained_dogs_count', -1 do + DogLover.reset_counters(david.id, :trained_dogs) + end + end + + test "update counter with initial null value" do + category = categories(:general) + assert_equal 2, category.categorizations.count + assert_nil category.categorizations_count + + Category.update_counters(category.id, :categorizations_count => category.categorizations.count) + assert_equal 2, category.reload.categorizations_count + end + + test "update counter for decrement" do + assert_difference '@topic.reload.replies_count', -3 do + Topic.update_counters(@topic.id, :replies_count => -3) + end + end + + test "update counters of multiple records" do + t1, t2 = topics(:first, :second) + + assert_difference ['t1.reload.replies_count', 't2.reload.replies_count'], 2 do + Topic.update_counters([t1.id, t2.id], :replies_count => 2) + end + end + + test 'update multiple counters' do + assert_difference ['@topic.reload.replies_count', '@topic.reload.unique_replies_count'], 2 do + Topic.update_counters @topic.id, replies_count: 2, unique_replies_count: 2 + end + end + + test "update other counters on parent destroy" do + david, joanna = dog_lovers(:david, :joanna) + joanna = joanna # squelch a warning + + assert_difference 'joanna.reload.dogs_count', -1 do + david.destroy + end + end + + test "reset the right counter if two have the same foreign key" do + michael = people(:michael) + assert_nothing_raised(ActiveRecord::StatementInvalid) do + Person.reset_counters(michael.id, :friends_too) + end + end + + test "reset counter of has_many :through association" do + subscriber = subscribers('second') + Subscriber.reset_counters(subscriber.id, 'books') + Subscriber.increment_counter('books_count', subscriber.id) + + assert_difference 'subscriber.reload.books_count', -1 do + Subscriber.reset_counters(subscriber.id, 'books') + end + end + + test "the passed symbol needs to be an association name or counter name" do + e = assert_raises(ArgumentError) do + Topic.reset_counters(@topic.id, :undefined_count) + end + assert_equal "'Topic' has no association called 'undefined_count'", e.message + end + + test "reset counter works with select declared on association" do + special = SpecialTopic.create!(:title => 'Special') + SpecialTopic.increment_counter(:replies_count, special.id) + + assert_difference 'special.reload.replies_count', -1 do + SpecialTopic.reset_counters(special.id, :lightweight_special_replies) + end + end +end diff --git a/activerecord/test/cases/custom_locking_test.rb b/activerecord/test/cases/custom_locking_test.rb new file mode 100644 index 0000000000..e8290297e3 --- /dev/null +++ b/activerecord/test/cases/custom_locking_test.rb @@ -0,0 +1,17 @@ +require "cases/helper" +require 'models/person' + +module ActiveRecord + class CustomLockingTest < ActiveRecord::TestCase + fixtures :people + + def test_custom_lock + if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + assert_match 'SHARE MODE', Person.lock('LOCK IN SHARE MODE').to_sql + assert_sql(/LOCK IN SHARE MODE/) do + Person.all.merge!(:lock => 'LOCK IN SHARE MODE').find(1) + end + end + end + end +end diff --git a/activerecord/test/cases/database_statements_test.rb b/activerecord/test/cases/database_statements_test.rb new file mode 100644 index 0000000000..c689e97d83 --- /dev/null +++ b/activerecord/test/cases/database_statements_test.rb @@ -0,0 +1,19 @@ +require "cases/helper" + +class DatabaseStatementsTest < ActiveRecord::TestCase + def setup + @connection = ActiveRecord::Base.connection + end + + def test_insert_should_return_the_inserted_id + # Oracle adapter uses prefetched primary key values from sequence and passes them to connection adapter insert method + if current_adapter?(:OracleAdapter) + sequence_name = "accounts_seq" + id_value = @connection.next_sequence_value(sequence_name) + id = @connection.insert("INSERT INTO accounts (id, firm_id,credit_limit) VALUES (accounts_seq.nextval,42,5000)", nil, :id, id_value, sequence_name) + else + id = @connection.insert("INSERT INTO accounts (firm_id,credit_limit) VALUES (42,5000)") + end + assert_not_nil id + end +end diff --git a/activerecord/test/cases/date_time_test.rb b/activerecord/test/cases/date_time_test.rb new file mode 100644 index 0000000000..c0491bbee5 --- /dev/null +++ b/activerecord/test/cases/date_time_test.rb @@ -0,0 +1,43 @@ +require "cases/helper" +require 'models/topic' +require 'models/task' + +class DateTimeTest < ActiveRecord::TestCase + def test_saves_both_date_and_time + with_env_tz 'America/New_York' do + with_timezone_config default: :utc do + time_values = [1807, 2, 10, 15, 30, 45] + # create DateTime value with local time zone offset + local_offset = Rational(Time.local(*time_values).utc_offset, 86400) + now = DateTime.civil(*(time_values + [local_offset])) + + task = Task.new + task.starting = now + task.save! + + # check against Time.local, since some platforms will return a Time instead of a DateTime + assert_equal Time.local(*time_values), Task.find(task.id).starting + end + end + end + + def test_assign_empty_date_time + task = Task.new + task.starting = '' + task.ending = nil + assert_nil task.starting + assert_nil task.ending + end + + def test_assign_empty_date + topic = Topic.new + topic.last_read = '' + assert_nil topic.last_read + end + + def test_assign_empty_time + topic = Topic.new + topic.bonus_time = '' + assert_nil topic.bonus_time + end +end diff --git a/activerecord/test/cases/defaults_test.rb b/activerecord/test/cases/defaults_test.rb new file mode 100644 index 0000000000..c089e63128 --- /dev/null +++ b/activerecord/test/cases/defaults_test.rb @@ -0,0 +1,219 @@ +require "cases/helper" +require 'models/default' +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 || (50100..50122).include?(Mysql.client_version)) + { 'id' => nil, 'name' => '', 'course_id' => nil } + else + { 'id' => nil, 'name' => nil, 'course_id' => nil } + end + + column_defaults.each do |name, default| + column = Entrant.columns_hash[name] + assert !column.null, "#{name} column should be NOT NULL" + assert_equal default, column.default, "#{name} column should be DEFAULT #{default.inspect}" + end + end + + if current_adapter?(:PostgreSQLAdapter, :OracleAdapter) + def test_default_integers + default = Default.new + assert_instance_of Fixnum, default.positive_integer + assert_equal 1, default.positive_integer + assert_instance_of Fixnum, default.negative_integer + assert_equal(-1, default.negative_integer) + assert_instance_of BigDecimal, default.decimal_number + assert_equal BigDecimal.new("2.78"), default.decimal_number + end + end + + if current_adapter?(:PostgreSQLAdapter) + def test_multiline_default_text + # older postgres versions represent the default with escapes ("\\012" for a newline) + assert( "--- []\n\n" == Default.columns_hash['multiline_default'].default || + "--- []\\012\\012" == Default.columns_hash['multiline_default'].default) + end + end +end + +class DefaultStringsTest < ActiveRecord::TestCase + class DefaultString < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table :default_strings do |t| + t.string :string_col, default: "Smith" + t.string :string_col_with_quotes, default: "O'Connor" + end + DefaultString.reset_column_information + end + + def test_default_strings + assert_equal "Smith", DefaultString.new.string_col + end + + def test_default_strings_containing_single_quotes + assert_equal "O'Connor", DefaultString.new.string_col_with_quotes + end + + teardown do + @connection.drop_table :default_strings + end +end + +if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + class DefaultsTestWithoutTransactionalFixtures < ActiveRecord::TestCase + # ActiveRecord::Base#create! (and #save and other related methods) will + # open a new transaction. When in transactional fixtures mode, this will + # cause Active Record to create a new savepoint. However, since MySQL doesn't + # support DDL transactions, creating a table will result in any created + # savepoints to be automatically released. This in turn causes the savepoint + # release code in AbstractAdapter#transaction to fail. + # + # We don't want that to happen, so we disable transactional fixtures here. + self.use_transactional_fixtures = false + + def using_strict(strict) + connection = ActiveRecord::Base.remove_connection + ActiveRecord::Base.establish_connection connection.merge(strict: strict) + yield + ensure + ActiveRecord::Base.remove_connection + ActiveRecord::Base.establish_connection connection + end + + # MySQL cannot have defaults on text/blob columns. It reports the + # default value as null. + # + # Despite this, in non-strict mode, MySQL will use an empty string + # as the default value of the field, if no other value is + # specified. + # + # Therefore, in non-strict mode, we want column.default to report + # an empty string as its default, to be consistent with that. + # + # In strict mode, column.default should be nil. + def test_mysql_text_not_null_defaults_non_strict + using_strict(false) do + with_text_blob_not_null_table do |klass| + assert_equal '', klass.columns_hash['non_null_blob'].default + assert_equal '', klass.columns_hash['non_null_text'].default + + assert_nil klass.columns_hash['null_blob'].default + assert_nil klass.columns_hash['null_text'].default + + instance = klass.create! + + assert_equal '', instance.non_null_text + assert_equal '', instance.non_null_blob + + assert_nil instance.null_text + assert_nil instance.null_blob + end + end + end + + def test_mysql_text_not_null_defaults_strict + using_strict(true) do + with_text_blob_not_null_table do |klass| + assert_nil klass.columns_hash['non_null_blob'].default + assert_nil klass.columns_hash['non_null_text'].default + assert_nil klass.columns_hash['null_blob'].default + assert_nil klass.columns_hash['null_text'].default + + assert_raises(ActiveRecord::StatementInvalid) { klass.create } + end + end + end + + def with_text_blob_not_null_table + klass = Class.new(ActiveRecord::Base) + klass.table_name = 'test_mysql_text_not_null_defaults' + klass.connection.create_table klass.table_name do |t| + t.column :non_null_text, :text, :null => false + t.column :non_null_blob, :blob, :null => false + t.column :null_text, :text, :null => true + t.column :null_blob, :blob, :null => true + end + + yield klass + ensure + klass.connection.drop_table(klass.table_name) rescue nil + end + + # MySQL uses an implicit default 0 rather than NULL unless in strict mode. + # We use an implicit NULL so schema.rb is compatible with other databases. + def test_mysql_integer_not_null_defaults + klass = Class.new(ActiveRecord::Base) + klass.table_name = 'test_integer_not_null_default_zero' + klass.connection.create_table klass.table_name do |t| + t.column :zero, :integer, :null => false, :default => 0 + t.column :omit, :integer, :null => false + end + + assert_equal '0', klass.columns_hash['zero'].default + assert !klass.columns_hash['zero'].null + # 0 in MySQL 4, nil in 5. + assert [0, nil].include?(klass.columns_hash['omit'].default) + assert !klass.columns_hash['omit'].null + + assert_raise(ActiveRecord::StatementInvalid) { klass.create! } + + assert_nothing_raised do + instance = klass.create!(:omit => 1) + assert_equal 0, instance.zero + assert_equal 1, instance.omit + end + ensure + klass.connection.drop_table(klass.table_name) rescue nil + end + end +end + +if current_adapter?(:PostgreSQLAdapter) + class DefaultsUsingMultipleSchemasAndDomainTest < ActiveSupport::TestCase + def setup + @connection = ActiveRecord::Base.connection + + @old_search_path = @connection.schema_search_path + @connection.schema_search_path = "schema_1, pg_catalog" + @connection.create_table "defaults" do |t| + t.text "text_col", :default => "some value" + t.string "string_col", :default => "some value" + end + Default.reset_column_information + end + + def test_text_defaults_in_new_schema_when_overriding_domain + assert_equal "some value", Default.new.text_col, "Default of text column was not correctly parse" + end + + def test_string_defaults_in_new_schema_when_overriding_domain + assert_equal "some value", Default.new.string_col, "Default of string column was not correctly parse" + end + + def test_bpchar_defaults_in_new_schema_when_overriding_domain + @connection.execute "ALTER TABLE defaults ADD bpchar_col bpchar DEFAULT 'some value'" + Default.reset_column_information + assert_equal "some value", Default.new.bpchar_col, "Default of bpchar column was not correctly parse" + end + + def test_text_defaults_after_updating_column_default + @connection.execute "ALTER TABLE defaults ALTER COLUMN text_col SET DEFAULT 'some text'::schema_1.text" + assert_equal "some text", Default.new.text_col, "Default of text column was not correctly parse after updating default using '::text' since postgreSQL will add parens to the default in db" + end + + def test_default_containing_quote_and_colons + @connection.execute "ALTER TABLE defaults ALTER COLUMN string_col SET DEFAULT 'foo''::bar'" + assert_equal "foo'::bar", Default.new.string_col + end + + teardown do + @connection.schema_search_path = @old_search_path + Default.reset_column_information + end + end +end diff --git a/activerecord/test/cases/dirty_test.rb b/activerecord/test/cases/dirty_test.rb new file mode 100644 index 0000000000..69a7f25213 --- /dev/null +++ b/activerecord/test/cases/dirty_test.rb @@ -0,0 +1,679 @@ +require 'cases/helper' +require 'models/topic' # For booleans +require 'models/pirate' # For timestamps +require 'models/parrot' +require 'models/person' # For optimistic locking +require 'models/aircraft' + +class Pirate # Just reopening it, not defining it + attr_accessor :detected_changes_in_after_update # Boolean for if changes are detected + attr_accessor :changes_detected_in_after_update # Actual changes + + after_update :check_changes + +private + # after_save/update and the model itself + # can end up checking dirty status and acting on the results + def check_changes + if self.changed? + self.detected_changes_in_after_update = true + self.changes_detected_in_after_update = self.changes + end + end +end + +class NumericData < ActiveRecord::Base + self.table_name = 'numeric_data' +end + +class DirtyTest < ActiveRecord::TestCase + include InTimeZone + + # Dummy to force column loads so query counts are clean. + def setup + Person.create :first_name => 'foo' + end + + def test_attribute_changes + # New record - no changes. + pirate = Pirate.new + assert !pirate.catchphrase_changed? + assert_nil pirate.catchphrase_change + + # Change catchphrase. + pirate.catchphrase = 'arrr' + assert pirate.catchphrase_changed? + assert_nil pirate.catchphrase_was + assert_equal [nil, 'arrr'], pirate.catchphrase_change + + # Saved - no changes. + pirate.save! + assert !pirate.catchphrase_changed? + assert_nil pirate.catchphrase_change + + # Same value - no changes. + pirate.catchphrase = 'arrr' + assert !pirate.catchphrase_changed? + assert_nil pirate.catchphrase_change + end + + def test_time_attributes_changes_with_time_zone + in_time_zone 'Paris' do + target = Class.new(ActiveRecord::Base) + target.table_name = 'pirates' + + # New record - no changes. + pirate = target.new + assert !pirate.created_on_changed? + assert_nil pirate.created_on_change + + # Saved - no changes. + pirate.catchphrase = 'arrrr, time zone!!' + pirate.save! + assert !pirate.created_on_changed? + assert_nil pirate.created_on_change + + # Change created_on. + old_created_on = pirate.created_on + pirate.created_on = Time.now - 1.day + assert pirate.created_on_changed? + assert_kind_of ActiveSupport::TimeWithZone, pirate.created_on_was + assert_equal old_created_on, pirate.created_on_was + pirate.created_on = old_created_on + assert !pirate.created_on_changed? + end + end + + def test_setting_time_attributes_with_time_zone_field_to_itself_should_not_be_marked_as_a_change + in_time_zone 'Paris' do + target = Class.new(ActiveRecord::Base) + target.table_name = 'pirates' + + pirate = target.create + pirate.created_on = pirate.created_on + assert !pirate.created_on_changed? + end + end + + def test_time_attributes_changes_without_time_zone_by_skip + in_time_zone 'Paris' do + target = Class.new(ActiveRecord::Base) + target.table_name = 'pirates' + + target.skip_time_zone_conversion_for_attributes = [:created_on] + + # New record - no changes. + pirate = target.new + assert !pirate.created_on_changed? + assert_nil pirate.created_on_change + + # Saved - no changes. + pirate.catchphrase = 'arrrr, time zone!!' + pirate.save! + assert !pirate.created_on_changed? + assert_nil pirate.created_on_change + + # Change created_on. + old_created_on = pirate.created_on + pirate.created_on = Time.now + 1.day + assert pirate.created_on_changed? + # kind_of does not work because + # ActiveSupport::TimeWithZone.name == 'Time' + assert_instance_of Time, pirate.created_on_was + assert_equal old_created_on, pirate.created_on_was + end + end + + def test_time_attributes_changes_without_time_zone + with_timezone_config aware_attributes: false do + target = Class.new(ActiveRecord::Base) + target.table_name = 'pirates' + + # New record - no changes. + pirate = target.new + assert !pirate.created_on_changed? + assert_nil pirate.created_on_change + + # Saved - no changes. + pirate.catchphrase = 'arrrr, time zone!!' + pirate.save! + assert !pirate.created_on_changed? + assert_nil pirate.created_on_change + + # Change created_on. + old_created_on = pirate.created_on + pirate.created_on = Time.now + 1.day + assert pirate.created_on_changed? + # kind_of does not work because + # ActiveSupport::TimeWithZone.name == 'Time' + assert_instance_of Time, pirate.created_on_was + assert_equal old_created_on, pirate.created_on_was + end + end + + + def test_aliased_attribute_changes + # the actual attribute here is name, title is an + # alias setup via alias_attribute + parrot = Parrot.new + assert !parrot.title_changed? + assert_nil parrot.title_change + + parrot.name = 'Sam' + assert parrot.title_changed? + assert_nil parrot.title_was + assert_equal parrot.name_change, parrot.title_change + end + + def test_reset_attribute! + pirate = Pirate.create!(:catchphrase => 'Yar!') + pirate.catchphrase = 'Ahoy!' + + assert_deprecated do + pirate.reset_catchphrase! + end + assert_equal "Yar!", pirate.catchphrase + assert_equal Hash.new, pirate.changes + assert !pirate.catchphrase_changed? + end + + def test_restore_attribute! + pirate = Pirate.create!(:catchphrase => 'Yar!') + pirate.catchphrase = 'Ahoy!' + + pirate.restore_catchphrase! + assert_equal "Yar!", pirate.catchphrase + assert_equal Hash.new, pirate.changes + assert !pirate.catchphrase_changed? + end + + def test_nullable_number_not_marked_as_changed_if_new_value_is_blank + pirate = Pirate.new + + ["", nil].each do |value| + pirate.parrot_id = value + assert !pirate.parrot_id_changed? + assert_nil pirate.parrot_id_change + end + end + + def test_nullable_decimal_not_marked_as_changed_if_new_value_is_blank + numeric_data = NumericData.new + + ["", nil].each do |value| + numeric_data.bank_balance = value + assert !numeric_data.bank_balance_changed? + assert_nil numeric_data.bank_balance_change + end + end + + def test_nullable_float_not_marked_as_changed_if_new_value_is_blank + numeric_data = NumericData.new + + ["", nil].each do |value| + numeric_data.temperature = value + assert !numeric_data.temperature_changed? + assert_nil numeric_data.temperature_change + end + end + + def test_nullable_datetime_not_marked_as_changed_if_new_value_is_blank + in_time_zone 'Edinburgh' do + target = Class.new(ActiveRecord::Base) + target.table_name = 'topics' + + topic = target.create + assert_nil topic.written_on + + ["", nil].each do |value| + topic.written_on = value + assert_nil topic.written_on + assert !topic.written_on_changed? + end + end + end + + def test_integer_zero_to_string_zero_not_marked_as_changed + pirate = Pirate.new + pirate.parrot_id = 0 + pirate.catchphrase = 'arrr' + assert pirate.save! + + assert !pirate.changed? + + pirate.parrot_id = '0' + assert !pirate.changed? + end + + def test_integer_zero_to_integer_zero_not_marked_as_changed + pirate = Pirate.new + pirate.parrot_id = 0 + pirate.catchphrase = 'arrr' + assert pirate.save! + + assert !pirate.changed? + + pirate.parrot_id = 0 + assert !pirate.changed? + end + + def test_float_zero_to_string_zero_not_marked_as_changed + data = NumericData.new :temperature => 0.0 + data.save! + + assert_not data.changed? + + data.temperature = '0' + assert_empty data.changes + + data.temperature = '0.0' + assert_empty data.changes + + data.temperature = '0.00' + assert_empty data.changes + end + + def test_zero_to_blank_marked_as_changed + pirate = Pirate.new + pirate.catchphrase = "Yarrrr, me hearties" + pirate.parrot_id = 1 + pirate.save + + # check the change from 1 to '' + pirate = Pirate.find_by_catchphrase("Yarrrr, me hearties") + pirate.parrot_id = '' + assert pirate.parrot_id_changed? + assert_equal([1, nil], pirate.parrot_id_change) + pirate.save + + # check the change from nil to 0 + pirate = Pirate.find_by_catchphrase("Yarrrr, me hearties") + pirate.parrot_id = 0 + assert pirate.parrot_id_changed? + assert_equal([nil, 0], pirate.parrot_id_change) + pirate.save + + # check the change from 0 to '' + pirate = Pirate.find_by_catchphrase("Yarrrr, me hearties") + pirate.parrot_id = '' + assert pirate.parrot_id_changed? + assert_equal([0, nil], pirate.parrot_id_change) + end + + def test_object_should_be_changed_if_any_attribute_is_changed + pirate = Pirate.new + assert !pirate.changed? + assert_equal [], pirate.changed + assert_equal Hash.new, pirate.changes + + pirate.catchphrase = 'arrr' + assert pirate.changed? + assert_nil pirate.catchphrase_was + assert_equal %w(catchphrase), pirate.changed + assert_equal({'catchphrase' => [nil, 'arrr']}, pirate.changes) + + pirate.save + assert !pirate.changed? + assert_equal [], pirate.changed + assert_equal Hash.new, pirate.changes + end + + def test_attribute_will_change! + pirate = Pirate.create!(:catchphrase => 'arr') + + assert !pirate.catchphrase_changed? + assert pirate.catchphrase_will_change! + assert pirate.catchphrase_changed? + assert_equal ['arr', 'arr'], pirate.catchphrase_change + + pirate.catchphrase << ' matey!' + assert pirate.catchphrase_changed? + assert_equal ['arr', 'arr matey!'], pirate.catchphrase_change + end + + def test_association_assignment_changes_foreign_key + pirate = Pirate.create!(:catchphrase => 'jarl') + pirate.parrot = Parrot.create!(:name => 'Lorre') + assert pirate.changed? + assert_equal %w(parrot_id), pirate.changed + end + + def test_attribute_should_be_compared_with_type_cast + topic = Topic.new + assert topic.approved? + assert !topic.approved_changed? + + # Coming from web form. + params = {:topic => {:approved => 1}} + # In the controller. + topic.attributes = params[:topic] + assert topic.approved? + assert !topic.approved_changed? + end + + def test_partial_update + pirate = Pirate.new(:catchphrase => 'foo') + old_updated_on = 1.hour.ago.beginning_of_day + + with_partial_writes Pirate, false do + assert_queries(2) { 2.times { pirate.save! } } + Pirate.where(id: pirate.id).update_all(:updated_on => old_updated_on) + end + + with_partial_writes Pirate, true do + assert_queries(0) { 2.times { pirate.save! } } + assert_equal old_updated_on, pirate.reload.updated_on + + assert_queries(1) { pirate.catchphrase = 'bar'; pirate.save! } + assert_not_equal old_updated_on, pirate.reload.updated_on + end + end + + def test_partial_update_with_optimistic_locking + person = Person.new(:first_name => 'foo') + old_lock_version = 1 + + with_partial_writes Person, false do + assert_queries(2) { 2.times { person.save! } } + Person.where(id: person.id).update_all(:first_name => 'baz') + end + + with_partial_writes Person, true do + assert_queries(0) { 2.times { person.save! } } + assert_equal old_lock_version, person.reload.lock_version + + assert_queries(1) { person.first_name = 'bar'; person.save! } + assert_not_equal old_lock_version, person.reload.lock_version + end + end + + def test_changed_attributes_should_be_preserved_if_save_failure + pirate = Pirate.new + pirate.parrot_id = 1 + assert !pirate.save + check_pirate_after_save_failure(pirate) + + pirate = Pirate.new + pirate.parrot_id = 1 + assert_raise(ActiveRecord::RecordInvalid) { pirate.save! } + check_pirate_after_save_failure(pirate) + end + + def test_reload_should_clear_changed_attributes + pirate = Pirate.create!(:catchphrase => "shiver me timbers") + pirate.catchphrase = "*hic*" + assert pirate.changed? + pirate.reload + assert !pirate.changed? + end + + def test_dup_objects_should_not_copy_dirty_flag_from_creator + pirate = Pirate.create!(:catchphrase => "shiver me timbers") + pirate_dup = pirate.dup + pirate_dup.restore_catchphrase! + pirate.catchphrase = "I love Rum" + assert pirate.catchphrase_changed? + assert !pirate_dup.catchphrase_changed? + end + + def test_reverted_changes_are_not_dirty + phrase = "shiver me timbers" + pirate = Pirate.create!(:catchphrase => phrase) + pirate.catchphrase = "*hic*" + assert pirate.changed? + pirate.catchphrase = phrase + assert !pirate.changed? + end + + def test_reverted_changes_are_not_dirty_after_multiple_changes + phrase = "shiver me timbers" + pirate = Pirate.create!(:catchphrase => phrase) + 10.times do |i| + pirate.catchphrase = "*hic*" * i + assert pirate.changed? + end + assert pirate.changed? + pirate.catchphrase = phrase + assert !pirate.changed? + end + + + def test_reverted_changes_are_not_dirty_going_from_nil_to_value_and_back + pirate = Pirate.create!(:catchphrase => "Yar!") + + pirate.parrot_id = 1 + assert pirate.changed? + assert pirate.parrot_id_changed? + assert !pirate.catchphrase_changed? + + pirate.parrot_id = nil + assert !pirate.changed? + assert !pirate.parrot_id_changed? + assert !pirate.catchphrase_changed? + end + + def test_save_should_store_serialized_attributes_even_with_partial_writes + with_partial_writes(Topic) do + topic = Topic.create!(:content => {:a => "a"}) + + assert_not topic.changed? + + topic.content[:b] = "b" + + assert topic.changed? + + topic.save! + + assert_not topic.changed? + assert_equal "b", topic.content[:b] + + topic.reload + + assert_equal "b", topic.content[:b] + end + end + + def test_save_always_should_update_timestamps_when_serialized_attributes_are_present + with_partial_writes(Topic) do + topic = Topic.create!(:content => {:a => "a"}) + topic.save! + + updated_at = topic.updated_at + topic.content[:hello] = 'world' + topic.save! + + assert_not_equal updated_at, topic.updated_at + assert_equal 'world', topic.content[:hello] + end + end + + def test_save_should_not_save_serialized_attribute_with_partial_writes_if_not_present + with_partial_writes(Topic) do + Topic.create!(:author_name => 'Bill', :content => {:a => "a"}) + topic = Topic.select('id, author_name').first + topic.update_columns author_name: 'John' + topic = Topic.first + assert_not_nil topic.content + end + end + + def test_previous_changes + # original values should be in previous_changes + pirate = Pirate.new + + assert_equal Hash.new, pirate.previous_changes + pirate.catchphrase = "arrr" + pirate.save! + + assert_equal 4, pirate.previous_changes.size + assert_equal [nil, "arrr"], pirate.previous_changes['catchphrase'] + assert_equal [nil, pirate.id], pirate.previous_changes['id'] + assert_nil pirate.previous_changes['updated_on'][0] + assert_not_nil pirate.previous_changes['updated_on'][1] + assert_nil pirate.previous_changes['created_on'][0] + assert_not_nil pirate.previous_changes['created_on'][1] + assert !pirate.previous_changes.key?('parrot_id') + + # original values should be in previous_changes + pirate = Pirate.new + + assert_equal Hash.new, pirate.previous_changes + pirate.catchphrase = "arrr" + pirate.save + + assert_equal 4, pirate.previous_changes.size + assert_equal [nil, "arrr"], pirate.previous_changes['catchphrase'] + assert_equal [nil, pirate.id], pirate.previous_changes['id'] + assert pirate.previous_changes.include?('updated_on') + assert pirate.previous_changes.include?('created_on') + assert !pirate.previous_changes.key?('parrot_id') + + pirate.catchphrase = "Yar!!" + pirate.reload + assert_equal Hash.new, pirate.previous_changes + + pirate = Pirate.find_by_catchphrase("arrr") + pirate.catchphrase = "Me Maties!" + pirate.save! + + assert_equal 2, pirate.previous_changes.size + assert_equal ["arrr", "Me Maties!"], pirate.previous_changes['catchphrase'] + assert_not_nil pirate.previous_changes['updated_on'][0] + assert_not_nil pirate.previous_changes['updated_on'][1] + assert !pirate.previous_changes.key?('parrot_id') + assert !pirate.previous_changes.key?('created_on') + + pirate = Pirate.find_by_catchphrase("Me Maties!") + pirate.catchphrase = "Thar She Blows!" + pirate.save + + assert_equal 2, pirate.previous_changes.size + assert_equal ["Me Maties!", "Thar She Blows!"], pirate.previous_changes['catchphrase'] + assert_not_nil pirate.previous_changes['updated_on'][0] + assert_not_nil pirate.previous_changes['updated_on'][1] + assert !pirate.previous_changes.key?('parrot_id') + assert !pirate.previous_changes.key?('created_on') + + pirate = Pirate.find_by_catchphrase("Thar She Blows!") + pirate.update(catchphrase: "Ahoy!") + + assert_equal 2, pirate.previous_changes.size + assert_equal ["Thar She Blows!", "Ahoy!"], pirate.previous_changes['catchphrase'] + assert_not_nil pirate.previous_changes['updated_on'][0] + assert_not_nil pirate.previous_changes['updated_on'][1] + assert !pirate.previous_changes.key?('parrot_id') + assert !pirate.previous_changes.key?('created_on') + + pirate = Pirate.find_by_catchphrase("Ahoy!") + pirate.update_attribute(:catchphrase, "Ninjas suck!") + + assert_equal 2, pirate.previous_changes.size + assert_equal ["Ahoy!", "Ninjas suck!"], pirate.previous_changes['catchphrase'] + assert_not_nil pirate.previous_changes['updated_on'][0] + assert_not_nil pirate.previous_changes['updated_on'][1] + assert !pirate.previous_changes.key?('parrot_id') + assert !pirate.previous_changes.key?('created_on') + end + + if ActiveRecord::Base.connection.supports_migrations? + class Testings < ActiveRecord::Base; end + def test_field_named_field + ActiveRecord::Base.connection.create_table :testings do |t| + t.string :field + end + assert_nothing_raised do + Testings.new.attributes + end + ensure + ActiveRecord::Base.connection.drop_table :testings rescue nil + end + end + + def test_datetime_attribute_can_be_updated_with_fractional_seconds + in_time_zone 'Paris' do + target = Class.new(ActiveRecord::Base) + target.table_name = 'topics' + + written_on = Time.utc(2012, 12, 1, 12, 0, 0).in_time_zone('Paris') + + topic = target.create(:written_on => written_on) + topic.written_on += 0.3 + + assert topic.written_on_changed?, 'Fractional second update not detected' + end + end + + def test_datetime_attribute_doesnt_change_if_zone_is_modified_in_string + time_in_paris = Time.utc(2014, 1, 1, 12, 0, 0).in_time_zone('Paris') + pirate = Pirate.create!(:catchphrase => 'rrrr', :created_on => time_in_paris) + + pirate.created_on = pirate.created_on.in_time_zone('Tokyo').to_s + assert !pirate.created_on_changed? + end + + test "partial insert" do + with_partial_writes Person do + jon = nil + assert_sql(/first_name/i) do + jon = Person.create! first_name: 'Jon' + end + + assert ActiveRecord::SQLCounter.log_all.none? { |sql| sql =~ /followers_count/ } + + jon.reload + assert_equal 'Jon', jon.first_name + assert_equal 0, jon.followers_count + assert_not_nil jon.id + end + end + + test "partial insert with empty values" do + with_partial_writes Aircraft do + a = Aircraft.create! + a.reload + assert_not_nil a.id + end + end + + test "defaults with type that implements `type_cast_for_database`" do + type = Class.new(ActiveRecord::Type::Value) do + def type_cast(value) + value.to_i + end + + def type_cast_for_database(value) + value.to_s + end + end + + model_class = Class.new(ActiveRecord::Base) do + self.table_name = 'numeric_data' + attribute :foo, type.new, default: 1 + end + + model = model_class.new + assert_not model.foo_changed? + + model = model_class.new(foo: 1) + assert_not model.foo_changed? + + model = model_class.new(foo: '1') + assert_not model.foo_changed? + end + + private + def with_partial_writes(klass, on = true) + old = klass.partial_writes? + klass.partial_writes = on + yield + ensure + klass.partial_writes = old + end + + def check_pirate_after_save_failure(pirate) + assert pirate.changed? + assert pirate.parrot_id_changed? + assert_equal %w(parrot_id), pirate.changed + assert_nil pirate.parrot_id_was + end +end diff --git a/activerecord/test/cases/disconnected_test.rb b/activerecord/test/cases/disconnected_test.rb new file mode 100644 index 0000000000..94447addc1 --- /dev/null +++ b/activerecord/test/cases/disconnected_test.rb @@ -0,0 +1,28 @@ +require "cases/helper" + +class TestRecord < ActiveRecord::Base +end + +class TestDisconnectedAdapter < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + def setup + @connection = ActiveRecord::Base.connection + end + + teardown do + return if in_memory_db? + spec = ActiveRecord::Base.connection_config + ActiveRecord::Base.establish_connection(spec) + end + + unless in_memory_db? + test "can't execute statements while disconnected" do + @connection.execute "SELECT count(*) from products" + @connection.disconnect! + assert_raises(ActiveRecord::StatementInvalid) do + @connection.execute "SELECT count(*) from products" + end + end + end +end diff --git a/activerecord/test/cases/dup_test.rb b/activerecord/test/cases/dup_test.rb new file mode 100644 index 0000000000..638cffe0e6 --- /dev/null +++ b/activerecord/test/cases/dup_test.rb @@ -0,0 +1,157 @@ +require "cases/helper" +require 'models/reply' +require 'models/topic' + +module ActiveRecord + class DupTest < ActiveRecord::TestCase + fixtures :topics + + def test_dup + assert !Topic.new.freeze.dup.frozen? + end + + def test_not_readonly + topic = Topic.first + + duped = topic.dup + assert !duped.readonly?, 'should not be readonly' + end + + def test_is_readonly + topic = Topic.first + topic.readonly! + + duped = topic.dup + assert duped.readonly?, 'should be readonly' + end + + def test_dup_not_persisted + topic = Topic.first + duped = topic.dup + + assert !duped.persisted?, 'topic not persisted' + assert duped.new_record?, 'topic is new' + end + + def test_dup_not_destroyed + topic = Topic.first + topic.destroy + + duped = topic.dup + assert_not duped.destroyed? + end + + def test_dup_has_no_id + topic = Topic.first + duped = topic.dup + assert_nil duped.id + end + + def test_dup_with_modified_attributes + topic = Topic.first + topic.author_name = 'Aaron' + duped = topic.dup + assert_equal 'Aaron', duped.author_name + end + + def test_dup_with_changes + dbtopic = Topic.first + topic = Topic.new + + topic.attributes = dbtopic.attributes.except("id") + + #duped has no timestamp values + duped = dbtopic.dup + + #clear topic timestamp values + topic.send(:clear_timestamp_attributes) + + assert_equal topic.changes, duped.changes + end + + def test_dup_topics_are_independent + topic = Topic.first + topic.author_name = 'Aaron' + duped = topic.dup + + duped.author_name = 'meow' + + assert_not_equal topic.changes, duped.changes + end + + def test_dup_attributes_are_independent + topic = Topic.first + duped = topic.dup + + duped.author_name = 'meow' + topic.author_name = 'Aaron' + + assert_equal 'Aaron', topic.author_name + assert_equal 'meow', duped.author_name + end + + def test_dup_timestamps_are_cleared + topic = Topic.first + assert_not_nil topic.updated_at + assert_not_nil topic.created_at + + # temporary change to the topic object + topic.updated_at -= 3.days + + #dup should not preserve the timestamps if present + new_topic = topic.dup + assert_nil new_topic.updated_at + assert_nil new_topic.created_at + + new_topic.save + assert_not_nil new_topic.updated_at + assert_not_nil new_topic.created_at + end + + def test_dup_after_initialize_callbacks + topic = Topic.new + assert Topic.after_initialize_called + Topic.after_initialize_called = false + topic.dup + assert Topic.after_initialize_called + end + + def test_dup_validity_is_independent + repair_validations(Topic) do + Topic.validates_presence_of :title + topic = Topic.new("title" => "Literature") + topic.valid? + + duped = topic.dup + duped.title = nil + assert duped.invalid? + + topic.title = nil + duped.title = 'Mathematics' + assert topic.invalid? + assert duped.valid? + end + end + + def test_dup_with_default_scope + prev_default_scopes = Topic.default_scopes + Topic.default_scopes = [proc { Topic.where(:approved => true) }] + topic = Topic.new(:approved => false) + assert !topic.dup.approved?, "should not be overridden by default scopes" + ensure + Topic.default_scopes = prev_default_scopes + end + + def test_dup_without_primary_key + klass = Class.new(ActiveRecord::Base) do + self.table_name = 'parrots_pirates' + end + + record = klass.create! + + assert_nothing_raised do + record.dup + end + end + end +end diff --git a/activerecord/test/cases/enum_test.rb b/activerecord/test/cases/enum_test.rb new file mode 100644 index 0000000000..3b2f0dfe07 --- /dev/null +++ b/activerecord/test/cases/enum_test.rb @@ -0,0 +1,289 @@ +require 'cases/helper' +require 'models/book' + +class EnumTest < ActiveRecord::TestCase + fixtures :books + + setup do + @book = books(:awdr) + end + + test "query state by predicate" do + assert @book.proposed? + assert_not @book.written? + assert_not @book.published? + + assert @book.unread? + end + + test "query state with strings" do + assert_equal "proposed", @book.status + assert_equal "unread", @book.read_status + end + + test "find via scope" do + assert_equal @book, Book.proposed.first + assert_equal @book, Book.unread.first + end + + test "update by declaration" do + @book.written! + assert @book.written? + end + + test "update by setter" do + @book.update! status: :written + assert @book.written? + end + + test "enum methods are overwritable" do + assert_equal "do publish work...", @book.published! + assert @book.published? + end + + test "direct assignment" do + @book.status = :written + assert @book.written? + end + + test "assign string value" do + @book.status = "written" + assert @book.written? + end + + test "enum changed attributes" do + old_status = @book.status + @book.status = :published + assert_equal old_status, @book.changed_attributes[:status] + end + + test "enum changes" do + old_status = @book.status + @book.status = :published + assert_equal [old_status, 'published'], @book.changes[:status] + end + + test "enum attribute was" do + old_status = @book.status + @book.status = :published + assert_equal old_status, @book.attribute_was(:status) + end + + test "enum attribute changed" do + @book.status = :published + assert @book.attribute_changed?(:status) + end + + test "enum attribute changed to" do + @book.status = :published + assert @book.attribute_changed?(:status, to: 'published') + end + + test "enum attribute changed from" do + old_status = @book.status + @book.status = :published + assert @book.attribute_changed?(:status, from: old_status) + end + + test "enum attribute changed from old status to new status" do + old_status = @book.status + @book.status = :published + assert @book.attribute_changed?(:status, from: old_status, to: 'published') + end + + test "enum didn't change" do + old_status = @book.status + @book.status = old_status + assert_not @book.attribute_changed?(:status) + end + + test "persist changes that are dirty" do + @book.status = :published + assert @book.attribute_changed?(:status) + @book.status = :written + assert @book.attribute_changed?(:status) + end + + test "reverted changes that are not dirty" do + old_status = @book.status + @book.status = :published + assert @book.attribute_changed?(:status) + @book.status = old_status + assert_not @book.attribute_changed?(:status) + end + + test "reverted changes are not dirty going from nil to value and back" do + book = Book.create!(nullable_status: nil) + + book.nullable_status = :married + assert book.attribute_changed?(:nullable_status) + + book.nullable_status = nil + assert_not book.attribute_changed?(:nullable_status) + end + + test "assign non existing value raises an error" do + e = assert_raises(ArgumentError) do + @book.status = :unknown + end + assert_equal "'unknown' is not a valid status", e.message + end + + test "assign nil value" do + @book.status = nil + assert @book.status.nil? + end + + test "assign empty string value" do + @book.status = '' + assert @book.status.nil? + end + + test "assign long empty string value" do + @book.status = ' ' + assert @book.status.nil? + end + + test "constant to access the mapping" do + assert_equal 0, Book.statuses[:proposed] + assert_equal 1, Book.statuses["written"] + assert_equal 2, Book.statuses[:published] + end + + test "building new objects with enum scopes" do + assert Book.written.build.written? + assert Book.read.build.read? + end + + test "creating new objects with enum scopes" do + assert Book.written.create.written? + assert Book.read.create.read? + end + + test "_before_type_cast returns the enum label (required for form fields)" do + assert_equal "proposed", @book.status_before_type_cast + end + + test "reserved enum names" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: [:proposed, :written, :published] + end + + conflicts = [ + :column, # generates class method .columns, which conflicts with an AR method + :logger, # generates #logger, which conflicts with an AR method + :attributes, # generates #attributes=, which conflicts with an AR method + ] + + conflicts.each_with_index do |name, i| + assert_raises(ArgumentError, "enum name `#{name}` should not be allowed") do + klass.class_eval { enum name => ["value_#{i}"] } + end + end + end + + test "reserved enum values" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: [:proposed, :written, :published] + end + + conflicts = [ + :new, # generates a scope that conflicts with an AR class method + :valid, # generates #valid?, which conflicts with an AR method + :save, # generates #save!, which conflicts with an AR method + :proposed, # same value as an existing enum + :public, :private, :protected, # generates a method that conflict with ruby words + ] + + conflicts.each_with_index do |value, i| + assert_raises(ArgumentError, "enum value `#{value}` should not be allowed") do + klass.class_eval { enum "status_#{i}" => [value] } + end + end + end + + test "overriding enum method should not raise" do + assert_nothing_raised do + Class.new(ActiveRecord::Base) do + self.table_name = "books" + + def published! + super + "do publish work..." + end + + enum status: [:proposed, :written, :published] + + def written! + super + "do written work..." + end + end + end + end + + test "validate uniqueness" do + klass = Class.new(ActiveRecord::Base) do + def self.name; 'Book'; end + enum status: [:proposed, :written] + validates_uniqueness_of :status + end + klass.delete_all + klass.create!(status: "proposed") + book = klass.new(status: "written") + assert book.valid? + book.status = "proposed" + assert_not book.valid? + end + + test "validate inclusion of value in array" do + klass = Class.new(ActiveRecord::Base) do + def self.name; 'Book'; end + enum status: [:proposed, :written] + validates_inclusion_of :status, in: ["written"] + end + klass.delete_all + invalid_book = klass.new(status: "proposed") + assert_not invalid_book.valid? + valid_book = klass.new(status: "written") + assert valid_book.valid? + end + + test "enums are distinct per class" do + klass1 = Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: [:proposed, :written] + end + + klass2 = Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: [:drafted, :uploaded] + end + + book1 = klass1.proposed.create! + book1.status = :written + assert_equal ['proposed', 'written'], book1.status_change + + book2 = klass2.drafted.create! + book2.status = :uploaded + assert_equal ['drafted', 'uploaded'], book2.status_change + end + + test "enums are inheritable" do + subklass1 = Class.new(Book) + + subklass2 = Class.new(Book) do + enum status: [:drafted, :uploaded] + end + + book1 = subklass1.proposed.create! + book1.status = :written + assert_equal ['proposed', 'written'], book1.status_change + + book2 = subklass2.drafted.create! + book2.status = :uploaded + assert_equal ['drafted', 'uploaded'], book2.status_change + end +end diff --git a/activerecord/test/cases/explain_subscriber_test.rb b/activerecord/test/cases/explain_subscriber_test.rb new file mode 100644 index 0000000000..8de2ddb10d --- /dev/null +++ b/activerecord/test/cases/explain_subscriber_test.rb @@ -0,0 +1,59 @@ +require 'cases/helper' +require 'active_record/explain_subscriber' +require 'active_record/explain_registry' + +if ActiveRecord::Base.connection.supports_explain? + class ExplainSubscriberTest < ActiveRecord::TestCase + SUBSCRIBER = ActiveRecord::ExplainSubscriber.new + + def setup + ActiveRecord::ExplainRegistry.reset + ActiveRecord::ExplainRegistry.collect = true + end + + def test_collects_nothing_if_the_payload_has_an_exception + SUBSCRIBER.finish(nil, nil, exception: Exception.new) + assert queries.empty? + end + + def test_collects_nothing_for_ignored_payloads + ActiveRecord::ExplainSubscriber::IGNORED_PAYLOADS.each do |ip| + SUBSCRIBER.finish(nil, nil, name: ip) + end + assert queries.empty? + end + + def test_collects_nothing_if_collect_is_false + ActiveRecord::ExplainRegistry.collect = false + SUBSCRIBER.finish(nil, nil, name: 'SQL', sql: 'select 1 from users', binds: [1, 2]) + assert queries.empty? + end + + def test_collects_pairs_of_queries_and_binds + sql = 'select 1 from users' + binds = [1, 2] + SUBSCRIBER.finish(nil, nil, name: 'SQL', sql: sql, binds: binds) + assert_equal 1, queries.size + assert_equal sql, queries[0][0] + assert_equal binds, queries[0][1] + end + + def test_collects_nothing_if_the_statement_is_not_whitelisted + SUBSCRIBER.finish(nil, nil, name: 'SQL', sql: 'SHOW max_identifier_length') + assert queries.empty? + end + + def test_collects_nothing_if_the_statement_is_only_partially_matched + SUBSCRIBER.finish(nil, nil, name: 'SQL', sql: 'select_db yo_mama') + assert queries.empty? + end + + teardown do + ActiveRecord::ExplainRegistry.reset + end + + def queries + ActiveRecord::ExplainRegistry.queries + end + end +end diff --git a/activerecord/test/cases/explain_test.rb b/activerecord/test/cases/explain_test.rb new file mode 100644 index 0000000000..9d25bdd82a --- /dev/null +++ b/activerecord/test/cases/explain_test.rb @@ -0,0 +1,76 @@ +require 'cases/helper' +require 'models/car' +require 'active_support/core_ext/string/strip' + +if ActiveRecord::Base.connection.supports_explain? + class ExplainTest < ActiveRecord::TestCase + fixtures :cars + + def base + ActiveRecord::Base + end + + def connection + base.connection + end + + def test_relation_explain + message = Car.where(:name => 'honda').explain + assert_match(/^EXPLAIN for:/, message) + end + + def test_collecting_queries_for_explain + queries = ActiveRecord::Base.collecting_queries_for_explain do + Car.where(:name => 'honda').to_a + end + + sql, binds = queries[0] + assert_match "SELECT", sql + if binds.any? + assert_equal 1, binds.length + assert_equal "honda", binds.flatten.last + else + assert_match 'honda', sql + end + end + + def test_exec_explain_with_no_binds + sqls = %w(foo bar) + binds = [[], []] + queries = sqls.zip(binds) + + connection.stubs(:explain).returns('query plan foo', 'query plan bar') + expected = sqls.map {|sql| "EXPLAIN for: #{sql}\nquery plan #{sql}"}.join("\n") + assert_equal expected, base.exec_explain(queries) + end + + def test_exec_explain_with_binds + cols = [Object.new, Object.new] + cols[0].expects(:name).returns('wadus') + cols[1].expects(:name).returns('chaflan') + + sqls = %w(foo bar) + binds = [[[cols[0], 1]], [[cols[1], 2]]] + queries = sqls.zip(binds) + + connection.stubs(:explain).returns("query plan foo\n", "query plan bar\n") + expected = <<-SQL.strip_heredoc + EXPLAIN for: #{sqls[0]} [["wadus", 1]] + query plan foo + + EXPLAIN for: #{sqls[1]} [["chaflan", 2]] + query plan bar + SQL + assert_equal expected, base.exec_explain(queries) + end + + def test_unsupported_connection_adapter + connection.stubs(:supports_explain?).returns(false) + + base.logger.expects(:warn).never + + Car.where(:name => 'honda').to_a + end + + end +end diff --git a/activerecord/test/cases/finder_respond_to_test.rb b/activerecord/test/cases/finder_respond_to_test.rb new file mode 100644 index 0000000000..6ab2657c44 --- /dev/null +++ b/activerecord/test/cases/finder_respond_to_test.rb @@ -0,0 +1,60 @@ +require "cases/helper" +require 'models/topic' + +class FinderRespondToTest < ActiveRecord::TestCase + + fixtures :topics + + def test_should_preserve_normal_respond_to_behaviour_on_base + assert_respond_to ActiveRecord::Base, :new + assert !ActiveRecord::Base.respond_to?(:find_by_something) + end + + def test_should_preserve_normal_respond_to_behaviour_and_respond_to_newly_added_method + class << Topic; self; end.send(:define_method, :method_added_for_finder_respond_to_test) { } + assert_respond_to Topic, :method_added_for_finder_respond_to_test + ensure + class << Topic; self; end.send(:remove_method, :method_added_for_finder_respond_to_test) + end + + def test_should_preserve_normal_respond_to_behaviour_and_respond_to_standard_object_method + assert_respond_to Topic, :to_s + end + + def test_should_respond_to_find_by_one_attribute_before_caching + ensure_topic_method_is_not_cached(:find_by_title) + assert_respond_to Topic, :find_by_title + end + + def test_should_respond_to_find_by_with_bang + ensure_topic_method_is_not_cached(:find_by_title!) + assert_respond_to Topic, :find_by_title! + end + + def test_should_respond_to_find_by_two_attributes + ensure_topic_method_is_not_cached(:find_by_title_and_author_name) + assert_respond_to Topic, :find_by_title_and_author_name + end + + def test_should_respond_to_find_all_by_an_aliased_attribute + ensure_topic_method_is_not_cached(:find_by_heading) + assert_respond_to Topic, :find_by_heading + end + + def test_should_not_respond_to_find_by_one_missing_attribute + assert !Topic.respond_to?(:find_by_undertitle) + end + + def test_should_not_respond_to_find_by_invalid_method_syntax + assert !Topic.respond_to?(:fail_to_find_by_title) + assert !Topic.respond_to?(:find_by_title?) + assert !Topic.respond_to?(:fail_to_find_or_create_by_title) + assert !Topic.respond_to?(:find_or_create_by_title?) + end + + private + + def ensure_topic_method_is_not_cached(method_id) + class << Topic; self; end.send(:remove_method, method_id) if Topic.public_methods.include? method_id + end +end diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb new file mode 100644 index 0000000000..40e51a0cdc --- /dev/null +++ b/activerecord/test/cases/finder_test.rb @@ -0,0 +1,1032 @@ +require "cases/helper" +require 'models/post' +require 'models/author' +require 'models/categorization' +require 'models/comment' +require 'models/company' +require 'models/topic' +require 'models/reply' +require 'models/entrant' +require 'models/project' +require 'models/developer' +require 'models/customer' +require 'models/toy' +require 'models/matey' +require 'models/dog' + +class FinderTest < ActiveRecord::TestCase + fixtures :companies, :topics, :entrants, :developers, :developers_projects, :posts, :comments, :accounts, :authors, :customers, :categories, :categorizations + + def test_find_by_id_with_hash + assert_raises(ActiveRecord::StatementInvalid) do + Post.find_by_id(:limit => 1) + end + end + + def test_find_by_title_and_id_with_hash + assert_raises(ActiveRecord::StatementInvalid) do + Post.find_by_title_and_id('foo', :limit => 1) + end + end + + def test_find + assert_equal(topics(:first).title, Topic.find(1).title) + end + + def test_find_with_proc_parameter_and_block + exception = assert_raises(RuntimeError) do + Topic.all.find(-> { raise "should happen" }) { |e| e.title == "non-existing-title" } + end + assert_equal "should happen", exception.message + + assert_nothing_raised(RuntimeError) do + Topic.all.find(-> { raise "should not happen" }) { |e| e.title == topics(:first).title } + end + end + + def test_find_passing_active_record_object_is_deprecated + assert_deprecated do + Topic.find(Topic.last) + end + end + + def test_symbols_table_ref + Post.first # warm up + x = Symbol.all_symbols.count + Post.where("title" => {"xxxqqqq" => "bar"}) + assert_equal x, Symbol.all_symbols.count + end + + # find should handle strings that come from URLs + # (example: Category.find(params[:id])) + def test_find_with_string + assert_equal(Topic.find(1).title,Topic.find("1").title) + end + + def test_exists + assert_equal true, Topic.exists?(1) + assert_equal true, Topic.exists?("1") + assert_equal true, Topic.exists?(title: "The First Topic") + assert_equal true, Topic.exists?(heading: "The First Topic") + assert_equal true, Topic.exists?(:author_name => "Mary", :approved => true) + assert_equal true, Topic.exists?(["parent_id = ?", 1]) + assert_equal true, Topic.exists?(id: [1, 9999]) + + assert_equal false, Topic.exists?(45) + assert_equal false, Topic.exists?(Topic.new.id) + + assert_raise(NoMethodError) { Topic.exists?([1,2]) } + end + + def test_exists_passing_active_record_object_is_deprecated + assert_deprecated do + Topic.exists?(Topic.new) + end + end + + def test_exists_fails_when_parameter_has_invalid_type + if current_adapter?(:PostgreSQLAdapter, :MysqlAdapter) + assert_raises ActiveRecord::StatementInvalid do + Topic.exists?(("9"*53).to_i) # number that's bigger than int + end + else + assert_equal false, Topic.exists?(("9"*53).to_i) # number that's bigger than int + end + + if current_adapter?(:PostgreSQLAdapter) + assert_raises ActiveRecord::StatementInvalid do + Topic.exists?("foo") + end + else + assert_equal false, Topic.exists?("foo") + end + end + + def test_exists_does_not_select_columns_without_alias + assert_sql(/SELECT\W+1 AS one FROM ["`]topics["`]/i) do + Topic.exists? + end + end + + def test_exists_returns_true_with_one_record_and_no_args + assert_equal true, Topic.exists? + end + + def test_exists_returns_false_with_false_arg + assert_equal false, Topic.exists?(false) + end + + # exists? should handle nil for id's that come from URLs and always return false + # (example: Topic.exists?(params[:id])) where params[:id] is nil + def test_exists_with_nil_arg + assert_equal false, Topic.exists?(nil) + assert_equal true, Topic.exists? + + assert_equal false, Topic.first.replies.exists?(nil) + assert_equal true, Topic.first.replies.exists? + end + + # ensures +exists?+ runs valid SQL by excluding order value + def test_exists_with_order + assert_equal true, Topic.order(:id).distinct.exists? + end + + def test_exists_with_includes_limit_and_empty_result + assert_equal false, Topic.includes(:replies).limit(0).exists? + assert_equal false, Topic.includes(:replies).limit(1).where('0 = 1').exists? + end + + def test_exists_with_distinct_association_includes_and_limit + author = Author.first + assert_equal false, author.unique_categorized_posts.includes(:special_comments).limit(0).exists? + assert_equal true, author.unique_categorized_posts.includes(:special_comments).limit(1).exists? + end + + def test_exists_with_distinct_association_includes_limit_and_order + author = Author.first + assert_equal false, author.unique_categorized_posts.includes(:special_comments).order('comments.tags_count DESC').limit(0).exists? + assert_equal true, author.unique_categorized_posts.includes(:special_comments).order('comments.tags_count DESC').limit(1).exists? + end + + def test_exists_with_empty_table_and_no_args_given + Topic.delete_all + assert_equal false, Topic.exists? + end + + def test_exists_with_aggregate_having_three_mappings + existing_address = customers(:david).address + assert_equal true, Customer.exists?(:address => existing_address) + end + + def test_exists_with_aggregate_having_three_mappings_with_one_difference + existing_address = customers(:david).address + assert_equal false, Customer.exists?(:address => + Address.new(existing_address.street, existing_address.city, existing_address.country + "1")) + assert_equal false, Customer.exists?(:address => + Address.new(existing_address.street, existing_address.city + "1", existing_address.country)) + assert_equal false, Customer.exists?(:address => + Address.new(existing_address.street + "1", existing_address.city, existing_address.country)) + end + + def test_exists_does_not_instantiate_records + Developer.expects(:instantiate).never + Developer.exists? + end + + def test_find_by_array_of_one_id + assert_kind_of(Array, Topic.find([ 1 ])) + assert_equal(1, Topic.find([ 1 ]).length) + end + + def test_find_by_ids + assert_equal 2, Topic.find(1, 2).size + assert_equal topics(:second).title, Topic.find([2]).first.title + end + + def test_find_by_ids_with_limit_and_offset + assert_equal 2, Entrant.limit(2).find([1,3,2]).size + assert_equal 1, Entrant.limit(3).offset(2).find([1,3,2]).size + + # Also test an edge case: If you have 11 results, and you set a + # limit of 3 and offset of 9, then you should find that there + # will be only 2 results, regardless of the limit. + devs = Developer.all + last_devs = Developer.limit(3).offset(9).find devs.map(&:id) + assert_equal 2, last_devs.size + end + + def test_find_an_empty_array + assert_equal [], Topic.find([]) + end + + def test_find_doesnt_have_implicit_ordering + assert_sql(/^((?!ORDER).)*$/) { Topic.find(1) } + end + + def test_find_by_ids_missing_one + assert_raise(ActiveRecord::RecordNotFound) { Topic.find(1, 2, 45) } + end + + def test_find_with_group_and_sanitized_having_method + developers = Developer.group(:salary).having("sum(salary) > ?", 10000).select('salary').to_a + assert_equal 3, developers.size + assert_equal 3, developers.map(&:salary).uniq.size + assert developers.all? { |developer| developer.salary > 10000 } + end + + def test_find_with_entire_select_statement + topics = Topic.find_by_sql "SELECT * FROM topics WHERE author_name = 'Mary'" + + assert_equal(1, topics.size) + assert_equal(topics(:second).title, topics.first.title) + end + + def test_find_with_prepared_select_statement + topics = Topic.find_by_sql ["SELECT * FROM topics WHERE author_name = ?", "Mary"] + + assert_equal(1, topics.size) + assert_equal(topics(:second).title, topics.first.title) + end + + def test_find_by_sql_with_sti_on_joined_table + accounts = Account.find_by_sql("SELECT * FROM accounts INNER JOIN companies ON companies.id = accounts.firm_id") + assert_equal [Account], accounts.collect(&:class).uniq + end + + def test_take + assert_equal topics(:first), Topic.take + end + + def test_take_failing + assert_nil Topic.where("title = 'This title does not exist'").take + end + + def test_take_bang_present + assert_nothing_raised do + assert_equal topics(:second), Topic.where("title = 'The Second Topic of the day'").take! + end + end + + def test_take_bang_missing + assert_raises ActiveRecord::RecordNotFound do + Topic.where("title = 'This title does not exist'").take! + end + end + + def test_first + assert_equal topics(:second).title, Topic.where("title = 'The Second Topic of the day'").first.title + end + + def test_first_failing + assert_nil Topic.where("title = 'The Second Topic of the day!'").first + end + + def test_first_bang_present + assert_nothing_raised do + assert_equal topics(:second), Topic.where("title = 'The Second Topic of the day'").first! + end + end + + def test_first_bang_missing + assert_raises ActiveRecord::RecordNotFound do + Topic.where("title = 'This title does not exist'").first! + end + end + + def test_first_have_primary_key_order_by_default + expected = topics(:first) + expected.touch # PostgreSQL changes the default order if no order clause is used + assert_equal expected, Topic.first + end + + def test_model_class_responds_to_first_bang + assert Topic.first! + Topic.delete_all + assert_raises ActiveRecord::RecordNotFound do + Topic.first! + end + end + + def test_second + assert_equal topics(:second).title, Topic.second.title + end + + def test_second_with_offset + assert_equal topics(:fifth), Topic.offset(3).second + end + + def test_second_have_primary_key_order_by_default + expected = topics(:second) + expected.touch # PostgreSQL changes the default order if no order clause is used + assert_equal expected, Topic.second + end + + def test_model_class_responds_to_second_bang + assert Topic.second! + Topic.delete_all + assert_raises ActiveRecord::RecordNotFound do + Topic.second! + end + end + + def test_third + assert_equal topics(:third).title, Topic.third.title + end + + def test_third_with_offset + assert_equal topics(:fifth), Topic.offset(2).third + end + + def test_third_have_primary_key_order_by_default + expected = topics(:third) + expected.touch # PostgreSQL changes the default order if no order clause is used + assert_equal expected, Topic.third + end + + def test_model_class_responds_to_third_bang + assert Topic.third! + Topic.delete_all + assert_raises ActiveRecord::RecordNotFound do + Topic.third! + end + end + + def test_fourth + assert_equal topics(:fourth).title, Topic.fourth.title + end + + def test_fourth_with_offset + assert_equal topics(:fifth), Topic.offset(1).fourth + end + + def test_fourth_have_primary_key_order_by_default + expected = topics(:fourth) + expected.touch # PostgreSQL changes the default order if no order clause is used + assert_equal expected, Topic.fourth + end + + def test_model_class_responds_to_fourth_bang + assert Topic.fourth! + Topic.delete_all + assert_raises ActiveRecord::RecordNotFound do + Topic.fourth! + end + end + + def test_fifth + assert_equal topics(:fifth).title, Topic.fifth.title + end + + def test_fifth_with_offset + assert_equal topics(:fifth), Topic.offset(0).fifth + end + + def test_fifth_have_primary_key_order_by_default + expected = topics(:fifth) + expected.touch # PostgreSQL changes the default order if no order clause is used + assert_equal expected, Topic.fifth + end + + def test_model_class_responds_to_fifth_bang + assert Topic.fifth! + Topic.delete_all + assert_raises ActiveRecord::RecordNotFound do + Topic.fifth! + end + end + + def test_last_bang_present + assert_nothing_raised do + assert_equal topics(:second), Topic.where("title = 'The Second Topic of the day'").last! + end + end + + def test_last_bang_missing + assert_raises ActiveRecord::RecordNotFound do + Topic.where("title = 'This title does not exist'").last! + end + end + + def test_model_class_responds_to_last_bang + assert_equal topics(:fifth), Topic.last! + assert_raises ActiveRecord::RecordNotFound do + Topic.delete_all + Topic.last! + end + end + + def test_take_and_first_and_last_with_integer_should_use_sql_limit + assert_sql(/LIMIT 3|ROWNUM <= 3/) { Topic.take(3).entries } + assert_sql(/LIMIT 2|ROWNUM <= 2/) { Topic.first(2).entries } + assert_sql(/LIMIT 5|ROWNUM <= 5/) { Topic.last(5).entries } + end + + def test_last_with_integer_and_order_should_keep_the_order + assert_equal Topic.order("title").to_a.last(2), Topic.order("title").last(2) + end + + def test_last_with_integer_and_order_should_not_use_sql_limit + query = assert_sql { Topic.order("title").last(5).entries } + assert_equal 1, query.length + assert_no_match(/LIMIT/, query.first) + end + + def test_last_with_integer_and_reorder_should_not_use_sql_limit + query = assert_sql { Topic.reorder("title").last(5).entries } + assert_equal 1, query.length + assert_no_match(/LIMIT/, query.first) + end + + def test_take_and_first_and_last_with_integer_should_return_an_array + assert_kind_of Array, Topic.take(5) + assert_kind_of Array, Topic.first(5) + assert_kind_of Array, Topic.last(5) + end + + def test_unexisting_record_exception_handling + assert_raise(ActiveRecord::RecordNotFound) { + Topic.find(1).parent + } + + Topic.find(2).topic + end + + def test_find_only_some_columns + topic = Topic.select("author_name").find(1) + assert_raise(ActiveModel::MissingAttributeError) {topic.title} + assert_raise(ActiveModel::MissingAttributeError) {topic.title?} + assert_nil topic.read_attribute("title") + assert_equal "David", topic.author_name + assert !topic.attribute_present?("title") + assert !topic.attribute_present?(:title) + assert topic.attribute_present?("author_name") + assert_respond_to topic, "author_name" + end + + def test_find_on_array_conditions + assert Topic.where(["approved = ?", false]).find(1) + assert_raise(ActiveRecord::RecordNotFound) { Topic.where(["approved = ?", true]).find(1) } + end + + def test_find_on_hash_conditions + assert Topic.where(approved: false).find(1) + assert_raise(ActiveRecord::RecordNotFound) { Topic.where(approved: true).find(1) } + end + + def test_find_on_hash_conditions_with_explicit_table_name + assert Topic.where('topics.approved' => false).find(1) + assert_raise(ActiveRecord::RecordNotFound) { Topic.where('topics.approved' => true).find(1) } + end + + def test_find_on_hash_conditions_with_hashed_table_name + assert Topic.where(topics: { approved: false }).find(1) + assert_raise(ActiveRecord::RecordNotFound) { Topic.where(topics: { approved: true }).find(1) } + end + + def test_find_with_hash_conditions_on_joined_table + firms = Firm.joins(:account).where(:accounts => { :credit_limit => 50 }) + assert_equal 1, firms.size + assert_equal companies(:first_firm), firms.first + end + + def test_find_with_hash_conditions_on_joined_table_and_with_range + firms = DependentFirm.joins(:account).where(name: 'RailsCore', accounts: { credit_limit: 55..60 }) + assert_equal 1, firms.size + assert_equal companies(:rails_core), firms.first + end + + def test_find_on_hash_conditions_with_explicit_table_name_and_aggregate + david = customers(:david) + assert Customer.where('customers.name' => david.name, :address => david.address).find(david.id) + assert_raise(ActiveRecord::RecordNotFound) { + Customer.where('customers.name' => david.name + "1", :address => david.address).find(david.id) + } + end + + def test_find_on_association_proxy_conditions + assert_equal [1, 2, 3, 5, 6, 7, 8, 9, 10, 12], Comment.where(post_id: authors(:david).posts).map(&:id).sort + end + + def test_find_on_hash_conditions_with_range + assert_equal [1,2], Topic.where(id: 1..2).to_a.map(&:id).sort + assert_raise(ActiveRecord::RecordNotFound) { Topic.where(id: 2..3).find(1) } + end + + def test_find_on_hash_conditions_with_end_exclusive_range + assert_equal [1,2,3], Topic.where(id: 1..3).to_a.map(&:id).sort + assert_equal [1,2], Topic.where(id: 1...3).to_a.map(&:id).sort + assert_raise(ActiveRecord::RecordNotFound) { Topic.where(id: 2...3).find(3) } + end + + def test_find_on_hash_conditions_with_multiple_ranges + assert_equal [1,2,3], Comment.where(id: 1..3, post_id: 1..2).to_a.map(&:id).sort + assert_equal [1], Comment.where(id: 1..1, post_id: 1..10).to_a.map(&:id).sort + end + + def test_find_on_hash_conditions_with_array_of_integers_and_ranges + assert_equal [1,2,3,5,6,7,8,9], Comment.where(id: [1..2, 3, 5, 6..8, 9]).to_a.map(&:id).sort + end + + def test_find_on_multiple_hash_conditions + assert Topic.where(author_name: "David", title: "The First Topic", replies_count: 1, approved: false).find(1) + assert_raise(ActiveRecord::RecordNotFound) { Topic.where(author_name: "David", title: "The First Topic", replies_count: 1, approved: true).find(1) } + assert_raise(ActiveRecord::RecordNotFound) { Topic.where(author_name: "David", title: "HHC", replies_count: 1, approved: false).find(1) } + assert_raise(ActiveRecord::RecordNotFound) { Topic.where(author_name: "David", title: "The First Topic", replies_count: 1, approved: true).find(1) } + end + + def test_condition_interpolation + assert_kind_of Firm, Company.where("name = '%s'", "37signals").first + assert_nil Company.where(["name = '%s'", "37signals!"]).first + assert_nil Company.where(["name = '%s'", "37signals!' OR 1=1"]).first + assert_kind_of Time, Topic.where(["id = %d", 1]).first.written_on + end + + def test_condition_array_interpolation + assert_kind_of Firm, Company.where(["name = '%s'", "37signals"]).first + assert_nil Company.where(["name = '%s'", "37signals!"]).first + assert_nil Company.where(["name = '%s'", "37signals!' OR 1=1"]).first + assert_kind_of Time, Topic.where(["id = %d", 1]).first.written_on + end + + def test_condition_hash_interpolation + assert_kind_of Firm, Company.where(name: "37signals").first + assert_nil Company.where(name: "37signals!").first + assert_kind_of Time, Topic.where(id: 1).first.written_on + end + + def test_hash_condition_find_malformed + assert_raise(ActiveRecord::StatementInvalid) { + Company.where(id: 2, dhh: true).first + } + end + + def test_hash_condition_find_with_escaped_characters + Company.create("name" => "Ain't noth'n like' \#stuff") + assert Company.where(name: "Ain't noth'n like' \#stuff").first + end + + def test_hash_condition_find_with_array + p1, p2 = Post.limit(2).order('id asc').to_a + assert_equal [p1, p2], Post.where(id: [p1, p2]).order('id asc').to_a + assert_equal [p1, p2], Post.where(id: [p1, p2.id]).order('id asc').to_a + end + + def test_hash_condition_find_with_nil + topic = Topic.where(last_read: nil).first + assert_not_nil topic + assert_nil topic.last_read + end + + def test_hash_condition_find_with_aggregate_having_one_mapping + balance = customers(:david).balance + assert_kind_of Money, balance + found_customer = Customer.where(:balance => balance).first + assert_equal customers(:david), found_customer + end + + def test_hash_condition_find_with_aggregate_attribute_having_same_name_as_field_and_key_value_being_aggregate + gps_location = customers(:david).gps_location + assert_kind_of GpsLocation, gps_location + found_customer = Customer.where(:gps_location => gps_location).first + assert_equal customers(:david), found_customer + end + + def test_hash_condition_find_with_aggregate_having_one_mapping_and_key_value_being_attribute_value + balance = customers(:david).balance + assert_kind_of Money, balance + found_customer = Customer.where(:balance => balance.amount).first + assert_equal customers(:david), found_customer + end + + def test_hash_condition_find_with_aggregate_attribute_having_same_name_as_field_and_key_value_being_attribute_value + gps_location = customers(:david).gps_location + assert_kind_of GpsLocation, gps_location + found_customer = Customer.where(:gps_location => gps_location.gps_location).first + assert_equal customers(:david), found_customer + end + + def test_hash_condition_find_with_aggregate_having_three_mappings + address = customers(:david).address + assert_kind_of Address, address + found_customer = Customer.where(:address => address).first + assert_equal customers(:david), found_customer + end + + def test_hash_condition_find_with_one_condition_being_aggregate_and_another_not + address = customers(:david).address + assert_kind_of Address, address + found_customer = Customer.where(:address => address, :name => customers(:david).name).first + assert_equal customers(:david), found_customer + end + + def test_condition_utc_time_interpolation_with_default_timezone_local + with_env_tz 'America/New_York' do + with_timezone_config default: :local do + topic = Topic.first + assert_equal topic, Topic.where(['written_on = ?', topic.written_on.getutc]).first + end + end + end + + def test_hash_condition_utc_time_interpolation_with_default_timezone_local + with_env_tz 'America/New_York' do + with_timezone_config default: :local do + topic = Topic.first + assert_equal topic, Topic.where(written_on: topic.written_on.getutc).first + end + end + end + + def test_condition_local_time_interpolation_with_default_timezone_utc + with_env_tz 'America/New_York' do + with_timezone_config default: :utc do + topic = Topic.first + assert_equal topic, Topic.where(['written_on = ?', topic.written_on.getlocal]).first + end + end + end + + def test_hash_condition_local_time_interpolation_with_default_timezone_utc + with_env_tz 'America/New_York' do + with_timezone_config default: :utc do + topic = Topic.first + assert_equal topic, Topic.where(written_on: topic.written_on.getlocal).first + end + end + end + + def test_bind_variables + assert_kind_of Firm, Company.where(["name = ?", "37signals"]).first + assert_nil Company.where(["name = ?", "37signals!"]).first + assert_nil Company.where(["name = ?", "37signals!' OR 1=1"]).first + assert_kind_of Time, Topic.where(["id = ?", 1]).first.written_on + assert_raise(ActiveRecord::PreparedStatementInvalid) { + Company.where(["id=? AND name = ?", 2]).first + } + assert_raise(ActiveRecord::PreparedStatementInvalid) { + Company.where(["id=?", 2, 3, 4]).first + } + end + + def test_bind_variables_with_quotes + Company.create("name" => "37signals' go'es agains") + assert Company.where(["name = ?", "37signals' go'es agains"]).first + end + + def test_named_bind_variables_with_quotes + Company.create("name" => "37signals' go'es agains") + assert Company.where(["name = :name", {name: "37signals' go'es agains"}]).first + end + + def test_bind_arity + assert_nothing_raised { bind '' } + assert_raise(ActiveRecord::PreparedStatementInvalid) { bind '', 1 } + + assert_raise(ActiveRecord::PreparedStatementInvalid) { bind '?' } + assert_nothing_raised { bind '?', 1 } + assert_raise(ActiveRecord::PreparedStatementInvalid) { bind '?', 1, 1 } + end + + def test_named_bind_variables + assert_equal '1', bind(':a', :a => 1) # ' ruby-mode + assert_equal '1 1', bind(':a :a', :a => 1) # ' ruby-mode + + assert_nothing_raised { bind("'+00:00'", :foo => "bar") } + + assert_kind_of Firm, Company.where(["name = :name", { name: "37signals" }]).first + assert_nil Company.where(["name = :name", { name: "37signals!" }]).first + assert_nil Company.where(["name = :name", { name: "37signals!' OR 1=1" }]).first + assert_kind_of Time, Topic.where(["id = :id", { id: 1 }]).first.written_on + end + + class SimpleEnumerable + include Enumerable + + def initialize(ary) + @ary = ary + end + + def each(&b) + @ary.each(&b) + end + end + + def test_bind_enumerable + quoted_abc = %(#{ActiveRecord::Base.connection.quote('a')},#{ActiveRecord::Base.connection.quote('b')},#{ActiveRecord::Base.connection.quote('c')}) + + assert_equal '1,2,3', bind('?', [1, 2, 3]) + assert_equal quoted_abc, bind('?', %w(a b c)) + + assert_equal '1,2,3', bind(':a', :a => [1, 2, 3]) + assert_equal quoted_abc, bind(':a', :a => %w(a b c)) # ' + + assert_equal '1,2,3', bind('?', SimpleEnumerable.new([1, 2, 3])) + assert_equal quoted_abc, bind('?', SimpleEnumerable.new(%w(a b c))) + + assert_equal '1,2,3', bind(':a', :a => SimpleEnumerable.new([1, 2, 3])) + assert_equal quoted_abc, bind(':a', :a => SimpleEnumerable.new(%w(a b c))) # ' + end + + def test_bind_empty_enumerable + quoted_nil = ActiveRecord::Base.connection.quote(nil) + assert_equal quoted_nil, bind('?', []) + assert_equal " in (#{quoted_nil})", bind(' in (?)', []) + assert_equal "foo in (#{quoted_nil})", bind('foo in (?)', []) + end + + def test_bind_empty_string + quoted_empty = ActiveRecord::Base.connection.quote('') + assert_equal quoted_empty, bind('?', '') + end + + def test_bind_chars + quoted_bambi = ActiveRecord::Base.connection.quote("Bambi") + quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote("Bambi\nand\nThumper") + assert_equal "name=#{quoted_bambi}", bind('name=?', "Bambi") + assert_equal "name=#{quoted_bambi_and_thumper}", bind('name=?', "Bambi\nand\nThumper") + assert_equal "name=#{quoted_bambi}", bind('name=?', "Bambi".mb_chars) + assert_equal "name=#{quoted_bambi_and_thumper}", bind('name=?', "Bambi\nand\nThumper".mb_chars) + end + + def test_bind_record + o = Struct.new(:quoted_id).new(1) + assert_equal '1', bind('?', o) + + os = [o] * 3 + assert_equal '1,1,1', bind('?', os) + end + + def test_named_bind_with_postgresql_type_casts + l = Proc.new { bind(":a::integer '2009-01-01'::date", :a => '10') } + assert_nothing_raised(&l) + assert_equal "#{ActiveRecord::Base.connection.quote('10')}::integer '2009-01-01'::date", l.call + end + + def test_string_sanitation + assert_not_equal "'something ' 1=1'", ActiveRecord::Base.sanitize("something ' 1=1") + assert_equal "'something; select table'", ActiveRecord::Base.sanitize("something; select table") + end + + def test_count_by_sql + assert_equal(0, Entrant.count_by_sql("SELECT COUNT(*) FROM entrants WHERE id > 3")) + assert_equal(1, Entrant.count_by_sql(["SELECT COUNT(*) FROM entrants WHERE id > ?", 2])) + assert_equal(2, Entrant.count_by_sql(["SELECT COUNT(*) FROM entrants WHERE id > ?", 1])) + end + + def test_find_by_one_attribute + assert_equal topics(:first), Topic.find_by_title("The First Topic") + 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_raise(ActiveRecord::RecordNotFound) { Topic.find_by_title!("The First Topic!") } + end + + def test_find_by_on_attribute_that_is_a_reserved_word + dog_alias = 'Dog' + dog = Dog.create(alias: dog_alias) + + assert_equal dog, Dog.find_by_alias(dog_alias) + end + + def test_find_by_one_attribute_that_is_an_alias + assert_equal topics(:first), Topic.find_by_heading("The First Topic") + assert_nil Topic.find_by_heading("The First Topic!") + end + + def test_find_by_one_attribute_bang_with_blank_defined + blank_topic = BlankTopic.create(title: "The Blank One") + assert_equal blank_topic, BlankTopic.find_by_title!("The Blank One") + end + + def test_find_by_one_attribute_with_conditions + assert_equal accounts(:rails_core_account), Account.where('firm_id = ?', 6).find_by_credit_limit(50) + end + + def test_find_by_one_attribute_that_is_an_aggregate + address = customers(:david).address + assert_kind_of Address, address + found_customer = Customer.find_by_address(address) + assert_equal customers(:david), found_customer + end + + def test_find_by_one_attribute_that_is_an_aggregate_with_one_attribute_difference + address = customers(:david).address + assert_kind_of Address, address + missing_address = Address.new(address.street, address.city, address.country + "1") + assert_nil Customer.find_by_address(missing_address) + missing_address = Address.new(address.street, address.city + "1", address.country) + assert_nil Customer.find_by_address(missing_address) + missing_address = Address.new(address.street + "1", address.city, address.country) + assert_nil Customer.find_by_address(missing_address) + end + + def test_find_by_two_attributes_that_are_both_aggregates + balance = customers(:david).balance + address = customers(:david).address + assert_kind_of Money, balance + assert_kind_of Address, address + found_customer = Customer.find_by_balance_and_address(balance, address) + assert_equal customers(:david), found_customer + end + + def test_find_by_two_attributes_with_one_being_an_aggregate + balance = customers(:david).balance + assert_kind_of Money, balance + found_customer = Customer.find_by_balance_and_name(balance, customers(:david).name) + assert_equal customers(:david), found_customer + end + + def test_dynamic_finder_on_one_attribute_with_conditions_returns_same_results_after_caching + # ensure this test can run independently of order + class << Account; self; end.send(:remove_method, :find_by_credit_limit) if Account.public_methods.include?(:find_by_credit_limit) + a = Account.where('firm_id = ?', 6).find_by_credit_limit(50) + assert_equal a, Account.where('firm_id = ?', 6).find_by_credit_limit(50) # find_by_credit_limit has been cached + end + + def test_find_by_one_attribute_with_several_options + assert_equal accounts(:unknown), Account.order('id DESC').where('id != ?', 3).find_by_credit_limit(50) + end + + def test_find_by_one_missing_attribute + assert_raise(NoMethodError) { Topic.find_by_undertitle("The First Topic!") } + end + + def test_find_by_invalid_method_syntax + assert_raise(NoMethodError) { Topic.fail_to_find_by_title("The First Topic") } + assert_raise(NoMethodError) { Topic.find_by_title?("The First Topic") } + assert_raise(NoMethodError) { Topic.fail_to_find_or_create_by_title("Nonexistent Title") } + assert_raise(NoMethodError) { Topic.find_or_create_by_title?("Nonexistent Title") } + end + + def test_find_by_two_attributes + assert_equal topics(:first), Topic.find_by_title_and_author_name("The First Topic", "David") + assert_nil Topic.find_by_title_and_author_name("The First Topic", "Mary") + end + + def test_find_by_two_attributes_but_passing_only_one + assert_raise(ArgumentError) { Topic.find_by_title_and_author_name("The First Topic") } + end + + def test_find_last_with_offset + devs = Developer.order('id') + + assert_equal devs[2], Developer.offset(2).first + assert_equal devs[-3], Developer.offset(2).last + assert_equal devs[-3], Developer.offset(2).last + assert_equal devs[-3], Developer.offset(2).order('id DESC').first + end + + def test_find_by_nil_attribute + topic = Topic.find_by_last_read nil + assert_not_nil topic + assert_nil topic.last_read + end + + def test_find_by_nil_and_not_nil_attributes + topic = Topic.find_by_last_read_and_author_name nil, "Mary" + assert_equal "Mary", topic.author_name + end + + def test_find_with_bad_sql + assert_raise(ActiveRecord::StatementInvalid) { Topic.find_by_sql "select 1 from badtable" } + end + + def test_find_all_with_join + developers_on_project_one = Developer. + joins('LEFT JOIN developers_projects ON developers.id = developers_projects.developer_id'). + where('project_id=1').to_a + assert_equal 3, developers_on_project_one.length + developer_names = developers_on_project_one.map { |d| d.name } + assert developer_names.include?('David') + assert developer_names.include?('Jamis') + end + + def test_joins_dont_clobber_id + first = Firm. + joins('INNER JOIN companies clients ON clients.firm_id = companies.id'). + where('companies.id = 1').first + assert_equal 1, first.id + end + + def test_joins_with_string_array + person_with_reader_and_post = Post. + joins(["INNER JOIN categorizations ON categorizations.post_id = posts.id", + "INNER JOIN categories ON categories.id = categorizations.category_id AND categories.type = 'SpecialCategory'" + ]) + assert_equal 1, person_with_reader_and_post.size + end + + def test_find_by_id_with_conditions_with_or + assert_nothing_raised do + Post.where("posts.id <= 3 OR posts.#{QUOTED_TYPE} = 'Post'").find([1,2,3]) + end + end + + # http://dev.rubyonrails.org/ticket/6778 + def test_find_ignores_previously_inserted_record + Post.create!(:title => 'test', :body => 'it out') + assert_equal [], Post.where(id: nil) + end + + def test_find_by_empty_ids + assert_equal [], Post.find([]) + end + + def test_find_by_empty_in_condition + assert_equal [], Post.where('id in (?)', []) + end + + def test_find_by_records + p1, p2 = Post.limit(2).order('id asc').to_a + assert_equal [p1, p2], Post.where(['id in (?)', [p1, p2]]).order('id asc') + assert_equal [p1, p2], Post.where(['id in (?)', [p1, p2.id]]).order('id asc') + end + + def test_select_value + assert_equal "37signals", Company.connection.select_value("SELECT name FROM companies WHERE id = 1") + assert_nil Company.connection.select_value("SELECT name FROM companies WHERE id = -1") + # make sure we didn't break count... + assert_equal 0, Company.count_by_sql("SELECT COUNT(*) FROM companies WHERE name = 'Halliburton'") + assert_equal 1, Company.count_by_sql("SELECT COUNT(*) FROM companies WHERE name = '37signals'") + end + + def test_select_values + assert_equal ["1","2","3","4","5","6","7","8","9", "10", "11"], Company.connection.select_values("SELECT id FROM companies ORDER BY id").map! { |i| i.to_s } + assert_equal ["37signals","Summit","Microsoft", "Flamboyant Software", "Ex Nihilo", "RailsCore", "Leetsoft", "Jadedpixel", "Odegy", "Ex Nihilo Part Deux", "Apex"], Company.connection.select_values("SELECT name FROM companies ORDER BY id") + end + + def test_select_rows + assert_equal( + [["1", "1", nil, "37signals"], + ["2", "1", "2", "Summit"], + ["3", "1", "1", "Microsoft"]], + Company.connection.select_rows("SELECT id, firm_id, client_of, name FROM companies WHERE id IN (1,2,3) ORDER BY id").map! {|i| i.map! {|j| j.to_s unless j.nil?}}) + assert_equal [["1", "37signals"], ["2", "Summit"], ["3", "Microsoft"]], + Company.connection.select_rows("SELECT id, name FROM companies WHERE id IN (1,2,3) ORDER BY id").map! {|i| i.map! {|j| j.to_s unless j.nil?}} + end + + def test_find_with_order_on_included_associations_with_construct_finder_sql_for_association_limiting_and_is_distinct + assert_equal 2, Post.includes(authors: :author_address).order('author_addresses.id DESC ').limit(2).to_a.size + + assert_equal 3, Post.includes(author: :author_address, authors: :author_address). + order('author_addresses_authors.id DESC ').limit(3).to_a.size + end + + def test_find_with_nil_inside_set_passed_for_one_attribute + client_of = Company. + where(client_of: [2, 1, nil], + name: ['37signals', 'Summit', 'Microsoft']). + order('client_of DESC'). + map { |x| x.client_of } + + assert client_of.include?(nil) + assert_equal [2, 1].sort, client_of.compact.sort + end + + def test_find_with_nil_inside_set_passed_for_attribute + client_of = Company. + where(client_of: [nil]). + order('client_of DESC'). + map { |x| x.client_of } + + assert_equal [], client_of.compact + end + + def test_with_limiting_with_custom_select + posts = Post.references(:authors).merge( + :includes => :author, :select => 'posts.*, authors.id as "author_id"', + :limit => 3, :order => 'posts.id' + ).to_a + assert_equal 3, posts.size + assert_equal [0, 1, 1], posts.map(&:author_id).sort + end + + def test_find_one_message_with_custom_primary_key + table_with_custom_primary_key do |model| + model.primary_key = :name + e = assert_raises(ActiveRecord::RecordNotFound) do + model.find 'Hello World!' + end + assert_equal %Q{Couldn't find MercedesCar with 'name'=Hello World!}, e.message + end + end + + def test_find_some_message_with_custom_primary_key + table_with_custom_primary_key do |model| + model.primary_key = :name + e = assert_raises(ActiveRecord::RecordNotFound) do + model.find 'Hello', 'World!' + end + assert_equal %Q{Couldn't find all MercedesCars with 'name': (Hello, World!) (found 0 results, but was looking for 2)}, e.message + end + end + + def test_find_without_primary_key + assert_raises(ActiveRecord::UnknownPrimaryKey) do + Matey.find(1) + end + end + + def test_finder_with_offset_string + assert_nothing_raised(ActiveRecord::StatementInvalid) { Topic.offset("3").to_a } + end + + protected + def bind(statement, *vars) + if vars.first.is_a?(Hash) + ActiveRecord::Base.send(:replace_named_bind_variables, statement, vars.first) + else + ActiveRecord::Base.send(:replace_bind_variables, statement, vars) + end + end + + def table_with_custom_primary_key + yield(Class.new(Toy) do + def self.name + 'MercedesCar' + end + end) + end +end diff --git a/activerecord/test/cases/fixture_set/file_test.rb b/activerecord/test/cases/fixture_set/file_test.rb new file mode 100644 index 0000000000..92efa8aca7 --- /dev/null +++ b/activerecord/test/cases/fixture_set/file_test.rb @@ -0,0 +1,138 @@ +require 'cases/helper' +require 'tempfile' + +module ActiveRecord + class FixtureSet + class FileTest < ActiveRecord::TestCase + def test_open + fh = File.open(::File.join(FIXTURES_ROOT, "accounts.yml")) + assert_equal 6, fh.to_a.length + end + + def test_open_with_block + called = false + File.open(::File.join(FIXTURES_ROOT, "accounts.yml")) do |fh| + called = true + assert_equal 6, fh.to_a.length + end + assert called, 'block called' + end + + def test_names + File.open(::File.join(FIXTURES_ROOT, "accounts.yml")) do |fh| + assert_equal ["signals37", + "unknown", + "rails_core_account", + "last_account", + "rails_core_account_2", + "odegy_account"].sort, fh.to_a.map(&:first).sort + end + end + + def test_values + File.open(::File.join(FIXTURES_ROOT, "accounts.yml")) do |fh| + assert_equal [1,2,3,4,5,6].sort, fh.to_a.map(&:last).map { |x| + x['id'] + }.sort + end + end + + def test_erb_processing + File.open(::File.join(FIXTURES_ROOT, "developers.yml")) do |fh| + devs = Array.new(8) { |i| "dev_#{i + 3}" } + assert_equal [], devs - fh.to_a.map(&:first) + end + end + + def test_empty_file + tmp_yaml ['empty', 'yml'], '' do |t| + assert_equal [], File.open(t.path) { |fh| fh.to_a } + end + end + + # A valid YAML file is not necessarily a value Fixture file. Make sure + # an exception is raised if the format is not valid Fixture format. + def test_wrong_fixture_format_string + tmp_yaml ['empty', 'yml'], 'qwerty' do |t| + assert_raises(ActiveRecord::Fixture::FormatError) do + File.open(t.path) { |fh| fh.to_a } + end + end + end + + def test_wrong_fixture_format_nested + tmp_yaml ['empty', 'yml'], 'one: two' do |t| + assert_raises(ActiveRecord::Fixture::FormatError) do + File.open(t.path) { |fh| fh.to_a } + end + end + end + + def test_render_context_helper + ActiveRecord::FixtureSet.context_class.class_eval do + def fixture_helper + "Fixture helper" + end + end + yaml = "one:\n name: <%= fixture_helper %>\n" + tmp_yaml ['curious', 'yml'], yaml do |t| + golden = + [["one", {"name" => "Fixture helper"}]] + assert_equal golden, File.open(t.path) { |fh| fh.to_a } + end + ActiveRecord::FixtureSet.context_class.class_eval do + remove_method :fixture_helper + end + end + + def test_render_context_lookup_scope + yaml = <<END +one: + ActiveRecord: <%= defined? ActiveRecord %> + ActiveRecord_FixtureSet: <%= defined? ActiveRecord::FixtureSet %> + FixtureSet: <%= defined? FixtureSet %> + ActiveRecord_FixtureSet_File: <%= defined? ActiveRecord::FixtureSet::File %> + File: <%= File.name %> +END + + golden = [['one', { + 'ActiveRecord' => 'constant', + 'ActiveRecord_FixtureSet' => 'constant', + 'FixtureSet' => nil, + 'ActiveRecord_FixtureSet_File' => 'constant', + 'File' => 'File' + }]] + + tmp_yaml ['curious', 'yml'], yaml do |t| + assert_equal golden, File.open(t.path) { |fh| fh.to_a } + end + end + + # Make sure that each fixture gets its own rendering context so that + # fixtures are independent. + def test_independent_render_contexts + yaml1 = "<% def leaked_method; 'leak'; end %>\n" + yaml2 = "one:\n name: <%= leaked_method %>\n" + tmp_yaml ['leaky', 'yml'], yaml1 do |t1| + tmp_yaml ['curious', 'yml'], yaml2 do |t2| + File.open(t1.path) { |fh| fh.to_a } + assert_raises(NameError) do + File.open(t2.path) { |fh| fh.to_a } + end + end + end + end + + private + def tmp_yaml(name, contents) + t = Tempfile.new name + t.binmode + t.write contents + t.close + yield t + ensure + t.close true + end + end + end +end diff --git a/activerecord/test/cases/fixtures_test.rb b/activerecord/test/cases/fixtures_test.rb new file mode 100644 index 0000000000..042fdaf0bb --- /dev/null +++ b/activerecord/test/cases/fixtures_test.rb @@ -0,0 +1,867 @@ +require 'cases/helper' +require 'models/admin' +require 'models/admin/account' +require 'models/admin/randomly_named_c1' +require 'models/admin/user' +require 'models/binary' +require 'models/book' +require 'models/category' +require 'models/company' +require 'models/computer' +require 'models/course' +require 'models/developer' +require 'models/joke' +require 'models/matey' +require 'models/parrot' +require 'models/pirate' +require 'models/post' +require 'models/randomly_named_c1' +require 'models/reply' +require 'models/ship' +require 'models/task' +require 'models/topic' +require 'models/traffic_light' +require 'models/treasure' +require 'tempfile' + +class FixturesTest < ActiveRecord::TestCase + self.use_instantiated_fixtures = true + self.use_transactional_fixtures = false + + # other_topics fixture should not be included here + fixtures :topics, :developers, :accounts, :tasks, :categories, :funny_jokes, :binaries, :traffic_lights + + FIXTURES = %w( accounts binaries companies customers + developers developers_projects entrants + movies projects subscribers topics tasks ) + MATCH_ATTRIBUTE_NAME = /[a-zA-Z][-\w]*/ + + def test_clean_fixtures + FIXTURES.each do |name| + fixtures = nil + assert_nothing_raised { fixtures = create_fixtures(name).first } + assert_kind_of(ActiveRecord::FixtureSet, fixtures) + fixtures.each { |_name, fixture| + fixture.each { |key, value| + assert_match(MATCH_ATTRIBUTE_NAME, key) + } + } + end + end + + def test_broken_yaml_exception + badyaml = Tempfile.new ['foo', '.yml'] + badyaml.write 'a: : ' + badyaml.flush + + dir = File.dirname badyaml.path + name = File.basename badyaml.path, '.yml' + assert_raises(ActiveRecord::Fixture::FormatError) do + ActiveRecord::FixtureSet.create_fixtures(dir, name) + end + ensure + badyaml.close + badyaml.unlink + end + + def test_create_fixtures + fixtures = ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT, "parrots") + assert Parrot.find_by_name('Curious George'), 'George is not in the database' + assert fixtures.detect { |f| f.name == 'parrots' }, "no fixtures named 'parrots' in #{fixtures.map(&:name).inspect}" + end + + def test_multiple_clean_fixtures + fixtures_array = nil + assert_nothing_raised { fixtures_array = create_fixtures(*FIXTURES) } + assert_kind_of(Array, fixtures_array) + fixtures_array.each { |fixtures| assert_kind_of(ActiveRecord::FixtureSet, fixtures) } + end + + def test_create_symbol_fixtures + fixtures = ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT, :collections, :collections => Course) { Course.connection } + + assert Course.find_by_name('Collection'), 'course is not in the database' + assert fixtures.detect { |f| f.name == 'collections' }, "no fixtures named 'collections' in #{fixtures.map(&:name).inspect}" + end + + def test_attributes + topics = create_fixtures("topics").first + assert_equal("The First Topic", topics["first"]["title"]) + assert_nil(topics["second"]["author_email_address"]) + end + + def test_inserts + create_fixtures("topics") + first_row = ActiveRecord::Base.connection.select_one("SELECT * FROM topics WHERE author_name = 'David'") + assert_equal("The First Topic", first_row["title"]) + + second_row = ActiveRecord::Base.connection.select_one("SELECT * FROM topics WHERE author_name = 'Mary'") + assert_nil(second_row["author_email_address"]) + end + + if ActiveRecord::Base.connection.supports_migrations? + def test_inserts_with_pre_and_suffix + # Reset cache to make finds on the new table work + ActiveRecord::FixtureSet.reset_cache + + ActiveRecord::Base.connection.create_table :prefix_other_topics_suffix do |t| + t.column :title, :string + t.column :author_name, :string + t.column :author_email_address, :string + t.column :written_on, :datetime + t.column :bonus_time, :time + t.column :last_read, :date + t.column :content, :string + t.column :approved, :boolean, :default => true + t.column :replies_count, :integer, :default => 0 + t.column :parent_id, :integer + t.column :type, :string, :limit => 50 + end + + # Store existing prefix/suffix + old_prefix = ActiveRecord::Base.table_name_prefix + old_suffix = ActiveRecord::Base.table_name_suffix + + # Set a prefix/suffix we can test against + ActiveRecord::Base.table_name_prefix = 'prefix_' + ActiveRecord::Base.table_name_suffix = '_suffix' + + other_topic_klass = Class.new(ActiveRecord::Base) do + def self.name + "OtherTopic" + end + end + + topics = [create_fixtures("other_topics")].flatten.first + + # This checks for a caching problem which causes a bug in the fixtures + # class-level configuration helper. + assert_not_nil topics, "Fixture data inserted, but fixture objects not returned from create" + + first_row = ActiveRecord::Base.connection.select_one("SELECT * FROM prefix_other_topics_suffix WHERE author_name = 'David'") + assert_not_nil first_row, "The prefix_other_topics_suffix table appears to be empty despite create_fixtures: the row with author_name = 'David' was not found" + assert_equal("The First Topic", first_row["title"]) + + second_row = ActiveRecord::Base.connection.select_one("SELECT * FROM prefix_other_topics_suffix WHERE author_name = 'Mary'") + assert_nil(second_row["author_email_address"]) + + assert_equal :prefix_other_topics_suffix, topics.table_name.to_sym + # This assertion should preferably be the last in the list, because calling + # other_topic_klass.table_name sets a class-level instance variable + assert_equal :prefix_other_topics_suffix, other_topic_klass.table_name.to_sym + + ensure + # Restore prefix/suffix to its previous values + ActiveRecord::Base.table_name_prefix = old_prefix + ActiveRecord::Base.table_name_suffix = old_suffix + + ActiveRecord::Base.connection.drop_table :prefix_other_topics_suffix rescue nil + end + end + + def test_insert_with_datetime + create_fixtures("tasks") + first = Task.find(1) + assert first + end + + def test_logger_level_invariant + level = ActiveRecord::Base.logger.level + create_fixtures('topics') + assert_equal level, ActiveRecord::Base.logger.level + end + + def test_instantiation + topics = create_fixtures("topics").first + assert_kind_of Topic, topics["first"].find + end + + def test_complete_instantiation + assert_equal "The First Topic", @first.title + end + + def test_fixtures_from_root_yml_with_instantiation + # assert_equal 2, @accounts.size + assert_equal 50, @unknown.credit_limit + end + + def test_erb_in_fixtures + assert_equal "fixture_5", @dev_5.name + end + + def test_empty_yaml_fixture + assert_not_nil ActiveRecord::FixtureSet.new( Account.connection, "accounts", Account, FIXTURES_ROOT + "/naked/yml/accounts") + end + + def test_empty_yaml_fixture_with_a_comment_in_it + assert_not_nil ActiveRecord::FixtureSet.new( Account.connection, "companies", Company, FIXTURES_ROOT + "/naked/yml/companies") + end + + def test_nonexistent_fixture_file + nonexistent_fixture_path = FIXTURES_ROOT + "/imnothere" + + #sanity check to make sure that this file never exists + assert Dir[nonexistent_fixture_path+"*"].empty? + + assert_raise(Errno::ENOENT) do + ActiveRecord::FixtureSet.new( Account.connection, "companies", Company, nonexistent_fixture_path) + end + end + + def test_dirty_dirty_yaml_file + assert_raise(ActiveRecord::Fixture::FormatError) do + ActiveRecord::FixtureSet.new( Account.connection, "courses", Course, FIXTURES_ROOT + "/naked/yml/courses") + end + end + + def test_omap_fixtures + assert_nothing_raised do + fixtures = ActiveRecord::FixtureSet.new(Account.connection, 'categories', Category, FIXTURES_ROOT + "/categories_ordered") + + fixtures.each.with_index do |(name, fixture), i| + assert_equal "fixture_no_#{i}", name + assert_equal "Category #{i}", fixture['name'] + end + end + end + + def test_yml_file_in_subdirectory + assert_equal(categories(:sub_special_1).name, "A special category in a subdir file") + assert_equal(categories(:sub_special_1).class, SpecialCategory) + end + + def test_subsubdir_file_with_arbitrary_name + assert_equal(categories(:sub_special_3).name, "A special category in an arbitrarily named subsubdir file") + assert_equal(categories(:sub_special_3).class, SpecialCategory) + end + + def test_binary_in_fixtures + data = File.open(ASSETS_ROOT + "/flowers.jpg", 'rb') { |f| f.read } + data.force_encoding('ASCII-8BIT') + data.freeze + assert_equal data, @flowers.data + end + + def test_serialized_fixtures + assert_equal ["Green", "Red", "Orange"], traffic_lights(:uk).state + end + + def test_fixtures_are_set_up_with_database_env_variable + db_url_tmp = ENV['DATABASE_URL'] + ENV['DATABASE_URL'] = "sqlite3::memory:" + ActiveRecord::Base.stubs(:configurations).returns({}) + test_case = Class.new(ActiveRecord::TestCase) do + fixtures :accounts + + def test_fixtures + assert accounts(:signals37) + end + end + + result = test_case.new(:test_fixtures).run + + assert result.passed?, "Expected #{result.name} to pass:\n#{result}" + ensure + ENV['DATABASE_URL'] = db_url_tmp + end +end + +class HasManyThroughFixture < ActiveSupport::TestCase + def make_model(name) + Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } } + end + + def test_has_many_through + pt = make_model "ParrotTreasure" + parrot = make_model "Parrot" + treasure = make_model "Treasure" + + pt.table_name = "parrots_treasures" + pt.belongs_to :parrot, :class => parrot + pt.belongs_to :treasure, :class => treasure + + parrot.has_many :parrot_treasures, :class => pt + parrot.has_many :treasures, :through => :parrot_treasures + + parrots = File.join FIXTURES_ROOT, 'parrots' + + fs = ActiveRecord::FixtureSet.new parrot.connection, "parrots", parrot, parrots + rows = fs.table_rows + assert_equal load_has_and_belongs_to_many['parrots_treasures'], rows['parrots_treasures'] + end + + def load_has_and_belongs_to_many + parrot = make_model "Parrot" + parrot.has_and_belongs_to_many :treasures + + parrots = File.join FIXTURES_ROOT, 'parrots' + + fs = ActiveRecord::FixtureSet.new parrot.connection, "parrots", parrot, parrots + fs.table_rows + end +end + +if Account.connection.respond_to?(:reset_pk_sequence!) + class FixturesResetPkSequenceTest < ActiveRecord::TestCase + fixtures :accounts + fixtures :companies + + def setup + @instances = [Account.new(:credit_limit => 50), Company.new(:name => 'RoR Consulting')] + ActiveRecord::FixtureSet.reset_cache # make sure tables get reinitialized + end + + def test_resets_to_min_pk_with_specified_pk_and_sequence + @instances.each do |instance| + model = instance.class + model.delete_all + model.connection.reset_pk_sequence!(model.table_name, model.primary_key, model.sequence_name) + + instance.save! + assert_equal 1, instance.id, "Sequence reset for #{model.table_name} failed." + end + end + + def test_resets_to_min_pk_with_default_pk_and_sequence + @instances.each do |instance| + model = instance.class + model.delete_all + model.connection.reset_pk_sequence!(model.table_name) + + instance.save! + assert_equal 1, instance.id, "Sequence reset for #{model.table_name} failed." + end + end + + def test_create_fixtures_resets_sequences_when_not_cached + @instances.each do |instance| + max_id = create_fixtures(instance.class.table_name).first.fixtures.inject(0) do |_max_id, (_, fixture)| + fixture_id = fixture['id'].to_i + fixture_id > _max_id ? fixture_id : _max_id + end + + # Clone the last fixture to check that it gets the next greatest id. + instance.save! + assert_equal max_id + 1, instance.id, "Sequence reset for #{instance.class.table_name} failed." + end + end + end +end + +class FixturesWithoutInstantiationTest < ActiveRecord::TestCase + self.use_instantiated_fixtures = false + fixtures :topics, :developers, :accounts + + def test_without_complete_instantiation + assert !defined?(@first) + assert !defined?(@topics) + assert !defined?(@developers) + assert !defined?(@accounts) + end + + def test_fixtures_from_root_yml_without_instantiation + assert !defined?(@unknown), "@unknown is not defined" + end + + def test_visibility_of_accessor_method + assert_equal false, respond_to?(:topics, false), "should be private method" + assert_equal true, respond_to?(:topics, true), "confirm to respond surely" + end + + def test_accessor_methods + assert_equal "The First Topic", topics(:first).title + assert_equal "Jamis", developers(:jamis).name + assert_equal 50, accounts(:signals37).credit_limit + end + + def test_accessor_methods_with_multiple_args + assert_equal 2, topics(:first, :second).size + assert_raise(StandardError) { topics([:first, :second]) } + end + + def test_reloading_fixtures_through_accessor_methods + assert_equal "The First Topic", topics(:first).title + @loaded_fixtures['topics']['first'].expects(:find).returns(stub(:title => "Fresh Topic!")) + assert_equal "Fresh Topic!", topics(:first, true).title + end +end + +class FixturesWithoutInstanceInstantiationTest < ActiveRecord::TestCase + self.use_instantiated_fixtures = true + self.use_instantiated_fixtures = :no_instances + + fixtures :topics, :developers, :accounts + + def test_without_instance_instantiation + assert !defined?(@first), "@first is not defined" + end +end + +class TransactionalFixturesTest < ActiveRecord::TestCase + self.use_instantiated_fixtures = true + self.use_transactional_fixtures = true + + fixtures :topics + + def test_destroy + assert_not_nil @first + @first.destroy + end + + def test_destroy_just_kidding + assert_not_nil @first + end +end + +class MultipleFixturesTest < ActiveRecord::TestCase + fixtures :topics + fixtures :developers, :accounts + + def test_fixture_table_names + assert_equal %w(topics developers accounts), fixture_table_names + end +end + +class SetupTest < ActiveRecord::TestCase + # fixtures :topics + + def setup + @first = true + end + + def test_nothing + end +end + +class SetupSubclassTest < SetupTest + def setup + super + @second = true + end + + def test_subclassing_should_preserve_setups + assert @first + assert @second + end +end + + +class OverlappingFixturesTest < ActiveRecord::TestCase + fixtures :topics, :developers + fixtures :developers, :accounts + + def test_fixture_table_names + assert_equal %w(topics developers accounts), fixture_table_names + end +end + +class ForeignKeyFixturesTest < ActiveRecord::TestCase + fixtures :fk_test_has_pk, :fk_test_has_fk + + # if foreign keys are implemented and fixtures + # are not deleted in reverse order then this test + # case will raise StatementInvalid + + def test_number1 + assert true + end + + def test_number2 + assert true + end +end + +class OverRideFixtureMethodTest < ActiveRecord::TestCase + fixtures :topics + + def topics(name) + topic = super + topic.title = 'omg' + topic + end + + def test_fixture_methods_can_be_overridden + x = topics :first + assert_equal 'omg', x.title + end +end + +class CheckSetTableNameFixturesTest < ActiveRecord::TestCase + set_fixture_class :funny_jokes => Joke + fixtures :funny_jokes + # Set to false to blow away fixtures cache and ensure our fixtures are loaded + # and thus takes into account our set_fixture_class + self.use_transactional_fixtures = false + + def test_table_method + assert_kind_of Joke, funny_jokes(:a_joke) + end +end + +class FixtureNameIsNotTableNameFixturesTest < ActiveRecord::TestCase + set_fixture_class :items => Book + fixtures :items + # Set to false to blow away fixtures cache and ensure our fixtures are loaded + # and thus takes into account our set_fixture_class + self.use_transactional_fixtures = false + + def test_named_accessor + assert_kind_of Book, items(:dvd) + end +end + +class FixtureNameIsNotTableNameMultipleFixturesTest < ActiveRecord::TestCase + set_fixture_class :items => Book, :funny_jokes => Joke + fixtures :items, :funny_jokes + # Set to false to blow away fixtures cache and ensure our fixtures are loaded + # and thus takes into account our set_fixture_class + self.use_transactional_fixtures = false + + def test_named_accessor_of_differently_named_fixture + assert_kind_of Book, items(:dvd) + end + + def test_named_accessor_of_same_named_fixture + assert_kind_of Joke, funny_jokes(:a_joke) + end +end + +class CustomConnectionFixturesTest < ActiveRecord::TestCase + set_fixture_class :courses => Course + fixtures :courses + self.use_transactional_fixtures = false + + def test_leaky_destroy + assert_nothing_raised { courses(:ruby) } + courses(:ruby).destroy + end + + def test_it_twice_in_whatever_order_to_check_for_fixture_leakage + test_leaky_destroy + end +end + +class TransactionalFixturesOnCustomConnectionTest < ActiveRecord::TestCase + set_fixture_class :courses => Course + fixtures :courses + self.use_transactional_fixtures = true + + def test_leaky_destroy + assert_nothing_raised { courses(:ruby) } + courses(:ruby).destroy + end + + def test_it_twice_in_whatever_order_to_check_for_fixture_leakage + test_leaky_destroy + end +end + +class InvalidTableNameFixturesTest < ActiveRecord::TestCase + fixtures :funny_jokes + # Set to false to blow away fixtures cache and ensure our fixtures are loaded + # and thus takes into account our lack of set_fixture_class + self.use_transactional_fixtures = false + + def test_raises_error + assert_raise ActiveRecord::FixtureClassNotFound do + funny_jokes(:a_joke) + end + end +end + +class CheckEscapedYamlFixturesTest < ActiveRecord::TestCase + set_fixture_class :funny_jokes => Joke + fixtures :funny_jokes + # Set to false to blow away fixtures cache and ensure our fixtures are loaded + # and thus takes into account our set_fixture_class + self.use_transactional_fixtures = false + + def test_proper_escaped_fixture + assert_equal "The \\n Aristocrats\nAte the candy\n", funny_jokes(:another_joke).name + end +end + +class DevelopersProject; end +class ManyToManyFixturesWithClassDefined < ActiveRecord::TestCase + fixtures :developers_projects + + def test_this_should_run_cleanly + assert true + end +end + +class FixturesBrokenRollbackTest < ActiveRecord::TestCase + def blank_setup + @fixture_connections = [ActiveRecord::Base.connection] + end + alias_method :ar_setup_fixtures, :setup_fixtures + alias_method :setup_fixtures, :blank_setup + alias_method :setup, :blank_setup + + def blank_teardown; end + alias_method :ar_teardown_fixtures, :teardown_fixtures + alias_method :teardown_fixtures, :blank_teardown + alias_method :teardown, :blank_teardown + + def test_no_rollback_in_teardown_unless_transaction_active + assert_equal 0, ActiveRecord::Base.connection.open_transactions + assert_raise(RuntimeError) { ar_setup_fixtures } + assert_equal 0, ActiveRecord::Base.connection.open_transactions + assert_nothing_raised { ar_teardown_fixtures } + assert_equal 0, ActiveRecord::Base.connection.open_transactions + end + + private + def load_fixtures(config) + raise 'argh' + end +end + +class LoadAllFixturesTest < ActiveRecord::TestCase + def test_all_there + self.class.fixture_path = FIXTURES_ROOT + "/all" + self.class.fixtures :all + + if File.symlink? FIXTURES_ROOT + "/all/admin" + assert_equal %w(admin/accounts admin/users developers people tasks), fixture_table_names.sort + end + ensure + ActiveRecord::FixtureSet.reset_cache + end +end + +class LoadAllFixturesWithPathnameTest < ActiveRecord::TestCase + def test_all_there + self.class.fixture_path = Pathname.new(FIXTURES_ROOT).join('all') + self.class.fixtures :all + + if File.symlink? FIXTURES_ROOT + "/all/admin" + assert_equal %w(admin/accounts admin/users developers people tasks), fixture_table_names.sort + end + ensure + ActiveRecord::FixtureSet.reset_cache + end +end + +class FasterFixturesTest < ActiveRecord::TestCase + fixtures :categories, :authors + + def load_extra_fixture(name) + fixture = create_fixtures(name).first + assert fixture.is_a?(ActiveRecord::FixtureSet) + @loaded_fixtures[fixture.table_name] = fixture + end + + def test_cache + assert ActiveRecord::FixtureSet.fixture_is_cached?(ActiveRecord::Base.connection, 'categories') + assert ActiveRecord::FixtureSet.fixture_is_cached?(ActiveRecord::Base.connection, 'authors') + + assert_no_queries do + create_fixtures('categories') + create_fixtures('authors') + end + + load_extra_fixture('posts') + assert ActiveRecord::FixtureSet.fixture_is_cached?(ActiveRecord::Base.connection, 'posts') + self.class.setup_fixture_accessors :posts + assert_equal 'Welcome to the weblog', posts(:welcome).title + end +end + +class FoxyFixturesTest < ActiveRecord::TestCase + fixtures :parrots, :parrots_pirates, :pirates, :treasures, :mateys, :ships, :computers, :developers, :"admin/accounts", :"admin/users" + + if ActiveRecord::Base.connection.adapter_name == 'PostgreSQL' + require 'models/uuid_parent' + require 'models/uuid_child' + fixtures :uuid_parents, :uuid_children + end + + def test_identifies_strings + assert_equal(ActiveRecord::FixtureSet.identify("foo"), ActiveRecord::FixtureSet.identify("foo")) + assert_not_equal(ActiveRecord::FixtureSet.identify("foo"), ActiveRecord::FixtureSet.identify("FOO")) + end + + def test_identifies_symbols + assert_equal(ActiveRecord::FixtureSet.identify(:foo), ActiveRecord::FixtureSet.identify(:foo)) + end + + def test_identifies_consistently + assert_equal 207281424, ActiveRecord::FixtureSet.identify(:ruby) + assert_equal 1066363776, ActiveRecord::FixtureSet.identify(:sapphire_2) + + assert_equal 'f92b6bda-0d0d-5fe1-9124-502b18badded', ActiveRecord::FixtureSet.identify(:daddy, :uuid) + assert_equal 'b4b10018-ad47-595d-b42f-d8bdaa6d01bf', ActiveRecord::FixtureSet.identify(:sonny, :uuid) + end + + TIMESTAMP_COLUMNS = %w(created_at created_on updated_at updated_on) + + def test_populates_timestamp_columns + TIMESTAMP_COLUMNS.each do |property| + assert_not_nil(parrots(:george).send(property), "should set #{property}") + end + end + + def test_does_not_populate_timestamp_columns_if_model_has_set_record_timestamps_to_false + TIMESTAMP_COLUMNS.each do |property| + assert_nil(ships(:black_pearl).send(property), "should not set #{property}") + end + end + + def test_populates_all_columns_with_the_same_time + last = nil + + TIMESTAMP_COLUMNS.each do |property| + current = parrots(:george).send(property) + last ||= current + + assert_equal(last, current) + last = current + end + end + + def test_only_populates_columns_that_exist + assert_not_nil(pirates(:blackbeard).created_on) + assert_not_nil(pirates(:blackbeard).updated_on) + end + + def test_preserves_existing_fixture_data + assert_equal(2.weeks.ago.to_date, pirates(:redbeard).created_on.to_date) + assert_equal(2.weeks.ago.to_date, pirates(:redbeard).updated_on.to_date) + end + + def test_generates_unique_ids + assert_not_nil(parrots(:george).id) + assert_not_equal(parrots(:george).id, parrots(:louis).id) + end + + def test_automatically_sets_primary_key + assert_not_nil(ships(:black_pearl)) + end + + def test_preserves_existing_primary_key + assert_equal(2, ships(:interceptor).id) + end + + def test_resolves_belongs_to_symbols + assert_equal(parrots(:george), pirates(:blackbeard).parrot) + end + + def test_ignores_belongs_to_symbols_if_association_and_foreign_key_are_named_the_same + assert_equal(developers(:david), computers(:workstation).developer) + end + + def test_supports_join_tables + assert(pirates(:blackbeard).parrots.include?(parrots(:george))) + assert(pirates(:blackbeard).parrots.include?(parrots(:louis))) + assert(parrots(:george).pirates.include?(pirates(:blackbeard))) + end + + def test_supports_inline_habtm + assert(parrots(:george).treasures.include?(treasures(:diamond))) + assert(parrots(:george).treasures.include?(treasures(:sapphire))) + assert(!parrots(:george).treasures.include?(treasures(:ruby))) + end + + def test_supports_inline_habtm_with_specified_id + assert(parrots(:polly).treasures.include?(treasures(:ruby))) + assert(parrots(:polly).treasures.include?(treasures(:sapphire))) + assert(!parrots(:polly).treasures.include?(treasures(:diamond))) + end + + def test_supports_yaml_arrays + assert(parrots(:louis).treasures.include?(treasures(:diamond))) + assert(parrots(:louis).treasures.include?(treasures(:sapphire))) + end + + def test_strips_DEFAULTS_key + assert_raise(StandardError) { parrots(:DEFAULTS) } + + # this lets us do YAML defaults and not have an extra fixture entry + %w(sapphire ruby).each { |t| assert(parrots(:davey).treasures.include?(treasures(t))) } + end + + def test_supports_label_interpolation + assert_equal("frederick", parrots(:frederick).name) + end + + def test_supports_label_string_interpolation + assert_equal("X marks the spot!", pirates(:mark).catchphrase) + end + + def test_supports_polymorphic_belongs_to + assert_equal(pirates(:redbeard), treasures(:sapphire).looter) + assert_equal(parrots(:louis), treasures(:ruby).looter) + end + + def test_only_generates_a_pk_if_necessary + m = Matey.first + m.pirate = pirates(:blackbeard) + m.target = pirates(:redbeard) + end + + def test_supports_sti + assert_kind_of DeadParrot, parrots(:polly) + assert_equal pirates(:blackbeard), parrots(:polly).killer + end + + def test_namespaced_models + assert admin_accounts(:signals37).users.include?(admin_users(:david)) + assert_equal 2, admin_accounts(:signals37).users.size + end +end + +class ActiveSupportSubclassWithFixturesTest < ActiveRecord::TestCase + fixtures :parrots + + # This seemingly useless assertion catches a bug that caused the fixtures + # setup code call nil[] + def test_foo + assert_equal parrots(:louis), Parrot.find_by_name("King Louis") + end +end + +class FixtureLoadingTest < ActiveRecord::TestCase + def test_logs_message_for_failed_dependency_load + ActiveRecord::TestCase.expects(:require_dependency).with(:does_not_exist).raises(LoadError) + ActiveRecord::Base.logger.expects(:warn) + ActiveRecord::TestCase.try_to_load_dependency(:does_not_exist) + end + + def test_does_not_logs_message_for_successful_dependency_load + ActiveRecord::TestCase.expects(:require_dependency).with(:works_out_fine) + ActiveRecord::Base.logger.expects(:warn).never + ActiveRecord::TestCase.try_to_load_dependency(:works_out_fine) + end +end + +class CustomNameForFixtureOrModelTest < ActiveRecord::TestCase + ActiveRecord::FixtureSet.reset_cache + + set_fixture_class :randomly_named_a9 => + ClassNameThatDoesNotFollowCONVENTIONS, + :'admin/randomly_named_a9' => + Admin::ClassNameThatDoesNotFollowCONVENTIONS, + 'admin/randomly_named_b0' => + Admin::ClassNameThatDoesNotFollowCONVENTIONS + + fixtures :randomly_named_a9, 'admin/randomly_named_a9', + :'admin/randomly_named_b0' + + def test_named_accessor_for_randomly_named_fixture_and_class + assert_kind_of ClassNameThatDoesNotFollowCONVENTIONS, + randomly_named_a9(:first_instance) + end + + def test_named_accessor_for_randomly_named_namespaced_fixture_and_class + assert_kind_of Admin::ClassNameThatDoesNotFollowCONVENTIONS, + admin_randomly_named_a9(:first_instance) + assert_kind_of Admin::ClassNameThatDoesNotFollowCONVENTIONS, + admin_randomly_named_b0(:second_instance) + end + + def test_table_name_is_defined_in_the_model + assert_equal 'randomly_named_table', ActiveRecord::FixtureSet::all_loaded_fixtures["admin/randomly_named_a9"].table_name + assert_equal 'randomly_named_table', Admin::ClassNameThatDoesNotFollowCONVENTIONS.table_name + end +end diff --git a/activerecord/test/cases/forbidden_attributes_protection_test.rb b/activerecord/test/cases/forbidden_attributes_protection_test.rb new file mode 100644 index 0000000000..981a75faf6 --- /dev/null +++ b/activerecord/test/cases/forbidden_attributes_protection_test.rb @@ -0,0 +1,69 @@ +require 'cases/helper' +require 'active_support/core_ext/hash/indifferent_access' +require 'models/person' +require 'models/company' + +class ProtectedParams < ActiveSupport::HashWithIndifferentAccess + attr_accessor :permitted + alias :permitted? :permitted + + def initialize(attributes) + super(attributes) + @permitted = false + end + + def permit! + @permitted = true + self + end + + def dup + super.tap do |duplicate| + duplicate.instance_variable_set :@permitted, @permitted + end + end +end + +class ForbiddenAttributesProtectionTest < ActiveRecord::TestCase + def test_forbidden_attributes_cannot_be_used_for_mass_assignment + params = ProtectedParams.new(first_name: 'Guille', gender: 'm') + assert_raises(ActiveModel::ForbiddenAttributesError) do + Person.new(params) + end + end + + def test_permitted_attributes_can_be_used_for_mass_assignment + params = ProtectedParams.new(first_name: 'Guille', gender: 'm') + params.permit! + person = Person.new(params) + + assert_equal 'Guille', person.first_name + assert_equal 'm', person.gender + end + + def test_forbidden_attributes_cannot_be_used_for_sti_inheritance_column + params = ProtectedParams.new(type: 'Client') + assert_raises(ActiveModel::ForbiddenAttributesError) do + Company.new(params) + end + end + + def test_permitted_attributes_can_be_used_for_sti_inheritance_column + params = ProtectedParams.new(type: 'Client') + params.permit! + person = Company.new(params) + assert_equal person.class, Client + end + + def test_regular_hash_should_still_be_used_for_mass_assignment + person = Person.new(first_name: 'Guille', gender: 'm') + + assert_equal 'Guille', person.first_name + assert_equal 'm', person.gender + end + + def test_blank_attributes_should_not_raise + person = Person.new + assert_nil person.assign_attributes(ProtectedParams.new({})) + end +end diff --git a/activerecord/test/cases/habtm_destroy_order_test.rb b/activerecord/test/cases/habtm_destroy_order_test.rb new file mode 100644 index 0000000000..2ce0de360e --- /dev/null +++ b/activerecord/test/cases/habtm_destroy_order_test.rb @@ -0,0 +1,61 @@ +require "cases/helper" +require "models/lesson" +require "models/student" + +class HabtmDestroyOrderTest < ActiveRecord::TestCase + test "may not delete a lesson with students" do + sicp = Lesson.new(:name => "SICP") + ben = Student.new(:name => "Ben Bitdiddle") + sicp.students << ben + sicp.save! + assert_raises LessonError do + assert_no_difference('Lesson.count') do + sicp.destroy + end + end + assert !sicp.destroyed? + end + + test 'should not raise error if have foreign key in the join table' do + student = Student.new(:name => "Ben Bitdiddle") + lesson = Lesson.new(:name => "SICP") + lesson.students << student + lesson.save! + assert_nothing_raised do + student.destroy + end + end + + test "not destroying a student with lessons leaves student<=>lesson association intact" do + # test a normal before_destroy doesn't destroy the habtm joins + begin + sicp = Lesson.new(:name => "SICP") + ben = Student.new(:name => "Ben Bitdiddle") + # add a before destroy to student + Student.class_eval do + before_destroy do + raise ActiveRecord::Rollback unless lessons.empty? + end + end + ben.lessons << sicp + ben.save! + ben.destroy + assert !ben.reload.lessons.empty? + ensure + # get rid of it so Student is still like it was + Student.reset_callbacks(:destroy) + end + end + + test "not destroying a lesson with students leaves student<=>lesson association intact" do + # test a more aggressive before_destroy doesn't destroy the habtm joins and still throws the exception + sicp = Lesson.new(:name => "SICP") + ben = Student.new(:name => "Ben Bitdiddle") + sicp.students << ben + sicp.save! + assert_raises LessonError do + sicp.destroy + end + assert !sicp.reload.students.empty? + end +end diff --git a/activerecord/test/cases/helper.rb b/activerecord/test/cases/helper.rb new file mode 100644 index 0000000000..6a8aff4b69 --- /dev/null +++ b/activerecord/test/cases/helper.rb @@ -0,0 +1,203 @@ +require File.expand_path('../../../../load_paths', __FILE__) + +require 'config' + +require 'active_support/testing/autorun' +require 'stringio' + +require 'active_record' +require 'cases/test_case' +require 'active_support/dependencies' +require 'active_support/logger' +require 'active_support/core_ext/string/strip' + +require 'support/config' +require 'support/connection' + +# TODO: Move all these random hacks into the ARTest namespace and into the support/ dir + +Thread.abort_on_exception = true + +# Show backtraces for deprecated behavior for quicker cleanup. +ActiveSupport::Deprecation.debug = true + +# Disable available locale checks to avoid warnings running the test suite. +I18n.enforce_available_locales = false + +# Connect to the database +ARTest.connect + +# Quote "type" if it's a reserved word for the current connection. +QUOTED_TYPE = ActiveRecord::Base.connection.quote_column_name('type') + +def current_adapter?(*types) + types.any? do |type| + ActiveRecord::ConnectionAdapters.const_defined?(type) && + ActiveRecord::Base.connection.is_a?(ActiveRecord::ConnectionAdapters.const_get(type)) + end +end + +def in_memory_db? + current_adapter?(:SQLite3Adapter) && + ActiveRecord::Base.connection_pool.spec.config[:database] == ":memory:" +end + +def mysql_56? + current_adapter?(:Mysql2Adapter) && + ActiveRecord::Base.connection.send(:version).join(".") >= "5.6.0" +end + +def supports_savepoints? + ActiveRecord::Base.connection.supports_savepoints? +end + +def with_env_tz(new_tz = 'US/Eastern') + old_tz, ENV['TZ'] = ENV['TZ'], new_tz + yield +ensure + old_tz ? ENV['TZ'] = old_tz : ENV.delete('TZ') +end + +def with_timezone_config(cfg) + verify_default_timezone_config + + old_default_zone = ActiveRecord::Base.default_timezone + old_awareness = ActiveRecord::Base.time_zone_aware_attributes + old_zone = Time.zone + + if cfg.has_key?(:default) + ActiveRecord::Base.default_timezone = cfg[:default] + end + if cfg.has_key?(:aware_attributes) + ActiveRecord::Base.time_zone_aware_attributes = cfg[:aware_attributes] + end + if cfg.has_key?(:zone) + Time.zone = cfg[:zone] + end + yield +ensure + ActiveRecord::Base.default_timezone = old_default_zone + ActiveRecord::Base.time_zone_aware_attributes = old_awareness + Time.zone = old_zone +end + +# This method makes sure that tests don't leak global state related to time zones. +EXPECTED_ZONE = nil +EXPECTED_DEFAULT_TIMEZONE = :utc +EXPECTED_TIME_ZONE_AWARE_ATTRIBUTES = false +def verify_default_timezone_config + if Time.zone != EXPECTED_ZONE + $stderr.puts <<-MSG +\n#{self.to_s} + Global state `Time.zone` was leaked. + Expected: #{EXPECTED_ZONE} + Got: #{Time.zone} + MSG + end + if ActiveRecord::Base.default_timezone != EXPECTED_DEFAULT_TIMEZONE + $stderr.puts <<-MSG +\n#{self.to_s} + Global state `ActiveRecord::Base.default_timezone` was leaked. + Expected: #{EXPECTED_DEFAULT_TIMEZONE} + Got: #{ActiveRecord::Base.default_timezone} + MSG + end + if ActiveRecord::Base.time_zone_aware_attributes != EXPECTED_TIME_ZONE_AWARE_ATTRIBUTES + $stderr.puts <<-MSG +\n#{self.to_s} + Global state `ActiveRecord::Base.time_zone_aware_attributes` was leaked. + Expected: #{EXPECTED_TIME_ZONE_AWARE_ATTRIBUTES} + Got: #{ActiveRecord::Base.time_zone_aware_attributes} + MSG + end +end + +def enable_uuid_ossp!(connection) + return false unless connection.supports_extensions? + return true if connection.extension_enabled?('uuid-ossp') + + connection.enable_extension 'uuid-ossp' + connection.commit_db_transaction + connection.reconnect! +end + +unless ENV['FIXTURE_DEBUG'] + module ActiveRecord::TestFixtures::ClassMethods + def try_to_load_dependency_with_silence(*args) + old = ActiveRecord::Base.logger.level + ActiveRecord::Base.logger.level = ActiveSupport::Logger::ERROR + try_to_load_dependency_without_silence(*args) + ActiveRecord::Base.logger.level = old + end + + alias_method_chain :try_to_load_dependency, :silence + end +end + +require "cases/validations_repair_helper" +class ActiveSupport::TestCase + include ActiveRecord::TestFixtures + include ActiveRecord::ValidationsRepairHelper + + self.fixture_path = FIXTURES_ROOT + self.use_instantiated_fixtures = false + self.use_transactional_fixtures = true + + def create_fixtures(*fixture_set_names, &block) + ActiveRecord::FixtureSet.create_fixtures(ActiveSupport::TestCase.fixture_path, fixture_set_names, fixture_class_names, &block) + end +end + +def load_schema + # silence verbose schema loading + original_stdout = $stdout + $stdout = StringIO.new + + adapter_name = ActiveRecord::Base.connection.adapter_name.downcase + adapter_specific_schema_file = SCHEMA_ROOT + "/#{adapter_name}_specific_schema.rb" + + load SCHEMA_ROOT + "/schema.rb" + + if File.exist?(adapter_specific_schema_file) + load adapter_specific_schema_file + end +ensure + $stdout = original_stdout +end + +load_schema + +class SQLSubscriber + attr_reader :logged + attr_reader :payloads + + def initialize + @logged = [] + @payloads = [] + end + + def start(name, id, payload) + @payloads << payload + @logged << [payload[:sql].squish, payload[:name], payload[:binds]] + end + + def finish(name, id, payload); end +end + +module InTimeZone + private + + def in_time_zone(zone) + old_zone = Time.zone + old_tz = ActiveRecord::Base.time_zone_aware_attributes + + Time.zone = zone ? ActiveSupport::TimeZone[zone] : nil + ActiveRecord::Base.time_zone_aware_attributes = !zone.nil? + yield + ensure + Time.zone = old_zone + ActiveRecord::Base.time_zone_aware_attributes = old_tz + end +end + +require 'mocha/setup' # FIXME: stop using mocha diff --git a/activerecord/test/cases/hot_compatibility_test.rb b/activerecord/test/cases/hot_compatibility_test.rb new file mode 100644 index 0000000000..b4617cf6f9 --- /dev/null +++ b/activerecord/test/cases/hot_compatibility_test.rb @@ -0,0 +1,54 @@ +require 'cases/helper' + +class HotCompatibilityTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + setup do + @klass = Class.new(ActiveRecord::Base) do + connection.create_table :hot_compatibilities, force: true do |t| + t.string :foo + t.string :bar + end + + def self.name; 'HotCompatibility'; end + end + end + + teardown do + ActiveRecord::Base.connection.drop_table :hot_compatibilities + end + + test "insert after remove_column" do + # warm cache + @klass.create! + + # we have 3 columns + assert_equal 3, @klass.columns.length + + # remove one of them + @klass.connection.remove_column :hot_compatibilities, :bar + + # we still have 3 columns in the cache + assert_equal 3, @klass.columns.length + + # but we can successfully create a record so long as we don't + # reference the removed column + record = @klass.create! foo: 'foo' + record.reload + assert_equal 'foo', record.foo + end + + test "update after remove_column" do + record = @klass.create! foo: 'foo' + assert_equal 3, @klass.columns.length + @klass.connection.remove_column :hot_compatibilities, :bar + assert_equal 3, @klass.columns.length + + record.reload + assert_equal 'foo', record.foo + record.foo = 'bar' + record.save! + record.reload + assert_equal 'bar', record.foo + end +end diff --git a/activerecord/test/cases/i18n_test.rb b/activerecord/test/cases/i18n_test.rb new file mode 100644 index 0000000000..a428f1d87b --- /dev/null +++ b/activerecord/test/cases/i18n_test.rb @@ -0,0 +1,45 @@ +require "cases/helper" +require 'models/topic' +require 'models/reply' + +class ActiveRecordI18nTests < ActiveRecord::TestCase + + def setup + I18n.backend = I18n::Backend::Simple.new + end + + def test_translated_model_attributes + I18n.backend.store_translations 'en', :activerecord => {:attributes => {:topic => {:title => 'topic title attribute'} } } + assert_equal 'topic title attribute', Topic.human_attribute_name('title') + end + + def test_translated_model_attributes_with_symbols + I18n.backend.store_translations 'en', :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.backend.store_translations 'en', :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.backend.store_translations 'en', :activerecord => {:attributes => {:topic => {:title => 'topic title attribute'} } } + assert_equal 'topic title attribute', Reply.human_attribute_name('title') + end + + def test_translated_model_names + I18n.backend.store_translations 'en', :activerecord => {:models => {:topic => 'topic model'} } + assert_equal 'topic model', Topic.model_name.human + end + + def test_translated_model_names_with_sti + I18n.backend.store_translations 'en', :activerecord => {:models => {:reply => 'reply model'} } + assert_equal 'reply model', Reply.model_name.human + end + + def test_translated_model_names_with_sti_fallback + I18n.backend.store_translations 'en', :activerecord => {:models => {:topic => 'topic model'} } + assert_equal 'topic model', Reply.model_name.human + end +end diff --git a/activerecord/test/cases/inheritance_test.rb b/activerecord/test/cases/inheritance_test.rb new file mode 100644 index 0000000000..792950d24d --- /dev/null +++ b/activerecord/test/cases/inheritance_test.rb @@ -0,0 +1,369 @@ +require 'cases/helper' +require 'models/company' +require 'models/person' +require 'models/post' +require 'models/project' +require 'models/subscriber' +require 'models/vegetables' +require 'models/shop' + +class InheritanceTest < ActiveRecord::TestCase + fixtures :companies, :projects, :subscribers, :accounts, :vegetables + + def test_class_with_store_full_sti_class_returns_full_name + old = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = true + assert_equal 'Namespaced::Company', Namespaced::Company.sti_name + ensure + ActiveRecord::Base.store_full_sti_class = old + end + + def test_class_with_blank_sti_name + company = Company.first + company = company.dup + company.extend(Module.new { + def read_attribute(name) + return ' ' if name == 'type' + super + end + }) + company.save! + company = Company.all.to_a.find { |x| x.id == company.id } + assert_equal ' ', company.type + end + + def test_class_without_store_full_sti_class_returns_demodulized_name + old = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = false + assert_equal 'Company', Namespaced::Company.sti_name + ensure + ActiveRecord::Base.store_full_sti_class = old + end + + def test_should_store_demodulized_class_name_with_store_full_sti_class_option_disabled + old = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = false + item = Namespaced::Company.new + assert_equal 'Company', item[:type] + ensure + ActiveRecord::Base.store_full_sti_class = old + end + + def test_should_store_full_class_name_with_store_full_sti_class_option_enabled + old = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = true + item = Namespaced::Company.new + assert_equal 'Namespaced::Company', item[:type] + ensure + ActiveRecord::Base.store_full_sti_class = old + end + + def test_different_namespace_subclass_should_load_correctly_with_store_full_sti_class_option + old = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = true + item = Namespaced::Company.create :name => "Wolverine 2" + assert_not_nil Company.find(item.id) + assert_not_nil Namespaced::Company.find(item.id) + ensure + ActiveRecord::Base.store_full_sti_class = old + end + + def test_company_descends_from_active_record + assert !ActiveRecord::Base.descends_from_active_record? + assert AbstractCompany.descends_from_active_record?, 'AbstractCompany should descend from ActiveRecord::Base' + assert Company.descends_from_active_record?, 'Company should descend from ActiveRecord::Base' + assert !Class.new(Company).descends_from_active_record?, 'Company subclass should not descend from ActiveRecord::Base' + end + + def test_inheritance_base_class + assert_equal Post, Post.base_class + assert_equal Post, SpecialPost.base_class + assert_equal Post, StiPost.base_class + assert_equal SubStiPost, SubStiPost.base_class + end + + def test_abstract_inheritance_base_class + assert_equal LoosePerson, LoosePerson.base_class + assert_equal LooseDescendant, LooseDescendant.base_class + assert_equal TightPerson, TightPerson.base_class + assert_equal TightPerson, TightDescendant.base_class + end + + def test_base_class_activerecord_error + klass = Class.new { include ActiveRecord::Inheritance } + assert_raise(ActiveRecord::ActiveRecordError) { klass.base_class } + end + + def test_a_bad_type_column + Company.connection.insert "INSERT INTO companies (id, #{QUOTED_TYPE}, name) VALUES(100, 'bad_class!', 'Not happening')" + + assert_raise(ActiveRecord::SubclassNotFound) { Company.find(100) } + end + + def test_inheritance_find + assert_kind_of Firm, Company.find(1), "37signals should be a firm" + assert_kind_of Firm, Firm.find(1), "37signals should be a firm" + assert_kind_of Client, Company.find(2), "Summit should be a client" + assert_kind_of Client, Client.find(2), "Summit should be a client" + end + + def test_alt_inheritance_find + assert_kind_of Cucumber, Vegetable.find(1) + assert_kind_of Cucumber, Cucumber.find(1) + assert_kind_of Cabbage, Vegetable.find(2) + assert_kind_of Cabbage, Cabbage.find(2) + end + + def test_alt_becomes_works_with_sti + vegetable = Vegetable.find(1) + assert_kind_of Vegetable, vegetable + cabbage = vegetable.becomes(Cabbage) + assert_kind_of Cabbage, cabbage + end + + def test_alt_becomes_bang_resets_inheritance_type_column + vegetable = Vegetable.create!(name: "Red Pepper") + assert_nil vegetable.custom_type + + cabbage = vegetable.becomes!(Cabbage) + assert_equal "Cabbage", cabbage.custom_type + + vegetable = cabbage.becomes!(Vegetable) + assert_nil cabbage.custom_type + end + + def test_inheritance_find_all + companies = Company.all.merge!(:order => 'id').to_a + assert_kind_of Firm, companies[0], "37signals should be a firm" + assert_kind_of Client, companies[1], "Summit should be a client" + end + + def test_alt_inheritance_find_all + companies = Vegetable.all.merge!(:order => 'id').to_a + assert_kind_of Cucumber, companies[0] + assert_kind_of Cabbage, companies[1] + end + + def test_inheritance_save + firm = Firm.new + firm.name = "Next Angle" + firm.save + + next_angle = Company.find(firm.id) + assert_kind_of Firm, next_angle, "Next Angle should be a firm" + end + + def test_alt_inheritance_save + cabbage = Cabbage.new(:name => 'Savoy') + cabbage.save! + + savoy = Vegetable.find(cabbage.id) + assert_kind_of Cabbage, savoy + end + + def test_inheritance_new_with_default_class + company = Company.new + assert_equal Company, company.class + end + + def test_inheritance_new_with_base_class + company = Company.new(:type => 'Company') + assert_equal Company, company.class + end + + def test_inheritance_new_with_subclass + firm = Company.new(:type => 'Firm') + assert_equal Firm, firm.class + end + + def test_new_with_abstract_class + e = assert_raises(NotImplementedError) do + AbstractCompany.new + end + assert_equal("AbstractCompany is an abstract class and cannot be instantiated.", e.message) + end + + def test_new_with_ar_base + e = assert_raises(NotImplementedError) do + ActiveRecord::Base.new + end + assert_equal("ActiveRecord::Base is an abstract class and cannot be instantiated.", e.message) + end + + def test_new_with_invalid_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.new(:type => 'InvalidType') } + end + + def test_new_with_unrelated_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.new(:type => 'Account') } + end + + def test_new_with_complex_inheritance + assert_nothing_raised { Client.new(type: 'VerySpecialClient') } + end + + def test_new_with_autoload_paths + path = File.expand_path('../../models/autoloadable', __FILE__) + ActiveSupport::Dependencies.autoload_paths << path + + firm = Company.new(:type => 'ExtraFirm') + assert_equal ExtraFirm, firm.class + ensure + ActiveSupport::Dependencies.autoload_paths.reject! { |p| p == path } + ActiveSupport::Dependencies.clear + end + + def test_inheritance_condition + assert_equal 11, Company.count + assert_equal 2, Firm.count + assert_equal 5, Client.count + end + + def test_alt_inheritance_condition + assert_equal 4, Vegetable.count + assert_equal 1, Cucumber.count + assert_equal 3, Cabbage.count + end + + def test_finding_incorrect_type_data + assert_raise(ActiveRecord::RecordNotFound) { Firm.find(2) } + assert_nothing_raised { Firm.find(1) } + end + + def test_alt_finding_incorrect_type_data + assert_raise(ActiveRecord::RecordNotFound) { Cucumber.find(2) } + assert_nothing_raised { Cucumber.find(1) } + end + + def test_update_all_within_inheritance + Client.update_all "name = 'I am a client'" + assert_equal "I am a client", Client.first.name + # Order by added as otherwise Oracle tests were failing because of different order of results + assert_equal "37signals", Firm.all.merge!(:order => "id").to_a.first.name + end + + def test_alt_update_all_within_inheritance + Cabbage.update_all "name = 'the cabbage'" + assert_equal "the cabbage", Cabbage.first.name + assert_equal ["my cucumber"], Cucumber.all.map(&:name).uniq + end + + def test_destroy_all_within_inheritance + Client.destroy_all + assert_equal 0, Client.count + assert_equal 2, Firm.count + end + + def test_alt_destroy_all_within_inheritance + Cabbage.destroy_all + assert_equal 0, Cabbage.count + assert_equal 1, Cucumber.count + end + + def test_find_first_within_inheritance + assert_kind_of Firm, Company.all.merge!(:where => "name = '37signals'").first + assert_kind_of Firm, Firm.all.merge!(:where => "name = '37signals'").first + assert_nil Client.all.merge!(:where => "name = '37signals'").first + end + + def test_alt_find_first_within_inheritance + assert_kind_of Cabbage, Vegetable.all.merge!(:where => "name = 'his cabbage'").first + assert_kind_of Cabbage, Cabbage.all.merge!(:where => "name = 'his cabbage'").first + assert_nil Cucumber.all.merge!(:where => "name = 'his cabbage'").first + end + + def test_complex_inheritance + very_special_client = VerySpecialClient.create("name" => "veryspecial") + assert_equal very_special_client, VerySpecialClient.where("name = 'veryspecial'").first + assert_equal very_special_client, SpecialClient.all.merge!(:where => "name = 'veryspecial'").first + assert_equal very_special_client, Company.all.merge!(:where => "name = 'veryspecial'").first + assert_equal very_special_client, Client.all.merge!(:where => "name = 'veryspecial'").first + assert_equal 1, Client.all.merge!(:where => "name = 'Summit'").to_a.size + assert_equal very_special_client, Client.find(very_special_client.id) + end + + def test_alt_complex_inheritance + king_cole = KingCole.create("name" => "uniform heads") + assert_equal king_cole, KingCole.where("name = 'uniform heads'").first + assert_equal king_cole, GreenCabbage.all.merge!(:where => "name = 'uniform heads'").first + assert_equal king_cole, Cabbage.all.merge!(:where => "name = 'uniform heads'").first + assert_equal king_cole, Vegetable.all.merge!(:where => "name = 'uniform heads'").first + assert_equal 1, Cabbage.all.merge!(:where => "name = 'his cabbage'").to_a.size + assert_equal king_cole, Cabbage.find(king_cole.id) + end + + def test_eager_load_belongs_to_something_inherited + account = Account.all.merge!(:includes => :firm).find(1) + assert account.association_cache.key?(:firm), "nil proves eager load failed" + end + + def test_alt_eager_loading + cabbage = RedCabbage.all.merge!(:includes => :seller).find(4) + assert cabbage.association_cache.key?(:seller), "nil proves eager load failed" + end + + def test_eager_load_belongs_to_primary_key_quoting + con = Account.connection + assert_sql(/#{con.quote_table_name('companies')}.#{con.quote_column_name('id')} IN \(1\)/) do + Account.all.merge!(:includes => :firm).find(1) + end + end + + def test_inherits_custom_primary_key + assert_equal Subscriber.primary_key, SpecialSubscriber.primary_key + end + + def test_inheritance_without_mapping + assert_kind_of SpecialSubscriber, SpecialSubscriber.find("webster132") + assert_nothing_raised { s = SpecialSubscriber.new("name" => "And breaaaaathe!"); s.id = 'roger'; s.save } + end + + def test_scope_inherited_properly + assert_nothing_raised { Company.of_first_firm } + assert_nothing_raised { Client.of_first_firm } + end +end + +class InheritanceComputeTypeTest < ActiveRecord::TestCase + fixtures :companies + + def setup + ActiveSupport::Dependencies.log_activity = true + end + + teardown do + ActiveSupport::Dependencies.log_activity = false + self.class.const_remove :FirmOnTheFly rescue nil + Firm.const_remove :FirmOnTheFly rescue nil + end + + def test_instantiation_doesnt_try_to_require_corresponding_file + ActiveRecord::Base.store_full_sti_class = false + foo = Firm.first.clone + foo.type = 'FirmOnTheFly' + foo.save! + + # Should fail without FirmOnTheFly in the type condition. + assert_raise(ActiveRecord::RecordNotFound) { Firm.find(foo.id) } + + # Nest FirmOnTheFly in the test case where Dependencies won't see it. + self.class.const_set :FirmOnTheFly, Class.new(Firm) + assert_raise(ActiveRecord::SubclassNotFound) { Firm.find(foo.id) } + + # Nest FirmOnTheFly in Firm where Dependencies will see it. + # This is analogous to nesting models in a migration. + Firm.const_set :FirmOnTheFly, Class.new(Firm) + + # And instantiate will find the existing constant rather than trying + # to require firm_on_the_fly. + assert_nothing_raised { assert_kind_of Firm::FirmOnTheFly, Firm.find(foo.id) } + ensure + ActiveRecord::Base.store_full_sti_class = true + end + + def test_sti_type_from_attributes_disabled_in_non_sti_class + phone = Shop::Product::Type.new(name: 'Phone') + product = Shop::Product.new(:type => phone) + assert product.save + end +end diff --git a/activerecord/test/cases/integration_test.rb b/activerecord/test/cases/integration_test.rb new file mode 100644 index 0000000000..dfb8a608cb --- /dev/null +++ b/activerecord/test/cases/integration_test.rb @@ -0,0 +1,138 @@ +# encoding: utf-8 + +require 'cases/helper' +require 'models/company' +require 'models/developer' +require 'models/owner' +require 'models/pet' + +class IntegrationTest < ActiveRecord::TestCase + fixtures :companies, :developers, :owners, :pets + + def test_to_param_should_return_string + assert_kind_of String, Client.first.to_param + end + + def test_to_param_returns_nil_if_not_persisted + client = Client.new + assert_equal nil, client.to_param + end + + def test_to_param_returns_id_if_not_persisted_but_id_is_set + client = Client.new + client.id = 1 + assert_equal '1', client.to_param + end + + def test_to_param_class_method + firm = Firm.find(4) + assert_equal '4-flamboyant-software', firm.to_param + end + + def test_to_param_class_method_truncates + firm = Firm.find(4) + firm.name = 'a ' * 100 + assert_equal '4-a-a-a-a-a-a-a-a-a', firm.to_param + end + + def test_to_param_class_method_truncates_edge_case + firm = Firm.find(4) + firm.name = 'David HeinemeierHansson' + assert_equal '4-david', firm.to_param + end + + def test_to_param_class_method_squishes + firm = Firm.find(4) + firm.name = "ab \n" * 100 + assert_equal '4-ab-ab-ab-ab-ab-ab', firm.to_param + end + + def test_to_param_class_method_multibyte_character + firm = Firm.find(4) + firm.name = "戦場ヶ原 ひたぎ" + assert_equal '4', firm.to_param + end + + def test_to_param_class_method_uses_default_if_blank + firm = Firm.find(4) + firm.name = nil + assert_equal '4', firm.to_param + firm.name = ' ' + assert_equal '4', firm.to_param + end + + def test_to_param_class_method_uses_default_if_not_persisted + firm = Firm.new(name: 'Fancy Shirts') + assert_equal nil, firm.to_param + end + + def test_to_param_with_no_arguments + assert_equal 'Firm', Firm.to_param + end + + def test_cache_key_for_existing_record_is_not_timezone_dependent + utc_key = Developer.first.cache_key + + with_timezone_config zone: "EST" do + est_key = Developer.first.cache_key + assert_equal utc_key, est_key + end + end + + def test_cache_key_format_for_existing_record_with_updated_at + dev = Developer.first + assert_equal "developers/#{dev.id}-#{dev.updated_at.utc.to_s(:nsec)}", dev.cache_key + end + + def test_cache_key_format_for_existing_record_with_updated_at_and_custom_cache_timestamp_format + dev = CachedDeveloper.first + assert_equal "cached_developers/#{dev.id}-#{dev.updated_at.utc.to_s(:number)}", dev.cache_key + end + + def test_cache_key_changes_when_child_touched + owner = owners(:blackbeard) + pet = pets(:parrot) + + owner.update_column :updated_at, Time.current + key = owner.cache_key + + assert pet.touch + assert_not_equal key, owner.reload.cache_key + end + + def test_cache_key_format_for_existing_record_with_nil_updated_timestamps + dev = Developer.first + dev.update_columns(updated_at: nil, updated_on: nil) + assert_match(/\/#{dev.id}$/, dev.cache_key) + end + + def test_cache_key_for_updated_on + dev = Developer.first + dev.updated_at = nil + assert_equal "developers/#{dev.id}-#{dev.updated_on.utc.to_s(:nsec)}", dev.cache_key + end + + def test_cache_key_for_newer_updated_at + dev = Developer.first + dev.updated_at += 3600 + assert_equal "developers/#{dev.id}-#{dev.updated_at.utc.to_s(:nsec)}", dev.cache_key + end + + def test_cache_key_for_newer_updated_on + dev = Developer.first + dev.updated_on += 3600 + assert_equal "developers/#{dev.id}-#{dev.updated_on.utc.to_s(:nsec)}", dev.cache_key + end + + def test_cache_key_format_is_precise_enough + dev = Developer.first + key = dev.cache_key + dev.touch + assert_not_equal key, dev.cache_key + end + + def test_named_timestamps_for_cache_key + owner = owners(:blackbeard) + assert_equal "owners/#{owner.id}-#{owner.happy_at.utc.to_s(:nsec)}", owner.cache_key(:updated_at, :happy_at) + end +end diff --git a/activerecord/test/cases/invalid_connection_test.rb b/activerecord/test/cases/invalid_connection_test.rb new file mode 100644 index 0000000000..8416c81f45 --- /dev/null +++ b/activerecord/test/cases/invalid_connection_test.rb @@ -0,0 +1,22 @@ +require "cases/helper" + +class TestAdapterWithInvalidConnection < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + class Bird < ActiveRecord::Base + end + + def setup + # Can't just use current adapter; sqlite3 will create a database + # file on the fly. + Bird.establish_connection adapter: 'mysql', database: 'i_do_not_exist' + end + + teardown do + Bird.remove_connection + end + + test "inspect on Model class does not raise" do + assert_equal "#{Bird.name} (call '#{Bird.name}.connection' to establish a connection)", Bird.inspect + end +end diff --git a/activerecord/test/cases/invalid_date_test.rb b/activerecord/test/cases/invalid_date_test.rb new file mode 100644 index 0000000000..426a350379 --- /dev/null +++ b/activerecord/test/cases/invalid_date_test.rb @@ -0,0 +1,32 @@ +require 'cases/helper' +require 'models/topic' + +class InvalidDateTest < ActiveRecord::TestCase + def test_assign_valid_dates + valid_dates = [[2007, 11, 30], [1993, 2, 28], [2008, 2, 29]] + + invalid_dates = [[2007, 11, 31], [1993, 2, 29], [2007, 2, 29]] + + valid_dates.each do |date_src| + topic = Topic.new("last_read(1i)" => date_src[0].to_s, "last_read(2i)" => date_src[1].to_s, "last_read(3i)" => date_src[2].to_s) + # Oracle DATE columns are datetime columns and Oracle adapter returns Time value + if current_adapter?(:OracleAdapter) + assert_equal(topic.last_read.to_date, Date.new(*date_src)) + else + assert_equal(topic.last_read, Date.new(*date_src)) + end + end + + invalid_dates.each do |date_src| + assert_nothing_raised do + topic = Topic.new({"last_read(1i)" => date_src[0].to_s, "last_read(2i)" => date_src[1].to_s, "last_read(3i)" => date_src[2].to_s}) + # Oracle DATE columns are datetime columns and Oracle adapter returns Time value + if current_adapter?(:OracleAdapter) + assert_equal(topic.last_read.to_date, Time.local(*date_src).to_date, "The date should be modified according to the behavior of the Time object") + else + assert_equal(topic.last_read, Time.local(*date_src).to_date, "The date should be modified according to the behavior of the Time object") + end + end + end + end +end diff --git a/activerecord/test/cases/invertible_migration_test.rb b/activerecord/test/cases/invertible_migration_test.rb new file mode 100644 index 0000000000..285172d33e --- /dev/null +++ b/activerecord/test/cases/invertible_migration_test.rb @@ -0,0 +1,290 @@ +require "cases/helper" + +module ActiveRecord + class InvertibleMigrationTest < ActiveRecord::TestCase + class SilentMigration < ActiveRecord::Migration + def write(text = '') + # sssshhhhh!! + end + end + + class InvertibleMigration < SilentMigration + def change + create_table("horses") do |t| + t.column :content, :text + t.column :remind_at, :datetime + end + end + end + + class InvertibleRevertMigration < SilentMigration + def change + revert do + create_table("horses") do |t| + t.column :content, :text + t.column :remind_at, :datetime + end + end + end + end + + class InvertibleByPartsMigration < SilentMigration + attr_writer :test + def change + create_table("new_horses") do |t| + t.column :breed, :string + end + reversible do |dir| + @test.yield :both + dir.up { @test.yield :up } + dir.down { @test.yield :down } + end + revert do + create_table("horses") do |t| + t.column :content, :text + t.column :remind_at, :datetime + end + end + end + end + + class NonInvertibleMigration < SilentMigration + def change + create_table("horses") do |t| + t.column :content, :text + t.column :remind_at, :datetime + end + remove_column "horses", :content + end + end + + class RemoveIndexMigration1 < SilentMigration + def self.up + create_table("horses") do |t| + t.column :name, :string + t.column :color, :string + t.index [:name, :color] + end + end + end + + class RemoveIndexMigration2 < SilentMigration + def change + change_table("horses") do |t| + t.remove_index [:name, :color] + end + end + end + + class LegacyMigration < ActiveRecord::Migration + def self.up + create_table("horses") do |t| + t.column :content, :text + t.column :remind_at, :datetime + end + end + + def self.down + drop_table("horses") + end + end + + class RevertWholeMigration < SilentMigration + def initialize(name = self.class.name, version = nil, migration) + @migration = migration + super(name, version) + end + + def change + revert @migration + end + end + + class NestedRevertWholeMigration < RevertWholeMigration + def change + revert { super } + end + end + + class RevertNamedIndexMigration1 < SilentMigration + def change + create_table("horses") do |t| + t.column :content, :string + t.column :remind_at, :datetime + end + add_index :horses, :content + end + end + + class RevertNamedIndexMigration2 < SilentMigration + def change + add_index :horses, :content, name: "horses_index_named" + end + end + + teardown do + %w[horses new_horses].each do |table| + if ActiveRecord::Base.connection.table_exists?(table) + ActiveRecord::Base.connection.drop_table(table) + end + end + end + + def test_no_reverse + migration = NonInvertibleMigration.new + migration.migrate(:up) + assert_raises(IrreversibleMigration) do + migration.migrate(:down) + end + end + + def test_exception_on_removing_index_without_column_option + RemoveIndexMigration1.new.migrate(:up) + migration = RemoveIndexMigration2.new + migration.migrate(:up) + + assert_raises(IrreversibleMigration) do + migration.migrate(:down) + end + end + + def test_migrate_up + migration = InvertibleMigration.new + migration.migrate(:up) + assert migration.connection.table_exists?("horses"), "horses should exist" + end + + def test_migrate_down + migration = InvertibleMigration.new + migration.migrate :up + migration.migrate :down + assert !migration.connection.table_exists?("horses") + end + + def test_migrate_revert + migration = InvertibleMigration.new + revert = InvertibleRevertMigration.new + migration.migrate :up + revert.migrate :up + assert !migration.connection.table_exists?("horses") + revert.migrate :down + assert migration.connection.table_exists?("horses") + migration.migrate :down + assert !migration.connection.table_exists?("horses") + end + + def test_migrate_revert_by_part + InvertibleMigration.new.migrate :up + received = [] + migration = InvertibleByPartsMigration.new + migration.test = ->(dir){ + assert migration.connection.table_exists?("horses") + assert migration.connection.table_exists?("new_horses") + received << dir + } + migration.migrate :up + assert_equal [:both, :up], received + assert !migration.connection.table_exists?("horses") + assert migration.connection.table_exists?("new_horses") + migration.migrate :down + assert_equal [:both, :up, :both, :down], received + assert migration.connection.table_exists?("horses") + assert !migration.connection.table_exists?("new_horses") + end + + def test_migrate_revert_whole_migration + migration = InvertibleMigration.new + [LegacyMigration, InvertibleMigration].each do |klass| + revert = RevertWholeMigration.new(klass) + migration.migrate :up + revert.migrate :up + assert !migration.connection.table_exists?("horses") + revert.migrate :down + assert migration.connection.table_exists?("horses") + migration.migrate :down + assert !migration.connection.table_exists?("horses") + end + end + + def test_migrate_nested_revert_whole_migration + revert = NestedRevertWholeMigration.new(InvertibleRevertMigration) + revert.migrate :down + assert revert.connection.table_exists?("horses") + revert.migrate :up + assert !revert.connection.table_exists?("horses") + end + + def test_revert_order + block = Proc.new{|t| t.string :name } + recorder = ActiveRecord::Migration::CommandRecorder.new(ActiveRecord::Base.connection) + recorder.instance_eval do + create_table("apples", &block) + revert do + create_table("bananas", &block) + revert do + create_table("clementines") + create_table("dates") + end + create_table("elderberries") + end + revert do + create_table("figs") + create_table("grapes") + end + end + assert_equal [[:create_table, ["apples"], block], [:drop_table, ["elderberries"], nil], + [:create_table, ["clementines"], nil], [:create_table, ["dates"], nil], + [:drop_table, ["bananas"], block], [:drop_table, ["grapes"], nil], + [:drop_table, ["figs"], nil]], recorder.commands + end + + def test_legacy_up + LegacyMigration.migrate :up + assert ActiveRecord::Base.connection.table_exists?("horses"), "horses should exist" + end + + def test_legacy_down + LegacyMigration.migrate :up + LegacyMigration.migrate :down + assert !ActiveRecord::Base.connection.table_exists?("horses"), "horses should not exist" + end + + def test_up + LegacyMigration.up + assert ActiveRecord::Base.connection.table_exists?("horses"), "horses should exist" + end + + def test_down + LegacyMigration.up + LegacyMigration.down + assert !ActiveRecord::Base.connection.table_exists?("horses"), "horses should not exist" + end + + def test_migrate_down_with_table_name_prefix + ActiveRecord::Base.table_name_prefix = 'p_' + ActiveRecord::Base.table_name_suffix = '_s' + migration = InvertibleMigration.new + migration.migrate(:up) + assert_nothing_raised { migration.migrate(:down) } + assert !ActiveRecord::Base.connection.table_exists?("p_horses_s"), "p_horses_s should not exist" + ensure + ActiveRecord::Base.table_name_prefix = ActiveRecord::Base.table_name_suffix = '' + end + + # MySQL 5.7 and Oracle do not allow to create duplicate indexes on the same columns + unless current_adapter?(:MysqlAdapter, :Mysql2Adapter, :OracleAdapter) + def test_migrate_revert_add_index_with_name + RevertNamedIndexMigration1.new.migrate(:up) + RevertNamedIndexMigration2.new.migrate(:up) + RevertNamedIndexMigration2.new.migrate(:down) + + connection = ActiveRecord::Base.connection + assert connection.index_exists?(:horses, :content), + "index on content should exist" + assert !connection.index_exists?(:horses, :content, name: "horses_index_named"), + "horses_index_named index should not exist" + end + end + + end +end diff --git a/activerecord/test/cases/json_serialization_test.rb b/activerecord/test/cases/json_serialization_test.rb new file mode 100644 index 0000000000..a222675918 --- /dev/null +++ b/activerecord/test/cases/json_serialization_test.rb @@ -0,0 +1,300 @@ +require "cases/helper" +require 'models/contact' +require 'models/post' +require 'models/author' +require 'models/tagging' +require 'models/tag' +require 'models/comment' + +module JsonSerializationHelpers + private + + def set_include_root_in_json(value) + original_root_in_json = ActiveRecord::Base.include_root_in_json + ActiveRecord::Base.include_root_in_json = value + yield + ensure + ActiveRecord::Base.include_root_in_json = original_root_in_json + end +end + +class JsonSerializationTest < ActiveRecord::TestCase + include JsonSerializationHelpers + + class NamespacedContact < Contact + column :name, :string + end + + def setup + @contact = Contact.new( + :name => 'Konata Izumi', + :age => 16, + :avatar => 'binarydata', + :created_at => Time.utc(2006, 8, 1), + :awesome => true, + :preferences => { :shows => 'anime' } + ) + end + + def test_should_demodulize_root_in_json + set_include_root_in_json(true) do + @contact = NamespacedContact.new name: 'whatever' + json = @contact.to_json + assert_match %r{^\{"namespaced_contact":\{}, json + end + end + + def test_should_include_root_in_json + set_include_root_in_json(true) do + json = @contact.to_json + + assert_match %r{^\{"contact":\{}, json + assert_match %r{"name":"Konata Izumi"}, json + assert_match %r{"age":16}, json + assert json.include?(%("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})) + assert_match %r{"awesome":true}, json + assert_match %r{"preferences":\{"shows":"anime"\}}, json + end + end + + def test_should_encode_all_encodable_attributes + json = @contact.to_json + + assert_match %r{"name":"Konata Izumi"}, json + assert_match %r{"age":16}, json + assert json.include?(%("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})) + assert_match %r{"awesome":true}, json + assert_match %r{"preferences":\{"shows":"anime"\}}, json + end + + def test_should_allow_attribute_filtering_with_only + json = @contact.to_json(:only => [:name, :age]) + + assert_match %r{"name":"Konata Izumi"}, json + assert_match %r{"age":16}, json + assert_no_match %r{"awesome":true}, json + assert !json.include?(%("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})) + assert_no_match %r{"preferences":\{"shows":"anime"\}}, json + end + + def test_should_allow_attribute_filtering_with_except + json = @contact.to_json(:except => [:name, :age]) + + assert_no_match %r{"name":"Konata Izumi"}, json + assert_no_match %r{"age":16}, json + assert_match %r{"awesome":true}, json + assert json.include?(%("created_at":#{ActiveSupport::JSON.encode(Time.utc(2006, 8, 1))})) + assert_match %r{"preferences":\{"shows":"anime"\}}, json + end + + def test_methods_are_called_on_object + # Define methods on fixture. + def @contact.label; "Has cheezburger"; end + def @contact.favorite_quote; "Constraints are liberating"; end + + # Single method. + assert_match %r{"label":"Has cheezburger"}, @contact.to_json(:only => :name, :methods => :label) + + # Both methods. + methods_json = @contact.to_json(:only => :name, :methods => [:label, :favorite_quote]) + assert_match %r{"label":"Has cheezburger"}, methods_json + assert_match %r{"favorite_quote":"Constraints are liberating"}, methods_json + end + + def test_uses_serializable_hash_with_only_option + def @contact.serializable_hash(options=nil) + super(only: %w(name)) + end + + json = @contact.to_json + assert_match %r{"name":"Konata Izumi"}, json + assert_no_match %r{awesome}, json + assert_no_match %r{age}, json + end + + def test_uses_serializable_hash_with_except_option + def @contact.serializable_hash(options=nil) + super(except: %w(age)) + end + + json = @contact.to_json + assert_match %r{"name":"Konata Izumi"}, json + assert_match %r{"awesome":true}, json + assert_no_match %r{age}, json + end + + def test_does_not_include_inheritance_column_from_sti + @contact = ContactSti.new(@contact.attributes) + assert_equal 'ContactSti', @contact.type + + json = @contact.to_json + assert_match %r{"name":"Konata Izumi"}, json + assert_no_match %r{type}, json + assert_no_match %r{ContactSti}, json + end + + def test_serializable_hash_with_default_except_option_and_excluding_inheritance_column_from_sti + @contact = ContactSti.new(@contact.attributes) + assert_equal 'ContactSti', @contact.type + + def @contact.serializable_hash(options={}) + super({ except: %w(age) }.merge!(options)) + end + + json = @contact.to_json + assert_match %r{"name":"Konata Izumi"}, json + assert_no_match %r{age}, json + assert_no_match %r{type}, json + assert_no_match %r{ContactSti}, json + end + + def test_serializable_hash_should_not_modify_options_in_argument + options = { :only => :name } + @contact.serializable_hash(options) + + assert_nil options[:except] + end +end + +class DatabaseConnectedJsonEncodingTest < ActiveRecord::TestCase + fixtures :authors, :posts, :comments, :tags, :taggings + + include JsonSerializationHelpers + + def setup + @david = authors(:david) + @mary = authors(:mary) + end + + def test_includes_uses_association_name + json = @david.to_json(:include => :posts) + + assert_match %r{"posts":\[}, json + + assert_match %r{"id":1}, json + assert_match %r{"name":"David"}, json + + assert_match %r{"author_id":1}, json + assert_match %r{"title":"Welcome to the weblog"}, json + assert_match %r{"body":"Such a lovely day"}, json + + assert_match %r{"title":"So I was thinking"}, json + assert_match %r{"body":"Like I hopefully always am"}, json + end + + def test_includes_uses_association_name_and_applies_attribute_filters + json = @david.to_json(:include => { :posts => { :only => :title } }) + + assert_match %r{"name":"David"}, json + assert_match %r{"posts":\[}, json + + assert_match %r{"title":"Welcome to the weblog"}, json + assert_no_match %r{"body":"Such a lovely day"}, json + + assert_match %r{"title":"So I was thinking"}, json + assert_no_match %r{"body":"Like I hopefully always am"}, json + end + + def test_includes_fetches_second_level_associations + json = @david.to_json(:include => { :posts => { :include => { :comments => { :only => :body } } } }) + + assert_match %r{"name":"David"}, json + assert_match %r{"posts":\[}, json + + assert_match %r{"comments":\[}, json + assert_match %r{\{"body":"Thank you again for the welcome"\}}, json + assert_match %r{\{"body":"Don't think too hard"\}}, json + assert_no_match %r{"post_id":}, json + end + + def test_includes_fetches_nth_level_associations + json = @david.to_json( + :include => { + :posts => { + :include => { + :taggings => { + :include => { + :tag => { :only => :name } + } + } + } + } + }) + + assert_match %r{"name":"David"}, json + assert_match %r{"posts":\[}, json + + assert_match %r{"taggings":\[}, json + assert_match %r{"tag":\{"name":"General"\}}, json + end + + def test_includes_doesnt_merge_opts_from_base + json = @david.to_json( + :only => :id, + :include => :posts + ) + + assert_match %{"title":"Welcome to the weblog"}, json + end + + def test_should_not_call_methods_on_associations_that_dont_respond + def @david.favorite_quote; "Constraints are liberating"; end + json = @david.to_json(:include => :posts, :methods => :favorite_quote) + + assert !@david.posts.first.respond_to?(:favorite_quote) + assert_match %r{"favorite_quote":"Constraints are liberating"}, json + assert_equal %r{"favorite_quote":}.match(json).size, 1 + end + + def test_should_allow_only_option_for_list_of_authors + set_include_root_in_json(false) do + authors = [@david, @mary] + assert_equal %([{"name":"David"},{"name":"Mary"}]), ActiveSupport::JSON.encode(authors, only: :name) + end + end + + def test_should_allow_except_option_for_list_of_authors + set_include_root_in_json(false) do + authors = [@david, @mary] + encoded = ActiveSupport::JSON.encode(authors, except: [ + :name, :author_address_id, :author_address_extra_id, + :organization_id, :owned_essay_id + ]) + assert_equal %([{"id":1},{"id":2}]), encoded + end + end + + def test_should_allow_includes_for_list_of_authors + authors = [@david, @mary] + json = ActiveSupport::JSON.encode(authors, + :only => :name, + :include => { + :posts => { :only => :id } + } + ) + + ['"name":"David"', '"posts":[', '{"id":1}', '{"id":2}', '{"id":4}', + '{"id":5}', '{"id":6}', '"name":"Mary"', '"posts":[', '{"id":7}', '{"id":9}'].each do |fragment| + assert json.include?(fragment), json + end + end + + def test_should_allow_options_for_hash_of_authors + set_include_root_in_json(true) do + authors_hash = { + 1 => @david, + 2 => @mary + } + assert_equal %({"1":{"author":{"name":"David"}}}), ActiveSupport::JSON.encode(authors_hash, only: [1, :name]) + end + end + + def test_should_be_able_to_encode_relation + set_include_root_in_json(true) do + authors_relation = Author.where(id: [@david.id, @mary.id]) + + json = ActiveSupport::JSON.encode authors_relation, only: :name + assert_equal '[{"author":{"name":"David"}},{"author":{"name":"Mary"}}]', json + end + end +end diff --git a/activerecord/test/cases/locking_test.rb b/activerecord/test/cases/locking_test.rb new file mode 100644 index 0000000000..0c9dff2c25 --- /dev/null +++ b/activerecord/test/cases/locking_test.rb @@ -0,0 +1,482 @@ +require 'thread' +require "cases/helper" +require 'models/person' +require 'models/job' +require 'models/reader' +require 'models/ship' +require 'models/legacy_thing' +require 'models/reference' +require 'models/string_key_object' +require 'models/car' +require 'models/bulb' +require 'models/engine' +require 'models/wheel' +require 'models/treasure' + +class LockWithoutDefault < ActiveRecord::Base; end + +class LockWithCustomColumnWithoutDefault < ActiveRecord::Base + self.table_name = :lock_without_defaults_cust + self.column_defaults # to test @column_defaults caching. + self.locking_column = :custom_lock_version +end + +class ReadonlyNameShip < Ship + attr_readonly :name +end + +class OptimisticLockingTest < ActiveRecord::TestCase + fixtures :people, :legacy_things, :references, :string_key_objects, :peoples_treasures + + def test_quote_value_passed_lock_col + p1 = Person.find(1) + assert_equal 0, p1.lock_version + + Person.expects(:quote_value).with(0, Person.columns_hash[Person.locking_column]).returns('0').once + + p1.first_name = 'anika2' + p1.save! + + assert_equal 1, p1.lock_version + end + + def test_non_integer_lock_existing + s1 = StringKeyObject.find("record1") + s2 = StringKeyObject.find("record1") + assert_equal 0, s1.lock_version + assert_equal 0, s2.lock_version + + s1.name = 'updated record' + s1.save! + assert_equal 1, s1.lock_version + assert_equal 0, s2.lock_version + + s2.name = 'doubly updated record' + assert_raise(ActiveRecord::StaleObjectError) { s2.save! } + end + + def test_non_integer_lock_destroy + s1 = StringKeyObject.find("record1") + s2 = StringKeyObject.find("record1") + assert_equal 0, s1.lock_version + assert_equal 0, s2.lock_version + + s1.name = 'updated record' + s1.save! + assert_equal 1, s1.lock_version + assert_equal 0, s2.lock_version + assert_raise(ActiveRecord::StaleObjectError) { s2.destroy } + + assert s1.destroy + assert s1.frozen? + assert s1.destroyed? + assert_raises(ActiveRecord::RecordNotFound) { StringKeyObject.find("record1") } + end + + def test_lock_existing + p1 = Person.find(1) + p2 = Person.find(1) + assert_equal 0, p1.lock_version + assert_equal 0, p2.lock_version + + p1.first_name = 'stu' + p1.save! + assert_equal 1, p1.lock_version + assert_equal 0, p2.lock_version + + p2.first_name = 'sue' + assert_raise(ActiveRecord::StaleObjectError) { p2.save! } + end + + # See Lighthouse ticket #1966 + def test_lock_destroy + p1 = Person.find(1) + p2 = Person.find(1) + assert_equal 0, p1.lock_version + assert_equal 0, p2.lock_version + + p1.first_name = 'stu' + p1.save! + assert_equal 1, p1.lock_version + assert_equal 0, p2.lock_version + + assert_raises(ActiveRecord::StaleObjectError) { p2.destroy } + + assert p1.destroy + assert p1.frozen? + assert p1.destroyed? + assert_raises(ActiveRecord::RecordNotFound) { Person.find(1) } + end + + def test_lock_repeating + p1 = Person.find(1) + p2 = Person.find(1) + assert_equal 0, p1.lock_version + assert_equal 0, p2.lock_version + + p1.first_name = 'stu' + p1.save! + assert_equal 1, p1.lock_version + assert_equal 0, p2.lock_version + + p2.first_name = 'sue' + assert_raise(ActiveRecord::StaleObjectError) { p2.save! } + p2.first_name = 'sue2' + assert_raise(ActiveRecord::StaleObjectError) { p2.save! } + end + + def test_lock_new + p1 = Person.new(:first_name => 'anika') + assert_equal 0, p1.lock_version + + p1.first_name = 'anika2' + p1.save! + p2 = Person.find(p1.id) + assert_equal 0, p1.lock_version + assert_equal 0, p2.lock_version + + p1.first_name = 'anika3' + p1.save! + assert_equal 1, p1.lock_version + assert_equal 0, p2.lock_version + + p2.first_name = 'sue' + assert_raise(ActiveRecord::StaleObjectError) { p2.save! } + end + + def test_lock_exception_record + p1 = Person.new(:first_name => 'mira') + assert_equal 0, p1.lock_version + + p1.first_name = 'mira2' + p1.save! + p2 = Person.find(p1.id) + assert_equal 0, p1.lock_version + assert_equal 0, p2.lock_version + + p1.first_name = 'mira3' + p1.save! + + p2.first_name = 'sue' + error = assert_raise(ActiveRecord::StaleObjectError) { p2.save! } + assert_equal(error.record.object_id, p2.object_id) + end + + def test_lock_new_with_nil + p1 = Person.new(:first_name => 'anika') + p1.save! + p1.lock_version = nil # simulate bad fixture or column with no default + p1.save! + assert_equal 1, p1.lock_version + end + + def test_touch_existing_lock + p1 = Person.find(1) + assert_equal 0, p1.lock_version + + p1.touch + assert_equal 1, p1.lock_version + end + + def test_lock_column_name_existing + t1 = LegacyThing.find(1) + t2 = LegacyThing.find(1) + assert_equal 0, t1.version + assert_equal 0, t2.version + + t1.tps_report_number = 700 + t1.save! + assert_equal 1, t1.version + assert_equal 0, t2.version + + t2.tps_report_number = 800 + assert_raise(ActiveRecord::StaleObjectError) { t2.save! } + end + + def test_lock_column_is_mass_assignable + p1 = Person.create(:first_name => 'bianca') + assert_equal 0, p1.lock_version + assert_equal p1.lock_version, Person.new(p1.attributes).lock_version + + p1.first_name = 'bianca2' + p1.save! + assert_equal 1, p1.lock_version + assert_equal p1.lock_version, Person.new(p1.attributes).lock_version + end + + def test_lock_without_default_sets_version_to_zero + t1 = LockWithoutDefault.new + assert_equal 0, t1.lock_version + + t1.save + t1 = LockWithoutDefault.find(t1.id) + assert_equal 0, t1.lock_version + end + + def test_lock_with_custom_column_without_default_sets_version_to_zero + t1 = LockWithCustomColumnWithoutDefault.new + assert_equal 0, t1.custom_lock_version + + t1.save + t1 = LockWithCustomColumnWithoutDefault.find(t1.id) + assert_equal 0, t1.custom_lock_version + end + + def test_readonly_attributes + assert_equal Set.new([ 'name' ]), ReadonlyNameShip.readonly_attributes + + s = ReadonlyNameShip.create(:name => "unchangeable name") + s.reload + assert_equal "unchangeable name", s.name + + s.update(name: "changed name") + s.reload + assert_equal "unchangeable name", s.name + end + + def test_quote_table_name + ref = references(:michael_magician) + ref.favourite = !ref.favourite + assert ref.save + end + + # Useful for partial updates, don't only update the lock_version if there + # is nothing else being updated. + def test_update_without_attributes_does_not_only_update_lock_version + assert_nothing_raised do + p1 = Person.create!(:first_name => 'anika') + lock_version = p1.lock_version + p1.save + p1.reload + assert_equal lock_version, p1.lock_version + end + end + + def test_polymorphic_destroy_with_dependencies_and_lock_version + car = Car.create! + + assert_difference 'car.wheels.count' do + car.wheels << Wheel.create! + end + assert_difference 'car.wheels.count', -1 do + car.destroy + end + assert car.destroyed? + end + + def test_removing_has_and_belongs_to_many_associations_upon_destroy + p = RichPerson.create! first_name: 'Jon' + p.treasures.create! + assert !p.treasures.empty? + p.destroy + assert p.treasures.empty? + assert RichPerson.connection.select_all("SELECT * FROM peoples_treasures WHERE rich_person_id = 1").empty? + end + + def test_yaml_dumping_with_lock_column + t1 = LockWithoutDefault.new + t2 = YAML.load(YAML.dump(t1)) + + assert_equal t1.attributes, t2.attributes + end +end + +class OptimisticLockingWithSchemaChangeTest < ActiveRecord::TestCase + fixtures :people, :legacy_things, :references + + # need to disable transactional fixtures, because otherwise the sqlite3 + # adapter (at least) chokes when we try and change the schema in the middle + # of a test (see test_increment_counter_*). + self.use_transactional_fixtures = false + + { :lock_version => Person, :custom_lock_version => LegacyThing }.each do |name, model| + define_method("test_increment_counter_updates_#{name}") do + counter_test model, 1 do |id| + model.increment_counter :test_count, id + end + end + + define_method("test_decrement_counter_updates_#{name}") do + counter_test model, -1 do |id| + model.decrement_counter :test_count, id + end + end + + define_method("test_update_counters_updates_#{name}") do + counter_test model, 1 do |id| + model.update_counters id, :test_count => 1 + end + end + end + + # See Lighthouse ticket #1966 + def test_destroy_dependents + # Establish dependent relationship between People and LegacyThing + add_counter_column_to(Person, 'legacy_things_count') + LegacyThing.connection.add_column LegacyThing.table_name, 'person_id', :integer + LegacyThing.reset_column_information + LegacyThing.class_eval do + belongs_to :person, :counter_cache => true + end + Person.class_eval do + has_many :legacy_things, :dependent => :destroy + end + + # Make sure that counter incrementing doesn't cause problems + p1 = Person.new(:first_name => 'fjord') + p1.save! + t = LegacyThing.new(:person => p1) + t.save! + p1.reload + assert_equal 1, p1.legacy_things_count + assert p1.destroy + assert_equal true, p1.frozen? + assert_raises(ActiveRecord::RecordNotFound) { Person.find(p1.id) } + assert_raises(ActiveRecord::RecordNotFound) { LegacyThing.find(t.id) } + ensure + remove_counter_column_from(Person, 'legacy_things_count') + end + + private + + def add_counter_column_to(model, col='test_count') + model.connection.add_column model.table_name, col, :integer, :null => false, :default => 0 + model.reset_column_information + end + + def remove_counter_column_from(model, col = :test_count) + model.connection.remove_column model.table_name, col + model.reset_column_information + end + + def counter_test(model, expected_count) + add_counter_column_to(model) + object = model.first + assert_equal 0, object.test_count + assert_equal 0, object.send(model.locking_column) + yield object.id + object.reload + assert_equal expected_count, object.test_count + assert_equal 1, object.send(model.locking_column) + ensure + remove_counter_column_from(model) + end +end + + +# TODO: test against the generated SQL since testing locking behavior itself +# is so cumbersome. Will deadlock Ruby threads if the underlying db.execute +# blocks, so separate script called by Kernel#system is needed. +# (See exec vs. async_exec in the PostgreSQL adapter.) +unless in_memory_db? + class PessimisticLockingTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + fixtures :people, :readers + + def setup + Person.connection_pool.clear_reloadable_connections! + # Avoid introspection queries during tests. + Person.columns; Reader.columns + end + + # Test typical find. + def test_sane_find_with_lock + assert_nothing_raised do + Person.transaction do + Person.lock.find(1) + end + end + end + + # PostgreSQL protests SELECT ... FOR UPDATE on an outer join. + unless current_adapter?(:PostgreSQLAdapter) + # Test locked eager find. + def test_eager_find_with_lock + assert_nothing_raised do + Person.transaction do + Person.includes(:readers).lock.find(1) + end + end + end + end + + # Locking a record reloads it. + def test_sane_lock_method + assert_nothing_raised do + Person.transaction do + person = Person.find 1 + old, person.first_name = person.first_name, 'fooman' + person.lock! + assert_equal old, person.first_name + end + end + end + + def test_with_lock_commits_transaction + person = Person.find 1 + person.with_lock do + person.first_name = 'fooman' + person.save! + end + assert_equal 'fooman', person.reload.first_name + end + + def test_with_lock_rolls_back_transaction + person = Person.find 1 + old = person.first_name + person.with_lock do + person.first_name = 'fooman' + person.save! + raise 'oops' + end rescue nil + assert_equal old, person.reload.first_name + end + + if current_adapter?(:PostgreSQLAdapter) + def test_lock_sending_custom_lock_statement + Person.transaction do + person = Person.find(1) + assert_sql(/LIMIT 1 FOR SHARE NOWAIT/) do + person.lock!('FOR SHARE NOWAIT') + end + end + end + end + + if current_adapter?(:PostgreSQLAdapter, :OracleAdapter) + def test_no_locks_no_wait + first, second = duel { Person.find 1 } + assert first.end > second.end + end + + protected + def duel(zzz = 5) + t0, t1, t2, t3 = nil, nil, nil, nil + + a = Thread.new do + t0 = Time.now + Person.transaction do + yield + sleep zzz # block thread 2 for zzz seconds + end + t1 = Time.now + end + + b = Thread.new do + sleep zzz / 2.0 # ensure thread 1 tx starts first + t2 = Time.now + Person.transaction { yield } + t3 = Time.now + end + + a.join + b.join + + assert t1 > t0 + zzz + assert t2 > t0 + assert t3 > t2 + [t0.to_f..t1.to_f, t2.to_f..t3.to_f] + end + end + end +end diff --git a/activerecord/test/cases/log_subscriber_test.rb b/activerecord/test/cases/log_subscriber_test.rb new file mode 100644 index 0000000000..a578e81844 --- /dev/null +++ b/activerecord/test/cases/log_subscriber_test.rb @@ -0,0 +1,136 @@ +require "cases/helper" +require "models/binary" +require "models/developer" +require "models/post" +require "active_support/log_subscriber/test_helper" + +class LogSubscriberTest < ActiveRecord::TestCase + include ActiveSupport::LogSubscriber::TestHelper + include ActiveSupport::Logger::Severity + + class TestDebugLogSubscriber < ActiveRecord::LogSubscriber + attr_reader :debugs + + def initialize + @debugs = [] + super + end + + def debug message + @debugs << message + end + end + + fixtures :posts + + def setup + @old_logger = ActiveRecord::Base.logger + Developer.primary_key + super + ActiveRecord::LogSubscriber.attach_to(:active_record) + end + + def teardown + super + ActiveRecord::LogSubscriber.log_subscribers.pop + ActiveRecord::Base.logger = @old_logger + end + + def set_logger(logger) + ActiveRecord::Base.logger = logger + end + + def test_schema_statements_are_ignored + event = Struct.new(:duration, :payload) + + logger = TestDebugLogSubscriber.new + assert_equal 0, logger.debugs.length + + logger.sql(event.new(0, sql: 'hi mom!')) + assert_equal 1, logger.debugs.length + + logger.sql(event.new(0, sql: 'hi mom!', name: 'foo')) + assert_equal 2, logger.debugs.length + + logger.sql(event.new(0, sql: 'hi mom!', name: 'SCHEMA')) + assert_equal 2, logger.debugs.length + end + + def test_sql_statements_are_not_squeezed + event = Struct.new(:duration, :payload) + logger = TestDebugLogSubscriber.new + logger.sql(event.new(0, sql: 'ruby rails')) + assert_match(/ruby rails/, logger.debugs.first) + end + + def test_ignore_binds_payload_with_nil_column + event = Struct.new(:duration, :payload) + + logger = TestDebugLogSubscriber.new + logger.sql(event.new(0, sql: 'hi mom!', binds: [[nil, 1]])) + assert_equal 1, logger.debugs.length + end + + def test_basic_query_logging + Developer.all.load + wait + assert_equal 1, @logger.logged(:debug).size + assert_match(/Developer Load/, @logger.logged(:debug).last) + assert_match(/SELECT .*?FROM .?developers.?/i, @logger.logged(:debug).last) + end + + def test_exists_query_logging + Developer.exists? 1 + wait + assert_equal 1, @logger.logged(:debug).size + assert_match(/Developer Exists/, @logger.logged(:debug).last) + assert_match(/SELECT .*?FROM .?developers.?/i, @logger.logged(:debug).last) + end + + def test_cached_queries + ActiveRecord::Base.cache do + Developer.all.load + Developer.all.load + end + wait + assert_equal 2, @logger.logged(:debug).size + assert_match(/CACHE/, @logger.logged(:debug).last) + assert_match(/SELECT .*?FROM .?developers.?/i, @logger.logged(:debug).last) + end + + def test_basic_query_doesnt_log_when_level_is_not_debug + @logger.level = INFO + Developer.all.load + wait + assert_equal 0, @logger.logged(:debug).size + end + + def test_cached_queries_doesnt_log_when_level_is_not_debug + @logger.level = INFO + ActiveRecord::Base.cache do + Developer.all.load + Developer.all.load + end + wait + assert_equal 0, @logger.logged(:debug).size + end + + def test_initializes_runtime + Thread.new { assert_equal 0, ActiveRecord::LogSubscriber.runtime }.join + end + + unless current_adapter?(:Mysql2Adapter) + def test_binary_data_is_not_logged + Binary.create(data: 'some binary data') + wait + assert_match(/<16 bytes of binary data>/, @logger.logged(:debug).join) + end + + def test_nil_binary_data_is_logged + binary = Binary.create(data: "") + binary.update_attributes(data: nil) + wait + assert_match(/<NULL binary data>/, @logger.logged(:debug).join) + end + end +end diff --git a/activerecord/test/cases/migration/change_schema_test.rb b/activerecord/test/cases/migration/change_schema_test.rb new file mode 100644 index 0000000000..c66eaf1ee1 --- /dev/null +++ b/activerecord/test/cases/migration/change_schema_test.rb @@ -0,0 +1,397 @@ +require 'cases/helper' + +module ActiveRecord + class Migration + class ChangeSchemaTest < ActiveRecord::TestCase + attr_reader :connection, :table_name + + def setup + super + @connection = ActiveRecord::Base.connection + @table_name = :testings + end + + teardown do + connection.drop_table :testings rescue nil + ActiveRecord::Base.primary_key_prefix_type = nil + ActiveRecord::Base.clear_cache! + end + + def test_create_table_without_id + testing_table_with_only_foo_attribute do + assert_equal connection.columns(:testings).size, 1 + end + end + + def test_add_column_with_primary_key_attribute + testing_table_with_only_foo_attribute do + connection.add_column :testings, :id, :primary_key + assert_equal connection.columns(:testings).size, 2 + end + end + + def test_create_table_adds_id + connection.create_table :testings do |t| + t.column :foo, :string + end + + assert_equal %w(id foo), connection.columns(:testings).map(&:name) + end + + def test_create_table_with_not_null_column + connection.create_table :testings do |t| + t.column :foo, :string, :null => false + end + + assert_raises(ActiveRecord::StatementInvalid) do + connection.execute "insert into testings (foo) values (NULL)" + end + end + + def test_create_table_with_defaults + # MySQL doesn't allow defaults on TEXT or BLOB columns. + mysql = current_adapter?(:MysqlAdapter, :Mysql2Adapter) + + connection.create_table :testings do |t| + t.column :one, :string, :default => "hello" + t.column :two, :boolean, :default => true + t.column :three, :boolean, :default => false + t.column :four, :integer, :default => 1 + t.column :five, :text, :default => "hello" unless mysql + end + + columns = connection.columns(:testings) + one = columns.detect { |c| c.name == "one" } + two = columns.detect { |c| c.name == "two" } + three = columns.detect { |c| c.name == "three" } + four = columns.detect { |c| c.name == "four" } + five = columns.detect { |c| c.name == "five" } unless mysql + + assert_equal "hello", one.default + assert_equal true, two.type_cast_from_database(two.default) + assert_equal false, three.type_cast_from_database(three.default) + assert_equal '1', four.default + assert_equal "hello", five.default unless mysql + end + + if current_adapter?(:PostgreSQLAdapter) + def test_add_column_with_array + connection.create_table :testings + connection.add_column :testings, :foo, :string, :array => true + + columns = connection.columns(:testings) + array_column = columns.detect { |c| c.name == "foo" } + + assert array_column.array + end + + def test_create_table_with_array_column + connection.create_table :testings do |t| + t.string :foo, :array => true + end + + columns = connection.columns(:testings) + array_column = columns.detect { |c| c.name == "foo" } + + assert array_column.array + end + end + + def test_create_table_with_limits + connection.create_table :testings do |t| + t.column :foo, :string, :limit => 255 + + t.column :default_int, :integer + + t.column :one_int, :integer, :limit => 1 + t.column :four_int, :integer, :limit => 4 + t.column :eight_int, :integer, :limit => 8 + end + + columns = connection.columns(:testings) + foo = columns.detect { |c| c.name == "foo" } + assert_equal 255, foo.limit + + default = columns.detect { |c| c.name == "default_int" } + one = columns.detect { |c| c.name == "one_int" } + four = columns.detect { |c| c.name == "four_int" } + eight = columns.detect { |c| c.name == "eight_int" } + + if current_adapter?(:PostgreSQLAdapter) + assert_equal 'integer', default.sql_type + assert_equal 'smallint', one.sql_type + assert_equal 'integer', four.sql_type + assert_equal 'bigint', eight.sql_type + elsif current_adapter?(:MysqlAdapter, :Mysql2Adapter) + assert_match 'int(11)', default.sql_type + assert_match 'tinyint', one.sql_type + assert_match 'int', four.sql_type + assert_match 'bigint', eight.sql_type + elsif current_adapter?(:OracleAdapter) + assert_equal 'NUMBER(38)', default.sql_type + assert_equal 'NUMBER(1)', one.sql_type + assert_equal 'NUMBER(4)', four.sql_type + assert_equal 'NUMBER(8)', eight.sql_type + end + end + + def test_create_table_with_primary_key_prefix_as_table_name_with_underscore + ActiveRecord::Base.primary_key_prefix_type = :table_name_with_underscore + + connection.create_table :testings do |t| + t.column :foo, :string + end + + assert_equal %w(testing_id foo), connection.columns(:testings).map(&:name) + end + + def test_create_table_with_primary_key_prefix_as_table_name + ActiveRecord::Base.primary_key_prefix_type = :table_name + + connection.create_table :testings do |t| + t.column :foo, :string + end + + assert_equal %w(testingid foo), connection.columns(:testings).map(&:name) + end + + def test_create_table_raises_when_redefining_primary_key_column + error = assert_raise(ArgumentError) do + connection.create_table :testings do |t| + t.column :id, :string + end + end + + assert_equal "you can't redefine the primary key column 'id'. To define a custom primary key, pass { id: false } to create_table.", error.message + end + + def test_create_table_raises_when_redefining_custom_primary_key_column + error = assert_raise(ArgumentError) do + connection.create_table :testings, primary_key: :testing_id do |t| + t.column :testing_id, :string + end + end + + assert_equal "you can't redefine the primary key column 'testing_id'. To define a custom primary key, pass { id: false } to create_table.", error.message + end + + def test_create_table_with_timestamps_should_create_datetime_columns + connection.create_table table_name do |t| + t.timestamps + end + created_columns = 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 + end + + def test_create_table_with_timestamps_should_create_datetime_columns_with_options + connection.create_table table_name do |t| + t.timestamps :null => false + end + created_columns = 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 + end + + def test_create_table_without_a_block + connection.create_table table_name + end + + # SQLite3 will not allow you to add a NOT NULL + # column to a table without a default value. + unless current_adapter?(:SQLite3Adapter) + def test_add_column_not_null_without_default + connection.create_table :testings do |t| + t.column :foo, :string + end + connection.add_column :testings, :bar, :string, :null => false + + assert_raise(ActiveRecord::StatementInvalid) do + connection.execute "insert into testings (foo, bar) values ('hello', NULL)" + end + end + end + + def test_add_column_not_null_with_default + connection.create_table :testings do |t| + t.column :foo, :string + end + + con = connection + connection.execute "insert into testings (#{con.quote_column_name('id')}, #{con.quote_column_name('foo')}) values (1, 'hello')" + assert_nothing_raised {connection.add_column :testings, :bar, :string, :null => false, :default => "default" } + + assert_raises(ActiveRecord::StatementInvalid) do + connection.execute "insert into testings (#{con.quote_column_name('id')}, #{con.quote_column_name('foo')}, #{con.quote_column_name('bar')}) values (2, 'hello', NULL)" + end + end + + def test_add_column_with_timestamp_type + connection.create_table :testings do |t| + t.column :foo, :timestamp + end + + klass = Class.new(ActiveRecord::Base) + klass.table_name = 'testings' + + assert_equal :datetime, klass.columns_hash['foo'].type + + if current_adapter?(:PostgreSQLAdapter) + assert_equal 'timestamp without time zone', klass.columns_hash['foo'].sql_type + else + assert_equal klass.connection.type_to_sql('datetime'), klass.columns_hash['foo'].sql_type + end + end + + def test_change_column_quotes_column_names + connection.create_table :testings do |t| + t.column :select, :string + end + + connection.change_column :testings, :select, :string, :limit => 10 + + # Oracle needs primary key value from sequence + if current_adapter?(:OracleAdapter) + connection.execute "insert into testings (id, #{connection.quote_column_name('select')}) values (testings_seq.nextval, '7 chars')" + else + connection.execute "insert into testings (#{connection.quote_column_name('select')}) values ('7 chars')" + end + end + + def test_keeping_default_and_notnull_constraints_on_change + connection.create_table :testings do |t| + t.column :title, :string + end + person_klass = Class.new(ActiveRecord::Base) + person_klass.table_name = 'testings' + + person_klass.connection.add_column "testings", "wealth", :integer, :null => false, :default => 99 + person_klass.reset_column_information + assert_equal 99, person_klass.column_defaults["wealth"] + assert_equal false, person_klass.columns_hash["wealth"].null + # Oracle needs primary key value from sequence + if current_adapter?(:OracleAdapter) + assert_nothing_raised {person_klass.connection.execute("insert into testings (id, title) values (testings_seq.nextval, 'tester')")} + else + assert_nothing_raised {person_klass.connection.execute("insert into testings (title) values ('tester')")} + end + + # change column default to see that column doesn't lose its not null definition + person_klass.connection.change_column_default "testings", "wealth", 100 + person_klass.reset_column_information + assert_equal 100, person_klass.column_defaults["wealth"] + assert_equal false, person_klass.columns_hash["wealth"].null + + # rename column to see that column doesn't lose its not null and/or default definition + person_klass.connection.rename_column "testings", "wealth", "money" + person_klass.reset_column_information + assert_nil person_klass.columns_hash["wealth"] + assert_equal 100, person_klass.column_defaults["money"] + assert_equal false, person_klass.columns_hash["money"].null + + # change column + person_klass.connection.change_column "testings", "money", :integer, :null => false, :default => 1000 + person_klass.reset_column_information + assert_equal 1000, person_klass.column_defaults["money"] + assert_equal false, person_klass.columns_hash["money"].null + + # change column, make it nullable and clear default + person_klass.connection.change_column "testings", "money", :integer, :null => true, :default => nil + person_klass.reset_column_information + assert_nil person_klass.columns_hash["money"].default + assert_equal true, person_klass.columns_hash["money"].null + + # change_column_null, make it not nullable and set null values to a default value + person_klass.connection.execute('UPDATE testings SET money = NULL') + person_klass.connection.change_column_null "testings", "money", false, 2000 + person_klass.reset_column_information + assert_nil person_klass.columns_hash["money"].default + assert_equal false, person_klass.columns_hash["money"].null + assert_equal 2000, connection.select_values("SELECT money FROM testings").first.to_i + end + + def test_change_column_null + testing_table_with_only_foo_attribute do + notnull_migration = Class.new(ActiveRecord::Migration) do + def change + change_column_null :testings, :foo, false + end + end + notnull_migration.new.suppress_messages do + notnull_migration.migrate(:up) + assert_equal false, connection.columns(:testings).find{ |c| c.name == "foo"}.null + notnull_migration.migrate(:down) + assert connection.columns(:testings).find{ |c| c.name == "foo"}.null + end + end + end + + def test_column_exists + connection.create_table :testings do |t| + t.column :foo, :string + end + + assert connection.column_exists?(:testings, :foo) + assert_not connection.column_exists?(:testings, :bar) + end + + def test_column_exists_with_type + connection.create_table :testings do |t| + t.column :foo, :string + t.column :bar, :decimal, :precision => 8, :scale => 2 + end + + assert connection.column_exists?(:testings, :foo, :string) + assert_not connection.column_exists?(:testings, :foo, :integer) + + assert connection.column_exists?(:testings, :bar, :decimal) + assert_not connection.column_exists?(:testings, :bar, :integer) + end + + def test_column_exists_with_definition + connection.create_table :testings do |t| + t.column :foo, :string, limit: 100 + t.column :bar, :decimal, precision: 8, scale: 2 + t.column :taggable_id, :integer, null: false + t.column :taggable_type, :string, default: 'Photo' + end + + assert connection.column_exists?(:testings, :foo, :string, limit: 100) + assert_not connection.column_exists?(:testings, :foo, :string, limit: nil) + assert connection.column_exists?(:testings, :bar, :decimal, precision: 8, scale: 2) + assert_not connection.column_exists?(:testings, :bar, :decimal, precision: nil, scale: nil) + assert connection.column_exists?(:testings, :taggable_id, :integer, null: false) + assert_not connection.column_exists?(:testings, :taggable_id, :integer, null: true) + assert connection.column_exists?(:testings, :taggable_type, :string, default: 'Photo') + assert_not connection.column_exists?(:testings, :taggable_type, :string, default: nil) + end + + def test_column_exists_on_table_with_no_options_parameter_supplied + connection.create_table :testings do |t| + t.string :foo + end + connection.change_table :testings do |t| + assert t.column_exists?(:foo) + assert !(t.column_exists?(:bar)) + end + end + + private + def testing_table_with_only_foo_attribute + connection.create_table :testings, :id => false do |t| + t.column :foo, :string + end + + yield + end + end + end +end diff --git a/activerecord/test/cases/migration/change_table_test.rb b/activerecord/test/cases/migration/change_table_test.rb new file mode 100644 index 0000000000..3e9d957ed3 --- /dev/null +++ b/activerecord/test/cases/migration/change_table_test.rb @@ -0,0 +1,218 @@ +require "cases/migration/helper" +require "minitest/mock" + +module ActiveRecord + class Migration + class TableTest < ActiveRecord::TestCase + def setup + @connection = Minitest::Mock.new + end + + teardown do + assert @connection.verify + end + + def with_change_table + yield ConnectionAdapters::Table.new(:delete_me, @connection) + end + + def test_references_column_type_adds_id + with_change_table do |t| + @connection.expect :add_reference, nil, [:delete_me, :customer, {}] + t.references :customer + end + end + + def test_remove_references_column_type_removes_id + with_change_table do |t| + @connection.expect :remove_reference, nil, [:delete_me, :customer, {}] + t.remove_references :customer + end + end + + def test_add_belongs_to_works_like_add_references + with_change_table do |t| + @connection.expect :add_reference, nil, [:delete_me, :customer, {}] + t.belongs_to :customer + end + end + + def test_remove_belongs_to_works_like_remove_references + with_change_table do |t| + @connection.expect :remove_reference, nil, [:delete_me, :customer, {}] + t.remove_belongs_to :customer + end + end + + def test_references_column_type_with_polymorphic_adds_type + with_change_table do |t| + @connection.expect :add_reference, nil, [:delete_me, :taggable, polymorphic: true] + t.references :taggable, polymorphic: true + end + end + + def test_remove_references_column_type_with_polymorphic_removes_type + with_change_table do |t| + @connection.expect :remove_reference, nil, [:delete_me, :taggable, polymorphic: true] + t.remove_references :taggable, polymorphic: true + end + end + + def test_references_column_type_with_polymorphic_and_options_null_is_false_adds_table_flag + with_change_table do |t| + @connection.expect :add_reference, nil, [:delete_me, :taggable, polymorphic: true, null: false] + t.references :taggable, polymorphic: true, null: false + end + end + + def test_remove_references_column_type_with_polymorphic_and_options_null_is_false_removes_table_flag + with_change_table do |t| + @connection.expect :remove_reference, nil, [:delete_me, :taggable, polymorphic: true, null: false] + t.remove_references :taggable, polymorphic: true, null: false + end + end + + def test_references_column_type_with_polymorphic_and_type + with_change_table do |t| + @connection.expect :add_reference, nil, [:delete_me, :taggable, polymorphic: true, type: :string] + t.references :taggable, polymorphic: true, type: :string + end + end + + def test_remove_references_column_type_with_polymorphic_and_type + with_change_table do |t| + @connection.expect :remove_reference, nil, [:delete_me, :taggable, polymorphic: true, type: :string] + t.remove_references :taggable, polymorphic: true, type: :string + end + end + + def test_timestamps_creates_updated_at_and_created_at + with_change_table do |t| + @connection.expect :add_timestamps, nil, [:delete_me] + t.timestamps + end + end + + def test_remove_timestamps_creates_updated_at_and_created_at + with_change_table do |t| + @connection.expect :remove_timestamps, nil, [:delete_me] + t.remove_timestamps + end + end + + def test_integer_creates_integer_column + with_change_table do |t| + @connection.expect :add_column, nil, [:delete_me, :foo, :integer, {}] + @connection.expect :add_column, nil, [:delete_me, :bar, :integer, {}] + t.integer :foo, :bar + end + end + + def test_string_creates_string_column + with_change_table do |t| + @connection.expect :add_column, nil, [:delete_me, :foo, :string, {}] + @connection.expect :add_column, nil, [:delete_me, :bar, :string, {}] + t.string :foo, :bar + end + end + + def test_column_creates_column + with_change_table do |t| + @connection.expect :add_column, nil, [:delete_me, :bar, :integer, {}] + t.column :bar, :integer + end + end + + def test_column_creates_column_with_options + with_change_table do |t| + @connection.expect :add_column, nil, [:delete_me, :bar, :integer, {:null => false}] + t.column :bar, :integer, :null => false + end + end + + def test_index_creates_index + with_change_table do |t| + @connection.expect :add_index, nil, [:delete_me, :bar, {}] + t.index :bar + end + end + + def test_index_creates_index_with_options + with_change_table do |t| + @connection.expect :add_index, nil, [:delete_me, :bar, {:unique => true}] + t.index :bar, :unique => true + end + end + + def test_index_exists + with_change_table do |t| + @connection.expect :index_exists?, nil, [:delete_me, :bar, {}] + t.index_exists?(:bar) + end + end + + def test_index_exists_with_options + with_change_table do |t| + @connection.expect :index_exists?, nil, [:delete_me, :bar, {:unique => true}] + t.index_exists?(:bar, :unique => true) + end + end + + def test_rename_index_renames_index + with_change_table do |t| + @connection.expect :rename_index, nil, [:delete_me, :bar, :baz] + t.rename_index :bar, :baz + end + end + + def test_change_changes_column + with_change_table do |t| + @connection.expect :change_column, nil, [:delete_me, :bar, :string, {}] + t.change :bar, :string + end + end + + def test_change_changes_column_with_options + with_change_table do |t| + @connection.expect :change_column, nil, [:delete_me, :bar, :string, {:null => true}] + t.change :bar, :string, :null => true + end + end + + def test_change_default_changes_column + with_change_table do |t| + @connection.expect :change_column_default, nil, [:delete_me, :bar, :string] + t.change_default :bar, :string + end + end + + def test_remove_drops_single_column + with_change_table do |t| + @connection.expect :remove_columns, nil, [:delete_me, :bar] + t.remove :bar + end + end + + def test_remove_drops_multiple_columns + with_change_table do |t| + @connection.expect :remove_columns, nil, [:delete_me, :bar, :baz] + t.remove :bar, :baz + end + end + + def test_remove_index_removes_index_with_options + with_change_table do |t| + @connection.expect :remove_index, nil, [:delete_me, {:unique => true}] + t.remove_index :unique => true + end + end + + def test_rename_renames_column + with_change_table do |t| + @connection.expect :rename_column, nil, [:delete_me, :bar, :baz] + t.rename :bar, :baz + end + end + end + end +end diff --git a/activerecord/test/cases/migration/column_attributes_test.rb b/activerecord/test/cases/migration/column_attributes_test.rb new file mode 100644 index 0000000000..763aa88f72 --- /dev/null +++ b/activerecord/test/cases/migration/column_attributes_test.rb @@ -0,0 +1,185 @@ +require "cases/migration/helper" + +module ActiveRecord + class Migration + class ColumnAttributesTest < ActiveRecord::TestCase + include ActiveRecord::Migration::TestHelper + + self.use_transactional_fixtures = false + + def test_add_column_newline_default + string = "foo\nbar" + add_column 'test_models', 'command', :string, :default => string + TestModel.reset_column_information + + assert_equal string, TestModel.new.command + end + + def test_add_remove_single_field_using_string_arguments + assert_no_column TestModel, :last_name + + add_column 'test_models', 'last_name', :string + assert_column TestModel, :last_name + + remove_column 'test_models', 'last_name' + assert_no_column TestModel, :last_name + end + + def test_add_remove_single_field_using_symbol_arguments + assert_no_column TestModel, :last_name + + add_column :test_models, :last_name, :string + assert_column TestModel, :last_name + + remove_column :test_models, :last_name + assert_no_column TestModel, :last_name + end + + def test_add_column_without_limit + # TODO: limit: nil should work with all adapters. + skip "MySQL wrongly enforces a limit of 255" if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + add_column :test_models, :description, :string, limit: nil + TestModel.reset_column_information + assert_nil TestModel.columns_hash["description"].limit + end + + if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + def test_unabstracted_database_dependent_types + add_column :test_models, :intelligence_quotient, :tinyint + TestModel.reset_column_information + assert_match(/tinyint/, TestModel.columns_hash['intelligence_quotient'].sql_type) + end + end + + unless current_adapter?(:SQLite3Adapter) + # We specifically do a manual INSERT here, and then test only the SELECT + # functionality. This allows us to more easily catch INSERT being broken, + # but SELECT actually working fine. + def test_native_decimal_insert_manual_vs_automatic + correct_value = '0012345678901234567890.0123456789'.to_d + + connection.add_column "test_models", "wealth", :decimal, :precision => '30', :scale => '10' + + # Do a manual insertion + if current_adapter?(:OracleAdapter) + connection.execute "insert into test_models (id, wealth) values (people_seq.nextval, 12345678901234567890.0123456789)" + elsif current_adapter?(:MysqlAdapter) && Mysql.client_version < 50003 #before MySQL 5.0.3 decimals stored as strings + connection.execute "insert into test_models (wealth) values ('12345678901234567890.0123456789')" + elsif current_adapter?(:PostgreSQLAdapter) + connection.execute "insert into test_models (wealth) values (12345678901234567890.0123456789)" + else + connection.execute "insert into test_models (wealth) values (12345678901234567890.0123456789)" + end + + # SELECT + row = TestModel.first + assert_kind_of BigDecimal, row.wealth + + # If this assert fails, that means the SELECT is broken! + unless current_adapter?(:SQLite3Adapter) + assert_equal correct_value, row.wealth + end + + # Reset to old state + TestModel.delete_all + + # Now use the Rails insertion + TestModel.create :wealth => BigDecimal.new("12345678901234567890.0123456789") + + # SELECT + row = TestModel.first + assert_kind_of BigDecimal, row.wealth + + # If these asserts fail, that means the INSERT (create function, or cast to SQL) is broken! + assert_equal correct_value, row.wealth + end + end + + def test_add_column_with_precision_and_scale + connection.add_column 'test_models', 'wealth', :decimal, :precision => 9, :scale => 7 + + wealth_column = TestModel.columns_hash['wealth'] + assert_equal 9, wealth_column.precision + assert_equal 7, wealth_column.scale + end + + if current_adapter?(:SQLite3Adapter) + def test_change_column_preserve_other_column_precision_and_scale + connection.add_column 'test_models', 'last_name', :string + connection.add_column 'test_models', 'wealth', :decimal, :precision => 9, :scale => 7 + + wealth_column = TestModel.columns_hash['wealth'] + assert_equal 9, wealth_column.precision + assert_equal 7, wealth_column.scale + + connection.change_column 'test_models', 'last_name', :string, :null => false + TestModel.reset_column_information + + wealth_column = TestModel.columns_hash['wealth'] + assert_equal 9, wealth_column.precision + assert_equal 7, wealth_column.scale + end + end + + unless current_adapter?(:SQLite3Adapter) + def test_native_types + add_column "test_models", "first_name", :string + add_column "test_models", "last_name", :string + add_column "test_models", "bio", :text + add_column "test_models", "age", :integer + add_column "test_models", "height", :float + add_column "test_models", "wealth", :decimal, :precision => '30', :scale => '10' + add_column "test_models", "birthday", :datetime + add_column "test_models", "favorite_day", :date + add_column "test_models", "moment_of_truth", :datetime + add_column "test_models", "male", :boolean + + TestModel.create :first_name => 'bob', :last_name => 'bobsen', + :bio => "I was born ....", :age => 18, :height => 1.78, + :wealth => BigDecimal.new("12345678901234567890.0123456789"), + :birthday => 18.years.ago, :favorite_day => 10.days.ago, + :moment_of_truth => "1782-10-10 21:40:18", :male => true + + bob = TestModel.first + assert_equal 'bob', bob.first_name + assert_equal 'bobsen', bob.last_name + assert_equal "I was born ....", bob.bio + assert_equal 18, bob.age + + # Test for 30 significant digits (beyond the 16 of float), 10 of them + # after the decimal place. + + assert_equal BigDecimal.new("0012345678901234567890.0123456789"), bob.wealth + + assert_equal true, bob.male? + + assert_equal String, bob.first_name.class + assert_equal String, bob.last_name.class + assert_equal String, bob.bio.class + assert_equal Fixnum, bob.age.class + assert_equal Time, bob.birthday.class + + if current_adapter?(:OracleAdapter) + # Oracle doesn't differentiate between date/time + assert_equal Time, bob.favorite_day.class + else + assert_equal Date, bob.favorite_day.class + end + + assert_instance_of TrueClass, bob.male? + assert_kind_of BigDecimal, bob.wealth + end + end + + if current_adapter?(:MysqlAdapter, :Mysql2Adapter, :PostgreSQLAdapter) + def test_out_of_range_limit_should_raise + assert_raise(ActiveRecordError) { add_column :test_models, :integer_too_big, :integer, :limit => 10 } + + unless current_adapter?(:PostgreSQLAdapter) + assert_raise(ActiveRecordError) { add_column :test_models, :text_too_big, :integer, :limit => 0xfffffffff } + end + end + end + end + end +end diff --git a/activerecord/test/cases/migration/column_positioning_test.rb b/activerecord/test/cases/migration/column_positioning_test.rb new file mode 100644 index 0000000000..77a752f050 --- /dev/null +++ b/activerecord/test/cases/migration/column_positioning_test.rb @@ -0,0 +1,56 @@ +require 'cases/helper' + +module ActiveRecord + class Migration + class ColumnPositioningTest < ActiveRecord::TestCase + attr_reader :connection, :table_name + alias :conn :connection + + def setup + super + + @connection = ActiveRecord::Base.connection + + connection.create_table :testings, :id => false do |t| + t.column :first, :integer + t.column :second, :integer + t.column :third, :integer + end + end + + teardown do + connection.drop_table :testings rescue nil + ActiveRecord::Base.primary_key_prefix_type = nil + end + + if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + def test_column_positioning + assert_equal %w(first second third), conn.columns(:testings).map {|c| c.name } + end + + def test_add_column_with_positioning + conn.add_column :testings, :new_col, :integer + assert_equal %w(first second third new_col), conn.columns(:testings).map {|c| c.name } + end + + def test_add_column_with_positioning_first + conn.add_column :testings, :new_col, :integer, :first => true + assert_equal %w(new_col first second third), conn.columns(:testings).map {|c| c.name } + end + + def test_add_column_with_positioning_after + conn.add_column :testings, :new_col, :integer, :after => :first + assert_equal %w(first new_col second third), conn.columns(:testings).map {|c| c.name } + end + + def test_change_column_with_positioning + conn.change_column :testings, :second, :integer, :first => true + assert_equal %w(second first third), conn.columns(:testings).map {|c| c.name } + + conn.change_column :testings, :second, :integer, :after => :third + assert_equal %w(first third second), conn.columns(:testings).map {|c| c.name } + end + end + end + end +end diff --git a/activerecord/test/cases/migration/columns_test.rb b/activerecord/test/cases/migration/columns_test.rb new file mode 100644 index 0000000000..4e6d7963aa --- /dev/null +++ b/activerecord/test/cases/migration/columns_test.rb @@ -0,0 +1,296 @@ +require "cases/migration/helper" + +module ActiveRecord + class Migration + class ColumnsTest < ActiveRecord::TestCase + include ActiveRecord::Migration::TestHelper + + self.use_transactional_fixtures = false + + # FIXME: this is more of an integration test with AR::Base and the + # schema modifications. Maybe we should move this? + def test_add_rename + add_column "test_models", "girlfriend", :string + TestModel.reset_column_information + + TestModel.create :girlfriend => 'bobette' + + rename_column "test_models", "girlfriend", "exgirlfriend" + + TestModel.reset_column_information + bob = TestModel.first + + assert_equal "bobette", bob.exgirlfriend + end + + # FIXME: another integration test. We should decouple this from the + # AR::Base implementation. + def test_rename_column_using_symbol_arguments + add_column :test_models, :first_name, :string + + TestModel.create :first_name => 'foo' + + rename_column :test_models, :first_name, :nick_name + TestModel.reset_column_information + assert TestModel.column_names.include?("nick_name") + assert_equal ['foo'], TestModel.all.map(&:nick_name) + end + + # FIXME: another integration test. We should decouple this from the + # AR::Base implementation. + def test_rename_column + add_column "test_models", "first_name", "string" + + TestModel.create :first_name => 'foo' + + rename_column "test_models", "first_name", "nick_name" + TestModel.reset_column_information + assert TestModel.column_names.include?("nick_name") + assert_equal ['foo'], TestModel.all.map(&:nick_name) + end + + def test_rename_column_preserves_default_value_not_null + add_column 'test_models', 'salary', :integer, :default => 70000 + + default_before = connection.columns("test_models").find { |c| c.name == "salary" }.default + assert_equal '70000', default_before + + rename_column "test_models", "salary", "annual_salary" + + assert TestModel.column_names.include?("annual_salary") + default_after = connection.columns("test_models").find { |c| c.name == "annual_salary" }.default + assert_equal '70000', default_after + end + + if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + def test_mysql_rename_column_preserves_auto_increment + rename_column "test_models", "id", "id_test" + assert_equal "auto_increment", connection.columns("test_models").find { |c| c.name == "id_test" }.extra + end + end + + def test_rename_nonexistent_column + exception = if current_adapter?(:PostgreSQLAdapter, :OracleAdapter) + ActiveRecord::StatementInvalid + else + ActiveRecord::ActiveRecordError + end + assert_raise(exception) do + rename_column "test_models", "nonexistent", "should_fail" + end + end + + def test_rename_column_with_sql_reserved_word + add_column 'test_models', 'first_name', :string + rename_column "test_models", "first_name", "group" + + assert TestModel.column_names.include?("group") + end + + def test_rename_column_with_an_index + add_column "test_models", :hat_name, :string + add_index :test_models, :hat_name + + assert_equal 1, connection.indexes('test_models').size + rename_column "test_models", "hat_name", "name" + + assert_equal ['index_test_models_on_name'], connection.indexes('test_models').map(&:name) + end + + def test_rename_column_with_multi_column_index + add_column "test_models", :hat_size, :integer + add_column "test_models", :hat_style, :string, limit: 100 + add_index "test_models", ["hat_style", "hat_size"], unique: true + + rename_column "test_models", "hat_size", 'size' + if current_adapter? :OracleAdapter + assert_equal ['i_test_models_hat_style_size'], connection.indexes('test_models').map(&:name) + else + assert_equal ['index_test_models_on_hat_style_and_size'], connection.indexes('test_models').map(&:name) + end + + rename_column "test_models", "hat_style", 'style' + if current_adapter? :OracleAdapter + assert_equal ['i_test_models_style_size'], connection.indexes('test_models').map(&:name) + else + assert_equal ['index_test_models_on_style_and_size'], connection.indexes('test_models').map(&:name) + end + end + + def test_rename_column_does_not_rename_custom_named_index + add_column "test_models", :hat_name, :string + add_index :test_models, :hat_name, :name => 'idx_hat_name' + + assert_equal 1, connection.indexes('test_models').size + rename_column "test_models", "hat_name", "name" + assert_equal ['idx_hat_name'], connection.indexes('test_models').map(&:name) + end + + def test_remove_column_with_index + add_column "test_models", :hat_name, :string + add_index :test_models, :hat_name + + assert_equal 1, connection.indexes('test_models').size + remove_column("test_models", "hat_name") + assert_equal 0, connection.indexes('test_models').size + end + + def test_remove_column_with_multi_column_index + add_column "test_models", :hat_size, :integer + add_column "test_models", :hat_style, :string, :limit => 100 + add_index "test_models", ["hat_style", "hat_size"], :unique => true + + assert_equal 1, connection.indexes('test_models').size + remove_column("test_models", "hat_size") + + # Every database and/or database adapter has their own behavior + # if it drops the multi-column index when any of the indexed columns dropped by remove_column. + if current_adapter?(:PostgreSQLAdapter, :OracleAdapter) + assert_equal [], connection.indexes('test_models').map(&:name) + else + assert_equal ['index_test_models_on_hat_style_and_hat_size'], connection.indexes('test_models').map(&:name) + end + end + + def test_change_type_of_not_null_column + change_column "test_models", "updated_at", :datetime, :null => false + change_column "test_models", "updated_at", :datetime, :null => false + + TestModel.reset_column_information + assert_equal false, TestModel.columns_hash['updated_at'].null + ensure + change_column "test_models", "updated_at", :datetime, :null => true + end + + def test_change_column_nullability + add_column "test_models", "funny", :boolean + assert TestModel.columns_hash["funny"].null, "Column 'funny' must initially allow nulls" + + change_column "test_models", "funny", :boolean, :null => false, :default => true + + TestModel.reset_column_information + assert_not TestModel.columns_hash["funny"].null, "Column 'funny' must *not* allow nulls at this point" + + change_column "test_models", "funny", :boolean, :null => true + TestModel.reset_column_information + assert TestModel.columns_hash["funny"].null, "Column 'funny' must allow nulls again at this point" + end + + def test_change_column + add_column 'test_models', 'age', :integer + add_column 'test_models', 'approved', :boolean, :default => true + + old_columns = connection.columns(TestModel.table_name) + + assert old_columns.find { |c| c.name == 'age' && c.type == :integer } + + change_column "test_models", "age", :string + + new_columns = connection.columns(TestModel.table_name) + + assert_not new_columns.find { |c| c.name == 'age' and c.type == :integer } + assert new_columns.find { |c| c.name == 'age' and c.type == :string } + + old_columns = connection.columns(TestModel.table_name) + assert old_columns.find { |c| + default = c.type_cast_from_database(c.default) + c.name == 'approved' && c.type == :boolean && default == true + } + + change_column :test_models, :approved, :boolean, :default => false + new_columns = connection.columns(TestModel.table_name) + + assert_not new_columns.find { |c| + default = c.type_cast_from_database(c.default) + c.name == 'approved' and c.type == :boolean and default == true + } + assert new_columns.find { |c| + default = c.type_cast_from_database(c.default) + c.name == 'approved' and c.type == :boolean and default == false + } + change_column :test_models, :approved, :boolean, :default => true + end + + def test_change_column_with_nil_default + add_column "test_models", "contributor", :boolean, :default => true + assert TestModel.new.contributor? + + change_column "test_models", "contributor", :boolean, :default => nil + TestModel.reset_column_information + assert_not TestModel.new.contributor? + assert_nil TestModel.new.contributor + end + + def test_change_column_with_new_default + add_column "test_models", "administrator", :boolean, :default => true + assert TestModel.new.administrator? + + change_column "test_models", "administrator", :boolean, :default => false + TestModel.reset_column_information + assert_not TestModel.new.administrator? + end + + def test_change_column_with_custom_index_name + add_column "test_models", "category", :string + add_index :test_models, :category, name: 'test_models_categories_idx' + + assert_equal ['test_models_categories_idx'], connection.indexes('test_models').map(&:name) + change_column "test_models", "category", :string, null: false, default: 'article' + + assert_equal ['test_models_categories_idx'], connection.indexes('test_models').map(&:name) + end + + def test_change_column_with_long_index_name + table_name_prefix = 'test_models_' + long_index_name = table_name_prefix + ('x' * (connection.allowed_index_name_length - table_name_prefix.length)) + add_column "test_models", "category", :string + add_index :test_models, :category, name: long_index_name + + change_column "test_models", "category", :string, null: false, default: 'article' + + assert_equal [long_index_name], connection.indexes('test_models').map(&:name) + end + + def test_change_column_default + add_column "test_models", "first_name", :string + connection.change_column_default "test_models", "first_name", "Tester" + + assert_equal "Tester", TestModel.new.first_name + end + + def test_change_column_default_to_null + add_column "test_models", "first_name", :string + connection.change_column_default "test_models", "first_name", nil + assert_nil TestModel.new.first_name + end + + def test_remove_column_no_second_parameter_raises_exception + assert_raise(ArgumentError) { connection.remove_column("funny") } + end + + def test_removing_and_renaming_column_preserves_custom_primary_key + connection.create_table "my_table", primary_key: "my_table_id", force: true do |t| + t.integer "col_one" + t.string "col_two", limit: 128, null: false + end + + remove_column("my_table", "col_two") + rename_column("my_table", "col_one", "col_three") + + assert_equal 'my_table_id', connection.primary_key('my_table') + ensure + connection.drop_table(:my_table) rescue nil + end + + def test_column_with_index + connection.create_table "my_table", force: true do |t| + t.string :item_number, index: true + end + + assert connection.index_exists?("my_table", :item_number, name: :index_my_table_on_item_number) + ensure + connection.drop_table(:my_table) rescue nil + end + end + end +end diff --git a/activerecord/test/cases/migration/command_recorder_test.rb b/activerecord/test/cases/migration/command_recorder_test.rb new file mode 100644 index 0000000000..e955beae1a --- /dev/null +++ b/activerecord/test/cases/migration/command_recorder_test.rb @@ -0,0 +1,300 @@ +require "cases/helper" + +module ActiveRecord + class Migration + class CommandRecorderTest < ActiveRecord::TestCase + def setup + connection = ActiveRecord::Base.connection + @recorder = CommandRecorder.new(connection) + end + + def test_respond_to_delegates + recorder = CommandRecorder.new(Class.new { + def america; end + }.new) + assert recorder.respond_to?(:america) + end + + def test_send_calls_super + assert_raises(NoMethodError) do + @recorder.send(:non_existing_method, :horses) + end + end + + def test_send_delegates_to_record + recorder = CommandRecorder.new(Class.new { + def create_table(name); end + }.new) + assert recorder.respond_to?(:create_table), 'respond_to? create_table' + recorder.send(:create_table, :horses) + assert_equal [[:create_table, [:horses], nil]], recorder.commands + end + + def test_unknown_commands_delegate + recorder = CommandRecorder.new(stub(:foo => 'bar')) + assert_equal 'bar', recorder.foo + end + + def test_inverse_of_raise_exception_on_unknown_commands + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.inverse_of :execute, ['some sql'] + end + end + + def test_irreversible_commands_raise_exception + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.revert{ @recorder.execute 'some sql' } + end + end + + def test_record + @recorder.record :create_table, [:system_settings] + assert_equal 1, @recorder.commands.length + end + + def test_inverted_commands_are_reversed + @recorder.revert do + @recorder.record :create_table, [:hello] + @recorder.record :create_table, [:world] + end + tables = @recorder.commands.map{|_cmd, args, _block| args} + assert_equal [[:world], [:hello]], tables + end + + def test_revert_order + block = Proc.new{|t| t.string :name } + @recorder.instance_eval do + create_table("apples", &block) + revert do + create_table("bananas", &block) + revert do + create_table("clementines", &block) + create_table("dates") + end + create_table("elderberries") + end + revert do + create_table("figs", &block) + create_table("grapes") + end + end + assert_equal [[:create_table, ["apples"], block], [:drop_table, ["elderberries"], nil], + [:create_table, ["clementines"], block], [:create_table, ["dates"], nil], + [:drop_table, ["bananas"], block], [:drop_table, ["grapes"], nil], + [:drop_table, ["figs"], block]], @recorder.commands + end + + def test_invert_change_table + @recorder.revert do + @recorder.change_table :fruits do |t| + t.string :name + t.rename :kind, :cultivar + end + end + assert_equal [ + [:rename_column, [:fruits, :cultivar, :kind]], + [:remove_column, [:fruits, :name, :string, {}], nil], + ], @recorder.commands + + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.revert do + @recorder.change_table :fruits do |t| + t.remove :kind + end + end + end + end + + def test_invert_create_table + @recorder.revert do + @recorder.record :create_table, [:system_settings] + end + drop_table = @recorder.commands.first + assert_equal [:drop_table, [:system_settings], nil], drop_table + end + + def test_invert_create_table_with_options_and_block + block = Proc.new{} + drop_table = @recorder.inverse_of :create_table, [:people_reminders, id: false], &block + assert_equal [:drop_table, [:people_reminders, id: false], block], drop_table + end + + def test_invert_drop_table + block = Proc.new{} + create_table = @recorder.inverse_of :drop_table, [:people_reminders, id: false], &block + assert_equal [:create_table, [:people_reminders, id: false], block], create_table + end + + def test_invert_drop_table_without_a_block_nor_option + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.inverse_of :drop_table, [:people_reminders] + end + end + + def test_invert_create_join_table + drop_join_table = @recorder.inverse_of :create_join_table, [:musics, :artists] + assert_equal [:drop_join_table, [:musics, :artists], nil], drop_join_table + end + + def test_invert_create_join_table_with_table_name + drop_join_table = @recorder.inverse_of :create_join_table, [:musics, :artists, table_name: :catalog] + assert_equal [:drop_join_table, [:musics, :artists, table_name: :catalog], nil], drop_join_table + end + + def test_invert_drop_join_table + block = Proc.new{} + create_join_table = @recorder.inverse_of :drop_join_table, [:musics, :artists, table_name: :catalog], &block + assert_equal [:create_join_table, [:musics, :artists, table_name: :catalog], block], create_join_table + end + + def test_invert_rename_table + rename = @recorder.inverse_of :rename_table, [:old, :new] + assert_equal [:rename_table, [:new, :old]], rename + end + + def test_invert_add_column + remove = @recorder.inverse_of :add_column, [:table, :column, :type, {}] + assert_equal [:remove_column, [:table, :column, :type, {}], nil], remove + end + + def test_invert_change_column + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.inverse_of :change_column, [:table, :column, :type, {}] + end + end + + def test_invert_change_column_default + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.inverse_of :change_column_default, [:table, :column, 'default_value'] + end + end + + def test_invert_change_column_null + add = @recorder.inverse_of :change_column_null, [:table, :column, true] + assert_equal [:change_column_null, [:table, :column, false]], add + end + + def test_invert_remove_column + add = @recorder.inverse_of :remove_column, [:table, :column, :type, {}] + assert_equal [:add_column, [:table, :column, :type, {}], nil], add + end + + def test_invert_remove_column_without_type + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.inverse_of :remove_column, [:table, :column] + end + end + + def test_invert_rename_column + rename = @recorder.inverse_of :rename_column, [:table, :old, :new] + assert_equal [:rename_column, [:table, :new, :old]], rename + end + + def test_invert_add_index + remove = @recorder.inverse_of :add_index, [:table, [:one, :two]] + assert_equal [:remove_index, [:table, {column: [:one, :two]}]], remove + end + + def test_invert_add_index_with_name + remove = @recorder.inverse_of :add_index, [:table, [:one, :two], name: "new_index"] + assert_equal [:remove_index, [:table, {name: "new_index"}]], remove + end + + def test_invert_add_index_with_no_options + remove = @recorder.inverse_of :add_index, [:table, [:one, :two]] + assert_equal [:remove_index, [:table, {column: [:one, :two]}]], remove + end + + def test_invert_remove_index + add = @recorder.inverse_of :remove_index, [:table, {column: [:one, :two], options: true}] + assert_equal [:add_index, [:table, [:one, :two], options: true]], add + end + + def test_invert_remove_index_with_name + add = @recorder.inverse_of :remove_index, [:table, {column: [:one, :two], name: "new_index"}] + assert_equal [:add_index, [:table, [:one, :two], name: "new_index"]], add + end + + def test_invert_remove_index_with_no_special_options + add = @recorder.inverse_of :remove_index, [:table, {column: [:one, :two]}] + assert_equal [:add_index, [:table, [:one, :two], {}]], add + end + + def test_invert_remove_index_with_no_column + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.inverse_of :remove_index, [:table, name: "new_index"] + end + end + + def test_invert_rename_index + rename = @recorder.inverse_of :rename_index, [:table, :old, :new] + assert_equal [:rename_index, [:table, :new, :old]], rename + end + + def test_invert_add_timestamps + remove = @recorder.inverse_of :add_timestamps, [:table] + assert_equal [:remove_timestamps, [:table], nil], remove + end + + def test_invert_remove_timestamps + add = @recorder.inverse_of :remove_timestamps, [:table] + assert_equal [:add_timestamps, [:table], nil], add + end + + def test_invert_add_reference + remove = @recorder.inverse_of :add_reference, [:table, :taggable, { polymorphic: true }] + assert_equal [:remove_reference, [:table, :taggable, { polymorphic: true }], nil], remove + end + + def test_invert_add_belongs_to_alias + remove = @recorder.inverse_of :add_belongs_to, [:table, :user] + assert_equal [:remove_reference, [:table, :user], nil], remove + end + + def test_invert_remove_reference + add = @recorder.inverse_of :remove_reference, [:table, :taggable, { polymorphic: true }] + assert_equal [:add_reference, [:table, :taggable, { polymorphic: true }], nil], add + end + + def test_invert_remove_belongs_to_alias + add = @recorder.inverse_of :remove_belongs_to, [:table, :user] + assert_equal [:add_reference, [:table, :user], nil], add + end + + def test_invert_enable_extension + disable = @recorder.inverse_of :enable_extension, ['uuid-ossp'] + assert_equal [:disable_extension, ['uuid-ossp'], nil], disable + end + + def test_invert_disable_extension + enable = @recorder.inverse_of :disable_extension, ['uuid-ossp'] + assert_equal [:enable_extension, ['uuid-ossp'], nil], enable + end + + def test_invert_add_foreign_key + enable = @recorder.inverse_of :add_foreign_key, [:dogs, :people] + assert_equal [:remove_foreign_key, [:dogs, :people]], enable + end + + def test_invert_add_foreign_key_with_column + enable = @recorder.inverse_of :add_foreign_key, [:dogs, :people, column: "owner_id"] + assert_equal [:remove_foreign_key, [:dogs, column: "owner_id"]], enable + end + + def test_invert_add_foreign_key_with_column_and_name + enable = @recorder.inverse_of :add_foreign_key, [:dogs, :people, column: "owner_id", name: "fk"] + assert_equal [:remove_foreign_key, [:dogs, name: "fk"]], enable + end + + def test_remove_foreign_key_is_irreversible + assert_raises ActiveRecord::IrreversibleMigration do + @recorder.inverse_of :remove_foreign_key, [:dogs, column: "owner_id"] + end + + assert_raises ActiveRecord::IrreversibleMigration do + @recorder.inverse_of :remove_foreign_key, [:dogs, name: "fk"] + end + end + end + end +end diff --git a/activerecord/test/cases/migration/create_join_table_test.rb b/activerecord/test/cases/migration/create_join_table_test.rb new file mode 100644 index 0000000000..bea9d6b2c9 --- /dev/null +++ b/activerecord/test/cases/migration/create_join_table_test.rb @@ -0,0 +1,148 @@ +require 'cases/helper' + +module ActiveRecord + class Migration + class CreateJoinTableTest < ActiveRecord::TestCase + attr_reader :connection + + def setup + super + @connection = ActiveRecord::Base.connection + end + + teardown do + %w(artists_musics musics_videos catalog).each do |table_name| + connection.drop_table table_name if connection.tables.include?(table_name) + end + end + + def test_create_join_table + connection.create_join_table :artists, :musics + + assert_equal %w(artist_id music_id), connection.columns(:artists_musics).map(&:name).sort + end + + def test_create_join_table_set_not_null_by_default + connection.create_join_table :artists, :musics + + assert_equal [false, false], connection.columns(:artists_musics).map(&:null) + end + + def test_create_join_table_with_strings + connection.create_join_table 'artists', 'musics' + + assert_equal %w(artist_id music_id), connection.columns(:artists_musics).map(&:name).sort + end + + def test_create_join_table_with_symbol_and_string + connection.create_join_table :artists, 'musics' + + assert_equal %w(artist_id music_id), connection.columns(:artists_musics).map(&:name).sort + end + + def test_create_join_table_with_the_proper_order + connection.create_join_table :videos, :musics + + assert_equal %w(music_id video_id), connection.columns(:musics_videos).map(&:name).sort + end + + def test_create_join_table_with_the_table_name + connection.create_join_table :artists, :musics, table_name: :catalog + + assert_equal %w(artist_id music_id), connection.columns(:catalog).map(&:name).sort + end + + def test_create_join_table_with_the_table_name_as_string + connection.create_join_table :artists, :musics, table_name: 'catalog' + + assert_equal %w(artist_id music_id), connection.columns(:catalog).map(&:name).sort + end + + def test_create_join_table_with_column_options + connection.create_join_table :artists, :musics, column_options: {null: true} + + assert_equal [true, true], connection.columns(:artists_musics).map(&:null) + end + + def test_create_join_table_without_indexes + connection.create_join_table :artists, :musics + + assert connection.indexes(:artists_musics).blank? + end + + def test_create_join_table_with_index + connection.create_join_table :artists, :musics do |t| + t.index [:artist_id, :music_id] + end + + assert_equal [%w(artist_id music_id)], connection.indexes(:artists_musics).map(&:columns) + end + + def test_drop_join_table + connection.create_join_table :artists, :musics + connection.drop_join_table :artists, :musics + + assert !connection.tables.include?('artists_musics') + end + + def test_drop_join_table_with_strings + connection.create_join_table :artists, :musics + connection.drop_join_table 'artists', 'musics' + + assert !connection.tables.include?('artists_musics') + end + + def test_drop_join_table_with_the_proper_order + connection.create_join_table :videos, :musics + connection.drop_join_table :videos, :musics + + assert !connection.tables.include?('musics_videos') + end + + def test_drop_join_table_with_the_table_name + connection.create_join_table :artists, :musics, table_name: :catalog + connection.drop_join_table :artists, :musics, table_name: :catalog + + assert !connection.tables.include?('catalog') + end + + def test_drop_join_table_with_the_table_name_as_string + connection.create_join_table :artists, :musics, table_name: 'catalog' + connection.drop_join_table :artists, :musics, table_name: 'catalog' + + assert !connection.tables.include?('catalog') + end + + def test_drop_join_table_with_column_options + connection.create_join_table :artists, :musics, column_options: {null: true} + connection.drop_join_table :artists, :musics, column_options: {null: true} + + assert !connection.tables.include?('artists_musics') + end + + def test_create_and_drop_join_table_with_common_prefix + with_table_cleanup do + connection.create_join_table 'audio_artists', 'audio_musics' + assert_includes connection.tables, 'audio_artists_musics' + + connection.drop_join_table 'audio_artists', 'audio_musics' + assert !connection.tables.include?('audio_artists_musics'), "Should have dropped join table, but didn't" + end + end + + private + + def with_table_cleanup + tables_before = connection.tables + + yield + ensure + tables_after = connection.tables - tables_before + + tables_after.each do |table| + connection.execute "DROP TABLE #{table}" + end + end + end + end +end diff --git a/activerecord/test/cases/migration/foreign_key_test.rb b/activerecord/test/cases/migration/foreign_key_test.rb new file mode 100644 index 0000000000..c985092b4c --- /dev/null +++ b/activerecord/test/cases/migration/foreign_key_test.rb @@ -0,0 +1,242 @@ +require 'cases/helper' +require 'support/ddl_helper' +require 'support/schema_dumping_helper' + +if ActiveRecord::Base.connection.supports_foreign_keys? +module ActiveRecord + class Migration + class ForeignKeyTest < ActiveRecord::TestCase + include DdlHelper + include SchemaDumpingHelper + + class Rocket < ActiveRecord::Base + end + + class Astronaut < ActiveRecord::Base + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table "rockets" do |t| + t.string :name + end + + @connection.create_table "astronauts" do |t| + t.string :name + t.references :rocket + end + end + + teardown do + if defined?(@connection) + @connection.execute "DROP TABLE IF EXISTS astronauts" + @connection.execute "DROP TABLE IF EXISTS rockets" + end + end + + def test_foreign_keys + foreign_keys = @connection.foreign_keys("fk_test_has_fk") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "fk_test_has_fk", fk.from_table + assert_equal "fk_test_has_pk", fk.to_table + assert_equal "fk_id", fk.column + assert_equal "pk_id", fk.primary_key + assert_equal "fk_name", fk.name + end + + def test_add_foreign_key_inferes_column + @connection.add_foreign_key :astronauts, :rockets + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "astronauts", fk.from_table + assert_equal "rockets", fk.to_table + assert_equal "rocket_id", fk.column + assert_equal "id", fk.primary_key + assert_match(/^fk_rails_.{10}$/, fk.name) + end + + def test_add_foreign_key_with_column + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id" + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "astronauts", fk.from_table + assert_equal "rockets", fk.to_table + assert_equal "rocket_id", fk.column + assert_equal "id", fk.primary_key + assert_match(/^fk_rails_.{10}$/, fk.name) + end + + def test_add_foreign_key_with_non_standard_primary_key + with_example_table @connection, "space_shuttles", "pk integer PRIMARY KEY" do + @connection.add_foreign_key(:astronauts, :space_shuttles, + column: "rocket_id", primary_key: "pk", name: "custom_pk") + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "astronauts", fk.from_table + assert_equal "space_shuttles", fk.to_table + assert_equal "pk", fk.primary_key + + @connection.remove_foreign_key :astronauts, name: "custom_pk" + end + end + + def test_add_on_delete_restrict_foreign_key + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_delete: :restrict + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + # ON DELETE RESTRICT is the default on MySQL + assert_equal nil, fk.on_delete + else + assert_equal :restrict, fk.on_delete + end + end + + def test_add_on_delete_cascade_foreign_key + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_delete: :cascade + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal :cascade, fk.on_delete + end + + def test_add_on_delete_nullify_foreign_key + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_delete: :nullify + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal :nullify, fk.on_delete + end + + def test_on_update_and_on_delete_raises_with_invalid_values + assert_raises ArgumentError do + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_delete: :invalid + end + + assert_raises ArgumentError do + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_update: :invalid + end + end + + def test_add_foreign_key_with_on_update + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_update: :nullify + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal :nullify, fk.on_update + end + + def test_remove_foreign_key_inferes_column + @connection.add_foreign_key :astronauts, :rockets + + assert_equal 1, @connection.foreign_keys("astronauts").size + @connection.remove_foreign_key :astronauts, :rockets + assert_equal [], @connection.foreign_keys("astronauts") + end + + def test_remove_foreign_key_by_column + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id" + + assert_equal 1, @connection.foreign_keys("astronauts").size + @connection.remove_foreign_key :astronauts, column: "rocket_id" + assert_equal [], @connection.foreign_keys("astronauts") + end + + def test_remove_foreign_key_by_name + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk" + + assert_equal 1, @connection.foreign_keys("astronauts").size + @connection.remove_foreign_key :astronauts, name: "fancy_named_fk" + assert_equal [], @connection.foreign_keys("astronauts") + end + + def test_remove_foreign_non_existing_foreign_key_raises + assert_raises ArgumentError do + @connection.remove_foreign_key :astronauts, :rockets + end + end + + def test_schema_dumping + @connection.add_foreign_key :astronauts, :rockets + output = dump_table_schema "astronauts" + assert_match %r{\s+add_foreign_key "astronauts", "rockets"$}, output + end + + def test_schema_dumping_with_options + output = dump_table_schema "fk_test_has_fk" + assert_match %r{\s+add_foreign_key "fk_test_has_fk", "fk_test_has_pk", column: "fk_id", primary_key: "pk_id", name: "fk_name"$}, output + end + + def test_schema_dumping_on_delete_and_on_update_options + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", on_delete: :nullify, on_update: :cascade + + output = dump_table_schema "astronauts" + assert_match %r{\s+add_foreign_key "astronauts",.+on_update: :cascade,.+on_delete: :nullify$}, output + end + + class CreateCitiesAndHousesMigration < ActiveRecord::Migration + def change + create_table("cities") { |t| } + + create_table("houses") do |t| + t.column :city_id, :integer + end + add_foreign_key :houses, :cities, column: "city_id" + end + end + + def test_add_foreign_key_is_reversible + migration = CreateCitiesAndHousesMigration.new + silence_stream($stdout) { migration.migrate(:up) } + assert_equal 1, @connection.foreign_keys("houses").size + ensure + silence_stream($stdout) { migration.migrate(:down) } + end + end + end +end +else +module ActiveRecord + class Migration + class NoForeignKeySupportTest < ActiveRecord::TestCase + setup do + @connection = ActiveRecord::Base.connection + end + + def test_add_foreign_key_should_be_noop + @connection.add_foreign_key :clubs, :categories + end + + def test_remove_foreign_key_should_be_noop + @connection.remove_foreign_key :clubs, :categories + end + + def test_foreign_keys_should_raise_not_implemented + assert_raises NotImplementedError do + @connection.foreign_keys("clubs") + end + end + end + end +end +end diff --git a/activerecord/test/cases/migration/helper.rb b/activerecord/test/cases/migration/helper.rb new file mode 100644 index 0000000000..e28feedcf9 --- /dev/null +++ b/activerecord/test/cases/migration/helper.rb @@ -0,0 +1,43 @@ +require "cases/helper" + +module ActiveRecord + class Migration + class << self; attr_accessor :message_count; end + self.message_count = 0 + + def puts(text="") + ActiveRecord::Migration.message_count += 1 + end + + module TestHelper + attr_reader :connection, :table_name + + CONNECTION_METHODS = %w[add_column remove_column rename_column add_index change_column rename_table column_exists? index_exists? add_reference add_belongs_to remove_reference remove_references remove_belongs_to] + + class TestModel < ActiveRecord::Base + self.table_name = :test_models + end + + def setup + super + @connection = ActiveRecord::Base.connection + connection.create_table :test_models do |t| + t.timestamps + end + + TestModel.reset_column_information + end + + def teardown + super + TestModel.reset_table_name + TestModel.reset_sequence_name + connection.drop_table :test_models rescue nil + end + + private + + delegate(*CONNECTION_METHODS, to: :connection) + end + end +end diff --git a/activerecord/test/cases/migration/index_test.rb b/activerecord/test/cases/migration/index_test.rb new file mode 100644 index 0000000000..93c3bfae7a --- /dev/null +++ b/activerecord/test/cases/migration/index_test.rb @@ -0,0 +1,188 @@ +require 'cases/helper' + +module ActiveRecord + class Migration + class IndexTest < ActiveRecord::TestCase + attr_reader :connection, :table_name + + def setup + super + @connection = ActiveRecord::Base.connection + @table_name = :testings + + connection.create_table table_name do |t| + t.column :foo, :string, :limit => 100 + t.column :bar, :string, :limit => 100 + + t.string :first_name + t.string :last_name, :limit => 100 + t.string :key, :limit => 100 + t.boolean :administrator + end + end + + teardown do + connection.drop_table :testings rescue nil + ActiveRecord::Base.primary_key_prefix_type = nil + end + + def test_rename_index + # keep the names short to make Oracle and similar behave + connection.add_index(table_name, [:foo], :name => 'old_idx') + connection.rename_index(table_name, 'old_idx', 'new_idx') + + # if the adapter doesn't support the indexes call, pick defaults that let the test pass + assert_not connection.index_name_exists?(table_name, 'old_idx', false) + assert connection.index_name_exists?(table_name, 'new_idx', true) + end + + def test_double_add_index + connection.add_index(table_name, [:foo], :name => 'some_idx') + assert_raises(ArgumentError) { + connection.add_index(table_name, [:foo], :name => 'some_idx') + } + end + + def test_remove_nonexistent_index + assert_raise(ArgumentError) { connection.remove_index(table_name, "no_such_index") } + end + + def test_add_index_works_with_long_index_names + connection.add_index(table_name, "foo", name: good_index_name) + + assert connection.index_name_exists?(table_name, good_index_name, false) + connection.remove_index(table_name, name: good_index_name) + end + + def test_add_index_does_not_accept_too_long_index_names + too_long_index_name = good_index_name + 'x' + + e = assert_raises(ArgumentError) { + connection.add_index(table_name, "foo", name: too_long_index_name) + } + assert_match(/too long; the limit is #{connection.allowed_index_name_length} characters/, e.message) + + assert_not connection.index_name_exists?(table_name, too_long_index_name, false) + connection.add_index(table_name, "foo", :name => good_index_name) + end + + def test_internal_index_with_name_matching_database_limit + good_index_name = 'x' * connection.index_name_length + connection.add_index(table_name, "foo", name: good_index_name, internal: true) + + assert connection.index_name_exists?(table_name, good_index_name, false) + connection.remove_index(table_name, name: good_index_name) + end + + def test_index_symbol_names + connection.add_index table_name, :foo, :name => :symbol_index_name + assert connection.index_exists?(table_name, :foo, :name => :symbol_index_name) + + connection.remove_index table_name, :name => :symbol_index_name + assert_not connection.index_exists?(table_name, :foo, :name => :symbol_index_name) + end + + def test_index_exists + connection.add_index :testings, :foo + + assert connection.index_exists?(:testings, :foo) + assert !connection.index_exists?(:testings, :bar) + end + + def test_index_exists_on_multiple_columns + connection.add_index :testings, [:foo, :bar] + + assert connection.index_exists?(:testings, [:foo, :bar]) + end + + def test_valid_index_options + assert_raise ArgumentError do + connection.add_index :testings, :foo, unqiue: true + end + end + + def test_unique_index_exists + connection.add_index :testings, :foo, :unique => true + + assert connection.index_exists?(:testings, :foo, :unique => true) + end + + def test_named_index_exists + connection.add_index :testings, :foo, :name => "custom_index_name" + + assert connection.index_exists?(:testings, :foo, :name => "custom_index_name") + end + + def test_add_index_attribute_length_limit + connection.add_index :testings, [:foo, :bar], :length => {:foo => 10, :bar => nil} + + assert connection.index_exists?(:testings, [:foo, :bar]) + end + + def test_add_index + connection.add_index("testings", "last_name") + connection.remove_index("testings", "last_name") + + connection.add_index("testings", ["last_name", "first_name"]) + connection.remove_index("testings", :column => ["last_name", "first_name"]) + + # Oracle adapter cannot have specified index name larger than 30 characters + # Oracle adapter is shortening index name when just column list is given + unless current_adapter?(:OracleAdapter) + connection.add_index("testings", ["last_name", "first_name"]) + connection.remove_index("testings", :name => :index_testings_on_last_name_and_first_name) + connection.add_index("testings", ["last_name", "first_name"]) + connection.remove_index("testings", "last_name_and_first_name") + end + connection.add_index("testings", ["last_name", "first_name"]) + connection.remove_index("testings", ["last_name", "first_name"]) + + connection.add_index("testings", ["last_name"], :length => 10) + connection.remove_index("testings", "last_name") + + connection.add_index("testings", ["last_name"], :length => {:last_name => 10}) + connection.remove_index("testings", ["last_name"]) + + connection.add_index("testings", ["last_name", "first_name"], :length => 10) + connection.remove_index("testings", ["last_name", "first_name"]) + + connection.add_index("testings", ["last_name", "first_name"], :length => {:last_name => 10, :first_name => 20}) + connection.remove_index("testings", ["last_name", "first_name"]) + + connection.add_index("testings", ["key"], :name => "key_idx", :unique => true) + connection.remove_index("testings", :name => "key_idx", :unique => true) + + connection.add_index("testings", %w(last_name first_name administrator), :name => "named_admin") + connection.remove_index("testings", :name => "named_admin") + + # Selected adapters support index sort order + if current_adapter?(:SQLite3Adapter, :MysqlAdapter, :Mysql2Adapter, :PostgreSQLAdapter) + connection.add_index("testings", ["last_name"], :order => {:last_name => :desc}) + connection.remove_index("testings", ["last_name"]) + connection.add_index("testings", ["last_name", "first_name"], :order => {:last_name => :desc}) + connection.remove_index("testings", ["last_name", "first_name"]) + connection.add_index("testings", ["last_name", "first_name"], :order => {:last_name => :desc, :first_name => :asc}) + connection.remove_index("testings", ["last_name", "first_name"]) + connection.add_index("testings", ["last_name", "first_name"], :order => :desc) + connection.remove_index("testings", ["last_name", "first_name"]) + end + end + + if current_adapter?(:PostgreSQLAdapter) + def test_add_partial_index + connection.add_index("testings", "last_name", :where => "first_name = 'john doe'") + assert connection.index_exists?("testings", "last_name") + + connection.remove_index("testings", "last_name") + assert !connection.index_exists?("testings", "last_name") + end + end + + private + def good_index_name + 'x' * connection.allowed_index_name_length + end + + end + end +end diff --git a/activerecord/test/cases/migration/logger_test.rb b/activerecord/test/cases/migration/logger_test.rb new file mode 100644 index 0000000000..319d3e1af3 --- /dev/null +++ b/activerecord/test/cases/migration/logger_test.rb @@ -0,0 +1,36 @@ +require "cases/helper" + +module ActiveRecord + class Migration + class LoggerTest < ActiveRecord::TestCase + # MySQL can't roll back ddl changes + self.use_transactional_fixtures = false + + Migration = Struct.new(:name, :version) do + def disable_ddl_transaction; false end + def migrate direction + # do nothing + end + end + + def setup + super + ActiveRecord::SchemaMigration.create_table + ActiveRecord::SchemaMigration.delete_all + end + + teardown do + ActiveRecord::SchemaMigration.drop_table + end + + def test_migration_should_be_run_without_logger + previous_logger = ActiveRecord::Base.logger + ActiveRecord::Base.logger = nil + migrations = [Migration.new('a', 1), Migration.new('b', 2), Migration.new('c', 3)] + ActiveRecord::Migrator.new(:up, migrations).migrate + ensure + ActiveRecord::Base.logger = previous_logger + end + end + end +end diff --git a/activerecord/test/cases/migration/pending_migrations_test.rb b/activerecord/test/cases/migration/pending_migrations_test.rb new file mode 100644 index 0000000000..7afac83bd2 --- /dev/null +++ b/activerecord/test/cases/migration/pending_migrations_test.rb @@ -0,0 +1,53 @@ +require 'cases/helper' +require "minitest/mock" + +module ActiveRecord + class Migration + class PendingMigrationsTest < ActiveRecord::TestCase + def setup + super + @connection = Minitest::Mock.new + @app = Minitest::Mock.new + conn = @connection + @pending = Class.new(CheckPending) { + define_method(:connection) { conn } + }.new(@app) + @pending.instance_variable_set :@last_check, -1 # Force checking + end + + def teardown + assert @connection.verify + assert @app.verify + super + end + + def test_errors_if_pending + @connection.expect :supports_migrations?, true + + ActiveRecord::Migrator.stub :needs_migration?, true do + assert_raise ActiveRecord::PendingMigrationError do + @pending.call(nil) + end + end + end + + def test_checks_if_supported + @connection.expect :supports_migrations?, true + @app.expect :call, nil, [:foo] + + ActiveRecord::Migrator.stub :needs_migration?, false do + @pending.call(:foo) + end + end + + def test_doesnt_check_if_unsupported + @connection.expect :supports_migrations?, false + @app.expect :call, nil, [:foo] + + ActiveRecord::Migrator.stub :needs_migration?, true do + @pending.call(:foo) + end + end + end + end +end diff --git a/activerecord/test/cases/migration/references_index_test.rb b/activerecord/test/cases/migration/references_index_test.rb new file mode 100644 index 0000000000..4485701a4e --- /dev/null +++ b/activerecord/test/cases/migration/references_index_test.rb @@ -0,0 +1,101 @@ +require 'cases/helper' + +module ActiveRecord + class Migration + class ReferencesIndexTest < ActiveRecord::TestCase + attr_reader :connection, :table_name + + def setup + super + @connection = ActiveRecord::Base.connection + @table_name = :testings + end + + teardown do + connection.drop_table :testings rescue nil + end + + def test_creates_index + connection.create_table table_name do |t| + t.references :foo, :index => true + end + + assert connection.index_exists?(table_name, :foo_id, :name => :index_testings_on_foo_id) + end + + def test_does_not_create_index + connection.create_table table_name do |t| + t.references :foo + end + + assert_not connection.index_exists?(table_name, :foo_id, :name => :index_testings_on_foo_id) + end + + def test_does_not_create_index_explicit + connection.create_table table_name do |t| + t.references :foo, :index => false + end + + assert_not connection.index_exists?(table_name, :foo_id, :name => :index_testings_on_foo_id) + end + + def test_creates_index_with_options + connection.create_table table_name do |t| + t.references :foo, :index => {:name => :index_testings_on_yo_momma} + t.references :bar, :index => {:unique => true} + end + + assert connection.index_exists?(table_name, :foo_id, :name => :index_testings_on_yo_momma) + assert connection.index_exists?(table_name, :bar_id, :name => :index_testings_on_bar_id, :unique => true) + end + + unless current_adapter? :OracleAdapter + def test_creates_polymorphic_index + connection.create_table table_name do |t| + t.references :foo, :polymorphic => true, :index => true + end + + assert connection.index_exists?(table_name, [:foo_id, :foo_type], :name => :index_testings_on_foo_id_and_foo_type) + end + end + + def test_creates_index_for_existing_table + connection.create_table table_name + connection.change_table table_name do |t| + t.references :foo, :index => true + end + + assert connection.index_exists?(table_name, :foo_id, :name => :index_testings_on_foo_id) + end + + def test_does_not_create_index_for_existing_table + connection.create_table table_name + connection.change_table table_name do |t| + t.references :foo + end + + assert_not connection.index_exists?(table_name, :foo_id, :name => :index_testings_on_foo_id) + end + + def test_does_not_create_index_for_existing_table_explicit + connection.create_table table_name + connection.change_table table_name do |t| + t.references :foo, :index => false + end + + assert_not connection.index_exists?(table_name, :foo_id, :name => :index_testings_on_foo_id) + end + + unless current_adapter? :OracleAdapter + def test_creates_polymorphic_index_for_existing_table + connection.create_table table_name + connection.change_table table_name do |t| + t.references :foo, :polymorphic => true, :index => true + end + + assert connection.index_exists?(table_name, [:foo_id, :foo_type], :name => :index_testings_on_foo_id_and_foo_type) + end + end + end + end +end diff --git a/activerecord/test/cases/migration/references_statements_test.rb b/activerecord/test/cases/migration/references_statements_test.rb new file mode 100644 index 0000000000..b8b4fa1135 --- /dev/null +++ b/activerecord/test/cases/migration/references_statements_test.rb @@ -0,0 +1,116 @@ +require "cases/migration/helper" + +module ActiveRecord + class Migration + class ReferencesStatementsTest < ActiveRecord::TestCase + include ActiveRecord::Migration::TestHelper + + self.use_transactional_fixtures = false + + def setup + super + @table_name = :test_models + + add_column table_name, :supplier_id, :integer + add_index table_name, :supplier_id + end + + def test_creates_reference_id_column + add_reference table_name, :user + assert column_exists?(table_name, :user_id, :integer) + end + + def test_does_not_create_reference_type_column + add_reference table_name, :taggable + assert_not column_exists?(table_name, :taggable_type, :string) + end + + def test_creates_reference_type_column + add_reference table_name, :taggable, polymorphic: true + assert column_exists?(table_name, :taggable_type, :string) + end + + def test_creates_reference_id_index + add_reference table_name, :user, index: true + assert index_exists?(table_name, :user_id) + end + + def test_does_not_create_reference_id_index + add_reference table_name, :user + assert_not index_exists?(table_name, :user_id) + end + + def test_creates_polymorphic_index + add_reference table_name, :taggable, polymorphic: true, index: true + assert index_exists?(table_name, [:taggable_id, :taggable_type]) + end + + def test_creates_reference_type_column_with_default + add_reference table_name, :taggable, polymorphic: { default: 'Photo' }, index: true + assert column_exists?(table_name, :taggable_type, :string, default: 'Photo') + end + + def test_creates_named_index + add_reference table_name, :tag, index: { name: 'index_taggings_on_tag_id' } + assert index_exists?(table_name, :tag_id, name: 'index_taggings_on_tag_id') + end + + def test_creates_reference_id_with_specified_type + add_reference table_name, :user, type: :string + assert column_exists?(table_name, :user_id, :string) + end + + def test_deletes_reference_id_column + remove_reference table_name, :supplier + assert_not column_exists?(table_name, :supplier_id, :integer) + end + + def test_deletes_reference_id_index + remove_reference table_name, :supplier + assert_not index_exists?(table_name, :supplier_id) + end + + def test_does_not_delete_reference_type_column + with_polymorphic_column do + remove_reference table_name, :supplier + + assert_not column_exists?(table_name, :supplier_id, :integer) + assert column_exists?(table_name, :supplier_type, :string) + end + end + + def test_deletes_reference_type_column + with_polymorphic_column do + remove_reference table_name, :supplier, polymorphic: true + assert_not column_exists?(table_name, :supplier_type, :string) + end + end + + def test_deletes_polymorphic_index + with_polymorphic_column do + remove_reference table_name, :supplier, polymorphic: true + assert_not index_exists?(table_name, [:supplier_id, :supplier_type]) + end + end + + def test_add_belongs_to_alias + add_belongs_to table_name, :user + assert column_exists?(table_name, :user_id, :integer) + end + + def test_remove_belongs_to_alias + remove_belongs_to table_name, :supplier + assert_not column_exists?(table_name, :supplier_id, :integer) + end + + private + + def with_polymorphic_column + add_column table_name, :supplier_type, :string + add_index table_name, [:supplier_id, :supplier_type] + + yield + end + end + end +end diff --git a/activerecord/test/cases/migration/rename_table_test.rb b/activerecord/test/cases/migration/rename_table_test.rb new file mode 100644 index 0000000000..ba39fb1dec --- /dev/null +++ b/activerecord/test/cases/migration/rename_table_test.rb @@ -0,0 +1,92 @@ +require "cases/migration/helper" + +module ActiveRecord + class Migration + class RenameTableTest < ActiveRecord::TestCase + include ActiveRecord::Migration::TestHelper + + self.use_transactional_fixtures = false + + def setup + super + add_column 'test_models', :url, :string + remove_column 'test_models', :created_at + remove_column 'test_models', :updated_at + end + + def teardown + rename_table :octopi, :test_models if connection.table_exists? :octopi + super + end + + if current_adapter?(:SQLite3Adapter) + def test_rename_table_for_sqlite_should_work_with_reserved_words + renamed = false + + add_column :test_models, :url, :string + connection.rename_table :references, :old_references + connection.rename_table :test_models, :references + + renamed = true + + # Using explicit id in insert for compatibility across all databases + connection.execute "INSERT INTO 'references' (url, created_at, updated_at) VALUES ('http://rubyonrails.com', 0, 0)" + assert_equal 'http://rubyonrails.com', connection.select_value("SELECT url FROM 'references' WHERE id=1") + ensure + return unless renamed + connection.rename_table :references, :test_models + connection.rename_table :old_references, :references + end + end + + def test_rename_table + rename_table :test_models, :octopi + + connection.execute "INSERT INTO octopi (#{connection.quote_column_name('id')}, #{connection.quote_column_name('url')}) VALUES (1, 'http://www.foreverflying.com/octopus-black7.jpg')" + + assert_equal 'http://www.foreverflying.com/octopus-black7.jpg', connection.select_value("SELECT url FROM octopi WHERE id=1") + end + + def test_rename_table_with_an_index + add_index :test_models, :url + + rename_table :test_models, :octopi + + connection.execute "INSERT INTO octopi (#{connection.quote_column_name('id')}, #{connection.quote_column_name('url')}) VALUES (1, 'http://www.foreverflying.com/octopus-black7.jpg')" + + assert_equal 'http://www.foreverflying.com/octopus-black7.jpg', connection.select_value("SELECT url FROM octopi WHERE id=1") + index = connection.indexes(:octopi).first + assert index.columns.include?("url") + assert_equal 'index_octopi_on_url', index.name + end + + def test_rename_table_does_not_rename_custom_named_index + add_index :test_models, :url, name: 'special_url_idx' + + rename_table :test_models, :octopi + + assert_equal ['special_url_idx'], connection.indexes(:octopi).map(&:name) + end + + if current_adapter?(:PostgreSQLAdapter) + def test_rename_table_for_postgresql_should_also_rename_default_sequence + rename_table :test_models, :octopi + + pk, seq = connection.pk_and_sequence_for('octopi') + + assert_equal ConnectionAdapters::PostgreSQL::Name.new("public", "octopi_#{pk}_seq"), seq + end + + def test_renaming_table_doesnt_attempt_to_rename_non_existent_sequences + enable_uuid_ossp!(connection) + connection.create_table :cats, id: :uuid + assert_nothing_raised { rename_table :cats, :felines } + assert connection.table_exists? :felines + ensure + connection.drop_table :cats if connection.table_exists? :cats + connection.drop_table :felines if connection.table_exists? :felines + end + end + end + end +end diff --git a/activerecord/test/cases/migration/table_and_index_test.rb b/activerecord/test/cases/migration/table_and_index_test.rb new file mode 100644 index 0000000000..8fd770abd1 --- /dev/null +++ b/activerecord/test/cases/migration/table_and_index_test.rb @@ -0,0 +1,24 @@ +require "cases/helper" + +module ActiveRecord + class Migration + class TableAndIndexTest < ActiveRecord::TestCase + def test_add_schema_info_respects_prefix_and_suffix + conn = ActiveRecord::Base.connection + + conn.drop_table(ActiveRecord::Migrator.schema_migrations_table_name) if conn.table_exists?(ActiveRecord::Migrator.schema_migrations_table_name) + # Use shorter prefix and suffix as in Oracle database identifier cannot be larger than 30 characters + ActiveRecord::Base.table_name_prefix = 'p_' + ActiveRecord::Base.table_name_suffix = '_s' + conn.drop_table(ActiveRecord::Migrator.schema_migrations_table_name) if conn.table_exists?(ActiveRecord::Migrator.schema_migrations_table_name) + + conn.initialize_schema_migrations_table + + assert_equal "p_unique_schema_migrations_s", conn.indexes(ActiveRecord::Migrator.schema_migrations_table_name)[0][:name] + ensure + ActiveRecord::Base.table_name_prefix = "" + ActiveRecord::Base.table_name_suffix = "" + end + end + end +end diff --git a/activerecord/test/cases/migration_test.rb b/activerecord/test/cases/migration_test.rb new file mode 100644 index 0000000000..ef3f073472 --- /dev/null +++ b/activerecord/test/cases/migration_test.rb @@ -0,0 +1,908 @@ +require "cases/helper" +require "cases/migration/helper" +require 'bigdecimal/util' + +require 'models/person' +require 'models/topic' +require 'models/developer' + +require MIGRATIONS_ROOT + "/valid/2_we_need_reminders" +require MIGRATIONS_ROOT + "/rename/1_we_need_things" +require MIGRATIONS_ROOT + "/rename/2_rename_things" +require MIGRATIONS_ROOT + "/decimal/1_give_me_big_numbers" + +class BigNumber < ActiveRecord::Base + unless current_adapter?(:PostgreSQLAdapter, :SQLite3Adapter) + attribute :value_of_e, Type::Integer.new + end + attribute :world_population, Type::Integer.new + attribute :my_house_population, Type::Integer.new +end + +class Reminder < ActiveRecord::Base; end + +class Thing < ActiveRecord::Base; end + +class MigrationTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + fixtures :people + + def setup + super + %w(reminders people_reminders prefix_reminders_suffix p_things_s).each do |table| + Reminder.connection.drop_table(table) rescue nil + end + Reminder.reset_column_information + ActiveRecord::Migration.verbose = true + ActiveRecord::Migration.message_count = 0 + ActiveRecord::Base.connection.schema_cache.clear! + end + + teardown do + ActiveRecord::Base.table_name_prefix = "" + ActiveRecord::Base.table_name_suffix = "" + + ActiveRecord::Base.connection.initialize_schema_migrations_table + ActiveRecord::Base.connection.execute "DELETE FROM #{ActiveRecord::Migrator.schema_migrations_table_name}" + + %w(things awesome_things prefix_things_suffix p_awesome_things_s ).each do |table| + Thing.connection.drop_table(table) rescue nil + end + Thing.reset_column_information + + %w(reminders people_reminders prefix_reminders_suffix).each do |table| + Reminder.connection.drop_table(table) rescue nil + end + Reminder.reset_table_name + Reminder.reset_column_information + + %w(last_name key bio age height wealth birthday favorite_day + moment_of_truth male administrator funny).each do |column| + Person.connection.remove_column('people', column) rescue nil + end + Person.connection.remove_column("people", "first_name") rescue nil + Person.connection.remove_column("people", "middle_name") rescue nil + Person.connection.add_column("people", "first_name", :string) + Person.reset_column_information + end + + def test_migrator_versions + migrations_path = MIGRATIONS_ROOT + "/valid" + old_path = ActiveRecord::Migrator.migrations_paths + ActiveRecord::Migrator.migrations_paths = migrations_path + + ActiveRecord::Migrator.up(migrations_path) + assert_equal 3, ActiveRecord::Migrator.current_version + assert_equal 3, ActiveRecord::Migrator.last_version + assert_equal false, ActiveRecord::Migrator.needs_migration? + + ActiveRecord::Migrator.down(MIGRATIONS_ROOT + "/valid") + assert_equal 0, ActiveRecord::Migrator.current_version + assert_equal 3, ActiveRecord::Migrator.last_version + assert_equal true, ActiveRecord::Migrator.needs_migration? + ensure + ActiveRecord::Migrator.migrations_paths = old_path + end + + def test_migration_version + ActiveRecord::Migrator.run(:up, MIGRATIONS_ROOT + "/version_check", 20131219224947) + end + + def test_create_table_with_force_true_does_not_drop_nonexisting_table + if Person.connection.table_exists?(:testings2) + Person.connection.drop_table :testings2 + end + + # using a copy as we need the drop_table method to + # continue to work for the ensure block of the test + temp_conn = Person.connection.dup + + assert_not_equal temp_conn, Person.connection + + temp_conn.create_table :testings2, :force => true do |t| + t.column :foo, :string + end + ensure + Person.connection.drop_table :testings2 rescue nil + end + + def connection + ActiveRecord::Base.connection + end + + def test_migration_instance_has_connection + migration = Class.new(ActiveRecord::Migration).new + assert_equal connection, migration.connection + end + + def test_method_missing_delegates_to_connection + migration = Class.new(ActiveRecord::Migration) { + def connection + Class.new { + def create_table; "hi mom!"; end + }.new + end + }.new + + assert_equal "hi mom!", migration.method_missing(:create_table) + end + + def test_add_table_with_decimals + Person.connection.drop_table :big_numbers rescue nil + + assert !BigNumber.table_exists? + GiveMeBigNumbers.up + + assert BigNumber.create( + :bank_balance => 1586.43, + :big_bank_balance => BigDecimal("1000234000567.95"), + :world_population => 6000000000, + :my_house_population => 3, + :value_of_e => BigDecimal("2.7182818284590452353602875") + ) + + b = BigNumber.first + assert_not_nil b + + assert_not_nil b.bank_balance + assert_not_nil b.big_bank_balance + assert_not_nil b.world_population + assert_not_nil b.my_house_population + assert_not_nil b.value_of_e + + # TODO: set world_population >= 2**62 to cover 64-bit platforms and test + # is_a?(Bignum) + assert_kind_of Integer, b.world_population + assert_equal 6000000000, b.world_population + assert_kind_of Fixnum, b.my_house_population + assert_equal 3, b.my_house_population + assert_kind_of BigDecimal, b.bank_balance + assert_equal BigDecimal("1586.43"), b.bank_balance + assert_kind_of BigDecimal, b.big_bank_balance + assert_equal BigDecimal("1000234000567.95"), b.big_bank_balance + + # This one is fun. The 'value_of_e' field is defined as 'DECIMAL' with + # precision/scale explicitly left out. By the SQL standard, numbers + # assigned to this field should be truncated but that's seldom respected. + if current_adapter?(:PostgreSQLAdapter) + # - PostgreSQL changes the SQL spec on columns declared simply as + # "decimal" to something more useful: instead of being given a scale + # of 0, they take on the compile-time limit for precision and scale, + # so the following should succeed unless you have used really wacky + # compilation options + # - SQLite2 has the default behavior of preserving all data sent in, + # so this happens there too + assert_kind_of BigDecimal, b.value_of_e + assert_equal BigDecimal("2.7182818284590452353602875"), b.value_of_e + elsif current_adapter?(:SQLite3Adapter) + # - SQLite3 stores a float, in violation of SQL + assert_kind_of BigDecimal, b.value_of_e + assert_in_delta BigDecimal("2.71828182845905"), b.value_of_e, 0.00000000000001 + else + # - SQL standard is an integer + assert_kind_of Fixnum, b.value_of_e + assert_equal 2, b.value_of_e + end + + GiveMeBigNumbers.down + assert_raise(ActiveRecord::StatementInvalid) { BigNumber.first } + end + + def test_filtering_migrations + assert_no_column Person, :last_name + assert !Reminder.table_exists? + + name_filter = lambda { |migration| migration.name == "ValidPeopleHaveLastNames" } + ActiveRecord::Migrator.up(MIGRATIONS_ROOT + "/valid", &name_filter) + + assert_column Person, :last_name + assert_raise(ActiveRecord::StatementInvalid) { Reminder.first } + + ActiveRecord::Migrator.down(MIGRATIONS_ROOT + "/valid", &name_filter) + + assert_no_column Person, :last_name + assert_raise(ActiveRecord::StatementInvalid) { Reminder.first } + end + + class MockMigration < ActiveRecord::Migration + attr_reader :went_up, :went_down + def initialize + @went_up = false + @went_down = false + end + + def up + @went_up = true + super + end + + def down + @went_down = true + super + end + end + + def test_instance_based_migration_up + migration = MockMigration.new + assert !migration.went_up, 'have not gone up' + assert !migration.went_down, 'have not gone down' + + migration.migrate :up + assert migration.went_up, 'have gone up' + assert !migration.went_down, 'have not gone down' + end + + def test_instance_based_migration_down + migration = MockMigration.new + assert !migration.went_up, 'have not gone up' + assert !migration.went_down, 'have not gone down' + + migration.migrate :down + assert !migration.went_up, 'have gone up' + assert migration.went_down, 'have not gone down' + end + + if ActiveRecord::Base.connection.supports_ddl_transactions? + def test_migrator_one_up_with_exception_and_rollback + assert_no_column Person, :last_name + + migration = Class.new(ActiveRecord::Migration) { + def version; 100 end + def migrate(x) + add_column "people", "last_name", :string + raise 'Something broke' + end + }.new + + migrator = ActiveRecord::Migrator.new(:up, [migration], 100) + + e = assert_raise(StandardError) { migrator.migrate } + + assert_equal "An error has occurred, this and all later migrations canceled:\n\nSomething broke", e.message + + assert_no_column Person, :last_name, + "On error, the Migrator should revert schema changes but it did not." + end + + def test_migrator_one_up_with_exception_and_rollback_using_run + assert_no_column Person, :last_name + + migration = Class.new(ActiveRecord::Migration) { + def version; 100 end + def migrate(x) + add_column "people", "last_name", :string + raise 'Something broke' + end + }.new + + migrator = ActiveRecord::Migrator.new(:up, [migration], 100) + + e = assert_raise(StandardError) { migrator.run } + + assert_equal "An error has occurred, this migration was canceled:\n\nSomething broke", e.message + + assert_no_column Person, :last_name, + "On error, the Migrator should revert schema changes but it did not." + end + + def test_migration_without_transaction + assert_no_column Person, :last_name + + migration = Class.new(ActiveRecord::Migration) { + self.disable_ddl_transaction! + + def version; 101 end + def migrate(x) + add_column "people", "last_name", :string + raise 'Something broke' + end + }.new + + migrator = ActiveRecord::Migrator.new(:up, [migration], 101) + e = assert_raise(StandardError) { migrator.migrate } + assert_equal "An error has occurred, all later migrations canceled:\n\nSomething broke", e.message + + assert_column Person, :last_name, + "without ddl transactions, the Migrator should not rollback on error but it did." + ensure + Person.reset_column_information + if Person.column_names.include?('last_name') + Person.connection.remove_column('people', 'last_name') + end + end + end + + def test_schema_migrations_table_name + original_schema_migrations_table_name = ActiveRecord::Migrator.schema_migrations_table_name + + assert_equal "schema_migrations", ActiveRecord::Migrator.schema_migrations_table_name + ActiveRecord::Base.table_name_prefix = "prefix_" + ActiveRecord::Base.table_name_suffix = "_suffix" + Reminder.reset_table_name + assert_equal "prefix_schema_migrations_suffix", ActiveRecord::Migrator.schema_migrations_table_name + ActiveRecord::Base.schema_migrations_table_name = "changed" + Reminder.reset_table_name + assert_equal "prefix_changed_suffix", ActiveRecord::Migrator.schema_migrations_table_name + ActiveRecord::Base.table_name_prefix = "" + ActiveRecord::Base.table_name_suffix = "" + Reminder.reset_table_name + assert_equal "changed", ActiveRecord::Migrator.schema_migrations_table_name + ensure + ActiveRecord::Base.schema_migrations_table_name = original_schema_migrations_table_name + Reminder.reset_table_name + end + + def test_proper_table_name_on_migration + reminder_class = new_isolated_reminder_class + migration = ActiveRecord::Migration.new + assert_equal "table", migration.proper_table_name('table') + assert_equal "table", migration.proper_table_name(:table) + assert_equal "reminders", migration.proper_table_name(reminder_class) + reminder_class.reset_table_name + assert_equal reminder_class.table_name, migration.proper_table_name(reminder_class) + + # Use the model's own prefix/suffix if a model is given + ActiveRecord::Base.table_name_prefix = "ARprefix_" + ActiveRecord::Base.table_name_suffix = "_ARsuffix" + reminder_class.table_name_prefix = 'prefix_' + reminder_class.table_name_suffix = '_suffix' + reminder_class.reset_table_name + assert_equal "prefix_reminders_suffix", migration.proper_table_name(reminder_class) + reminder_class.table_name_prefix = '' + reminder_class.table_name_suffix = '' + reminder_class.reset_table_name + + # Use AR::Base's prefix/suffix if string or symbol is given + ActiveRecord::Base.table_name_prefix = "prefix_" + ActiveRecord::Base.table_name_suffix = "_suffix" + reminder_class.reset_table_name + assert_equal "prefix_table_suffix", migration.proper_table_name('table', migration.table_name_options) + assert_equal "prefix_table_suffix", migration.proper_table_name(:table, migration.table_name_options) + end + + def test_rename_table_with_prefix_and_suffix + assert !Thing.table_exists? + ActiveRecord::Base.table_name_prefix = 'p_' + ActiveRecord::Base.table_name_suffix = '_s' + Thing.reset_table_name + Thing.reset_sequence_name + WeNeedThings.up + + assert Thing.create("content" => "hello world") + assert_equal "hello world", Thing.first.content + + RenameThings.up + Thing.table_name = "p_awesome_things_s" + + assert_equal "hello world", Thing.first.content + ensure + Thing.reset_table_name + Thing.reset_sequence_name + end + + def test_add_drop_table_with_prefix_and_suffix + assert !Reminder.table_exists? + ActiveRecord::Base.table_name_prefix = 'prefix_' + ActiveRecord::Base.table_name_suffix = '_suffix' + Reminder.reset_table_name + Reminder.reset_sequence_name + WeNeedReminders.up + assert Reminder.create("content" => "hello world", "remind_at" => Time.now) + assert_equal "hello world", Reminder.first.content + + WeNeedReminders.down + assert_raise(ActiveRecord::StatementInvalid) { Reminder.first } + ensure + Reminder.reset_sequence_name + end + + def test_create_table_with_binary_column + Person.connection.drop_table :binary_testings rescue nil + + assert_nothing_raised { + Person.connection.create_table :binary_testings do |t| + t.column "data", :binary, :null => false + end + } + + columns = Person.connection.columns(:binary_testings) + data_column = columns.detect { |c| c.name == "data" } + + assert_nil data_column.default + + Person.connection.drop_table :binary_testings rescue nil + end + + def test_create_table_with_query + Person.connection.drop_table :table_from_query_testings rescue nil + Person.connection.create_table(:person, force: true) + + Person.connection.create_table :table_from_query_testings, as: "SELECT id FROM person" + + columns = Person.connection.columns(:table_from_query_testings) + assert_equal 1, columns.length + assert_equal "id", columns.first.name + + Person.connection.drop_table :table_from_query_testings rescue nil + end + + def test_create_table_with_query_from_relation + Person.connection.drop_table :table_from_query_testings rescue nil + Person.connection.create_table(:person, force: true) + + Person.connection.create_table :table_from_query_testings, as: Person.select(:id) + + columns = Person.connection.columns(:table_from_query_testings) + assert_equal 1, columns.length + assert_equal "id", columns.first.name + + Person.connection.drop_table :table_from_query_testings rescue nil + end + + if current_adapter? :OracleAdapter + def test_create_table_with_custom_sequence_name + # table name is 29 chars, the standard sequence name will + # be 33 chars and should be shortened + assert_nothing_raised do + begin + Person.connection.create_table :table_with_name_thats_just_ok do |t| + t.column :foo, :string, :null => false + end + ensure + Person.connection.drop_table :table_with_name_thats_just_ok rescue nil + end + end + + # should be all good w/ a custom sequence name + assert_nothing_raised do + begin + Person.connection.create_table :table_with_name_thats_just_ok, + :sequence_name => 'suitably_short_seq' do |t| + t.column :foo, :string, :null => false + end + + Person.connection.execute("select suitably_short_seq.nextval from dual") + + ensure + Person.connection.drop_table :table_with_name_thats_just_ok, + :sequence_name => 'suitably_short_seq' rescue nil + end + end + + # confirm the custom sequence got dropped + assert_raise(ActiveRecord::StatementInvalid) do + Person.connection.execute("select suitably_short_seq.nextval from dual") + end + end + end + + if current_adapter?(:MysqlAdapter, :Mysql2Adapter, :PostgreSQLAdapter) + def test_out_of_range_limit_should_raise + Person.connection.drop_table :test_limits rescue nil + assert_raise(ActiveRecord::ActiveRecordError, "integer limit didn't raise") do + Person.connection.create_table :test_integer_limits, :force => true do |t| + t.column :bigone, :integer, :limit => 10 + end + end + + unless current_adapter?(:PostgreSQLAdapter) + assert_raise(ActiveRecord::ActiveRecordError, "text limit didn't raise") do + Person.connection.create_table :test_text_limits, :force => true do |t| + t.column :bigtext, :text, :limit => 0xfffffffff + end + end + end + + Person.connection.drop_table :test_limits rescue nil + end + end + + protected + # This is needed to isolate class_attribute assignments like `table_name_prefix` + # for each test case. + def new_isolated_reminder_class + Class.new(Reminder) { + def self.name; "Reminder"; end + def self.base_class; self; end + } + end +end + +class ReservedWordsMigrationTest < ActiveRecord::TestCase + def test_drop_index_from_table_named_values + connection = Person.connection + connection.create_table :values, :force => true do |t| + t.integer :value + end + + assert_nothing_raised do + connection.add_index :values, :value + connection.remove_index :values, :column => :value + end + + connection.drop_table :values rescue nil + end +end + +class ExplicitlyNamedIndexMigrationTest < ActiveRecord::TestCase + def test_drop_index_by_name + connection = Person.connection + connection.create_table :values, force: true do |t| + t.integer :value + end + + assert_nothing_raised ArgumentError do + connection.add_index :values, :value, name: 'a_different_name' + connection.remove_index :values, column: :value, name: 'a_different_name' + end + + connection.drop_table :values rescue nil + end +end + +if ActiveRecord::Base.connection.supports_bulk_alter? + class BulkAlterTableMigrationsTest < ActiveRecord::TestCase + def setup + @connection = Person.connection + @connection.create_table(:delete_me, :force => true) {|t| } + Person.reset_column_information + Person.reset_sequence_name + end + + teardown do + Person.connection.drop_table(:delete_me) rescue nil + end + + def test_adding_multiple_columns + assert_queries(1) do + with_bulk_change_table do |t| + t.column :name, :string + t.string :qualification, :experience + t.integer :age, :default => 0 + t.date :birthdate + t.timestamps + end + end + + assert_equal 8, columns.size + [:name, :qualification, :experience].each {|s| assert_equal :string, column(s).type } + assert_equal '0', column(:age).default + end + + def test_removing_columns + with_bulk_change_table do |t| + t.string :qualification, :experience + end + + [:qualification, :experience].each {|c| assert column(c) } + + assert_queries(1) do + with_bulk_change_table do |t| + t.remove :qualification, :experience + t.string :qualification_experience + end + end + + [:qualification, :experience].each {|c| assert ! column(c) } + assert column(:qualification_experience) + end + + def test_adding_indexes + with_bulk_change_table do |t| + t.string :username + t.string :name + t.integer :age + end + + # Adding an index fires a query every time to check if an index already exists or not + assert_queries(3) do + with_bulk_change_table do |t| + t.index :username, :unique => true, :name => :awesome_username_index + t.index [:name, :age] + end + end + + assert_equal 2, indexes.size + + name_age_index = index(:index_delete_me_on_name_and_age) + assert_equal ['name', 'age'].sort, name_age_index.columns.sort + assert ! name_age_index.unique + + assert index(:awesome_username_index).unique + end + + def test_removing_index + with_bulk_change_table do |t| + t.string :name + t.index :name + end + + assert index(:index_delete_me_on_name) + + assert_queries(3) do + with_bulk_change_table do |t| + t.remove_index :name + t.index :name, :name => :new_name_index, :unique => true + end + end + + assert ! index(:index_delete_me_on_name) + + new_name_index = index(:new_name_index) + assert new_name_index.unique + end + + def test_changing_columns + with_bulk_change_table do |t| + t.string :name + t.date :birthdate + end + + assert ! column(:name).default + assert_equal :date, column(:birthdate).type + + # One query for columns (delete_me table) + # One query for primary key (delete_me table) + # One query to do the bulk change + assert_queries(3, :ignore_none => true) do + with_bulk_change_table do |t| + t.change :name, :string, :default => 'NONAME' + t.change :birthdate, :datetime + end + end + + assert_equal 'NONAME', column(:name).default + assert_equal :datetime, column(:birthdate).type + end + + protected + + def with_bulk_change_table + # Reset columns/indexes cache as we're changing the table + @columns = @indexes = nil + + Person.connection.change_table(:delete_me, :bulk => true) do |t| + yield t + end + end + + def column(name) + columns.detect {|c| c.name == name.to_s } + end + + def columns + @columns ||= Person.connection.columns('delete_me') + end + + def index(name) + indexes.detect {|i| i.name == name.to_s } + end + + def indexes + @indexes ||= Person.connection.indexes('delete_me') + end + end # AlterTableMigrationsTest + +end + +class CopyMigrationsTest < ActiveRecord::TestCase + def setup + end + + def clear + ActiveRecord::Base.timestamped_migrations = true + to_delete = Dir[@migrations_path + "/*.rb"] - @existing_migrations + File.delete(*to_delete) + end + + def test_copying_migrations_without_timestamps + ActiveRecord::Base.timestamped_migrations = false + @migrations_path = MIGRATIONS_ROOT + "/valid" + @existing_migrations = Dir[@migrations_path + "/*.rb"] + + copied = ActiveRecord::Migration.copy(@migrations_path, {:bukkits => MIGRATIONS_ROOT + "/to_copy"}) + assert File.exist?(@migrations_path + "/4_people_have_hobbies.bukkits.rb") + assert File.exist?(@migrations_path + "/5_people_have_descriptions.bukkits.rb") + assert_equal [@migrations_path + "/4_people_have_hobbies.bukkits.rb", @migrations_path + "/5_people_have_descriptions.bukkits.rb"], copied.map(&:filename) + + expected = "# This migration comes from bukkits (originally 1)" + assert_equal expected, IO.readlines(@migrations_path + "/4_people_have_hobbies.bukkits.rb")[0].chomp + + files_count = Dir[@migrations_path + "/*.rb"].length + copied = ActiveRecord::Migration.copy(@migrations_path, {:bukkits => MIGRATIONS_ROOT + "/to_copy"}) + assert_equal files_count, Dir[@migrations_path + "/*.rb"].length + assert copied.empty? + ensure + clear + end + + def test_copying_migrations_without_timestamps_from_2_sources + ActiveRecord::Base.timestamped_migrations = false + @migrations_path = MIGRATIONS_ROOT + "/valid" + @existing_migrations = Dir[@migrations_path + "/*.rb"] + + sources = {} + sources[:bukkits] = MIGRATIONS_ROOT + "/to_copy" + sources[:omg] = MIGRATIONS_ROOT + "/to_copy2" + ActiveRecord::Migration.copy(@migrations_path, sources) + assert File.exist?(@migrations_path + "/4_people_have_hobbies.bukkits.rb") + assert File.exist?(@migrations_path + "/5_people_have_descriptions.bukkits.rb") + assert File.exist?(@migrations_path + "/6_create_articles.omg.rb") + assert File.exist?(@migrations_path + "/7_create_comments.omg.rb") + + files_count = Dir[@migrations_path + "/*.rb"].length + ActiveRecord::Migration.copy(@migrations_path, sources) + assert_equal files_count, Dir[@migrations_path + "/*.rb"].length + ensure + clear + end + + def test_copying_migrations_with_timestamps + @migrations_path = MIGRATIONS_ROOT + "/valid_with_timestamps" + @existing_migrations = Dir[@migrations_path + "/*.rb"] + + travel_to(Time.utc(2010, 7, 26, 10, 10, 10)) do + copied = ActiveRecord::Migration.copy(@migrations_path, {:bukkits => MIGRATIONS_ROOT + "/to_copy_with_timestamps"}) + assert File.exist?(@migrations_path + "/20100726101010_people_have_hobbies.bukkits.rb") + assert File.exist?(@migrations_path + "/20100726101011_people_have_descriptions.bukkits.rb") + expected = [@migrations_path + "/20100726101010_people_have_hobbies.bukkits.rb", + @migrations_path + "/20100726101011_people_have_descriptions.bukkits.rb"] + assert_equal expected, copied.map(&:filename) + + files_count = Dir[@migrations_path + "/*.rb"].length + copied = ActiveRecord::Migration.copy(@migrations_path, {:bukkits => MIGRATIONS_ROOT + "/to_copy_with_timestamps"}) + assert_equal files_count, Dir[@migrations_path + "/*.rb"].length + assert copied.empty? + end + ensure + clear + end + + def test_copying_migrations_with_timestamps_from_2_sources + @migrations_path = MIGRATIONS_ROOT + "/valid_with_timestamps" + @existing_migrations = Dir[@migrations_path + "/*.rb"] + + sources = {} + sources[:bukkits] = MIGRATIONS_ROOT + "/to_copy_with_timestamps" + sources[:omg] = MIGRATIONS_ROOT + "/to_copy_with_timestamps2" + + travel_to(Time.utc(2010, 7, 26, 10, 10, 10)) do + copied = ActiveRecord::Migration.copy(@migrations_path, sources) + assert File.exist?(@migrations_path + "/20100726101010_people_have_hobbies.bukkits.rb") + assert File.exist?(@migrations_path + "/20100726101011_people_have_descriptions.bukkits.rb") + assert File.exist?(@migrations_path + "/20100726101012_create_articles.omg.rb") + assert File.exist?(@migrations_path + "/20100726101013_create_comments.omg.rb") + assert_equal 4, copied.length + + files_count = Dir[@migrations_path + "/*.rb"].length + ActiveRecord::Migration.copy(@migrations_path, sources) + assert_equal files_count, Dir[@migrations_path + "/*.rb"].length + end + ensure + clear + end + + def test_copying_migrations_with_timestamps_to_destination_with_timestamps_in_future + @migrations_path = MIGRATIONS_ROOT + "/valid_with_timestamps" + @existing_migrations = Dir[@migrations_path + "/*.rb"] + + travel_to(Time.utc(2010, 2, 20, 10, 10, 10)) do + ActiveRecord::Migration.copy(@migrations_path, {:bukkits => MIGRATIONS_ROOT + "/to_copy_with_timestamps"}) + assert File.exist?(@migrations_path + "/20100301010102_people_have_hobbies.bukkits.rb") + assert File.exist?(@migrations_path + "/20100301010103_people_have_descriptions.bukkits.rb") + + files_count = Dir[@migrations_path + "/*.rb"].length + copied = ActiveRecord::Migration.copy(@migrations_path, {:bukkits => MIGRATIONS_ROOT + "/to_copy_with_timestamps"}) + assert_equal files_count, Dir[@migrations_path + "/*.rb"].length + assert copied.empty? + end + ensure + clear + end + + def test_copying_migrations_preserving_magic_comments + ActiveRecord::Base.timestamped_migrations = false + @migrations_path = MIGRATIONS_ROOT + "/valid" + @existing_migrations = Dir[@migrations_path + "/*.rb"] + + copied = ActiveRecord::Migration.copy(@migrations_path, {:bukkits => MIGRATIONS_ROOT + "/magic"}) + assert File.exist?(@migrations_path + "/4_currencies_have_symbols.bukkits.rb") + assert_equal [@migrations_path + "/4_currencies_have_symbols.bukkits.rb"], copied.map(&:filename) + + expected = "# coding: ISO-8859-15\n# This migration comes from bukkits (originally 1)" + assert_equal expected, IO.readlines(@migrations_path + "/4_currencies_have_symbols.bukkits.rb")[0..1].join.chomp + + files_count = Dir[@migrations_path + "/*.rb"].length + copied = ActiveRecord::Migration.copy(@migrations_path, {:bukkits => MIGRATIONS_ROOT + "/magic"}) + assert_equal files_count, Dir[@migrations_path + "/*.rb"].length + assert copied.empty? + ensure + clear + end + + def test_skipping_migrations + @migrations_path = MIGRATIONS_ROOT + "/valid_with_timestamps" + @existing_migrations = Dir[@migrations_path + "/*.rb"] + + sources = {} + sources[:bukkits] = MIGRATIONS_ROOT + "/to_copy_with_timestamps" + sources[:omg] = MIGRATIONS_ROOT + "/to_copy_with_name_collision" + + skipped = [] + on_skip = Proc.new { |name, migration| skipped << "#{name} #{migration.name}" } + copied = ActiveRecord::Migration.copy(@migrations_path, sources, :on_skip => on_skip) + assert_equal 2, copied.length + + assert_equal 1, skipped.length + assert_equal ["omg PeopleHaveHobbies"], skipped + ensure + clear + end + + def test_skip_is_not_called_if_migrations_are_from_the_same_plugin + @migrations_path = MIGRATIONS_ROOT + "/valid_with_timestamps" + @existing_migrations = Dir[@migrations_path + "/*.rb"] + + sources = {} + sources[:bukkits] = MIGRATIONS_ROOT + "/to_copy_with_timestamps" + + skipped = [] + on_skip = Proc.new { |name, migration| skipped << "#{name} #{migration.name}" } + copied = ActiveRecord::Migration.copy(@migrations_path, sources, :on_skip => on_skip) + ActiveRecord::Migration.copy(@migrations_path, sources, :on_skip => on_skip) + + assert_equal 2, copied.length + assert_equal 0, skipped.length + ensure + clear + end + + def test_copying_migrations_to_non_existing_directory + @migrations_path = MIGRATIONS_ROOT + "/non_existing" + @existing_migrations = [] + + travel_to(Time.utc(2010, 7, 26, 10, 10, 10)) do + copied = ActiveRecord::Migration.copy(@migrations_path, {:bukkits => MIGRATIONS_ROOT + "/to_copy_with_timestamps"}) + assert File.exist?(@migrations_path + "/20100726101010_people_have_hobbies.bukkits.rb") + assert File.exist?(@migrations_path + "/20100726101011_people_have_descriptions.bukkits.rb") + assert_equal 2, copied.length + end + ensure + clear + Dir.delete(@migrations_path) + end + + def test_copying_migrations_to_empty_directory + @migrations_path = MIGRATIONS_ROOT + "/empty" + @existing_migrations = [] + + travel_to(Time.utc(2010, 7, 26, 10, 10, 10)) do + copied = ActiveRecord::Migration.copy(@migrations_path, {:bukkits => MIGRATIONS_ROOT + "/to_copy_with_timestamps"}) + assert File.exist?(@migrations_path + "/20100726101010_people_have_hobbies.bukkits.rb") + assert File.exist?(@migrations_path + "/20100726101011_people_have_descriptions.bukkits.rb") + assert_equal 2, copied.length + end + ensure + clear + end + + def test_check_pending_with_stdlib_logger + old, ActiveRecord::Base.logger = ActiveRecord::Base.logger, ::Logger.new($stdout) + quietly do + assert_nothing_raised { ActiveRecord::Migration::CheckPending.new(Proc.new {}).call({}) } + end + ensure + ActiveRecord::Base.logger = old + end + + private + + def quietly + silence_stream(STDOUT) do + silence_stream(STDERR) do + yield + end + end + end +end diff --git a/activerecord/test/cases/migrator_test.rb b/activerecord/test/cases/migrator_test.rb new file mode 100644 index 0000000000..9568aa2217 --- /dev/null +++ b/activerecord/test/cases/migrator_test.rb @@ -0,0 +1,377 @@ +require "cases/helper" +require "cases/migration/helper" + +module ActiveRecord + class MigratorTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + # Use this class to sense if migrations have gone + # up or down. + class Sensor < ActiveRecord::Migration + attr_reader :went_up, :went_down + + def initialize name = self.class.name, version = nil + super + @went_up = false + @went_down = false + end + + def up; @went_up = true; end + def down; @went_down = true; end + end + + def setup + super + ActiveRecord::SchemaMigration.create_table + ActiveRecord::SchemaMigration.delete_all rescue nil + end + + teardown do + ActiveRecord::SchemaMigration.delete_all rescue nil + ActiveRecord::Migration.verbose = true + end + + def test_migrator_with_duplicate_names + assert_raises(ActiveRecord::DuplicateMigrationNameError, "Multiple migrations have the name Chunky") do + list = [Migration.new('Chunky'), Migration.new('Chunky')] + ActiveRecord::Migrator.new(:up, list) + end + end + + def test_migrator_with_duplicate_versions + assert_raises(ActiveRecord::DuplicateMigrationVersionError) do + list = [Migration.new('Foo', 1), Migration.new('Bar', 1)] + ActiveRecord::Migrator.new(:up, list) + end + end + + def test_migrator_with_missing_version_numbers + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [Migration.new('Foo', 1), Migration.new('Bar', 2)] + ActiveRecord::Migrator.new(:up, list, 3).run + end + end + + def test_finds_migrations + migrations = ActiveRecord::Migrator.migrations(MIGRATIONS_ROOT + "/valid") + + [[1, 'ValidPeopleHaveLastNames'], [2, 'WeNeedReminders'], [3, 'InnocentJointable']].each_with_index do |pair, i| + assert_equal migrations[i].version, pair.first + assert_equal migrations[i].name, pair.last + end + end + + def test_finds_migrations_in_subdirectories + migrations = ActiveRecord::Migrator.migrations(MIGRATIONS_ROOT + "/valid_with_subdirectories") + + [[1, 'ValidPeopleHaveLastNames'], [2, 'WeNeedReminders'], [3, 'InnocentJointable']].each_with_index do |pair, i| + assert_equal migrations[i].version, pair.first + assert_equal migrations[i].name, pair.last + end + end + + def test_finds_migrations_from_two_directories + directories = [MIGRATIONS_ROOT + '/valid_with_timestamps', MIGRATIONS_ROOT + '/to_copy_with_timestamps'] + migrations = ActiveRecord::Migrator.migrations directories + + [[20090101010101, "PeopleHaveHobbies"], + [20090101010202, "PeopleHaveDescriptions"], + [20100101010101, "ValidWithTimestampsPeopleHaveLastNames"], + [20100201010101, "ValidWithTimestampsWeNeedReminders"], + [20100301010101, "ValidWithTimestampsInnocentJointable"]].each_with_index do |pair, i| + assert_equal pair.first, migrations[i].version + assert_equal pair.last, migrations[i].name + end + end + + def test_finds_migrations_in_numbered_directory + migrations = ActiveRecord::Migrator.migrations [MIGRATIONS_ROOT + '/10_urban'] + assert_equal 9, migrations[0].version + assert_equal 'AddExpressions', migrations[0].name + end + + def test_relative_migrations + list = Dir.chdir(MIGRATIONS_ROOT) do + ActiveRecord::Migrator.migrations("valid") + end + + migration_proxy = list.find { |item| + item.name == 'ValidPeopleHaveLastNames' + } + assert migration_proxy, 'should find pending migration' + end + + def test_finds_pending_migrations + ActiveRecord::SchemaMigration.create!(:version => '1') + migration_list = [ Migration.new('foo', 1), Migration.new('bar', 3) ] + migrations = ActiveRecord::Migrator.new(:up, migration_list).pending_migrations + + assert_equal 1, migrations.size + assert_equal migration_list.last, migrations.first + end + + def test_migrator_interleaved_migrations + pass_one = [Sensor.new('One', 1)] + + ActiveRecord::Migrator.new(:up, pass_one).migrate + assert pass_one.first.went_up + assert_not pass_one.first.went_down + + pass_two = [Sensor.new('One', 1), Sensor.new('Three', 3)] + ActiveRecord::Migrator.new(:up, pass_two).migrate + assert_not pass_two[0].went_up + assert pass_two[1].went_up + assert pass_two.all? { |x| !x.went_down } + + pass_three = [Sensor.new('One', 1), + Sensor.new('Two', 2), + Sensor.new('Three', 3)] + + ActiveRecord::Migrator.new(:down, pass_three).migrate + assert pass_three[0].went_down + assert_not pass_three[1].went_down + assert pass_three[2].went_down + end + + def test_up_calls_up + migrations = [Sensor.new(nil, 0), Sensor.new(nil, 1), Sensor.new(nil, 2)] + ActiveRecord::Migrator.new(:up, migrations).migrate + assert migrations.all? { |m| m.went_up } + assert migrations.all? { |m| !m.went_down } + assert_equal 2, ActiveRecord::Migrator.current_version + end + + def test_down_calls_down + test_up_calls_up + + migrations = [Sensor.new(nil, 0), Sensor.new(nil, 1), Sensor.new(nil, 2)] + ActiveRecord::Migrator.new(:down, migrations).migrate + assert migrations.all? { |m| !m.went_up } + assert migrations.all? { |m| m.went_down } + assert_equal 0, ActiveRecord::Migrator.current_version + end + + def test_current_version + ActiveRecord::SchemaMigration.create!(:version => '1000') + assert_equal 1000, ActiveRecord::Migrator.current_version + end + + def test_migrator_one_up + calls, migrations = sensors(3) + + ActiveRecord::Migrator.new(:up, migrations, 1).migrate + assert_equal [[:up, 1]], calls + calls.clear + + ActiveRecord::Migrator.new(:up, migrations, 2).migrate + assert_equal [[:up, 2]], calls + end + + def test_migrator_one_down + calls, migrations = sensors(3) + + ActiveRecord::Migrator.new(:up, migrations).migrate + assert_equal [[:up, 1], [:up, 2], [:up, 3]], calls + calls.clear + + ActiveRecord::Migrator.new(:down, migrations, 1).migrate + + assert_equal [[:down, 3], [:down, 2]], calls + end + + def test_migrator_one_up_one_down + calls, migrations = sensors(3) + + ActiveRecord::Migrator.new(:up, migrations, 1).migrate + assert_equal [[:up, 1]], calls + calls.clear + + ActiveRecord::Migrator.new(:down, migrations, 0).migrate + assert_equal [[:down, 1]], calls + end + + def test_migrator_double_up + calls, migrations = sensors(3) + assert_equal(0, ActiveRecord::Migrator.current_version) + + ActiveRecord::Migrator.new(:up, migrations, 1).migrate + assert_equal [[:up, 1]], calls + calls.clear + + ActiveRecord::Migrator.new(:up, migrations, 1).migrate + assert_equal [], calls + end + + def test_migrator_double_down + calls, migrations = sensors(3) + + assert_equal(0, ActiveRecord::Migrator.current_version) + + ActiveRecord::Migrator.new(:up, migrations, 1).run + assert_equal [[:up, 1]], calls + calls.clear + + ActiveRecord::Migrator.new(:down, migrations, 1).run + assert_equal [[:down, 1]], calls + calls.clear + + ActiveRecord::Migrator.new(:down, migrations, 1).run + assert_equal [], calls + + assert_equal(0, ActiveRecord::Migrator.current_version) + end + + def test_migrator_verbosity + _, migrations = sensors(3) + + ActiveRecord::Migrator.new(:up, migrations, 1).migrate + assert_not_equal 0, ActiveRecord::Migration.message_count + + ActiveRecord::Migration.message_count = 0 + + ActiveRecord::Migrator.new(:down, migrations, 0).migrate + assert_not_equal 0, ActiveRecord::Migration.message_count + ActiveRecord::Migration.message_count = 0 + end + + def test_migrator_verbosity_off + _, migrations = sensors(3) + + ActiveRecord::Migration.message_count = 0 + ActiveRecord::Migration.verbose = false + ActiveRecord::Migrator.new(:up, migrations, 1).migrate + assert_equal 0, ActiveRecord::Migration.message_count + ActiveRecord::Migrator.new(:down, migrations, 0).migrate + assert_equal 0, ActiveRecord::Migration.message_count + end + + def test_target_version_zero_should_run_only_once + calls, migrations = sensors(3) + + # migrate up to 1 + ActiveRecord::Migrator.new(:up, migrations, 1).migrate + assert_equal [[:up, 1]], calls + calls.clear + + # migrate down to 0 + ActiveRecord::Migrator.new(:down, migrations, 0).migrate + assert_equal [[:down, 1]], calls + calls.clear + + # migrate down to 0 again + ActiveRecord::Migrator.new(:down, migrations, 0).migrate + assert_equal [], calls + end + + def test_migrator_going_down_due_to_version_target + calls, migrator = migrator_class(3) + + migrator.up("valid", 1) + assert_equal [[:up, 1]], calls + calls.clear + + migrator.migrate("valid", 0) + assert_equal [[:down, 1]], calls + calls.clear + + migrator.migrate("valid") + assert_equal [[:up, 1], [:up, 2], [:up, 3]], calls + end + + def test_migrator_rollback + _, migrator = migrator_class(3) + + migrator.migrate("valid") + assert_equal(3, ActiveRecord::Migrator.current_version) + + migrator.rollback("valid") + assert_equal(2, ActiveRecord::Migrator.current_version) + + migrator.rollback("valid") + assert_equal(1, ActiveRecord::Migrator.current_version) + + migrator.rollback("valid") + assert_equal(0, ActiveRecord::Migrator.current_version) + + migrator.rollback("valid") + assert_equal(0, ActiveRecord::Migrator.current_version) + end + + def test_migrator_db_has_no_schema_migrations_table + _, migrator = migrator_class(3) + + ActiveRecord::Base.connection.execute("DROP TABLE schema_migrations") + assert_not ActiveRecord::Base.connection.table_exists?('schema_migrations') + migrator.migrate("valid", 1) + assert ActiveRecord::Base.connection.table_exists?('schema_migrations') + end + + def test_migrator_forward + _, migrator = migrator_class(3) + migrator.migrate("/valid", 1) + assert_equal(1, ActiveRecord::Migrator.current_version) + + migrator.forward("/valid", 2) + assert_equal(3, ActiveRecord::Migrator.current_version) + + migrator.forward("/valid") + assert_equal(3, ActiveRecord::Migrator.current_version) + end + + def test_only_loads_pending_migrations + # migrate up to 1 + ActiveRecord::SchemaMigration.create!(:version => '1') + + calls, migrator = migrator_class(3) + migrator.migrate("valid", nil) + + assert_equal [[:up, 2], [:up, 3]], calls + end + + def test_get_all_versions + _, migrator = migrator_class(3) + + migrator.migrate("valid") + assert_equal([1,2,3], ActiveRecord::Migrator.get_all_versions) + + migrator.rollback("valid") + assert_equal([1,2], ActiveRecord::Migrator.get_all_versions) + + migrator.rollback("valid") + assert_equal([1], ActiveRecord::Migrator.get_all_versions) + + migrator.rollback("valid") + assert_equal([], ActiveRecord::Migrator.get_all_versions) + end + + private + def m(name, version, &block) + x = Sensor.new name, version + x.extend(Module.new { + define_method(:up) { block.call(:up, x); super() } + define_method(:down) { block.call(:down, x); super() } + }) if block_given? + end + + def sensors(count) + calls = [] + migrations = count.times.map { |i| + m(nil, i + 1) { |c,migration| + calls << [c, migration.version] + } + } + [calls, migrations] + end + + def migrator_class(count) + calls, migrations = sensors(count) + + migrator = Class.new(Migrator).extend(Module.new { + define_method(:migrations) { |paths| + migrations + } + }) + [calls, migrator] + end + end +end diff --git a/activerecord/test/cases/mixin_test.rb b/activerecord/test/cases/mixin_test.rb new file mode 100644 index 0000000000..7ddb2bfee1 --- /dev/null +++ b/activerecord/test/cases/mixin_test.rb @@ -0,0 +1,70 @@ +require "cases/helper" + +class Mixin < ActiveRecord::Base +end + +class TouchTest < ActiveRecord::TestCase + fixtures :mixins + + setup do + travel_to Time.now + end + + teardown do + travel_back + end + + def test_update + stamped = Mixin.new + + assert_nil stamped.updated_at + assert_nil stamped.created_at + stamped.save + assert_equal Time.now, stamped.updated_at + assert_equal Time.now, stamped.created_at + end + + def test_create + obj = Mixin.create + assert_equal Time.now, obj.updated_at + assert_equal Time.now, obj.created_at + end + + def test_many_updates + stamped = Mixin.new + + assert_nil stamped.updated_at + assert_nil stamped.created_at + stamped.save + assert_equal Time.now, stamped.created_at + assert_equal Time.now, stamped.updated_at + + old_updated_at = stamped.updated_at + + travel 5.minutes do + stamped.lft_will_change! + stamped.save + + assert_equal Time.now, stamped.updated_at + assert_equal old_updated_at, stamped.created_at + end + end + + def test_create_turned_off + Mixin.record_timestamps = false + + mixin = Mixin.new + + assert_nil mixin.updated_at + mixin.save + assert_nil mixin.updated_at + + # Make sure Mixin.record_timestamps gets reset, even if this test fails, + # so that other tests do not fail because Mixin.record_timestamps == false + rescue Exception => e + raise e + ensure + Mixin.record_timestamps = true + end + +end diff --git a/activerecord/test/cases/modules_test.rb b/activerecord/test/cases/modules_test.rb new file mode 100644 index 0000000000..e87773df94 --- /dev/null +++ b/activerecord/test/cases/modules_test.rb @@ -0,0 +1,172 @@ +require "cases/helper" +require 'models/company_in_module' +require 'models/shop' +require 'models/developer' + +class ModulesTest < ActiveRecord::TestCase + fixtures :accounts, :companies, :projects, :developers, :collections, :products, :variants + + def setup + # need to make sure Object::Firm and Object::Client are not defined, + # so that constantize will not be able to cheat when having to load namespaced classes + @undefined_consts = {} + + [:Firm, :Client].each do |const| + @undefined_consts.merge! const => Object.send(:remove_const, const) if Object.const_defined?(const) + end + + ActiveRecord::Base.store_full_sti_class = false + end + + teardown do + # reinstate the constants that we undefined in the setup + @undefined_consts.each do |constant, value| + Object.send :const_set, constant, value unless value.nil? + end + + ActiveRecord::Base.store_full_sti_class = true + end + + def test_module_spanning_associations + firm = MyApplication::Business::Firm.first + assert !firm.clients.empty?, "Firm should have clients" + assert_nil firm.class.table_name.match('::'), "Firm shouldn't have the module appear in its table name" + end + + def test_module_spanning_has_and_belongs_to_many_associations + project = MyApplication::Business::Project.first + project.developers << MyApplication::Business::Developer.create("name" => "John") + assert_equal "John", project.developers.last.name + end + + def test_associations_spanning_cross_modules + account = MyApplication::Billing::Account.all.merge!(:order => 'id').first + assert_kind_of MyApplication::Business::Firm, account.firm + assert_kind_of MyApplication::Billing::Firm, account.qualified_billing_firm + assert_kind_of MyApplication::Billing::Firm, account.unqualified_billing_firm + assert_kind_of MyApplication::Billing::Nested::Firm, account.nested_qualified_billing_firm + assert_kind_of MyApplication::Billing::Nested::Firm, account.nested_unqualified_billing_firm + end + + def test_find_account_and_include_company + account = MyApplication::Billing::Account.all.merge!(:includes => :firm).find(1) + assert_kind_of MyApplication::Business::Firm, account.firm + end + + def test_table_name + assert_equal 'accounts', MyApplication::Billing::Account.table_name, 'table_name for ActiveRecord model in module' + assert_equal 'companies', MyApplication::Business::Client.table_name, 'table_name for ActiveRecord model subclass' + assert_equal 'company_contacts', MyApplication::Business::Client::Contact.table_name, 'table_name for ActiveRecord model enclosed by another ActiveRecord model' + end + + def test_assign_ids + firm = MyApplication::Business::Firm.first + + assert_nothing_raised NameError, "Should be able to resolve all class constants via reflection" do + firm.client_ids = [MyApplication::Business::Client.first.id] + end + end + + # need to add an eager loading condition to force the eager loading model into + # the old join model, to test that. See http://dev.rubyonrails.org/ticket/9640 + def test_eager_loading_in_modules + clients = [] + + assert_nothing_raised NameError, "Should be able to resolve all class constants via reflection" do + clients << MyApplication::Business::Client.references(:accounts).merge!(:includes => {:firm => :account}, :where => 'accounts.id IS NOT NULL').find(3) + clients << MyApplication::Business::Client.includes(:firm => :account).find(3) + end + + clients.each do |client| + assert_no_queries do + assert_not_nil(client.firm.account) + end + end + end + + def test_module_table_name_prefix + assert_equal 'prefixed_companies', MyApplication::Business::Prefixed::Company.table_name, 'inferred table_name for ActiveRecord model in module with table_name_prefix' + assert_equal 'prefixed_companies', MyApplication::Business::Prefixed::Nested::Company.table_name, 'table_name for ActiveRecord model in nested module with a parent table_name_prefix' + assert_equal 'companies', MyApplication::Business::Prefixed::Firm.table_name, 'explicit table_name for ActiveRecord model in module with table_name_prefix should not be prefixed' + end + + def test_module_table_name_prefix_with_global_prefix + classes = [ MyApplication::Business::Company, + MyApplication::Business::Firm, + MyApplication::Business::Client, + MyApplication::Business::Client::Contact, + MyApplication::Business::Developer, + MyApplication::Business::Project, + MyApplication::Business::Prefixed::Company, + MyApplication::Business::Prefixed::Nested::Company, + MyApplication::Billing::Account ] + + ActiveRecord::Base.table_name_prefix = 'global_' + classes.each(&:reset_table_name) + assert_equal 'global_companies', MyApplication::Business::Company.table_name, 'inferred table_name for ActiveRecord model in module without table_name_prefix' + assert_equal 'prefixed_companies', MyApplication::Business::Prefixed::Company.table_name, 'inferred table_name for ActiveRecord model in module with table_name_prefix' + assert_equal 'prefixed_companies', MyApplication::Business::Prefixed::Nested::Company.table_name, 'table_name for ActiveRecord model in nested module with a parent table_name_prefix' + assert_equal 'companies', MyApplication::Business::Prefixed::Firm.table_name, 'explicit table_name for ActiveRecord model in module with table_name_prefix should not be prefixed' + ensure + ActiveRecord::Base.table_name_prefix = '' + classes.each(&:reset_table_name) + end + + def test_module_table_name_suffix + assert_equal 'companies_suffixed', MyApplication::Business::Suffixed::Company.table_name, 'inferred table_name for ActiveRecord model in module with table_name_suffix' + assert_equal 'companies_suffixed', MyApplication::Business::Suffixed::Nested::Company.table_name, 'table_name for ActiveRecord model in nested module with a parent table_name_suffix' + assert_equal 'companies', MyApplication::Business::Suffixed::Firm.table_name, 'explicit table_name for ActiveRecord model in module with table_name_suffix should not be suffixed' + end + + def test_module_table_name_suffix_with_global_suffix + classes = [ MyApplication::Business::Company, + MyApplication::Business::Firm, + MyApplication::Business::Client, + MyApplication::Business::Client::Contact, + MyApplication::Business::Developer, + MyApplication::Business::Project, + MyApplication::Business::Suffixed::Company, + MyApplication::Business::Suffixed::Nested::Company, + MyApplication::Billing::Account ] + + ActiveRecord::Base.table_name_suffix = '_global' + classes.each(&:reset_table_name) + assert_equal 'companies_global', MyApplication::Business::Company.table_name, 'inferred table_name for ActiveRecord model in module without table_name_suffix' + assert_equal 'companies_suffixed', MyApplication::Business::Suffixed::Company.table_name, 'inferred table_name for ActiveRecord model in module with table_name_suffix' + assert_equal 'companies_suffixed', MyApplication::Business::Suffixed::Nested::Company.table_name, 'table_name for ActiveRecord model in nested module with a parent table_name_suffix' + assert_equal 'companies', MyApplication::Business::Suffixed::Firm.table_name, 'explicit table_name for ActiveRecord model in module with table_name_suffix should not be suffixed' + ensure + ActiveRecord::Base.table_name_suffix = '' + classes.each(&:reset_table_name) + end + + def test_compute_type_can_infer_class_name_of_sibling_inside_module + old = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = true + assert_equal MyApplication::Business::Firm, MyApplication::Business::Client.send(:compute_type, "Firm") + ensure + ActiveRecord::Base.store_full_sti_class = old + end + + def test_nested_models_should_not_raise_exception_when_using_delete_all_dependency_on_association + old = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = true + + collection = Shop::Collection.first + assert !collection.products.empty?, "Collection should have products" + assert_nothing_raised { collection.destroy } + ensure + ActiveRecord::Base.store_full_sti_class = old + end + + def test_nested_models_should_not_raise_exception_when_using_nullify_dependency_on_association + old = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = true + + product = Shop::Product.first + assert !product.variants.empty?, "Product should have variants" + assert_nothing_raised { product.destroy } + ensure + ActiveRecord::Base.store_full_sti_class = old + end +end diff --git a/activerecord/test/cases/multiparameter_attributes_test.rb b/activerecord/test/cases/multiparameter_attributes_test.rb new file mode 100644 index 0000000000..14d4ef457d --- /dev/null +++ b/activerecord/test/cases/multiparameter_attributes_test.rb @@ -0,0 +1,350 @@ +require "cases/helper" +require 'models/topic' +require 'models/customer' + +class MultiParameterAttributeTest < ActiveRecord::TestCase + fixtures :topics + + def test_multiparameter_attributes_on_date + attributes = { "last_read(1i)" => "2004", "last_read(2i)" => "6", "last_read(3i)" => "24" } + topic = Topic.find(1) + topic.attributes = attributes + # note that extra #to_date call allows test to pass for Oracle, which + # treats dates/times the same + assert_date_from_db Date.new(2004, 6, 24), topic.last_read.to_date + end + + def test_multiparameter_attributes_on_date_with_empty_year + attributes = { "last_read(1i)" => "", "last_read(2i)" => "6", "last_read(3i)" => "24" } + topic = Topic.find(1) + topic.attributes = attributes + # note that extra #to_date call allows test to pass for Oracle, which + # treats dates/times the same + assert_nil topic.last_read + end + + def test_multiparameter_attributes_on_date_with_empty_month + attributes = { "last_read(1i)" => "2004", "last_read(2i)" => "", "last_read(3i)" => "24" } + topic = Topic.find(1) + topic.attributes = attributes + # note that extra #to_date call allows test to pass for Oracle, which + # treats dates/times the same + assert_nil topic.last_read + end + + def test_multiparameter_attributes_on_date_with_empty_day + attributes = { "last_read(1i)" => "2004", "last_read(2i)" => "6", "last_read(3i)" => "" } + topic = Topic.find(1) + topic.attributes = attributes + # note that extra #to_date call allows test to pass for Oracle, which + # treats dates/times the same + assert_nil topic.last_read + end + + def test_multiparameter_attributes_on_date_with_empty_day_and_year + attributes = { "last_read(1i)" => "", "last_read(2i)" => "6", "last_read(3i)" => "" } + topic = Topic.find(1) + topic.attributes = attributes + # note that extra #to_date call allows test to pass for Oracle, which + # treats dates/times the same + assert_nil topic.last_read + end + + def test_multiparameter_attributes_on_date_with_empty_day_and_month + attributes = { "last_read(1i)" => "2004", "last_read(2i)" => "", "last_read(3i)" => "" } + topic = Topic.find(1) + topic.attributes = attributes + # note that extra #to_date call allows test to pass for Oracle, which + # treats dates/times the same + assert_nil topic.last_read + end + + def test_multiparameter_attributes_on_date_with_empty_year_and_month + attributes = { "last_read(1i)" => "", "last_read(2i)" => "", "last_read(3i)" => "24" } + topic = Topic.find(1) + topic.attributes = attributes + # note that extra #to_date call allows test to pass for Oracle, which + # treats dates/times the same + assert_nil topic.last_read + end + + def test_multiparameter_attributes_on_date_with_all_empty + attributes = { "last_read(1i)" => "", "last_read(2i)" => "", "last_read(3i)" => "" } + topic = Topic.find(1) + topic.attributes = attributes + assert_nil topic.last_read + end + + def test_multiparameter_attributes_on_time + with_timezone_config default: :local do + attributes = { + "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24", + "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_equal Time.local(2004, 6, 24, 16, 24, 0), topic.written_on + end + end + + def test_multiparameter_attributes_on_time_with_no_date + ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do + attributes = { + "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00" + } + topic = Topic.find(1) + topic.attributes = attributes + end + assert_equal("written_on", ex.errors[0].attribute) + end + + def test_multiparameter_attributes_on_time_with_invalid_time_params + ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do + attributes = { + "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24", + "written_on(4i)" => "2004", "written_on(5i)" => "36", "written_on(6i)" => "64", + } + topic = Topic.find(1) + topic.attributes = attributes + end + assert_equal("written_on", ex.errors[0].attribute) + end + + def test_multiparameter_attributes_on_time_with_old_date + attributes = { + "written_on(1i)" => "1850", "written_on(2i)" => "6", "written_on(3i)" => "24", + "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00" + } + topic = Topic.find(1) + topic.attributes = attributes + # testing against to_s(:db) representation because either a Time or a DateTime might be returned, depending on platform + assert_equal "1850-06-24 16:24:00", topic.written_on.to_s(:db) + end + + def test_multiparameter_attributes_on_time_will_raise_on_big_time_if_missing_date_parts + ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do + attributes = { + "written_on(4i)" => "16", "written_on(5i)" => "24" + } + topic = Topic.find(1) + topic.attributes = attributes + end + assert_equal("written_on", ex.errors[0].attribute) + end + + def test_multiparameter_attributes_on_time_with_raise_on_small_time_if_missing_date_parts + ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do + attributes = { + "written_on(4i)" => "16", "written_on(5i)" => "12", "written_on(6i)" => "02" + } + topic = Topic.find(1) + topic.attributes = attributes + end + assert_equal("written_on", ex.errors[0].attribute) + end + + def test_multiparameter_attributes_on_time_will_ignore_hour_if_missing + with_timezone_config default: :local do + attributes = { + "written_on(1i)" => "2004", "written_on(2i)" => "12", "written_on(3i)" => "12", + "written_on(5i)" => "12", "written_on(6i)" => "02" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_equal Time.local(2004, 12, 12, 0, 12, 2), topic.written_on + end + end + + def test_multiparameter_attributes_on_time_will_ignore_hour_if_blank + attributes = { + "written_on(1i)" => "", "written_on(2i)" => "", "written_on(3i)" => "", + "written_on(4i)" => "", "written_on(5i)" => "12", "written_on(6i)" => "02" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_nil topic.written_on + end + + def test_multiparameter_attributes_on_time_will_ignore_date_if_empty + attributes = { + "written_on(1i)" => "", "written_on(2i)" => "", "written_on(3i)" => "", + "written_on(4i)" => "16", "written_on(5i)" => "24" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_nil topic.written_on + end + + def test_multiparameter_attributes_on_time_with_seconds_will_ignore_date_if_empty + attributes = { + "written_on(1i)" => "", "written_on(2i)" => "", "written_on(3i)" => "", + "written_on(4i)" => "16", "written_on(5i)" => "12", "written_on(6i)" => "02" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_nil topic.written_on + end + + def test_multiparameter_attributes_on_time_with_utc + with_timezone_config default: :utc do + attributes = { + "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24", + "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_equal Time.utc(2004, 6, 24, 16, 24, 0), topic.written_on + end + end + + def test_multiparameter_attributes_on_time_with_time_zone_aware_attributes + with_timezone_config default: :utc, aware_attributes: true, zone: -28800 do + attributes = { + "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24", + "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_equal Time.utc(2004, 6, 24, 23, 24, 0), topic.written_on + assert_equal Time.utc(2004, 6, 24, 16, 24, 0), topic.written_on.time + assert_equal Time.zone, topic.written_on.time_zone + end + end + + def test_multiparameter_attributes_on_time_with_time_zone_aware_attributes_false + with_timezone_config default: :local, aware_attributes: false, zone: -28800 do + attributes = { + "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24", + "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_equal Time.local(2004, 6, 24, 16, 24, 0), topic.written_on + assert_equal false, topic.written_on.respond_to?(:time_zone) + end + end + + def test_multiparameter_attributes_on_time_with_skip_time_zone_conversion_for_attributes + with_timezone_config default: :utc, aware_attributes: true, zone: -28800 do + Topic.skip_time_zone_conversion_for_attributes = [:written_on] + attributes = { + "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24", + "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_equal Time.utc(2004, 6, 24, 16, 24, 0), topic.written_on + assert_equal false, topic.written_on.respond_to?(:time_zone) + end + ensure + Topic.skip_time_zone_conversion_for_attributes = [] + end + + # Oracle does not have a TIME datatype. + unless current_adapter?(:OracleAdapter) + def test_multiparameter_attributes_on_time_only_column_with_time_zone_aware_attributes_does_not_do_time_zone_conversion + with_timezone_config default: :utc, aware_attributes: true, zone: -28800 do + attributes = { + "bonus_time(1i)" => "2000", "bonus_time(2i)" => "1", "bonus_time(3i)" => "1", + "bonus_time(4i)" => "16", "bonus_time(5i)" => "24" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_equal Time.utc(2000, 1, 1, 16, 24, 0), topic.bonus_time + assert topic.bonus_time.utc? + end + end + end + + def test_multiparameter_attributes_on_time_with_empty_seconds + with_timezone_config default: :local do + attributes = { + "written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24", + "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_equal Time.local(2004, 6, 24, 16, 24, 0), topic.written_on + end + end + + unless current_adapter? :OracleAdapter + def test_multiparameter_attributes_setting_time_attribute + topic = Topic.new( "bonus_time(4i)"=> "01", "bonus_time(5i)" => "05" ) + assert_equal 1, topic.bonus_time.hour + assert_equal 5, topic.bonus_time.min + end + end + + def test_multiparameter_attributes_setting_date_attribute + topic = Topic.new( "written_on(1i)" => "1952", "written_on(2i)" => "3", "written_on(3i)" => "11" ) + assert_equal 1952, topic.written_on.year + assert_equal 3, topic.written_on.month + assert_equal 11, topic.written_on.day + end + + def test_multiparameter_attributes_setting_date_and_time_attribute + topic = Topic.new( + "written_on(1i)" => "1952", + "written_on(2i)" => "3", + "written_on(3i)" => "11", + "written_on(4i)" => "13", + "written_on(5i)" => "55") + assert_equal 1952, topic.written_on.year + assert_equal 3, topic.written_on.month + assert_equal 11, topic.written_on.day + assert_equal 13, topic.written_on.hour + assert_equal 55, topic.written_on.min + end + + def test_multiparameter_attributes_setting_time_but_not_date_on_date_field + assert_raise( ActiveRecord::MultiparameterAssignmentErrors ) do + Topic.new( "written_on(4i)" => "13", "written_on(5i)" => "55" ) + end + end + + def test_multiparameter_assignment_of_aggregation + customer = Customer.new + address = Address.new("The Street", "The City", "The Country") + attributes = { "address(1)" => address.street, "address(2)" => address.city, "address(3)" => address.country } + customer.attributes = attributes + assert_equal address, customer.address + end + + def test_multiparameter_assignment_of_aggregation_out_of_order + customer = Customer.new + address = Address.new("The Street", "The City", "The Country") + attributes = { "address(3)" => address.country, "address(2)" => address.city, "address(1)" => address.street } + customer.attributes = attributes + assert_equal address, customer.address + end + + def test_multiparameter_assignment_of_aggregation_with_missing_values + ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do + customer = Customer.new + address = Address.new("The Street", "The City", "The Country") + attributes = { "address(2)" => address.city, "address(3)" => address.country } + customer.attributes = attributes + end + assert_equal("address", ex.errors[0].attribute) + end + + def test_multiparameter_assignment_of_aggregation_with_blank_values + customer = Customer.new + address = Address.new("The Street", "The City", "The Country") + attributes = { "address(1)" => "", "address(2)" => address.city, "address(3)" => address.country } + customer.attributes = attributes + assert_equal Address.new(nil, "The City", "The Country"), customer.address + end + + def test_multiparameter_assignment_of_aggregation_with_large_index + ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do + customer = Customer.new + address = Address.new("The Street", "The City", "The Country") + attributes = { "address(1)" => "The Street", "address(2)" => address.city, "address(3000)" => address.country } + customer.attributes = attributes + end + + assert_equal("address", ex.errors[0].attribute) + end +end diff --git a/activerecord/test/cases/multiple_db_test.rb b/activerecord/test/cases/multiple_db_test.rb new file mode 100644 index 0000000000..3831de6ae3 --- /dev/null +++ b/activerecord/test/cases/multiple_db_test.rb @@ -0,0 +1,108 @@ +require "cases/helper" +require 'models/entrant' +require 'models/bird' +require 'models/course' + +class MultipleDbTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + def setup + @courses = create_fixtures("courses") { Course.retrieve_connection } + @colleges = create_fixtures("colleges") { College.retrieve_connection } + @entrants = create_fixtures("entrants") + end + + def test_connected + assert_not_nil Entrant.connection + assert_not_nil Course.connection + end + + def test_proper_connection + assert_not_equal(Entrant.connection, Course.connection) + assert_equal(Entrant.connection, Entrant.retrieve_connection) + assert_equal(Course.connection, Course.retrieve_connection) + assert_equal(ActiveRecord::Base.connection, Entrant.connection) + end + + def test_find + c1 = Course.find(1) + assert_equal "Ruby Development", c1.name + c2 = Course.find(2) + assert_equal "Java Development", c2.name + e1 = Entrant.find(1) + assert_equal "Ruby Developer", e1.name + e2 = Entrant.find(2) + assert_equal "Ruby Guru", e2.name + e3 = Entrant.find(3) + assert_equal "Java Lover", e3.name + end + + def test_associations + c1 = Course.find(1) + assert_equal 2, c1.entrants.count + e1 = Entrant.find(1) + assert_equal e1.course.id, c1.id + c2 = Course.find(2) + assert_equal 1, c2.entrants.count + e3 = Entrant.find(3) + assert_equal e3.course.id, c2.id + end + + def test_course_connection_should_survive_dependency_reload + assert Course.connection + + ActiveSupport::Dependencies.clear + Object.send(:remove_const, :Course) + require_dependency 'models/course' + + 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 + + def test_arel_table_engines + assert_not_equal Entrant.arel_engine, Bird.arel_engine + assert_not_equal Entrant.arel_engine, Course.arel_engine + end + + def test_connection + assert_equal Entrant.arel_engine.connection, Bird.arel_engine.connection + assert_not_equal Entrant.arel_engine.connection, Course.arel_engine.connection + end + + unless in_memory_db? + def test_associations_should_work_when_model_has_no_connection + begin + ActiveRecord::Base.remove_connection + assert_nothing_raised ActiveRecord::ConnectionNotEstablished do + College.first.courses.first + end + ensure + ActiveRecord::Base.establish_connection :arunit + end + end + end +end diff --git a/activerecord/test/cases/nested_attributes_test.rb b/activerecord/test/cases/nested_attributes_test.rb new file mode 100644 index 0000000000..cf96c3fccf --- /dev/null +++ b/activerecord/test/cases/nested_attributes_test.rb @@ -0,0 +1,1040 @@ +require "cases/helper" +require "models/pirate" +require "models/ship" +require "models/ship_part" +require "models/bird" +require "models/parrot" +require "models/treasure" +require "models/man" +require "models/interest" +require "models/owner" +require "models/pet" +require 'active_support/hash_with_indifferent_access' + +class TestNestedAttributesInGeneral < ActiveRecord::TestCase + teardown do + Pirate.accepts_nested_attributes_for :ship, :allow_destroy => true, :reject_if => proc { |attributes| attributes.empty? } + end + + def test_base_should_have_an_empty_nested_attributes_options + assert_equal Hash.new, ActiveRecord::Base.nested_attributes_options + end + + def test_should_add_a_proc_to_nested_attributes_options + assert_equal ActiveRecord::NestedAttributes::ClassMethods::REJECT_ALL_BLANK_PROC, + Pirate.nested_attributes_options[:birds_with_reject_all_blank][:reject_if] + + [:parrots, :birds].each do |name| + assert_instance_of Proc, Pirate.nested_attributes_options[name][:reject_if] + end + end + + def test_should_not_build_a_new_record_using_reject_all_even_if_destroy_is_given + pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?") + pirate.birds_with_reject_all_blank_attributes = [{:name => '', :color => '', :_destroy => '0'}] + pirate.save! + + assert pirate.birds_with_reject_all_blank.empty? + end + + def test_should_not_build_a_new_record_if_reject_all_blank_returns_false + pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?") + pirate.birds_with_reject_all_blank_attributes = [{:name => '', :color => ''}] + pirate.save! + + assert pirate.birds_with_reject_all_blank.empty? + end + + def test_should_build_a_new_record_if_reject_all_blank_does_not_return_false + pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?") + pirate.birds_with_reject_all_blank_attributes = [{:name => 'Tweetie', :color => ''}] + pirate.save! + + assert_equal 1, pirate.birds_with_reject_all_blank.count + assert_equal 'Tweetie', pirate.birds_with_reject_all_blank.first.name + end + + def test_should_raise_an_ArgumentError_for_non_existing_associations + exception = assert_raise ArgumentError do + Pirate.accepts_nested_attributes_for :honesty + end + assert_equal "No association found for name `honesty'. Has it been defined yet?", exception.message + end + + def test_should_disable_allow_destroy_by_default + Pirate.accepts_nested_attributes_for :ship + + pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?") + ship = pirate.create_ship(name: 'Nights Dirty Lightning') + + pirate.update(ship_attributes: { '_destroy' => true, :id => ship.id }) + + assert_nothing_raised(ActiveRecord::RecordNotFound) { pirate.ship.reload } + end + + def test_a_model_should_respond_to_underscore_destroy_and_return_if_it_is_marked_for_destruction + ship = Ship.create!(:name => 'Nights Dirty Lightning') + assert !ship._destroy + ship.mark_for_destruction + assert ship._destroy + end + + def test_reject_if_method_without_arguments + Pirate.accepts_nested_attributes_for :ship, :reject_if => :new_record? + + pirate = Pirate.new(:catchphrase => "Stop wastin' me time") + pirate.ship_attributes = { :name => 'Black Pearl' } + assert_no_difference('Ship.count') { pirate.save! } + end + + def test_reject_if_method_with_arguments + Pirate.accepts_nested_attributes_for :ship, :reject_if => :reject_empty_ships_on_create + + pirate = Pirate.new(:catchphrase => "Stop wastin' me time") + pirate.ship_attributes = { :name => 'Red Pearl', :_reject_me_if_new => true } + assert_no_difference('Ship.count') { pirate.save! } + + # pirate.reject_empty_ships_on_create returns false for saved pirate records + # in the previous step note that pirate gets saved but ship fails + pirate.ship_attributes = { :name => 'Red Pearl', :_reject_me_if_new => true } + assert_difference('Ship.count') { pirate.save! } + end + + def test_reject_if_with_indifferent_keys + Pirate.accepts_nested_attributes_for :ship, :reject_if => proc {|attributes| attributes[:name].blank? } + + pirate = Pirate.new(:catchphrase => "Stop wastin' me time") + pirate.ship_attributes = { :name => 'Hello Pearl' } + assert_difference('Ship.count') { pirate.save! } + end + + def test_reject_if_with_a_proc_which_returns_true_always_for_has_one + Pirate.accepts_nested_attributes_for :ship, :reject_if => proc {|attributes| true } + pirate = Pirate.new(catchphrase: "Stop wastin' me time") + ship = pirate.create_ship(name: 's1') + pirate.update({ship_attributes: { name: 's2', id: ship.id } }) + assert_equal 's1', ship.reload.name + end + + def test_reuse_already_built_new_record + pirate = Pirate.new + ship_built_first = pirate.build_ship + pirate.ship_attributes = { name: 'Ship 1' } + assert_equal ship_built_first.object_id, pirate.ship.object_id + end + + def test_do_not_allow_assigning_foreign_key_when_reusing_existing_new_record + pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?") + pirate.build_ship + pirate.ship_attributes = { name: 'Ship 1', pirate_id: pirate.id + 1 } + assert_equal pirate.id, pirate.ship.pirate_id + end + + def test_reject_if_with_a_proc_which_returns_true_always_for_has_many + Man.accepts_nested_attributes_for :interests, :reject_if => proc {|attributes| true } + man = Man.create(name: "John") + interest = man.interests.create(topic: 'photography') + man.update({interests_attributes: { topic: 'gardening', id: interest.id } }) + assert_equal 'photography', interest.reload.topic + end + + def test_destroy_works_independent_of_reject_if + Man.accepts_nested_attributes_for :interests, :reject_if => proc {|attributes| true }, :allow_destroy => true + man = Man.create(name: "Jon") + interest = man.interests.create(topic: 'the ladies') + man.update({interests_attributes: { _destroy: "1", id: interest.id } }) + assert man.reload.interests.empty? + end + + def test_has_many_association_updating_a_single_record + Man.accepts_nested_attributes_for(:interests) + man = Man.create(name: 'John') + interest = man.interests.create(topic: 'photography') + man.update({interests_attributes: {topic: 'gardening', id: interest.id}}) + assert_equal 'gardening', interest.reload.topic + end + + def test_reject_if_with_blank_nested_attributes_id + # When using a select list to choose an existing 'ship' id, with include_blank: true + Pirate.accepts_nested_attributes_for :ship, :reject_if => proc {|attributes| attributes[:id].blank? } + + pirate = Pirate.new(:catchphrase => "Stop wastin' me time") + pirate.ship_attributes = { :id => "" } + assert_nothing_raised(ActiveRecord::RecordNotFound) { pirate.save! } + end + + def test_first_and_array_index_zero_methods_return_the_same_value_when_nested_attributes_are_set_to_update_existing_record + Man.accepts_nested_attributes_for(:interests) + man = Man.create(:name => "John") + interest = man.interests.create :topic => 'gardening' + man = Man.find man.id + man.interests_attributes = [{:id => interest.id, :topic => 'gardening'}] + assert_equal man.interests.first.topic, man.interests[0].topic + end + + def test_allows_class_to_override_setter_and_call_super + mean_pirate_class = Class.new(Pirate) do + accepts_nested_attributes_for :parrot + def parrot_attributes=(attrs) + super(attrs.merge(:color => "blue")) + end + end + mean_pirate = mean_pirate_class.new + mean_pirate.parrot_attributes = { :name => "James" } + assert_equal "James", mean_pirate.parrot.name + assert_equal "blue", mean_pirate.parrot.color + end + + def test_accepts_nested_attributes_for_can_be_overridden_in_subclasses + Pirate.accepts_nested_attributes_for(:parrot) + + mean_pirate_class = Class.new(Pirate) do + accepts_nested_attributes_for :parrot + end + mean_pirate = mean_pirate_class.new + mean_pirate.parrot_attributes = { :name => "James" } + assert_equal "James", mean_pirate.parrot.name + end +end + +class TestNestedAttributesOnAHasOneAssociation < ActiveRecord::TestCase + def setup + @pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?") + @ship = @pirate.create_ship(:name => 'Nights Dirty Lightning') + end + + def test_should_raise_argument_error_if_trying_to_build_polymorphic_belongs_to + exception = assert_raise ArgumentError do + Treasure.new(:name => 'pearl', :looter_attributes => {:catchphrase => "Arrr"}) + end + assert_equal "Cannot build association `looter'. Are you trying to build a polymorphic one-to-one association?", exception.message + end + + def test_should_define_an_attribute_writer_method_for_the_association + assert_respond_to @pirate, :ship_attributes= + end + + def test_should_build_a_new_record_if_there_is_no_id + @ship.destroy + @pirate.reload.ship_attributes = { :name => 'Davy Jones Gold Dagger' } + + assert !@pirate.ship.persisted? + assert_equal 'Davy Jones Gold Dagger', @pirate.ship.name + end + + def test_should_not_build_a_new_record_if_there_is_no_id_and_destroy_is_truthy + @ship.destroy + @pirate.reload.ship_attributes = { :name => 'Davy Jones Gold Dagger', :_destroy => '1' } + + assert_nil @pirate.ship + end + + def test_should_not_build_a_new_record_if_a_reject_if_proc_returns_false + @ship.destroy + @pirate.reload.ship_attributes = {} + + assert_nil @pirate.ship + end + + def test_should_replace_an_existing_record_if_there_is_no_id + @pirate.reload.ship_attributes = { :name => 'Davy Jones Gold Dagger' } + + assert !@pirate.ship.persisted? + assert_equal 'Davy Jones Gold Dagger', @pirate.ship.name + assert_equal 'Nights Dirty Lightning', @ship.name + end + + def test_should_not_replace_an_existing_record_if_there_is_no_id_and_destroy_is_truthy + @pirate.reload.ship_attributes = { :name => 'Davy Jones Gold Dagger', :_destroy => '1' } + + assert_equal @ship, @pirate.ship + assert_equal 'Nights Dirty Lightning', @pirate.ship.name + end + + def test_should_modify_an_existing_record_if_there_is_a_matching_id + @pirate.reload.ship_attributes = { :id => @ship.id, :name => 'Davy Jones Gold Dagger' } + + assert_equal @ship, @pirate.ship + assert_equal 'Davy Jones Gold Dagger', @pirate.ship.name + end + + def test_should_raise_RecordNotFound_if_an_id_is_given_but_doesnt_return_a_record + exception = assert_raise ActiveRecord::RecordNotFound do + @pirate.ship_attributes = { :id => 1234567890 } + end + assert_equal "Couldn't find Ship with ID=1234567890 for Pirate with ID=#{@pirate.id}", exception.message + end + + def test_should_take_a_hash_with_string_keys_and_update_the_associated_model + @pirate.reload.ship_attributes = { 'id' => @ship.id, 'name' => 'Davy Jones Gold Dagger' } + + assert_equal @ship, @pirate.ship + assert_equal 'Davy Jones Gold Dagger', @pirate.ship.name + end + + def test_should_modify_an_existing_record_if_there_is_a_matching_composite_id + @ship.stubs(:id).returns('ABC1X') + @pirate.ship_attributes = { :id => @ship.id, :name => 'Davy Jones Gold Dagger' } + + assert_equal 'Davy Jones Gold Dagger', @pirate.ship.name + end + + def test_should_destroy_an_existing_record_if_there_is_a_matching_id_and_destroy_is_truthy + @pirate.ship.destroy + + [1, '1', true, 'true'].each do |truth| + ship = @pirate.reload.create_ship(name: 'Mister Pablo') + @pirate.update(ship_attributes: { id: ship.id, _destroy: truth }) + + assert_nil @pirate.reload.ship + assert_raise(ActiveRecord::RecordNotFound) { Ship.find(ship.id) } + end + end + + def test_should_not_destroy_an_existing_record_if_destroy_is_not_truthy + [nil, '0', 0, 'false', false].each do |not_truth| + @pirate.update(ship_attributes: { id: @pirate.ship.id, _destroy: not_truth }) + + assert_equal @ship, @pirate.reload.ship + end + end + + def test_should_not_destroy_an_existing_record_if_allow_destroy_is_false + Pirate.accepts_nested_attributes_for :ship, :allow_destroy => false, :reject_if => proc { |attributes| attributes.empty? } + + @pirate.update(ship_attributes: { id: @pirate.ship.id, _destroy: '1' }) + + assert_equal @ship, @pirate.reload.ship + + Pirate.accepts_nested_attributes_for :ship, :allow_destroy => true, :reject_if => proc { |attributes| attributes.empty? } + end + + def test_should_also_work_with_a_HashWithIndifferentAccess + @pirate.ship_attributes = ActiveSupport::HashWithIndifferentAccess.new(:id => @ship.id, :name => 'Davy Jones Gold Dagger') + + assert @pirate.ship.persisted? + assert_equal 'Davy Jones Gold Dagger', @pirate.ship.name + end + + def test_should_work_with_update_as_well + @pirate.update({ catchphrase: 'Arr', ship_attributes: { id: @ship.id, name: 'Mister Pablo' } }) + @pirate.reload + + assert_equal 'Arr', @pirate.catchphrase + assert_equal 'Mister Pablo', @pirate.ship.name + end + + def test_should_not_destroy_the_associated_model_until_the_parent_is_saved + @pirate.attributes = { :ship_attributes => { :id => @ship.id, :_destroy => '1' } } + + assert !@pirate.ship.destroyed? + assert @pirate.ship.marked_for_destruction? + + @pirate.save + + assert @pirate.ship.destroyed? + assert_nil @pirate.reload.ship + end + + def test_should_automatically_enable_autosave_on_the_association + assert Pirate.reflect_on_association(:ship).options[:autosave] + end + + def test_should_accept_update_only_option + @pirate.update(update_only_ship_attributes: { id: @pirate.ship.id, name: 'Mayflower' }) + end + + def test_should_create_new_model_when_nothing_is_there_and_update_only_is_true + @ship.delete + + @pirate.reload.update(update_only_ship_attributes: { name: 'Mayflower' }) + + assert_not_nil @pirate.ship + end + + def test_should_update_existing_when_update_only_is_true_and_no_id_is_given + @ship.delete + @ship = @pirate.create_update_only_ship(name: 'Nights Dirty Lightning') + + @pirate.update(update_only_ship_attributes: { name: 'Mayflower' }) + + assert_equal 'Mayflower', @ship.reload.name + assert_equal @ship, @pirate.reload.ship + end + + def test_should_update_existing_when_update_only_is_true_and_id_is_given + @ship.delete + @ship = @pirate.create_update_only_ship(name: 'Nights Dirty Lightning') + + @pirate.update(update_only_ship_attributes: { name: 'Mayflower', id: @ship.id }) + + assert_equal 'Mayflower', @ship.reload.name + assert_equal @ship, @pirate.reload.ship + end + + def test_should_destroy_existing_when_update_only_is_true_and_id_is_given_and_is_marked_for_destruction + Pirate.accepts_nested_attributes_for :update_only_ship, :update_only => true, :allow_destroy => true + @ship.delete + @ship = @pirate.create_update_only_ship(name: 'Nights Dirty Lightning') + + @pirate.update(update_only_ship_attributes: { name: 'Mayflower', id: @ship.id, _destroy: true }) + + assert_nil @pirate.reload.ship + assert_raise(ActiveRecord::RecordNotFound) { Ship.find(@ship.id) } + + Pirate.accepts_nested_attributes_for :update_only_ship, :update_only => true, :allow_destroy => false + end + +end + +class TestNestedAttributesOnABelongsToAssociation < ActiveRecord::TestCase + def setup + @ship = Ship.new(:name => 'Nights Dirty Lightning') + @pirate = @ship.build_pirate(:catchphrase => 'Aye') + @ship.save! + end + + def test_should_define_an_attribute_writer_method_for_the_association + assert_respond_to @ship, :pirate_attributes= + end + + def test_should_build_a_new_record_if_there_is_no_id + @pirate.destroy + @ship.reload.pirate_attributes = { :catchphrase => 'Arr' } + + assert !@ship.pirate.persisted? + assert_equal 'Arr', @ship.pirate.catchphrase + end + + def test_should_not_build_a_new_record_if_there_is_no_id_and_destroy_is_truthy + @pirate.destroy + @ship.reload.pirate_attributes = { :catchphrase => 'Arr', :_destroy => '1' } + + assert_nil @ship.pirate + end + + def test_should_not_build_a_new_record_if_a_reject_if_proc_returns_false + @pirate.destroy + @ship.reload.pirate_attributes = {} + + assert_nil @ship.pirate + end + + def test_should_replace_an_existing_record_if_there_is_no_id + @ship.reload.pirate_attributes = { :catchphrase => 'Arr' } + + assert !@ship.pirate.persisted? + assert_equal 'Arr', @ship.pirate.catchphrase + assert_equal 'Aye', @pirate.catchphrase + end + + def test_should_not_replace_an_existing_record_if_there_is_no_id_and_destroy_is_truthy + @ship.reload.pirate_attributes = { :catchphrase => 'Arr', :_destroy => '1' } + + assert_equal @pirate, @ship.pirate + assert_equal 'Aye', @ship.pirate.catchphrase + end + + def test_should_modify_an_existing_record_if_there_is_a_matching_id + @ship.reload.pirate_attributes = { :id => @pirate.id, :catchphrase => 'Arr' } + + assert_equal @pirate, @ship.pirate + assert_equal 'Arr', @ship.pirate.catchphrase + end + + def test_should_raise_RecordNotFound_if_an_id_is_given_but_doesnt_return_a_record + exception = assert_raise ActiveRecord::RecordNotFound do + @ship.pirate_attributes = { :id => 1234567890 } + end + assert_equal "Couldn't find Pirate with ID=1234567890 for Ship with ID=#{@ship.id}", exception.message + end + + def test_should_take_a_hash_with_string_keys_and_update_the_associated_model + @ship.reload.pirate_attributes = { 'id' => @pirate.id, 'catchphrase' => 'Arr' } + + assert_equal @pirate, @ship.pirate + assert_equal 'Arr', @ship.pirate.catchphrase + end + + def test_should_modify_an_existing_record_if_there_is_a_matching_composite_id + @pirate.stubs(:id).returns('ABC1X') + @ship.pirate_attributes = { :id => @pirate.id, :catchphrase => 'Arr' } + + assert_equal 'Arr', @ship.pirate.catchphrase + end + + def test_should_destroy_an_existing_record_if_there_is_a_matching_id_and_destroy_is_truthy + @ship.pirate.destroy + [1, '1', true, 'true'].each do |truth| + pirate = @ship.reload.create_pirate(catchphrase: 'Arr') + @ship.update(pirate_attributes: { id: pirate.id, _destroy: truth }) + assert_raise(ActiveRecord::RecordNotFound) { pirate.reload } + end + end + + def test_should_unset_association_when_an_existing_record_is_destroyed + original_pirate_id = @ship.pirate.id + @ship.update! pirate_attributes: { id: @ship.pirate.id, _destroy: true } + + assert_empty Pirate.where(id: original_pirate_id) + assert_nil @ship.pirate_id + assert_nil @ship.pirate + + @ship.reload + assert_empty Pirate.where(id: original_pirate_id) + assert_nil @ship.pirate_id + assert_nil @ship.pirate + end + + def test_should_not_destroy_an_existing_record_if_destroy_is_not_truthy + [nil, '0', 0, 'false', false].each do |not_truth| + @ship.update(pirate_attributes: { id: @ship.pirate.id, _destroy: not_truth }) + assert_nothing_raised(ActiveRecord::RecordNotFound) { @ship.pirate.reload } + end + end + + def test_should_not_destroy_an_existing_record_if_allow_destroy_is_false + Ship.accepts_nested_attributes_for :pirate, :allow_destroy => false, :reject_if => proc { |attributes| attributes.empty? } + + @ship.update(pirate_attributes: { id: @ship.pirate.id, _destroy: '1' }) + assert_nothing_raised(ActiveRecord::RecordNotFound) { @ship.pirate.reload } + ensure + Ship.accepts_nested_attributes_for :pirate, :allow_destroy => true, :reject_if => proc { |attributes| attributes.empty? } + end + + def test_should_work_with_update_as_well + @ship.update({ name: 'Mister Pablo', pirate_attributes: { catchphrase: 'Arr' } }) + @ship.reload + + assert_equal 'Mister Pablo', @ship.name + assert_equal 'Arr', @ship.pirate.catchphrase + end + + def test_should_not_destroy_the_associated_model_until_the_parent_is_saved + pirate = @ship.pirate + + @ship.attributes = { :pirate_attributes => { :id => pirate.id, '_destroy' => true } } + assert_nothing_raised(ActiveRecord::RecordNotFound) { Pirate.find(pirate.id) } + @ship.save + assert_raise(ActiveRecord::RecordNotFound) { Pirate.find(pirate.id) } + end + + def test_should_automatically_enable_autosave_on_the_association + assert Ship.reflect_on_association(:pirate).options[:autosave] + end + + def test_should_create_new_model_when_nothing_is_there_and_update_only_is_true + @pirate.delete + @ship.reload.attributes = { :update_only_pirate_attributes => { :catchphrase => 'Arr' } } + + assert !@ship.update_only_pirate.persisted? + end + + def test_should_update_existing_when_update_only_is_true_and_no_id_is_given + @pirate.delete + @pirate = @ship.create_update_only_pirate(catchphrase: 'Aye') + + @ship.update(update_only_pirate_attributes: { catchphrase: 'Arr' }) + assert_equal 'Arr', @pirate.reload.catchphrase + assert_equal @pirate, @ship.reload.update_only_pirate + end + + def test_should_update_existing_when_update_only_is_true_and_id_is_given + @pirate.delete + @pirate = @ship.create_update_only_pirate(catchphrase: 'Aye') + + @ship.update(update_only_pirate_attributes: { catchphrase: 'Arr', id: @pirate.id }) + + assert_equal 'Arr', @pirate.reload.catchphrase + assert_equal @pirate, @ship.reload.update_only_pirate + end + + def test_should_destroy_existing_when_update_only_is_true_and_id_is_given_and_is_marked_for_destruction + Ship.accepts_nested_attributes_for :update_only_pirate, :update_only => true, :allow_destroy => true + @pirate.delete + @pirate = @ship.create_update_only_pirate(catchphrase: 'Aye') + + @ship.update(update_only_pirate_attributes: { catchphrase: 'Arr', id: @pirate.id, _destroy: true }) + + assert_raise(ActiveRecord::RecordNotFound) { @pirate.reload } + + Ship.accepts_nested_attributes_for :update_only_pirate, :update_only => true, :allow_destroy => false + end +end + +module NestedAttributesOnACollectionAssociationTests + def test_should_define_an_attribute_writer_method_for_the_association + assert_respond_to @pirate, association_setter + end + + def test_should_save_only_one_association_on_create + pirate = Pirate.create!({ + :catchphrase => 'Arr', + association_getter => { 'foo' => { :name => 'Grace OMalley' } } + }) + + assert_equal 1, pirate.reload.send(@association_name).count + end + + def test_should_take_a_hash_with_string_keys_and_assign_the_attributes_to_the_associated_models + @alternate_params[association_getter].stringify_keys! + @pirate.update @alternate_params + assert_equal ['Grace OMalley', 'Privateers Greed'], [@child_1.reload.name, @child_2.reload.name] + end + + def test_should_take_an_array_and_assign_the_attributes_to_the_associated_models + @pirate.send(association_setter, @alternate_params[association_getter].values) + @pirate.save + assert_equal ['Grace OMalley', 'Privateers Greed'], [@child_1.reload.name, @child_2.reload.name] + end + + def test_should_also_work_with_a_HashWithIndifferentAccess + @pirate.send(association_setter, ActiveSupport::HashWithIndifferentAccess.new('foo' => ActiveSupport::HashWithIndifferentAccess.new(:id => @child_1.id, :name => 'Grace OMalley'))) + @pirate.save + assert_equal 'Grace OMalley', @child_1.reload.name + end + + def test_should_take_a_hash_and_assign_the_attributes_to_the_associated_models + @pirate.attributes = @alternate_params + assert_equal 'Grace OMalley', @pirate.send(@association_name).first.name + assert_equal 'Privateers Greed', @pirate.send(@association_name).last.name + end + + def test_should_not_load_association_when_updating_existing_records + @pirate.reload + @pirate.send(association_setter, [{ :id => @child_1.id, :name => 'Grace OMalley' }]) + assert ! @pirate.send(@association_name).loaded? + + @pirate.save + assert ! @pirate.send(@association_name).loaded? + assert_equal 'Grace OMalley', @child_1.reload.name + end + + def test_should_not_overwrite_unsaved_updates_when_loading_association + @pirate.reload + @pirate.send(association_setter, [{ :id => @child_1.id, :name => 'Grace OMalley' }]) + assert_equal 'Grace OMalley', @pirate.send(@association_name).send(:load_target).find { |r| r.id == @child_1.id }.name + end + + def test_should_preserve_order_when_not_overwriting_unsaved_updates + @pirate.reload + @pirate.send(association_setter, [{ :id => @child_1.id, :name => 'Grace OMalley' }]) + assert_equal @child_1.id, @pirate.send(@association_name).send(:load_target).first.id + end + + def test_should_refresh_saved_records_when_not_overwriting_unsaved_updates + @pirate.reload + record = @pirate.class.reflect_on_association(@association_name).klass.new(name: 'Grace OMalley') + @pirate.send(@association_name) << record + record.save! + @pirate.send(@association_name).last.update!(name: 'Polly') + assert_equal 'Polly', @pirate.send(@association_name).send(:load_target).last.name + end + + def test_should_not_remove_scheduled_destroys_when_loading_association + @pirate.reload + @pirate.send(association_setter, [{ :id => @child_1.id, :_destroy => '1' }]) + assert @pirate.send(@association_name).send(:load_target).find { |r| r.id == @child_1.id }.marked_for_destruction? + end + + def test_should_take_a_hash_with_composite_id_keys_and_assign_the_attributes_to_the_associated_models + @child_1.stubs(:id).returns('ABC1X') + @child_2.stubs(:id).returns('ABC2X') + + @pirate.attributes = { + association_getter => [ + { :id => @child_1.id, :name => 'Grace OMalley' }, + { :id => @child_2.id, :name => 'Privateers Greed' } + ] + } + + assert_equal ['Grace OMalley', 'Privateers Greed'], [@child_1.name, @child_2.name] + end + + def test_should_raise_RecordNotFound_if_an_id_is_given_but_doesnt_return_a_record + exception = assert_raise ActiveRecord::RecordNotFound do + @pirate.attributes = { association_getter => [{ :id => 1234567890 }] } + end + assert_equal "Couldn't find #{@child_1.class.name} with ID=1234567890 for Pirate with ID=#{@pirate.id}", exception.message + end + + def test_should_automatically_build_new_associated_models_for_each_entry_in_a_hash_where_the_id_is_missing + @pirate.send(@association_name).destroy_all + @pirate.reload.attributes = { + association_getter => { 'foo' => { :name => 'Grace OMalley' }, 'bar' => { :name => 'Privateers Greed' }} + } + + assert !@pirate.send(@association_name).first.persisted? + assert_equal 'Grace OMalley', @pirate.send(@association_name).first.name + + assert !@pirate.send(@association_name).last.persisted? + assert_equal 'Privateers Greed', @pirate.send(@association_name).last.name + end + + def test_should_not_assign_destroy_key_to_a_record + assert_nothing_raised ActiveRecord::UnknownAttributeError do + @pirate.send(association_setter, { 'foo' => { '_destroy' => '0' }}) + end + end + + def test_should_ignore_new_associated_records_with_truthy_destroy_attribute + @pirate.send(@association_name).destroy_all + @pirate.reload.attributes = { + association_getter => { + 'foo' => { :name => 'Grace OMalley' }, + 'bar' => { :name => 'Privateers Greed', '_destroy' => '1' } + } + } + + assert_equal 1, @pirate.send(@association_name).length + assert_equal 'Grace OMalley', @pirate.send(@association_name).first.name + end + + def test_should_ignore_new_associated_records_if_a_reject_if_proc_returns_false + @alternate_params[association_getter]['baz'] = {} + assert_no_difference("@pirate.send(@association_name).count") do + @pirate.attributes = @alternate_params + end + end + + def test_should_sort_the_hash_by_the_keys_before_building_new_associated_models + attributes = {} + attributes['123726353'] = { :name => 'Grace OMalley' } + attributes['2'] = { :name => 'Privateers Greed' } # 2 is lower then 123726353 + @pirate.send(association_setter, attributes) + + assert_equal ['Posideons Killer', 'Killer bandita Dionne', 'Privateers Greed', 'Grace OMalley'].to_set, @pirate.send(@association_name).map(&:name).to_set + end + + def test_should_raise_an_argument_error_if_something_else_than_a_hash_is_passed + assert_nothing_raised(ArgumentError) { @pirate.send(association_setter, {}) } + assert_nothing_raised(ArgumentError) { @pirate.send(association_setter, Hash.new) } + + exception = assert_raise ArgumentError do + @pirate.send(association_setter, "foo") + end + assert_equal 'Hash or Array expected, got String ("foo")', exception.message + end + + def test_should_work_with_update_as_well + @pirate.update(catchphrase: 'Arr', + association_getter => { 'foo' => { :id => @child_1.id, :name => 'Grace OMalley' }}) + + assert_equal 'Grace OMalley', @child_1.reload.name + end + + def test_should_update_existing_records_and_add_new_ones_that_have_no_id + @alternate_params[association_getter]['baz'] = { name: 'Buccaneers Servant' } + assert_difference('@pirate.send(@association_name).count', +1) do + @pirate.update @alternate_params + end + assert_equal ['Grace OMalley', 'Privateers Greed', 'Buccaneers Servant'].to_set, @pirate.reload.send(@association_name).map(&:name).to_set + end + + def test_should_be_possible_to_destroy_a_record + ['1', 1, 'true', true].each do |true_variable| + record = @pirate.reload.send(@association_name).create!(:name => 'Grace OMalley') + @pirate.send(association_setter, + @alternate_params[association_getter].merge('baz' => { :id => record.id, '_destroy' => true_variable }) + ) + + assert_difference('@pirate.send(@association_name).count', -1) do + @pirate.save + end + end + end + + def test_should_not_destroy_the_associated_model_with_a_non_truthy_argument + [nil, '', '0', 0, 'false', false].each do |false_variable| + @alternate_params[association_getter]['foo']['_destroy'] = false_variable + assert_no_difference('@pirate.send(@association_name).count') do + @pirate.update(@alternate_params) + end + end + end + + def test_should_not_destroy_the_associated_model_until_the_parent_is_saved + assert_no_difference('@pirate.send(@association_name).count') do + @pirate.send(association_setter, @alternate_params[association_getter].merge('baz' => { :id => @child_1.id, '_destroy' => true })) + end + assert_difference('@pirate.send(@association_name).count', -1) { @pirate.save } + end + + def test_should_automatically_enable_autosave_on_the_association + assert Pirate.reflect_on_association(@association_name).options[:autosave] + end + + def test_validate_presence_of_parent_works_with_inverse_of + Man.accepts_nested_attributes_for(:interests) + assert_equal :man, Man.reflect_on_association(:interests).options[:inverse_of] + assert_equal :interests, Interest.reflect_on_association(:man).options[:inverse_of] + + repair_validations(Interest) do + Interest.validates_presence_of(:man) + assert_difference 'Man.count' do + assert_difference 'Interest.count', 2 do + man = Man.create!(:name => 'John', + :interests_attributes => [{:topic=>'Cars'}, {:topic=>'Sports'}]) + assert_equal 2, man.interests.count + end + end + end + end + + def test_can_use_symbols_as_object_identifier + @pirate.attributes = { :parrots_attributes => { :foo => { :name => 'Lovely Day' }, :bar => { :name => 'Blown Away' } } } + assert_nothing_raised(NoMethodError) { @pirate.save! } + end + + def test_numeric_column_changes_from_zero_to_no_empty_string + Man.accepts_nested_attributes_for(:interests) + + repair_validations(Interest) do + Interest.validates_numericality_of(:zine_id) + man = Man.create(name: 'John') + interest = man.interests.create(topic: 'bar', zine_id: 0) + assert interest.save + assert !man.update({interests_attributes: { id: interest.id, zine_id: 'foo' }}) + end + end + + private + + def association_setter + @association_setter ||= "#{@association_name}_attributes=".to_sym + end + + def association_getter + @association_getter ||= "#{@association_name}_attributes".to_sym + end +end + +class TestNestedAttributesOnAHasManyAssociation < ActiveRecord::TestCase + def setup + @association_type = :has_many + @association_name = :birds + + @pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?") + @pirate.birds.create!(:name => 'Posideons Killer') + @pirate.birds.create!(:name => 'Killer bandita Dionne') + + @child_1, @child_2 = @pirate.birds + + @alternate_params = { + :birds_attributes => { + 'foo' => { :id => @child_1.id, :name => 'Grace OMalley' }, + 'bar' => { :id => @child_2.id, :name => 'Privateers Greed' } + } + } + end + + include NestedAttributesOnACollectionAssociationTests +end + +class TestNestedAttributesOnAHasAndBelongsToManyAssociation < ActiveRecord::TestCase + def setup + @association_type = :has_and_belongs_to_many + @association_name = :parrots + + @pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?") + @pirate.parrots.create!(:name => 'Posideons Killer') + @pirate.parrots.create!(:name => 'Killer bandita Dionne') + + @child_1, @child_2 = @pirate.parrots + + @alternate_params = { + :parrots_attributes => { + 'foo' => { :id => @child_1.id, :name => 'Grace OMalley' }, + 'bar' => { :id => @child_2.id, :name => 'Privateers Greed' } + } + } + end + + include NestedAttributesOnACollectionAssociationTests +end + +module NestedAttributesLimitTests + def teardown + Pirate.accepts_nested_attributes_for :parrots, :allow_destroy => true, :reject_if => proc { |attributes| attributes.empty? } + end + + def test_limit_with_less_records + @pirate.attributes = { :parrots_attributes => { 'foo' => { :name => 'Big Big Love' } } } + assert_difference('Parrot.count') { @pirate.save! } + end + + def test_limit_with_number_exact_records + @pirate.attributes = { :parrots_attributes => { 'foo' => { :name => 'Lovely Day' }, 'bar' => { :name => 'Blown Away' } } } + assert_difference('Parrot.count', 2) { @pirate.save! } + end + + def test_limit_with_exceeding_records + assert_raises(ActiveRecord::NestedAttributes::TooManyRecords) do + @pirate.attributes = { :parrots_attributes => { 'foo' => { :name => 'Lovely Day' }, + 'bar' => { :name => 'Blown Away' }, + 'car' => { :name => 'The Happening' }} } + end + end +end + +class TestNestedAttributesLimitNumeric < ActiveRecord::TestCase + def setup + Pirate.accepts_nested_attributes_for :parrots, :limit => 2 + + @pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?") + end + + include NestedAttributesLimitTests +end + +class TestNestedAttributesLimitSymbol < ActiveRecord::TestCase + def setup + Pirate.accepts_nested_attributes_for :parrots, :limit => :parrots_limit + + @pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?", :parrots_limit => 2) + end + + include NestedAttributesLimitTests +end + +class TestNestedAttributesLimitProc < ActiveRecord::TestCase + def setup + Pirate.accepts_nested_attributes_for :parrots, :limit => proc { 2 } + + @pirate = Pirate.create!(:catchphrase => "Don' botharrr talkin' like one, savvy?") + end + + include NestedAttributesLimitTests +end + +class TestNestedAttributesWithNonStandardPrimaryKeys < ActiveRecord::TestCase + fixtures :owners, :pets + + def setup + Owner.accepts_nested_attributes_for :pets, :allow_destroy => true + + @owner = owners(:ashley) + @pet1, @pet2 = pets(:chew), pets(:mochi) + + @params = { + :pets_attributes => { + '0' => { :id => @pet1.id, :name => 'Foo' }, + '1' => { :id => @pet2.id, :name => 'Bar' } + } + } + end + + def test_should_update_existing_records_with_non_standard_primary_key + @owner.update(@params) + assert_equal ['Foo', 'Bar'], @owner.pets.map(&:name) + end + + def test_attr_accessor_of_child_should_be_value_provided_during_update + @owner = owners(:ashley) + @pet1 = pets(:chew) + attributes = {:pets_attributes => { "1"=> { :id => @pet1.id, + :name => "Foo2", + :current_user => "John", + :_destroy=>true }}} + @owner.update(attributes) + assert_equal 'John', Pet.after_destroy_output + end + +end + +class TestHasOneAutosaveAssociationWhichItselfHasAutosaveAssociations < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + @pirate = Pirate.create!(:catchphrase => "My baby takes tha mornin' train!") + @ship = @pirate.create_ship(:name => "The good ship Dollypop") + @part = @ship.parts.create!(:name => "Mast") + @trinket = @part.trinkets.create!(:name => "Necklace") + end + + test "when great-grandchild changed in memory, saving parent should save great-grandchild" do + @trinket.name = "changed" + @pirate.save + assert_equal "changed", @trinket.reload.name + end + + test "when great-grandchild changed via attributes, saving parent should save great-grandchild" do + @pirate.attributes = {:ship_attributes => {:id => @ship.id, :parts_attributes => [{:id => @part.id, :trinkets_attributes => [{:id => @trinket.id, :name => "changed"}]}]}} + @pirate.save + assert_equal "changed", @trinket.reload.name + end + + test "when great-grandchild marked_for_destruction via attributes, saving parent should destroy great-grandchild" do + @pirate.attributes = {:ship_attributes => {:id => @ship.id, :parts_attributes => [{:id => @part.id, :trinkets_attributes => [{:id => @trinket.id, :_destroy => true}]}]}} + assert_difference('@part.trinkets.count', -1) { @pirate.save } + end + + test "when great-grandchild added via attributes, saving parent should create great-grandchild" do + @pirate.attributes = {:ship_attributes => {:id => @ship.id, :parts_attributes => [{:id => @part.id, :trinkets_attributes => [{:name => "created"}]}]}} + assert_difference('@part.trinkets.count', 1) { @pirate.save } + end + + test "when extra records exist for associations, validate (which calls nested_records_changed_for_autosave?) should not load them up" do + @trinket.name = "changed" + Ship.create!(:pirate => @pirate, :name => "The Black Rock") + ShipPart.create!(:ship => @ship, :name => "Stern") + assert_no_queries { @pirate.valid? } + end +end + +class TestHasManyAutosaveAssociationWhichItselfHasAutosaveAssociations < ActiveRecord::TestCase + self.use_transactional_fixtures = false unless supports_savepoints? + + def setup + @ship = Ship.create!(:name => "The good ship Dollypop") + @part = @ship.parts.create!(:name => "Mast") + @trinket = @part.trinkets.create!(:name => "Necklace") + end + + test "if association is not loaded and association record is saved and then in memory record attributes should be saved" do + @ship.parts_attributes=[{:id => @part.id,:name =>'Deck'}] + assert_equal 1, @ship.association(:parts).target.size + assert_equal 'Deck', @ship.parts[0].name + end + + test "if association is not loaded and child doesn't change and I am saving a grandchild then in memory record should be used" do + @ship.parts_attributes=[{:id => @part.id,:trinkets_attributes =>[{:id => @trinket.id, :name => 'Ruby'}]}] + assert_equal 1, @ship.association(:parts).target.size + assert_equal 'Mast', @ship.parts[0].name + assert_no_difference("@ship.parts[0].association(:trinkets).target.size") do + @ship.parts[0].association(:trinkets).target.size + end + assert_equal 'Ruby', @ship.parts[0].trinkets[0].name + @ship.save + assert_equal 'Ruby', @ship.parts[0].trinkets[0].name + end + + test "when grandchild changed in memory, saving parent should save grandchild" do + @trinket.name = "changed" + @ship.save + assert_equal "changed", @trinket.reload.name + end + + test "when grandchild changed via attributes, saving parent should save grandchild" do + @ship.attributes = {:parts_attributes => [{:id => @part.id, :trinkets_attributes => [{:id => @trinket.id, :name => "changed"}]}]} + @ship.save + assert_equal "changed", @trinket.reload.name + end + + test "when grandchild marked_for_destruction via attributes, saving parent should destroy grandchild" do + @ship.attributes = {:parts_attributes => [{:id => @part.id, :trinkets_attributes => [{:id => @trinket.id, :_destroy => true}]}]} + assert_difference('@part.trinkets.count', -1) { @ship.save } + end + + test "when grandchild added via attributes, saving parent should create grandchild" do + @ship.attributes = {:parts_attributes => [{:id => @part.id, :trinkets_attributes => [{:name => "created"}]}]} + assert_difference('@part.trinkets.count', 1) { @ship.save } + end + + test "when extra records exist for associations, validate (which calls nested_records_changed_for_autosave?) should not load them up" do + @trinket.name = "changed" + Ship.create!(:name => "The Black Rock") + ShipPart.create!(:ship => @ship, :name => "Stern") + assert_no_queries { @ship.valid? } + end +end diff --git a/activerecord/test/cases/nested_attributes_with_callbacks_test.rb b/activerecord/test/cases/nested_attributes_with_callbacks_test.rb new file mode 100644 index 0000000000..43a69928b6 --- /dev/null +++ b/activerecord/test/cases/nested_attributes_with_callbacks_test.rb @@ -0,0 +1,144 @@ +require "cases/helper" +require "models/pirate" +require "models/bird" + +class NestedAttributesWithCallbacksTest < ActiveRecord::TestCase + Pirate.has_many(:birds_with_add_load, + :class_name => "Bird", + :before_add => proc { |p,b| + @@add_callback_called << b + p.birds_with_add_load.to_a + }) + Pirate.has_many(:birds_with_add, + :class_name => "Bird", + :before_add => proc { |p,b| @@add_callback_called << b }) + + Pirate.accepts_nested_attributes_for(:birds_with_add_load, + :birds_with_add, + :allow_destroy => true) + + def setup + @@add_callback_called = [] + @pirate = Pirate.new.tap do |pirate| + pirate.catchphrase = "Don't call me!" + pirate.birds_attributes = [{:name => 'Bird1'},{:name => 'Bird2'}] + pirate.save! + end + @birds = @pirate.birds.to_a + end + + def bird_to_update + @birds[0] + end + + def bird_to_destroy + @birds[1] + end + + def existing_birds_attributes + @birds.map do |bird| + bird.attributes.slice("id","name") + end + end + + def new_birds + @pirate.birds_with_add.to_a - @birds + end + + def new_bird_attributes + [{'name' => "New Bird"}] + end + + def destroy_bird_attributes + [{'id' => bird_to_destroy.id.to_s, "_destroy" => true}] + end + + def update_new_and_destroy_bird_attributes + [{'id' => @birds[0].id.to_s, 'name' => 'New Name'}, + {'name' => "New Bird"}, + {'id' => bird_to_destroy.id.to_s, "_destroy" => true}] + end + + # Characterizing when :before_add callback is called + test ":before_add called for new bird when not loaded" do + assert_not @pirate.birds_with_add.loaded? + @pirate.birds_with_add_attributes = new_bird_attributes + assert_new_bird_with_callback_called + end + + test ":before_add called for new bird when loaded" do + @pirate.birds_with_add.load_target + @pirate.birds_with_add_attributes = new_bird_attributes + assert_new_bird_with_callback_called + end + + def assert_new_bird_with_callback_called + assert_equal(1, new_birds.size) + assert_equal(new_birds, @@add_callback_called) + end + + test ":before_add not called for identical assignment when not loaded" do + assert_not @pirate.birds_with_add.loaded? + @pirate.birds_with_add_attributes = existing_birds_attributes + assert_callbacks_not_called + end + + test ":before_add not called for identical assignment when loaded" do + @pirate.birds_with_add.load_target + @pirate.birds_with_add_attributes = existing_birds_attributes + assert_callbacks_not_called + end + + test ":before_add not called for destroy assignment when not loaded" do + assert_not @pirate.birds_with_add.loaded? + @pirate.birds_with_add_attributes = destroy_bird_attributes + assert_callbacks_not_called + end + + test ":before_add not called for deletion assignment when loaded" do + @pirate.birds_with_add.load_target + @pirate.birds_with_add_attributes = destroy_bird_attributes + assert_callbacks_not_called + end + + def assert_callbacks_not_called + assert_empty new_birds + assert_empty @@add_callback_called + end + + # Ensuring that the records in the association target are updated, + # whether the association is loaded before or not + test "Assignment updates records in target when not loaded" do + assert_not @pirate.birds_with_add.loaded? + @pirate.birds_with_add_attributes = update_new_and_destroy_bird_attributes + assert_assignment_affects_records_in_target(:birds_with_add) + end + + test "Assignment updates records in target when loaded" do + @pirate.birds_with_add.load_target + @pirate.birds_with_add_attributes = update_new_and_destroy_bird_attributes + assert_assignment_affects_records_in_target(:birds_with_add) + end + + test("Assignment updates records in target when not loaded" + + " and callback loads target") do + assert_not @pirate.birds_with_add_load.loaded? + @pirate.birds_with_add_load_attributes = update_new_and_destroy_bird_attributes + assert_assignment_affects_records_in_target(:birds_with_add_load) + end + + test("Assignment updates records in target when loaded" + + " and callback loads target") do + @pirate.birds_with_add_load.load_target + @pirate.birds_with_add_load_attributes = update_new_and_destroy_bird_attributes + assert_assignment_affects_records_in_target(:birds_with_add_load) + end + + def assert_assignment_affects_records_in_target(association_name) + association = @pirate.send(association_name) + assert association.detect {|b| b == bird_to_update }.name_changed?, + 'Update record not updated' + assert association.detect {|b| b == bird_to_destroy }.marked_for_destruction?, + 'Destroy record not marked for destruction' + end +end diff --git a/activerecord/test/cases/persistence_test.rb b/activerecord/test/cases/persistence_test.rb new file mode 100644 index 0000000000..2170fe6118 --- /dev/null +++ b/activerecord/test/cases/persistence_test.rb @@ -0,0 +1,880 @@ +require "cases/helper" +require 'models/aircraft' +require 'models/post' +require 'models/comment' +require 'models/author' +require 'models/topic' +require 'models/reply' +require 'models/category' +require 'models/company' +require 'models/developer' +require 'models/project' +require 'models/minimalistic' +require 'models/warehouse_thing' +require 'models/parrot' +require 'models/minivan' +require 'models/owner' +require 'models/person' +require 'models/pet' +require 'models/toy' +require 'rexml/document' + +class PersistenceTest < ActiveRecord::TestCase + fixtures :topics, :companies, :developers, :projects, :computers, :accounts, :minimalistics, 'warehouse-things', :authors, :author_addresses, :categorizations, :categories, :posts, :minivans, :pets, :toys + + # Oracle UPDATE does not support ORDER BY + unless current_adapter?(:OracleAdapter) + def test_update_all_ignores_order_without_limit_from_association + author = authors(:david) + assert_nothing_raised do + assert_equal author.posts_with_comments_and_categories.length, author.posts_with_comments_and_categories.update_all([ "body = ?", "bulk update!" ]) + end + end + + def test_update_all_doesnt_ignore_order + assert_equal authors(:david).id + 1, authors(:mary).id # make sure there is going to be a duplicate PK error + test_update_with_order_succeeds = lambda do |order| + begin + Author.order(order).update_all('id = id + 1') + rescue ActiveRecord::ActiveRecordError + false + end + end + + if test_update_with_order_succeeds.call('id DESC') + assert !test_update_with_order_succeeds.call('id ASC') # test that this wasn't a fluke and using an incorrect order results in an exception + else + # test that we're failing because the current Arel's engine doesn't support UPDATE ORDER BY queries is using subselects instead + assert_sql(/\AUPDATE .+ \(SELECT .* ORDER BY id DESC\)\Z/i) do + test_update_with_order_succeeds.call('id DESC') + end + end + end + + def test_update_all_with_order_and_limit_updates_subset_only + author = authors(:david) + assert_nothing_raised do + assert_equal 1, author.posts_sorted_by_id_limited.size + assert_equal 2, author.posts_sorted_by_id_limited.limit(2).to_a.size + assert_equal 1, author.posts_sorted_by_id_limited.update_all([ "body = ?", "bulk update!" ]) + assert_equal "bulk update!", posts(:welcome).body + assert_not_equal "bulk update!", posts(:thinking).body + end + end + end + + def test_update_many + topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } } + updated = Topic.update(topic_data.keys, topic_data.values) + + assert_equal 2, updated.size + assert_equal "1 updated", Topic.find(1).content + assert_equal "2 updated", Topic.find(2).content + end + + def test_delete_all + assert Topic.count > 0 + + assert_equal Topic.count, Topic.delete_all + end + + def test_delete_all_with_joins_and_where_part_is_hash + where_args = {:toys => {:name => 'Bone'}} + count = Pet.joins(:toys).where(where_args).count + + assert_equal count, 1 + assert_equal count, Pet.joins(:toys).where(where_args).delete_all + end + + def test_delete_all_with_joins_and_where_part_is_not_hash + where_args = ['toys.name = ?', 'Bone'] + count = Pet.joins(:toys).where(where_args).count + + assert_equal count, 1 + assert_equal count, Pet.joins(:toys).where(where_args).delete_all + end + + def test_increment_attribute + assert_equal 50, accounts(:signals37).credit_limit + accounts(:signals37).increment! :credit_limit + assert_equal 51, accounts(:signals37, :reload).credit_limit + + accounts(:signals37).increment(:credit_limit).increment!(:credit_limit) + assert_equal 53, accounts(:signals37, :reload).credit_limit + end + + def test_increment_nil_attribute + assert_nil topics(:first).parent_id + topics(:first).increment! :parent_id + assert_equal 1, topics(:first).parent_id + end + + def test_increment_attribute_by + assert_equal 50, accounts(:signals37).credit_limit + accounts(:signals37).increment! :credit_limit, 5 + assert_equal 55, accounts(:signals37, :reload).credit_limit + + accounts(:signals37).increment(:credit_limit, 1).increment!(:credit_limit, 3) + assert_equal 59, accounts(:signals37, :reload).credit_limit + end + + def test_destroy_all + conditions = "author_name = 'Mary'" + topics_by_mary = Topic.all.merge!(:where => conditions, :order => 'id').to_a + assert ! topics_by_mary.empty? + + assert_difference('Topic.count', -topics_by_mary.size) do + destroyed = Topic.destroy_all(conditions).sort_by(&:id) + assert_equal topics_by_mary, destroyed + assert destroyed.all? { |topic| topic.frozen? }, "destroyed topics should be frozen" + end + end + + def test_destroy_many + clients = Client.all.merge!(:order => 'id').find([2, 3]) + + assert_difference('Client.count', -2) do + destroyed = Client.destroy([2, 3]).sort_by(&:id) + assert_equal clients, destroyed + assert destroyed.all? { |client| client.frozen? }, "destroyed clients should be frozen" + end + end + + def test_becomes + assert_kind_of Reply, topics(:first).becomes(Reply) + assert_equal "The First Topic", topics(:first).becomes(Reply).title + end + + def test_becomes_includes_errors + company = Company.new(:name => nil) + assert !company.valid? + original_errors = company.errors + client = company.becomes(Client) + assert_equal original_errors, client.errors + end + + def test_dupd_becomes_persists_changes_from_the_original + original = topics(:first) + copy = original.dup.becomes(Reply) + copy.save! + assert_equal "The First Topic", Topic.find(copy.id).title + end + + def test_becomes_includes_changed_attributes + company = Company.new(name: "37signals") + client = company.becomes(Client) + assert_equal "37signals", client.name + assert_equal %w{name}, client.changed + end + + def test_delete_many + original_count = Topic.count + Topic.delete(deleting = [1, 2]) + assert_equal original_count - deleting.size, Topic.count + end + + def test_decrement_attribute + assert_equal 50, accounts(:signals37).credit_limit + + accounts(:signals37).decrement!(:credit_limit) + assert_equal 49, accounts(:signals37, :reload).credit_limit + + accounts(:signals37).decrement(:credit_limit).decrement!(:credit_limit) + assert_equal 47, accounts(:signals37, :reload).credit_limit + end + + def test_decrement_attribute_by + assert_equal 50, accounts(:signals37).credit_limit + accounts(:signals37).decrement! :credit_limit, 5 + assert_equal 45, accounts(:signals37, :reload).credit_limit + + accounts(:signals37).decrement(:credit_limit, 1).decrement!(:credit_limit, 3) + assert_equal 41, accounts(:signals37, :reload).credit_limit + end + + def test_create + topic = Topic.new + topic.title = "New Topic" + topic.save + topic_reloaded = Topic.find(topic.id) + assert_equal("New Topic", topic_reloaded.title) + end + + def test_save! + topic = Topic.new(:title => "New Topic") + assert topic.save! + + reply = WrongReply.new + assert_raise(ActiveRecord::RecordInvalid) { reply.save! } + end + + def test_save_null_string_attributes + topic = Topic.find(1) + topic.attributes = { "title" => "null", "author_name" => "null" } + topic.save! + topic.reload + assert_equal("null", topic.title) + assert_equal("null", topic.author_name) + end + + def test_save_nil_string_attributes + topic = Topic.find(1) + topic.title = nil + topic.save! + topic.reload + assert_nil topic.title + end + + def test_save_for_record_with_only_primary_key + minimalistic = Minimalistic.new + assert_nothing_raised { minimalistic.save } + end + + def test_save_for_record_with_only_primary_key_that_is_provided + assert_nothing_raised { Minimalistic.create!(:id => 2) } + end + + def test_save_with_duping_of_destroyed_object + developer = Developer.first + developer.destroy + new_developer = developer.dup + new_developer.save + assert new_developer.persisted? + assert_not new_developer.destroyed? + end + + def test_create_many + topics = Topic.create([ { "title" => "first" }, { "title" => "second" }]) + assert_equal 2, topics.size + assert_equal "first", topics.first.title + end + + def test_create_columns_not_equal_attributes + topic = Topic.instantiate( + 'title' => 'Another New Topic', + 'does_not_exist' => 'test' + ) + assert_nothing_raised { topic.save } + end + + def test_create_through_factory_with_block + topic = Topic.create("title" => "New Topic") do |t| + t.author_name = "David" + end + assert_equal("New Topic", topic.title) + assert_equal("David", topic.author_name) + end + + def test_create_many_through_factory_with_block + topics = Topic.create([ { "title" => "first" }, { "title" => "second" }]) do |t| + t.author_name = "David" + end + assert_equal 2, topics.size + topic1, topic2 = Topic.find(topics[0].id), Topic.find(topics[1].id) + assert_equal "first", topic1.title + assert_equal "David", topic1.author_name + assert_equal "second", topic2.title + assert_equal "David", topic2.author_name + end + + def test_update_object + topic = Topic.new + topic.title = "Another New Topic" + topic.written_on = "2003-12-12 23:23:00" + topic.save + topic_reloaded = Topic.find(topic.id) + assert_equal("Another New Topic", topic_reloaded.title) + + topic_reloaded.title = "Updated topic" + topic_reloaded.save + + topic_reloaded_again = Topic.find(topic.id) + + assert_equal("Updated topic", topic_reloaded_again.title) + end + + def test_update_columns_not_equal_attributes + topic = Topic.new + topic.title = "Still another topic" + topic.save + + topic_reloaded = Topic.instantiate(topic.attributes.merge('does_not_exist' => 'test')) + topic_reloaded.title = 'A New Topic' + assert_nothing_raised { topic_reloaded.save } + end + + def test_update_for_record_with_only_primary_key + minimalistic = minimalistics(:first) + assert_nothing_raised { minimalistic.save } + end + + def test_update_sti_type + assert_instance_of Reply, topics(:second) + + topic = topics(:second).becomes!(Topic) + assert_instance_of Topic, topic + topic.save! + assert_instance_of Topic, Topic.find(topic.id) + end + + def test_preserve_original_sti_type + reply = topics(:second) + assert_equal "Reply", reply.type + + topic = reply.becomes(Topic) + assert_equal "Reply", reply.type + + assert_instance_of Topic, topic + assert_equal "Reply", topic.type + end + + def test_update_sti_subclass_type + assert_instance_of Topic, topics(:first) + + reply = topics(:first).becomes!(Reply) + assert_instance_of Reply, reply + reply.save! + assert_instance_of Reply, Reply.find(reply.id) + end + + def test_update_after_create + klass = Class.new(Topic) do + def self.name; 'Topic'; end + after_create do + update_attribute("author_name", "David") + end + end + topic = klass.new + topic.title = "Another New Topic" + topic.save + + topic_reloaded = Topic.find(topic.id) + assert_equal("Another New Topic", topic_reloaded.title) + assert_equal("David", topic_reloaded.author_name) + end + + def test_delete + topic = Topic.find(1) + assert_equal topic, topic.delete, 'topic.delete did not return self' + assert topic.frozen?, 'topic not frozen after delete' + assert_raise(ActiveRecord::RecordNotFound) { Topic.find(topic.id) } + end + + def test_delete_doesnt_run_callbacks + Topic.find(1).delete + assert_not_nil Topic.find(2) + end + + def test_destroy + topic = Topic.find(1) + assert_equal topic, topic.destroy, 'topic.destroy did not return self' + assert topic.frozen?, 'topic not frozen after destroy' + assert_raise(ActiveRecord::RecordNotFound) { Topic.find(topic.id) } + end + + def test_destroy! + topic = Topic.find(1) + assert_equal topic, topic.destroy!, 'topic.destroy! did not return self' + assert topic.frozen?, 'topic not frozen after destroy!' + assert_raise(ActiveRecord::RecordNotFound) { Topic.find(topic.id) } + end + + def test_record_not_found_exception + assert_raise(ActiveRecord::RecordNotFound) { Topic.find(99999) } + end + + def test_update_all + assert_equal Topic.count, Topic.update_all("content = 'bulk updated!'") + assert_equal "bulk updated!", Topic.find(1).content + assert_equal "bulk updated!", Topic.find(2).content + + assert_equal Topic.count, Topic.update_all(['content = ?', 'bulk updated again!']) + assert_equal "bulk updated again!", Topic.find(1).content + assert_equal "bulk updated again!", Topic.find(2).content + + assert_equal Topic.count, Topic.update_all(['content = ?', nil]) + assert_nil Topic.find(1).content + end + + def test_update_all_with_hash + assert_not_nil Topic.find(1).last_read + assert_equal Topic.count, Topic.update_all(:content => 'bulk updated with hash!', :last_read => nil) + assert_equal "bulk updated with hash!", Topic.find(1).content + assert_equal "bulk updated with hash!", Topic.find(2).content + assert_nil Topic.find(1).last_read + assert_nil Topic.find(2).last_read + end + + def test_update_all_with_non_standard_table_name + assert_equal 1, WarehouseThing.where(id: 1).update_all(['value = ?', 0]) + assert_equal 0, WarehouseThing.find(1).value + end + + def test_delete_new_record + client = Client.new + client.delete + assert client.frozen? + end + + def test_delete_record_with_associations + client = Client.find(3) + client.delete + assert client.frozen? + assert_kind_of Firm, client.firm + assert_raise(RuntimeError) { client.name = "something else" } + end + + def test_destroy_new_record + client = Client.new + client.destroy + assert client.frozen? + end + + def test_destroy_record_with_associations + client = Client.find(3) + client.destroy + assert client.frozen? + assert_kind_of Firm, client.firm + assert_raise(RuntimeError) { client.name = "something else" } + end + + def test_update_attribute + assert !Topic.find(1).approved? + Topic.find(1).update_attribute("approved", true) + assert Topic.find(1).approved? + + Topic.find(1).update_attribute(:approved, false) + assert !Topic.find(1).approved? + end + + def test_update_attribute_for_readonly_attribute + minivan = Minivan.find('m1') + assert_raises(ActiveRecord::ActiveRecordError) { minivan.update_attribute(:color, 'black') } + end + + def test_update_attribute_with_one_updated + t = Topic.first + t.update_attribute(:title, 'super_title') + assert_equal 'super_title', t.title + assert !t.changed?, "topic should not have changed" + assert !t.title_changed?, "title should not have changed" + assert_nil t.title_change, 'title change should be nil' + + t.reload + assert_equal 'super_title', t.title + end + + def test_update_attribute_for_updated_at_on + developer = Developer.find(1) + prev_month = Time.now.prev_month.change(usec: 0) + + developer.update_attribute(:updated_at, prev_month) + assert_equal prev_month, developer.updated_at + + developer.update_attribute(:salary, 80001) + assert_not_equal prev_month, developer.updated_at + + developer.reload + assert_not_equal prev_month, developer.updated_at + end + + def test_update_column + topic = Topic.find(1) + topic.update_column("approved", true) + assert topic.approved? + topic.reload + assert topic.approved? + + topic.update_column(:approved, false) + assert !topic.approved? + topic.reload + assert !topic.approved? + end + + def test_update_column_should_not_use_setter_method + dev = Developer.find(1) + dev.instance_eval { def salary=(value); write_attribute(:salary, value * 2); end } + + dev.update_column(:salary, 80000) + assert_equal 80000, dev.salary + + dev.reload + assert_equal 80000, dev.salary + end + + def test_update_column_should_raise_exception_if_new_record + topic = Topic.new + assert_raises(ActiveRecord::ActiveRecordError) { topic.update_column("approved", false) } + end + + def test_update_column_should_not_leave_the_object_dirty + topic = Topic.find(1) + topic.update_column("content", "--- Have a nice day\n...\n") + + topic.reload + topic.update_column(:content, "--- You too\n...\n") + assert_equal [], topic.changed + + topic.reload + topic.update_column("content", "--- Have a nice day\n...\n") + assert_equal [], topic.changed + end + + def test_update_column_with_model_having_primary_key_other_than_id + minivan = Minivan.find('m1') + new_name = 'sebavan' + + minivan.update_column(:name, new_name) + assert_equal new_name, minivan.name + end + + def test_update_column_for_readonly_attribute + minivan = Minivan.find('m1') + prev_color = minivan.color + assert_raises(ActiveRecord::ActiveRecordError) { minivan.update_column(:color, 'black') } + assert_equal prev_color, minivan.color + end + + def test_update_column_should_not_modify_updated_at + developer = Developer.find(1) + prev_month = Time.now.prev_month.change(usec: 0) + + developer.update_column(:updated_at, prev_month) + assert_equal prev_month, developer.updated_at + + developer.update_column(:salary, 80001) + assert_equal prev_month, developer.updated_at + + developer.reload + assert_equal prev_month.to_i, developer.updated_at.to_i + end + + def test_update_column_with_one_changed_and_one_updated + t = Topic.order('id').limit(1).first + author_name = t.author_name + t.author_name = 'John' + t.update_column(:title, 'super_title') + assert_equal 'John', t.author_name + assert_equal 'super_title', t.title + assert t.changed?, "topic should have changed" + assert t.author_name_changed?, "author_name should have changed" + + t.reload + assert_equal author_name, t.author_name + assert_equal 'super_title', t.title + end + + def test_update_column_with_default_scope + developer = DeveloperCalledDavid.first + developer.name = 'John' + developer.save! + + assert developer.update_column(:name, 'Will'), 'did not update record due to default scope' + end + + def test_update_columns + topic = Topic.find(1) + topic.update_columns({ "approved" => true, title: "Sebastian Topic" }) + assert topic.approved? + assert_equal "Sebastian Topic", topic.title + topic.reload + assert topic.approved? + assert_equal "Sebastian Topic", topic.title + end + + def test_update_columns_should_not_use_setter_method + dev = Developer.find(1) + dev.instance_eval { def salary=(value); write_attribute(:salary, value * 2); end } + + dev.update_columns(salary: 80000) + assert_equal 80000, dev.salary + + dev.reload + assert_equal 80000, dev.salary + end + + def test_update_columns_should_raise_exception_if_new_record + topic = Topic.new + assert_raises(ActiveRecord::ActiveRecordError) { topic.update_columns({ approved: false }) } + end + + def test_update_columns_should_not_leave_the_object_dirty + topic = Topic.find(1) + topic.update({ "content" => "--- Have a nice day\n...\n", :author_name => "Jose" }) + + topic.reload + topic.update_columns({ content: "--- You too\n...\n", "author_name" => "Sebastian" }) + assert_equal [], topic.changed + + topic.reload + topic.update_columns({ content: "--- Have a nice day\n...\n", author_name: "Jose" }) + assert_equal [], topic.changed + end + + def test_update_columns_with_model_having_primary_key_other_than_id + minivan = Minivan.find('m1') + new_name = 'sebavan' + + minivan.update_columns(name: new_name) + assert_equal new_name, minivan.name + end + + def test_update_columns_with_one_readonly_attribute + minivan = Minivan.find('m1') + prev_color = minivan.color + prev_name = minivan.name + assert_raises(ActiveRecord::ActiveRecordError) { minivan.update_columns({ name: "My old minivan", color: 'black' }) } + assert_equal prev_color, minivan.color + assert_equal prev_name, minivan.name + + minivan.reload + assert_equal prev_color, minivan.color + assert_equal prev_name, minivan.name + end + + def test_update_columns_should_not_modify_updated_at + developer = Developer.find(1) + prev_month = Time.now.prev_month.change(usec: 0) + + developer.update_columns(updated_at: prev_month) + assert_equal prev_month, developer.updated_at + + developer.update_columns(salary: 80000) + assert_equal prev_month, developer.updated_at + assert_equal 80000, developer.salary + + developer.reload + assert_equal prev_month.to_i, developer.updated_at.to_i + assert_equal 80000, developer.salary + end + + def test_update_columns_with_one_changed_and_one_updated + t = Topic.order('id').limit(1).first + author_name = t.author_name + t.author_name = 'John' + t.update_columns(title: 'super_title') + assert_equal 'John', t.author_name + assert_equal 'super_title', t.title + assert t.changed?, "topic should have changed" + assert t.author_name_changed?, "author_name should have changed" + + t.reload + assert_equal author_name, t.author_name + assert_equal 'super_title', t.title + end + + def test_update_columns_changing_id + topic = Topic.find(1) + topic.update_columns(id: 123) + assert_equal 123, topic.id + topic.reload + assert_equal 123, topic.id + end + + def test_update_columns_returns_boolean + topic = Topic.find(1) + assert_equal true, topic.update_columns(title: "New title") + end + + def test_update_columns_with_default_scope + developer = DeveloperCalledDavid.first + developer.name = 'John' + developer.save! + + assert developer.update_columns(name: 'Will'), 'did not update record due to default scope' + end + + def test_update + topic = Topic.find(1) + assert !topic.approved? + assert_equal "The First Topic", topic.title + + topic.update("approved" => true, "title" => "The First Topic Updated") + topic.reload + assert topic.approved? + assert_equal "The First Topic Updated", topic.title + + topic.update(approved: false, title: "The First Topic") + topic.reload + assert !topic.approved? + assert_equal "The First Topic", topic.title + end + + def test_update_attributes + topic = Topic.find(1) + assert !topic.approved? + assert_equal "The First Topic", topic.title + + topic.update_attributes("approved" => true, "title" => "The First Topic Updated") + topic.reload + assert topic.approved? + assert_equal "The First Topic Updated", topic.title + + topic.update_attributes(approved: false, title: "The First Topic") + topic.reload + assert !topic.approved? + assert_equal "The First Topic", topic.title + + assert_raise(ActiveRecord::RecordNotUnique, ActiveRecord::StatementInvalid) do + topic.update_attributes(id: 3, title: "Hm is it possible?") + end + assert_not_equal "Hm is it possible?", Topic.find(3).title + + topic.update_attributes(id: 1234) + assert_nothing_raised { topic.reload } + assert_equal topic.title, Topic.find(1234).title + end + + def test_update_attributes_parameters + topic = Topic.find(1) + assert_nothing_raised do + topic.update_attributes({}) + end + + assert_raises(ArgumentError) do + topic.update_attributes(nil) + end + end + + def test_update! + Reply.validates_presence_of(:title) + reply = Reply.find(2) + assert_equal "The Second Topic of the day", reply.title + assert_equal "Have a nice day", reply.content + + reply.update!("title" => "The Second Topic of the day updated", "content" => "Have a nice evening") + reply.reload + assert_equal "The Second Topic of the day updated", reply.title + assert_equal "Have a nice evening", reply.content + + reply.update!(title: "The Second Topic of the day", content: "Have a nice day") + reply.reload + assert_equal "The Second Topic of the day", reply.title + assert_equal "Have a nice day", reply.content + + assert_raise(ActiveRecord::RecordInvalid) { reply.update!(title: nil, content: "Have a nice evening") } + ensure + Reply.clear_validators! + end + + def test_update_attributes! + Reply.validates_presence_of(:title) + reply = Reply.find(2) + assert_equal "The Second Topic of the day", reply.title + assert_equal "Have a nice day", reply.content + + reply.update_attributes!("title" => "The Second Topic of the day updated", "content" => "Have a nice evening") + reply.reload + assert_equal "The Second Topic of the day updated", reply.title + assert_equal "Have a nice evening", reply.content + + reply.update_attributes!(title: "The Second Topic of the day", content: "Have a nice day") + reply.reload + assert_equal "The Second Topic of the day", reply.title + assert_equal "Have a nice day", reply.content + + assert_raise(ActiveRecord::RecordInvalid) { reply.update_attributes!(title: nil, content: "Have a nice evening") } + ensure + Reply.clear_validators! + end + + def test_destroyed_returns_boolean + developer = Developer.first + assert_equal false, developer.destroyed? + developer.destroy + assert_equal true, developer.destroyed? + + developer = Developer.last + assert_equal false, developer.destroyed? + developer.delete + assert_equal true, developer.destroyed? + end + + def test_persisted_returns_boolean + developer = Developer.new(:name => "Jose") + assert_equal false, developer.persisted? + developer.save! + assert_equal true, developer.persisted? + + developer = Developer.first + assert_equal true, developer.persisted? + developer.destroy + assert_equal false, developer.persisted? + + developer = Developer.last + assert_equal true, developer.persisted? + developer.delete + assert_equal false, developer.persisted? + end + + def test_class_level_destroy + should_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world") + Topic.find(1).replies << should_be_destroyed_reply + + Topic.destroy(1) + assert_raise(ActiveRecord::RecordNotFound) { Topic.find(1) } + assert_raise(ActiveRecord::RecordNotFound) { Reply.find(should_be_destroyed_reply.id) } + end + + def test_class_level_delete + should_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world") + Topic.find(1).replies << should_be_destroyed_reply + + Topic.delete(1) + assert_raise(ActiveRecord::RecordNotFound) { Topic.find(1) } + assert_nothing_raised { Reply.find(should_be_destroyed_reply.id) } + end + + def test_create_with_custom_timestamps + custom_datetime = 1.hour.ago.beginning_of_day + + %w(created_at created_on updated_at updated_on).each do |attribute| + parrot = LiveParrot.create(:name => "colombian", attribute => custom_datetime) + assert_equal custom_datetime, parrot[attribute] + end + end + + def test_persist_inherited_class_with_different_table_name + minimalistic_aircrafts = Class.new(Minimalistic) do + self.table_name = "aircraft" + end + + assert_difference "Aircraft.count", 1 do + aircraft = minimalistic_aircrafts.create(name: "Wright Flyer") + aircraft.name = "Wright Glider" + aircraft.save + end + + assert_equal "Wright Glider", Aircraft.last.name + end + + def test_instantiate_creates_a_new_instance + post = Post.instantiate("title" => "appropriate documentation", "type" => "SpecialPost") + assert_equal "appropriate documentation", post.title + assert_instance_of SpecialPost, post + + # body was not initialized + assert_raises ActiveModel::MissingAttributeError do + post.body + end + end + + def test_reload_removes_custom_selects + post = Post.select('posts.*, 1 as wibble').last! + + assert_equal 1, post[:wibble] + assert_nil post.reload[:wibble] + end + + def test_find_via_reload + post = Post.new + + assert post.new_record? + + post.id = 1 + post.reload + + assert_equal "Welcome to the weblog", post.title + assert_not post.new_record? + end +end diff --git a/activerecord/test/cases/pooled_connections_test.rb b/activerecord/test/cases/pooled_connections_test.rb new file mode 100644 index 0000000000..8eea10143f --- /dev/null +++ b/activerecord/test/cases/pooled_connections_test.rb @@ -0,0 +1,51 @@ +require "cases/helper" +require "models/project" +require "timeout" + +class PooledConnectionsTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + def setup + @per_test_teardown = [] + @connection = ActiveRecord::Base.remove_connection + end + + teardown do + ActiveRecord::Base.clear_all_connections! + ActiveRecord::Base.establish_connection(@connection) + @per_test_teardown.each {|td| td.call } + end + + # Will deadlock due to lack of Monitor timeouts in 1.9 + def checkout_checkin_connections(pool_size, threads) + ActiveRecord::Base.establish_connection(@connection.merge({:pool => pool_size, :checkout_timeout => 0.5})) + @connection_count = 0 + @timed_out = 0 + threads.times do + Thread.new do + begin + conn = ActiveRecord::Base.connection_pool.checkout + sleep 0.1 + ActiveRecord::Base.connection_pool.checkin conn + @connection_count += 1 + rescue ActiveRecord::ConnectionTimeoutError + @timed_out += 1 + end + end.join + end + end + + def test_pooled_connection_checkin_one + checkout_checkin_connections 1, 2 + assert_equal 2, @connection_count + assert_equal 0, @timed_out + assert_equal 1, ActiveRecord::Base.connection_pool.connections.size + end + + + private + + def add_record(name) + ActiveRecord::Base.connection_pool.with_connection { Project.create! :name => name } + end +end unless in_memory_db? diff --git a/activerecord/test/cases/primary_keys_test.rb b/activerecord/test/cases/primary_keys_test.rb new file mode 100644 index 0000000000..f19a6ea5e3 --- /dev/null +++ b/activerecord/test/cases/primary_keys_test.rb @@ -0,0 +1,236 @@ +require "cases/helper" +require 'models/topic' +require 'models/reply' +require 'models/subscriber' +require 'models/movie' +require 'models/keyboard' +require 'models/mixed_case_monkey' +require 'models/dashboard' + +class PrimaryKeysTest < ActiveRecord::TestCase + fixtures :topics, :subscribers, :movies, :mixed_case_monkeys + + def test_to_key_with_default_primary_key + topic = Topic.new + assert_nil topic.to_key + topic = Topic.find(1) + assert_equal [1], topic.to_key + end + + def test_to_key_with_customized_primary_key + keyboard = Keyboard.new + assert_nil keyboard.to_key + keyboard.save + assert_equal keyboard.to_key, [keyboard.id] + end + + def test_read_attribute_with_custom_primary_key + keyboard = Keyboard.create! + assert_equal keyboard.key_number, keyboard.read_attribute(:id) + end + + def test_to_key_with_primary_key_after_destroy + topic = Topic.find(1) + topic.destroy + assert_equal [1], topic.to_key + end + + def test_integer_key + topic = Topic.find(1) + assert_equal(topics(:first).author_name, topic.author_name) + topic = Topic.find(2) + assert_equal(topics(:second).author_name, topic.author_name) + + topic = Topic.new + topic.title = "New Topic" + assert_nil topic.id + assert_nothing_raised { topic.save! } + id = topic.id + + topicReloaded = Topic.find(id) + assert_equal("New Topic", topicReloaded.title) + end + + def test_customized_primary_key_auto_assigns_on_save + Keyboard.delete_all + keyboard = Keyboard.new(:name => 'HHKB') + assert_nothing_raised { keyboard.save! } + assert_equal keyboard.id, Keyboard.find_by_name('HHKB').id + end + + def test_customized_primary_key_can_be_get_before_saving + keyboard = Keyboard.new + assert_nil keyboard.id + assert_nothing_raised { assert_nil keyboard.key_number } + end + + def test_customized_string_primary_key_settable_before_save + subscriber = Subscriber.new + assert_nothing_raised { subscriber.id = 'webster123' } + assert_equal 'webster123', subscriber.id + assert_equal 'webster123', subscriber.nick + end + + def test_string_key + subscriber = Subscriber.find(subscribers(:first).nick) + assert_equal(subscribers(:first).name, subscriber.name) + subscriber = Subscriber.find(subscribers(:second).nick) + assert_equal(subscribers(:second).name, subscriber.name) + + subscriber = Subscriber.new + subscriber.id = "jdoe" + assert_equal("jdoe", subscriber.id) + subscriber.name = "John Doe" + assert_nothing_raised { subscriber.save! } + assert_equal("jdoe", subscriber.id) + + subscriberReloaded = Subscriber.find("jdoe") + assert_equal("John Doe", subscriberReloaded.name) + end + + def test_find_with_more_than_one_string_key + assert_equal 2, Subscriber.find(subscribers(:first).nick, subscribers(:second).nick).length + end + + def test_primary_key_prefix + old_primary_key_prefix_type = ActiveRecord::Base.primary_key_prefix_type + ActiveRecord::Base.primary_key_prefix_type = :table_name + Topic.reset_primary_key + assert_equal "topicid", Topic.primary_key + + ActiveRecord::Base.primary_key_prefix_type = :table_name_with_underscore + Topic.reset_primary_key + assert_equal "topic_id", Topic.primary_key + + ActiveRecord::Base.primary_key_prefix_type = nil + Topic.reset_primary_key + assert_equal "id", Topic.primary_key + ensure + ActiveRecord::Base.primary_key_prefix_type = old_primary_key_prefix_type + end + + def test_delete_should_quote_pkey + assert_nothing_raised { MixedCaseMonkey.delete(1) } + end + def test_update_counters_should_quote_pkey_and_quote_counter_columns + assert_nothing_raised { MixedCaseMonkey.update_counters(1, :fleaCount => 99) } + end + def test_find_with_one_id_should_quote_pkey + assert_nothing_raised { MixedCaseMonkey.find(1) } + end + def test_find_with_multiple_ids_should_quote_pkey + assert_nothing_raised { MixedCaseMonkey.find([1,2]) } + end + def test_instance_update_should_quote_pkey + assert_nothing_raised { MixedCaseMonkey.find(1).save } + end + def test_instance_destroy_should_quote_pkey + assert_nothing_raised { MixedCaseMonkey.find(1).destroy } + end + + def test_supports_primary_key + assert_nothing_raised NoMethodError do + ActiveRecord::Base.connection.supports_primary_key? + end + end + + def test_primary_key_returns_value_if_it_exists + klass = Class.new(ActiveRecord::Base) do + self.table_name = 'developers' + end + + if ActiveRecord::Base.connection.supports_primary_key? + assert_equal 'id', klass.primary_key + end + end + + def test_primary_key_returns_nil_if_it_does_not_exist + klass = Class.new(ActiveRecord::Base) do + self.table_name = 'developers_projects' + end + + if ActiveRecord::Base.connection.supports_primary_key? + assert_nil klass.primary_key + end + end + + def test_quoted_primary_key_after_set_primary_key + k = Class.new( ActiveRecord::Base ) + assert_equal k.connection.quote_column_name("id"), k.quoted_primary_key + k.primary_key = "foo" + assert_equal k.connection.quote_column_name("foo"), k.quoted_primary_key + end + + def test_auto_detect_primary_key_from_schema + MixedCaseMonkey.reset_primary_key + assert_equal "monkeyID", MixedCaseMonkey.primary_key + end + + def test_primary_key_update_with_custom_key_name + dashboard = Dashboard.create!(dashboard_id: '1') + dashboard.id = '2' + dashboard.save! + + dashboard = Dashboard.first + assert_equal '2', dashboard.id + end +end + +class PrimaryKeyWithNoConnectionTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + unless in_memory_db? + def test_set_primary_key_with_no_connection + connection = ActiveRecord::Base.remove_connection + + model = Class.new(ActiveRecord::Base) + model.primary_key = 'foo' + + assert_equal 'foo', model.primary_key + + ActiveRecord::Base.establish_connection(connection) + + assert_equal 'foo', model.primary_key + end + end +end + +if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + class PrimaryKeyWithAnsiQuotesTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + def test_primary_key_method_with_ansi_quotes + con = ActiveRecord::Base.connection + con.execute("SET SESSION sql_mode='ANSI_QUOTES'") + assert_equal "id", con.primary_key("topics") + ensure + con.reconnect! + end + end +end + +if current_adapter?(:PostgreSQLAdapter) + class PrimaryKeyBigSerialTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + class Widget < ActiveRecord::Base + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table(:widgets, id: :bigserial) { |t| } + end + + teardown do + @connection.drop_table :widgets + end + + def test_bigserial_primary_key + assert_equal "id", Widget.primary_key + assert_equal :integer, Widget.columns_hash[Widget.primary_key].type + + widget = Widget.create! + assert_not_nil widget.id + end + end +end diff --git a/activerecord/test/cases/query_cache_test.rb b/activerecord/test/cases/query_cache_test.rb new file mode 100644 index 0000000000..9d89d6a1e8 --- /dev/null +++ b/activerecord/test/cases/query_cache_test.rb @@ -0,0 +1,290 @@ +require "cases/helper" +require 'models/topic' +require 'models/task' +require 'models/category' +require 'models/post' +require 'rack' + +class QueryCacheTest < ActiveRecord::TestCase + fixtures :tasks, :topics, :categories, :posts, :categories_posts + + teardown do + Task.connection.clear_query_cache + ActiveRecord::Base.connection.disable_query_cache! + end + + def test_exceptional_middleware_clears_and_disables_cache_on_error + assert !ActiveRecord::Base.connection.query_cache_enabled, 'cache off' + + mw = ActiveRecord::QueryCache.new lambda { |env| + Task.find 1 + Task.find 1 + assert_equal 1, ActiveRecord::Base.connection.query_cache.length + raise "lol borked" + } + assert_raises(RuntimeError) { mw.call({}) } + + assert_equal 0, ActiveRecord::Base.connection.query_cache.length + assert !ActiveRecord::Base.connection.query_cache_enabled, 'cache off' + end + + def test_exceptional_middleware_leaves_enabled_cache_alone + ActiveRecord::Base.connection.enable_query_cache! + + mw = ActiveRecord::QueryCache.new lambda { |env| + raise "lol borked" + } + assert_raises(RuntimeError) { mw.call({}) } + + assert ActiveRecord::Base.connection.query_cache_enabled, 'cache on' + end + + def test_exceptional_middleware_assigns_original_connection_id_on_error + connection_id = ActiveRecord::Base.connection_id + + mw = ActiveRecord::QueryCache.new lambda { |env| + ActiveRecord::Base.connection_id = self.object_id + raise "lol borked" + } + assert_raises(RuntimeError) { mw.call({}) } + + assert_equal connection_id, ActiveRecord::Base.connection_id + end + + def test_middleware_delegates + called = false + mw = ActiveRecord::QueryCache.new lambda { |env| + called = true + [200, {}, nil] + } + mw.call({}) + assert called, 'middleware should delegate' + end + + def test_middleware_caches + mw = ActiveRecord::QueryCache.new lambda { |env| + Task.find 1 + Task.find 1 + assert_equal 1, ActiveRecord::Base.connection.query_cache.length + [200, {}, nil] + } + mw.call({}) + end + + def test_cache_enabled_during_call + assert !ActiveRecord::Base.connection.query_cache_enabled, 'cache off' + + mw = ActiveRecord::QueryCache.new lambda { |env| + assert ActiveRecord::Base.connection.query_cache_enabled, 'cache on' + [200, {}, nil] + } + mw.call({}) + end + + def test_cache_on_during_body_write + streaming = Class.new do + def each + yield ActiveRecord::Base.connection.query_cache_enabled + end + end + + mw = ActiveRecord::QueryCache.new lambda { |env| + [200, {}, streaming.new] + } + body = mw.call({}).last + body.each { |x| assert x, 'cache should be on' } + body.close + assert !ActiveRecord::Base.connection.query_cache_enabled, 'cache disabled' + end + + def test_cache_off_after_close + mw = ActiveRecord::QueryCache.new lambda { |env| [200, {}, nil] } + body = mw.call({}).last + + assert ActiveRecord::Base.connection.query_cache_enabled, 'cache enabled' + body.close + assert !ActiveRecord::Base.connection.query_cache_enabled, 'cache disabled' + end + + def test_cache_clear_after_close + mw = ActiveRecord::QueryCache.new lambda { |env| + Post.first + [200, {}, nil] + } + body = mw.call({}).last + + assert !ActiveRecord::Base.connection.query_cache.empty?, 'cache not empty' + body.close + assert ActiveRecord::Base.connection.query_cache.empty?, 'cache should be empty' + end + + def test_cache_passing_a_relation + post = Post.first + Post.cache do + query = post.categories.select(:post_id) + assert Post.connection.select_all(query).is_a?(ActiveRecord::Result) + end + end + + def test_find_queries + assert_queries(2) { Task.find(1); Task.find(1) } + end + + def test_find_queries_with_cache + Task.cache do + assert_queries(1) { Task.find(1); Task.find(1) } + end + end + + def test_find_queries_with_cache_multi_record + Task.cache do + assert_queries(2) { Task.find(1); Task.find(1); Task.find(2) } + end + end + + def test_find_queries_with_multi_cache_blocks + Task.cache do + Task.cache do + assert_queries(2) { Task.find(1); Task.find(2) } + end + assert_queries(0) { Task.find(1); Task.find(1); Task.find(2) } + end + end + + def test_count_queries_with_cache + Task.cache do + assert_queries(1) { Task.count; Task.count } + end + end + + def test_query_cache_dups_results_correctly + Task.cache do + now = Time.now.utc + task = Task.find 1 + assert_not_equal now, task.starting + task.starting = now + task.reload + assert_not_equal now, task.starting + end + end + + def test_cache_is_flat + Task.cache do + Topic.columns # don't count this query + assert_queries(1) { Topic.find(1); Topic.find(1); } + end + + ActiveRecord::Base.cache do + assert_queries(1) { Task.find(1); Task.find(1) } + end + end + + def test_cache_does_not_wrap_string_results_in_arrays + Task.cache do + # Oracle adapter returns count() as Fixnum or Float + if current_adapter?(:OracleAdapter) + assert_kind_of Numeric, Task.connection.select_value("SELECT count(*) AS count_all FROM tasks") + elsif current_adapter?(:SQLite3Adapter, :Mysql2Adapter) + # Future versions of the sqlite3 adapter will return numeric + assert_instance_of Fixnum, + Task.connection.select_value("SELECT count(*) AS count_all FROM tasks") + else + assert_instance_of String, Task.connection.select_value("SELECT count(*) AS count_all FROM tasks") + end + end + end + + def test_cache_is_ignored_for_locked_relations + task = Task.find 1 + + Task.cache do + assert_queries(2) { task.lock!; task.lock! } + end + end + + def test_cache_is_available_when_connection_is_connected + conf = ActiveRecord::Base.configurations + + ActiveRecord::Base.configurations = {} + Task.cache do + assert_queries(1) { Task.find(1); Task.find(1) } + end + ensure + ActiveRecord::Base.configurations = conf + end +end + +class QueryCacheExpiryTest < ActiveRecord::TestCase + fixtures :tasks, :posts, :categories, :categories_posts + + def test_cache_gets_cleared_after_migration + # warm the cache + Post.find(1) + + # change the column definition + Post.connection.change_column :posts, :title, :string, limit: 80 + assert_nothing_raised { Post.find(1) } + + # restore the old definition + Post.connection.change_column :posts, :title, :string + end + + def test_find + Task.connection.expects(:clear_query_cache).times(1) + + assert !Task.connection.query_cache_enabled + Task.cache do + assert Task.connection.query_cache_enabled + Task.find(1) + + Task.uncached do + assert !Task.connection.query_cache_enabled + Task.find(1) + end + + assert Task.connection.query_cache_enabled + end + assert !Task.connection.query_cache_enabled + end + + def test_update + Task.connection.expects(:clear_query_cache).times(2) + Task.cache do + task = Task.find(1) + task.starting = Time.now.utc + task.save! + end + end + + def test_destroy + Task.connection.expects(:clear_query_cache).times(2) + Task.cache do + Task.find(1).destroy + end + end + + def test_insert + ActiveRecord::Base.connection.expects(:clear_query_cache).times(2) + Task.cache do + Task.create! + end + end + + def test_cache_is_expired_by_habtm_update + ActiveRecord::Base.connection.expects(:clear_query_cache).times(2) + ActiveRecord::Base.cache do + c = Category.first + p = Post.first + p.categories << c + end + end + + def test_cache_is_expired_by_habtm_delete + ActiveRecord::Base.connection.expects(:clear_query_cache).times(2) + ActiveRecord::Base.cache do + p = Post.find(1) + assert p.categories.any? + p.categories.delete_all + end + end +end diff --git a/activerecord/test/cases/quoting_test.rb b/activerecord/test/cases/quoting_test.rb new file mode 100644 index 0000000000..1d6ae2f67f --- /dev/null +++ b/activerecord/test/cases/quoting_test.rb @@ -0,0 +1,156 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class QuotingTest < ActiveRecord::TestCase + def setup + @quoter = Class.new { include Quoting }.new + end + + def test_quoted_true + assert_equal "'t'", @quoter.quoted_true + end + + def test_quoted_false + assert_equal "'f'", @quoter.quoted_false + end + + def test_quote_column_name + assert_equal "foo", @quoter.quote_column_name('foo') + end + + def test_quote_table_name + assert_equal "foo", @quoter.quote_table_name('foo') + end + + def test_quote_table_name_calls_quote_column_name + @quoter.extend(Module.new { + def quote_column_name(string) + 'lol' + end + }) + assert_equal 'lol', @quoter.quote_table_name('foo') + end + + def test_quote_string + assert_equal "''", @quoter.quote_string("'") + assert_equal "\\\\", @quoter.quote_string("\\") + assert_equal "hi''i", @quoter.quote_string("hi'i") + assert_equal "hi\\\\i", @quoter.quote_string("hi\\i") + end + + def test_quoted_date + t = Date.today + assert_equal t.to_s(:db), @quoter.quoted_date(t) + end + + def test_quoted_time_utc + with_timezone_config default: :utc do + t = Time.now + assert_equal t.getutc.to_s(:db), @quoter.quoted_date(t) + end + end + + def test_quoted_time_local + with_timezone_config default: :local do + t = Time.now + assert_equal t.getlocal.to_s(:db), @quoter.quoted_date(t) + end + end + + def test_quoted_time_crazy + with_timezone_config default: :asdfasdf do + t = Time.now + assert_equal t.getlocal.to_s(:db), @quoter.quoted_date(t) + end + end + + def test_quoted_datetime_utc + with_timezone_config default: :utc do + t = DateTime.now + assert_equal t.getutc.to_s(:db), @quoter.quoted_date(t) + end + end + + ### + # DateTime doesn't define getlocal, so make sure it does nothing + def test_quoted_datetime_local + with_timezone_config default: :local do + t = DateTime.now + assert_equal t.to_s(:db), @quoter.quoted_date(t) + end + end + + def test_quote_with_quoted_id + assert_equal 1, @quoter.quote(Struct.new(:quoted_id).new(1), nil) + end + + def test_quote_nil + assert_equal 'NULL', @quoter.quote(nil, nil) + end + + def test_quote_true + assert_equal @quoter.quoted_true, @quoter.quote(true, nil) + end + + def test_quote_false + assert_equal @quoter.quoted_false, @quoter.quote(false, nil) + end + + def test_quote_float + float = 1.2 + assert_equal float.to_s, @quoter.quote(float, nil) + end + + def test_quote_fixnum + fixnum = 1 + assert_equal fixnum.to_s, @quoter.quote(fixnum, nil) + end + + def test_quote_bignum + bignum = 1 << 100 + assert_equal bignum.to_s, @quoter.quote(bignum, nil) + end + + def test_quote_bigdecimal + bigdec = BigDecimal.new((1 << 100).to_s) + assert_equal bigdec.to_s('F'), @quoter.quote(bigdec, nil) + end + + def test_dates_and_times + @quoter.extend(Module.new { def quoted_date(value) 'lol' end }) + assert_equal "'lol'", @quoter.quote(Date.today, nil) + assert_equal "'lol'", @quoter.quote(Time.now, nil) + assert_equal "'lol'", @quoter.quote(DateTime.now, nil) + end + + def test_crazy_object + crazy = Class.new.new + expected = "'#{YAML.dump(crazy)}'" + assert_equal expected, @quoter.quote(crazy, nil) + end + + def test_crazy_object_calls_quote_string + crazy = Class.new { def initialize; @lol = 'lo\l' end }.new + assert_match "lo\\\\l", @quoter.quote(crazy, nil) + end + + def test_quote_string_no_column + assert_equal "'lo\\\\l'", @quoter.quote('lo\l', nil) + end + + def test_quote_as_mb_chars_no_column + string = ActiveSupport::Multibyte::Chars.new('lo\l') + assert_equal "'lo\\\\l'", @quoter.quote(string, nil) + end + + def test_string_with_crazy_column + assert_equal "'lo\\\\l'", @quoter.quote('lo\l') + end + + def test_quote_duration + assert_equal "1800", @quoter.quote(30.minutes) + end + end + end +end diff --git a/activerecord/test/cases/readonly_test.rb b/activerecord/test/cases/readonly_test.rb new file mode 100644 index 0000000000..2afd25c989 --- /dev/null +++ b/activerecord/test/cases/readonly_test.rb @@ -0,0 +1,111 @@ +require "cases/helper" +require 'models/author' +require 'models/post' +require 'models/comment' +require 'models/developer' +require 'models/project' +require 'models/reader' +require 'models/person' + +class ReadOnlyTest < ActiveRecord::TestCase + fixtures :authors, :posts, :comments, :developers, :projects, :developers_projects, :people, :readers + + def test_cant_save_readonly_record + dev = Developer.find(1) + assert !dev.readonly? + + dev.readonly! + assert dev.readonly? + + assert_nothing_raised do + dev.name = 'Luscious forbidden fruit.' + assert !dev.save + dev.name = 'Forbidden.' + end + assert_raise(ActiveRecord::ReadOnlyRecord) { dev.save } + assert_raise(ActiveRecord::ReadOnlyRecord) { dev.save! } + assert_raise(ActiveRecord::ReadOnlyRecord) { dev.destroy } + end + + + def test_find_with_readonly_option + Developer.all.each { |d| assert !d.readonly? } + Developer.readonly(false).each { |d| assert !d.readonly? } + Developer.readonly(true).each { |d| assert d.readonly? } + Developer.readonly.each { |d| assert d.readonly? } + end + + def test_find_with_joins_option_does_not_imply_readonly + Developer.joins(' ').each { |d| assert_not d.readonly? } + Developer.joins(' ').readonly(true).each { |d| assert d.readonly? } + + Developer.joins(', projects').each { |d| assert_not d.readonly? } + Developer.joins(', projects').readonly(true).each { |d| assert d.readonly? } + end + + def test_has_many_find_readonly + post = Post.find(1) + assert !post.comments.empty? + assert !post.comments.any?(&:readonly?) + assert !post.comments.to_a.any?(&:readonly?) + assert post.comments.readonly(true).all?(&:readonly?) + end + + def test_has_many_with_through_is_not_implicitly_marked_readonly + assert people = Post.find(1).people + assert !people.any?(&:readonly?) + end + + def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_by_id + assert !posts(:welcome).people.find(1).readonly? + end + + def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_first + assert !posts(:welcome).people.first.readonly? + end + + def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_last + assert !posts(:welcome).people.last.readonly? + end + + def test_readonly_scoping + Post.where('1=1').scoping do + assert !Post.find(1).readonly? + assert Post.readonly(true).find(1).readonly? + assert !Post.readonly(false).find(1).readonly? + end + + Post.joins(' ').scoping do + assert !Post.find(1).readonly? + assert Post.readonly.find(1).readonly? + assert !Post.readonly(false).find(1).readonly? + end + + # Oracle barfs on this because the join includes unqualified and + # conflicting column names + unless current_adapter?(:OracleAdapter) + Post.joins(', developers').scoping do + assert_not Post.find(1).readonly? + assert Post.readonly.find(1).readonly? + assert !Post.readonly(false).find(1).readonly? + end + end + + Post.readonly(true).scoping do + assert Post.find(1).readonly? + assert Post.readonly.find(1).readonly? + assert !Post.readonly(false).find(1).readonly? + end + end + + def test_association_collection_method_missing_scoping_not_readonly + developer = Developer.find(1) + project = Post.find(1) + + assert !developer.projects.all_as_method.first.readonly? + assert !developer.projects.all_as_scope.first.readonly? + + assert !project.comments.all_as_method.first.readonly? + assert !project.comments.all_as_scope.first.readonly? + end +end diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb new file mode 100644 index 0000000000..f52fd22489 --- /dev/null +++ b/activerecord/test/cases/reaper_test.rb @@ -0,0 +1,85 @@ +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class ReaperTest < ActiveRecord::TestCase + attr_reader :pool + + def setup + super + @pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec + end + + teardown do + @pool.connections.each(&:close) + end + + class FakePool + attr_reader :reaped + + def initialize + @reaped = false + end + + def reap + @reaped = true + end + end + + # A reaper with nil time should never reap connections + def test_nil_time + fp = FakePool.new + assert !fp.reaped + reaper = ConnectionPool::Reaper.new(fp, nil) + reaper.run + assert !fp.reaped + end + + def test_some_time + fp = FakePool.new + assert !fp.reaped + + reaper = ConnectionPool::Reaper.new(fp, 0.0001) + reaper.run + until fp.reaped + Thread.pass + end + assert fp.reaped + end + + def test_pool_has_reaper + assert pool.reaper + end + + def test_reaping_frequency_configuration + spec = ActiveRecord::Base.connection_pool.spec.dup + spec.config[:reaping_frequency] = 100 + pool = ConnectionPool.new spec + assert_equal 100, pool.reaper.frequency + end + + def test_connection_pool_starts_reaper + spec = ActiveRecord::Base.connection_pool.spec.dup + spec.config[:reaping_frequency] = 0.0001 + + pool = ConnectionPool.new spec + + conn = nil + child = Thread.new do + conn = pool.checkout + Thread.stop + end + Thread.pass while conn.nil? + + assert conn.in_use? + + child.terminate + + while conn.in_use? + Thread.pass + end + assert !conn.in_use? + end + end + end +end diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb new file mode 100644 index 0000000000..84abaf0291 --- /dev/null +++ b/activerecord/test/cases/reflection_test.rb @@ -0,0 +1,450 @@ +require "cases/helper" +require 'models/topic' +require 'models/customer' +require 'models/company' +require 'models/company_in_module' +require 'models/ship' +require 'models/pirate' +require 'models/price_estimate' +require 'models/essay' +require 'models/author' +require 'models/organization' +require 'models/post' +require 'models/tagging' +require 'models/category' +require 'models/book' +require 'models/subscriber' +require 'models/subscription' +require 'models/tag' +require 'models/sponsor' +require 'models/edge' +require 'models/hotel' +require 'models/chef' +require 'models/department' +require 'models/cake_designer' +require 'models/drink_designer' + +class ReflectionTest < ActiveRecord::TestCase + include ActiveRecord::Reflection + + fixtures :topics, :customers, :companies, :subscribers, :price_estimates + + def setup + @first = Topic.find(1) + end + + def test_human_name + assert_equal "Price estimate", PriceEstimate.model_name.human + assert_equal "Subscriber", Subscriber.model_name.human + end + + def test_read_attribute_names + assert_equal( + %w( id title author_name author_email_address bonus_time written_on last_read content important group approved replies_count unique_replies_count parent_id parent_title type created_at updated_at ).sort, + @first.attribute_names.sort + ) + end + + def test_columns + assert_equal 18, Topic.columns.length + end + + def test_columns_are_returned_in_the_order_they_were_declared + column_names = Topic.columns.map { |column| column.name } + assert_equal %w(id title author_name author_email_address written_on bonus_time last_read content important approved replies_count unique_replies_count parent_id parent_title type group created_at updated_at), column_names + end + + def test_content_columns + content_columns = Topic.content_columns + content_column_names = content_columns.map {|column| column.name} + assert_equal 13, content_columns.length + assert_equal %w(title author_name author_email_address written_on bonus_time last_read content important group approved parent_title created_at updated_at).sort, content_column_names.sort + end + + def test_column_string_type_and_limit + assert_equal :string, @first.column_for_attribute("title").type + assert_equal 250, @first.column_for_attribute("title").limit + end + + def test_column_null_not_null + subscriber = Subscriber.first + assert subscriber.column_for_attribute("name").null + assert !subscriber.column_for_attribute("nick").null + end + + def test_human_name_for_column + assert_equal "Author name", @first.column_for_attribute("author_name").human_name + end + + def test_integer_columns + assert_equal :integer, @first.column_for_attribute("id").type + end + + def test_non_existent_columns_return_nil + assert_deprecated do + assert_nil @first.column_for_attribute("attribute_that_doesnt_exist") + end + end + + def test_reflection_klass_for_nested_class_name + reflection = ActiveRecord::Reflection.create(:has_many, nil, nil, { :class_name => 'MyApplication::Business::Company' }, ActiveRecord::Base) + assert_nothing_raised do + assert_equal MyApplication::Business::Company, reflection.klass + end + end + + def test_irregular_reflection_class_name + ActiveSupport::Inflector.inflections do |inflect| + inflect.irregular 'plural_irregular', 'plurales_irregulares' + end + reflection = ActiveRecord::Reflection.create(:has_many, 'plurales_irregulares', nil, {}, ActiveRecord::Base) + assert_equal 'PluralIrregular', reflection.class_name + end + + def test_aggregation_reflection + reflection_for_address = AggregateReflection.new( + :address, nil, { :mapping => [ %w(address_street street), %w(address_city city), %w(address_country country) ] }, Customer + ) + + reflection_for_balance = AggregateReflection.new( + :balance, nil, { :class_name => "Money", :mapping => %w(balance amount) }, Customer + ) + + reflection_for_gps_location = AggregateReflection.new( + :gps_location, nil, { }, Customer + ) + + assert Customer.reflect_on_all_aggregations.include?(reflection_for_gps_location) + assert Customer.reflect_on_all_aggregations.include?(reflection_for_balance) + assert Customer.reflect_on_all_aggregations.include?(reflection_for_address) + + assert_equal reflection_for_address, Customer.reflect_on_aggregation(:address) + + assert_equal Address, Customer.reflect_on_aggregation(:address).klass + + assert_equal Money, Customer.reflect_on_aggregation(:balance).klass + end + + def test_reflect_on_all_autosave_associations + expected = Pirate.reflect_on_all_associations.select { |r| r.options[:autosave] } + received = Pirate.reflect_on_all_autosave_associations + + assert !received.empty? + assert_not_equal Pirate.reflect_on_all_associations.length, received.length + assert_equal expected, received + end + + def test_has_many_reflection + reflection_for_clients = ActiveRecord::Reflection.create(:has_many, :clients, nil, { :order => "id", :dependent => :destroy }, Firm) + + assert_equal reflection_for_clients, Firm.reflect_on_association(:clients) + + assert_equal Client, Firm.reflect_on_association(:clients).klass + assert_equal 'companies', Firm.reflect_on_association(:clients).table_name + + assert_equal Client, Firm.reflect_on_association(:clients_of_firm).klass + assert_equal 'companies', Firm.reflect_on_association(:clients_of_firm).table_name + end + + def test_has_one_reflection + reflection_for_account = ActiveRecord::Reflection.create(:has_one, :account, nil, { :foreign_key => "firm_id", :dependent => :destroy }, Firm) + assert_equal reflection_for_account, Firm.reflect_on_association(:account) + + assert_equal Account, Firm.reflect_on_association(:account).klass + assert_equal 'accounts', Firm.reflect_on_association(:account).table_name + end + + def test_belongs_to_inferred_foreign_key_from_assoc_name + Company.belongs_to :foo + assert_equal "foo_id", Company.reflect_on_association(:foo).foreign_key + Company.belongs_to :bar, :class_name => "Xyzzy" + assert_equal "bar_id", Company.reflect_on_association(:bar).foreign_key + Company.belongs_to :baz, :class_name => "Xyzzy", :foreign_key => "xyzzy_id" + assert_equal "xyzzy_id", Company.reflect_on_association(:baz).foreign_key + end + + def test_association_reflection_in_modules + ActiveRecord::Base.store_full_sti_class = false + + assert_reflection MyApplication::Business::Firm, + :clients_of_firm, + :klass => MyApplication::Business::Client, + :class_name => 'Client', + :table_name => 'companies' + + assert_reflection MyApplication::Billing::Account, + :firm, + :klass => MyApplication::Business::Firm, + :class_name => 'MyApplication::Business::Firm', + :table_name => 'companies' + + assert_reflection MyApplication::Billing::Account, + :qualified_billing_firm, + :klass => MyApplication::Billing::Firm, + :class_name => 'MyApplication::Billing::Firm', + :table_name => 'companies' + + assert_reflection MyApplication::Billing::Account, + :unqualified_billing_firm, + :klass => MyApplication::Billing::Firm, + :class_name => 'Firm', + :table_name => 'companies' + + assert_reflection MyApplication::Billing::Account, + :nested_qualified_billing_firm, + :klass => MyApplication::Billing::Nested::Firm, + :class_name => 'MyApplication::Billing::Nested::Firm', + :table_name => 'companies' + + assert_reflection MyApplication::Billing::Account, + :nested_unqualified_billing_firm, + :klass => MyApplication::Billing::Nested::Firm, + :class_name => 'Nested::Firm', + :table_name => 'companies' + ensure + ActiveRecord::Base.store_full_sti_class = true + end + + def test_reflection_should_not_raise_error_when_compared_to_other_object + assert_not_equal Object.new, Firm._reflections['clients'] + end + + def test_has_and_belongs_to_many_reflection + assert_equal :has_and_belongs_to_many, Category.reflections['posts'].macro + assert_equal :posts, Category.reflect_on_all_associations(:has_and_belongs_to_many).first.name + end + + def test_has_many_through_reflection + assert_kind_of ThroughReflection, Subscriber.reflect_on_association(:books) + end + + def test_chain + expected = [ + Organization.reflect_on_association(:author_essay_categories), + Author.reflect_on_association(:essays), + Organization.reflect_on_association(:authors) + ] + actual = Organization.reflect_on_association(:author_essay_categories).chain + + assert_equal expected, actual + end + + def test_scope_chain + expected = [ + [Tagging.reflect_on_association(:tag).scope, Post.reflect_on_association(:first_blue_tags).scope], + [Post.reflect_on_association(:first_taggings).scope], + [Author.reflect_on_association(:misc_posts).scope] + ] + actual = Author.reflect_on_association(:misc_post_first_blue_tags).scope_chain + assert_equal expected, actual + + expected = [ + [ + Tagging.reflect_on_association(:blue_tag).scope, + Post.reflect_on_association(:first_blue_tags_2).scope, + Author.reflect_on_association(:misc_post_first_blue_tags_2).scope + ], + [], + [] + ] + actual = Author.reflect_on_association(:misc_post_first_blue_tags_2).scope_chain + assert_equal expected, actual + end + + def test_scope_chain_does_not_interfere_with_hmt_with_polymorphic_case + @hotel = Hotel.create! + @department = @hotel.departments.create! + @department.chefs.create!(employable: CakeDesigner.create!) + @department.chefs.create!(employable: DrinkDesigner.create!) + + assert_equal 1, @hotel.cake_designers.size + assert_equal 1, @hotel.drink_designers.size + assert_equal 2, @hotel.chefs.size + end + + def test_nested? + assert !Author.reflect_on_association(:comments).nested? + assert Author.reflect_on_association(:tags).nested? + + # Only goes :through once, but the through_reflection is a has_and_belongs_to_many, so this is + # a nested through association + assert Category.reflect_on_association(:post_comments).nested? + end + + def test_association_primary_key + # Normal association + assert_equal "id", Author.reflect_on_association(:posts).association_primary_key.to_s + assert_equal "name", Author.reflect_on_association(:essay).association_primary_key.to_s + assert_equal "name", Essay.reflect_on_association(:writer).association_primary_key.to_s + + # Through association (uses the :primary_key option from the source reflection) + assert_equal "nick", Author.reflect_on_association(:subscribers).association_primary_key.to_s + assert_equal "name", Author.reflect_on_association(:essay_category).association_primary_key.to_s + assert_equal "custom_primary_key", Author.reflect_on_association(:tags_with_primary_key).association_primary_key.to_s # nested + end + + def test_association_primary_key_raises_when_missing_primary_key + reflection = ActiveRecord::Reflection.create(:has_many, :edge, nil, {}, Author) + assert_raises(ActiveRecord::UnknownPrimaryKey) { reflection.association_primary_key } + + through = Class.new(ActiveRecord::Reflection::ThroughReflection) { + define_method(:source_reflection) { reflection } + }.new(reflection) + assert_raises(ActiveRecord::UnknownPrimaryKey) { through.association_primary_key } + end + + def test_active_record_primary_key + assert_equal "nick", Subscriber.reflect_on_association(:subscriptions).active_record_primary_key.to_s + assert_equal "name", Author.reflect_on_association(:essay).active_record_primary_key.to_s + end + + def test_active_record_primary_key_raises_when_missing_primary_key + reflection = ActiveRecord::Reflection.create(:has_many, :author, nil, {}, Edge) + assert_raises(ActiveRecord::UnknownPrimaryKey) { reflection.active_record_primary_key } + end + + def test_foreign_type + assert_equal "sponsorable_type", Sponsor.reflect_on_association(:sponsorable).foreign_type.to_s + assert_equal "sponsorable_type", Sponsor.reflect_on_association(:thing).foreign_type.to_s + end + + def test_collection_association + assert Pirate.reflect_on_association(:birds).collection? + assert Pirate.reflect_on_association(:parrots).collection? + + assert !Pirate.reflect_on_association(:ship).collection? + assert !Ship.reflect_on_association(:pirate).collection? + end + + def test_default_association_validation + assert ActiveRecord::Reflection.create(:has_many, :clients, nil, {}, Firm).validate? + + assert !ActiveRecord::Reflection.create(:has_one, :client, nil, {}, Firm).validate? + assert !ActiveRecord::Reflection.create(:belongs_to, :client, nil, {}, Firm).validate? + end + + def test_always_validate_association_if_explicit + assert ActiveRecord::Reflection.create(:has_one, :client, nil, { :validate => true }, Firm).validate? + assert ActiveRecord::Reflection.create(:belongs_to, :client, nil, { :validate => true }, Firm).validate? + assert ActiveRecord::Reflection.create(:has_many, :clients, nil, { :validate => true }, Firm).validate? + end + + def test_validate_association_if_autosave + assert ActiveRecord::Reflection.create(:has_one, :client, nil, { :autosave => true }, Firm).validate? + assert ActiveRecord::Reflection.create(:belongs_to, :client, nil, { :autosave => true }, Firm).validate? + assert ActiveRecord::Reflection.create(:has_many, :clients, nil, { :autosave => true }, Firm).validate? + end + + def test_never_validate_association_if_explicit + assert !ActiveRecord::Reflection.create(:has_one, :client, nil, { :autosave => true, :validate => false }, Firm).validate? + assert !ActiveRecord::Reflection.create(:belongs_to, :client, nil, { :autosave => true, :validate => false }, Firm).validate? + assert !ActiveRecord::Reflection.create(:has_many, :clients, nil, { :autosave => true, :validate => false }, Firm).validate? + end + + def test_foreign_key + assert_equal "author_id", Author.reflect_on_association(:posts).foreign_key.to_s + assert_equal "category_id", Post.reflect_on_association(:categorizations).foreign_key.to_s + end + + def test_through_reflection_scope_chain_does_not_modify_other_reflections + orig_conds = Post.reflect_on_association(:first_blue_tags_2).scope_chain.inspect + Author.reflect_on_association(:misc_post_first_blue_tags_2).scope_chain + assert_equal orig_conds, Post.reflect_on_association(:first_blue_tags_2).scope_chain.inspect + end + + def test_symbol_for_class_name + assert_equal Client, Firm.reflect_on_association(:unsorted_clients_with_symbol).klass + end + + def test_join_table + category = Struct.new(:table_name, :pluralize_table_names).new('categories', true) + product = Struct.new(:table_name, :pluralize_table_names).new('products', true) + + reflection = ActiveRecord::Reflection.create(:has_many, :categories, nil, {}, product) + reflection.stubs(:klass).returns(category) + assert_equal 'categories_products', reflection.join_table + + reflection = ActiveRecord::Reflection.create(:has_many, :products, nil, {}, category) + reflection.stubs(:klass).returns(product) + assert_equal 'categories_products', reflection.join_table + end + + def test_join_table_with_common_prefix + category = Struct.new(:table_name, :pluralize_table_names).new('catalog_categories', true) + product = Struct.new(:table_name, :pluralize_table_names).new('catalog_products', true) + + reflection = ActiveRecord::Reflection.create(:has_many, :categories, nil, {}, product) + reflection.stubs(:klass).returns(category) + assert_equal 'catalog_categories_products', reflection.join_table + + reflection = ActiveRecord::Reflection.create(:has_many, :products, nil, {}, category) + reflection.stubs(:klass).returns(product) + assert_equal 'catalog_categories_products', reflection.join_table + end + + def test_join_table_with_different_prefix + category = Struct.new(:table_name, :pluralize_table_names).new('catalog_categories', true) + page = Struct.new(:table_name, :pluralize_table_names).new('content_pages', true) + + reflection = ActiveRecord::Reflection.create(:has_many, :categories, nil, {}, page) + reflection.stubs(:klass).returns(category) + assert_equal 'catalog_categories_content_pages', reflection.join_table + + reflection = ActiveRecord::Reflection.create(:has_many, :pages, nil, {}, category) + reflection.stubs(:klass).returns(page) + assert_equal 'catalog_categories_content_pages', reflection.join_table + end + + def test_join_table_can_be_overridden + category = Struct.new(:table_name, :pluralize_table_names).new('categories', true) + product = Struct.new(:table_name, :pluralize_table_names).new('products', true) + + reflection = ActiveRecord::Reflection.create(:has_many, :categories, nil, { :join_table => 'product_categories' }, product) + reflection.stubs(:klass).returns(category) + assert_equal 'product_categories', reflection.join_table + + reflection = ActiveRecord::Reflection.create(:has_many, :products, nil, { :join_table => 'product_categories' }, category) + reflection.stubs(:klass).returns(product) + assert_equal 'product_categories', reflection.join_table + end + + def test_includes_accepts_symbols + hotel = Hotel.create! + department = hotel.departments.create! + department.chefs.create! + + assert_nothing_raised do + assert_equal department.chefs, Hotel.includes([departments: :chefs]).first.chefs + end + end + + def test_includes_accepts_strings + hotel = Hotel.create! + department = hotel.departments.create! + department.chefs.create! + + assert_nothing_raised do + assert_equal department.chefs, Hotel.includes(['departments' => 'chefs']).first.chefs + end + end + + def test_reflect_on_association_accepts_symbols + assert_nothing_raised do + assert_equal Hotel.reflect_on_association(:departments).name, :departments + end + end + + def test_reflect_on_association_accepts_strings + assert_nothing_raised do + assert_equal Hotel.reflect_on_association("departments").name, :departments + end + end + + private + def assert_reflection(klass, association, options) + assert reflection = klass.reflect_on_association(association) + options.each do |method, value| + assert_equal(value, reflection.send(method)) + end + end +end diff --git a/activerecord/test/cases/relation/delegation_test.rb b/activerecord/test/cases/relation/delegation_test.rb new file mode 100644 index 0000000000..29c9d0e2af --- /dev/null +++ b/activerecord/test/cases/relation/delegation_test.rb @@ -0,0 +1,68 @@ +require 'cases/helper' +require 'models/post' +require 'models/comment' + +module ActiveRecord + class DelegationTest < ActiveRecord::TestCase + fixtures :posts + + def call_method(target, method) + method_arity = target.to_a.method(method).arity + + if method_arity.zero? + target.public_send(method) + elsif method_arity < 0 + if method == :shuffle! + target.public_send(method) + else + target.public_send(method, 1) + end + elsif method_arity == 1 + target.public_send(method, 1) + else + raise NotImplementedError + end + end + end + + module DelegationWhitelistBlacklistTests + ARRAY_DELEGATES = [ + :+, :-, :|, :&, :[], + :all?, :collect, :detect, :each, :each_cons, :each_with_index, + :exclude?, :find_all, :flat_map, :group_by, :include?, :length, + :map, :none?, :one?, :partition, :reject, :reverse, + :sample, :second, :sort, :sort_by, :third, + :to_ary, :to_set, :to_xml, :to_yaml, :join + ] + + ARRAY_DELEGATES.each do |method| + define_method "test_delegates_#{method}_to_Array" do + assert_respond_to target, method + end + end + + ActiveRecord::Delegation::BLACKLISTED_ARRAY_METHODS.each do |method| + define_method "test_#{method}_is_not_delegated_to_Array" do + assert_raises(NoMethodError) { call_method(target, method) } + end + end + end + + class DelegationAssociationTest < DelegationTest + include DelegationWhitelistBlacklistTests + + def target + Post.first.comments + end + end + + class DelegationRelationTest < DelegationTest + include DelegationWhitelistBlacklistTests + + fixtures :comments + + def target + Comment.all + end + end +end diff --git a/activerecord/test/cases/relation/merging_test.rb b/activerecord/test/cases/relation/merging_test.rb new file mode 100644 index 0000000000..2b5c2fd5a4 --- /dev/null +++ b/activerecord/test/cases/relation/merging_test.rb @@ -0,0 +1,147 @@ +require 'cases/helper' +require 'models/author' +require 'models/comment' +require 'models/developer' +require 'models/post' +require 'models/project' + +class RelationMergingTest < ActiveRecord::TestCase + fixtures :developers, :comments, :authors, :posts + + def test_relation_merging + devs = Developer.where("salary >= 80000").merge(Developer.limit(2)).merge(Developer.order('id ASC').where("id < 3")) + assert_equal [developers(:david), developers(:jamis)], devs.to_a + + dev_with_count = Developer.limit(1).merge(Developer.order('id DESC')).merge(Developer.select('developers.*')) + assert_equal [developers(:poor_jamis)], dev_with_count.to_a + end + + def test_relation_to_sql + post = Post.first + sql = post.comments.to_sql + assert_match(/.?post_id.? = #{post.id}\Z/i, sql) + end + + def test_relation_merging_with_arel_equalities_keeps_last_equality + devs = Developer.where(Developer.arel_table[:salary].eq(80000)).merge( + Developer.where(Developer.arel_table[:salary].eq(9000)) + ) + assert_equal [developers(:poor_jamis)], devs.to_a + end + + def test_relation_merging_with_arel_equalities_keeps_last_equality_with_non_attribute_left_hand + salary_attr = Developer.arel_table[:salary] + devs = Developer.where( + Arel::Nodes::NamedFunction.new('abs', [salary_attr]).eq(80000) + ).merge( + Developer.where( + Arel::Nodes::NamedFunction.new('abs', [salary_attr]).eq(9000) + ) + ) + assert_equal [developers(:poor_jamis)], devs.to_a + end + + def test_relation_merging_with_eager_load + relations = [] + relations << Post.order('comments.id DESC').merge(Post.eager_load(:last_comment)).merge(Post.all) + relations << Post.eager_load(:last_comment).merge(Post.order('comments.id DESC')).merge(Post.all) + + relations.each do |posts| + post = posts.find { |p| p.id == 1 } + assert_equal Post.find(1).last_comment, post.last_comment + end + end + + def test_relation_merging_with_locks + devs = Developer.lock.where("salary >= 80000").order("id DESC").merge(Developer.limit(2)) + assert devs.locked.present? + end + + def test_relation_merging_with_preload + [Post.all.merge(Post.preload(:author)), Post.preload(:author).merge(Post.all)].each do |posts| + assert_queries(2) { assert posts.first.author } + end + end + + def test_relation_merging_with_joins + comments = Comment.joins(:post).where(:body => 'Thank you for the welcome').merge(Post.where(:body => 'Such a lovely day')) + assert_equal 1, comments.count + end + + def test_relation_merging_with_association + assert_queries(2) do # one for loading post, and another one merged query + post = Post.where(:body => 'Such a lovely day').first + comments = Comment.where(:body => 'Thank you for the welcome').merge(post.comments) + assert_equal 1, comments.count + end + end + + test "merge collapses wheres from the LHS only" do + left = Post.where(title: "omg").where(comments_count: 1) + right = Post.where(title: "wtf").where(title: "bbq") + + expected = [left.bind_values[1]] + right.bind_values + merged = left.merge(right) + + assert_equal expected, merged.bind_values + assert !merged.to_sql.include?("omg") + assert merged.to_sql.include?("wtf") + assert merged.to_sql.include?("bbq") + end + + def test_merging_keeps_lhs_bind_parameters + column = Post.columns_hash['id'] + binds = [[column, 20]] + + right = Post.where(id: 20) + left = Post.where(id: 10) + + merged = left.merge(right) + assert_equal binds, merged.bind_values + end + + def test_merging_reorders_bind_params + post = Post.first + right = Post.where(id: 1) + left = Post.where(title: post.title) + + merged = left.merge(right) + assert_equal post, merged.first + end + + def test_merging_compares_symbols_and_strings_as_equal + post = PostThatLoadsCommentsInAnAfterSaveHook.create!(title: "First Post", body: "Blah blah blah.") + assert_equal "First comment!", post.comments.where(body: "First comment!").first_or_create.body + end +end + +class MergingDifferentRelationsTest < ActiveRecord::TestCase + fixtures :posts, :authors + + test "merging where relations" do + hello_by_bob = Post.where(body: "hello").joins(:author). + merge(Author.where(name: "Bob")).order("posts.id").pluck("posts.id") + + assert_equal [posts(:misc_by_bob).id, + posts(:other_by_bob).id], hello_by_bob + end + + test "merging order relations" do + posts_by_author_name = Post.limit(3).joins(:author). + merge(Author.order(:name)).pluck("authors.name") + + assert_equal ["Bob", "Bob", "David"], posts_by_author_name + + posts_by_author_name = Post.limit(3).joins(:author). + merge(Author.order("name")).pluck("authors.name") + + assert_equal ["Bob", "Bob", "David"], posts_by_author_name + end + + test "merging order relations (using a hash argument)" do + posts_by_author_name = Post.limit(4).joins(:author). + merge(Author.order(name: :desc)).pluck("authors.name") + + assert_equal ["Mary", "Mary", "Mary", "David"], posts_by_author_name + end +end diff --git a/activerecord/test/cases/relation/mutation_test.rb b/activerecord/test/cases/relation/mutation_test.rb new file mode 100644 index 0000000000..1da5c36e1c --- /dev/null +++ b/activerecord/test/cases/relation/mutation_test.rb @@ -0,0 +1,161 @@ +require 'cases/helper' +require 'models/post' + +module ActiveRecord + class RelationMutationTest < ActiveSupport::TestCase + class FakeKlass < Struct.new(:table_name, :name) + extend ActiveRecord::Delegation::DelegateCache + inherited self + + def connection + Post.connection + end + + def relation_delegate_class(klass) + self.class.relation_delegate_class(klass) + end + + def attribute_alias?(name) + false + end + end + + def relation + @relation ||= Relation.new FakeKlass.new('posts'), Post.arel_table + end + + (Relation::MULTI_VALUE_METHODS - [:references, :extending, :order, :unscope, :select]).each do |method| + test "##{method}!" do + assert relation.public_send("#{method}!", :foo).equal?(relation) + assert_equal [:foo], relation.public_send("#{method}_values") + end + end + + test "#_select!" do + assert relation.public_send("_select!", :foo).equal?(relation) + assert_equal [:foo], relation.public_send("select_values") + end + + test '#order!' do + assert relation.order!('name ASC').equal?(relation) + assert_equal ['name ASC'], relation.order_values + end + + test '#order! with symbol prepends the table name' do + assert relation.order!(:name).equal?(relation) + node = relation.order_values.first + assert node.ascending? + assert_equal :name, node.expr.name + assert_equal "posts", node.expr.relation.name + end + + test '#order! on non-string does not attempt regexp match for references' do + obj = Object.new + obj.expects(:=~).never + assert relation.order!(obj) + assert_equal [obj], relation.order_values + end + + test '#references!' do + assert relation.references!(:foo).equal?(relation) + assert relation.references_values.include?('foo') + end + + test 'extending!' do + mod, mod2 = Module.new, Module.new + + assert relation.extending!(mod).equal?(relation) + assert_equal [mod], relation.extending_values + assert relation.is_a?(mod) + + relation.extending!(mod2) + assert_equal [mod, mod2], relation.extending_values + end + + test 'extending! with empty args' do + relation.extending! + assert_equal [], relation.extending_values + end + + (Relation::SINGLE_VALUE_METHODS - [:from, :lock, :reordering, :reverse_order, :create_with]).each do |method| + test "##{method}!" do + assert relation.public_send("#{method}!", :foo).equal?(relation) + assert_equal :foo, relation.public_send("#{method}_value") + end + end + + test '#from!' do + assert relation.from!('foo').equal?(relation) + assert_equal ['foo', nil], relation.from_value + end + + test '#lock!' do + assert relation.lock!('foo').equal?(relation) + assert_equal 'foo', relation.lock_value + end + + test '#reorder!' do + relation = self.relation.order('foo') + + assert relation.reorder!('bar').equal?(relation) + assert_equal ['bar'], relation.order_values + assert relation.reordering_value + end + + test '#reorder! with symbol prepends the table name' do + assert relation.reorder!(:name).equal?(relation) + node = relation.order_values.first + + assert node.ascending? + assert_equal :name, node.expr.name + assert_equal "posts", node.expr.relation.name + end + + test 'reverse_order!' do + relation = Post.order('title ASC, comments_count DESC') + + relation.reverse_order! + + assert_equal 'title DESC', relation.order_values.first + assert_equal 'comments_count ASC', relation.order_values.last + + + relation.reverse_order! + + assert_equal 'title ASC', relation.order_values.first + assert_equal 'comments_count DESC', relation.order_values.last + end + + test 'create_with!' do + assert relation.create_with!(foo: 'bar').equal?(relation) + assert_equal({foo: 'bar'}, relation.create_with_value) + end + + test 'test_merge!' do + assert relation.merge!(where: :foo).equal?(relation) + assert_equal [:foo], relation.where_values + end + + test 'merge with a proc' do + assert_equal [:foo], relation.merge(-> { where(:foo) }).where_values + end + + test 'none!' do + assert relation.none!.equal?(relation) + assert_equal [NullRelation], relation.extending_values + assert relation.is_a?(NullRelation) + end + + test 'distinct!' do + relation.distinct! :foo + assert_equal :foo, relation.distinct_value + assert_equal :foo, relation.uniq_value # deprecated access + end + + test 'uniq! was replaced by distinct!' do + relation.uniq! :foo + assert_equal :foo, relation.distinct_value + assert_equal :foo, relation.uniq_value # deprecated access + end + end +end diff --git a/activerecord/test/cases/relation/predicate_builder_test.rb b/activerecord/test/cases/relation/predicate_builder_test.rb new file mode 100644 index 0000000000..4057835688 --- /dev/null +++ b/activerecord/test/cases/relation/predicate_builder_test.rb @@ -0,0 +1,14 @@ +require "cases/helper" +require 'models/topic' + +module ActiveRecord + class PredicateBuilderTest < ActiveRecord::TestCase + def test_registering_new_handlers + PredicateBuilder.register_handler(Regexp, proc do |column, value| + Arel::Nodes::InfixOperation.new('~', column, Arel.sql(value.source)) + end) + + assert_match %r{["`]topics["`].["`]title["`] ~ rails}i, Topic.where(title: /rails/).to_sql + end + end +end diff --git a/activerecord/test/cases/relation/where_chain_test.rb b/activerecord/test/cases/relation/where_chain_test.rb new file mode 100644 index 0000000000..b9e69bdb08 --- /dev/null +++ b/activerecord/test/cases/relation/where_chain_test.rb @@ -0,0 +1,153 @@ +require 'cases/helper' +require 'models/post' +require 'models/comment' + +module ActiveRecord + class WhereChainTest < ActiveRecord::TestCase + fixtures :posts + + def setup + super + @name = 'title' + end + + def test_not_eq + relation = Post.where.not(title: 'hello') + + assert_equal 1, relation.where_values.length + + value = relation.where_values.first + bind = relation.bind_values.first + + assert_bound_ast value, Post.arel_table[@name], Arel::Nodes::NotEqual + assert_equal 'hello', bind.last + end + + def test_not_null + expected = Post.arel_table[@name].not_eq(nil) + relation = Post.where.not(title: nil) + assert_equal([expected], relation.where_values) + end + + def test_not_with_nil + assert_raise ArgumentError do + Post.where.not(nil) + end + end + + def test_not_in + expected = Post.arel_table[@name].not_in(%w[hello goodbye]) + relation = Post.where.not(title: %w[hello goodbye]) + assert_equal([expected], relation.where_values) + end + + def test_association_not_eq + expected = Comment.arel_table[@name].not_eq('hello') + relation = Post.joins(:comments).where.not(comments: {title: 'hello'}) + assert_equal(expected.to_sql, relation.where_values.first.to_sql) + end + + def test_not_eq_with_preceding_where + relation = Post.where(title: 'hello').where.not(title: 'world') + + value = relation.where_values.first + bind = relation.bind_values.first + assert_bound_ast value, Post.arel_table[@name], Arel::Nodes::Equality + assert_equal 'hello', bind.last + + value = relation.where_values.last + bind = relation.bind_values.last + assert_bound_ast value, Post.arel_table[@name], Arel::Nodes::NotEqual + assert_equal 'world', bind.last + end + + def test_not_eq_with_succeeding_where + relation = Post.where.not(title: 'hello').where(title: 'world') + + value = relation.where_values.first + bind = relation.bind_values.first + assert_bound_ast value, Post.arel_table[@name], Arel::Nodes::NotEqual + assert_equal 'hello', bind.last + + value = relation.where_values.last + bind = relation.bind_values.last + assert_bound_ast value, Post.arel_table[@name], Arel::Nodes::Equality + assert_equal 'world', bind.last + end + + def test_not_eq_with_string_parameter + expected = Arel::Nodes::Not.new("title = 'hello'") + relation = Post.where.not("title = 'hello'") + assert_equal([expected], relation.where_values) + end + + def test_not_eq_with_array_parameter + expected = Arel::Nodes::Not.new("title = 'hello'") + relation = Post.where.not(['title = ?', 'hello']) + assert_equal([expected], relation.where_values) + end + + def test_chaining_multiple + relation = Post.where.not(author_id: [1, 2]).where.not(title: 'ruby on rails') + + expected = Post.arel_table['author_id'].not_in([1, 2]) + assert_equal(expected, relation.where_values[0]) + + value = relation.where_values[1] + bind = relation.bind_values.first + + assert_bound_ast value, Post.arel_table[@name], Arel::Nodes::NotEqual + assert_equal 'ruby on rails', bind.last + end + + def test_rewhere_with_one_condition + relation = Post.where(title: 'hello').where(title: 'world').rewhere(title: 'alone') + + assert_equal 1, relation.where_values.size + value = relation.where_values.first + bind = relation.bind_values.first + assert_bound_ast value, Post.arel_table[@name], Arel::Nodes::Equality + assert_equal 'alone', bind.last + end + + def test_rewhere_with_multiple_overwriting_conditions + relation = Post.where(title: 'hello').where(body: 'world').rewhere(title: 'alone', body: 'again') + + assert_equal 2, relation.where_values.size + + value = relation.where_values.first + bind = relation.bind_values.first + assert_bound_ast value, Post.arel_table['title'], Arel::Nodes::Equality + assert_equal 'alone', bind.last + + value = relation.where_values[1] + bind = relation.bind_values[1] + assert_bound_ast value, Post.arel_table['body'], Arel::Nodes::Equality + assert_equal 'again', bind.last + end + + def assert_bound_ast value, table, type + assert_equal table, value.left + assert_kind_of type, value + assert_kind_of Arel::Nodes::BindParam, value.right + end + + def test_rewhere_with_one_overwriting_condition_and_one_unrelated + relation = Post.where(title: 'hello').where(body: 'world').rewhere(title: 'alone') + + assert_equal 2, relation.where_values.size + + value = relation.where_values.first + bind = relation.bind_values.first + + assert_bound_ast value, Post.arel_table['body'], Arel::Nodes::Equality + assert_equal 'world', bind.last + + value = relation.where_values.second + bind = relation.bind_values.second + + assert_bound_ast value, Post.arel_table['title'], Arel::Nodes::Equality + assert_equal 'alone', bind.last + end + end +end diff --git a/activerecord/test/cases/relation/where_test.rb b/activerecord/test/cases/relation/where_test.rb new file mode 100644 index 0000000000..a6a36a6fd9 --- /dev/null +++ b/activerecord/test/cases/relation/where_test.rb @@ -0,0 +1,214 @@ +require "cases/helper" +require 'models/author' +require 'models/price_estimate' +require 'models/treasure' +require 'models/post' +require 'models/comment' +require 'models/edge' +require 'models/topic' +require 'models/binary' + +module ActiveRecord + class WhereTest < ActiveRecord::TestCase + fixtures :posts, :edges, :authors, :binaries + + def test_where_copies_bind_params + author = authors(:david) + posts = author.posts.where('posts.id != 1') + joined = Post.where(id: posts) + + assert_operator joined.length, :>, 0 + + joined.each { |post| + assert_equal author, post.author + assert_not_equal 1, post.id + } + end + + def test_rewhere_on_root + assert_equal posts(:welcome), Post.rewhere(title: 'Welcome to the weblog').first + end + + def test_belongs_to_shallow_where + author = Author.new + author.id = 1 + + assert_equal Post.where(author_id: 1).to_sql, Post.where(author: author).to_sql + end + + def test_belongs_to_nil_where + assert_equal Post.where(author_id: nil).to_sql, Post.where(author: nil).to_sql + end + + def test_belongs_to_array_value_where + assert_equal Post.where(author_id: [1,2]).to_sql, Post.where(author: [1,2]).to_sql + end + + def test_belongs_to_nested_relation_where + expected = Post.where(author_id: Author.where(id: [1,2])).to_sql + actual = Post.where(author: Author.where(id: [1,2])).to_sql + + assert_equal expected, actual + end + + def test_belongs_to_nested_where + parent = Comment.new + parent.id = 1 + + expected = Post.where(comments: { parent_id: 1 }).joins(:comments) + actual = Post.where(comments: { parent: parent }).joins(:comments) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_polymorphic_shallow_where + treasure = Treasure.new + treasure.id = 1 + + expected = PriceEstimate.where(estimate_of_type: 'Treasure', estimate_of_id: 1) + actual = PriceEstimate.where(estimate_of: treasure) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_polymorphic_nested_array_where + treasure = Treasure.new + treasure.id = 1 + hidden = HiddenTreasure.new + hidden.id = 2 + + expected = PriceEstimate.where(estimate_of_type: 'Treasure', estimate_of_id: [treasure, hidden]) + actual = PriceEstimate.where(estimate_of: [treasure, hidden]) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_polymorphic_nested_relation_where + expected = PriceEstimate.where(estimate_of_type: 'Treasure', estimate_of_id: Treasure.where(id: [1,2])) + actual = PriceEstimate.where(estimate_of: Treasure.where(id: [1,2])) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_polymorphic_sti_shallow_where + treasure = HiddenTreasure.new + treasure.id = 1 + + expected = PriceEstimate.where(estimate_of_type: 'Treasure', estimate_of_id: 1) + actual = PriceEstimate.where(estimate_of: treasure) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_polymorphic_nested_where + thing = Post.new + thing.id = 1 + + expected = Treasure.where(price_estimates: { thing_type: 'Post', thing_id: 1 }).joins(:price_estimates) + actual = Treasure.where(price_estimates: { thing: thing }).joins(:price_estimates) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_polymorphic_sti_nested_where + treasure = HiddenTreasure.new + treasure.id = 1 + + expected = Treasure.where(price_estimates: { estimate_of_type: 'Treasure', estimate_of_id: 1 }).joins(:price_estimates) + actual = Treasure.where(price_estimates: { estimate_of: treasure }).joins(:price_estimates) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_decorated_polymorphic_where + treasure_decorator = Struct.new(:model) do + def self.method_missing(method, *args, &block) + Treasure.send(method, *args, &block) + end + + def is_a?(klass) + model.is_a?(klass) + end + + def method_missing(method, *args, &block) + model.send(method, *args, &block) + end + end + + treasure = Treasure.new + treasure.id = 1 + decorated_treasure = treasure_decorator.new(treasure) + + expected = PriceEstimate.where(estimate_of_type: 'Treasure', estimate_of_id: 1) + actual = PriceEstimate.where(estimate_of: decorated_treasure) + + assert_equal expected.to_sql, actual.to_sql + end + + def test_aliased_attribute + expected = Topic.where(heading: 'The First Topic') + actual = Topic.where(title: 'The First Topic') + + assert_equal expected.to_sql, actual.to_sql + end + + def test_where_error + assert_raises(ActiveRecord::StatementInvalid) do + Post.where(:id => { 'posts.author_id' => 10 }).first + end + end + + def test_where_with_table_name + post = Post.first + assert_equal post, Post.where(:posts => { 'id' => post.id }).first + end + + def test_where_with_table_name_and_empty_hash + assert_equal 0, Post.where(:posts => {}).count + end + + def test_where_with_table_name_and_empty_array + assert_equal 0, Post.where(:id => []).count + end + + def test_where_with_empty_hash_and_no_foreign_key + assert_equal 0, Edge.where(:sink => {}).count + end + + def test_where_with_blank_conditions + [[], {}, nil, ""].each do |blank| + assert_equal 4, Edge.where(blank).order("sink_id").to_a.size + end + end + + def test_where_with_integer_for_string_column + count = Post.where(:title => 0).count + assert_equal 0, count + end + + def test_where_with_float_for_string_column + count = Post.where(:title => 0.0).count + assert_equal 0, count + end + + def test_where_with_boolean_for_string_column + count = Post.where(:title => false).count + assert_equal 0, count + end + + def test_where_with_decimal_for_string_column + count = Post.where(:title => BigDecimal.new(0)).count + assert_equal 0, count + end + + def test_where_with_duration_for_string_column + count = Post.where(:title => 0.seconds).count + assert_equal 0, count + end + + def test_where_with_integer_for_binary_column + count = Binary.where(:data => 0).count + assert_equal 0, count + end + end +end diff --git a/activerecord/test/cases/relation_test.rb b/activerecord/test/cases/relation_test.rb new file mode 100644 index 0000000000..3280945d09 --- /dev/null +++ b/activerecord/test/cases/relation_test.rb @@ -0,0 +1,267 @@ +require "cases/helper" +require 'models/post' +require 'models/comment' +require 'models/author' +require 'models/rating' + +module ActiveRecord + class RelationTest < ActiveRecord::TestCase + fixtures :posts, :comments, :authors + + class FakeKlass < Struct.new(:table_name, :name) + extend ActiveRecord::Delegation::DelegateCache + + inherited self + + def self.connection + Post.connection + end + + def self.table_name + 'fake_table' + end + end + + def test_construction + relation = Relation.new FakeKlass, :b + assert_equal FakeKlass, relation.klass + assert_equal :b, relation.table + assert !relation.loaded, 'relation is not loaded' + end + + def test_responds_to_model_and_returns_klass + relation = Relation.new FakeKlass, :b + assert_equal FakeKlass, relation.model + end + + def test_initialize_single_values + relation = Relation.new FakeKlass, :b + (Relation::SINGLE_VALUE_METHODS - [:create_with]).each do |method| + assert_nil relation.send("#{method}_value"), method.to_s + end + assert_equal({}, relation.create_with_value) + end + + def test_multi_value_initialize + relation = Relation.new FakeKlass, :b + Relation::MULTI_VALUE_METHODS.each do |method| + assert_equal [], relation.send("#{method}_values"), method.to_s + end + end + + def test_extensions + relation = Relation.new FakeKlass, :b + assert_equal [], relation.extensions + end + + def test_empty_where_values_hash + relation = Relation.new FakeKlass, :b + assert_equal({}, relation.where_values_hash) + + relation.where! :hello + assert_equal({}, relation.where_values_hash) + end + + def test_has_values + relation = Relation.new Post, Post.arel_table + relation.where! relation.table[:id].eq(10) + assert_equal({:id => 10}, relation.where_values_hash) + end + + def test_values_wrong_table + relation = Relation.new Post, Post.arel_table + relation.where! Comment.arel_table[:id].eq(10) + assert_equal({}, relation.where_values_hash) + end + + def test_tree_is_not_traversed + relation = Relation.new Post, Post.arel_table + left = relation.table[:id].eq(10) + right = relation.table[:id].eq(10) + combine = left.and right + relation.where! combine + assert_equal({}, relation.where_values_hash) + end + + def test_table_name_delegates_to_klass + relation = Relation.new FakeKlass.new('posts'), :b + assert_equal 'posts', relation.table_name + end + + def test_scope_for_create + relation = Relation.new FakeKlass, :b + assert_equal({}, relation.scope_for_create) + end + + def test_create_with_value + relation = Relation.new Post, Post.arel_table + hash = { :hello => 'world' } + relation.create_with_value = hash + assert_equal hash, relation.scope_for_create + end + + def test_create_with_value_with_wheres + relation = Relation.new Post, Post.arel_table + relation.where! relation.table[:id].eq(10) + relation.create_with_value = {:hello => 'world'} + assert_equal({:hello => 'world', :id => 10}, relation.scope_for_create) + end + + # FIXME: is this really wanted or expected behavior? + def test_scope_for_create_is_cached + relation = Relation.new Post, Post.arel_table + assert_equal({}, relation.scope_for_create) + + relation.where! relation.table[:id].eq(10) + assert_equal({}, relation.scope_for_create) + + relation.create_with_value = {:hello => 'world'} + assert_equal({}, relation.scope_for_create) + end + + def test_bad_constants_raise_errors + assert_raises(NameError) do + ActiveRecord::Relation::HelloWorld + end + end + + def test_empty_eager_loading? + relation = Relation.new FakeKlass, :b + assert !relation.eager_loading? + end + + def test_eager_load_values + relation = Relation.new FakeKlass, :b + relation.eager_load! :b + assert relation.eager_loading? + end + + def test_references_values + relation = Relation.new FakeKlass, :b + assert_equal [], relation.references_values + relation = relation.references(:foo).references(:omg, :lol) + assert_equal ['foo', 'omg', 'lol'], relation.references_values + end + + def test_references_values_dont_duplicate + relation = Relation.new FakeKlass, :b + relation = relation.references(:foo).references(:foo) + assert_equal ['foo'], relation.references_values + end + + test 'merging a hash into a relation' do + relation = Relation.new FakeKlass, :b + relation = relation.merge where: :lol, readonly: true + + assert_equal [:lol], relation.where_values + assert_equal true, relation.readonly_value + end + + test 'merging an empty hash into a relation' do + assert_equal [], Relation.new(FakeKlass, :b).merge({}).where_values + end + + test 'merging a hash with unknown keys raises' do + assert_raises(ArgumentError) { Relation::HashMerger.new(nil, omg: 'lol') } + end + + test '#values returns a dup of the values' do + relation = Relation.new(FakeKlass, :b).where! :foo + values = relation.values + + values[:where] = nil + assert_not_nil relation.where_values + end + + test 'relations can be created with a values hash' do + relation = Relation.new(FakeKlass, :b, where: [:foo]) + assert_equal [:foo], relation.where_values + end + + test 'merging a single where value' do + relation = Relation.new(FakeKlass, :b) + relation.merge!(where: :foo) + assert_equal [:foo], relation.where_values + end + + test 'merging a hash interpolates conditions' do + klass = Class.new(FakeKlass) do + def self.sanitize_sql(args) + raise unless args == ['foo = ?', 'bar'] + 'foo = bar' + end + end + + relation = Relation.new(klass, :b) + relation.merge!(where: ['foo = ?', 'bar']) + assert_equal ['foo = bar'], relation.where_values + end + + def test_merging_readonly_false + relation = Relation.new FakeKlass, :b + readonly_false_relation = relation.readonly(false) + # test merging in both directions + assert_equal false, relation.merge(readonly_false_relation).readonly_value + assert_equal false, readonly_false_relation.merge(relation).readonly_value + end + + def test_relation_merging_with_merged_joins_as_symbols + special_comments_with_ratings = SpecialComment.joins(:ratings) + posts_with_special_comments_with_ratings = Post.group("posts.id").joins(:special_comments).merge(special_comments_with_ratings) + assert_equal 3, authors(:david).posts.merge(posts_with_special_comments_with_ratings).count.length + end + + def test_relation_merging_with_joins_as_join_dependency_pick_proper_parent + post = Post.create!(title: "haha", body: "huhu") + comment = post.comments.create!(body: "hu") + 3.times { comment.ratings.create! } + + relation = Post.joins(:comments).merge Comment.joins(:ratings) + + assert_equal 3, relation.where(id: post.id).pluck(:id).size + end + + def test_respond_to_for_non_selected_element + post = Post.select(:title).first + assert_equal false, post.respond_to?(:body), "post should not respond_to?(:body) since invoking it raises exception" + + silence_warnings { post = Post.select("'title' as post_title").first } + assert_equal false, post.respond_to?(:title), "post should not respond_to?(:body) since invoking it raises exception" + end + + def test_relation_merging_with_merged_joins_as_strings + join_string = "LEFT OUTER JOIN #{Rating.quoted_table_name} ON #{SpecialComment.quoted_table_name}.id = #{Rating.quoted_table_name}.comment_id" + special_comments_with_ratings = SpecialComment.joins join_string + posts_with_special_comments_with_ratings = Post.group("posts.id").joins(:special_comments).merge(special_comments_with_ratings) + assert_equal 3, authors(:david).posts.merge(posts_with_special_comments_with_ratings).count.length + end + + class EnsureRoundTripTypeCasting < ActiveRecord::Type::Value + def type + :string + end + + def type_cast_from_database(value) + raise value unless value == "type cast for database" + "type cast from database" + end + + def type_cast_for_database(value) + raise value unless value == "value from user" + "type cast for database" + end + end + + class UpdateAllTestModel < ActiveRecord::Base + self.table_name = 'posts' + + attribute :body, EnsureRoundTripTypeCasting.new + end + + def test_update_all_goes_through_normal_type_casting + UpdateAllTestModel.update_all(body: "value from user", type: nil) # No STI + + assert_equal "type cast from database", UpdateAllTestModel.first.body + end + end +end diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb new file mode 100644 index 0000000000..88df997a2f --- /dev/null +++ b/activerecord/test/cases/relations_test.rb @@ -0,0 +1,1739 @@ +require "cases/helper" +require 'models/tag' +require 'models/tagging' +require 'models/post' +require 'models/topic' +require 'models/comment' +require 'models/author' +require 'models/entrant' +require 'models/developer' +require 'models/reply' +require 'models/company' +require 'models/bird' +require 'models/car' +require 'models/engine' +require 'models/tyre' +require 'models/minivan' +require 'models/aircraft' + + +class RelationTest < ActiveRecord::TestCase + fixtures :authors, :topics, :entrants, :developers, :companies, :developers_projects, :accounts, :categories, :categorizations, :posts, :comments, + :tags, :taggings, :cars, :minivans + + def test_do_not_double_quote_string_id + van = Minivan.last + assert van + assert_equal van.id, Minivan.where(:minivan_id => van).to_a.first.minivan_id + end + + def test_do_not_double_quote_string_id_with_array + van = Minivan.last + assert van + assert_equal van, Minivan.where(:minivan_id => [van]).to_a.first + end + + def test_bind_values + relation = Post.all + assert_equal [], relation.bind_values + + relation2 = relation.bind 'foo' + assert_equal %w{ foo }, relation2.bind_values + assert_equal [], relation.bind_values + end + + def test_two_scopes_with_includes_should_not_drop_any_include + # heat habtm cache + car = Car.incl_engines.incl_tyres.first + car.tyres.length + car.engines.length + + car = Car.incl_engines.incl_tyres.first + assert_no_queries { car.tyres.length } + assert_no_queries { car.engines.length } + end + + def test_dynamic_finder + x = Post.where('author_id = ?', 1) + assert x.klass.respond_to?(:find_by_id), '@klass should handle dynamic finders' + end + + def test_multivalue_where + posts = Post.where('author_id = ? AND id = ?', 1, 1) + assert_equal 1, posts.to_a.size + end + + def test_scoped + topics = Topic.all + assert_kind_of ActiveRecord::Relation, topics + assert_equal 5, topics.size + end + + def test_to_json + assert_nothing_raised { Bird.all.to_json } + assert_nothing_raised { Bird.all.to_a.to_json } + end + + def test_to_yaml + assert_nothing_raised { Bird.all.to_yaml } + assert_nothing_raised { Bird.all.to_a.to_yaml } + end + + def test_to_xml + assert_nothing_raised { Bird.all.to_xml } + assert_nothing_raised { Bird.all.to_a.to_xml } + end + + def test_scoped_all + topics = Topic.all.to_a + assert_kind_of Array, topics + assert_no_queries { assert_equal 5, topics.size } + end + + def test_loaded_all + topics = Topic.all + + assert_queries(1) do + 2.times { assert_equal 5, topics.to_a.size } + end + + assert topics.loaded? + end + + def test_scoped_first + topics = Topic.all.order('id ASC') + + assert_queries(1) do + 2.times { assert_equal "The First Topic", topics.first.title } + end + + assert ! topics.loaded? + end + + def test_loaded_first + topics = Topic.all.order('id ASC') + + assert_queries(1) do + topics.to_a # force load + 2.times { assert_equal "The First Topic", topics.first.title } + end + + assert topics.loaded? + end + + def test_reload + topics = Topic.all + + assert_queries(1) do + 2.times { topics.to_a } + end + + assert topics.loaded? + + original_size = topics.to_a.size + Topic.create! :title => 'fake' + + assert_queries(1) { topics.reload } + assert_equal original_size + 1, topics.size + assert topics.loaded? + end + + def test_finding_with_subquery + relation = Topic.where(:approved => true) + assert_equal relation.to_a, Topic.select('*').from(relation).to_a + assert_equal relation.to_a, Topic.select('subquery.*').from(relation).to_a + assert_equal relation.to_a, Topic.select('a.*').from(relation, :a).to_a + end + + def test_finding_with_subquery_with_binds + relation = Post.first.comments + assert_equal relation.to_a, Comment.select('*').from(relation).to_a + assert_equal relation.to_a, Comment.select('subquery.*').from(relation).to_a + assert_equal relation.to_a, Comment.select('a.*').from(relation, :a).to_a + end + + def test_finding_with_subquery_without_select_does_not_change_the_select + relation = Topic.where(approved: true) + assert_raises(ActiveRecord::StatementInvalid) do + Topic.from(relation).to_a + end + end + + + def test_finding_with_conditions + assert_equal ["David"], Author.where(:name => 'David').map(&:name) + assert_equal ['Mary'], Author.where(["name = ?", 'Mary']).map(&:name) + assert_equal ['Mary'], Author.where("name = ?", 'Mary').map(&:name) + end + + def test_finding_with_order + topics = Topic.order('id') + assert_equal 5, topics.to_a.size + assert_equal topics(:first).title, topics.first.title + end + + def test_finding_with_arel_order + topics = Topic.order(Topic.arel_table[:id].asc) + assert_equal 5, topics.to_a.size + assert_equal topics(:first).title, topics.first.title + end + + def test_finding_with_assoc_order + topics = Topic.order(:id => :desc) + assert_equal 5, topics.to_a.size + assert_equal topics(:fifth).title, topics.first.title + end + + def test_finding_with_reverted_assoc_order + topics = Topic.order(:id => :asc).reverse_order + assert_equal 5, topics.to_a.size + assert_equal topics(:fifth).title, topics.first.title + end + + def test_order_with_hash_and_symbol_generates_the_same_sql + assert_equal Topic.order(:id).to_sql, Topic.order(:id => :asc).to_sql + end + + def test_finding_with_desc_order_with_string + topics = Topic.order(id: "desc") + assert_equal 5, topics.to_a.size + assert_equal [topics(:fifth), topics(:fourth), topics(:third), topics(:second), topics(:first)], topics.to_a + end + + def test_finding_with_asc_order_with_string + topics = Topic.order(id: 'asc') + assert_equal 5, topics.to_a.size + assert_equal [topics(:first), topics(:second), topics(:third), topics(:fourth), topics(:fifth)], topics.to_a + end + + def test_support_upper_and_lower_case_directions + assert_includes Topic.order(id: "ASC").to_sql, "ASC" + assert_includes Topic.order(id: "asc").to_sql, "ASC" + assert_includes Topic.order(id: :ASC).to_sql, "ASC" + assert_includes Topic.order(id: :asc).to_sql, "ASC" + + assert_includes Topic.order(id: "DESC").to_sql, "DESC" + assert_includes Topic.order(id: "desc").to_sql, "DESC" + assert_includes Topic.order(id: :DESC).to_sql, "DESC" + assert_includes Topic.order(id: :desc).to_sql,"DESC" + end + + def test_raising_exception_on_invalid_hash_params + e = assert_raise(ArgumentError) { Topic.order(:name, "id DESC", id: :asfsdf) } + assert_equal 'Direction "asfsdf" is invalid. Valid directions are: [:asc, :desc, :ASC, :DESC, "asc", "desc", "ASC", "DESC"]', e.message + end + + def test_finding_last_with_arel_order + topics = Topic.order(Topic.arel_table[:id].asc) + assert_equal topics(:fifth).title, topics.last.title + end + + def test_finding_with_order_concatenated + topics = Topic.order('author_name').order('title') + assert_equal 5, topics.to_a.size + assert_equal topics(:fourth).title, topics.first.title + end + + def test_finding_with_order_by_aliased_attributes + topics = Topic.order(:heading) + assert_equal 5, topics.to_a.size + assert_equal topics(:fifth).title, topics.first.title + end + + def test_finding_with_assoc_order_by_aliased_attributes + topics = Topic.order(heading: :desc) + assert_equal 5, topics.to_a.size + assert_equal topics(:third).title, topics.first.title + end + + def test_finding_with_reorder + topics = Topic.order('author_name').order('title').reorder('id').to_a + topics_titles = topics.map{ |t| t.title } + assert_equal ['The First Topic', 'The Second Topic of the day', 'The Third Topic of the day', 'The Fourth Topic of the day', 'The Fifth Topic of the day'], topics_titles + end + + def test_finding_with_reorder_by_aliased_attributes + topics = Topic.order('author_name').reorder(:heading) + assert_equal 5, topics.to_a.size + assert_equal topics(:fifth).title, topics.first.title + end + + def test_finding_with_assoc_reorder_by_aliased_attributes + topics = Topic.order('author_name').reorder(heading: :desc) + assert_equal 5, topics.to_a.size + assert_equal topics(:third).title, topics.first.title + end + + def test_finding_with_order_and_take + entrants = Entrant.order("id ASC").limit(2).to_a + + assert_equal 2, entrants.size + assert_equal entrants(:first).name, entrants.first.name + end + + def test_finding_with_cross_table_order_and_limit + tags = Tag.includes(:taggings). + order("tags.name asc", "taggings.taggable_id asc", "REPLACE('abc', taggings.taggable_type, taggings.taggable_type)"). + limit(1).to_a + assert_equal 1, tags.length + end + + def test_finding_with_complex_order_and_limit + tags = Tag.includes(:taggings).references(:taggings).order("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)").limit(1).to_a + assert_equal 1, tags.length + end + + def test_finding_with_complex_order + tags = Tag.includes(:taggings).references(:taggings).order("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)").to_a + assert_equal 3, tags.length + end + + def test_finding_with_order_limit_and_offset + entrants = Entrant.order("id ASC").limit(2).offset(1) + + assert_equal 2, entrants.to_a.size + assert_equal entrants(:second).name, entrants.first.name + + entrants = Entrant.order("id ASC").limit(2).offset(2) + assert_equal 1, entrants.to_a.size + assert_equal entrants(:third).name, entrants.first.name + end + + def test_finding_with_group + developers = Developer.group("salary").select("salary").to_a + assert_equal 4, developers.size + assert_equal 4, developers.map(&:salary).uniq.size + end + + def test_select_with_block + even_ids = Developer.all.select {|d| d.id % 2 == 0 }.map(&:id) + assert_equal [2, 4, 6, 8, 10], even_ids.sort + end + + def test_none + assert_no_queries do + assert_equal [], Developer.none + assert_equal [], Developer.all.none + end + end + + def test_none_chainable + assert_no_queries do + assert_equal [], Developer.none.where(:name => 'David') + end + end + + def test_none_chainable_to_existing_scope_extension_method + assert_no_queries do + assert_equal 1, Topic.anonymous_extension.none.one + end + end + + def test_none_chained_to_methods_firing_queries_straight_to_db + assert_no_queries do + assert_equal [], Developer.none.pluck(:id, :name) + assert_equal 0, Developer.none.delete_all + assert_equal 0, Developer.none.update_all(:name => 'David') + assert_equal 0, Developer.none.delete(1) + assert_equal false, Developer.none.exists?(1) + end + end + + def test_null_relation_content_size_methods + assert_no_queries do + assert_equal 0, Developer.none.size + assert_equal 0, Developer.none.count + assert_equal true, Developer.none.empty? + assert_equal false, Developer.none.any? + assert_equal false, Developer.none.many? + end + end + + def test_null_relation_calculations_methods + assert_no_queries do + assert_equal 0, Developer.none.count + assert_equal 0, Developer.none.calculate(:count, nil, {}) + assert_equal nil, Developer.none.calculate(:average, 'salary') + end + end + + def test_null_relation_metadata_methods + assert_equal "", Developer.none.to_sql + assert_equal({}, Developer.none.where_values_hash) + end + + def test_null_relation_where_values_hash + assert_equal({ 'salary' => 100_000 }, Developer.none.where(salary: 100_000).where_values_hash) + end + + def test_null_relation_sum + ac = Aircraft.new + assert_equal Hash.new, ac.engines.group(:id).sum(:id) + assert_equal 0, ac.engines.count + ac.save + assert_equal Hash.new, ac.engines.group(:id).sum(:id) + assert_equal 0, ac.engines.count + end + + def test_null_relation_count + ac = Aircraft.new + assert_equal Hash.new, ac.engines.group(:id).count + assert_equal 0, ac.engines.count + ac.save + assert_equal Hash.new, ac.engines.group(:id).count + assert_equal 0, ac.engines.count + end + + def test_null_relation_size + ac = Aircraft.new + assert_equal Hash.new, ac.engines.group(:id).size + assert_equal 0, ac.engines.size + ac.save + assert_equal Hash.new, ac.engines.group(:id).size + assert_equal 0, ac.engines.size + end + + def test_null_relation_average + ac = Aircraft.new + assert_equal Hash.new, ac.engines.group(:car_id).average(:id) + assert_equal nil, ac.engines.average(:id) + ac.save + assert_equal Hash.new, ac.engines.group(:car_id).average(:id) + assert_equal nil, ac.engines.average(:id) + end + + def test_null_relation_minimum + ac = Aircraft.new + assert_equal Hash.new, ac.engines.group(:car_id).minimum(:id) + assert_equal nil, ac.engines.minimum(:id) + ac.save + assert_equal Hash.new, ac.engines.group(:car_id).minimum(:id) + assert_equal nil, ac.engines.minimum(:id) + end + + def test_null_relation_maximum + ac = Aircraft.new + assert_equal Hash.new, ac.engines.group(:car_id).maximum(:id) + assert_equal nil, ac.engines.maximum(:id) + ac.save + assert_equal Hash.new, ac.engines.group(:car_id).maximum(:id) + assert_equal nil, ac.engines.maximum(:id) + end + + def test_joins_with_nil_argument + assert_nothing_raised { DependentFirm.joins(nil).first } + end + + def test_finding_with_hash_conditions_on_joined_table + firms = DependentFirm.joins(:account).where({:name => 'RailsCore', :accounts => { :credit_limit => 55..60 }}).to_a + assert_equal 1, firms.size + assert_equal companies(:rails_core), firms.first + end + + def test_find_all_with_join + developers_on_project_one = Developer.joins('LEFT JOIN developers_projects ON developers.id = developers_projects.developer_id'). + where('project_id=1').to_a + + assert_equal 3, developers_on_project_one.length + developer_names = developers_on_project_one.map { |d| d.name } + assert developer_names.include?('David') + assert developer_names.include?('Jamis') + end + + def test_find_on_hash_conditions + assert_equal Topic.all.merge!(:where => {:approved => false}).to_a, Topic.where({ :approved => false }).to_a + end + + def test_joins_with_string_array + person_with_reader_and_post = Post.joins([ + "INNER JOIN categorizations ON categorizations.post_id = posts.id", + "INNER JOIN categories ON categories.id = categorizations.category_id AND categories.type = 'SpecialCategory'" + ] + ).to_a + assert_equal 1, person_with_reader_and_post.size + end + + def test_no_arguments_to_query_methods_raise_errors + assert_raises(ArgumentError) { Topic.references() } + assert_raises(ArgumentError) { Topic.includes() } + assert_raises(ArgumentError) { Topic.preload() } + assert_raises(ArgumentError) { Topic.group() } + assert_raises(ArgumentError) { Topic.reorder() } + end + + def test_blank_like_arguments_to_query_methods_dont_raise_errors + assert_nothing_raised { Topic.references([]) } + assert_nothing_raised { Topic.includes([]) } + assert_nothing_raised { Topic.preload([]) } + assert_nothing_raised { Topic.group([]) } + assert_nothing_raised { Topic.reorder([]) } + end + + def test_scoped_responds_to_delegated_methods + relation = Topic.all + + ["map", "uniq", "sort", "insert", "delete", "update"].each do |method| + assert_respond_to relation, method, "Topic.all should respond to #{method.inspect}" + end + end + + def test_respond_to_delegates_to_relation + relation = Topic.all + fake_arel = Struct.new(:responds) { + def respond_to? method, access = false + responds << [method, access] + end + }.new [] + + relation.extend(Module.new { attr_accessor :arel }) + relation.arel = fake_arel + + relation.respond_to?(:matching_attributes) + assert_equal [:matching_attributes, false], fake_arel.responds.first + + fake_arel.responds = [] + relation.respond_to?(:matching_attributes, true) + assert_equal [:matching_attributes, true], fake_arel.responds.first + end + + def test_respond_to_dynamic_finders + relation = Topic.all + + ["find_by_title", "find_by_title_and_author_name"].each do |method| + assert_respond_to relation, method, "Topic.all should respond to #{method.inspect}" + end + end + + def test_respond_to_class_methods_and_scopes + assert Topic.all.respond_to?(:by_lifo) + end + + def test_find_with_readonly_option + Developer.all.each { |d| assert !d.readonly? } + Developer.all.readonly.each { |d| assert d.readonly? } + end + + def test_eager_association_loading_of_stis_with_multiple_references + authors = Author.eager_load(:posts => { :special_comments => { :post => [ :special_comments, :very_special_comment ] } }). + order('comments.body, very_special_comments_posts.body').where('posts.id = 4').to_a + + assert_equal [authors(:david)], authors + assert_no_queries do + authors.first.posts.first.special_comments.first.post.special_comments + authors.first.posts.first.special_comments.first.post.very_special_comment + end + end + + def test_find_with_preloaded_associations + assert_queries(2) do + posts = Post.preload(:comments).order('posts.id') + assert posts.first.comments.first + end + + assert_queries(2) do + posts = Post.preload(:comments).order('posts.id') + assert posts.first.comments.first + end + + assert_queries(2) do + posts = Post.preload(:author).order('posts.id') + assert posts.first.author + end + + assert_queries(2) do + posts = Post.preload(:author).order('posts.id') + assert posts.first.author + end + + assert_queries(3) do + posts = Post.preload(:author, :comments).order('posts.id') + assert posts.first.author + assert posts.first.comments.first + end + end + + def test_preload_applies_to_all_chained_preloaded_scopes + assert_queries(3) do + post = Post.with_comments.with_tags.first + assert post + end + end + + def test_find_with_included_associations + assert_queries(2) do + posts = Post.includes(:comments).order('posts.id') + assert posts.first.comments.first + end + + assert_queries(2) do + posts = Post.all.includes(:comments).order('posts.id') + assert posts.first.comments.first + end + + assert_queries(2) do + posts = Post.includes(:author).order('posts.id') + assert posts.first.author + end + + assert_queries(3) do + posts = Post.includes(:author, :comments).order('posts.id') + assert posts.first.author + assert posts.first.comments.first + end + end + + def test_default_scope_with_conditions_string + assert_equal Developer.where(name: 'David').map(&:id).sort, DeveloperCalledDavid.all.map(&:id).sort + assert_nil DeveloperCalledDavid.create!.name + end + + def test_default_scope_with_conditions_hash + assert_equal Developer.where(name: 'Jamis').map(&:id).sort, DeveloperCalledJamis.all.map(&:id).sort + assert_equal 'Jamis', DeveloperCalledJamis.create!.name + end + + def test_default_scoping_finder_methods + developers = DeveloperCalledDavid.order('id').map(&:id).sort + assert_equal Developer.where(name: 'David').map(&:id).sort, developers + end + + def test_includes_with_select + query = Post.select('comments_count AS ranking').order('ranking').includes(:comments) + .where(comments: { id: 1 }) + + assert_equal ['comments_count AS ranking'], query.select_values + assert_equal 1, query.to_a.size + end + + def test_loading_with_one_association + posts = Post.preload(:comments) + post = posts.find { |p| p.id == 1 } + assert_equal 2, post.comments.size + assert post.comments.include?(comments(:greetings)) + + post = Post.where("posts.title = 'Welcome to the weblog'").preload(:comments).first + assert_equal 2, post.comments.size + assert post.comments.include?(comments(:greetings)) + + posts = Post.preload(:last_comment) + post = posts.find { |p| p.id == 1 } + assert_equal Post.find(1).last_comment, post.last_comment + end + + def test_to_sql_on_eager_join + expected = assert_sql { + Post.eager_load(:last_comment).order('comments.id DESC').to_a + }.first + actual = Post.eager_load(:last_comment).order('comments.id DESC').to_sql + assert_equal expected, actual + end + + def test_to_sql_on_scoped_proxy + auth = Author.first + Post.where("1=1").written_by(auth) + assert_not auth.posts.to_sql.include?("1=1") + end + + def test_loading_with_one_association_with_non_preload + posts = Post.eager_load(: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_dynamic_find_by_attributes + david = authors(:david) + author = Author.preload(:taggings).find_by_id(david.id) + expected_taggings = taggings(:welcome_general, :thinking_general) + + assert_no_queries do + assert_equal expected_taggings, author.taggings.distinct.sort_by { |t| t.id } + assert_equal expected_taggings, author.taggings.uniq.sort_by { |t| t.id } + end + + authors = Author.all + assert_equal david, authors.find_by_id_and_name(david.id, david.name) + assert_equal david, authors.find_by_id_and_name!(david.id, david.name) + end + + def test_dynamic_find_by_attributes_bang + author = Author.all.find_by_id!(authors(:david).id) + assert_equal "David", author.name + + assert_raises(ActiveRecord::RecordNotFound) { Author.all.find_by_id_and_name!(20, 'invalid') } + end + + def test_find_id + authors = Author.all + + david = authors.find(authors(:david).id) + assert_equal 'David', david.name + + assert_raises(ActiveRecord::RecordNotFound) { authors.where(:name => 'lifo').find('42') } + end + + def test_find_ids + authors = Author.order('id ASC') + + results = authors.find(authors(:david).id, authors(:mary).id) + assert_kind_of Array, results + assert_equal 2, results.size + assert_equal 'David', results[0].name + assert_equal 'Mary', results[1].name + assert_equal results, authors.find([authors(:david).id, authors(:mary).id]) + + assert_raises(ActiveRecord::RecordNotFound) { authors.where(:name => 'lifo').find(authors(:david).id, '42') } + assert_raises(ActiveRecord::RecordNotFound) { authors.find(['42', 43]) } + end + + def test_find_in_empty_array + authors = Author.all.where(:id => []) + assert authors.to_a.blank? + end + + def test_where_with_ar_object + author = Author.first + authors = Author.all.where(:id => author) + assert_equal 1, authors.to_a.length + end + + def test_find_with_list_of_ar + author = Author.first + authors = Author.find([author.id]) + assert_equal author, authors.first + end + + class Mary < Author; end + + def test_find_by_classname + Author.create!(:name => Mary.name) + assert_equal 1, Author.where(:name => Mary).size + end + + def test_find_by_id_with_list_of_ar + author = Author.first + authors = Author.find_by_id([author]) + assert_equal author, authors + end + + def test_find_all_using_where_twice_should_or_the_relation + david = authors(:david) + relation = Author.unscoped + relation = relation.where(:name => david.name) + relation = relation.where(:name => 'Santiago') + relation = relation.where(:id => david.id) + assert_equal [], relation.to_a + end + + def test_multi_where_ands_queries + relation = Author.unscoped + david = authors(:david) + sql = relation.where(:name => david.name).where(:name => 'Santiago').to_sql + assert_match('AND', sql) + end + + def test_find_all_with_multiple_should_use_and + david = authors(:david) + relation = [ + { :name => david.name }, + { :name => 'Santiago' }, + { :name => 'tenderlove' }, + ].inject(Author.unscoped) do |memo, param| + memo.where(param) + end + assert_equal [], relation.to_a + end + + def test_typecasting_where_with_array + ids = Author.pluck(:id) + slugs = ids.map { |id| "#{id}-as-a-slug" } + + assert_equal Author.all.to_a, Author.where(id: slugs).to_a + end + + def test_find_all_using_where_with_relation + david = authors(:david) + # switching the lines below would succeed in current rails + # assert_queries(2) { + assert_queries(1) { + relation = Author.where(:id => Author.where(:id => david.id)) + assert_equal [david], relation.to_a + } + + assert_queries(1) { + relation = Author.where('id in (?)', Author.where(id: david).select(:id)) + assert_equal [david], relation.to_a + } + + assert_queries(1) do + relation = Author.where('id in (:author_ids)', author_ids: Author.where(id: david).select(:id)) + assert_equal [david], relation.to_a + end + end + + def test_find_all_using_where_with_relation_with_bound_values + david = authors(:david) + davids_posts = david.posts.order(:id).to_a + + assert_queries(1) do + relation = Post.where(id: david.posts.select(:id)) + assert_equal davids_posts, relation.order(:id).to_a + end + + assert_queries(1) do + relation = Post.where('id in (?)', david.posts.select(:id)) + assert_equal davids_posts, relation.order(:id).to_a, 'should process Relation as bind variables' + end + + assert_queries(1) do + relation = Post.where('id in (:post_ids)', post_ids: david.posts.select(:id)) + assert_equal davids_posts, relation.order(:id).to_a, 'should process Relation as named bind variables' + end + end + + def test_find_all_using_where_with_relation_and_alternate_primary_key + cool_first = minivans(:cool_first) + # switching the lines below would succeed in current rails + # assert_queries(2) { + assert_queries(1) { + relation = Minivan.where(:minivan_id => Minivan.where(:name => cool_first.name)) + assert_equal [cool_first], relation.to_a + } + end + + def test_find_all_using_where_with_relation_does_not_alter_select_values + david = authors(:david) + + subquery = Author.where(:id => david.id) + + assert_queries(1) { + relation = Author.where(:id => subquery) + assert_equal [david], relation.to_a + } + + assert_equal 0, subquery.select_values.size + end + + def test_find_all_using_where_with_relation_with_joins + david = authors(:david) + assert_queries(1) { + relation = Author.where(:id => Author.joins(:posts).where(:id => david.id)) + assert_equal [david], relation.to_a + } + end + + + def test_find_all_using_where_with_relation_with_select_to_build_subquery + david = authors(:david) + assert_queries(1) { + relation = Author.where(:name => Author.where(:id => david.id).select(:name)) + assert_equal [david], relation.to_a + } + end + + def test_exists + davids = Author.where(:name => 'David') + assert davids.exists? + assert davids.exists?(authors(:david).id) + assert ! davids.exists?(authors(:mary).id) + assert ! davids.exists?("42") + assert ! davids.exists?(42) + assert ! davids.exists?(davids.new.id) + + fake = Author.where(:name => 'fake author') + assert ! fake.exists? + assert ! fake.exists?(authors(:david).id) + end + + def test_last + authors = Author.all + assert_equal authors(:bob), authors.last + end + + def test_destroy_all + davids = Author.where(:name => 'David') + + # Force load + assert_equal [authors(:david)], davids.to_a + assert davids.loaded? + + assert_difference('Author.count', -1) { davids.destroy_all } + + assert_equal [], davids.to_a + assert davids.loaded? + end + + def test_delete_all + davids = Author.where(:name => 'David') + + assert_difference('Author.count', -1) { davids.delete_all } + assert ! davids.loaded? + end + + def test_delete_all_loaded + davids = Author.where(:name => 'David') + + # Force load + assert_equal [authors(:david)], davids.to_a + assert davids.loaded? + + assert_difference('Author.count', -1) { davids.delete_all } + + assert_equal [], davids.to_a + assert davids.loaded? + end + + def test_delete_all_with_unpermitted_relation_raises_error + assert_raises(ActiveRecord::ActiveRecordError) { Author.limit(10).delete_all } + assert_raises(ActiveRecord::ActiveRecordError) { Author.uniq.delete_all } + assert_raises(ActiveRecord::ActiveRecordError) { Author.group(:name).delete_all } + assert_raises(ActiveRecord::ActiveRecordError) { Author.having('SUM(id) < 3').delete_all } + assert_raises(ActiveRecord::ActiveRecordError) { Author.offset(10).delete_all } + end + + def test_select_with_aggregates + posts = Post.select(:title, :body) + + assert_equal 11, posts.count(:all) + assert_equal 11, posts.size + assert posts.any? + assert posts.many? + assert_not posts.empty? + end + + def test_select_takes_a_variable_list_of_args + david = developers(:david) + + developer = Developer.where(id: david.id).select(:name, :salary).first + assert_equal david.name, developer.name + assert_equal david.salary, developer.salary + end + + def test_select_takes_an_aliased_attribute + first = topics(:first) + + topic = Topic.where(id: first.id).select(:heading).first + assert_equal first.heading, topic.heading + end + + def test_select_argument_error + assert_raises(ArgumentError) { Developer.select } + end + + def test_count + posts = Post.all + + assert_equal 11, posts.count + assert_equal 11, posts.count(:all) + assert_equal 11, posts.count(:id) + + assert_equal 1, posts.where('comments_count > 1').count + assert_equal 9, posts.where(:comments_count => 0).count + end + + def test_count_on_association_relation + author = Author.last + another_author = Author.first + posts = Post.where(author_id: author.id) + + assert_equal author.posts.where(author_id: author.id).size, posts.count + + assert_equal 0, author.posts.where(author_id: another_author.id).size + assert author.posts.where(author_id: another_author.id).empty? + end + + def test_count_with_distinct + posts = Post.all + + assert_equal 3, posts.distinct(true).count(:comments_count) + assert_equal 11, posts.distinct(false).count(:comments_count) + + assert_equal 3, posts.distinct(true).select(:comments_count).count + assert_equal 11, posts.distinct(false).select(:comments_count).count + end + + def test_update_all_with_scope + tag = Tag.first + Post.tagged_with(tag.id).update_all title: "rofl" + list = Post.tagged_with(tag.id).all.to_a + assert_operator list.length, :>, 0 + list.each { |post| assert_equal 'rofl', post.title } + end + + def test_count_explicit_columns + Post.update_all(:comments_count => nil) + posts = Post.all + + assert_equal [0], posts.select('comments_count').where('id is not null').group('id').order('id').count.values.uniq + assert_equal 0, posts.where('id is not null').select('comments_count').count + + assert_equal 11, posts.select('comments_count').count('id') + assert_equal 0, posts.select('comments_count').count + assert_equal 0, posts.count(:comments_count) + assert_equal 0, posts.count('comments_count') + end + + def test_multiple_selects + post = Post.all.select('comments_count').select('title').order("id ASC").first + assert_equal "Welcome to the weblog", post.title + assert_equal 2, post.comments_count + end + + def test_size + posts = Post.all + + assert_queries(1) { assert_equal 11, posts.size } + assert ! posts.loaded? + + best_posts = posts.where(:comments_count => 0) + best_posts.to_a # force load + assert_no_queries { assert_equal 9, best_posts.size } + end + + def test_size_with_limit + posts = Post.limit(10) + + assert_queries(1) { assert_equal 10, posts.size } + assert ! posts.loaded? + + best_posts = posts.where(:comments_count => 0) + best_posts.to_a # force load + assert_no_queries { assert_equal 9, best_posts.size } + end + + def test_size_with_zero_limit + posts = Post.limit(0) + + assert_no_queries { assert_equal 0, posts.size } + assert ! posts.loaded? + + posts.to_a # force load + assert_no_queries { assert_equal 0, posts.size } + end + + def test_empty_with_zero_limit + posts = Post.limit(0) + + assert_no_queries { assert_equal true, posts.empty? } + assert ! posts.loaded? + end + + def test_count_complex_chained_relations + posts = Post.select('comments_count').where('id is not null').group("author_id").where("comments_count > 0") + + expected = { 1 => 2 } + assert_equal expected, posts.count + end + + def test_empty + posts = Post.all + + assert_queries(1) { assert_equal false, posts.empty? } + assert ! posts.loaded? + + no_posts = posts.where(:title => "") + assert_queries(1) { assert_equal true, no_posts.empty? } + assert ! no_posts.loaded? + + best_posts = posts.where(:comments_count => 0) + best_posts.to_a # force load + assert_no_queries { assert_equal false, best_posts.empty? } + end + + def test_empty_complex_chained_relations + posts = Post.select("comments_count").where("id is not null").group("author_id").where("comments_count > 0") + + assert_queries(1) { assert_equal false, posts.empty? } + assert ! posts.loaded? + + no_posts = posts.where(:title => "") + assert_queries(1) { assert_equal true, no_posts.empty? } + assert ! no_posts.loaded? + end + + def test_any + posts = Post.all + + # This test was failing when run on its own (as opposed to running the entire suite). + # The second line in the assert_queries block was causing visit_Arel_Attributes_Attribute + # in Arel::Visitors::ToSql to trigger a SHOW TABLES query. Running that line here causes + # the SHOW TABLES result to be cached so we don't have to do it again in the block. + # + # This is obviously a rubbish fix but it's the best I can come up with for now... + posts.where(:id => nil).any? + + assert_queries(3) do + assert posts.any? # Uses COUNT() + assert ! posts.where(:id => nil).any? + + assert posts.any? {|p| p.id > 0 } + assert ! posts.any? {|p| p.id <= 0 } + end + + assert posts.loaded? + end + + def test_many + posts = Post.all + + assert_queries(2) do + assert posts.many? # Uses COUNT() + assert posts.many? {|p| p.id > 0 } + assert ! posts.many? {|p| p.id < 2 } + end + + assert posts.loaded? + end + + def test_many_with_limits + posts = Post.all + + assert posts.many? + assert ! posts.limit(1).many? + end + + def test_build + posts = Post.all + + post = posts.new + assert_kind_of Post, post + end + + def test_scoped_build + posts = Post.where(:title => 'You told a lie') + + post = posts.new + assert_kind_of Post, post + assert_equal 'You told a lie', post.title + end + + def test_create + birds = Bird.all + + sparrow = birds.create + assert_kind_of Bird, sparrow + assert !sparrow.persisted? + + hen = birds.where(:name => 'hen').create + assert hen.persisted? + assert_equal 'hen', hen.name + end + + def test_create_bang + birds = Bird.all + + assert_raises(ActiveRecord::RecordInvalid) { birds.create! } + + hen = birds.where(:name => 'hen').create! + assert_kind_of Bird, hen + assert hen.persisted? + assert_equal 'hen', hen.name + end + + def test_first_or_create + parrot = Bird.where(:color => 'green').first_or_create(:name => 'parrot') + assert_kind_of Bird, parrot + assert parrot.persisted? + assert_equal 'parrot', parrot.name + assert_equal 'green', parrot.color + + same_parrot = Bird.where(:color => 'green').first_or_create(:name => 'parakeet') + assert_kind_of Bird, same_parrot + assert same_parrot.persisted? + assert_equal parrot, same_parrot + end + + def test_first_or_create_with_no_parameters + parrot = Bird.where(:color => 'green').first_or_create + assert_kind_of Bird, parrot + assert !parrot.persisted? + assert_equal 'green', parrot.color + end + + def test_first_or_create_with_block + parrot = Bird.where(:color => 'green').first_or_create { |bird| bird.name = 'parrot' } + assert_kind_of Bird, parrot + assert parrot.persisted? + assert_equal 'green', parrot.color + assert_equal 'parrot', parrot.name + + same_parrot = Bird.where(:color => 'green').first_or_create { |bird| bird.name = 'parakeet' } + assert_equal parrot, same_parrot + end + + def test_first_or_create_with_array + several_green_birds = Bird.where(:color => 'green').first_or_create([{:name => 'parrot'}, {:name => 'parakeet'}]) + assert_kind_of Array, several_green_birds + several_green_birds.each { |bird| assert bird.persisted? } + + same_parrot = Bird.where(:color => 'green').first_or_create([{:name => 'hummingbird'}, {:name => 'macaw'}]) + assert_kind_of Bird, same_parrot + assert_equal several_green_birds.first, same_parrot + end + + def test_first_or_create_bang_with_valid_options + parrot = Bird.where(:color => 'green').first_or_create!(:name => 'parrot') + assert_kind_of Bird, parrot + assert parrot.persisted? + assert_equal 'parrot', parrot.name + assert_equal 'green', parrot.color + + same_parrot = Bird.where(:color => 'green').first_or_create!(:name => 'parakeet') + assert_kind_of Bird, same_parrot + assert same_parrot.persisted? + assert_equal parrot, same_parrot + end + + def test_first_or_create_bang_with_invalid_options + assert_raises(ActiveRecord::RecordInvalid) { Bird.where(:color => 'green').first_or_create!(:pirate_id => 1) } + end + + def test_first_or_create_bang_with_no_parameters + assert_raises(ActiveRecord::RecordInvalid) { Bird.where(:color => 'green').first_or_create! } + end + + def test_first_or_create_bang_with_valid_block + parrot = Bird.where(:color => 'green').first_or_create! { |bird| bird.name = 'parrot' } + assert_kind_of Bird, parrot + assert parrot.persisted? + assert_equal 'green', parrot.color + assert_equal 'parrot', parrot.name + + same_parrot = Bird.where(:color => 'green').first_or_create! { |bird| bird.name = 'parakeet' } + assert_equal parrot, same_parrot + end + + def test_first_or_create_bang_with_invalid_block + assert_raise(ActiveRecord::RecordInvalid) do + Bird.where(:color => 'green').first_or_create! { |bird| bird.pirate_id = 1 } + end + end + + def test_first_or_create_with_valid_array + several_green_birds = Bird.where(:color => 'green').first_or_create!([{:name => 'parrot'}, {:name => 'parakeet'}]) + assert_kind_of Array, several_green_birds + several_green_birds.each { |bird| assert bird.persisted? } + + same_parrot = Bird.where(:color => 'green').first_or_create!([{:name => 'hummingbird'}, {:name => 'macaw'}]) + assert_kind_of Bird, same_parrot + assert_equal several_green_birds.first, same_parrot + end + + def test_first_or_create_with_invalid_array + assert_raises(ActiveRecord::RecordInvalid) { Bird.where(:color => 'green').first_or_create!([ {:name => 'parrot'}, {:pirate_id => 1} ]) } + end + + def test_first_or_initialize + parrot = Bird.where(:color => 'green').first_or_initialize(:name => 'parrot') + assert_kind_of Bird, parrot + assert !parrot.persisted? + assert parrot.valid? + assert parrot.new_record? + assert_equal 'parrot', parrot.name + assert_equal 'green', parrot.color + end + + def test_first_or_initialize_with_no_parameters + parrot = Bird.where(:color => 'green').first_or_initialize + assert_kind_of Bird, parrot + assert !parrot.persisted? + assert !parrot.valid? + assert parrot.new_record? + assert_equal 'green', parrot.color + end + + def test_first_or_initialize_with_block + parrot = Bird.where(:color => 'green').first_or_initialize { |bird| bird.name = 'parrot' } + assert_kind_of Bird, parrot + assert !parrot.persisted? + assert parrot.valid? + assert parrot.new_record? + assert_equal 'green', parrot.color + assert_equal 'parrot', parrot.name + end + + def test_find_or_create_by + assert_nil Bird.find_by(name: 'bob') + + bird = Bird.find_or_create_by(name: 'bob') + assert bird.persisted? + + assert_equal bird, Bird.find_or_create_by(name: 'bob') + end + + def test_find_or_create_by_with_create_with + assert_nil Bird.find_by(name: 'bob') + + bird = Bird.create_with(color: 'green').find_or_create_by(name: 'bob') + assert bird.persisted? + assert_equal 'green', bird.color + + assert_equal bird, Bird.create_with(color: 'blue').find_or_create_by(name: 'bob') + end + + def test_find_or_create_by! + assert_raises(ActiveRecord::RecordInvalid) { Bird.find_or_create_by!(color: 'green') } + end + + def test_find_or_initialize_by + assert_nil Bird.find_by(name: 'bob') + + bird = Bird.find_or_initialize_by(name: 'bob') + assert bird.new_record? + bird.save! + + assert_equal bird, Bird.find_or_initialize_by(name: 'bob') + end + + def test_explicit_create_scope + hens = Bird.where(:name => 'hen') + assert_equal 'hen', hens.new.name + + hens = hens.create_with(:name => 'cock') + assert_equal 'cock', hens.new.name + end + + def test_except + relation = Post.where(:author_id => 1).order('id ASC').limit(1) + assert_equal [posts(:welcome)], relation.to_a + + author_posts = relation.except(:order, :limit) + assert_equal Post.where(:author_id => 1).to_a, author_posts.to_a + + all_posts = relation.except(:where, :order, :limit) + assert_equal Post.all, all_posts + end + + def test_only + relation = Post.where(:author_id => 1).order('id ASC').limit(1) + assert_equal [posts(:welcome)], relation.to_a + + author_posts = relation.only(:where) + assert_equal Post.where(:author_id => 1).to_a, author_posts.to_a + + all_posts = relation.only(:limit) + assert_equal Post.limit(1).to_a.first, all_posts.first + end + + def test_anonymous_extension + relation = Post.where(:author_id => 1).order('id ASC').extending do + def author + 'lifo' + end + end + + assert_equal "lifo", relation.author + assert_equal "lifo", relation.limit(1).author + end + + def test_named_extension + relation = Post.where(:author_id => 1).order('id ASC').extending(Post::NamedExtension) + assert_equal "lifo", relation.author + assert_equal "lifo", relation.limit(1).author + end + + def test_order_by_relation_attribute + assert_equal Post.order(Post.arel_table[:title]).to_a, Post.order("title").to_a + end + + def test_default_scope_order_with_scope_order + assert_equal 'zyke', CoolCar.order_using_new_style.limit(1).first.name + assert_equal 'zyke', FastCar.order_using_new_style.limit(1).first.name + end + + def test_order_using_scoping + car1 = CoolCar.order('id DESC').scoping do + CoolCar.all.merge!(order: 'id asc').first + end + assert_equal 'zyke', car1.name + + car2 = FastCar.order('id DESC').scoping do + FastCar.all.merge!(order: 'id asc').first + end + assert_equal 'zyke', car2.name + end + + def test_unscoped_block_style + assert_equal 'honda', CoolCar.unscoped { CoolCar.order_using_new_style.limit(1).first.name} + assert_equal 'honda', FastCar.unscoped { FastCar.order_using_new_style.limit(1).first.name} + end + + def test_intersection_with_array + relation = Author.where(:name => "David") + rails_author = relation.first + + assert_equal [rails_author], [rails_author] & relation + assert_equal [rails_author], relation & [rails_author] + end + + def test_primary_key + assert_equal "id", Post.all.primary_key + end + + def test_disable_implicit_join_references_is_deprecated + assert_deprecated do + ActiveRecord::Base.disable_implicit_join_references = true + end + end + + def test_ordering_with_extra_spaces + assert_equal authors(:david), Author.order('id DESC , name DESC').last + end + + def test_update_all_with_blank_argument + assert_raises(ArgumentError) { Comment.update_all({}) } + end + + def test_update_all_with_joins + comments = Comment.joins(:post).where('posts.id' => posts(:welcome).id) + count = comments.count + + assert_equal count, comments.update_all(:post_id => posts(:thinking).id) + assert_equal posts(:thinking), comments(:greetings).post + end + + def test_update_all_with_joins_and_limit + comments = Comment.joins(:post).where('posts.id' => posts(:welcome).id).limit(1) + assert_equal 1, comments.update_all(:post_id => posts(:thinking).id) + end + + def test_update_all_with_joins_and_limit_and_order + comments = Comment.joins(:post).where('posts.id' => posts(:welcome).id).order('comments.id').limit(1) + assert_equal 1, comments.update_all(:post_id => posts(:thinking).id) + assert_equal posts(:thinking), comments(:greetings).post + assert_equal posts(:welcome), comments(:more_greetings).post + end + + def test_update_all_with_joins_and_offset + all_comments = Comment.joins(:post).where('posts.id' => posts(:welcome).id) + count = all_comments.count + comments = all_comments.offset(1) + + assert_equal count - 1, comments.update_all(:post_id => posts(:thinking).id) + end + + def test_update_all_with_joins_and_offset_and_order + all_comments = Comment.joins(:post).where('posts.id' => posts(:welcome).id).order('posts.id', 'comments.id') + count = all_comments.count + comments = all_comments.offset(1) + + assert_equal count - 1, comments.update_all(:post_id => posts(:thinking).id) + assert_equal posts(:thinking), comments(:more_greetings).post + assert_equal posts(:welcome), comments(:greetings).post + end + + def test_distinct + tag1 = Tag.create(:name => 'Foo') + tag2 = Tag.create(:name => 'Foo') + + query = Tag.select(:name).where(:id => [tag1.id, tag2.id]) + + assert_equal ['Foo', 'Foo'], query.map(&:name) + assert_sql(/DISTINCT/) do + assert_equal ['Foo'], query.distinct.map(&:name) + assert_equal ['Foo'], query.uniq.map(&:name) + end + assert_sql(/DISTINCT/) do + assert_equal ['Foo'], query.distinct(true).map(&:name) + assert_equal ['Foo'], query.uniq(true).map(&:name) + end + assert_equal ['Foo', 'Foo'], query.distinct(true).distinct(false).map(&:name) + assert_equal ['Foo', 'Foo'], query.uniq(true).uniq(false).map(&:name) + end + + def test_doesnt_add_having_values_if_options_are_blank + scope = Post.having('') + assert_equal [], scope.having_values + + scope = Post.having([]) + assert_equal [], scope.having_values + end + + def test_references_triggers_eager_loading + scope = Post.includes(:comments) + assert !scope.eager_loading? + assert scope.references(:comments).eager_loading? + end + + def test_references_doesnt_trigger_eager_loading_if_reference_not_included + scope = Post.references(:comments) + assert !scope.eager_loading? + end + + def test_automatically_added_where_references + scope = Post.where(:comments => { :body => "Bla" }) + assert_equal ['comments'], scope.references_values + + scope = Post.where('comments.body' => 'Bla') + assert_equal ['comments'], scope.references_values + end + + def test_automatically_added_where_not_references + scope = Post.where.not(comments: { body: "Bla" }) + assert_equal ['comments'], scope.references_values + + scope = Post.where.not('comments.body' => 'Bla') + assert_equal ['comments'], scope.references_values + end + + def test_automatically_added_having_references + scope = Post.having(:comments => { :body => "Bla" }) + assert_equal ['comments'], scope.references_values + + scope = Post.having('comments.body' => 'Bla') + assert_equal ['comments'], scope.references_values + end + + def test_automatically_added_order_references + scope = Post.order('comments.body') + assert_equal ['comments'], scope.references_values + + scope = Post.order('comments.body', 'yaks.body') + assert_equal ['comments', 'yaks'], scope.references_values + + # Don't infer yaks, let's not go down that road again... + scope = Post.order('comments.body, yaks.body') + assert_equal ['comments'], scope.references_values + + scope = Post.order('comments.body asc') + assert_equal ['comments'], scope.references_values + + scope = Post.order('foo(comments.body)') + assert_equal [], scope.references_values + end + + def test_automatically_added_reorder_references + scope = Post.reorder('comments.body') + assert_equal %w(comments), scope.references_values + + scope = Post.reorder('comments.body', 'yaks.body') + assert_equal %w(comments yaks), scope.references_values + + # Don't infer yaks, let's not go down that road again... + scope = Post.reorder('comments.body, yaks.body') + assert_equal %w(comments), scope.references_values + + scope = Post.reorder('comments.body asc') + assert_equal %w(comments), scope.references_values + + scope = Post.reorder('foo(comments.body)') + assert_equal [], scope.references_values + end + + def test_order_with_reorder_nil_removes_the_order + relation = Post.order(:title).reorder(nil) + + assert_nil relation.order_values.first + end + + def test_reverse_order_with_reorder_nil_removes_the_order + relation = Post.order(:title).reverse_order.reorder(nil) + + assert_nil relation.order_values.first + end + + def test_presence + topics = Topic.all + + # the first query is triggered because there are no topics yet. + assert_queries(1) { assert topics.present? } + + # checking if there are topics is used before you actually display them, + # thus it shouldn't invoke an extra count query. + assert_no_queries { assert topics.present? } + assert_no_queries { assert !topics.blank? } + + # shows count of topics and loops after loading the query should not trigger extra queries either. + assert_no_queries { topics.size } + assert_no_queries { topics.length } + assert_no_queries { topics.each } + + # count always trigger the COUNT query. + assert_queries(1) { topics.count } + + assert topics.loaded? + end + + test "find_by with hash conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.order(:id).find_by(author_id: 2) + end + + test "find_by with non-hash conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.order(:id).find_by("author_id = 2") + end + + test "find_by with multi-arg conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.order(:id).find_by('author_id = ?', 2) + end + + test "find_by returns nil if the record is missing" do + assert_equal nil, Post.all.find_by("1 = 0") + end + + test "find_by doesn't have implicit ordering" do + assert_sql(/^((?!ORDER).)*$/) { Post.find_by(author_id: 2) } + end + + test "find_by! with hash conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.order(:id).find_by!(author_id: 2) + end + + test "find_by! with non-hash conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.order(:id).find_by!("author_id = 2") + end + + test "find_by! with multi-arg conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.order(:id).find_by!('author_id = ?', 2) + end + + test "find_by! doesn't have implicit ordering" do + assert_sql(/^((?!ORDER).)*$/) { Post.find_by!(author_id: 2) } + end + + test "find_by! raises RecordNotFound if the record is missing" do + assert_raises(ActiveRecord::RecordNotFound) do + Post.all.find_by!("1 = 0") + end + end + + test "loaded relations cannot be mutated by multi value methods" do + relation = Post.all + relation.to_a + + assert_raises(ActiveRecord::ImmutableRelation) do + relation.where! 'foo' + end + end + + test "loaded relations cannot be mutated by single value methods" do + relation = Post.all + relation.to_a + + assert_raises(ActiveRecord::ImmutableRelation) do + relation.limit! 5 + end + end + + test "loaded relations cannot be mutated by merge!" do + relation = Post.all + relation.to_a + + assert_raises(ActiveRecord::ImmutableRelation) do + relation.merge! where: 'foo' + end + end + + test "loaded relations cannot be mutated by extending!" do + relation = Post.all + relation.to_a + + assert_raises(ActiveRecord::ImmutableRelation) do + relation.extending! Module.new + end + end + + test "relations show the records in #inspect" do + relation = Post.limit(2) + assert_equal "#<ActiveRecord::Relation [#{Post.limit(2).map(&:inspect).join(', ')}]>", relation.inspect + end + + test "relations limit the records in #inspect at 10" do + relation = Post.limit(11) + assert_equal "#<ActiveRecord::Relation [#{Post.limit(10).map(&:inspect).join(', ')}, ...]>", relation.inspect + end + + test "already-loaded relations don't perform a new query in #inspect" do + relation = Post.limit(2) + relation.to_a + + expected = "#<ActiveRecord::Relation [#{Post.limit(2).map(&:inspect).join(', ')}]>" + + assert_no_queries do + assert_equal expected, relation.inspect + end + end + + test 'using a custom table affects the wheres' do + table_alias = Post.arel_table.alias('omg_posts') + + relation = ActiveRecord::Relation.new Post, table_alias + relation.where!(:foo => "bar") + + node = relation.arel.constraints.first.grep(Arel::Attributes::Attribute).first + assert_equal table_alias, node.relation + end + + test '#load' do + relation = Post.all + assert_queries(1) do + assert_equal relation, relation.load + end + assert_no_queries { relation.to_a } + end + + test 'group with select and includes' do + authors_count = Post.select('author_id, COUNT(author_id) AS num_posts'). + group('author_id').order('author_id').includes(:author).to_a + + assert_no_queries do + result = authors_count.map do |post| + [post.num_posts, post.author.try(:name)] + end + + expected = [[1, nil], [5, "David"], [3, "Mary"], [2, "Bob"]] + assert_equal expected, result + end + end + + test "joins with select" do + posts = Post.joins(:author).select("id", "authors.author_address_id").order("posts.id").limit(3) + assert_equal [1, 2, 4], posts.map(&:id) + assert_equal [1, 1, 1], posts.map(&:author_address_id) + end + + test "delegations do not leak to other classes" do + Topic.all.by_lifo + assert Topic.all.class.method_defined?(:by_lifo) + assert !Post.all.respond_to?(:by_lifo) + end + + def test_unscope_removes_binds + left = Post.where(id: Arel::Nodes::BindParam.new('?')) + column = Post.columns_hash['id'] + left.bind_values += [[column, 20]] + + relation = left.unscope(where: :id) + assert_equal [], relation.bind_values + end + + def test_merging_removes_rhs_bind_parameters + left = Post.where(id: 20) + right = Post.where(id: [1,2,3,4]) + + merged = left.merge(right) + assert_equal [], merged.bind_values + end + + def test_merging_keeps_lhs_bind_parameters + column = Post.columns_hash['id'] + binds = [[column, 20]] + + right = Post.where(id: 20) + left = Post.where(id: 10) + + merged = left.merge(right) + assert_equal binds, merged.bind_values + end + + def test_merging_reorders_bind_params + post = Post.first + right = Post.where(id: post.id) + left = Post.where(title: post.title) + + merged = left.merge(right) + assert_equal post, merged.first + end + + def test_relation_join_method + assert_equal 'Thank you for the welcome,Thank you again for the welcome', Post.first.comments.join(",") + end +end diff --git a/activerecord/test/cases/reload_models_test.rb b/activerecord/test/cases/reload_models_test.rb new file mode 100644 index 0000000000..0d16a3526f --- /dev/null +++ b/activerecord/test/cases/reload_models_test.rb @@ -0,0 +1,22 @@ +require "cases/helper" +require 'models/owner' +require 'models/pet' + +class ReloadModelsTest < ActiveRecord::TestCase + fixtures :pets + + def test_has_one_with_reload + pet = Pet.find_by_name('parrot') + pet.owner = Owner.find_by_name('ashley') + + # Reload the class Owner, simulating auto-reloading of model classes in a + # development environment. Note that meanwhile the class Pet is not + # reloaded, simulating a class that is present in a plugin. + Object.class_eval { remove_const :Owner } + Kernel.load(File.expand_path(File.join(File.dirname(__FILE__), "../models/owner.rb"))) + + pet = Pet.find_by_name('parrot') + pet.owner = Owner.find_by_name('ashley') + assert_equal pet.owner, Owner.find_by_name('ashley') + end +end diff --git a/activerecord/test/cases/result_test.rb b/activerecord/test/cases/result_test.rb new file mode 100644 index 0000000000..d6decafad9 --- /dev/null +++ b/activerecord/test/cases/result_test.rb @@ -0,0 +1,76 @@ +require "cases/helper" + +module ActiveRecord + class ResultTest < ActiveRecord::TestCase + def result + Result.new(['col_1', 'col_2'], [ + ['row 1 col 1', 'row 1 col 2'], + ['row 2 col 1', 'row 2 col 2'], + ['row 3 col 1', 'row 3 col 2'], + ]) + end + + test "to_hash returns row_hashes" do + assert_equal [ + {'col_1' => 'row 1 col 1', 'col_2' => 'row 1 col 2'}, + {'col_1' => 'row 2 col 1', 'col_2' => 'row 2 col 2'}, + {'col_1' => 'row 3 col 1', 'col_2' => 'row 3 col 2'}, + ], result.to_hash + end + + test "each with block returns row hashes" do + result.each do |row| + assert_equal ['col_1', 'col_2'], row.keys + end + end + + test "each without block returns an enumerator" do + result.each.with_index do |row, index| + assert_equal ['col_1', 'col_2'], row.keys + assert_kind_of Integer, index + end + end + + if Enumerator.method_defined? :size + test "each without block returns a sized enumerator" do + assert_equal 3, result.each.size + end + end + + test "cast_values returns rows after type casting" do + values = [["1.1", "2.2"], ["3.3", "4.4"]] + columns = ["col1", "col2"] + types = { "col1" => Type::Integer.new, "col2" => Type::Float.new } + result = Result.new(columns, values, types) + + assert_equal [[1, 2.2], [3, 4.4]], result.cast_values + end + + test "cast_values uses identity type for unknown types" do + values = [["1.1", "2.2"], ["3.3", "4.4"]] + columns = ["col1", "col2"] + types = { "col1" => Type::Integer.new } + result = Result.new(columns, values, types) + + assert_equal [[1, "2.2"], [3, "4.4"]], result.cast_values + end + + test "cast_values returns single dimensional array if single column" do + values = [["1.1"], ["3.3"]] + columns = ["col1"] + types = { "col1" => Type::Integer.new } + result = Result.new(columns, values, types) + + assert_equal [1, 3], result.cast_values + end + + test "cast_values can receive types to use instead" do + values = [["1.1", "2.2"], ["3.3", "4.4"]] + columns = ["col1", "col2"] + types = { "col1" => Type::Integer.new, "col2" => Type::Float.new } + result = Result.new(columns, values, types) + + assert_equal [[1.1, 2.2], [3.3, 4.4]], result.cast_values("col1" => Type::Float.new) + end + end +end diff --git a/activerecord/test/cases/sanitize_test.rb b/activerecord/test/cases/sanitize_test.rb new file mode 100644 index 0000000000..dca85fb5eb --- /dev/null +++ b/activerecord/test/cases/sanitize_test.rb @@ -0,0 +1,81 @@ +require "cases/helper" +require 'models/binary' +require 'models/author' +require 'models/post' + +class SanitizeTest < ActiveRecord::TestCase + def setup + end + + def test_sanitize_sql_hash_handles_associations + quoted_bambi = ActiveRecord::Base.connection.quote("Bambi") + quoted_column_name = ActiveRecord::Base.connection.quote_column_name("name") + quoted_table_name = ActiveRecord::Base.connection.quote_table_name("adorable_animals") + expected_value = "#{quoted_table_name}.#{quoted_column_name} = #{quoted_bambi}" + + assert_equal expected_value, Binary.send(:sanitize_sql_hash, {adorable_animals: {name: 'Bambi'}}) + end + + def test_sanitize_sql_array_handles_string_interpolation + quoted_bambi = ActiveRecord::Base.connection.quote_string("Bambi") + assert_equal "name=#{quoted_bambi}", Binary.send(:sanitize_sql_array, ["name=%s", "Bambi"]) + assert_equal "name=#{quoted_bambi}", Binary.send(:sanitize_sql_array, ["name=%s", "Bambi".mb_chars]) + quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote_string("Bambi\nand\nThumper") + assert_equal "name=#{quoted_bambi_and_thumper}",Binary.send(:sanitize_sql_array, ["name=%s", "Bambi\nand\nThumper"]) + assert_equal "name=#{quoted_bambi_and_thumper}",Binary.send(:sanitize_sql_array, ["name=%s", "Bambi\nand\nThumper".mb_chars]) + end + + def test_sanitize_sql_array_handles_bind_variables + quoted_bambi = ActiveRecord::Base.connection.quote("Bambi") + assert_equal "name=#{quoted_bambi}", Binary.send(:sanitize_sql_array, ["name=?", "Bambi"]) + assert_equal "name=#{quoted_bambi}", Binary.send(:sanitize_sql_array, ["name=?", "Bambi".mb_chars]) + quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote("Bambi\nand\nThumper") + assert_equal "name=#{quoted_bambi_and_thumper}", Binary.send(:sanitize_sql_array, ["name=?", "Bambi\nand\nThumper"]) + assert_equal "name=#{quoted_bambi_and_thumper}", Binary.send(:sanitize_sql_array, ["name=?", "Bambi\nand\nThumper".mb_chars]) + end + + def test_sanitize_sql_array_handles_relations + david = Author.create!(name: 'David') + david_posts = david.posts.select(:id) + + sub_query_pattern = /\(\bselect\b.*?\bwhere\b.*?\)/i + + select_author_sql = Post.send(:sanitize_sql_array, ['id in (?)', david_posts]) + assert_match(sub_query_pattern, select_author_sql, 'should sanitize `Relation` as subquery for bind variables') + + select_author_sql = Post.send(:sanitize_sql_array, ['id in (:post_ids)', post_ids: david_posts]) + assert_match(sub_query_pattern, select_author_sql, 'should sanitize `Relation` as subquery for named bind variables') + end + + def test_sanitize_sql_array_handles_empty_statement + select_author_sql = Post.send(:sanitize_sql_array, ['']) + assert_equal('', select_author_sql) + end + + def test_sanitize_sql_like + assert_equal '100\%', Binary.send(:sanitize_sql_like, '100%') + assert_equal 'snake\_cased\_string', Binary.send(:sanitize_sql_like, 'snake_cased_string') + assert_equal 'C:\\\\Programs\\\\MsPaint', Binary.send(:sanitize_sql_like, 'C:\\Programs\\MsPaint') + assert_equal 'normal string 42', Binary.send(:sanitize_sql_like, 'normal string 42') + end + + def test_sanitize_sql_like_with_custom_escape_character + assert_equal '100!%', Binary.send(:sanitize_sql_like, '100%', '!') + assert_equal 'snake!_cased!_string', Binary.send(:sanitize_sql_like, 'snake_cased_string', '!') + assert_equal 'great!!', Binary.send(:sanitize_sql_like, 'great!', '!') + assert_equal 'C:\\Programs\\MsPaint', Binary.send(:sanitize_sql_like, 'C:\\Programs\\MsPaint', '!') + assert_equal 'normal string 42', Binary.send(:sanitize_sql_like, 'normal string 42', '!') + end + + def test_sanitize_sql_like_example_use_case + searchable_post = Class.new(Post) do + def self.search(term) + where("title LIKE ?", sanitize_sql_like(term, '!')) + end + end + + assert_sql(/LIKE '20!% !_reduction!_!!'/) do + searchable_post.search("20% _reduction_!").to_a + end + end +end diff --git a/activerecord/test/cases/schema_dumper_test.rb b/activerecord/test/cases/schema_dumper_test.rb new file mode 100644 index 0000000000..4e71d04bc0 --- /dev/null +++ b/activerecord/test/cases/schema_dumper_test.rb @@ -0,0 +1,451 @@ +require "cases/helper" +require 'support/schema_dumping_helper' + +class SchemaDumperTest < ActiveRecord::TestCase + setup do + ActiveRecord::SchemaMigration.create_table + end + + def standard_dump + @stream = StringIO.new + ActiveRecord::SchemaDumper.ignore_tables = [] + ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, @stream) + @stream.string + end + + def test_dump_schema_information_outputs_lexically_ordered_versions + versions = %w{ 20100101010101 20100201010101 20100301010101 } + versions.reverse.each do |v| + ActiveRecord::SchemaMigration.create!(:version => v) + end + + schema_info = ActiveRecord::Base.connection.dump_schema_information + assert_match(/20100201010101.*20100301010101/m, schema_info) + end + + def test_magic_comment + output = standard_dump + assert_match "# encoding: #{@stream.external_encoding.name}", output + end + + def test_schema_dump + output = standard_dump + assert_match %r{create_table "accounts"}, output + assert_match %r{create_table "authors"}, output + assert_no_match %r{create_table "schema_migrations"}, output + end + + def test_schema_dump_excludes_sqlite_sequence + output = standard_dump + assert_no_match %r{create_table "sqlite_sequence"}, output + end + + def test_schema_dump_includes_camelcase_table_name + output = standard_dump + assert_match %r{create_table "CamelCase"}, output + end + + def assert_line_up(lines, pattern, required = false) + return assert(true) if lines.empty? + matches = lines.map { |line| line.match(pattern) } + assert matches.all? if required + matches.compact! + return assert(true) if matches.empty? + assert_equal 1, matches.map{ |match| match.offset(0).first }.uniq.length + end + + def column_definition_lines(output = standard_dump) + output.scan(/^( *)create_table.*?\n(.*?)^\1end/m).map{ |m| m.last.split(/\n/) } + end + + def test_types_line_up + column_definition_lines.each do |column_set| + next if column_set.empty? + + lengths = column_set.map do |column| + if match = column.match(/t\.(?:integer|decimal|float|datetime|timestamp|time|date|text|binary|string|boolean|uuid|point)\s+"/) + match[0].length + end + end + + assert_equal 1, lengths.uniq.length + end + end + + def test_arguments_line_up + column_definition_lines.each do |column_set| + assert_line_up(column_set, /default: /) + assert_line_up(column_set, /limit: /) + assert_line_up(column_set, /null: /) + end + end + + def test_no_dump_errors + output = standard_dump + assert_no_match %r{\# Could not dump table}, output + end + + def test_schema_dump_includes_not_null_columns + stream = StringIO.new + + ActiveRecord::SchemaDumper.ignore_tables = [/^[^r]/] + ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream) + output = stream.string + assert_match %r{null: false}, output + end + + def test_schema_dump_includes_limit_constraint_for_integer_columns + stream = StringIO.new + + ActiveRecord::SchemaDumper.ignore_tables = [/^(?!integer_limits)/] + ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream) + output = stream.string + + if current_adapter?(:PostgreSQLAdapter) + assert_match %r{c_int_1.*limit: 2}, output + assert_match %r{c_int_2.*limit: 2}, output + + # int 3 is 4 bytes in postgresql + assert_match %r{c_int_3.*}, output + assert_no_match %r{c_int_3.*limit:}, output + + assert_match %r{c_int_4.*}, output + assert_no_match %r{c_int_4.*limit:}, output + elsif current_adapter?(:MysqlAdapter, :Mysql2Adapter) + assert_match %r{c_int_1.*limit: 1}, output + assert_match %r{c_int_2.*limit: 2}, output + assert_match %r{c_int_3.*limit: 3}, output + + assert_match %r{c_int_4.*}, output + assert_no_match %r{c_int_4.*:limit}, output + elsif current_adapter?(:SQLite3Adapter) + assert_match %r{c_int_1.*limit: 1}, output + assert_match %r{c_int_2.*limit: 2}, output + assert_match %r{c_int_3.*limit: 3}, output + assert_match %r{c_int_4.*limit: 4}, output + end + assert_match %r{c_int_without_limit.*}, output + assert_no_match %r{c_int_without_limit.*limit:}, output + + if current_adapter?(:SQLite3Adapter) + assert_match %r{c_int_5.*limit: 5}, output + assert_match %r{c_int_6.*limit: 6}, output + assert_match %r{c_int_7.*limit: 7}, output + assert_match %r{c_int_8.*limit: 8}, output + elsif current_adapter?(:OracleAdapter) + assert_match %r{c_int_5.*limit: 5}, output + assert_match %r{c_int_6.*limit: 6}, output + assert_match %r{c_int_7.*limit: 7}, output + assert_match %r{c_int_8.*limit: 8}, output + else + assert_match %r{c_int_5.*limit: 8}, output + assert_match %r{c_int_6.*limit: 8}, output + assert_match %r{c_int_7.*limit: 8}, output + assert_match %r{c_int_8.*limit: 8}, output + end + end + + def test_schema_dump_with_string_ignored_table + stream = StringIO.new + + ActiveRecord::SchemaDumper.ignore_tables = ['accounts'] + ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream) + output = stream.string + assert_no_match %r{create_table "accounts"}, output + assert_match %r{create_table "authors"}, output + assert_no_match %r{create_table "schema_migrations"}, output + end + + def test_schema_dump_with_regexp_ignored_table + stream = StringIO.new + + ActiveRecord::SchemaDumper.ignore_tables = [/^account/] + ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream) + output = stream.string + assert_no_match %r{create_table "accounts"}, output + assert_match %r{create_table "authors"}, output + assert_no_match %r{create_table "schema_migrations"}, output + end + + def test_schema_dump_illegal_ignored_table_value + stream = StringIO.new + ActiveRecord::SchemaDumper.ignore_tables = [5] + assert_raise(StandardError) do + ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream) + end + end + + def test_schema_dumps_index_columns_in_right_order + index_definition = standard_dump.split(/\n/).grep(/add_index.*companies/).first.strip + if current_adapter?(:MysqlAdapter, :Mysql2Adapter, :PostgreSQLAdapter) + assert_equal 'add_index "companies", ["firm_id", "type", "rating"], name: "company_index", using: :btree', index_definition + else + assert_equal 'add_index "companies", ["firm_id", "type", "rating"], name: "company_index"', index_definition + end + end + + def test_schema_dumps_partial_indices + index_definition = standard_dump.split(/\n/).grep(/add_index.*company_partial_index/).first.strip + if current_adapter?(:PostgreSQLAdapter) + assert_equal 'add_index "companies", ["firm_id", "type"], name: "company_partial_index", where: "(rating > 10)", using: :btree', index_definition + elsif current_adapter?(:MysqlAdapter, :Mysql2Adapter) + assert_equal 'add_index "companies", ["firm_id", "type"], name: "company_partial_index", using: :btree', index_definition + elsif current_adapter?(:SQLite3Adapter) && ActiveRecord::Base.connection.supports_partial_index? + assert_equal 'add_index "companies", ["firm_id", "type"], name: "company_partial_index", where: "rating > 10"', index_definition + else + assert_equal 'add_index "companies", ["firm_id", "type"], name: "company_partial_index"', index_definition + end + end + + def test_schema_dump_should_honor_nonstandard_primary_keys + output = standard_dump + match = output.match(%r{create_table "movies"(.*)do}) + assert_not_nil(match, "nonstandardpk table not found") + assert_match %r(primary_key: "movieid"), match[1], "non-standard primary key not preserved" + end + + def test_schema_dump_should_use_false_as_default + output = standard_dump + assert_match %r{t\.boolean\s+"has_fun",.+default: false}, output + end + + if current_adapter?(:MysqlAdapter, :Mysql2Adapter) + def test_schema_dump_should_not_add_default_value_for_mysql_text_field + output = standard_dump + assert_match %r{t.text\s+"body",\s+null: false$}, output + end + + def test_schema_dump_includes_length_for_mysql_binary_fields + output = standard_dump + assert_match %r{t.binary\s+"var_binary",\s+limit: 255$}, output + assert_match %r{t.binary\s+"var_binary_large",\s+limit: 4095$}, output + end + + def test_schema_dump_includes_length_for_mysql_blob_and_text_fields + output = standard_dump + assert_match %r{t.binary\s+"tiny_blob",\s+limit: 255$}, output + assert_match %r{t.binary\s+"normal_blob"$}, output + assert_match %r{t.binary\s+"medium_blob",\s+limit: 16777215$}, output + assert_match %r{t.binary\s+"long_blob",\s+limit: 2147483647$}, output + assert_match %r{t.text\s+"tiny_text",\s+limit: 255$}, output + assert_match %r{t.text\s+"normal_text"$}, output + assert_match %r{t.text\s+"medium_text",\s+limit: 16777215$}, output + assert_match %r{t.text\s+"long_text",\s+limit: 2147483647$}, output + end + + def test_schema_dumps_index_type + output = standard_dump + assert_match %r{add_index "key_tests", \["awesome"\], name: "index_key_tests_on_awesome", type: :fulltext}, output + assert_match %r{add_index "key_tests", \["pizza"\], name: "index_key_tests_on_pizza", using: :btree}, output + end + end + + def test_schema_dump_includes_decimal_options + stream = StringIO.new + ActiveRecord::SchemaDumper.ignore_tables = [/^[^n]/] + ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream) + output = stream.string + assert_match %r{precision: 3,[[:space:]]+scale: 2,[[:space:]]+default: 2.78}, output + end + + if current_adapter?(:PostgreSQLAdapter) + def test_schema_dump_includes_bigint_default + output = standard_dump + assert_match %r{t.integer\s+"bigint_default",\s+limit: 8,\s+default: 0}, output + end + + if ActiveRecord::Base.connection.supports_extensions? + def test_schema_dump_includes_extensions + connection = ActiveRecord::Base.connection + + connection.stubs(:extensions).returns(['hstore']) + output = standard_dump + assert_match "# These are extensions that must be enabled", output + assert_match %r{enable_extension "hstore"}, output + + connection.stubs(:extensions).returns([]) + output = standard_dump + assert_no_match "# These are extensions that must be enabled", output + assert_no_match %r{enable_extension}, output + end + end + + def test_schema_dump_includes_xml_shorthand_definition + output = standard_dump + if %r{create_table "postgresql_xml_data_type"} =~ output + assert_match %r{t.xml "data"}, output + end + end + + def test_schema_dump_includes_json_shorthand_definition + output = standard_dump + if %r{create_table "postgresql_json_data_type"} =~ output + assert_match %r|t.json "json_data", default: {}|, output + end + end + + def test_schema_dump_includes_inet_shorthand_definition + output = standard_dump + if %r{create_table "postgresql_network_addresses"} =~ output + assert_match %r{t.inet\s+"inet_address",\s+default: "192.168.1.1"}, output + end + end + + def test_schema_dump_includes_cidr_shorthand_definition + output = standard_dump + if %r{create_table "postgresql_network_addresses"} =~ output + assert_match %r{t.cidr\s+"cidr_address",\s+default: "192.168.1.0/24"}, output + end + end + + def test_schema_dump_includes_macaddr_shorthand_definition + output = standard_dump + if %r{create_table "postgresql_network_addresses"} =~ output + assert_match %r{t.macaddr\s+"mac_address",\s+default: "ff:ff:ff:ff:ff:ff"}, output + end + end + + def test_schema_dump_includes_uuid_shorthand_definition + output = standard_dump + if %r{create_table "postgresql_uuids"} =~ output + assert_match %r{t.uuid "guid"}, output + end + end + + def test_schema_dump_includes_hstores_shorthand_definition + output = standard_dump + if %r{create_table "postgresql_hstores"} =~ output + assert_match %r[t.hstore "hash_store", default: {}], output + end + end + + def test_schema_dump_includes_citext_shorthand_definition + output = standard_dump + if %r{create_table "postgresql_citext"} =~ output + assert_match %r[t.citext "text_citext"], output + end + end + + def test_schema_dump_includes_ltrees_shorthand_definition + output = standard_dump + if %r{create_table "postgresql_ltrees"} =~ output + assert_match %r[t.ltree "path"], output + end + end + + def test_schema_dump_includes_arrays_shorthand_definition + output = standard_dump + if %r{create_table "postgresql_arrays"} =~ output + assert_match %r[t.text\s+"nicknames",\s+array: true], output + assert_match %r[t.integer\s+"commission_by_quarter",\s+array: true], output + end + end + + def test_schema_dump_includes_tsvector_shorthand_definition + output = standard_dump + if %r{create_table "postgresql_tsvectors"} =~ output + assert_match %r{t.tsvector "text_vector"}, output + end + end + end + + def test_schema_dump_keeps_large_precision_integer_columns_as_decimal + output = standard_dump + # Oracle supports precision up to 38 and it identifies decimals with scale 0 as integers + if current_adapter?(:OracleAdapter) + assert_match %r{t.integer\s+"atoms_in_universe",\s+precision: 38}, output + else + assert_match %r{t.decimal\s+"atoms_in_universe",\s+precision: 55}, output + end + end + + def test_schema_dump_keeps_id_column_when_id_is_false_and_id_column_added + output = standard_dump + match = output.match(%r{create_table "goofy_string_id"(.*)do.*\n(.*)\n}) + assert_not_nil(match, "goofy_string_id table not found") + assert_match %r(id: false), match[1], "no table id not preserved" + assert_match %r{t.string[[:space:]]+"id",[[:space:]]+null: false$}, match[2], "non-primary key id column not preserved" + end + + def test_schema_dump_keeps_id_false_when_id_is_false_and_unique_not_null_column_added + output = standard_dump + assert_match %r{create_table "subscribers", id: false}, output + end + + if ActiveRecord::Base.connection.supports_foreign_keys? + def test_foreign_keys_are_dumped_at_the_bottom_to_circumvent_dependency_issues + output = standard_dump + assert_match(/^\s+add_foreign_key "fk_test_has_fk"[^\n]+\n\s+add_foreign_key "lessons_students"/, output) + end + end + + class CreateDogMigration < ActiveRecord::Migration + def up + create_table("dog_owners") do |t| + end + + create_table("dogs") do |t| + t.column :name, :string + t.column :owner_id, :integer + end + add_index "dogs", [:name] + add_foreign_key :dogs, :dog_owners, column: "owner_id" if supports_foreign_keys? + end + def down + drop_table("dogs") + drop_table("dog_owners") + end + end + + def test_schema_dump_with_table_name_prefix_and_suffix + original, $stdout = $stdout, StringIO.new + ActiveRecord::Base.table_name_prefix = 'foo_' + ActiveRecord::Base.table_name_suffix = '_bar' + + migration = CreateDogMigration.new + migration.migrate(:up) + + output = standard_dump + assert_no_match %r{create_table "foo_.+_bar"}, output + assert_no_match %r{add_index "foo_.+_bar"}, output + assert_no_match %r{create_table "schema_migrations"}, output + + if ActiveRecord::Base.connection.supports_foreign_keys? + assert_no_match %r{add_foreign_key "foo_.+_bar"}, output + assert_no_match %r{add_foreign_key "[^"]+", "foo_.+_bar"}, output + end + ensure + migration.migrate(:down) + + ActiveRecord::Base.table_name_suffix = ActiveRecord::Base.table_name_prefix = '' + $stdout = original + end +end + +class SchemaDumperDefaultsTest < ActiveRecord::TestCase + include SchemaDumpingHelper + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table :defaults, force: true do |t| + t.string :string_with_default, default: "Hello!" + t.date :date_with_default, default: '2014-06-05' + t.datetime :datetime_with_default, default: "2014-06-05 07:17:04" + t.time :time_with_default, default: "07:17:04" + end + end + + teardown do + return unless @connection + @connection.execute 'DROP TABLE IF EXISTS defaults' + end + + def test_schema_dump_defaults_with_universally_supported_types + output = dump_table_schema('defaults') + + assert_match %r{t\.string\s+"string_with_default",\s+default: "Hello!"}, output + assert_match %r{t\.date\s+"date_with_default",\s+default: '2014-06-05'}, output + assert_match %r{t\.datetime\s+"datetime_with_default",\s+default: '2014-06-05 07:17:04'}, output + assert_match %r{t\.time\s+"time_with_default",\s+default: '2000-01-01 07:17:04'}, output + end +end diff --git a/activerecord/test/cases/scoping/default_scoping_test.rb b/activerecord/test/cases/scoping/default_scoping_test.rb new file mode 100644 index 0000000000..9a4d8c6740 --- /dev/null +++ b/activerecord/test/cases/scoping/default_scoping_test.rb @@ -0,0 +1,416 @@ +require 'cases/helper' +require 'models/post' +require 'models/developer' + +class DefaultScopingTest < ActiveRecord::TestCase + fixtures :developers, :posts + + def test_default_scope + expected = Developer.all.merge!(:order => 'salary DESC').to_a.collect { |dev| dev.salary } + received = DeveloperOrderedBySalary.all.collect { |dev| dev.salary } + assert_equal expected, received + end + + def test_default_scope_as_class_method + assert_equal [developers(:david).becomes(ClassMethodDeveloperCalledDavid)], ClassMethodDeveloperCalledDavid.all + end + + def test_default_scope_as_class_method_referencing_scope + assert_equal [developers(:david).becomes(ClassMethodReferencingScopeDeveloperCalledDavid)], ClassMethodReferencingScopeDeveloperCalledDavid.all + end + + def test_default_scope_as_block_referencing_scope + assert_equal [developers(:david).becomes(LazyBlockReferencingScopeDeveloperCalledDavid)], LazyBlockReferencingScopeDeveloperCalledDavid.all + end + + def test_default_scope_with_lambda + assert_equal [developers(:david).becomes(LazyLambdaDeveloperCalledDavid)], LazyLambdaDeveloperCalledDavid.all + end + + def test_default_scope_with_block + assert_equal [developers(:david).becomes(LazyBlockDeveloperCalledDavid)], LazyBlockDeveloperCalledDavid.all + end + + def test_default_scope_with_callable + assert_equal [developers(:david).becomes(CallableDeveloperCalledDavid)], CallableDeveloperCalledDavid.all + end + + def test_default_scope_is_unscoped_on_find + assert_equal 1, DeveloperCalledDavid.count + assert_equal 11, DeveloperCalledDavid.unscoped.count + end + + def test_default_scope_is_unscoped_on_create + assert_nil DeveloperCalledJamis.unscoped.create!.name + end + + def test_default_scope_with_conditions_string + assert_equal Developer.where(name: 'David').map(&:id).sort, DeveloperCalledDavid.all.map(&:id).sort + assert_equal nil, DeveloperCalledDavid.create!.name + end + + def test_default_scope_with_conditions_hash + assert_equal Developer.where(name: 'Jamis').map(&:id).sort, DeveloperCalledJamis.all.map(&:id).sort + assert_equal 'Jamis', DeveloperCalledJamis.create!.name + end + + unless in_memory_db? + def test_default_scoping_with_threads + 2.times do + Thread.new { + assert DeveloperOrderedBySalary.all.to_sql.include?('salary DESC') + DeveloperOrderedBySalary.connection.close + }.join + end + end + end + + def test_default_scope_with_inheritance + wheres = InheritedPoorDeveloperCalledJamis.all.where_values_hash + assert_equal "Jamis", wheres['name'] + assert_equal 50000, wheres['salary'] + end + + def test_default_scope_with_module_includes + wheres = ModuleIncludedPoorDeveloperCalledJamis.all.where_values_hash + assert_equal "Jamis", wheres['name'] + assert_equal 50000, wheres['salary'] + end + + def test_default_scope_with_multiple_calls + wheres = MultiplePoorDeveloperCalledJamis.all.where_values_hash + assert_equal "Jamis", wheres['name'] + assert_equal 50000, wheres['salary'] + end + + def test_scope_overwrites_default + expected = Developer.all.merge!(order: 'salary DESC, name DESC').to_a.collect { |dev| dev.name } + received = DeveloperOrderedBySalary.by_name.to_a.collect { |dev| dev.name } + assert_equal expected, received + end + + def test_reorder_overrides_default_scope_order + expected = Developer.order('name DESC').collect { |dev| dev.name } + received = DeveloperOrderedBySalary.reorder('name DESC').collect { |dev| dev.name } + assert_equal expected, received + end + + def test_order_after_reorder_combines_orders + expected = Developer.order('name DESC, id DESC').collect { |dev| [dev.name, dev.id] } + received = Developer.order('name ASC').reorder('name DESC').order('id DESC').collect { |dev| [dev.name, dev.id] } + assert_equal expected, received + end + + def test_unscope_overrides_default_scope + expected = Developer.all.collect { |dev| [dev.name, dev.id] } + received = DeveloperCalledJamis.unscope(:where).collect { |dev| [dev.name, dev.id] } + assert_equal expected, received + end + + def test_unscope_after_reordering_and_combining + expected = Developer.order('id DESC, name DESC').collect { |dev| [dev.name, dev.id] } + received = DeveloperOrderedBySalary.reorder('name DESC').unscope(:order).order('id DESC, name DESC').collect { |dev| [dev.name, dev.id] } + assert_equal expected, received + + expected_2 = Developer.all.collect { |dev| [dev.name, dev.id] } + received_2 = Developer.order('id DESC, name DESC').unscope(:order).collect { |dev| [dev.name, dev.id] } + assert_equal expected_2, received_2 + + expected_3 = Developer.all.collect { |dev| [dev.name, dev.id] } + received_3 = Developer.reorder('name DESC').unscope(:order).collect { |dev| [dev.name, dev.id] } + assert_equal expected_3, received_3 + end + + def test_unscope_with_where_attributes + expected = Developer.order('salary DESC').collect(&:name) + received = DeveloperOrderedBySalary.where(name: 'David').unscope(where: :name).collect(&:name) + assert_equal expected, received + + expected_2 = Developer.order('salary DESC').collect(&:name) + received_2 = DeveloperOrderedBySalary.select("id").where("name" => "Jamis").unscope({:where => :name}, :select).collect(&:name) + assert_equal expected_2, received_2 + + expected_3 = Developer.order('salary DESC').collect(&:name) + received_3 = DeveloperOrderedBySalary.select("id").where("name" => "Jamis").unscope(:select, :where).collect(&:name) + assert_equal expected_3, received_3 + + expected_4 = Developer.order('salary DESC').collect(&:name) + received_4 = DeveloperOrderedBySalary.where.not("name" => "Jamis").unscope(where: :name).collect(&:name) + assert_equal expected_4, received_4 + + expected_5 = Developer.order('salary DESC').collect(&:name) + received_5 = DeveloperOrderedBySalary.where.not("name" => ["Jamis", "David"]).unscope(where: :name).collect(&:name) + assert_equal expected_5, received_5 + end + + def test_unscope_multiple_where_clauses + expected = Developer.order('salary DESC').collect { |dev| dev.name } + received = DeveloperOrderedBySalary.where(name: 'Jamis').where(id: 1).unscope(where: [:name, :id]).collect { |dev| dev.name } + assert_equal expected, received + end + + def test_unscope_string_where_clauses_involved + dev_relation = Developer.order('salary DESC').where("created_at > ?", 1.year.ago) + expected = dev_relation.collect { |dev| dev.name } + + dev_ordered_relation = DeveloperOrderedBySalary.where(name: 'Jamis').where("created_at > ?", 1.year.ago) + received = dev_ordered_relation.unscope(where: [:name]).collect { |dev| dev.name } + + assert_equal expected, received + end + + def test_unscope_with_grouping_attributes + expected = Developer.order('salary DESC').collect { |dev| dev.name } + received = DeveloperOrderedBySalary.group(:name).unscope(:group).collect { |dev| dev.name } + assert_equal expected, received + + expected_2 = Developer.order('salary DESC').collect { |dev| dev.name } + received_2 = DeveloperOrderedBySalary.group("name").unscope(:group).collect { |dev| dev.name } + assert_equal expected_2, received_2 + end + + def test_unscope_with_limit_in_query + expected = Developer.order('salary DESC').collect { |dev| dev.name } + received = DeveloperOrderedBySalary.limit(1).unscope(:limit).collect { |dev| dev.name } + assert_equal expected, received + end + + def test_order_to_unscope_reordering + scope = DeveloperOrderedBySalary.order('salary DESC, name ASC').reverse_order.unscope(:order) + assert !(scope.to_sql =~ /order/i) + end + + def test_unscope_reverse_order + expected = Developer.all.collect { |dev| dev.name } + received = Developer.order('salary DESC').reverse_order.unscope(:order).collect { |dev| dev.name } + assert_equal expected, received + end + + def test_unscope_select + expected = Developer.order('salary ASC').collect { |dev| dev.name } + received = Developer.order('salary DESC').reverse_order.select(:name).unscope(:select).collect { |dev| dev.name } + assert_equal expected, received + + expected_2 = Developer.all.collect { |dev| dev.id } + received_2 = Developer.select(:name).unscope(:select).collect { |dev| dev.id } + assert_equal expected_2, received_2 + end + + def test_unscope_offset + expected = Developer.all.collect { |dev| dev.name } + received = Developer.offset(5).unscope(:offset).collect { |dev| dev.name } + assert_equal expected, received + end + + def test_unscope_joins_and_select_on_developers_projects + expected = Developer.all.collect { |dev| dev.name } + received = Developer.joins('JOIN developers_projects ON id = developer_id').select(:id).unscope(:joins, :select).collect { |dev| dev.name } + assert_equal expected, received + end + + def test_unscope_includes + expected = Developer.all.collect { |dev| dev.name } + received = Developer.includes(:projects).select(:id).unscope(:includes, :select).collect { |dev| dev.name } + assert_equal expected, received + end + + def test_unscope_having + expected = DeveloperOrderedBySalary.all.collect { |dev| dev.name } + received = DeveloperOrderedBySalary.having("name IN ('Jamis', 'David')").unscope(:having).collect { |dev| dev.name } + assert_equal expected, received + end + + def test_unscope_and_scope + developer_klass = Class.new(Developer) do + scope :by_name, -> name { unscope(where: :name).where(name: name) } + end + + expected = developer_klass.where(name: 'Jamis').collect { |dev| [dev.name, dev.id] } + received = developer_klass.where(name: 'David').by_name('Jamis').collect { |dev| [dev.name, dev.id] } + assert_equal expected, received + end + + def test_unscope_errors_with_invalid_value + assert_raises(ArgumentError) do + Developer.includes(:projects).where(name: "Jamis").unscope(:stupidly_incorrect_value) + end + + assert_raises(ArgumentError) do + Developer.all.unscope(:includes, :select, :some_broken_value) + end + + assert_raises(ArgumentError) do + Developer.order('name DESC').reverse_order.unscope(:reverse_order) + end + + assert_raises(ArgumentError) do + Developer.order('name DESC').where(name: "Jamis").unscope() + end + end + + def test_unscope_errors_with_non_where_hash_keys + assert_raises(ArgumentError) do + Developer.where(name: "Jamis").limit(4).unscope(limit: 4) + end + + assert_raises(ArgumentError) do + Developer.where(name: "Jamis").unscope("where" => :name) + end + end + + def test_unscope_errors_with_non_symbol_or_hash_arguments + assert_raises(ArgumentError) do + Developer.where(name: "Jamis").limit(3).unscope("limit") + end + + assert_raises(ArgumentError) do + Developer.select("id").unscope("select") + end + + assert_raises(ArgumentError) do + Developer.select("id").unscope(5) + end + end + + def test_unscope_merging + merged = Developer.where(name: "Jamis").merge(Developer.unscope(:where)) + assert merged.where_values.empty? + assert !merged.where(name: "Jon").where_values.empty? + end + + def test_order_in_default_scope_should_not_prevail + expected = Developer.all.merge!(order: 'salary desc').to_a.collect { |dev| dev.salary } + received = DeveloperOrderedBySalary.all.merge!(order: 'salary').to_a.collect { |dev| dev.salary } + assert_equal expected, received + end + + def test_create_attribute_overwrites_default_scoping + assert_equal 'David', PoorDeveloperCalledJamis.create!(:name => 'David').name + assert_equal 200000, PoorDeveloperCalledJamis.create!(:name => 'David', :salary => 200000).salary + end + + def test_create_attribute_overwrites_default_values + assert_equal nil, PoorDeveloperCalledJamis.create!(:salary => nil).salary + assert_equal 50000, PoorDeveloperCalledJamis.create!(:name => 'David').salary + end + + def test_default_scope_attribute + jamis = PoorDeveloperCalledJamis.new(:name => 'David') + assert_equal 50000, jamis.salary + end + + def test_where_attribute + aaron = PoorDeveloperCalledJamis.where(:salary => 20).new(:name => 'Aaron') + assert_equal 20, aaron.salary + assert_equal 'Aaron', aaron.name + end + + def test_where_attribute_merge + aaron = PoorDeveloperCalledJamis.where(:name => 'foo').new(:name => 'Aaron') + assert_equal 'Aaron', aaron.name + end + + def test_scope_composed_by_limit_and_then_offset_is_equal_to_scope_composed_by_offset_and_then_limit + posts_limit_offset = Post.limit(3).offset(2) + posts_offset_limit = Post.offset(2).limit(3) + assert_equal posts_limit_offset, posts_offset_limit + end + + def test_create_with_merge + aaron = PoorDeveloperCalledJamis.create_with(:name => 'foo', :salary => 20).merge( + PoorDeveloperCalledJamis.create_with(:name => 'Aaron')).new + assert_equal 20, aaron.salary + assert_equal 'Aaron', aaron.name + + aaron = PoorDeveloperCalledJamis.create_with(:name => 'foo', :salary => 20). + create_with(:name => 'Aaron').new + assert_equal 20, aaron.salary + assert_equal 'Aaron', aaron.name + end + + def test_create_with_reset + jamis = PoorDeveloperCalledJamis.create_with(:name => 'Aaron').create_with(nil).new + assert_equal 'Jamis', jamis.name + end + + # FIXME: I don't know if this is *desired* behavior, but it is *today's* + # behavior. + def test_create_with_empty_hash_will_not_reset + jamis = PoorDeveloperCalledJamis.create_with(:name => 'Aaron').create_with({}).new + assert_equal 'Aaron', jamis.name + end + + def test_unscoped_with_named_scope_should_not_have_default_scope + assert_equal [DeveloperCalledJamis.find(developers(:poor_jamis).id)], DeveloperCalledJamis.poor + + assert DeveloperCalledJamis.unscoped.poor.include?(developers(:david).becomes(DeveloperCalledJamis)) + + assert_equal 11, DeveloperCalledJamis.unscoped.length + assert_equal 1, DeveloperCalledJamis.poor.length + assert_equal 10, DeveloperCalledJamis.unscoped.poor.length + assert_equal 10, DeveloperCalledJamis.unscoped { DeveloperCalledJamis.poor }.length + end + + def test_default_scope_select_ignored_by_aggregations + assert_equal DeveloperWithSelect.all.to_a.count, DeveloperWithSelect.count + end + + def test_default_scope_select_ignored_by_grouped_aggregations + assert_equal Hash[Developer.all.group_by(&:salary).map { |s, d| [s, d.count] }], + DeveloperWithSelect.group(:salary).count + end + + def test_default_scope_order_ignored_by_aggregations + assert_equal DeveloperOrderedBySalary.all.count, DeveloperOrderedBySalary.count + end + + def test_default_scope_find_last + assert DeveloperOrderedBySalary.count > 1, "need more than one row for test" + + lowest_salary_dev = DeveloperOrderedBySalary.find(developers(:poor_jamis).id) + assert_equal lowest_salary_dev, DeveloperOrderedBySalary.last + end + + def test_default_scope_include_with_count + d = DeveloperWithIncludes.create! + d.audit_logs.create! :message => 'foo' + + assert_equal 1, DeveloperWithIncludes.where(:audit_logs => { :message => 'foo' }).count + end + + unless in_memory_db? + def test_default_scope_is_threadsafe + threads = [] + assert_not_equal 1, ThreadsafeDeveloper.unscoped.count + + threads << Thread.new do + Thread.current[:long_default_scope] = true + assert_equal 1, ThreadsafeDeveloper.all.to_a.count + ThreadsafeDeveloper.connection.close + end + threads << Thread.new do + assert_equal 1, ThreadsafeDeveloper.all.to_a.count + ThreadsafeDeveloper.connection.close + end + threads.each(&:join) + end + end + + test "additional conditions are ANDed with the default scope" do + scope = DeveloperCalledJamis.where(name: "David") + assert_equal 2, scope.where_values.length + assert_equal [], scope.to_a + end + + test "additional conditions in a scope are ANDed with the default scope" do + scope = DeveloperCalledJamis.david + assert_equal 2, scope.where_values.length + assert_equal [], scope.to_a + end + + test "a scope can remove the condition from the default scope" do + scope = DeveloperCalledJamis.david2 + assert_equal 1, scope.where_values.length + assert_equal Developer.where(name: "David").map(&:id), scope.map(&:id) + end +end diff --git a/activerecord/test/cases/scoping/named_scoping_test.rb b/activerecord/test/cases/scoping/named_scoping_test.rb new file mode 100644 index 0000000000..59ec2dd6a4 --- /dev/null +++ b/activerecord/test/cases/scoping/named_scoping_test.rb @@ -0,0 +1,513 @@ +require "cases/helper" +require 'models/post' +require 'models/topic' +require 'models/comment' +require 'models/reply' +require 'models/author' +require 'models/developer' + +class NamedScopingTest < ActiveRecord::TestCase + fixtures :posts, :authors, :topics, :comments, :author_addresses + + def test_implements_enumerable + assert !Topic.all.empty? + + assert_equal Topic.all.to_a, Topic.base + assert_equal Topic.all.to_a, Topic.base.to_a + assert_equal Topic.first, Topic.base.first + assert_equal Topic.all.to_a, Topic.base.map { |i| i } + end + + def test_found_items_are_cached + all_posts = Topic.base + + assert_queries(1) do + all_posts.collect + all_posts.collect + end + end + + def test_reload_expires_cache_of_found_items + all_posts = Topic.base + all_posts.to_a + + new_post = Topic.create! + assert !all_posts.include?(new_post) + assert all_posts.reload.include?(new_post) + end + + def test_delegates_finds_and_calculations_to_the_base_class + assert !Topic.all.empty? + + assert_equal Topic.all.to_a, Topic.base.to_a + assert_equal Topic.first, Topic.base.first + assert_equal Topic.count, Topic.base.count + assert_equal Topic.average(:replies_count), Topic.base.average(:replies_count) + end + + def test_method_missing_priority_when_delegating + klazz = Class.new(ActiveRecord::Base) do + self.table_name = "topics" + scope :since, Proc.new { where('written_on >= ?', Time.now - 1.day) } + scope :to, Proc.new { where('written_on <= ?', Time.now) } + end + assert_equal klazz.to.since.to_a, klazz.since.to.to_a + end + + def test_scope_should_respond_to_own_methods_and_methods_of_the_proxy + assert Topic.approved.respond_to?(:limit) + assert Topic.approved.respond_to?(:count) + assert Topic.approved.respond_to?(:length) + end + + def test_scopes_with_options_limit_finds_to_those_matching_the_criteria_specified + assert !Topic.all.merge!(:where => {:approved => true}).to_a.empty? + + assert_equal Topic.all.merge!(:where => {:approved => true}).to_a, Topic.approved + assert_equal Topic.where(:approved => true).count, Topic.approved.count + end + + def test_scopes_with_string_name_can_be_composed + # NOTE that scopes defined with a string as a name worked on their own + # but when called on another scope the other scope was completely replaced + assert_equal Topic.replied.approved, Topic.replied.approved_as_string + end + + def test_scopes_are_composable + assert_equal((approved = Topic.all.merge!(:where => {:approved => true}).to_a), Topic.approved) + assert_equal((replied = Topic.all.merge!(:where => 'replies_count > 0').to_a), Topic.replied) + assert !(approved == replied) + assert !(approved & replied).empty? + + assert_equal approved & replied, Topic.approved.replied + end + + def test_procedural_scopes + topics_written_before_the_third = Topic.where('written_on < ?', topics(:third).written_on) + topics_written_before_the_second = Topic.where('written_on < ?', topics(:second).written_on) + assert_not_equal topics_written_before_the_second, topics_written_before_the_third + + assert_equal topics_written_before_the_third, Topic.written_before(topics(:third).written_on) + assert_equal topics_written_before_the_second, Topic.written_before(topics(:second).written_on) + end + + def test_procedural_scopes_returning_nil + all_topics = Topic.all + + assert_equal all_topics, Topic.written_before(nil) + end + + def test_scope_with_object + objects = Topic.with_object + assert_operator objects.length, :>, 0 + assert objects.all?(&:approved?), 'all objects should be approved' + end + + def test_has_many_associations_have_access_to_scopes + assert_not_equal Post.containing_the_letter_a, authors(:david).posts + assert !Post.containing_the_letter_a.empty? + + assert_equal authors(:david).posts & Post.containing_the_letter_a, authors(:david).posts.containing_the_letter_a + end + + def test_scope_with_STI + assert_equal 3,Post.containing_the_letter_a.count + assert_equal 1,SpecialPost.containing_the_letter_a.count + end + + def test_has_many_through_associations_have_access_to_scopes + assert_not_equal Comment.containing_the_letter_e, authors(:david).comments + assert !Comment.containing_the_letter_e.empty? + + assert_equal authors(:david).comments & Comment.containing_the_letter_e, authors(:david).comments.containing_the_letter_e + end + + def test_scopes_honor_current_scopes_from_when_defined + assert !Post.ranked_by_comments.limit_by(5).empty? + assert !authors(:david).posts.ranked_by_comments.limit_by(5).empty? + assert_not_equal Post.ranked_by_comments.limit_by(5), authors(:david).posts.ranked_by_comments.limit_by(5) + assert_not_equal Post.top(5), authors(:david).posts.top(5) + # Oracle sometimes sorts differently if WHERE condition is changed + assert_equal authors(:david).posts.ranked_by_comments.limit_by(5).to_a.sort_by(&:id), authors(:david).posts.top(5).to_a.sort_by(&:id) + assert_equal Post.ranked_by_comments.limit_by(5), Post.top(5) + end + + def test_active_records_have_scope_named__all__ + assert !Topic.all.empty? + + assert_equal Topic.all.to_a, Topic.base + end + + def test_active_records_have_scope_named__scoped__ + scope = Topic.where("content LIKE '%Have%'") + assert !scope.empty? + + assert_equal scope, Topic.all.merge!(where: "content LIKE '%Have%'") + end + + def test_first_and_last_should_allow_integers_for_limit + assert_equal Topic.base.first(2), Topic.base.to_a.first(2) + assert_equal Topic.base.last(2), Topic.base.order("id").to_a.last(2) + end + + def test_first_and_last_should_not_use_query_when_results_are_loaded + topics = Topic.base + topics.reload # force load + assert_no_queries do + topics.first + topics.last + end + end + + def test_empty_should_not_load_results + topics = Topic.base + assert_queries(2) do + topics.empty? # use count query + topics.collect # force load + topics.empty? # use loaded (no query) + 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_scope_loaded + topics = Topic.base + topics.collect # force load + assert_no_queries { assert topics.any? } + end + + def test_model_class_should_respond_to_any + assert Topic.any? + Topic.delete_all + assert !Topic.any? + end + + def test_many_should_not_load_results + topics = Topic.base + assert_queries(2) do + topics.many? # use count query + topics.collect # force load + topics.many? # use loaded (no query) + end + end + + def test_many_should_call_proxy_found_if_using_a_block + topics = Topic.base + assert_queries(1) do + topics.expects(:size).never + topics.many? { true } + end + end + + def test_many_should_not_fire_query_if_scope_loaded + topics = Topic.base + topics.collect # force load + assert_no_queries { assert topics.many? } + end + + def test_many_should_return_false_if_none_or_one + topics = Topic.base.where(:id => 0) + assert !topics.many? + topics = Topic.base.where(:id => 1) + assert !topics.many? + end + + def test_many_should_return_true_if_more_than_one + assert Topic.base.many? + end + + def test_model_class_should_respond_to_many + Topic.delete_all + assert !Topic.many? + Topic.create! + assert !Topic.many? + Topic.create! + assert Topic.many? + end + + def test_should_build_on_top_of_scope + topic = Topic.approved.build({}) + assert topic.approved + end + + def test_should_build_new_on_top_of_scope + topic = Topic.approved.new + assert topic.approved + end + + def test_should_create_on_top_of_scope + topic = Topic.approved.create({}) + assert topic.approved + end + + def test_should_create_with_bang_on_top_of_scope + topic = Topic.approved.create!({}) + assert topic.approved + end + + def test_should_build_on_top_of_chained_scopes + topic = Topic.approved.by_lifo.build({}) + assert topic.approved + assert_equal 'lifo', topic.author_name + end + + def test_reserved_scope_names + klass = Class.new(ActiveRecord::Base) do + self.table_name = "topics" + + scope :approved, -> { where(approved: true) } + + class << self + public + def pub; end + + private + def pri; end + + protected + def pro; end + end + end + + subklass = Class.new(klass) + + conflicts = [ + :create, # public class method on AR::Base + :relation, # private class method on AR::Base + :new, # redefined class method on AR::Base + :all, # a default scope + :public, + :protected, + :private + ] + + non_conflicts = [ + :find_by_title, # dynamic finder method + :approved, # existing scope + :pub, # existing public class method + :pri, # existing private class method + :pro, # existing protected class method + :open, # a ::Kernel method + ] + + conflicts.each do |name| + assert_raises(ArgumentError, "scope `#{name}` should not be allowed") do + klass.class_eval { scope name, ->{ where(approved: true) } } + end + + assert_raises(ArgumentError, "scope `#{name}` should not be allowed") do + subklass.class_eval { scope name, ->{ where(approved: true) } } + end + end + + non_conflicts.each do |name| + assert_nothing_raised do + silence_warnings do + klass.class_eval { scope name, ->{ where(approved: true) } } + end + end + + assert_nothing_raised do + subklass.class_eval { scope name, ->{ where(approved: true) } } + end + end + end + + # Method delegation for scope names which look like /\A[a-zA-Z_]\w*[!?]?\z/ + # has been done by evaluating a string with a plain def statement. For scope + # names which contain spaces this approach doesn't work. + def test_spaces_in_scope_names + klass = Class.new(ActiveRecord::Base) do + self.table_name = "topics" + scope :"title containing space", -> { where("title LIKE '% %'") } + scope :approved, -> { where(:approved => true) } + end + assert_equal klass.send(:"title containing space"), klass.where("title LIKE '% %'") + assert_equal klass.approved.send(:"title containing space"), klass.approved.where("title LIKE '% %'") + end + + def test_find_all_should_behave_like_select + assert_equal Topic.base.to_a.select(&:approved), Topic.base.to_a.find_all(&:approved) + end + + def test_rand_should_select_a_random_object_from_proxy + assert_kind_of Topic, Topic.approved.sample + end + + def test_should_use_where_in_query_for_scope + assert_equal Developer.where(name: 'Jamis').to_set, Developer.where(id: Developer.jamises).to_set + end + + def test_size_should_use_count_when_results_are_not_loaded + topics = Topic.base + assert_queries(1) do + assert_sql(/COUNT/i) { topics.size } + end + end + + def test_size_should_use_length_when_results_are_loaded + topics = Topic.base + topics.reload # force load + assert_no_queries do + topics.size # use loaded (no query) + end + end + + def test_should_not_duplicates_where_values + where_values = Topic.where("1=1").scope_with_lambda.where_values + assert_equal ["1=1"], where_values + end + + def test_chaining_with_duplicate_joins + join = "INNER JOIN comments ON comments.post_id = posts.id" + post = Post.find(1) + assert_equal post.comments.size, Post.joins(join).joins(join).where("posts.id = #{post.id}").size + end + + def test_chaining_applies_last_conditions_when_creating + post = Topic.rejected.new + assert !post.approved? + + post = Topic.rejected.approved.new + assert post.approved? + + post = Topic.approved.rejected.new + assert !post.approved? + + post = Topic.approved.rejected.approved.new + assert post.approved? + end + + def test_chaining_combines_conditions_when_searching + # Normal hash conditions + assert_equal Topic.where(approved: false).where(approved: true).to_a, Topic.rejected.approved.to_a + assert_equal Topic.where(approved: true).where(approved: false).to_a, Topic.approved.rejected.to_a + + # Nested hash conditions with same keys + assert_equal [], Post.with_special_comments.with_very_special_comments.to_a + + # Nested hash conditions with different keys + assert_equal [posts(:sti_comments)], Post.with_special_comments.with_post(4).to_a.uniq + end + + def test_scopes_batch_finders + assert_equal 4, Topic.approved.count + + assert_queries(5) do + Topic.approved.find_each(:batch_size => 1) {|t| assert t.approved? } + end + + assert_queries(3) do + Topic.approved.find_in_batches(:batch_size => 2) do |group| + group.each {|t| assert t.approved? } + end + end + end + + def test_table_names_for_chaining_scopes_with_and_without_table_name_included + assert_nothing_raised do + Comment.for_first_post.for_first_author.to_a + end + end + + def test_scopes_on_relations + # Topic.replied + approved_topics = Topic.all.approved.order('id DESC') + assert_equal topics(:fifth), approved_topics.first + + replied_approved_topics = approved_topics.replied + assert_equal topics(:third), replied_approved_topics.first + end + + def test_index_on_scope + approved = Topic.approved.order('id ASC') + assert_equal topics(:second), approved[0] + assert approved.loaded? + end + + def test_nested_scopes_queries_size + assert_queries(1) do + Topic.approved.by_lifo.replied.written_before(Time.now).to_a + end + end + + # Note: these next two are kinda odd because they are essentially just testing that the + # query cache works as it should, but they are here for legacy reasons as they was previously + # a separate cache on association proxies, and these show that that is not necessary. + def test_scopes_are_cached_on_associations + post = posts(:welcome) + + Post.cache do + assert_queries(1) { post.comments.containing_the_letter_e.to_a } + assert_no_queries { post.comments.containing_the_letter_e.to_a } + end + end + + def test_scopes_with_arguments_are_cached_on_associations + post = posts(:welcome) + + Post.cache do + one = assert_queries(1) { post.comments.limit_by(1).to_a } + assert_equal 1, one.size + + two = assert_queries(1) { post.comments.limit_by(2).to_a } + assert_equal 2, two.size + + assert_no_queries { post.comments.limit_by(1).to_a } + assert_no_queries { post.comments.limit_by(2).to_a } + end + end + + def test_scopes_to_get_newest + post = posts(:welcome) + old_last_comment = post.comments.newest + new_comment = post.comments.create(:body => "My new comment") + assert_equal new_comment, post.comments.newest + assert_not_equal old_last_comment, post.comments.newest + end + + def test_scopes_are_reset_on_association_reload + post = posts(:welcome) + + [:destroy_all, :reset, :delete_all].each do |method| + before = post.comments.containing_the_letter_e + post.association(:comments).send(method) + assert before.object_id != post.comments.containing_the_letter_e.object_id, "CollectionAssociation##{method} should reset the named scopes cache" + end + end + + def test_scoped_are_lazy_loaded_if_table_still_does_not_exist + assert_nothing_raised do + require "models/without_table" + end + end + + def test_eager_default_scope_relations_are_remove + klass = Class.new(ActiveRecord::Base) + klass.table_name = 'posts' + + assert_raises(ArgumentError) do + klass.send(:default_scope, klass.where(:id => posts(:welcome).id)) + end + end + + def test_subclass_merges_scopes_properly + assert_equal 1, SpecialComment.where(body: 'go crazy').created.count + end + +end diff --git a/activerecord/test/cases/scoping/relation_scoping_test.rb b/activerecord/test/cases/scoping/relation_scoping_test.rb new file mode 100644 index 0000000000..d8a467ec4d --- /dev/null +++ b/activerecord/test/cases/scoping/relation_scoping_test.rb @@ -0,0 +1,332 @@ +require "cases/helper" +require 'models/post' +require 'models/author' +require 'models/developer' +require 'models/project' +require 'models/comment' +require 'models/category' +require 'models/person' +require 'models/reference' + +class RelationScopingTest < ActiveRecord::TestCase + fixtures :authors, :developers, :projects, :comments, :posts, :developers_projects + + def test_reverse_order + assert_equal Developer.order("id DESC").to_a.reverse, Developer.order("id DESC").reverse_order + end + + def test_reverse_order_with_arel_node + assert_equal Developer.order("id DESC").to_a.reverse, Developer.order(Developer.arel_table[:id].desc).reverse_order + end + + def test_reverse_order_with_multiple_arel_nodes + assert_equal Developer.order("id DESC").order("name DESC").to_a.reverse, Developer.order(Developer.arel_table[:id].desc).order(Developer.arel_table[:name].desc).reverse_order + end + + def test_reverse_order_with_arel_nodes_and_strings + assert_equal Developer.order("id DESC").order("name DESC").to_a.reverse, Developer.order("id DESC").order(Developer.arel_table[:name].desc).reverse_order + end + + def test_double_reverse_order_produces_original_order + assert_equal Developer.order("name DESC"), Developer.order("name DESC").reverse_order.reverse_order + end + + def test_scoped_find + Developer.where("name = 'David'").scoping do + assert_nothing_raised { Developer.find(1) } + end + end + + def test_scoped_find_first + developer = Developer.find(10) + Developer.where("salary = 100000").scoping do + assert_equal developer, Developer.order("name").first + end + end + + def test_scoped_find_last + highest_salary = Developer.order("salary DESC").first + + Developer.order("salary").scoping do + assert_equal highest_salary, Developer.last + end + end + + def test_scoped_find_last_preserves_scope + lowest_salary = Developer.order("salary ASC").first + highest_salary = Developer.order("salary DESC").first + + Developer.order("salary").scoping do + assert_equal highest_salary, Developer.last + assert_equal lowest_salary, Developer.first + end + end + + def test_scoped_find_combines_and_sanitizes_conditions + Developer.where("salary = 9000").scoping do + assert_equal developers(:poor_jamis), Developer.where("name = 'Jamis'").first + end + end + + def test_scoped_find_all + Developer.where("name = 'David'").scoping do + assert_equal [developers(:david)], Developer.all + end + end + + def test_scoped_find_select + Developer.select("id, name").scoping do + developer = Developer.where("name = 'David'").first + assert_equal "David", developer.name + assert !developer.has_attribute?(:salary) + end + end + + def test_scope_select_concatenates + Developer.select("id, name").scoping do + developer = Developer.select('salary').where("name = 'David'").first + assert_equal 80000, developer.salary + assert developer.has_attribute?(:id) + assert developer.has_attribute?(:name) + assert developer.has_attribute?(:salary) + end + end + + def test_scoped_count + Developer.where("name = 'David'").scoping do + assert_equal 1, Developer.count + end + + Developer.where('salary = 100000').scoping do + assert_equal 8, Developer.count + assert_equal 1, Developer.where("name LIKE 'fixture_1%'").count + end + end + + def test_scoped_find_include + # with the include, will retrieve only developers for the given project + scoped_developers = Developer.includes(:projects).scoping do + Developer.where('projects.id' => 2).to_a + end + assert scoped_developers.include?(developers(:david)) + assert !scoped_developers.include?(developers(:jamis)) + assert_equal 1, scoped_developers.size + end + + def test_scoped_find_joins + scoped_developers = Developer.joins('JOIN developers_projects ON id = developer_id').scoping do + Developer.where('developers_projects.project_id = 2').to_a + end + + assert scoped_developers.include?(developers(:david)) + assert !scoped_developers.include?(developers(:jamis)) + assert_equal 1, scoped_developers.size + assert_equal developers(:david).attributes, scoped_developers.first.attributes + end + + def test_scoped_create_with_where + new_comment = VerySpecialComment.where(:post_id => 1).scoping do + VerySpecialComment.create :body => "Wonderful world" + end + + assert_equal 1, new_comment.post_id + assert Post.find(1).comments.include?(new_comment) + end + + def test_scoped_create_with_create_with + new_comment = VerySpecialComment.create_with(:post_id => 1).scoping do + VerySpecialComment.create :body => "Wonderful world" + end + + assert_equal 1, new_comment.post_id + assert Post.find(1).comments.include?(new_comment) + end + + def test_scoped_create_with_create_with_has_higher_priority + new_comment = VerySpecialComment.where(:post_id => 2).create_with(:post_id => 1).scoping do + VerySpecialComment.create :body => "Wonderful world" + end + + assert_equal 1, new_comment.post_id + assert Post.find(1).comments.include?(new_comment) + end + + def test_ensure_that_method_scoping_is_correctly_restored + begin + Developer.where("name = 'Jamis'").scoping do + raise "an exception" + end + rescue + end + + assert !Developer.all.where_values.include?("name = 'Jamis'") + end + + def test_default_scope_filters_on_joins + assert_equal 1, DeveloperFilteredOnJoins.all.count + assert_equal DeveloperFilteredOnJoins.all.first, developers(:david).becomes(DeveloperFilteredOnJoins) + end + + def test_update_all_default_scope_filters_on_joins + DeveloperFilteredOnJoins.update_all(:salary => 65000) + assert_equal 65000, Developer.find(developers(:david).id).salary + + # has not changed jamis + assert_not_equal 65000, Developer.find(developers(:jamis).id).salary + end + + def test_delete_all_default_scope_filters_on_joins + assert_not_equal [], DeveloperFilteredOnJoins.all + + DeveloperFilteredOnJoins.delete_all() + + assert_equal [], DeveloperFilteredOnJoins.all + assert_not_equal [], Developer.all + end +end + +class NestedRelationScopingTest < ActiveRecord::TestCase + fixtures :authors, :developers, :projects, :comments, :posts + + def test_merge_options + Developer.where('salary = 80000').scoping do + Developer.limit(10).scoping do + devs = Developer.all + sql = devs.to_sql + assert_match '(salary = 80000)', sql + assert_match 'LIMIT 10', sql + end + end + end + + def test_merge_inner_scope_has_priority + Developer.limit(5).scoping do + Developer.limit(10).scoping do + assert_equal 10, Developer.all.size + end + end + end + + def test_replace_options + Developer.where(:name => 'David').scoping do + Developer.unscoped do + assert_equal 'Jamis', Developer.where(:name => 'Jamis').first[:name] + end + + assert_equal 'David', Developer.first[:name] + end + end + + def test_three_level_nested_exclusive_scoped_find + Developer.where("name = 'Jamis'").scoping do + assert_equal 'Jamis', Developer.first.name + + Developer.unscoped.where("name = 'David'") do + assert_equal 'David', Developer.first.name + + Developer.unscoped.where("name = 'Maiha'") do + assert_equal nil, Developer.first + end + + # ensure that scoping is restored + assert_equal 'David', Developer.first.name + end + + # ensure that scoping is restored + assert_equal 'Jamis', Developer.first.name + end + end + + def test_nested_scoped_create + comment = Comment.create_with(:post_id => 1).scoping do + Comment.create_with(:post_id => 2).scoping do + Comment.create :body => "Hey guys, nested scopes are broken. Please fix!" + end + end + + assert_equal 2, comment.post_id + end + + def test_nested_exclusive_scope_for_create + comment = Comment.create_with(:body => "Hey guys, nested scopes are broken. Please fix!").scoping do + Comment.unscoped.create_with(:post_id => 1).scoping do + assert Comment.new.body.blank? + Comment.create :body => "Hey guys" + end + end + + assert_equal 1, comment.post_id + assert_equal 'Hey guys', comment.body + end +end + +class HasManyScopingTest< ActiveRecord::TestCase + fixtures :comments, :posts, :people, :references + + def setup + @welcome = Post.find(1) + end + + def test_forwarding_of_static_methods + assert_equal 'a comment...', Comment.what_are_you + assert_equal 'a comment...', @welcome.comments.what_are_you + end + + def test_forwarding_to_scoped + assert_equal 4, Comment.search_by_type('Comment').size + assert_equal 2, @welcome.comments.search_by_type('Comment').size + end + + def test_nested_scope_finder + Comment.where('1=0').scoping do + assert_equal 0, @welcome.comments.count + assert_equal 'a comment...', @welcome.comments.what_are_you + end + + Comment.where('1=1').scoping do + assert_equal 2, @welcome.comments.count + assert_equal 'a comment...', @welcome.comments.what_are_you + end + end + + def test_should_maintain_default_scope_on_associations + magician = BadReference.find(1) + assert_equal [magician], people(:michael).bad_references + end + + def test_should_default_scope_on_associations_is_overridden_by_association_conditions + reference = references(:michael_unicyclist).becomes(BadReference) + assert_equal [reference], people(:michael).fixed_bad_references + end + + def test_should_maintain_default_scope_on_eager_loaded_associations + michael = Person.where(:id => people(:michael).id).includes(:bad_references).first + magician = BadReference.find(1) + assert_equal [magician], michael.bad_references + end +end + +class HasAndBelongsToManyScopingTest< ActiveRecord::TestCase + fixtures :posts, :categories, :categories_posts + + def setup + @welcome = Post.find(1) + end + + def test_forwarding_of_static_methods + assert_equal 'a category...', Category.what_are_you + assert_equal 'a category...', @welcome.categories.what_are_you + end + + def test_nested_scope_finder + Category.where('1=0').scoping do + assert_equal 0, @welcome.categories.count + assert_equal 'a category...', @welcome.categories.what_are_you + end + + Category.where('1=1').scoping do + assert_equal 2, @welcome.categories.count + assert_equal 'a category...', @welcome.categories.what_are_you + end + end +end diff --git a/activerecord/test/cases/serialization_test.rb b/activerecord/test/cases/serialization_test.rb new file mode 100644 index 0000000000..3f52e80e11 --- /dev/null +++ b/activerecord/test/cases/serialization_test.rb @@ -0,0 +1,95 @@ +require "cases/helper" +require 'models/contact' +require 'models/topic' +require 'models/book' + +class SerializationTest < ActiveRecord::TestCase + fixtures :books + + FORMATS = [ :xml, :json ] + + def setup + @contact_attributes = { + :name => 'aaron stack', + :age => 25, + :avatar => 'binarydata', + :created_at => Time.utc(2006, 8, 1), + :awesome => false, + :preferences => { :gem => '<strong>ruby</strong>' }, + :alternative_id => nil, + :id => nil + } + end + + def test_include_root_in_json_is_false_by_default + assert_equal false, ActiveRecord::Base.include_root_in_json, "include_root_in_json should be false by default but was not" + end + + def test_serialize_should_be_reversible + FORMATS.each do |format| + @serialized = Contact.new.send("to_#{format}") + contact = Contact.new.send("from_#{format}", @serialized) + + assert_equal @contact_attributes.keys.collect(&:to_s).sort, contact.attributes.keys.collect(&:to_s).sort, "For #{format}" + end + end + + def test_serialize_should_allow_attribute_only_filtering + FORMATS.each do |format| + @serialized = Contact.new(@contact_attributes).send("to_#{format}", :only => [ :age, :name ]) + contact = Contact.new.send("from_#{format}", @serialized) + assert_equal @contact_attributes[:name], contact.name, "For #{format}" + assert_nil contact.avatar, "For #{format}" + end + end + + def test_serialize_should_allow_attribute_except_filtering + FORMATS.each do |format| + @serialized = Contact.new(@contact_attributes).send("to_#{format}", :except => [ :age, :name ]) + contact = Contact.new.send("from_#{format}", @serialized) + assert_nil contact.name, "For #{format}" + assert_nil contact.age, "For #{format}" + assert_equal @contact_attributes[:awesome], contact.awesome, "For #{format}" + end + end + + def test_include_root_in_json_allows_inheritance + original_root_in_json = ActiveRecord::Base.include_root_in_json + ActiveRecord::Base.include_root_in_json = true + + klazz = Class.new(ActiveRecord::Base) + klazz.table_name = 'topics' + assert klazz.include_root_in_json + + klazz.include_root_in_json = false + assert ActiveRecord::Base.include_root_in_json + assert !klazz.include_root_in_json + assert !klazz.new.include_root_in_json + ensure + ActiveRecord::Base.include_root_in_json = original_root_in_json + end + + def test_read_attribute_for_serialization_with_format_without_method_missing + klazz = Class.new(ActiveRecord::Base) + klazz.table_name = 'books' + + book = klazz.new + assert_nil book.read_attribute_for_serialization(:format) + end + + def test_read_attribute_for_serialization_with_format_after_init + klazz = Class.new(ActiveRecord::Base) + klazz.table_name = 'books' + + book = klazz.new(format: 'paperback') + assert_equal 'paperback', book.read_attribute_for_serialization(:format) + end + + def test_read_attribute_for_serialization_with_format_after_find + klazz = Class.new(ActiveRecord::Base) + klazz.table_name = 'books' + + book = klazz.find(books(:awdr).id) + assert_equal 'paperback', book.read_attribute_for_serialization(:format) + end +end diff --git a/activerecord/test/cases/serialized_attribute_test.rb b/activerecord/test/cases/serialized_attribute_test.rb new file mode 100644 index 0000000000..f8d87a3661 --- /dev/null +++ b/activerecord/test/cases/serialized_attribute_test.rb @@ -0,0 +1,258 @@ +require 'cases/helper' +require 'models/topic' +require 'models/reply' +require 'models/person' +require 'models/traffic_light' +require 'models/post' +require 'bcrypt' + +class SerializedAttributeTest < ActiveRecord::TestCase + fixtures :topics, :posts + + MyObject = Struct.new :attribute1, :attribute2 + + teardown do + Topic.serialize("content") + end + + def test_serialize_does_not_eagerly_load_columns + assert_no_queries do + Topic.reset_column_information + Topic.serialize(:content) + end + end + + def test_list_of_serialized_attributes + assert_deprecated do + assert_equal %w(content), Topic.serialized_attributes.keys + end + end + + def test_serialized_attribute + Topic.serialize("content", MyObject) + + myobj = MyObject.new('value1', 'value2') + topic = Topic.create("content" => myobj) + assert_equal(myobj, topic.content) + + topic.reload + assert_equal(myobj, topic.content) + end + + def test_serialized_attribute_in_base_class + Topic.serialize("content", Hash) + + hash = { 'content1' => 'value1', 'content2' => 'value2' } + important_topic = ImportantTopic.create("content" => hash) + assert_equal(hash, important_topic.content) + + important_topic.reload + assert_equal(hash, important_topic.content) + end + + def test_serialized_attributes_from_database_on_subclass + Topic.serialize :content, Hash + + t = Reply.new(content: { foo: :bar }) + assert_equal({ foo: :bar }, t.content) + t.save! + t = Reply.last + assert_equal({ foo: :bar }, t.content) + end + + def test_serialized_attribute_calling_dup_method + Topic.serialize :content, JSON + + orig = Topic.new(content: { foo: :bar }) + clone = orig.dup + assert_equal(orig.content, clone.content) + end + + def test_serialized_json_attribute_returns_unserialized_value + Topic.serialize :content, JSON + my_post = posts(:welcome) + + t = Topic.new(content: my_post) + t.save! + t.reload + + assert_instance_of(Hash, t.content) + assert_equal(my_post.id, t.content["id"]) + assert_equal(my_post.title, t.content["title"]) + end + + def test_json_read_legacy_null + Topic.serialize :content, JSON + + # Force a row to have a JSON "null" instead of a database NULL (this is how + # null values are saved on 4.1 and before) + id = Topic.connection.insert "INSERT INTO topics (content) VALUES('null')" + t = Topic.find(id) + + assert_nil t.content + end + + def test_json_read_db_null + Topic.serialize :content, JSON + + # Force a row to have a database NULL instead of a JSON "null" + id = Topic.connection.insert "INSERT INTO topics (content) VALUES(NULL)" + t = Topic.find(id) + + assert_nil t.content + end + + def test_serialized_attribute_declared_in_subclass + hash = { 'important1' => 'value1', 'important2' => 'value2' } + important_topic = ImportantTopic.create("important" => hash) + assert_equal(hash, important_topic.important) + + important_topic.reload + assert_equal(hash, important_topic.important) + assert_equal(hash, important_topic.read_attribute(:important)) + 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_serialized_string_attribute + myobj = "Yes" + topic = Topic.create("content" => myobj).reload + assert_equal(myobj, topic.content) + end + + def test_nil_serialized_attribute_without_class_constraint + topic = Topic.new + assert_nil topic.content + end + + def test_nil_not_serialized_without_class_constraint + assert Topic.new(:content => nil).save + assert_equal 1, Topic.where(:content => nil).count + end + + def test_nil_not_serialized_with_class_constraint + Topic.serialize :content, Hash + assert Topic.new(:content => nil).save + assert_equal 1, Topic.where(:content => nil).count + end + + def test_serialized_attribute_should_raise_exception_on_save_with_wrong_type + Topic.serialize(:content, Hash) + assert_raise(ActiveRecord::SerializationTypeMismatch) do + topic = Topic.new(content: 'string') + topic.save + end + end + + def test_should_raise_exception_on_serialized_attribute_with_type_mismatch + myobj = MyObject.new('value1', 'value2') + topic = Topic.new(:content => myobj) + assert topic.save + Topic.serialize(:content, Hash) + assert_raise(ActiveRecord::SerializationTypeMismatch) { Topic.find(topic.id).content } + end + + def test_serialized_attribute_with_class_constraint + settings = { "color" => "blue" } + Topic.serialize(:content, Hash) + topic = Topic.new(:content => settings) + assert topic.save + assert_equal(settings, Topic.find(topic.id).content) + end + + def test_serialized_default_class + Topic.serialize(:content, Hash) + topic = Topic.new + assert_equal Hash, topic.content.class + assert_equal Hash, topic.read_attribute(:content).class + topic.content["beer"] = "MadridRb" + assert topic.save + topic.reload + assert_equal Hash, topic.content.class + assert_equal "MadridRb", topic.content["beer"] + end + + def test_serialized_no_default_class_for_object + topic = Topic.new + assert_nil topic.content + end + + def test_serialized_boolean_value_true + topic = Topic.new(:content => true) + assert topic.save + topic = topic.reload + assert_equal topic.content, true + end + + def test_serialized_boolean_value_false + topic = Topic.new(:content => false) + assert topic.save + topic = topic.reload + assert_equal topic.content, false + end + + def test_serialize_with_coder + some_class = Struct.new(:foo) do + def self.dump(value) + value.foo + end + + def self.load(value) + new(value) + end + end + + Topic.serialize(:content, some_class) + topic = Topic.new(:content => some_class.new('my value')) + topic.save! + topic.reload + assert_kind_of some_class, topic.content + assert_equal topic.content, some_class.new('my value') + end + + def test_serialize_attribute_via_select_method_when_time_zone_available + with_timezone_config aware_attributes: true do + Topic.serialize(:content, MyObject) + + myobj = MyObject.new('value1', 'value2') + topic = Topic.create(content: myobj) + + assert_equal(myobj, Topic.select(:content).find(topic.id).content) + assert_raise(ActiveModel::MissingAttributeError) { Topic.select(:id).find(topic.id).content } + end + end + + def test_serialize_attribute_can_be_serialized_in_an_integer_column + insures = ['life'] + person = SerializedPerson.new(first_name: 'David', insures: insures) + assert person.save + person = person.reload + assert_equal(insures, person.insures) + end + + def test_regression_serialized_default_on_text_column_with_null_false + light = TrafficLight.new + assert_equal [], light.state + assert_equal [], light.long_state + end + + def test_serialized_column_should_unserialize_after_update_column + t = Topic.create(content: "first") + assert_equal("first", t.content) + + t.update_column(:content, Topic.type_for_attribute('content').type_cast_for_database("second")) + assert_equal("second", t.content) + end + + def test_serialized_column_should_unserialize_after_update_attribute + t = Topic.create(content: "first") + assert_equal("first", t.content) + + t.update_attribute(:content, "second") + assert_equal("second", t.content) + end +end diff --git a/activerecord/test/cases/statement_cache_test.rb b/activerecord/test/cases/statement_cache_test.rb new file mode 100644 index 0000000000..a704b861cb --- /dev/null +++ b/activerecord/test/cases/statement_cache_test.rb @@ -0,0 +1,98 @@ +require 'cases/helper' +require 'models/book' +require 'models/liquid' +require 'models/molecule' +require 'models/electron' + +module ActiveRecord + class StatementCacheTest < ActiveRecord::TestCase + def setup + @connection = ActiveRecord::Base.connection + end + + #Cache v 1.1 tests + def test_statement_cache + Book.create(name: "my book") + Book.create(name: "my other book") + + cache = StatementCache.create(Book.connection) do |params| + Book.where(:name => params.bind) + end + + b = cache.execute([ "my book" ], Book, Book.connection) + assert_equal "my book", b[0].name + b = cache.execute([ "my other book" ], Book, Book.connection) + assert_equal "my other book", b[0].name + end + + + def test_statement_cache_id + b1 = Book.create(name: "my book") + b2 = Book.create(name: "my other book") + + cache = StatementCache.create(Book.connection) do |params| + Book.where(id: params.bind) + end + + b = cache.execute([ b1.id ], Book, Book.connection) + assert_equal b1.name, b[0].name + b = cache.execute([ b2.id ], Book, Book.connection) + assert_equal b2.name, b[0].name + end + + def test_find_or_create_by + Book.create(name: "my book") + + a = Book.find_or_create_by(name: "my book") + b = Book.find_or_create_by(name: "my other book") + + assert_equal("my book", a.name) + assert_equal("my other book", b.name) + end + + #End + + def test_statement_cache_with_simple_statement + cache = ActiveRecord::StatementCache.create(Book.connection) do |params| + Book.where(name: "my book").where("author_id > 3") + end + + Book.create(name: "my book", author_id: 4) + + books = cache.execute([], Book, Book.connection) + assert_equal "my book", books[0].name + end + + def test_statement_cache_with_complex_statement + cache = ActiveRecord::StatementCache.create(Book.connection) do |params| + Liquid.joins(:molecules => :electrons).where('molecules.name' => 'dioxane', 'electrons.name' => 'lepton') + end + + salty = Liquid.create(name: 'salty') + molecule = salty.molecules.create(name: 'dioxane') + molecule.electrons.create(name: 'lepton') + + liquids = cache.execute([], Book, Book.connection) + assert_equal "salty", liquids[0].name + end + + def test_statement_cache_values_differ + cache = ActiveRecord::StatementCache.create(Book.connection) do |params| + Book.where(name: "my book") + end + + 3.times do + Book.create(name: "my book") + end + + first_books = cache.execute([], Book, Book.connection) + + 3.times do + Book.create(name: "my book") + end + + additional_books = cache.execute([], Book, Book.connection) + assert first_books != additional_books + end + end +end diff --git a/activerecord/test/cases/store_test.rb b/activerecord/test/cases/store_test.rb new file mode 100644 index 0000000000..e9cdf94c99 --- /dev/null +++ b/activerecord/test/cases/store_test.rb @@ -0,0 +1,194 @@ +require 'cases/helper' +require 'models/admin' +require 'models/admin/user' + +class StoreTest < ActiveRecord::TestCase + fixtures :'admin/users' + + setup do + @john = Admin::User.create!(:name => 'John Doe', :color => 'black', :remember_login => true, :height => 'tall', :is_a_good_guy => true) + end + + test "reading store attributes through accessors" do + assert_equal 'black', @john.color + assert_nil @john.homepage + end + + test "writing store attributes through accessors" do + @john.color = 'red' + @john.homepage = '37signals.com' + + assert_equal 'red', @john.color + assert_equal '37signals.com', @john.homepage + end + + test "accessing attributes not exposed by accessors" do + @john.settings[:icecream] = 'graeters' + @john.save + + assert_equal 'graeters', @john.reload.settings[:icecream] + end + + test "overriding a read accessor" do + @john.settings[:phone_number] = '1234567890' + + assert_equal '(123) 456-7890', @john.phone_number + end + + test "overriding a read accessor using super" do + @john.settings[:color] = nil + + assert_equal 'red', @john.color + end + + test "updating the store will mark it as changed" do + @john.color = 'red' + assert @john.settings_changed? + end + + test "updating the store populates the changed array correctly" do + @john.color = 'red' + assert_equal 'black', @john.settings_change[0]['color'] + assert_equal 'red', @john.settings_change[1]['color'] + end + + test "updating the store won't mark it as changed if an attribute isn't changed" do + @john.color = @john.color + assert !@john.settings_changed? + end + + test "object initialization with not nullable column" do + assert_equal true, @john.remember_login + end + + test "writing with not nullable column" do + @john.remember_login = false + assert_equal false, @john.remember_login + end + + test "overriding a write accessor" do + @john.phone_number = '(123) 456-7890' + + assert_equal '1234567890', @john.settings[:phone_number] + end + + test "overriding a write accessor using super" do + @john.color = 'yellow' + + assert_equal 'blue', @john.color + end + + test "preserve store attributes data in HashWithIndifferentAccess format without any conversion" do + @john.json_data = ActiveSupport::HashWithIndifferentAccess.new(:height => 'tall', 'weight' => 'heavy') + @john.height = 'low' + assert_equal true, @john.json_data.instance_of?(ActiveSupport::HashWithIndifferentAccess) + assert_equal 'low', @john.json_data[:height] + assert_equal 'low', @john.json_data['height'] + assert_equal 'heavy', @john.json_data[:weight] + assert_equal 'heavy', @john.json_data['weight'] + end + + test "convert store attributes from Hash to HashWithIndifferentAccess saving the data and access attributes indifferently" do + user = Admin::User.find_by_name('Jamis') + assert_equal 'symbol', user.settings[:symbol] + assert_equal 'symbol', user.settings['symbol'] + assert_equal 'string', user.settings[:string] + assert_equal 'string', user.settings['string'] + assert_equal true, user.settings.instance_of?(ActiveSupport::HashWithIndifferentAccess) + + user.height = 'low' + assert_equal 'symbol', user.settings[:symbol] + assert_equal 'symbol', user.settings['symbol'] + assert_equal 'string', user.settings[:string] + assert_equal 'string', user.settings['string'] + assert_equal true, user.settings.instance_of?(ActiveSupport::HashWithIndifferentAccess) + end + + test "convert store attributes from any format other than Hash or HashWithIndifferent access losing the data" do + @john.json_data = "somedata" + @john.height = 'low' + assert_equal true, @john.json_data.instance_of?(ActiveSupport::HashWithIndifferentAccess) + assert_equal 'low', @john.json_data[:height] + assert_equal 'low', @john.json_data['height'] + assert_equal false, @john.json_data.delete_if { |k, v| k == 'height' }.any? + end + + test "reading store attributes through accessors encoded with JSON" do + assert_equal 'tall', @john.height + assert_nil @john.weight + end + + test "writing store attributes through accessors encoded with JSON" do + @john.height = 'short' + @john.weight = 'heavy' + + assert_equal 'short', @john.height + assert_equal 'heavy', @john.weight + end + + test "accessing attributes not exposed by accessors encoded with JSON" do + @john.json_data['somestuff'] = 'somecoolstuff' + @john.save + + assert_equal 'somecoolstuff', @john.reload.json_data['somestuff'] + end + + test "updating the store will mark it as changed encoded with JSON" do + @john.height = 'short' + assert @john.json_data_changed? + end + + test "object initialization with not nullable column encoded with JSON" do + assert_equal true, @john.is_a_good_guy + end + + test "writing with not nullable column encoded with JSON" do + @john.is_a_good_guy = false + assert_equal false, @john.is_a_good_guy + end + + test "all stored attributes are returned" do + assert_equal [:color, :homepage, :favorite_food], Admin::User.stored_attributes[:settings] + end + + test "stored_attributes are tracked per class" do + first_model = Class.new(ActiveRecord::Base) do + store_accessor :data, :color + end + second_model = Class.new(ActiveRecord::Base) do + store_accessor :data, :width, :height + end + + assert_equal [:color], first_model.stored_attributes[:data] + assert_equal [:width, :height], second_model.stored_attributes[:data] + end + + test "stored_attributes are tracked per subclass" do + first_model = Class.new(ActiveRecord::Base) do + store_accessor :data, :color + end + second_model = Class.new(first_model) do + store_accessor :data, :width, :height + end + third_model = Class.new(first_model) do + store_accessor :data, :area, :volume + end + + assert_equal [:color], first_model.stored_attributes[:data] + assert_equal [:color, :width, :height], second_model.stored_attributes[:data] + assert_equal [:color, :area, :volume], third_model.stored_attributes[:data] + end + + test "YAML coder initializes the store when a Nil value is given" do + assert_equal({}, @john.params) + end + + test "dump, load and dump again a model" do + dumped = YAML.dump(@john) + loaded = YAML.load(dumped) + assert_equal @john, loaded + + second_dump = YAML.dump(loaded) + assert_equal @john, YAML.load(second_dump) + end +end diff --git a/activerecord/test/cases/tasks/database_tasks_test.rb b/activerecord/test/cases/tasks/database_tasks_test.rb new file mode 100644 index 0000000000..01d373b691 --- /dev/null +++ b/activerecord/test/cases/tasks/database_tasks_test.rb @@ -0,0 +1,379 @@ +require 'cases/helper' +require 'active_record/tasks/database_tasks' + +module ActiveRecord + module DatabaseTasksSetupper + def setup + @mysql_tasks, @postgresql_tasks, @sqlite_tasks = stub, stub, stub + ActiveRecord::Tasks::MySQLDatabaseTasks.stubs(:new).returns @mysql_tasks + ActiveRecord::Tasks::PostgreSQLDatabaseTasks.stubs(:new).returns @postgresql_tasks + ActiveRecord::Tasks::SQLiteDatabaseTasks.stubs(:new).returns @sqlite_tasks + end + end + + ADAPTERS_TASKS = { + mysql: :mysql_tasks, + mysql2: :mysql_tasks, + postgresql: :postgresql_tasks, + sqlite3: :sqlite_tasks + } + + class DatabaseTasksRegisterTask < ActiveRecord::TestCase + def test_register_task + klazz = Class.new do + def initialize(*arguments); end + def structure_dump(filename); end + end + instance = klazz.new + + klazz.stubs(:new).returns instance + instance.expects(:structure_dump).with("awesome-file.sql") + + ActiveRecord::Tasks::DatabaseTasks.register_task(/foo/, klazz) + ActiveRecord::Tasks::DatabaseTasks.structure_dump({'adapter' => :foo}, "awesome-file.sql") + end + + def test_unregistered_task + assert_raise(ActiveRecord::Tasks::DatabaseNotSupported) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump({'adapter' => :bar}, "awesome-file.sql") + end + end + end + + class DatabaseTasksCreateTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_create") do + eval("@#{v}").expects(:create) + ActiveRecord::Tasks::DatabaseTasks.create 'adapter' => k + end + end + end + + class DatabaseTasksCreateAllTest < ActiveRecord::TestCase + def setup + @configurations = {'development' => {'database' => 'my-db'}} + + ActiveRecord::Base.stubs(:configurations).returns(@configurations) + end + + def test_ignores_configurations_without_databases + @configurations['development'].merge!('database' => nil) + + ActiveRecord::Tasks::DatabaseTasks.expects(:create).never + + ActiveRecord::Tasks::DatabaseTasks.create_all + end + + def test_ignores_remote_databases + @configurations['development'].merge!('host' => 'my.server.tld') + $stderr.stubs(:puts).returns(nil) + + ActiveRecord::Tasks::DatabaseTasks.expects(:create).never + + ActiveRecord::Tasks::DatabaseTasks.create_all + end + + def test_warning_for_remote_databases + @configurations['development'].merge!('host' => 'my.server.tld') + + $stderr.expects(:puts).with('This task only modifies local databases. my-db is on a remote host.') + + ActiveRecord::Tasks::DatabaseTasks.create_all + end + + def test_creates_configurations_with_local_ip + @configurations['development'].merge!('host' => '127.0.0.1') + + ActiveRecord::Tasks::DatabaseTasks.expects(:create) + + ActiveRecord::Tasks::DatabaseTasks.create_all + end + + def test_creates_configurations_with_local_host + @configurations['development'].merge!('host' => 'localhost') + + ActiveRecord::Tasks::DatabaseTasks.expects(:create) + + ActiveRecord::Tasks::DatabaseTasks.create_all + end + + def test_creates_configurations_with_blank_hosts + @configurations['development'].merge!('host' => nil) + + ActiveRecord::Tasks::DatabaseTasks.expects(:create) + + ActiveRecord::Tasks::DatabaseTasks.create_all + end + end + + class DatabaseTasksCreateCurrentTest < ActiveRecord::TestCase + def setup + @configurations = { + 'development' => {'database' => 'dev-db'}, + 'test' => {'database' => 'test-db'}, + 'production' => {'database' => 'prod-db'} + } + + ActiveRecord::Base.stubs(:configurations).returns(@configurations) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_creates_current_environment_database + ActiveRecord::Tasks::DatabaseTasks.expects(:create). + with('database' => 'prod-db') + + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new('production') + ) + end + + def test_creates_test_and_development_databases_when_env_was_not_specified + ActiveRecord::Tasks::DatabaseTasks.expects(:create). + with('database' => 'dev-db') + ActiveRecord::Tasks::DatabaseTasks.expects(:create). + with('database' => 'test-db') + ENV.expects(:[]).with('RAILS_ENV').returns(nil) + + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new('development') + ) + end + + def test_creates_only_development_database_when_rails_env_is_development + ActiveRecord::Tasks::DatabaseTasks.expects(:create). + with('database' => 'dev-db') + ENV.expects(:[]).with('RAILS_ENV').returns('development') + + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new('development') + ) + end + + def test_establishes_connection_for_the_given_environment + ActiveRecord::Tasks::DatabaseTasks.stubs(:create).returns true + + ActiveRecord::Base.expects(:establish_connection).with(:development) + + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new('development') + ) + end + end + + class DatabaseTasksDropTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_drop") do + eval("@#{v}").expects(:drop) + ActiveRecord::Tasks::DatabaseTasks.drop 'adapter' => k + end + end + end + + class DatabaseTasksDropAllTest < ActiveRecord::TestCase + def setup + @configurations = {:development => {'database' => 'my-db'}} + + ActiveRecord::Base.stubs(:configurations).returns(@configurations) + end + + def test_ignores_configurations_without_databases + @configurations[:development].merge!('database' => nil) + + ActiveRecord::Tasks::DatabaseTasks.expects(:drop).never + + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + + def test_ignores_remote_databases + @configurations[:development].merge!('host' => 'my.server.tld') + $stderr.stubs(:puts).returns(nil) + + ActiveRecord::Tasks::DatabaseTasks.expects(:drop).never + + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + + def test_warning_for_remote_databases + @configurations[:development].merge!('host' => 'my.server.tld') + + $stderr.expects(:puts).with('This task only modifies local databases. my-db is on a remote host.') + + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + + def test_drops_configurations_with_local_ip + @configurations[:development].merge!('host' => '127.0.0.1') + + ActiveRecord::Tasks::DatabaseTasks.expects(:drop) + + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + + def test_drops_configurations_with_local_host + @configurations[:development].merge!('host' => 'localhost') + + ActiveRecord::Tasks::DatabaseTasks.expects(:drop) + + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + + def test_drops_configurations_with_blank_hosts + @configurations[:development].merge!('host' => nil) + + ActiveRecord::Tasks::DatabaseTasks.expects(:drop) + + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + end + + class DatabaseTasksDropCurrentTest < ActiveRecord::TestCase + def setup + @configurations = { + 'development' => {'database' => 'dev-db'}, + 'test' => {'database' => 'test-db'}, + 'production' => {'database' => 'prod-db'} + } + + ActiveRecord::Base.stubs(:configurations).returns(@configurations) + end + + def test_drops_current_environment_database + ActiveRecord::Tasks::DatabaseTasks.expects(:drop). + with('database' => 'prod-db') + + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new('production') + ) + end + + def test_drops_test_and_development_databases_when_env_was_not_specified + ActiveRecord::Tasks::DatabaseTasks.expects(:drop). + with('database' => 'dev-db') + ActiveRecord::Tasks::DatabaseTasks.expects(:drop). + with('database' => 'test-db') + ENV.expects(:[]).with('RAILS_ENV').returns(nil) + + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new('development') + ) + end + + def test_drops_only_development_database_when_rails_env_is_development + ActiveRecord::Tasks::DatabaseTasks.expects(:drop). + with('database' => 'dev-db') + ENV.expects(:[]).with('RAILS_ENV').returns('development') + + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new('development') + ) + end + end + + class DatabaseTasksMigrateTest < ActiveRecord::TestCase + def test_migrate_receives_correct_env_vars + verbose, version = ENV['VERBOSE'], ENV['VERSION'] + + ENV['VERBOSE'] = 'false' + ENV['VERSION'] = '4' + + ActiveRecord::Migrator.expects(:migrate).with(ActiveRecord::Migrator.migrations_paths, 4) + ActiveRecord::Tasks::DatabaseTasks.migrate + ensure + ENV['VERBOSE'], ENV['VERSION'] = verbose, version + end + end + + class DatabaseTasksPurgeTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_purge") do + eval("@#{v}").expects(:purge) + ActiveRecord::Tasks::DatabaseTasks.purge 'adapter' => k + end + end + end + + class DatabaseTasksPurgeCurrentTest < ActiveRecord::TestCase + def test_purges_current_environment_database + configurations = { + 'development' => {'database' => 'dev-db'}, + 'test' => {'database' => 'test-db'}, + 'production' => {'database' => 'prod-db'} + } + ActiveRecord::Base.stubs(:configurations).returns(configurations) + + ActiveRecord::Tasks::DatabaseTasks.expects(:purge). + with('database' => 'prod-db') + + ActiveRecord::Tasks::DatabaseTasks.purge_current('production') + end + end + + class DatabaseTasksPurgeAllTest < ActiveRecord::TestCase + def test_purge_all_local_configurations + configurations = {:development => {'database' => 'my-db'}} + ActiveRecord::Base.stubs(:configurations).returns(configurations) + + ActiveRecord::Tasks::DatabaseTasks.expects(:purge). + with('database' => 'my-db') + + ActiveRecord::Tasks::DatabaseTasks.purge_all + end + end + + class DatabaseTasksCharsetTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_charset") do + eval("@#{v}").expects(:charset) + ActiveRecord::Tasks::DatabaseTasks.charset 'adapter' => k + end + end + end + + class DatabaseTasksCollationTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_collation") do + eval("@#{v}").expects(:collation) + ActiveRecord::Tasks::DatabaseTasks.collation 'adapter' => k + end + end + end + + class DatabaseTasksStructureDumpTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_structure_dump") do + eval("@#{v}").expects(:structure_dump).with("awesome-file.sql") + ActiveRecord::Tasks::DatabaseTasks.structure_dump({'adapter' => k}, "awesome-file.sql") + end + end + end + + class DatabaseTasksStructureLoadTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_structure_load") do + eval("@#{v}").expects(:structure_load).with("awesome-file.sql") + ActiveRecord::Tasks::DatabaseTasks.structure_load({'adapter' => k}, "awesome-file.sql") + end + end + end + + class DatabaseTasksCheckSchemaFileTest < ActiveRecord::TestCase + def test_check_schema_file + Kernel.expects(:abort).with(regexp_matches(/awesome-file.sql/)) + ActiveRecord::Tasks::DatabaseTasks.check_schema_file("awesome-file.sql") + end + end +end diff --git a/activerecord/test/cases/tasks/mysql_rake_test.rb b/activerecord/test/cases/tasks/mysql_rake_test.rb new file mode 100644 index 0000000000..f58535f044 --- /dev/null +++ b/activerecord/test/cases/tasks/mysql_rake_test.rb @@ -0,0 +1,311 @@ +require 'cases/helper' + +if current_adapter?(:MysqlAdapter, :Mysql2Adapter) +module ActiveRecord + class MysqlDBCreateTest < ActiveRecord::TestCase + def setup + @connection = stub(:create_database => true) + @configuration = { + 'adapter' => 'mysql', + 'database' => 'my-app-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_establishes_connection_without_database + ActiveRecord::Base.expects(:establish_connection). + with('adapter' => 'mysql', 'database' => nil) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_creates_database_with_default_encoding_and_collation + @connection.expects(:create_database). + with('my-app-db', charset: 'utf8', collation: 'utf8_unicode_ci') + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_creates_database_with_given_encoding_and_default_collation + @connection.expects(:create_database). + with('my-app-db', charset: 'utf8', collation: 'utf8_unicode_ci') + + ActiveRecord::Tasks::DatabaseTasks.create @configuration.merge('encoding' => 'utf8') + end + + def test_creates_database_with_given_encoding_and_no_collation + @connection.expects(:create_database). + with('my-app-db', charset: 'latin1') + + ActiveRecord::Tasks::DatabaseTasks.create @configuration.merge('encoding' => 'latin1') + end + + def test_creates_database_with_given_collation_and_no_encoding + @connection.expects(:create_database). + with('my-app-db', collation: 'latin1_swedish_ci') + + ActiveRecord::Tasks::DatabaseTasks.create @configuration.merge('collation' => 'latin1_swedish_ci') + end + + def test_establishes_connection_to_database + ActiveRecord::Base.expects(:establish_connection).with(@configuration) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_create_when_database_exists_outputs_info_to_stderr + $stderr.expects(:puts).with("my-app-db already exists").once + + ActiveRecord::Base.connection.stubs(:create_database).raises( + ActiveRecord::StatementInvalid.new("Can't create database 'dev'; database exists:") + ) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end + + if current_adapter?(:MysqlAdapter) + class MysqlDBCreateAsRootTest < ActiveRecord::TestCase + def setup + @connection = stub("Connection", create_database: true) + @error = Mysql::Error.new "Invalid permissions" + @configuration = { + 'adapter' => 'mysql', + 'database' => 'my-app-db', + 'username' => 'pat', + 'password' => 'wossname' + } + + $stdin.stubs(:gets).returns("secret\n") + $stdout.stubs(:print).returns(nil) + @error.stubs(:errno).returns(1045) + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection). + raises(@error). + then.returns(true) + end + + if defined?(::Mysql) + def test_root_password_is_requested + assert_permissions_granted_for "pat" + $stdin.expects(:gets).returns("secret\n") + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end + + def test_connection_established_as_root + assert_permissions_granted_for "pat" + ActiveRecord::Base.expects(:establish_connection).with( + 'adapter' => 'mysql', + 'database' => nil, + 'username' => 'root', + 'password' => 'secret' + ) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_database_created_by_root + assert_permissions_granted_for "pat" + @connection.expects(:create_database). + with('my-app-db', :charset => 'utf8', :collation => 'utf8_unicode_ci') + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_grant_privileges_for_normal_user + assert_permissions_granted_for "pat" + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_do_not_grant_privileges_for_root_user + @configuration['username'] = 'root' + @configuration['password'] = '' + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_connection_established_as_normal_user + assert_permissions_granted_for "pat" + ActiveRecord::Base.expects(:establish_connection).returns do + ActiveRecord::Base.expects(:establish_connection).with( + 'adapter' => 'mysql', + 'database' => 'my-app-db', + 'username' => 'pat', + 'password' => 'secret' + ) + + raise @error + end + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_sends_output_to_stderr_when_other_errors + @error.stubs(:errno).returns(42) + + $stderr.expects(:puts).at_least_once.returns(nil) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + private + def assert_permissions_granted_for(db_user) + db_name = @configuration['database'] + db_password = @configuration['password'] + @connection.expects(:execute).with("GRANT ALL PRIVILEGES ON #{db_name}.* TO '#{db_user}'@'localhost' IDENTIFIED BY '#{db_password}' WITH GRANT OPTION;") + end + end + end + + class MySQLDBDropTest < ActiveRecord::TestCase + def setup + @connection = stub(:drop_database => true) + @configuration = { + 'adapter' => 'mysql', + 'database' => 'my-app-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_establishes_connection_to_mysql_database + ActiveRecord::Base.expects(:establish_connection).with @configuration + + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + + def test_drops_database + @connection.expects(:drop_database).with('my-app-db') + + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + end + + class MySQLPurgeTest < ActiveRecord::TestCase + def setup + @connection = stub(:recreate_database => true) + @configuration = { + 'adapter' => 'mysql', + 'database' => 'test-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_establishes_connection_to_the_appropriate_database + ActiveRecord::Base.expects(:establish_connection).with(@configuration) + + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + + def test_recreates_database_with_the_default_options + @connection.expects(:recreate_database). + with('test-db', charset: 'utf8', collation: 'utf8_unicode_ci') + + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + + def test_recreates_database_with_the_given_options + @connection.expects(:recreate_database). + with('test-db', charset: 'latin', collation: 'latin1_swedish_ci') + + ActiveRecord::Tasks::DatabaseTasks.purge @configuration.merge( + 'encoding' => 'latin', 'collation' => 'latin1_swedish_ci') + end + end + + class MysqlDBCharsetTest < ActiveRecord::TestCase + def setup + @connection = stub(:create_database => true) + @configuration = { + 'adapter' => 'mysql', + 'database' => 'my-app-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_db_retrieves_charset + @connection.expects(:charset) + ActiveRecord::Tasks::DatabaseTasks.charset @configuration + end + end + + class MysqlDBCollationTest < ActiveRecord::TestCase + def setup + @connection = stub(:create_database => true) + @configuration = { + 'adapter' => 'mysql', + 'database' => 'my-app-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_db_retrieves_collation + @connection.expects(:collation) + ActiveRecord::Tasks::DatabaseTasks.collation @configuration + end + end + + class MySQLStructureDumpTest < ActiveRecord::TestCase + def setup + @configuration = { + 'adapter' => 'mysql', + 'database' => 'test-db' + } + end + + def test_structure_dump + filename = "awesome-file.sql" + Kernel.expects(:system).with("mysqldump", "--result-file", filename, "--no-data", "test-db").returns(true) + + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + end + + def test_warn_when_external_structure_dump_fails + filename = "awesome-file.sql" + Kernel.expects(:system).with("mysqldump", "--result-file", filename, "--no-data", "test-db").returns(false) + + warnings = capture(:stderr) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + end + + assert_match(/Could not dump the database structure/, warnings) + end + + def test_structure_dump_with_port_number + filename = "awesome-file.sql" + Kernel.expects(:system).with("mysqldump", "--port", "10000", "--result-file", filename, "--no-data", "test-db").returns(true) + + ActiveRecord::Tasks::DatabaseTasks.structure_dump( + @configuration.merge('port' => 10000), + filename) + end + end + + class MySQLStructureLoadTest < ActiveRecord::TestCase + def setup + @configuration = { + 'adapter' => 'mysql', + 'database' => 'test-db' + } + end + + def test_structure_load + filename = "awesome-file.sql" + Kernel.expects(:system).with('mysql', '--execute', %{SET FOREIGN_KEY_CHECKS = 0; SOURCE #{filename}; SET FOREIGN_KEY_CHECKS = 1}, "--database", "test-db") + + ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + end + end + +end +end diff --git a/activerecord/test/cases/tasks/postgresql_rake_test.rb b/activerecord/test/cases/tasks/postgresql_rake_test.rb new file mode 100644 index 0000000000..0d574d071c --- /dev/null +++ b/activerecord/test/cases/tasks/postgresql_rake_test.rb @@ -0,0 +1,245 @@ +require 'cases/helper' + +if current_adapter?(:PostgreSQLAdapter) +module ActiveRecord + class PostgreSQLDBCreateTest < ActiveRecord::TestCase + def setup + @connection = stub(:create_database => true) + @configuration = { + 'adapter' => 'postgresql', + 'database' => 'my-app-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_establishes_connection_to_postgresql_database + ActiveRecord::Base.expects(:establish_connection).with( + 'adapter' => 'postgresql', + 'database' => 'postgres', + 'schema_search_path' => 'public' + ) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_creates_database_with_default_encoding + @connection.expects(:create_database). + with('my-app-db', @configuration.merge('encoding' => 'utf8')) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_creates_database_with_given_encoding + @connection.expects(:create_database). + with('my-app-db', @configuration.merge('encoding' => 'latin')) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration. + merge('encoding' => 'latin') + end + + def test_creates_database_with_given_collation_and_ctype + @connection.expects(:create_database). + with('my-app-db', @configuration.merge('encoding' => 'utf8', 'collation' => 'ja_JP.UTF8', 'ctype' => 'ja_JP.UTF8')) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration. + merge('collation' => 'ja_JP.UTF8', 'ctype' => 'ja_JP.UTF8') + end + + def test_establishes_connection_to_new_database + ActiveRecord::Base.expects(:establish_connection).with(@configuration) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_db_create_with_error_prints_message + ActiveRecord::Base.stubs(:establish_connection).raises(Exception) + + $stderr.stubs(:puts).returns(true) + $stderr.expects(:puts). + with("Couldn't create database for #{@configuration.inspect}") + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + + def test_create_when_database_exists_outputs_info_to_stderr + $stderr.expects(:puts).with("my-app-db already exists").once + + ActiveRecord::Base.connection.stubs(:create_database).raises( + ActiveRecord::StatementInvalid.new('database "my-app-db" already exists') + ) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end + + class PostgreSQLDBDropTest < ActiveRecord::TestCase + def setup + @connection = stub(:drop_database => true) + @configuration = { + 'adapter' => 'postgresql', + 'database' => 'my-app-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_establishes_connection_to_postgresql_database + ActiveRecord::Base.expects(:establish_connection).with( + 'adapter' => 'postgresql', + 'database' => 'postgres', + 'schema_search_path' => 'public' + ) + + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + + def test_drops_database + @connection.expects(:drop_database).with('my-app-db') + + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + end + + class PostgreSQLPurgeTest < ActiveRecord::TestCase + def setup + @connection = stub(:create_database => true, :drop_database => true) + @configuration = { + 'adapter' => 'postgresql', + 'database' => 'my-app-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:clear_active_connections!).returns(true) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_clears_active_connections + ActiveRecord::Base.expects(:clear_active_connections!) + + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + + def test_establishes_connection_to_postgresql_database + ActiveRecord::Base.expects(:establish_connection).with( + 'adapter' => 'postgresql', + 'database' => 'postgres', + 'schema_search_path' => 'public' + ) + + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + + def test_drops_database + @connection.expects(:drop_database).with('my-app-db') + + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + + def test_creates_database + @connection.expects(:create_database). + with('my-app-db', @configuration.merge('encoding' => 'utf8')) + + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + + def test_establishes_connection + ActiveRecord::Base.expects(:establish_connection).with(@configuration) + + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end + + class PostgreSQLDBCharsetTest < ActiveRecord::TestCase + def setup + @connection = stub(:create_database => true) + @configuration = { + 'adapter' => 'postgresql', + 'database' => 'my-app-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_db_retrieves_charset + @connection.expects(:encoding) + ActiveRecord::Tasks::DatabaseTasks.charset @configuration + end + end + + class PostgreSQLDBCollationTest < ActiveRecord::TestCase + def setup + @connection = stub(:create_database => true) + @configuration = { + 'adapter' => 'postgresql', + 'database' => 'my-app-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_db_retrieves_collation + @connection.expects(:collation) + ActiveRecord::Tasks::DatabaseTasks.collation @configuration + end + end + + class PostgreSQLStructureDumpTest < ActiveRecord::TestCase + def setup + @connection = stub(:structure_dump => true) + @configuration = { + 'adapter' => 'postgresql', + 'database' => 'my-app-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + Kernel.stubs(:system) + end + + def test_structure_dump + filename = "awesome-file.sql" + Kernel.expects(:system).with("pg_dump -i -s -x -O -f #{filename} my-app-db").returns(true) + @connection.expects(:schema_search_path).returns("foo") + + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + assert File.exist?(filename) + ensure + FileUtils.rm(filename) + end + end + + class PostgreSQLStructureLoadTest < ActiveRecord::TestCase + def setup + @connection = stub + @configuration = { + 'adapter' => 'postgresql', + 'database' => 'my-app-db' + } + + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + Kernel.stubs(:system) + end + + def test_structure_load + filename = "awesome-file.sql" + Kernel.expects(:system).with("psql -q -f #{filename} my-app-db") + + ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + end + + def test_structure_load_accepts_path_with_spaces + filename = "awesome file.sql" + Kernel.expects(:system).with("psql -q -f awesome\\ file.sql my-app-db") + + ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + end + end + +end +end diff --git a/activerecord/test/cases/tasks/sqlite_rake_test.rb b/activerecord/test/cases/tasks/sqlite_rake_test.rb new file mode 100644 index 0000000000..750d5e42dc --- /dev/null +++ b/activerecord/test/cases/tasks/sqlite_rake_test.rb @@ -0,0 +1,193 @@ +require 'cases/helper' +require 'pathname' + +if current_adapter?(:SQLite3Adapter) +module ActiveRecord + class SqliteDBCreateTest < ActiveRecord::TestCase + def setup + @database = 'db_create.sqlite3' + @connection = stub :connection + @configuration = { + 'adapter' => 'sqlite3', + 'database' => @database + } + + File.stubs(:exist?).returns(false) + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_db_checks_database_exists + File.expects(:exist?).with(@database).returns(false) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration, '/rails/root' + end + + def test_db_create_when_file_exists + File.stubs(:exist?).returns(true) + + $stderr.expects(:puts).with("#{@database} already exists") + + ActiveRecord::Tasks::DatabaseTasks.create @configuration, '/rails/root' + end + + def test_db_create_with_file_does_nothing + File.stubs(:exist?).returns(true) + $stderr.stubs(:puts).returns(nil) + + ActiveRecord::Base.expects(:establish_connection).never + + ActiveRecord::Tasks::DatabaseTasks.create @configuration, '/rails/root' + end + + def test_db_create_establishes_a_connection + ActiveRecord::Base.expects(:establish_connection).with(@configuration) + + ActiveRecord::Tasks::DatabaseTasks.create @configuration, '/rails/root' + end + + def test_db_create_with_error_prints_message + ActiveRecord::Base.stubs(:establish_connection).raises(Exception) + + $stderr.stubs(:puts).returns(true) + $stderr.expects(:puts). + with("Couldn't create database for #{@configuration.inspect}") + + ActiveRecord::Tasks::DatabaseTasks.create @configuration, '/rails/root' + end + end + + class SqliteDBDropTest < ActiveRecord::TestCase + def setup + @database = "db_create.sqlite3" + @path = stub(:to_s => '/absolute/path', :absolute? => true) + @configuration = { + 'adapter' => 'sqlite3', + 'database' => @database + } + + Pathname.stubs(:new).returns(@path) + File.stubs(:join).returns('/former/relative/path') + FileUtils.stubs(:rm).returns(true) + end + + def test_creates_path_from_database + Pathname.expects(:new).with(@database).returns(@path) + + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, '/rails/root' + end + + def test_removes_file_with_absolute_path + File.stubs(:exist?).returns(true) + @path.stubs(:absolute?).returns(true) + + FileUtils.expects(:rm).with('/absolute/path') + + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, '/rails/root' + end + + def test_generates_absolute_path_with_given_root + @path.stubs(:absolute?).returns(false) + + File.expects(:join).with('/rails/root', @path). + returns('/former/relative/path') + + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, '/rails/root' + end + + def test_removes_file_with_relative_path + File.stubs(:exist?).returns(true) + @path.stubs(:absolute?).returns(false) + + FileUtils.expects(:rm).with('/former/relative/path') + + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, '/rails/root' + end + end + + class SqliteDBCharsetTest < ActiveRecord::TestCase + def setup + @database = 'db_create.sqlite3' + @connection = stub :connection + @configuration = { + 'adapter' => 'sqlite3', + 'database' => @database + } + + File.stubs(:exist?).returns(false) + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_db_retrieves_charset + @connection.expects(:encoding) + ActiveRecord::Tasks::DatabaseTasks.charset @configuration, '/rails/root' + end + end + + class SqliteDBCollationTest < ActiveRecord::TestCase + def setup + @database = 'db_create.sqlite3' + @connection = stub :connection + @configuration = { + 'adapter' => 'sqlite3', + 'database' => @database + } + + File.stubs(:exist?).returns(false) + ActiveRecord::Base.stubs(:connection).returns(@connection) + ActiveRecord::Base.stubs(:establish_connection).returns(true) + end + + def test_db_retrieves_collation + assert_raise NoMethodError do + ActiveRecord::Tasks::DatabaseTasks.collation @configuration, '/rails/root' + end + end + end + + class SqliteStructureDumpTest < ActiveRecord::TestCase + def setup + @database = "db_create.sqlite3" + @configuration = { + 'adapter' => 'sqlite3', + 'database' => @database + } + end + + def test_structure_dump + dbfile = @database + filename = "awesome-file.sql" + + ActiveRecord::Tasks::DatabaseTasks.structure_dump @configuration, filename, '/rails/root' + assert File.exist?(dbfile) + assert File.exist?(filename) + ensure + FileUtils.rm_f(filename) + FileUtils.rm_f(dbfile) + end + end + + class SqliteStructureLoadTest < ActiveRecord::TestCase + def setup + @database = "db_create.sqlite3" + @configuration = { + 'adapter' => 'sqlite3', + 'database' => @database + } + end + + def test_structure_load + dbfile = @database + filename = "awesome-file.sql" + + open(filename, 'w') { |f| f.puts("select datetime('now', 'localtime');") } + ActiveRecord::Tasks::DatabaseTasks.structure_load @configuration, filename, '/rails/root' + assert File.exist?(dbfile) + ensure + FileUtils.rm_f(filename) + FileUtils.rm_f(dbfile) + end + end +end +end diff --git a/activerecord/test/cases/test_case.rb b/activerecord/test/cases/test_case.rb new file mode 100644 index 0000000000..23a170388e --- /dev/null +++ b/activerecord/test/cases/test_case.rb @@ -0,0 +1,123 @@ +require 'active_support/test_case' + +module ActiveRecord + # = Active Record Test Case + # + # Defines some test assertions to test against SQL queries. + class TestCase < ActiveSupport::TestCase #:nodoc: + def teardown + SQLCounter.clear_log + end + + def assert_date_from_db(expected, actual, message = nil) + assert_equal expected.to_s, actual.to_s, message + end + + def capture(stream) + stream = stream.to_s + captured_stream = Tempfile.new(stream) + stream_io = eval("$#{stream}") + origin_stream = stream_io.dup + stream_io.reopen(captured_stream) + + yield + + stream_io.rewind + return captured_stream.read + ensure + captured_stream.close + captured_stream.unlink + stream_io.reopen(origin_stream) + end + + def capture_sql + SQLCounter.clear_log + yield + SQLCounter.log_all.dup + end + + def assert_sql(*patterns_to_match) + capture_sql { yield } + ensure + failed_patterns = [] + patterns_to_match.each do |pattern| + failed_patterns << pattern unless SQLCounter.log_all.any?{ |sql| pattern === sql } + end + assert failed_patterns.empty?, "Query pattern(s) #{failed_patterns.map{ |p| p.inspect }.join(', ')} not found.#{SQLCounter.log.size == 0 ? '' : "\nQueries:\n#{SQLCounter.log.join("\n")}"}" + end + + def assert_queries(num = 1, options = {}) + ignore_none = options.fetch(:ignore_none) { num == :any } + SQLCounter.clear_log + x = yield + the_log = ignore_none ? SQLCounter.log_all : SQLCounter.log + if num == :any + assert_operator the_log.size, :>=, 1, "1 or more queries expected, but none were executed." + else + mesg = "#{the_log.size} instead of #{num} queries were executed.#{the_log.size == 0 ? '' : "\nQueries:\n#{the_log.join("\n")}"}" + assert_equal num, the_log.size, mesg + end + x + end + + def assert_no_queries(options = {}, &block) + options.reverse_merge! ignore_none: true + assert_queries(0, options, &block) + end + + def assert_column(model, column_name, msg=nil) + assert has_column?(model, column_name), msg + end + + def assert_no_column(model, column_name, msg=nil) + assert_not has_column?(model, column_name), msg + end + + def has_column?(model, column_name) + model.reset_column_information + model.column_names.include?(column_name.to_s) + end + end + + class SQLCounter + class << self + attr_accessor :ignored_sql, :log, :log_all + def clear_log; self.log = []; self.log_all = []; end + end + + self.clear_log + + self.ignored_sql = [/^PRAGMA/, /^SELECT currval/, /^SELECT CAST/, /^SELECT @@IDENTITY/, /^SELECT @@ROWCOUNT/, /^SAVEPOINT/, /^ROLLBACK TO SAVEPOINT/, /^RELEASE SAVEPOINT/, /^SHOW max_identifier_length/, /^BEGIN/, /^COMMIT/] + + # FIXME: this needs to be refactored so specific database can add their own + # ignored SQL, or better yet, use a different notification for the queries + # instead examining the SQL content. + oracle_ignored = [/^select .*nextval/i, /^SAVEPOINT/, /^ROLLBACK TO/, /^\s*select .* from all_triggers/im, /^\s*select .* from all_constraints/im, /^\s*select .* from all_tab_cols/im] + mysql_ignored = [/^SHOW TABLES/i, /^SHOW FULL FIELDS/, /^SHOW CREATE TABLE /i] + postgresql_ignored = [/^\s*select\b.*\bfrom\b.*pg_namespace\b/im, /^\s*select\b.*\battname\b.*\bfrom\b.*\bpg_attribute\b/im, /^SHOW search_path/i] + sqlite3_ignored = [/^\s*SELECT name\b.*\bFROM sqlite_master/im] + + [oracle_ignored, mysql_ignored, postgresql_ignored, sqlite3_ignored].each do |db_ignored_sql| + ignored_sql.concat db_ignored_sql + end + + attr_reader :ignore + + def initialize(ignore = Regexp.union(self.class.ignored_sql)) + @ignore = ignore + end + + def call(name, start, finish, message_id, values) + sql = values[:sql] + + # FIXME: this seems bad. we should probably have a better way to indicate + # the query was cached + return if 'CACHE' == values[:name] + + self.class.log_all << sql + self.class.log << sql unless ignore =~ sql + end + end + + ActiveSupport::Notifications.subscribe('sql.active_record', SQLCounter.new) +end diff --git a/activerecord/test/cases/timestamp_test.rb b/activerecord/test/cases/timestamp_test.rb new file mode 100644 index 0000000000..0472246f71 --- /dev/null +++ b/activerecord/test/cases/timestamp_test.rb @@ -0,0 +1,426 @@ +require 'cases/helper' +require 'models/developer' +require 'models/owner' +require 'models/pet' +require 'models/toy' +require 'models/car' +require 'models/task' + +class TimestampTest < ActiveRecord::TestCase + fixtures :developers, :owners, :pets, :toys, :cars, :tasks + + def setup + @developer = Developer.first + @owner = Owner.first + @developer.update_columns(updated_at: Time.now.prev_month) + @previously_updated_at = @developer.updated_at + end + + def test_saving_a_changed_record_updates_its_timestamp + @developer.name = "Jack Bauer" + @developer.save! + + assert_not_equal @previously_updated_at, @developer.updated_at + end + + def test_saving_a_unchanged_record_doesnt_update_its_timestamp + @developer.save! + + assert_equal @previously_updated_at, @developer.updated_at + end + + def test_touching_a_record_updates_its_timestamp + previous_salary = @developer.salary + @developer.salary = previous_salary + 10000 + @developer.touch + + assert_not_equal @previously_updated_at, @developer.updated_at + assert_equal previous_salary + 10000, @developer.salary + assert @developer.salary_changed?, 'developer salary should have changed' + assert @developer.changed?, 'developer should be marked as changed' + @developer.reload + assert_equal previous_salary, @developer.salary + end + + def test_touching_a_record_with_default_scope_that_excludes_it_updates_its_timestamp + developer = @developer.becomes(DeveloperCalledJamis) + + developer.touch + assert_not_equal @previously_updated_at, developer.updated_at + developer.reload + assert_not_equal @previously_updated_at, developer.updated_at + end + + def test_saving_when_record_timestamps_is_false_doesnt_update_its_timestamp + Developer.record_timestamps = false + @developer.name = "John Smith" + @developer.save! + + assert_equal @previously_updated_at, @developer.updated_at + ensure + Developer.record_timestamps = true + end + + def test_saving_when_instance_record_timestamps_is_false_doesnt_update_its_timestamp + @developer.record_timestamps = false + assert Developer.record_timestamps + + @developer.name = "John Smith" + @developer.save! + + assert_equal @previously_updated_at, @developer.updated_at + end + + def test_touching_an_attribute_updates_timestamp + previously_created_at = @developer.created_at + @developer.touch(:created_at) + + assert !@developer.created_at_changed? , 'created_at should not be changed' + assert !@developer.changed?, 'record should not be changed' + assert_not_equal previously_created_at, @developer.created_at + assert_not_equal @previously_updated_at, @developer.updated_at + end + + def test_touching_an_attribute_updates_it + task = Task.first + previous_value = task.ending + task.touch(:ending) + assert_not_equal previous_value, task.ending + assert_in_delta Time.now, task.ending, 1 + end + + def test_touching_many_attributes_updates_them + task = Task.first + previous_starting = task.starting + previous_ending = task.ending + task.touch(:starting, :ending) + + assert_not_equal previous_starting, task.starting + assert_not_equal previous_ending, task.ending + assert_in_delta Time.now, task.starting, 1 + assert_in_delta Time.now, task.ending, 1 + end + + def test_touching_a_record_without_timestamps_is_unexceptional + assert_nothing_raised { Car.first.touch } + end + + def test_touching_a_no_touching_object + Developer.no_touching do + assert @developer.no_touching? + assert !@owner.no_touching? + @developer.touch + end + + assert !@developer.no_touching? + assert !@owner.no_touching? + assert_equal @previously_updated_at, @developer.updated_at + end + + def test_touching_related_objects + @owner = Owner.first + @previously_updated_at = @owner.updated_at + + Owner.no_touching do + @owner.pets.first.touch + end + + assert_equal @previously_updated_at, @owner.updated_at + end + + def test_global_no_touching + ActiveRecord::Base.no_touching do + assert @developer.no_touching? + assert @owner.no_touching? + @developer.touch + end + + assert !@developer.no_touching? + assert !@owner.no_touching? + assert_equal @previously_updated_at, @developer.updated_at + end + + def test_no_touching_threadsafe + Thread.new do + Developer.no_touching do + assert @developer.no_touching? + + sleep(1) + end + end + + assert !@developer.no_touching? + end + + def test_no_touching_with_callbacks + klass = Class.new(ActiveRecord::Base) do + self.table_name = "developers" + + attr_accessor :after_touch_called + + after_touch do |user| + user.after_touch_called = true + end + end + + developer = klass.first + + klass.no_touching do + developer.touch + assert_not developer.after_touch_called + end + end + + def test_saving_a_record_with_a_belongs_to_that_specifies_touching_the_parent_should_update_the_parent_updated_at + pet = Pet.first + owner = pet.owner + previously_owner_updated_at = owner.updated_at + + pet.name = "Fluffy the Third" + pet.save + + assert_not_equal previously_owner_updated_at, pet.owner.updated_at + end + + def test_destroying_a_record_with_a_belongs_to_that_specifies_touching_the_parent_should_update_the_parent_updated_at + pet = Pet.first + owner = pet.owner + previously_owner_updated_at = owner.updated_at + + pet.destroy + + assert_not_equal previously_owner_updated_at, pet.owner.updated_at + end + + def test_saving_a_new_record_belonging_to_invalid_parent_with_touch_should_not_raise_exception + klass = Class.new(Owner) do + def self.name; 'Owner'; end + validate { errors.add(:base, :invalid) } + end + + pet = Pet.new(owner: klass.new) + pet.save! + + assert pet.owner.new_record? + end + + def test_saving_a_record_with_a_belongs_to_that_specifies_touching_a_specific_attribute_the_parent_should_update_that_attribute + klass = Class.new(ActiveRecord::Base) do + def self.name; 'Pet'; end + belongs_to :owner, :touch => :happy_at + end + + pet = klass.first + owner = pet.owner + previously_owner_happy_at = owner.happy_at + + pet.name = "Fluffy the Third" + pet.save + + assert_not_equal previously_owner_happy_at, pet.owner.happy_at + end + + def test_touching_a_record_with_a_belongs_to_that_uses_a_counter_cache_should_update_the_parent + klass = Class.new(ActiveRecord::Base) do + def self.name; 'Pet'; end + belongs_to :owner, :counter_cache => :use_count, :touch => true + end + + pet = klass.first + owner = pet.owner + owner.update_columns(happy_at: 3.days.ago) + previously_owner_updated_at = owner.updated_at + + pet.name = "I'm a parrot" + pet.save + + assert_not_equal previously_owner_updated_at, pet.owner.updated_at + end + + def test_touching_a_record_touches_parent_record_and_grandparent_record + klass = Class.new(ActiveRecord::Base) do + def self.name; 'Toy'; end + belongs_to :pet, :touch => true + end + + toy = klass.first + pet = toy.pet + owner = pet.owner + time = 3.days.ago + + owner.update_columns(updated_at: time) + toy.touch + owner.reload + + assert_not_equal time, owner.updated_at + end + + def test_touching_a_record_touches_polymorphic_record + klass = Class.new(ActiveRecord::Base) do + def self.name; 'Toy'; end + end + + wheel_klass = Class.new(ActiveRecord::Base) do + def self.name; 'Wheel'; end + belongs_to :wheelable, :polymorphic => true, :touch => true + end + + toy = klass.first + time = 3.days.ago + toy.update_columns(updated_at: time) + + wheel = wheel_klass.new + wheel.wheelable = toy + wheel.save + wheel.touch + + assert_not_equal time, toy.updated_at + end + + def test_changing_parent_of_a_record_touches_both_new_and_old_parent_record + klass = Class.new(ActiveRecord::Base) do + def self.name; 'Toy'; end + belongs_to :pet, touch: true + end + + toy1 = klass.find(1) + old_pet = toy1.pet + + toy2 = klass.find(2) + new_pet = toy2.pet + time = 3.days.ago.at_beginning_of_hour + + old_pet.update_columns(updated_at: time) + new_pet.update_columns(updated_at: time) + + toy1.pet = new_pet + toy1.save! + + old_pet.reload + new_pet.reload + + assert_not_equal time, new_pet.updated_at + assert_not_equal time, old_pet.updated_at + end + + def test_changing_parent_of_a_record_touches_both_new_and_old_polymorphic_parent_record_changes_within_same_class + car_class = Class.new(ActiveRecord::Base) do + def self.name; 'Car'; end + end + + wheel_class = Class.new(ActiveRecord::Base) do + def self.name; 'Wheel'; end + belongs_to :wheelable, :polymorphic => true, :touch => true + end + + car1 = car_class.find(1) + car2 = car_class.find(2) + + wheel = wheel_class.create!(wheelable: car1) + + time = 3.days.ago.at_beginning_of_hour + + car1.update_columns(updated_at: time) + car2.update_columns(updated_at: time) + + wheel.wheelable = car2 + wheel.save! + + assert_not_equal time, car1.reload.updated_at + assert_not_equal time, car2.reload.updated_at + end + + def test_changing_parent_of_a_record_touches_both_new_and_old_polymorphic_parent_record_changes_with_other_class + car_class = Class.new(ActiveRecord::Base) do + def self.name; 'Car'; end + end + + toy_class = Class.new(ActiveRecord::Base) do + def self.name; 'Toy'; end + end + + wheel_class = Class.new(ActiveRecord::Base) do + def self.name; 'Wheel'; end + belongs_to :wheelable, :polymorphic => true, :touch => true + end + + car = car_class.find(1) + toy = toy_class.find(3) + + wheel = wheel_class.create!(wheelable: car) + + time = 3.days.ago.at_beginning_of_hour + + car.update_columns(updated_at: time) + toy.update_columns(updated_at: time) + + wheel.wheelable = toy + wheel.save! + + assert_not_equal time, car.reload.updated_at + assert_not_equal time, toy.reload.updated_at + end + + def test_clearing_association_touches_the_old_record + klass = Class.new(ActiveRecord::Base) do + def self.name; 'Toy'; end + belongs_to :pet, touch: true + end + + toy = klass.find(1) + pet = toy.pet + time = 3.days.ago.at_beginning_of_hour + + pet.update_columns(updated_at: time) + + toy.pet = nil + toy.save! + + pet.reload + + assert_not_equal time, pet.updated_at + end + + def test_timestamp_column_values_are_present_in_the_callbacks + klass = Class.new(ActiveRecord::Base) do + self.table_name = 'people' + + before_create do + self.born_at = self.created_at + end + end + + person = klass.create first_name: 'David' + assert_not_equal person.born_at, nil + end + + def test_timestamp_attributes_for_create + toy = Toy.first + assert_equal [:created_at, :created_on], toy.send(:timestamp_attributes_for_create) + end + + def test_timestamp_attributes_for_update + toy = Toy.first + assert_equal [:updated_at, :updated_on], toy.send(:timestamp_attributes_for_update) + end + + def test_all_timestamp_attributes + toy = Toy.first + assert_equal [:created_at, :created_on, :updated_at, :updated_on], toy.send(:all_timestamp_attributes) + end + + def test_timestamp_attributes_for_create_in_model + toy = Toy.first + assert_equal [:created_at], toy.send(:timestamp_attributes_for_create_in_model) + end + + def test_timestamp_attributes_for_update_in_model + toy = Toy.first + assert_equal [:updated_at], toy.send(:timestamp_attributes_for_update_in_model) + end + + def test_all_timestamp_attributes_in_model + toy = Toy.first + assert_equal [:created_at, :updated_at], toy.send(:all_timestamp_attributes_in_model) + end +end diff --git a/activerecord/test/cases/transaction_callbacks_test.rb b/activerecord/test/cases/transaction_callbacks_test.rb new file mode 100644 index 0000000000..3d64ecb464 --- /dev/null +++ b/activerecord/test/cases/transaction_callbacks_test.rb @@ -0,0 +1,351 @@ +require "cases/helper" +require 'models/owner' +require 'models/pet' +require 'models/topic' + +class TransactionCallbacksTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + fixtures :topics, :owners, :pets + + class ReplyWithCallbacks < ActiveRecord::Base + self.table_name = :topics + + belongs_to :topic, foreign_key: "parent_id" + + validates_presence_of :content + + after_commit :do_after_commit, on: :create + + attr_accessor :save_on_after_create + after_create do + self.save! if save_on_after_create + end + + def history + @history ||= [] + end + + def do_after_commit + history << :commit_on_create + end + end + + class TopicWithCallbacks < ActiveRecord::Base + self.table_name = :topics + + has_many :replies, class_name: "ReplyWithCallbacks", foreign_key: "parent_id" + + after_commit { |record| record.do_after_commit(nil) } + after_commit(on: :create) { |record| record.do_after_commit(:create) } + after_commit(on: :update) { |record| record.do_after_commit(:update) } + after_commit(on: :destroy) { |record| record.do_after_commit(:destroy) } + after_rollback { |record| record.do_after_rollback(nil) } + after_rollback(on: :create) { |record| record.do_after_rollback(:create) } + after_rollback(on: :update) { |record| record.do_after_rollback(:update) } + after_rollback(on: :destroy) { |record| record.do_after_rollback(:destroy) } + + def history + @history ||= [] + end + + def after_commit_block(on = nil, &block) + @after_commit ||= {} + @after_commit[on] ||= [] + @after_commit[on] << block + end + + def after_rollback_block(on = nil, &block) + @after_rollback ||= {} + @after_rollback[on] ||= [] + @after_rollback[on] << block + end + + def do_after_commit(on) + blocks = @after_commit[on] if defined?(@after_commit) + blocks.each{|b| b.call(self)} if blocks + end + + def do_after_rollback(on) + blocks = @after_rollback[on] if defined?(@after_rollback) + blocks.each{|b| b.call(self)} if blocks + end + end + + def setup + @first = TopicWithCallbacks.find(1) + end + + def test_call_after_commit_after_transaction_commits + @first.after_commit_block{|r| r.history << :after_commit} + @first.after_rollback_block{|r| r.history << :after_rollback} + + @first.save! + assert_equal [:after_commit], @first.history + end + + def test_only_call_after_commit_on_update_after_transaction_commits_for_existing_record + add_transaction_execution_blocks @first + + @first.save! + assert_equal [:commit_on_update], @first.history + end + + def test_only_call_after_commit_on_destroy_after_transaction_commits_for_destroyed_record + add_transaction_execution_blocks @first + + @first.destroy + assert_equal [:commit_on_destroy], @first.history + end + + def test_only_call_after_commit_on_create_after_transaction_commits_for_new_record + new_record = TopicWithCallbacks.new(:title => "New topic", :written_on => Date.today) + add_transaction_execution_blocks new_record + + new_record.save! + assert_equal [:commit_on_create], new_record.history + end + + def test_only_call_after_commit_on_create_after_transaction_commits_for_new_record_if_create_succeeds_creating_through_association + topic = TopicWithCallbacks.create!(:title => "New topic", :written_on => Date.today) + reply = topic.replies.create + + assert_equal [], reply.history + end + + def test_only_call_after_commit_on_create_and_doesnt_leaky + r = ReplyWithCallbacks.new(content: 'foo') + r.save_on_after_create = true + r.save! + r.content = 'bar' + r.save! + r.save! + assert_equal [:commit_on_create], r.history + end + + def test_only_call_after_commit_on_update_after_transaction_commits_for_existing_record_on_touch + add_transaction_execution_blocks @first + + @first.touch + assert_equal [:commit_on_update], @first.history + end + + def test_call_after_rollback_after_transaction_rollsback + @first.after_commit_block{|r| r.history << :after_commit} + @first.after_rollback_block{|r| r.history << :after_rollback} + + Topic.transaction do + @first.save! + raise ActiveRecord::Rollback + end + + assert_equal [:after_rollback], @first.history + end + + def test_only_call_after_rollback_on_update_after_transaction_rollsback_for_existing_record + add_transaction_execution_blocks @first + + Topic.transaction do + @first.save! + raise ActiveRecord::Rollback + end + + assert_equal [:rollback_on_update], @first.history + end + + def test_only_call_after_rollback_on_update_after_transaction_rollsback_for_existing_record_on_touch + add_transaction_execution_blocks @first + + Topic.transaction do + @first.touch + raise ActiveRecord::Rollback + end + + assert_equal [:rollback_on_update], @first.history + end + + def test_only_call_after_rollback_on_destroy_after_transaction_rollsback_for_destroyed_record + add_transaction_execution_blocks @first + + Topic.transaction do + @first.destroy + raise ActiveRecord::Rollback + end + + assert_equal [:rollback_on_destroy], @first.history + end + + def test_only_call_after_rollback_on_create_after_transaction_rollsback_for_new_record + new_record = TopicWithCallbacks.new(:title => "New topic", :written_on => Date.today) + add_transaction_execution_blocks new_record + + Topic.transaction do + new_record.save! + raise ActiveRecord::Rollback + end + + assert_equal [:rollback_on_create], new_record.history + end + + def test_call_after_rollback_when_commit_fails + @first.class.connection.singleton_class.send(:alias_method, :real_method_commit_db_transaction, :commit_db_transaction) + begin + @first.class.connection.singleton_class.class_eval do + def commit_db_transaction; raise "boom!"; end + end + + @first.after_commit_block{|r| r.history << :after_commit} + @first.after_rollback_block{|r| r.history << :after_rollback} + + assert !@first.save rescue nil + assert_equal [:after_rollback], @first.history + ensure + @first.class.connection.singleton_class.send(:remove_method, :commit_db_transaction) + @first.class.connection.singleton_class.send(:alias_method, :commit_db_transaction, :real_method_commit_db_transaction) + end + end + + def test_only_call_after_rollback_on_records_rolled_back_to_a_savepoint + def @first.rollbacks(i=0); @rollbacks ||= 0; @rollbacks += i if i; end + def @first.commits(i=0); @commits ||= 0; @commits += i if i; end + @first.after_rollback_block{|r| r.rollbacks(1)} + @first.after_commit_block{|r| r.commits(1)} + + second = TopicWithCallbacks.find(3) + def second.rollbacks(i=0); @rollbacks ||= 0; @rollbacks += i if i; end + def second.commits(i=0); @commits ||= 0; @commits += i if i; end + second.after_rollback_block{|r| r.rollbacks(1)} + second.after_commit_block{|r| r.commits(1)} + + Topic.transaction do + @first.save! + Topic.transaction(:requires_new => true) do + second.save! + raise ActiveRecord::Rollback + end + end + + assert_equal 1, @first.commits + assert_equal 0, @first.rollbacks + assert_equal 0, second.commits + assert_equal 1, second.rollbacks + end + + def test_only_call_after_rollback_on_records_rolled_back_to_a_savepoint_when_release_savepoint_fails + def @first.rollbacks(i=0); @rollbacks ||= 0; @rollbacks += i if i; end + def @first.commits(i=0); @commits ||= 0; @commits += i if i; end + + @first.after_rollback_block{|r| r.rollbacks(1)} + @first.after_commit_block{|r| r.commits(1)} + + Topic.transaction do + @first.save + Topic.transaction(:requires_new => true) do + @first.save! + raise ActiveRecord::Rollback + end + Topic.transaction(:requires_new => true) do + @first.save! + raise ActiveRecord::Rollback + end + end + + assert_equal 1, @first.commits + assert_equal 2, @first.rollbacks + end + + def test_after_transaction_callbacks_should_prevent_callbacks_from_being_called + def @first.last_after_transaction_error=(e); @last_transaction_error = e; end + def @first.last_after_transaction_error; @last_transaction_error; end + @first.after_commit_block{|r| r.last_after_transaction_error = :commit; raise "fail!";} + @first.after_rollback_block{|r| r.last_after_transaction_error = :rollback; raise "fail!";} + + second = TopicWithCallbacks.find(3) + second.after_commit_block{|r| r.history << :after_commit} + second.after_rollback_block{|r| r.history << :after_rollback} + + Topic.transaction do + @first.save! + second.save! + end + assert_equal :commit, @first.last_after_transaction_error + assert_equal [:after_commit], second.history + + second.history.clear + Topic.transaction do + @first.save! + second.save! + raise ActiveRecord::Rollback + end + assert_equal :rollback, @first.last_after_transaction_error + assert_equal [:after_rollback], second.history + end + + def test_after_rollback_callbacks_should_validate_on_condition + assert_raise(ArgumentError) { Topic.after_rollback(on: :save) } + end + + def test_after_commit_callbacks_should_validate_on_condition + assert_raise(ArgumentError) { Topic.after_commit(on: :save) } + end + + def test_saving_a_record_with_a_belongs_to_that_specifies_touching_the_parent_should_call_callbacks_on_the_parent_object + pet = Pet.first + owner = pet.owner + flag = false + + owner.on_after_commit do + flag = true + end + + pet.name = "Fluffy the Third" + pet.save + + assert flag + end + + private + + def add_transaction_execution_blocks(record) + record.after_commit_block(:create) { |r| r.history << :commit_on_create } + record.after_commit_block(:update) { |r| r.history << :commit_on_update } + record.after_commit_block(:destroy) { |r| r.history << :commit_on_destroy } + record.after_rollback_block(:create) { |r| r.history << :rollback_on_create } + record.after_rollback_block(:update) { |r| r.history << :rollback_on_update } + record.after_rollback_block(:destroy) { |r| r.history << :rollback_on_destroy } + end +end + +class CallbacksOnMultipleActionsTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + class TopicWithCallbacksOnMultipleActions < ActiveRecord::Base + self.table_name = :topics + + after_commit(on: [:create, :destroy]) { |record| record.history << :create_and_destroy } + after_commit(on: [:create, :update]) { |record| record.history << :create_and_update } + after_commit(on: [:update, :destroy]) { |record| record.history << :update_and_destroy } + + def clear_history + @history = [] + end + + def history + @history ||= [] + end + end + + def test_after_commit_on_multiple_actions + topic = TopicWithCallbacksOnMultipleActions.new + topic.save + assert_equal [:create_and_update, :create_and_destroy], topic.history + + topic.clear_history + topic.approved = true + topic.save + assert_equal [:update_and_destroy, :create_and_update], topic.history + + topic.clear_history + topic.destroy + assert_equal [:update_and_destroy, :create_and_destroy], topic.history + end +end diff --git a/activerecord/test/cases/transaction_isolation_test.rb b/activerecord/test/cases/transaction_isolation_test.rb new file mode 100644 index 0000000000..f89c26532d --- /dev/null +++ b/activerecord/test/cases/transaction_isolation_test.rb @@ -0,0 +1,106 @@ +require 'cases/helper' + +unless ActiveRecord::Base.connection.supports_transaction_isolation? + class TransactionIsolationUnsupportedTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + class Tag < ActiveRecord::Base + end + + test "setting the isolation level raises an error" do + assert_raises(ActiveRecord::TransactionIsolationError) do + Tag.transaction(isolation: :serializable) { } + end + end + end +end + +if ActiveRecord::Base.connection.supports_transaction_isolation? + class TransactionIsolationTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + class Tag < ActiveRecord::Base + self.table_name = 'tags' + end + + class Tag2 < ActiveRecord::Base + self.table_name = 'tags' + end + + setup do + Tag.establish_connection :arunit + Tag2.establish_connection :arunit + Tag.destroy_all + end + + # It is impossible to properly test read uncommitted. The SQL standard only + # specifies what must not happen at a certain level, not what must happen. At + # the read uncommitted level, there is nothing that must not happen. + if ActiveRecord::Base.connection.transaction_isolation_levels.include?(:read_uncommitted) + test "read uncommitted" do + Tag.transaction(isolation: :read_uncommitted) do + assert_equal 0, Tag.count + Tag2.create + assert_equal 1, Tag.count + end + end + end + + # We are testing that a dirty read does not happen + test "read committed" do + Tag.transaction(isolation: :read_committed) do + assert_equal 0, Tag.count + + Tag2.transaction do + Tag2.create + assert_equal 0, Tag.count + end + end + + assert_equal 1, Tag.count + end + + # We are testing that a nonrepeatable read does not happen + if ActiveRecord::Base.connection.transaction_isolation_levels.include?(:repeatable_read) + test "repeatable read" do + tag = Tag.create(name: 'jon') + + Tag.transaction(isolation: :repeatable_read) do + tag.reload + Tag2.find(tag.id).update(name: 'emily') + + tag.reload + assert_equal 'jon', tag.name + end + + tag.reload + assert_equal 'emily', tag.name + end + end + + # We are only testing that there are no errors because it's too hard to + # test serializable. Databases behave differently to enforce the serializability + # constraint. + test "serializable" do + Tag.transaction(isolation: :serializable) do + Tag.create + end + end + + test "setting isolation when joining a transaction raises an error" do + Tag.transaction do + assert_raises(ActiveRecord::TransactionIsolationError) do + Tag.transaction(isolation: :serializable) { } + end + end + end + + test "setting isolation when starting a nested transaction raises error" do + Tag.transaction do + assert_raises(ActiveRecord::TransactionIsolationError) do + Tag.transaction(requires_new: true, isolation: :serializable) { } + end + end + end + end +end diff --git a/activerecord/test/cases/transactions_test.rb b/activerecord/test/cases/transactions_test.rb new file mode 100644 index 0000000000..b4849222b8 --- /dev/null +++ b/activerecord/test/cases/transactions_test.rb @@ -0,0 +1,700 @@ +require "cases/helper" +require 'models/topic' +require 'models/reply' +require 'models/developer' +require 'models/book' +require 'models/author' +require 'models/post' +require 'models/movie' + +class TransactionTest < ActiveRecord::TestCase + self.use_transactional_fixtures = false + fixtures :topics, :developers, :authors, :posts + + def setup + @first, @second = Topic.find(1, 2).sort_by { |t| t.id } + end + + def test_persisted_in_a_model_with_custom_primary_key_after_failed_save + movie = Movie.create + assert !movie.persisted? + end + + def test_raise_after_destroy + assert_not @first.frozen? + + assert_raises(RuntimeError) { + Topic.transaction do + @first.destroy + assert @first.frozen? + raise + end + } + + assert @first.reload + assert_not @first.frozen? + end + + def test_successful + Topic.transaction do + @first.approved = true + @second.approved = false + @first.save + @second.save + end + + assert Topic.find(1).approved?, "First should have been approved" + assert !Topic.find(2).approved?, "Second should have been unapproved" + end + + def transaction_with_return + Topic.transaction do + @first.approved = true + @second.approved = false + @first.save + @second.save + return + end + end + + def test_successful_with_return + committed = false + + Topic.connection.class_eval do + alias :real_commit_db_transaction :commit_db_transaction + define_method(:commit_db_transaction) do + committed = true + real_commit_db_transaction + end + end + + transaction_with_return + assert committed + + assert Topic.find(1).approved?, "First should have been approved" + assert !Topic.find(2).approved?, "Second should have been unapproved" + ensure + Topic.connection.class_eval do + remove_method :commit_db_transaction + alias :commit_db_transaction :real_commit_db_transaction rescue nil + end + end + + def test_successful_with_instance_method + @first.transaction do + @first.approved = true + @second.approved = false + @first.save + @second.save + end + + assert Topic.find(1).approved?, "First should have been approved" + assert !Topic.find(2).approved?, "Second should have been unapproved" + end + + def test_failing_on_exception + begin + Topic.transaction do + @first.approved = true + @second.approved = false + @first.save + @second.save + raise "Bad things!" + end + rescue + # caught it + end + + assert @first.approved?, "First should still be changed in the objects" + assert !@second.approved?, "Second should still be changed in the objects" + + assert !Topic.find(1).approved?, "First shouldn't have been approved" + assert Topic.find(2).approved?, "Second should still be approved" + end + + def test_raising_exception_in_callback_rollbacks_in_save + def @first.after_save_for_transaction + raise 'Make the transaction rollback' + end + + @first.approved = true + e = assert_raises(RuntimeError) { @first.save } + assert_equal "Make the transaction rollback", e.message + assert !Topic.find(1).approved? + end + + def test_rolling_back_in_a_callback_rollbacks_before_save + def @first.before_save_for_transaction + raise ActiveRecord::Rollback + end + assert !@first.approved + + Topic.transaction do + @first.approved = true + @first.save! + end + assert !Topic.find(@first.id).approved?, "Should not commit the approved flag" + end + + def test_raising_exception_in_nested_transaction_restore_state_in_save + topic = Topic.new + + def topic.after_save_for_transaction + raise 'Make the transaction rollback' + end + + assert_raises(RuntimeError) do + Topic.transaction { topic.save } + end + + assert topic.new_record?, "#{topic.inspect} should be new record" + end + + def test_update_should_rollback_on_failure + author = Author.find(1) + posts_count = author.posts.size + assert posts_count > 0 + status = author.update(name: nil, post_ids: []) + assert !status + assert_equal posts_count, author.posts(true).size + end + + def test_update_should_rollback_on_failure! + author = Author.find(1) + posts_count = author.posts.size + assert posts_count > 0 + assert_raise(ActiveRecord::RecordInvalid) do + author.update!(name: nil, post_ids: []) + end + assert_equal posts_count, author.posts(true).size + end + + def test_cancellation_from_before_destroy_rollbacks_in_destroy + add_cancelling_before_destroy_with_db_side_effect_to_topic @first + nbooks_before_destroy = Book.count + status = @first.destroy + assert !status + @first.reload + assert_equal nbooks_before_destroy, Book.count + end + + %w(validation save).each do |filter| + define_method("test_cancellation_from_before_filters_rollbacks_in_#{filter}") do + send("add_cancelling_before_#{filter}_with_db_side_effect_to_topic", @first) + 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 + end + + define_method("test_cancellation_from_before_filters_rollbacks_in_#{filter}!") do + send("add_cancelling_before_#{filter}_with_db_side_effect_to_topic", @first) + nbooks_before_save = Book.count + original_author_name = @first.author_name + @first.author_name += '_this_should_not_end_up_in_the_db' + + begin + @first.save! + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved + end + + assert_equal original_author_name, @first.reload.author_name + assert_equal nbooks_before_save, Book.count + end + end + + def test_callback_rollback_in_create + topic = Class.new(Topic) { + def after_create_for_transaction + raise 'Make the transaction rollback' + end + } + + new_topic = topic.new(:title => "A new topic", + :author_name => "Ben", + :author_email_address => "ben@example.com", + :written_on => "2003-07-16t15:28:11.2233+01:00", + :last_read => "2004-04-15", + :bonus_time => "2005-01-30t15:28:00.00+01:00", + :content => "Have a nice day", + :approved => false) + + new_record_snapshot = !new_topic.persisted? + id_present = new_topic.has_attribute?(Topic.primary_key) + id_snapshot = new_topic.id + + # Make sure the second save gets the after_create callback called. + 2.times do + new_topic.approved = true + e = assert_raises(RuntimeError) { new_topic.save } + assert_equal "Make the transaction rollback", e.message + assert_equal new_record_snapshot, !new_topic.persisted?, "The topic should have its old persisted value" + assert_equal id_snapshot, new_topic.id, "The topic should have its old id" + assert_equal id_present, new_topic.has_attribute?(Topic.primary_key) + end + end + + def test_callback_rollback_in_create_with_record_invalid_exception + topic = Class.new(Topic) { + def after_create_for_transaction + raise ActiveRecord::RecordInvalid.new(Author.new) + end + } + + new_topic = topic.create(:title => "A new topic") + assert !new_topic.persisted?, "The topic should not be persisted" + assert_nil new_topic.id, "The topic should not have an ID" + end + + def test_nested_explicit_transactions + Topic.transaction do + Topic.transaction do + @first.approved = true + @second.approved = false + @first.save + @second.save + end + end + + assert Topic.find(1).approved?, "First should have been approved" + assert !Topic.find(2).approved?, "Second should have been unapproved" + end + + def test_manually_rolling_back_a_transaction + Topic.transaction do + @first.approved = true + @second.approved = false + @first.save + @second.save + + raise ActiveRecord::Rollback + end + + assert @first.approved?, "First should still be changed in the objects" + assert !@second.approved?, "Second should still be changed in the objects" + + assert !Topic.find(1).approved?, "First shouldn't have been approved" + assert Topic.find(2).approved?, "Second should still be approved" + end + + def test_invalid_keys_for_transaction + assert_raise ArgumentError do + Topic.transaction :nested => true do + end + end + end + + def test_force_savepoint_in_nested_transaction + Topic.transaction do + @first.approved = true + @second.approved = false + @first.save! + @second.save! + + begin + Topic.transaction :requires_new => true do + @first.happy = false + @first.save! + raise + end + rescue + end + end + + assert @first.reload.approved? + assert !@second.reload.approved? + end if Topic.connection.supports_savepoints? + + def test_force_savepoint_on_instance + @first.transaction do + @first.approved = true + @second.approved = false + @first.save! + @second.save! + + begin + @second.transaction :requires_new => true do + @first.happy = false + @first.save! + raise + end + rescue + end + end + + assert @first.reload.approved? + assert !@second.reload.approved? + end if Topic.connection.supports_savepoints? + + def test_no_savepoint_in_nested_transaction_without_force + Topic.transaction do + @first.approved = true + @second.approved = false + @first.save! + @second.save! + + begin + Topic.transaction do + @first.approved = false + @first.save! + raise + end + rescue + end + end + + assert !@first.reload.approved? + assert !@second.reload.approved? + end if Topic.connection.supports_savepoints? + + def test_many_savepoints + Topic.transaction do + @first.content = "One" + @first.save! + + begin + Topic.transaction :requires_new => true do + @first.content = "Two" + @first.save! + + begin + Topic.transaction :requires_new => true do + @first.content = "Three" + @first.save! + + begin + Topic.transaction :requires_new => true do + @first.content = "Four" + @first.save! + raise + end + rescue + end + + @three = @first.reload.content + raise + end + rescue + end + + @two = @first.reload.content + raise + end + rescue + end + + @one = @first.reload.content + end + + assert_equal "One", @one + assert_equal "Two", @two + assert_equal "Three", @three + end if Topic.connection.supports_savepoints? + + def test_using_named_savepoints + Topic.transaction do + @first.approved = true + @first.save! + Topic.connection.create_savepoint("first") + + @first.approved = false + @first.save! + Topic.connection.rollback_to_savepoint("first") + assert @first.reload.approved? + + @first.approved = false + @first.save! + Topic.connection.release_savepoint("first") + assert_not @first.reload.approved? + end + end if Topic.connection.supports_savepoints? + + def test_releasing_named_savepoints + Topic.transaction do + Topic.connection.create_savepoint("another") + Topic.connection.release_savepoint("another") + + # The savepoint is now gone and we can't remove it again. + assert_raises(ActiveRecord::StatementInvalid) do + Topic.connection.release_savepoint("another") + end + end + end + + def test_savepoints_name + Topic.transaction do + assert_nil Topic.connection.current_savepoint_name + assert_nil Topic.connection.current_transaction.savepoint_name + + Topic.transaction(requires_new: true) do + assert_equal "active_record_1", Topic.connection.current_savepoint_name + assert_equal "active_record_1", Topic.connection.current_transaction.savepoint_name + + Topic.transaction(requires_new: true) do + assert_equal "active_record_2", Topic.connection.current_savepoint_name + assert_equal "active_record_2", Topic.connection.current_transaction.savepoint_name + end + + assert_equal "active_record_1", Topic.connection.current_savepoint_name + assert_equal "active_record_1", Topic.connection.current_transaction.savepoint_name + end + end + end + + def test_rollback_when_commit_raises + Topic.connection.expects(:begin_db_transaction) + Topic.connection.expects(:commit_db_transaction).raises('OH NOES') + Topic.connection.expects(:rollback_db_transaction) + + assert_raise RuntimeError do + Topic.transaction do + # do nothing + end + end + end + + def test_rollback_when_saving_a_frozen_record + topic = Topic.new(:title => 'test') + topic.freeze + e = assert_raise(RuntimeError) { topic.save } + assert_match(/frozen/i, e.message) # Not good enough, but we can't do much + # about it since there is no specific error + # for frozen objects. + assert !topic.persisted?, 'not persisted' + assert_nil topic.id + assert topic.frozen?, 'not frozen' + end + + def test_restore_active_record_state_for_all_records_in_a_transaction + topic_without_callbacks = Class.new(ActiveRecord::Base) do + self.table_name = 'topics' + end + + topic_1 = Topic.new(:title => 'test_1') + topic_2 = Topic.new(:title => 'test_2') + topic_3 = topic_without_callbacks.new(:title => 'test_3') + + Topic.transaction do + assert topic_1.save + assert topic_2.save + assert topic_3.save + @first.save + @second.destroy + assert topic_1.persisted?, 'persisted' + assert_not_nil topic_1.id + assert topic_2.persisted?, 'persisted' + assert_not_nil topic_2.id + assert topic_3.persisted?, 'persisted' + assert_not_nil topic_3.id + assert @first.persisted?, 'persisted' + assert_not_nil @first.id + assert @second.destroyed?, 'destroyed' + raise ActiveRecord::Rollback + end + + assert !topic_1.persisted?, 'not persisted' + assert_nil topic_1.id + assert !topic_2.persisted?, 'not persisted' + assert_nil topic_2.id + assert !topic_3.persisted?, 'not persisted' + assert_nil topic_3.id + assert @first.persisted?, 'persisted' + assert_not_nil @first.id + assert !@second.destroyed?, 'not destroyed' + end + + def test_sqlite_add_column_in_transaction + return true unless current_adapter?(:SQLite3Adapter) + + # Test first if column creation/deletion works correctly when no + # transaction is in place. + # + # We go back to the connection for the column queries because + # Topic.columns is cached and won't report changes to the DB + + assert_nothing_raised do + Topic.reset_column_information + Topic.connection.add_column('topics', 'stuff', :string) + assert Topic.column_names.include?('stuff') + + Topic.reset_column_information + Topic.connection.remove_column('topics', 'stuff') + assert !Topic.column_names.include?('stuff') + end + + if Topic.connection.supports_ddl_transactions? + assert_nothing_raised do + Topic.transaction { Topic.connection.add_column('topics', 'stuff', :string) } + end + else + Topic.transaction do + assert_raise(ActiveRecord::StatementInvalid) { Topic.connection.add_column('topics', 'stuff', :string) } + raise ActiveRecord::Rollback + end + end + ensure + begin + Topic.connection.remove_column('topics', 'stuff') + rescue + ensure + Topic.reset_column_information + end + end + + def test_transactions_state_from_rollback + connection = Topic.connection + transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction + + assert transaction.open? + assert !transaction.state.rolledback? + assert !transaction.state.committed? + + transaction.rollback + + assert transaction.state.rolledback? + assert !transaction.state.committed? + end + + def test_transactions_state_from_commit + connection = Topic.connection + transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction + + assert transaction.open? + assert !transaction.state.rolledback? + assert !transaction.state.committed? + + transaction.commit + + assert !transaction.state.rolledback? + assert transaction.state.committed? + end + + private + + %w(validation save destroy).each do |filter| + define_method("add_cancelling_before_#{filter}_with_db_side_effect_to_topic") do |topic| + meta = class << topic; self; end + meta.send("define_method", "before_#{filter}_for_transaction") do + Book.create + false + end + end + end +end + +class TransactionsWithTransactionalFixturesTest < ActiveRecord::TestCase + self.use_transactional_fixtures = true + fixtures :topics + + def test_automatic_savepoint_in_outer_transaction + @first = Topic.find(1) + + begin + Topic.transaction do + @first.approved = true + @first.save! + raise + end + rescue + assert !@first.reload.approved? + end + end + + def test_no_automatic_savepoint_for_inner_transaction + @first = Topic.find(1) + + Topic.transaction do + @first.approved = true + @first.save! + + begin + Topic.transaction do + @first.approved = false + @first.save! + raise + end + rescue + end + end + + assert !@first.reload.approved? + end +end if Topic.connection.supports_savepoints? + +if current_adapter?(:PostgreSQLAdapter) + class ConcurrentTransactionTest < TransactionTest + # This will cause transactions to overlap and fail unless they are performed on + # separate database connections. + unless in_memory_db? + def test_transaction_per_thread + threads = 3.times.map do + Thread.new do + Topic.transaction do + topic = Topic.find(1) + topic.approved = !topic.approved? + assert topic.save! + topic.approved = !topic.approved? + assert topic.save! + end + Topic.connection.close + end + end + + threads.each { |t| t.join } + end + end + + # Test for dirty reads among simultaneous transactions. + def test_transaction_isolation__read_committed + # Should be invariant. + original_salary = Developer.find(1).salary + temporary_salary = 200000 + + assert_nothing_raised do + threads = (1..3).map do + Thread.new do + Developer.transaction do + # Expect original salary. + dev = Developer.find(1) + assert_equal original_salary, dev.salary + + dev.salary = temporary_salary + dev.save! + + # Expect temporary salary. + dev = Developer.find(1) + assert_equal temporary_salary, dev.salary + + dev.salary = original_salary + dev.save! + + # Expect original salary. + dev = Developer.find(1) + assert_equal original_salary, dev.salary + end + Developer.connection.close + end + end + + # Keep our eyes peeled. + threads << Thread.new do + 10.times do + sleep 0.05 + Developer.transaction do + # Always expect original salary. + assert_equal original_salary, Developer.find(1).salary + end + end + Developer.connection.close + end + + threads.each { |t| t.join } + end + + assert_equal original_salary, Developer.find(1).salary + end + end +end diff --git a/activerecord/test/cases/type/decimal_test.rb b/activerecord/test/cases/type/decimal_test.rb new file mode 100644 index 0000000000..da30de373e --- /dev/null +++ b/activerecord/test/cases/type/decimal_test.rb @@ -0,0 +1,38 @@ +require "cases/helper" + +module ActiveRecord + module Type + class DecimalTest < ActiveRecord::TestCase + def test_type_cast_decimal + type = Decimal.new + assert_equal BigDecimal.new("0"), type.type_cast_from_user(BigDecimal.new("0")) + assert_equal BigDecimal.new("123"), type.type_cast_from_user(123.0) + assert_equal BigDecimal.new("1"), type.type_cast_from_user(:"1") + end + + def test_type_cast_decimal_from_float_with_large_precision + type = Decimal.new(precision: ::Float::DIG + 2) + assert_equal BigDecimal.new("123.0"), type.type_cast_from_user(123.0) + end + + def test_type_cast_decimal_from_rational_with_precision + type = Decimal.new(precision: 2) + assert_equal BigDecimal("0.33"), type.type_cast_from_user(Rational(1, 3)) + end + + def test_type_cast_decimal_from_rational_without_precision_defaults_to_18_36 + type = Decimal.new + assert_equal BigDecimal("0.333333333333333333E0"), type.type_cast_from_user(Rational(1, 3)) + end + + def test_type_cast_decimal_from_object_responding_to_d + value = Object.new + def value.to_d + BigDecimal.new("1") + end + type = Decimal.new + assert_equal BigDecimal("1"), type.type_cast_from_user(value) + end + end + end +end diff --git a/activerecord/test/cases/type/string_test.rb b/activerecord/test/cases/type/string_test.rb new file mode 100644 index 0000000000..420177ed49 --- /dev/null +++ b/activerecord/test/cases/type/string_test.rb @@ -0,0 +1,36 @@ +require 'cases/helper' + +module ActiveRecord + class StringTypeTest < ActiveRecord::TestCase + test "type casting" do + type = Type::String.new + assert_equal "1", type.type_cast_from_user(true) + assert_equal "0", type.type_cast_from_user(false) + assert_equal "123", type.type_cast_from_user(123) + end + + test "values are duped coming out" do + s = "foo" + type = Type::String.new + assert_not_same s, type.type_cast_from_user(s) + assert_not_same s, type.type_cast_from_database(s) + end + + test "string mutations are detected" do + klass = Class.new(Base) + klass.table_name = 'authors' + + author = klass.create!(name: 'Sean') + assert_not author.changed? + + author.name << ' Griffin' + assert author.name_changed? + + author.save! + author.reload + + assert_equal 'Sean Griffin', author.name + assert_not author.changed? + end + end +end diff --git a/activerecord/test/cases/type/type_map_test.rb b/activerecord/test/cases/type/type_map_test.rb new file mode 100644 index 0000000000..4e32f92dd0 --- /dev/null +++ b/activerecord/test/cases/type/type_map_test.rb @@ -0,0 +1,130 @@ +require "cases/helper" + +module ActiveRecord + module Type + class TypeMapTest < ActiveRecord::TestCase + def test_default_type + mapping = TypeMap.new + + assert_kind_of Value, mapping.lookup(:undefined) + end + + def test_registering_types + boolean = Boolean.new + mapping = TypeMap.new + + mapping.register_type(/boolean/i, boolean) + + assert_equal mapping.lookup('boolean'), boolean + end + + def test_overriding_registered_types + time = Time.new + timestamp = DateTime.new + mapping = TypeMap.new + + mapping.register_type(/time/i, time) + mapping.register_type(/time/i, timestamp) + + assert_equal mapping.lookup('time'), timestamp + end + + def test_fuzzy_lookup + string = String.new + mapping = TypeMap.new + + mapping.register_type(/varchar/i, string) + + assert_equal mapping.lookup('varchar(20)'), string + end + + def test_aliasing_types + string = String.new + mapping = TypeMap.new + + mapping.register_type(/string/i, string) + mapping.alias_type(/varchar/i, 'string') + + assert_equal mapping.lookup('varchar'), string + end + + def test_changing_type_changes_aliases + time = Time.new + timestamp = DateTime.new + mapping = TypeMap.new + + mapping.register_type(/timestamp/i, time) + mapping.alias_type(/datetime/i, 'timestamp') + mapping.register_type(/timestamp/i, timestamp) + + assert_equal mapping.lookup('datetime'), timestamp + end + + def test_aliases_keep_metadata + mapping = TypeMap.new + + mapping.register_type(/decimal/i) { |sql_type| sql_type } + mapping.alias_type(/number/i, 'decimal') + + assert_equal mapping.lookup('number(20)'), 'decimal(20)' + assert_equal mapping.lookup('number'), 'decimal' + end + + def test_register_proc + string = String.new + binary = Binary.new + mapping = TypeMap.new + + mapping.register_type(/varchar/i) do |type| + if type.include?('(') + string + else + binary + end + end + + assert_equal mapping.lookup('varchar(20)'), string + assert_equal mapping.lookup('varchar'), binary + end + + def test_additional_lookup_args + mapping = TypeMap.new + + mapping.register_type(/varchar/i) do |type, limit| + if limit > 255 + 'text' + else + 'string' + end + end + mapping.alias_type(/string/i, 'varchar') + + assert_equal mapping.lookup('varchar', 200), 'string' + assert_equal mapping.lookup('varchar', 400), 'text' + assert_equal mapping.lookup('string', 400), 'text' + end + + def test_requires_value_or_block + mapping = TypeMap.new + + assert_raises(ArgumentError) do + mapping.register_type(/only key/i) + end + end + + def test_lookup_non_strings + mapping = HashLookupTypeMap.new + + mapping.register_type(1, 'string') + mapping.register_type(2, 'int') + mapping.alias_type(3, 1) + + assert_equal mapping.lookup(1), 'string' + assert_equal mapping.lookup(2), 'int' + assert_equal mapping.lookup(3), 'string' + assert_kind_of Type::Value, mapping.lookup(4) + end + end + end +end + diff --git a/activerecord/test/cases/types_test.rb b/activerecord/test/cases/types_test.rb new file mode 100644 index 0000000000..5c54812f30 --- /dev/null +++ b/activerecord/test/cases/types_test.rb @@ -0,0 +1,163 @@ +require "cases/helper" +require 'models/company' + +module ActiveRecord + module ConnectionAdapters + class TypesTest < ActiveRecord::TestCase + def test_type_cast_boolean + type = Type::Boolean.new + assert type.type_cast_from_user('').nil? + assert type.type_cast_from_user(nil).nil? + + assert type.type_cast_from_user(true) + assert type.type_cast_from_user(1) + assert type.type_cast_from_user('1') + assert type.type_cast_from_user('t') + assert type.type_cast_from_user('T') + assert type.type_cast_from_user('true') + assert type.type_cast_from_user('TRUE') + assert type.type_cast_from_user('on') + assert type.type_cast_from_user('ON') + + # explicitly check for false vs nil + assert_equal false, type.type_cast_from_user(false) + assert_equal false, type.type_cast_from_user(0) + assert_equal false, type.type_cast_from_user('0') + assert_equal false, type.type_cast_from_user('f') + assert_equal false, type.type_cast_from_user('F') + assert_equal false, type.type_cast_from_user('false') + assert_equal false, type.type_cast_from_user('FALSE') + assert_equal false, type.type_cast_from_user('off') + assert_equal false, type.type_cast_from_user('OFF') + assert_equal false, type.type_cast_from_user(' ') + assert_equal false, type.type_cast_from_user("\u3000\r\n") + assert_equal false, type.type_cast_from_user("\u0000") + assert_equal false, type.type_cast_from_user('SOMETHING RANDOM') + end + + def test_type_cast_integer + type = Type::Integer.new + assert_equal 1, type.type_cast_from_user(1) + assert_equal 1, type.type_cast_from_user('1') + assert_equal 1, type.type_cast_from_user('1ignore') + assert_equal 0, type.type_cast_from_user('bad1') + assert_equal 0, type.type_cast_from_user('bad') + assert_equal 1, type.type_cast_from_user(1.7) + assert_equal 0, type.type_cast_from_user(false) + assert_equal 1, type.type_cast_from_user(true) + assert_nil type.type_cast_from_user(nil) + end + + def test_type_cast_non_integer_to_integer + type = Type::Integer.new + assert_nil type.type_cast_from_user([1,2]) + assert_nil type.type_cast_from_user({1 => 2}) + assert_nil type.type_cast_from_user((1..2)) + end + + def test_type_cast_activerecord_to_integer + type = Type::Integer.new + firm = Firm.create(:name => 'Apple') + assert_nil type.type_cast_from_user(firm) + end + + def test_type_cast_object_without_to_i_to_integer + type = Type::Integer.new + assert_nil type.type_cast_from_user(Object.new) + end + + def test_type_cast_nan_and_infinity_to_integer + type = Type::Integer.new + assert_nil type.type_cast_from_user(Float::NAN) + assert_nil type.type_cast_from_user(1.0/0.0) + end + + def test_changing_integers + type = Type::Integer.new + + assert type.changed?(5, 5, '5wibble') + assert_not type.changed?(5, 5, '5') + assert_not type.changed?(5, 5, '5.0') + assert_not type.changed?(nil, nil, nil) + end + + def test_type_cast_float + type = Type::Float.new + assert_equal 1.0, type.type_cast_from_user("1") + end + + def test_changing_float + type = Type::Float.new + + assert type.changed?(5.0, 5.0, '5wibble') + assert_not type.changed?(5.0, 5.0, '5') + assert_not type.changed?(5.0, 5.0, '5.0') + assert_not type.changed?(nil, nil, nil) + end + + def test_type_cast_binary + type = Type::Binary.new + assert_equal nil, type.type_cast_from_user(nil) + assert_equal "1", type.type_cast_from_user("1") + assert_equal 1, type.type_cast_from_user(1) + end + + def test_type_cast_time + type = Type::Time.new + assert_equal nil, type.type_cast_from_user(nil) + assert_equal nil, type.type_cast_from_user('') + assert_equal nil, type.type_cast_from_user('ABC') + + time_string = Time.now.utc.strftime("%T") + assert_equal time_string, type.type_cast_from_user(time_string).strftime("%T") + end + + def test_type_cast_datetime_and_timestamp + type = Type::DateTime.new + assert_equal nil, type.type_cast_from_user(nil) + assert_equal nil, type.type_cast_from_user('') + assert_equal nil, type.type_cast_from_user(' ') + assert_equal nil, type.type_cast_from_user('ABC') + + datetime_string = Time.now.utc.strftime("%FT%T") + assert_equal datetime_string, type.type_cast_from_user(datetime_string).strftime("%FT%T") + end + + def test_type_cast_date + type = Type::Date.new + assert_equal nil, type.type_cast_from_user(nil) + assert_equal nil, type.type_cast_from_user('') + assert_equal nil, type.type_cast_from_user(' ') + assert_equal nil, type.type_cast_from_user('ABC') + + date_string = Time.now.utc.strftime("%F") + assert_equal date_string, type.type_cast_from_user(date_string).strftime("%F") + end + + def test_type_cast_duration_to_integer + type = Type::Integer.new + assert_equal 1800, type.type_cast_from_user(30.minutes) + assert_equal 7200, type.type_cast_from_user(2.hours) + end + + def test_string_to_time_with_timezone + [:utc, :local].each do |zone| + with_timezone_config default: zone do + type = Type::DateTime.new + assert_equal Time.utc(2013, 9, 4, 0, 0, 0), type.type_cast_from_user("Wed, 04 Sep 2013 03:00:00 EAT") + end + end + end + + if current_adapter?(:SQLite3Adapter) + def test_binary_encoding + type = SQLite3Binary.new + utf8_string = "a string".encode(Encoding::UTF_8) + type_cast = type.type_cast_from_user(utf8_string) + + assert_equal Encoding::ASCII_8BIT, type_cast.encoding + end + end + end + end +end diff --git a/activerecord/test/cases/unconnected_test.rb b/activerecord/test/cases/unconnected_test.rb new file mode 100644 index 0000000000..afb893a52c --- /dev/null +++ b/activerecord/test/cases/unconnected_test.rb @@ -0,0 +1,33 @@ +require "cases/helper" + +class TestRecord < ActiveRecord::Base +end + +class TestUnconnectedAdapter < ActiveRecord::TestCase + self.use_transactional_fixtures = false + + def setup + @underlying = ActiveRecord::Base.connection + @specification = ActiveRecord::Base.remove_connection + end + + teardown do + @underlying = nil + ActiveRecord::Base.establish_connection(@specification) + load_schema if in_memory_db? + end + + def test_connection_no_longer_established + assert_raise(ActiveRecord::ConnectionNotEstablished) do + TestRecord.find(1) + end + + assert_raise(ActiveRecord::ConnectionNotEstablished) do + TestRecord.new.save + end + end + + def test_underlying_adapter_no_longer_active + assert !@underlying.active?, "Removed adapter should no longer be active" + end +end diff --git a/activerecord/test/cases/validations/association_validation_test.rb b/activerecord/test/cases/validations/association_validation_test.rb new file mode 100644 index 0000000000..e4edc437e6 --- /dev/null +++ b/activerecord/test/cases/validations/association_validation_test.rb @@ -0,0 +1,86 @@ +require "cases/helper" +require 'models/topic' +require 'models/reply' +require 'models/man' +require 'models/interest' + +class AssociationValidationTest < ActiveRecord::TestCase + fixtures :topics + + repair_validations(Topic, Reply) + + def test_validates_associated_many + Topic.validates_associated(:replies) + Reply.validates_presence_of(:content) + t = Topic.create("title" => "uhohuhoh", "content" => "whatever") + t.replies << [r = Reply.new("title" => "A reply"), r2 = Reply.new("title" => "Another reply", "content" => "non-empty"), r3 = Reply.new("title" => "Yet another reply"), r4 = Reply.new("title" => "The last reply", "content" => "non-empty")] + assert !t.valid? + assert t.errors[:replies].any? + assert_equal 1, r.errors.count # make sure all associated objects have been validated + assert_equal 0, r2.errors.count + assert_equal 1, r3.errors.count + assert_equal 0, r4.errors.count + r.content = r3.content = "non-empty" + assert t.valid? + end + + def test_validates_associated_one + Reply.validates :topic, :associated => true + Topic.validates_presence_of( :content ) + r = Reply.new("title" => "A reply", "content" => "with content!") + r.topic = Topic.create("title" => "uhohuhoh") + assert !r.valid? + assert r.errors[:topic].any? + r.topic.content = "non-empty" + assert r.valid? + end + + def test_validates_associated_marked_for_destruction + Topic.validates_associated(:replies) + Reply.validates_presence_of(:content) + t = Topic.new + t.replies << Reply.new + assert t.invalid? + t.replies.first.mark_for_destruction + assert t.valid? + end + + def test_validates_associated_with_custom_message_using_quotes + Reply.validates_associated :topic, :message=> "This string contains 'single' and \"double\" quotes" + Topic.validates_presence_of :content + r = Reply.create("title" => "A reply", "content" => "with content!") + r.topic = Topic.create("title" => "uhohuhoh") + assert !r.valid? + assert_equal ["This string contains 'single' and \"double\" quotes"], r.errors[:topic] + end + + def test_validates_associated_missing + Reply.validates_presence_of(:topic) + r = Reply.create("title" => "A reply", "content" => "with content!") + assert !r.valid? + assert r.errors[:topic].any? + + r.topic = Topic.first + assert r.valid? + end + + def test_validates_presence_of_belongs_to_association__parent_is_new_record + repair_validations(Interest) do + # Note that Interest and Man have the :inverse_of option set + Interest.validates_presence_of(:man) + man = Man.new(:name => 'John') + interest = man.interests.build(:topic => 'Airplanes') + assert interest.valid?, "Expected interest to be valid, but was not. Interest should have a man object associated" + end + end + + def test_validates_presence_of_belongs_to_association__existing_parent + repair_validations(Interest) do + Interest.validates_presence_of(:man) + man = Man.create!(:name => 'John') + interest = man.interests.build(:topic => 'Airplanes') + assert interest.valid?, "Expected interest to be valid, but was not. Interest should have a man object associated" + end + end + +end diff --git a/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb new file mode 100644 index 0000000000..13d4d85afa --- /dev/null +++ b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb @@ -0,0 +1,84 @@ +require "cases/helper" +require 'models/topic' + +class I18nGenerateMessageValidationTest < ActiveRecord::TestCase + def setup + Topic.clear_validators! + @topic = Topic.new + I18n.backend = I18n::Backend::Simple.new + end + + def reset_i18n_load_path + @old_load_path, @old_backend = I18n.load_path.dup, I18n.backend + I18n.load_path.clear + I18n.backend = I18n::Backend::Simple.new + yield + ensure + I18n.load_path.replace @old_load_path + I18n.backend = @old_backend + end + + # validates_associated: generate_message(attr_name, :invalid, :message => custom_message, :value => value) + def test_generate_message_invalid_with_default_message + assert_equal 'is invalid', @topic.errors.generate_message(:title, :invalid, :value => 'title') + end + + def test_generate_message_invalid_with_custom_message + assert_equal 'custom message title', @topic.errors.generate_message(:title, :invalid, :message => 'custom message %{value}', :value => 'title') + end + + # validates_uniqueness_of: generate_message(attr_name, :taken, :message => custom_message) + def test_generate_message_taken_with_default_message + assert_equal "has already been taken", @topic.errors.generate_message(:title, :taken, :value => 'title') + end + + def test_generate_message_taken_with_custom_message + assert_equal 'custom message title', @topic.errors.generate_message(:title, :taken, :message => 'custom message %{value}', :value => 'title') + end + + # ActiveRecord#RecordInvalid exception + + test "RecordInvalid exception can be localized" do + topic = Topic.new + topic.errors.add(:title, :invalid) + topic.errors.add(:title, :blank) + assert_equal "Validation failed: Title is invalid, Title can't be blank", ActiveRecord::RecordInvalid.new(topic).message + end + + test "RecordInvalid exception translation falls back to the :errors namespace" do + reset_i18n_load_path do + I18n.backend.store_translations 'en', :errors => {:messages => {:record_invalid => 'fallback message'}} + topic = Topic.new + topic.errors.add(:title, :blank) + assert_equal "fallback message", ActiveRecord::RecordInvalid.new(topic).message + end + end + + test "translation for 'taken' can be overridden" do + reset_i18n_load_path do + I18n.backend.store_translations "en", {errors: {attributes: {title: {taken: "Custom taken message" }}}} + assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, :value => 'title') + end + end + + test "translation for 'taken' can be overridden in activerecord scope" do + reset_i18n_load_path do + I18n.backend.store_translations "en", {activerecord: {errors: {messages: {taken: "Custom taken message" }}}} + assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, :value => 'title') + end + end + + test "translation for 'taken' can be overridden in activerecord model scope" do + reset_i18n_load_path do + I18n.backend.store_translations "en", {activerecord: {errors: {models: {topic: {taken: "Custom taken message" }}}}} + assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, :value => 'title') + end + end + + test "translation for 'taken' can be overridden in activerecord attributes scope" do + reset_i18n_load_path do + I18n.backend.store_translations "en", {activerecord: {errors: {models: {topic: {attributes: {title: {taken: "Custom taken message" }}}}}}} + assert_equal "Custom taken message", @topic.errors.generate_message(:title, :taken, :value => 'title') + end + end +end diff --git a/activerecord/test/cases/validations/i18n_validation_test.rb b/activerecord/test/cases/validations/i18n_validation_test.rb new file mode 100644 index 0000000000..3db742c15b --- /dev/null +++ b/activerecord/test/cases/validations/i18n_validation_test.rb @@ -0,0 +1,89 @@ +require "cases/helper" +require 'models/topic' +require 'models/reply' + +class I18nValidationTest < ActiveRecord::TestCase + repair_validations(Topic, Reply) + + def setup + Reply.validates_presence_of(:title) + @topic = Topic.new + @old_load_path, @old_backend = I18n.load_path.dup, I18n.backend + I18n.load_path.clear + I18n.backend = I18n::Backend::Simple.new + I18n.backend.store_translations('en', :errors => {:messages => {:custom => nil}}) + end + + teardown do + I18n.load_path.replace @old_load_path + I18n.backend = @old_backend + 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 + + # A set of common cases for ActiveModel::Validations message generation that + # are used to generate tests to keep things DRY + # + COMMON_CASES = [ + # [ case, validation_options, generate_message_options] + [ "given no options", {}, {}], + [ "given custom message", {:message => "custom"}, {:message => "custom"}], + [ "given if condition", {:if => lambda { true }}, {}], + [ "given unless condition", {:unless => lambda { false }}, {}], + [ "given option that is not reserved", {:format => "jpg"}, {:format => "jpg" }] + # TODO Add :on case, but below doesn't work, because then the validation isn't run for some reason + # even when using .save instead .valid? + # [ "given on condition", {on: :save}, {}] + ] + + # validates_uniqueness_of w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_uniqueness_of on generated message #{name}" do + Topic.validates_uniqueness_of :title, validation_options + @topic.title = unique_topic.title + @topic.errors.expects(:generate_message).with(:title, :taken, generate_message_options.merge(:value => 'unique!')) + @topic.valid? + end + end + + # validates_associated w/ mocha + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_associated on generated message #{name}" do + Topic.validates_associated :replies, validation_options + replied_topic.errors.expects(:generate_message).with(:replies, :invalid, generate_message_options.merge(:value => replied_topic.replies)) + replied_topic.save + end + end + + # validates_associated w/o mocha + + def test_validates_associated_finds_custom_model_key_translation + I18n.backend.store_translations 'en', :activerecord => {:errors => {:models => {:topic => {:attributes => {:replies => {:invalid => 'custom message'}}}}}} + I18n.backend.store_translations 'en', :activerecord => {:errors => {:messages => {:invalid => 'global message'}}} + + Topic.validates_associated :replies + replied_topic.valid? + assert_equal ['custom message'], replied_topic.errors[:replies].uniq + end + + def test_validates_associated_finds_global_default_translation + I18n.backend.store_translations 'en', :activerecord => {:errors => {:messages => {:invalid => 'global message'}}} + + Topic.validates_associated :replies + replied_topic.valid? + assert_equal ['global message'], replied_topic.errors[:replies] + end + +end diff --git a/activerecord/test/cases/validations/length_validation_test.rb b/activerecord/test/cases/validations/length_validation_test.rb new file mode 100644 index 0000000000..4a92da38ce --- /dev/null +++ b/activerecord/test/cases/validations/length_validation_test.rb @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +require "cases/helper" +require 'models/owner' +require 'models/pet' + +class LengthValidationTest < ActiveRecord::TestCase + fixtures :owners + repair_validations(Owner) + + def test_validates_size_of_association + repair_validations Owner do + assert_nothing_raised { Owner.validates_size_of :pets, :minimum => 1 } + o = Owner.new('name' => 'nopets') + assert !o.save + assert o.errors[:pets].any? + o.pets.build('name' => 'apet') + assert o.valid? + end + end + + def test_validates_size_of_association_using_within + repair_validations Owner do + assert_nothing_raised { Owner.validates_size_of :pets, :within => 1..2 } + o = Owner.new('name' => 'nopets') + assert !o.save + assert o.errors[:pets].any? + + o.pets.build('name' => 'apet') + assert o.valid? + + 2.times { o.pets.build('name' => 'apet') } + assert !o.save + assert o.errors[:pets].any? + end + end + + def test_validates_size_of_association_utf8 + repair_validations Owner do + assert_nothing_raised { Owner.validates_size_of :pets, :minimum => 1 } + o = Owner.new('name' => 'あいうえおかきくけこ') + assert !o.save + assert o.errors[:pets].any? + o.pets.build('name' => 'あいうえおかきくけこ') + assert o.valid? + end + end +end diff --git a/activerecord/test/cases/validations/presence_validation_test.rb b/activerecord/test/cases/validations/presence_validation_test.rb new file mode 100644 index 0000000000..4f38849131 --- /dev/null +++ b/activerecord/test/cases/validations/presence_validation_test.rb @@ -0,0 +1,68 @@ +# encoding: utf-8 +require "cases/helper" +require 'models/man' +require 'models/face' +require 'models/interest' +require 'models/speedometer' +require 'models/dashboard' + +class PresenceValidationTest < ActiveRecord::TestCase + class Boy < Man; end + + repair_validations(Boy) + + def test_validates_presence_of_non_association + Boy.validates_presence_of(:name) + b = Boy.new + assert b.invalid? + + b.name = "Alex" + assert b.valid? + end + + def test_validates_presence_of_has_one + Boy.validates_presence_of(:face) + b = Boy.new + assert b.invalid?, "should not be valid if has_one association missing" + assert_equal 1, b.errors[:face].size, "validates_presence_of should only add one error" + end + + def test_validates_presence_of_has_one_marked_for_destruction + Boy.validates_presence_of(:face) + b = Boy.new + f = Face.new + b.face = f + assert b.valid? + + f.mark_for_destruction + assert b.invalid? + end + + def test_validates_presence_of_has_many_marked_for_destruction + Boy.validates_presence_of(:interests) + b = Boy.new + b.interests << [i1 = Interest.new, i2 = Interest.new] + assert b.valid? + + i1.mark_for_destruction + assert b.valid? + + i2.mark_for_destruction + assert b.invalid? + end + + def test_validates_presence_doesnt_convert_to_array + speedometer = Class.new(Speedometer) + speedometer.validates_presence_of :dashboard + + dash = Dashboard.new + + # dashboard has to_a method + def dash.to_a; ['(/)', '(\)']; end + + s = speedometer.new + s.dashboard = dash + + assert_nothing_raised { s.valid? } + end +end diff --git a/activerecord/test/cases/validations/uniqueness_validation_test.rb b/activerecord/test/cases/validations/uniqueness_validation_test.rb new file mode 100644 index 0000000000..18221cc73d --- /dev/null +++ b/activerecord/test/cases/validations/uniqueness_validation_test.rb @@ -0,0 +1,406 @@ +# encoding: utf-8 +require "cases/helper" +require 'models/topic' +require 'models/reply' +require 'models/warehouse_thing' +require 'models/guid' +require 'models/event' + +class Wizard < ActiveRecord::Base + self.abstract_class = true + + validates_uniqueness_of :name +end + +class IneptWizard < Wizard + validates_uniqueness_of :city +end + +class Conjurer < IneptWizard +end + +class Thaumaturgist < IneptWizard +end + +class ReplyTitle; end + +class ReplyWithTitleObject < Reply + validates_uniqueness_of :content, :scope => :title + + def title; ReplyTitle.new; end +end + +class Employee < ActiveRecord::Base + self.table_name = 'postgresql_arrays' + validates_uniqueness_of :nicknames +end + +class TopicWithUniqEvent < Topic + belongs_to :event, foreign_key: :parent_id + validates :event, uniqueness: true +end + +class UniquenessValidationTest < ActiveRecord::TestCase + fixtures :topics, 'warehouse-things', :developers + + repair_validations(Topic, Reply) + + def test_validate_uniqueness + Topic.validates_uniqueness_of(:title) + + t = Topic.new("title" => "I'm uniqué!") + assert t.save, "Should save t as unique" + + t.content = "Remaining unique" + assert t.save, "Should still save t as unique" + + t2 = Topic.new("title" => "I'm uniqué!") + assert !t2.valid?, "Shouldn't be valid" + assert !t2.save, "Shouldn't save t2 as unique" + assert_equal ["has already been taken"], t2.errors[:title] + + t2.title = "Now I am really also unique" + assert t2.save, "Should now save t2 as unique" + end + + def test_validate_uniqueness_with_alias_attribute + Topic.alias_attribute :new_title, :title + Topic.validates_uniqueness_of(:new_title) + + topic = Topic.new(new_title: 'abc') + assert topic.valid? + end + + def test_validates_uniqueness_with_nil_value + Topic.validates_uniqueness_of(:title) + + t = Topic.new("title" => nil) + assert t.save, "Should save t as unique" + + t2 = Topic.new("title" => nil) + assert !t2.valid?, "Shouldn't be valid" + assert !t2.save, "Shouldn't save t2 as unique" + assert_equal ["has already been taken"], t2.errors[:title] + end + + def test_validates_uniqueness_with_validates + Topic.validates :title, :uniqueness => true + Topic.create!('title' => 'abc') + + t2 = Topic.new('title' => 'abc') + assert !t2.valid? + assert t2.errors[:title] + end + + def test_validates_uniqueness_with_newline_chars + Topic.validates_uniqueness_of(:title, :case_sensitive => false) + + t = Topic.new("title" => "new\nline") + assert t.save, "Should save t as unique" + end + + def test_validate_uniqueness_with_scope + Reply.validates_uniqueness_of(:content, :scope => "parent_id") + + t = Topic.create("title" => "I'm unique!") + + r1 = t.replies.create "title" => "r1", "content" => "hello world" + assert r1.valid?, "Saving r1" + + r2 = t.replies.create "title" => "r2", "content" => "hello world" + assert !r2.valid?, "Saving r2 first time" + + r2.content = "something else" + assert r2.save, "Saving r2 second time" + + t2 = Topic.create("title" => "I'm unique too!") + r3 = t2.replies.create "title" => "r3", "content" => "hello world" + assert r3.valid?, "Saving r3" + end + + def test_validate_uniqueness_with_object_scope + Reply.validates_uniqueness_of(:content, :scope => :topic) + + t = Topic.create("title" => "I'm unique!") + + r1 = t.replies.create "title" => "r1", "content" => "hello world" + assert r1.valid?, "Saving r1" + + r2 = t.replies.create "title" => "r2", "content" => "hello world" + assert !r2.valid?, "Saving r2 first time" + end + + def test_validate_uniqueness_with_composed_attribute_scope + r1 = ReplyWithTitleObject.create "title" => "r1", "content" => "hello world" + assert r1.valid?, "Saving r1" + + r2 = ReplyWithTitleObject.create "title" => "r1", "content" => "hello world" + assert !r2.valid?, "Saving r2 first time" + end + + def test_validate_uniqueness_with_object_arg + Reply.validates_uniqueness_of(:topic) + + t = Topic.create("title" => "I'm unique!") + + r1 = t.replies.create "title" => "r1", "content" => "hello world" + assert r1.valid?, "Saving r1" + + r2 = t.replies.create "title" => "r2", "content" => "hello world" + assert !r2.valid?, "Saving r2 first time" + end + + def test_validate_uniqueness_scoped_to_defining_class + t = Topic.create("title" => "What, me worry?") + + r1 = t.unique_replies.create "title" => "r1", "content" => "a barrel of fun" + assert r1.valid?, "Saving r1" + + r2 = t.silly_unique_replies.create "title" => "r2", "content" => "a barrel of fun" + assert !r2.valid?, "Saving r2" + + # Should succeed as validates_uniqueness_of only applies to + # UniqueReply and its subclasses + r3 = t.replies.create "title" => "r2", "content" => "a barrel of fun" + assert r3.valid?, "Saving r3" + end + + def test_validate_uniqueness_with_scope_array + Reply.validates_uniqueness_of(:author_name, :scope => [:author_email_address, :parent_id]) + + t = Topic.create("title" => "The earth is actually flat!") + + r1 = t.replies.create "author_name" => "jeremy", "author_email_address" => "jeremy@rubyonrails.com", "title" => "You're crazy!", "content" => "Crazy reply" + assert r1.valid?, "Saving r1" + + r2 = t.replies.create "author_name" => "jeremy", "author_email_address" => "jeremy@rubyonrails.com", "title" => "You're crazy!", "content" => "Crazy reply again..." + assert !r2.valid?, "Saving r2. Double reply by same author." + + r2.author_email_address = "jeremy_alt_email@rubyonrails.com" + assert r2.save, "Saving r2 the second time." + + r3 = t.replies.create "author_name" => "jeremy", "author_email_address" => "jeremy_alt_email@rubyonrails.com", "title" => "You're wrong", "content" => "It's cubic" + assert !r3.valid?, "Saving r3" + + r3.author_name = "jj" + assert r3.save, "Saving r3 the second time." + + r3.author_name = "jeremy" + assert !r3.save, "Saving r3 the third time." + end + + def test_validate_case_insensitive_uniqueness + Topic.validates_uniqueness_of(:title, :parent_id, :case_sensitive => false, :allow_nil => true) + + t = Topic.new("title" => "I'm unique!", :parent_id => 2) + assert t.save, "Should save t as unique" + + t.content = "Remaining unique" + assert t.save, "Should still save t as unique" + + t2 = Topic.new("title" => "I'm UNIQUE!", :parent_id => 1) + assert !t2.valid?, "Shouldn't be valid" + assert !t2.save, "Shouldn't save t2 as unique" + assert t2.errors[:title].any? + assert t2.errors[:parent_id].any? + assert_equal ["has already been taken"], t2.errors[:title] + + t2.title = "I'm truly UNIQUE!" + assert !t2.valid?, "Shouldn't be valid" + assert !t2.save, "Shouldn't save t2 as unique" + assert t2.errors[:title].empty? + assert t2.errors[:parent_id].any? + + t2.parent_id = 4 + assert t2.save, "Should now save t2 as unique" + + t2.parent_id = nil + t2.title = nil + assert t2.valid?, "should validate with nil" + assert t2.save, "should save with nil" + + t_utf8 = Topic.new("title" => "Я тоже уникальный!") + assert t_utf8.save, "Should save t_utf8 as unique" + + # If database hasn't UTF-8 character set, this test fails + if Topic.all.merge!(:select => 'LOWER(title) AS title').find(t_utf8.id).title == "я тоже уникальный!" + t2_utf8 = Topic.new("title" => "я тоже УНИКАЛЬНЫЙ!") + assert !t2_utf8.valid?, "Shouldn't be valid" + assert !t2_utf8.save, "Shouldn't save t2_utf8 as unique" + end + end + + def test_validate_case_sensitive_uniqueness_with_special_sql_like_chars + Topic.validates_uniqueness_of(:title, :case_sensitive => true) + + t = Topic.new("title" => "I'm unique!") + assert t.save, "Should save t as unique" + + t2 = Topic.new("title" => "I'm %") + assert t2.save, "Should save t2 as unique" + + t3 = Topic.new("title" => "I'm uniqu_!") + assert t3.save, "Should save t3 as unique" + end + + def test_validate_case_insensitive_uniqueness_with_special_sql_like_chars + Topic.validates_uniqueness_of(:title, :case_sensitive => false) + + t = Topic.new("title" => "I'm unique!") + assert t.save, "Should save t as unique" + + t2 = Topic.new("title" => "I'm %") + assert t2.save, "Should save t2 as unique" + + t3 = Topic.new("title" => "I'm uniqu_!") + assert t3.save, "Should save t3 as unique" + end + + def test_validate_case_sensitive_uniqueness + Topic.validates_uniqueness_of(:title, :case_sensitive => true, :allow_nil => true) + + t = Topic.new("title" => "I'm unique!") + assert t.save, "Should save t as unique" + + t.content = "Remaining unique" + assert t.save, "Should still save t as unique" + + t2 = Topic.new("title" => "I'M UNIQUE!") + assert t2.valid?, "Should be valid" + assert t2.save, "Should save t2 as unique" + assert t2.errors[:title].empty? + assert t2.errors[:parent_id].empty? + assert_not_equal ["has already been taken"], t2.errors[:title] + + t3 = Topic.new("title" => "I'M uNiQUe!") + assert t3.valid?, "Should be valid" + assert t3.save, "Should save t2 as unique" + assert t3.errors[:title].empty? + assert t3.errors[:parent_id].empty? + assert_not_equal ["has already been taken"], t3.errors[:title] + end + + def test_validate_case_sensitive_uniqueness_with_attribute_passed_as_integer + Topic.validates_uniqueness_of(:title, :case_sensitive => true) + Topic.create!('title' => 101) + + t2 = Topic.new('title' => 101) + assert !t2.valid? + assert t2.errors[:title] + end + + def test_validate_uniqueness_with_non_standard_table_names + i1 = WarehouseThing.create(:value => 1000) + assert !i1.valid?, "i1 should not be valid" + assert i1.errors[:value].any?, "Should not be empty" + end + + def test_validates_uniqueness_inside_scoping + Topic.validates_uniqueness_of(:title) + + Topic.where(:author_name => "David").scoping do + t1 = Topic.new("title" => "I'm unique!", "author_name" => "Mary") + assert t1.save + t2 = Topic.new("title" => "I'm unique!", "author_name" => "David") + assert !t2.valid? + end + end + + def test_validate_uniqueness_with_columns_which_are_sql_keywords + repair_validations(Guid) do + Guid.validates_uniqueness_of :key + g = Guid.new + g.key = "foo" + assert_nothing_raised { !g.valid? } + end + end + + def test_validate_uniqueness_with_limit + # Event.title is limited to 5 characters + e1 = Event.create(:title => "abcde") + assert e1.valid?, "Could not create an event with a unique, 5 character title" + e2 = Event.create(:title => "abcdefgh") + assert !e2.valid?, "Created an event whose title, with limit taken into account, is not unique" + end + + def test_validate_uniqueness_with_limit_and_utf8 + # Event.title is limited to 5 characters + e1 = Event.create(:title => "一二三四五") + assert e1.valid?, "Could not create an event with a unique, 5 character title" + e2 = Event.create(:title => "一二三四五六七八") + assert !e2.valid?, "Created an event whose title, with limit taken into account, is not unique" + end + + def test_validate_straight_inheritance_uniqueness + w1 = IneptWizard.create(:name => "Rincewind", :city => "Ankh-Morpork") + assert w1.valid?, "Saving w1" + + # Should use validation from base class (which is abstract) + w2 = IneptWizard.new(:name => "Rincewind", :city => "Quirm") + assert !w2.valid?, "w2 shouldn't be valid" + assert w2.errors[:name].any?, "Should have errors for name" + assert_equal ["has already been taken"], w2.errors[:name], "Should have uniqueness message for name" + + w3 = Conjurer.new(:name => "Rincewind", :city => "Quirm") + assert !w3.valid?, "w3 shouldn't be valid" + assert w3.errors[:name].any?, "Should have errors for name" + assert_equal ["has already been taken"], w3.errors[:name], "Should have uniqueness message for name" + + w4 = Conjurer.create(:name => "The Amazing Bonko", :city => "Quirm") + assert w4.valid?, "Saving w4" + + w5 = Thaumaturgist.new(:name => "The Amazing Bonko", :city => "Lancre") + assert !w5.valid?, "w5 shouldn't be valid" + assert w5.errors[:name].any?, "Should have errors for name" + assert_equal ["has already been taken"], w5.errors[:name], "Should have uniqueness message for name" + + w6 = Thaumaturgist.new(:name => "Mustrum Ridcully", :city => "Quirm") + assert !w6.valid?, "w6 shouldn't be valid" + assert w6.errors[:city].any?, "Should have errors for city" + assert_equal ["has already been taken"], w6.errors[:city], "Should have uniqueness message for city" + end + + def test_validate_uniqueness_with_conditions + Topic.validates_uniqueness_of :title, conditions: -> { where(approved: true) } + Topic.create("title" => "I'm a topic", "approved" => true) + Topic.create("title" => "I'm an unapproved topic", "approved" => false) + + t3 = Topic.new("title" => "I'm a topic", "approved" => true) + assert !t3.valid?, "t3 shouldn't be valid" + + t4 = Topic.new("title" => "I'm an unapproved topic", "approved" => false) + assert t4.valid?, "t4 should be valid" + end + + def test_validate_uniqueness_with_non_callable_conditions_is_not_supported + assert_raises(ArgumentError) { + Topic.validates_uniqueness_of :title, conditions: Topic.where(approved: true) + } + end + + if current_adapter? :PostgreSQLAdapter + def test_validate_uniqueness_with_array_column + e1 = Employee.create("nicknames" => ["john", "johnny"], "commission_by_quarter" => [1000, 1200]) + assert e1.persisted?, "Saving e1" + + e2 = Employee.create("nicknames" => ["john", "johnny"], "commission_by_quarter" => [2200]) + assert !e2.persisted?, "e2 shouldn't be valid" + assert e2.errors[:nicknames].any?, "Should have errors for nicknames" + assert_equal ["has already been taken"], e2.errors[:nicknames], "Should have uniqueness message for nicknames" + end + end + + def test_validate_uniqueness_on_existing_relation + event = Event.create + assert TopicWithUniqEvent.create(event: event).valid? + + topic = TopicWithUniqEvent.new(event: event) + assert_not topic.valid? + assert_equal ['has already been taken'], topic.errors[:event] + end + + def test_validate_uniqueness_on_empty_relation + topic = TopicWithUniqEvent.new + assert topic.valid? + end +end diff --git a/activerecord/test/cases/validations_repair_helper.rb b/activerecord/test/cases/validations_repair_helper.rb new file mode 100644 index 0000000000..c02b3241cd --- /dev/null +++ b/activerecord/test/cases/validations_repair_helper.rb @@ -0,0 +1,23 @@ +module ActiveRecord + module ValidationsRepairHelper + extend ActiveSupport::Concern + + module ClassMethods + def repair_validations(*model_classes) + teardown do + model_classes.each do |k| + k.clear_validators! + end + end + end + end + + def repair_validations(*model_classes) + yield + ensure + model_classes.each do |k| + k.clear_validators! + end + end + end +end diff --git a/activerecord/test/cases/validations_test.rb b/activerecord/test/cases/validations_test.rb new file mode 100644 index 0000000000..55804f9576 --- /dev/null +++ b/activerecord/test/cases/validations_test.rb @@ -0,0 +1,151 @@ +# encoding: utf-8 +require "cases/helper" +require 'models/topic' +require 'models/reply' +require 'models/person' +require 'models/developer' +require 'models/parrot' +require 'models/company' + +class ValidationsTest < ActiveRecord::TestCase + fixtures :topics, :developers + + # Most of the tests mess with the validations of Topic, so lets repair it all the time. + # Other classes we mess with will be dealt with in the specific tests + repair_validations(Topic) + + def test_valid_uses_create_context_when_new + r = WrongReply.new + r.title = "Wrong Create" + assert_not r.valid? + assert r.errors[:title].any?, "A reply with a bad title should mark that attribute as invalid" + assert_equal ["is Wrong Create"], r.errors[:title], "A reply with a bad content should contain an error" + end + + def test_valid_uses_update_context_when_persisted + r = WrongReply.new + r.title = "Bad" + r.content = "Good" + assert r.save, "First validation should be successful" + + r.title = "Wrong Update" + assert_not r.valid?, "Second validation should fail" + + assert r.errors[:title].any?, "A reply with a bad title should mark that attribute as invalid" + assert_equal ["is Wrong Update"], r.errors[:title], "A reply with a bad content should contain an error" + end + + def test_valid_using_special_context + r = WrongReply.new(:title => "Valid title") + assert !r.valid?(:special_case) + assert_equal "Invalid", r.errors[:author_name].join + + r.author_name = "secret" + r.content = "Good" + assert r.valid?(:special_case) + + r.author_name = nil + assert_not r.valid?(:special_case) + assert_equal "Invalid", r.errors[:author_name].join + + r.author_name = "secret" + assert r.valid?(:special_case) + end + + def test_validate + r = WrongReply.new + + r.validate + assert_empty r.errors[:author_name] + + r.validate(:special_case) + assert_not_empty r.errors[:author_name] + + r.author_name = "secret" + + r.validate(:special_case) + assert_empty r.errors[:author_name] + end + + def test_invalid_record_exception + assert_raise(ActiveRecord::RecordInvalid) { WrongReply.create! } + assert_raise(ActiveRecord::RecordInvalid) { WrongReply.new.save! } + + r = WrongReply.new + invalid = assert_raise ActiveRecord::RecordInvalid do + r.save! + end + assert_equal r, invalid.record + end + + def test_validate_with_bang + assert_raise(ActiveRecord::RecordInvalid) do + WrongReply.new.validate! + end + end + + def test_validate_with_bang_and_context + assert_raise(ActiveRecord::RecordInvalid) do + WrongReply.new.validate!(:special_case) + end + r = WrongReply.new(:title => "Valid title", :author_name => "secret", :content => "Good") + assert r.validate!(:special_case) + end + + def test_exception_on_create_bang_many + assert_raise(ActiveRecord::RecordInvalid) do + WrongReply.create!([ { "title" => "OK" }, { "title" => "Wrong Create" }]) + end + end + + def test_exception_on_create_bang_with_block + assert_raise(ActiveRecord::RecordInvalid) do + WrongReply.create!({ "title" => "OK" }) do |r| + r.content = nil + end + end + end + + def test_exception_on_create_bang_many_with_block + assert_raise(ActiveRecord::RecordInvalid) do + WrongReply.create!([{ "title" => "OK" }, { "title" => "Wrong Create" }]) do |r| + r.content = nil + end + end + end + + def test_save_without_validation + reply = WrongReply.new + assert !reply.save + assert reply.save(:validate => false) + end + + def test_validates_acceptance_of_with_non_existent_table + Object.const_set :IncorporealModel, Class.new(ActiveRecord::Base) + + assert_nothing_raised ActiveRecord::StatementInvalid do + IncorporealModel.validates_acceptance_of(:incorporeal_column) + end + end + + def test_throw_away_typing + d = Developer.new("name" => "David", "salary" => "100,000") + assert !d.valid? + assert_equal 100, d.salary + assert_equal "100,000", d.salary_before_type_cast + end + + def test_validates_acceptance_of_as_database_column + Topic.validates_acceptance_of(:approved) + topic = Topic.create("approved" => true) + assert topic["approved"] + end + + def test_validators + assert_equal 1, Parrot.validators.size + assert_equal 1, Company.validators.size + assert_equal 1, Parrot.validators_on(:name).size + assert_equal 1, Company.validators_on(:name).size + end + +end diff --git a/activerecord/test/cases/xml_serialization_test.rb b/activerecord/test/cases/xml_serialization_test.rb new file mode 100644 index 0000000000..c34e7d5a30 --- /dev/null +++ b/activerecord/test/cases/xml_serialization_test.rb @@ -0,0 +1,447 @@ +require "cases/helper" +require "rexml/document" +require 'models/contact' +require 'models/post' +require 'models/author' +require 'models/comment' +require 'models/company_in_module' +require 'models/toy' +require 'models/topic' +require 'models/reply' +require 'models/company' + +class XmlSerializationTest < ActiveRecord::TestCase + def test_should_serialize_default_root + @xml = Contact.new.to_xml + assert_match %r{^<contact>}, @xml + assert_match %r{</contact>$}, @xml + end + + def test_should_serialize_default_root_with_namespace + @xml = Contact.new.to_xml :namespace=>"http://xml.rubyonrails.org/contact" + assert_match %r{^<contact xmlns="http://xml.rubyonrails.org/contact">}, @xml + assert_match %r{</contact>$}, @xml + end + + def test_should_serialize_custom_root + @xml = Contact.new.to_xml :root => 'xml_contact' + assert_match %r{^<xml-contact>}, @xml + assert_match %r{</xml-contact>$}, @xml + end + + def test_should_allow_undasherized_tags + @xml = Contact.new.to_xml :root => 'xml_contact', :dasherize => false + assert_match %r{^<xml_contact>}, @xml + assert_match %r{</xml_contact>$}, @xml + assert_match %r{<created_at}, @xml + end + + def test_should_allow_camelized_tags + @xml = Contact.new.to_xml :root => 'xml_contact', :camelize => true + assert_match %r{^<XmlContact>}, @xml + assert_match %r{</XmlContact>$}, @xml + assert_match %r{<CreatedAt}, @xml + end + + def test_should_allow_skipped_types + @xml = Contact.new(:age => 25).to_xml :skip_types => true + assert %r{<age>25</age>}.match(@xml) + end + + def test_should_include_yielded_additions + @xml = Contact.new.to_xml do |xml| + xml.creator "David" + end + assert_match %r{<creator>David</creator>}, @xml + end + + def test_to_xml_with_block + value = "Rockin' the block" + xml = Contact.new.to_xml(:skip_instruct => true) do |_xml| + _xml.tag! "arbitrary-element", value + end + assert_equal "<contact>", xml.first(9) + assert xml.include?(%(<arbitrary-element>#{value}</arbitrary-element>)) + end + + def test_should_skip_instruct_for_included_records + @contact = Contact.new + @contact.alternative = Contact.new(:name => 'Copa Cabana') + @xml = @contact.to_xml(:include => [ :alternative ]) + assert_equal @xml.index('<?xml '), 0 + assert_nil @xml.index('<?xml ', 1) + end +end + +class DefaultXmlSerializationTest < ActiveRecord::TestCase + def setup + @contact = Contact.new( + :name => 'aaron stack', + :age => 25, + :avatar => 'binarydata', + :created_at => Time.utc(2006, 8, 1), + :awesome => false, + :preferences => { :gem => 'ruby' } + ) + end + + def test_should_serialize_string + assert_match %r{<name>aaron stack</name>}, @contact.to_xml + end + + def test_should_serialize_integer + assert_match %r{<age type="integer">25</age>}, @contact.to_xml + end + + def test_should_serialize_binary + xml = @contact.to_xml + assert_match %r{YmluYXJ5ZGF0YQ==\n</avatar>}, xml + assert_match %r{<avatar(.*)(type="binary")}, xml + assert_match %r{<avatar(.*)(encoding="base64")}, xml + end + + def test_should_serialize_datetime + assert_match %r{<created-at type=\"dateTime\">2006-08-01T00:00:00Z</created-at>}, @contact.to_xml + end + + def test_should_serialize_boolean + assert_match %r{<awesome type=\"boolean\">false</awesome>}, @contact.to_xml + end + + def test_should_serialize_hash + assert_match %r{<preferences>\s*<gem>ruby</gem>\s*</preferences>}m, @contact.to_xml + end + + def test_uses_serializable_hash_with_only_option + def @contact.serializable_hash(options=nil) + super(only: %w(name)) + end + + xml = @contact.to_xml + assert_match %r{<name>aaron stack</name>}, xml + assert_no_match %r{age}, xml + assert_no_match %r{awesome}, xml + end + + def test_uses_serializable_hash_with_except_option + def @contact.serializable_hash(options=nil) + super(except: %w(age)) + end + + xml = @contact.to_xml + assert_match %r{<name>aaron stack</name>}, xml + assert_match %r{<awesome type=\"boolean\">false</awesome>}, xml + assert_no_match %r{age}, xml + end + + def test_does_not_include_inheritance_column_from_sti + @contact = ContactSti.new(@contact.attributes) + assert_equal 'ContactSti', @contact.type + + xml = @contact.to_xml + assert_match %r{<name>aaron stack</name>}, xml + assert_no_match %r{<type}, xml + assert_no_match %r{ContactSti}, xml + end + + def test_serializable_hash_with_default_except_option_and_excluding_inheritance_column_from_sti + @contact = ContactSti.new(@contact.attributes) + assert_equal 'ContactSti', @contact.type + + def @contact.serializable_hash(options={}) + super({ except: %w(age) }.merge!(options)) + end + + xml = @contact.to_xml + assert_match %r{<name>aaron stack</name>}, xml + assert_no_match %r{age}, xml + assert_no_match %r{<type}, xml + assert_no_match %r{ContactSti}, xml + end +end + +class DefaultXmlSerializationTimezoneTest < ActiveRecord::TestCase + def test_should_serialize_datetime_with_timezone + with_timezone_config zone: "Pacific Time (US & Canada)" do + toy = Toy.create(:name => 'Mickey', :updated_at => Time.utc(2006, 8, 1)) + assert_match %r{<updated-at type=\"dateTime\">2006-07-31T17:00:00-07:00</updated-at>}, toy.to_xml + end + end + + def test_should_serialize_datetime_with_timezone_reloaded + with_timezone_config zone: "Pacific Time (US & Canada)" do + toy = Toy.create(:name => 'Minnie', :updated_at => Time.utc(2006, 8, 1)).reload + assert_match %r{<updated-at type=\"dateTime\">2006-07-31T17:00:00-07:00</updated-at>}, toy.to_xml + end + end +end + +class NilXmlSerializationTest < ActiveRecord::TestCase + def setup + @xml = Contact.new.to_xml(:root => 'xml_contact') + end + + def test_should_serialize_string + assert_match %r{<name nil="true"/>}, @xml + end + + def test_should_serialize_integer + assert %r{<age (.*)/>}.match(@xml) + attributes = $1 + assert_match %r{nil="true"}, attributes + assert_match %r{type="integer"}, attributes + end + + def test_should_serialize_binary + assert %r{<avatar (.*)/>}.match(@xml) + attributes = $1 + assert_match %r{type="binary"}, attributes + assert_match %r{encoding="base64"}, attributes + assert_match %r{nil="true"}, attributes + end + + def test_should_serialize_datetime + assert %r{<created-at (.*)/>}.match(@xml) + attributes = $1 + assert_match %r{nil="true"}, attributes + assert_match %r{type="dateTime"}, attributes + end + + def test_should_serialize_boolean + assert %r{<awesome (.*)/>}.match(@xml) + attributes = $1 + assert_match %r{type="boolean"}, attributes + assert_match %r{nil="true"}, attributes + end + + def test_should_serialize_yaml + assert_match %r{<preferences nil=\"true\"/>}, @xml + end +end + +class DatabaseConnectedXmlSerializationTest < ActiveRecord::TestCase + fixtures :topics, :companies, :accounts, :authors, :posts, :projects + + def test_to_xml + xml = REXML::Document.new(topics(:first).to_xml(:indent => 0)) + bonus_time_in_current_timezone = topics(:first).bonus_time.xmlschema + written_on_in_current_timezone = topics(:first).written_on.xmlschema + + assert_equal "topic", xml.root.name + assert_equal "The First Topic" , xml.elements["//title"].text + assert_equal "David" , xml.elements["//author-name"].text + assert_match "Have a nice day", xml.elements["//content"].text + + assert_equal "1", xml.elements["//id"].text + assert_equal "integer" , xml.elements["//id"].attributes['type'] + + assert_equal "1", xml.elements["//replies-count"].text + assert_equal "integer" , xml.elements["//replies-count"].attributes['type'] + + assert_equal written_on_in_current_timezone, xml.elements["//written-on"].text + assert_equal "dateTime" , xml.elements["//written-on"].attributes['type'] + + assert_equal "david@loudthinking.com", xml.elements["//author-email-address"].text + + assert_equal nil, xml.elements["//parent-id"].text + assert_equal "integer", xml.elements["//parent-id"].attributes['type'] + assert_equal "true", xml.elements["//parent-id"].attributes['nil'] + + # Oracle enhanced adapter allows to define Date attributes in model class (see topic.rb) + assert_equal "2004-04-15", xml.elements["//last-read"].text + assert_equal "date" , xml.elements["//last-read"].attributes['type'] + + # Oracle and DB2 don't have true boolean or time-only fields + unless current_adapter?(:OracleAdapter, :DB2Adapter) + assert_equal "false", xml.elements["//approved"].text + assert_equal "boolean" , xml.elements["//approved"].attributes['type'] + + assert_equal bonus_time_in_current_timezone, xml.elements["//bonus-time"].text + assert_equal "dateTime" , xml.elements["//bonus-time"].attributes['type'] + end + end + + def test_except_option + xml = topics(:first).to_xml(:indent => 0, :skip_instruct => true, :except => [:title, :replies_count]) + assert_equal "<topic>", xml.first(7) + assert !xml.include?(%(<title>The First Topic</title>)) + assert xml.include?(%(<author-name>David</author-name>)) + + xml = topics(:first).to_xml(:indent => 0, :skip_instruct => true, :except => [:title, :author_name, :replies_count]) + assert !xml.include?(%(<title>The First Topic</title>)) + assert !xml.include?(%(<author-name>David</author-name>)) + end + + # to_xml used to mess with the hash the user provided which + # caused the builder to be reused. This meant the document kept + # getting appended to. + + def test_modules + projects = MyApplication::Business::Project.all + xml = projects.to_xml + root = projects.first.class.to_s.underscore.pluralize.tr('/','_').dasherize + assert_match "<#{root} type=\"array\">", xml + assert_match "</#{root}>", xml + end + + def test_passing_hash_shouldnt_reuse_builder + options = {:include=>:posts} + david = authors(:david) + first_xml_size = david.to_xml(options).size + second_xml_size = david.to_xml(options).size + assert_equal first_xml_size, second_xml_size + end + + def test_include_uses_association_name + xml = authors(:david).to_xml :include=>:hello_posts, :indent => 0 + assert_match %r{<hello-posts type="array">}, xml + assert_match %r{<hello-post type="Post">}, xml + assert_match %r{<hello-post type="StiPost">}, xml + end + + def test_included_associations_should_skip_types + xml = authors(:david).to_xml :include=>:hello_posts, :indent => 0, :skip_types => true + assert_match %r{<hello-posts>}, xml + assert_match %r{<hello-post>}, xml + assert_match %r{<hello-post>}, xml + end + + def test_including_has_many_association + xml = topics(:first).to_xml(:indent => 0, :skip_instruct => true, :include => :replies, :except => :replies_count) + assert_equal "<topic>", xml.first(7) + assert xml.include?(%(<replies type="array"><reply>)) + assert xml.include?(%(<title>The Second Topic of the day</title>)) + end + + def test_including_belongs_to_association + xml = companies(:first_client).to_xml(:indent => 0, :skip_instruct => true, :include => :firm) + assert !xml.include?("<firm>") + + xml = companies(:second_client).to_xml(:indent => 0, :skip_instruct => true, :include => :firm) + assert xml.include?("<firm>") + end + + def test_including_multiple_associations + xml = companies(:first_firm).to_xml(:indent => 0, :skip_instruct => true, :include => [ :clients, :account ]) + assert_equal "<firm>", xml.first(6) + assert xml.include?(%(<account>)) + assert xml.include?(%(<clients type="array"><client>)) + end + + def test_including_association_with_options + xml = companies(:first_firm).to_xml( + :indent => 0, :skip_instruct => true, + :include => { :clients => { :only => :name } } + ) + + assert_equal "<firm>", xml.first(6) + assert xml.include?(%(<client><name>Summit</name></client>)) + assert xml.include?(%(<clients type="array"><client>)) + end + + def test_methods_are_called_on_object + xml = authors(:david).to_xml :methods => :label, :indent => 0 + assert_match %r{<label>.*</label>}, xml + end + + def test_should_not_call_methods_on_associations_that_dont_respond + xml = authors(:david).to_xml :include=>:hello_posts, :methods => :label, :indent => 2 + assert !authors(:david).hello_posts.first.respond_to?(:label) + assert_match %r{^ <label>.*</label>}, xml + assert_no_match %r{^ <label>}, xml + end + + def test_procs_are_called_on_object + proc = Proc.new { |options| options[:builder].tag!('nationality', 'Danish') } + xml = authors(:david).to_xml(:procs => [ proc ]) + assert_match %r{<nationality>Danish</nationality>}, xml + end + + def test_dual_arity_procs_are_called_on_object + proc = Proc.new { |options, record| options[:builder].tag!('name-reverse', record.name.reverse) } + xml = authors(:david).to_xml(:procs => [ proc ]) + assert_match %r{<name-reverse>divaD</name-reverse>}, xml + end + + def test_top_level_procs_arent_applied_to_associations + author_proc = Proc.new { |options| options[:builder].tag!('nationality', 'Danish') } + xml = authors(:david).to_xml(:procs => [ author_proc ], :include => :posts, :indent => 2) + + assert_match %r{^ <nationality>Danish</nationality>}, xml + assert_no_match %r{^ {6}<nationality>Danish</nationality>}, xml + end + + def test_procs_on_included_associations_are_called + posts_proc = Proc.new { |options| options[:builder].tag!('copyright', 'DHH') } + xml = authors(:david).to_xml( + :indent => 2, + :include => { + :posts => { :procs => [ posts_proc ] } + } + ) + + assert_no_match %r{^ <copyright>DHH</copyright>}, xml + assert_match %r{^ {6}<copyright>DHH</copyright>}, xml + end + + def test_should_include_empty_has_many_as_empty_array + authors(:david).posts.delete_all + xml = authors(:david).to_xml :include=>:posts, :indent => 2 + + assert_equal [], Hash.from_xml(xml)['author']['posts'] + assert_match %r{^ <posts type="array"/>}, xml + end + + def test_should_has_many_array_elements_should_include_type_when_different_from_guessed_value + xml = authors(:david).to_xml :include=>:posts_with_comments, :indent => 2 + + assert Hash.from_xml(xml) + assert_match %r{^ <posts-with-comments type="array">}, xml + assert_match %r{^ <posts-with-comment type="Post">}, xml + assert_match %r{^ <posts-with-comment type="StiPost">}, xml + + types = Hash.from_xml(xml)['author']['posts_with_comments'].collect {|t| t['type'] } + assert types.include?('SpecialPost') + assert types.include?('Post') + assert types.include?('StiPost') + end + + def test_should_produce_xml_for_methods_returning_array + xml = authors(:david).to_xml(:methods => :social) + array = Hash.from_xml(xml)['author']['social'] + assert_equal 2, array.size + assert array.include? 'twitter' + assert array.include? 'github' + end + + def test_should_support_aliased_attributes + xml = Author.select("name as firstname").to_xml + Author.all.each do |author| + assert xml.include?(%(<firstname>#{author.name}</firstname>)), xml + end + end + + def test_array_to_xml_including_has_many_association + xml = [ topics(:first), topics(:second) ].to_xml(:indent => 0, :skip_instruct => true, :include => :replies) + assert xml.include?(%(<replies type="array"><reply>)) + end + + def test_array_to_xml_including_methods + xml = [ topics(:first), topics(:second) ].to_xml(:indent => 0, :skip_instruct => true, :methods => [ :topic_id ]) + assert xml.include?(%(<topic-id type="integer">#{topics(:first).topic_id}</topic-id>)), xml + assert xml.include?(%(<topic-id type="integer">#{topics(:second).topic_id}</topic-id>)), xml + end + + def test_array_to_xml_including_has_one_association + xml = [ companies(:first_firm), companies(:rails_core) ].to_xml(:indent => 0, :skip_instruct => true, :include => :account) + assert xml.include?(companies(:first_firm).account.to_xml(:indent => 0, :skip_instruct => true)) + assert xml.include?(companies(:rails_core).account.to_xml(:indent => 0, :skip_instruct => true)) + end + + def test_array_to_xml_including_belongs_to_association + xml = [ companies(:first_client), companies(:second_client), companies(:another_client) ].to_xml(:indent => 0, :skip_instruct => true, :include => :firm) + assert xml.include?(companies(:first_client).to_xml(:indent => 0, :skip_instruct => true)) + assert xml.include?(companies(:second_client).firm.to_xml(:indent => 0, :skip_instruct => true)) + assert xml.include?(companies(:another_client).firm.to_xml(:indent => 0, :skip_instruct => true)) + end +end diff --git a/activerecord/test/cases/yaml_serialization_test.rb b/activerecord/test/cases/yaml_serialization_test.rb new file mode 100644 index 0000000000..bce59b4fcd --- /dev/null +++ b/activerecord/test/cases/yaml_serialization_test.rb @@ -0,0 +1,86 @@ +require 'cases/helper' +require 'models/topic' +require 'models/reply' +require 'models/post' +require 'models/author' + +class YamlSerializationTest < ActiveRecord::TestCase + fixtures :topics, :authors, :posts + + def test_to_yaml_with_time_with_zone_should_not_raise_exception + with_timezone_config aware_attributes: true, zone: "Pacific Time (US & Canada)" do + topic = Topic.new(:written_on => DateTime.now) + assert_nothing_raised { topic.to_yaml } + end + end + + def test_roundtrip + topic = Topic.first + assert topic + t = YAML.load YAML.dump topic + assert_equal topic, t + end + + def test_roundtrip_serialized_column + topic = Topic.new(:content => {:omg=>:lol}) + assert_equal({:omg=>:lol}, YAML.load(YAML.dump(topic)).content) + end + + def test_psych_roundtrip + topic = Topic.first + assert topic + t = Psych.load Psych.dump topic + assert_equal topic, t + end + + def test_psych_roundtrip_new_object + topic = Topic.new + assert topic + t = Psych.load Psych.dump topic + assert_equal topic.attributes, t.attributes + end + + def test_active_record_relation_serialization + [Topic.all].to_yaml + end + + def test_raw_types_are_not_changed_on_round_trip + topic = Topic.new(parent_id: "123") + assert_equal "123", topic.parent_id_before_type_cast + assert_equal "123", YAML.load(YAML.dump(topic)).parent_id_before_type_cast + end + + def test_cast_types_are_not_changed_on_round_trip + topic = Topic.new(parent_id: "123") + assert_equal 123, topic.parent_id + assert_equal 123, YAML.load(YAML.dump(topic)).parent_id + end + + def test_new_records_remain_new_after_round_trip + topic = Topic.new + + assert topic.new_record?, "Sanity check that new records are new" + assert YAML.load(YAML.dump(topic)).new_record?, "Record should be new after deserialization" + + topic.save! + + assert_not topic.new_record?, "Saved records are not new" + assert_not YAML.load(YAML.dump(topic)).new_record?, "Saved record should not be new after deserialization" + + topic = Topic.select('title').last + + assert_not topic.new_record?, "Loaded records without ID are not new" + assert_not YAML.load(YAML.dump(topic)).new_record?, "Record should not be new after deserialization" + end + + def test_types_of_virtual_columns_are_not_changed_on_round_trip + author = Author.select('authors.*, count(posts.id) as posts_count') + .joins(:posts) + .group('authors.id') + .first + dumped = YAML.load(YAML.dump(author)) + + assert_equal 5, author.posts_count + assert_equal 5, dumped.posts_count + end +end |